Search
▪️

실시간 GIF 채팅방 만들기

WebSocket 설명과 프로젝트 세팅

모든 Server는 Request에 대한 Response를 보내게 되는 것이고 이는 Response를 받기 위해선 Request를 보내야 한다는 것이다.
하지만 Request를 보내지 않아도 Response를 받고 싶을 때가 있는데, 이 때 WebSocket을 쓰게 된다. WebSocket은 양방향 실시간 통신을 가능하게 해준다.
WebSocket Package로는 ws 혹은 socket.io를 많이 쓴다. (후자가 조금 더 복잡하지만 많이 쓰인다.)
HTTP 통신과 WebSocket의 경우 Port Number도 공유하기 때문에 따로 Port를 연결할 필요 없이 HTTP Server만 Websocket의 인자로 넘겨 주면 된다.
http:// ~~ → ws:// ~~ → Port 동일
https:// ~~ → wss:// ~~ → Port 동일
WebSocket은 Event 기반으로 작동한다. (Code 참고하자.)
'connection' Event는 ws Request를 보냄에 따라서 양방향에 Connection이 맺히는데 이 때 발생하는 Event이다.
'connection' Event를 선두로 하여, 그 안에 'error', 'close', 'message' Event도 사용할 수 있다.

ws Package

ws Request를 보내면서 양방향 Connection이 생성됨에 따라, ws Request를 통해서 접속자의 IP 주소를 얻을 수 있다. req.headers['x-forwarded-for']은 Proxy를 거치기 전 IP 주소이고, req.connection.remoteAddress는 최종 IP 주소이다. 일반적으로 후자의 connection.remoteAddress를 쓰면 알아낼 수도 있는데, 중계 Server 역할을 하는 Proxy Server를 이용하는 경우 IP 주소가 바뀌는 경우가 있어서 Proxy Server에 대한 Original 주소를 알아내기 위해서 전자를 먼저 확인한다.
ws의 send() Method를 통해서 Server에서 Client로 Response를 보낼 수 있다.
WebSocket에서는 메모리에 대한 관리가 매우 매우 중요하다. 사용자가 Connection을 맺음과 동시에 작업하던 것들이 Disconnection이 되더라도 그대로 남아 있을 수 있기 때문이다. 따라서 'close' Event를 통해서 Disconnection이 되면 작업하던 것들을 정리하여 메모리 누수에 대해서 방지해야 한다.
WebSocket에는 readyState를 갖는다. readState는 CONNECTING, OPEN, CLOSING, CLOSED와 같이 4가지 State 중 하나를 갖는다. 이 State에 맞춰서 로직을 작성하여야 한다.

socket.io Package

같은 주소의 다른 Browser를 사용 시에 다른 Client로 인식을 하게 되는데, 이에 대해서 IP로 구분을 해야 하는 것이 아닌가? → IP로 구분하는 것은 쉽지 않다. 또한 IP 외에 다른 것으로 Client를 구분하는 것 역시도 쉽지 않다. 하지만 Client 구분에 대해서 쉽게 처리해주는 Package가 있다. socket.io Pacakge를 사용한다. (즉, ws Package는 WebSocket에 대한 기본적인 기능만 구현을 해놓은 것이고, socket.io를 사용하면 WebSocket에 대한 기본적인 기능 뿐 아니라 채팅방 기능, Client 구분 기능 등이 모두 구현되어 있다.) → socket에 ID값이 있기 떄문이다.
socket.io 역시도 Event Listening 방식이기 때문에 ws와 전반적인 구조는 비슷하다. Event의 이름만 'connection', 'disconnect', 'error'로 사용된다. 그렇다면 ws Package의 'message'처럼 데이터를 주고 받는 Event는 어디에 있나? → Custom으로 만들어 줄 수 있다.
또한 Socket으로 데이터를 보내는 경우 ws Package에서는 send() Method였지만, socket.io에서는 emit() Method를 사용하면 된다. (단, 사용법이 send() Method와는 달리 Key, Value를 묶어서 사용하고, Key 값을 Event로 사용하여 접근하게 된다. 즉, Event가 ws Package와 같이 1:1 Matching이 아니라, 1:n Matching이 가능하다.)
socket.io에서 Server를 인자로 받아 Socket Instance를 생성할 때 추가로 주는 Path Option은 해당 Path로 접근하면 Socket 연결이 되는 것이다. Frontend와 Backend간에 Path가 서로 같아야 연결이 된다. (ws는 ws Request만 받으면 연결이 되는 것이었는데, socket.io는 이와 다르다.)
Frontend에서 WebSocket은 기본으로 사용할 수 있지만, socket.io를 이용한 Websocket은 추가적으로 Script를 받아야 한다. 아래와 같이 사용한다. 자세한 것들은 Frontend Code를 참고하자.
script(src='/socket.io/socket.io.js')
** 통신에서는 HTTP Header를 많이 알수록 좋다. Zerocho Blog HTTP 강의 참고하자.

실시간 GIF 채팅방 DB & Pug 세팅

실제로 WebSocket을 이용하는 통신을 Network 분석을 했을 때, 단순히 WebSocket만 날아가는 것이 아니라 HTTP Request가 먼저 날아가고 WebSocket Request가 날아가게 된다. HTTP Request가 먼저 날아가는 이유는, socket.io에서 WebSocket 사용 가능 여부를 먼저 확인하기 위해서이다. (오래된 Browser의 경우 WebSocket을 지원하지 않는 경우도 있고, Server에서 WebSocket 설정이 안 되어 있을 수도 있기 때문이다.)
따라서 WebSocket에 대한 확인 과정을 위해 HTTP Request를 날리지 않고, 처음부터 WebSocket을 이용하고 싶은 경우에는 Frontend에서 socket에 대한 설정을 path 뿐만 아니라 transport: ['websocket]으로 하면 되겠다.
일반적으로 채팅방에 대한 구현은 socket.io를 많이 이용하긴 하지만, 데이터베이스로는 관계형 데이터베이스를 이용하는 것이 조금 더 적합하다. (간단한 Service의 경우 무엇을 사용하든 무방할 것으로 생각되긴 한다.)

socket.io Namespace

socket.io를 사용하면 WebSocket의 Namespace를 줄 수 있다. Namespace를 이용하여 실시간 데이터가 자신이 전송될 주소를 구분할 수 있도록 만들어준다. (기본 값은 '/'이다.) 즉, Namespace를 지정하게 되면, 데이터를 전송할 때 특정 주소로만 보낼 수 있도록 만들 수 있는 것이다.
Frontend에서 연결할 때 URL 부분 뒤에, Backend에서 Namespace를 지정한 것을 명시를 해주는 것으로 Namespace간 통신을 연결할 수 있다. 이를 통해 Namespace간 실시간 양방향 통신이 가능한 것이다.
Namespace를 사용함으로써 현재 실시간으로 받아오지 않아도 되는 데이터들을 배제할 수 있다. 즉, 불필요한 데이터 전송에 대해서 방지할 수 있다. (예를 들어서, 채팅방 리스트를 보고 있을 때는 채팅에 대해서 변화가 있더라도 바로 로딩하지 않아도 된다. 반대로 채팅방 내에서 채팅을 하고 있을 때에는 채팅방 리스트에 대한 변화를 바로 로딩하지 않아도 된다. 이럴 때, Namespace를 구분하여 필요한 부분에 대해서만 데이터를 실시간으로 받을 수 있게 만들 수 있다.) → Namespace를 통해서 Frontend, Backend 자원을 모두 아낄 수 있다.
선언한 Namespace 각각 connection Event를 등록하여 사용한다. (Code 참고하자.)

color-hash, app.set, io.use

color-hash는 익명의 사용자를 Color로 구분할 수 있도록 도와주는 Package이다.
WebSocket Object를 선언한 곳에 app.set('io', io)를 통해서 WebSocket Object를 Express.js 변수에 저장해둔다. 차후에 Router에서 해당 WebSocket Object를 이용할 수도 있기 때문이다. (이것이 싫다면 Singleton으로 WebSocket Object를 이용한다.) 이와 같이 설정한 io는 req.app.get('io')로 불러온다.
socket.io 안에서도 Middleware를 이용할 수 있다! 따라서 socket.io 안에서 express-session을 이용할 수 있는 것이다. (이를 통해 Session의 정보들을 활용 가능하다.) app.use() Method 에서 사용했던 express-session의 Session 설정을 Middleware로 뽑아내어 WebSocket Module로 넘겨준다. (Code 참고하자.)
WebSocket Module의 인자들을 정리하면 다음과 같다. Server를 인자로 받는 것은 Server를 기반으로 WebSocket을 동작하게 만들겠다는 것이고, Express Application을 받는 것은 WebSocket Object의 Express.js 변수 설정을 위한 것이고, Session Middleware를 받는 것은 WebSocket Module에서도 Middleware를 이용하기 위한 것이다.
넘겨받은 Middleware의 경우 io.use()가 가능하여 해당 Method의 인자로 Middleware를 넘겨준다. 이 때, 일반 Middleware를 사용하듯이 (req, res, next)가 아닌 (socket, next)를 사용한다. (Code 참고하자.)
socket.io에서 채팅방 기능을 사용할 때, socket.adapter.rooms[roomId]를 통해서 채팅방의 정보와 채팅방 인원을 확인할 수 있다.
** WebSocket을 이용하는 Script에서는 오로지 WebSocket 관련 Code만 쓰고, 데이터베이스 접근 Code는 Controller에서 쓰도록 한다. WebSocket을 이용하는 Code가 난잡할 수 있기 때문이다.

스스로 해보기2 (시스템 Message 데이터베이스 저장)

Cookie에는 암호화 된 Signed Cookie라는 것이 있다. 일반적인 Cookie와 같이 Browser에서 그냥 확인할 수 있는 것과는 달리, Cookie 자체는 Browser에서 볼 수 있지만 그 내용이 암호화 되어 있는 Cookie라고 보면 된다.
이런 암호화 된 Cookie들은 connect.sid와 같이 Session에 대한 Cookie를 둘 때 많이 이용하게 된다. 이 Cookie가 남아 있는 동안, 동일한 사용자로 취급한다. Cookie를 만들 때 사용하는 암호는 process.env에 두었던 COOKIE_SECRET을 많이 이용한다.
cookie-signature Package 활용법 확인하자.
** req.signedCookies['connect.sid']라고 나와 있는 Cookie 값은 이미 signed 인 것 같은데 Request를 보낼 때 cookie.sign() Method로 한 번 더 암호화 하여 보내는 이유는, Signed Cookie의 경우 Client에 있을 땐 Encrypt 된 상태로 유지되지만 Server로 오는 순간 Express.js가 Decrypt된 상태로 두기 때문이다. 따라서 Request를 보낼 땐, 다시 Encrypt하여 보내는 것이다.
** socket.io의 경우 Middleware의 Request와 달리 Cookie가 존재하지 않는다. 따라서 io.use() Method를 통해서 Cookie Parser를 사용하여 Cookie 값을 읽어와서 HTTP Request를 보낼 수 있도록 한다.

스스로 해보기3 (귓속말 보내기)

socket.id를 이용하면 해당 Socket의 ID를 갖고 있는 사용자에게 Message를 보낼 수 도 있다.
socket.to($socket.id).emit($event, $data)
** socket 정보를 이용하여 검사를 하거나, 데이터베이스를 이용해야 하는 경우는 Server의 Middleware를 이용하도록 한다.
** 채팅방이든 귓속말이든 Router를 거치면서 WebSocket을 이용하는 것이 아무래도 서비스 측면에서 안정성이나 확장성이 높다. 따라서 HTTP Router로 Ajax 요청을 하고 Server에서 Emit을 하는 방식을 연습하는 것이 좋다.

스스로 해보기4 (방장, 강퇴)

강퇴 기능 역시도 귓속말과 마찬가지로 WebSocket만 이용하면 Socket의 ID만 얻으면 어느 곳에서든 작동할 가능성이 높기 떄문에, HTTP Router를 거쳐서 데이터베이스를 조작하는 방식으로 구현하는 것이 좋다. 약간... WebSocket은 휘발성이 굉장히 강한 느낌이랄까 싶다. 실시간으로 데이터가 움직이기는 하는데 중간에 저장이라든가 처리하는 것에는 조금 취약한 부분이 있는 듯하다. 예를 들어서 Code에서 구현한 바로는, 다른 방에서도 강퇴를 당할 가능성이 매우 높다.
강퇴자 목록을 HTTP Router를 이용하여 데이터베이스에 두고 방에 다시 못 들어오게 할 수도 있다.
방장 권한을 넘기는 것 역시 귓속말과 동일하게 구현하면 된다. 새로운 Event를 생성하고 Router에서 데이터베이스로 방장을 수정한 뒤, 변경 사항을 Event Listener를 호출하는 방식으로 구현하면 된다.
방장이 방을 나가는 경우의 후처리 로직도 필요하다. 방을 없애도 되고, URL에 대해서 동작하지 않도록 만들 수도 있고, 랜덤으로 방장을 위임하여도 된다.