읽기 전에
Socket.io와 room에 대해 알고 있어야 합니다. Socket.io 공식 문서를 참고해 주세요.
채팅 로직
실시간 채팅을 위해서는 다양한 경우를 고려해야 합니다. 단순하게 모든 사용자가 socket 접속이 되어 있을 경우만 생각한다면 socket 이벤트만 전송하면 되겠지만 실제로는 그렇지 않습니다.
누군가는 인터넷 연결 환경 문제로 인해 오프라인이었다가 온라인이 되는 경우도 있을 수 있고, 누군가는 나중에 채팅을 확인하기 위해 앱을 껐을 수도 있습니다. 지금부터 미어캣이 이 경우의 수들을 어떻게 처리했는지 알아보겠습니다.
여기서는 실시간 채팅을 위해 socket.io를 사용합니다. 클라이언트에 2개의 socket 연결을 이용하는데, 하나는 실시간 메시징을 위해서이고 나머지 하나는 메시지 수신에 대한 알림을 위해서입니다. 편의상 실시간 메시징을 위한 socket 연결을 room socket, 메시지 수신에 대한 알림을 위한 socket 연결을 global socket이라 하겠습니다. global socket은 앱을 켰을 때부터 끌 때까지 계속 연결되며, room socket은 채팅방에 들어갈 때부터 나올 때까지 연결되어 있습니다.
실시간 채팅
제일 먼저 클라이언트 간 실시간 채팅이 어떤 과정을 거치는지 알아보겠습니다.
- 클라이언트가 채팅방에 들어가면 room socket이
client:joinChatroom
이벤트를 emit합니다. - 서버에서
client:joinChatroom
이벤트를 수신하면 해당 클라이언트를 room에 join시킵니다. - 클라이언트가 메시지에 정보를 담아
client:speakMessage
이벤트를 emit합니다. - 서버가
client:speakMessage
이벤트를 수신하면 해당 메시지를 DB에 넣고, message ID를 받아옵니다. - 서버는 해당 이벤트가 발생한 room의 모든 socket에
server:hearMessage
이벤트를 emit합니다. 이 때io.in([room ID]).emit()
을 이용합니다. - 클라이언트가
server:hearMessage
이벤트를 수신하면 메시지 발송자인 경우 창 오른쪽에, 메시지 발송자가 아닌 경우 창 왼쪽에 메시지를 렌더링합니다. - 클라이언트가 채팅방에서 나갈 경우 room socket을 disconnect합니다.
그러나 이 경우는 채팅에 참여하는 모든 인원이 채팅 화면을 보고 있어야 합니다. 지금부터는 다른 경우를 알아보겠습니다.
앱을 처음 켰을 때
- 클라이언트의 global socket이 연결됩니다. 이후 자신이 속해있는 room list를 서버에 요청합니다.
- 서버는 해당 room list를 클라이언트에게 돌려줍니다.
- 클라이언트가 자신이 속한 모든 room에 접속시켜 달라고 서버에 요청합니다.
- 서버는 해당 클라이언트를 room에 접속시킵니다.
채팅방 목록 화면에서 메시지가 올 때
채팅방에 들어가 있지 않더라도 메시지가 왔을 때 알람을 보여주어야 합니다. 여기서는 global socket을 이용해 이를 구현했습니다.
- 클라이언트가 앱을 켜는 시점에 global socket이 연결되면서 자신이 속해 있는 room list를 받습니다. 동시에 해당 room에서 사용자가 읽지 않은 메시지 개수도 받아와 채팅방 목록에서 자신이 속해있는 room list과 읽지 않은 메시지 개수를 렌더링합니다.
- 서버가 다른 클라이언트의
client:speakMessage
이벤트(메시지 발송 이벤트)를 수신하면 해당 room에 속해있는 모든 사용자에게server:notificateMessage
이벤트를 emit합니다. - 클라이언트가
server:notificateMessage
이벤트를 수신하면 해당 채팅방의 읽지 않은 메시지 개수를 더해 줍니다.
읽지 않은 메시지가 있는 채팅방에 들어갈 때
사용자가 채팅방 화면에 있을 때는 room socket의 이벤트를 수신해서 실시간으로 메시지를 전달받을 수 있지만, socket.io의 특성상 연결되지 않은 사용자에게는 메시지가 전달되지 않습니다. 사용자가 채팅방에 있지 않으면 room socket 연결을 끊어버리기 때문에 메시지 전달을 받을 수 없습니다.
그러나 편리한 채팅을 위해서는 읽지 않은 채팅도 확인할 수 있어야 합니다. 미어캣은 global socket으로 이를 핸들링했으며 해당 과정은 아래와 같습니다.
- 클라이언트가 채팅방에 들어가면 서버에 해당 채팅방에서 읽지 않은 메시지 목록을 요청합니다.
- 서버는 해당 클라이언트가 해당 채팅방에서 제일 최근에 읽은 메시지를 저장하고 있습니다. 그 메시지 이후에 온 메시지들을 전부 클라이언트에게 돌려줍니다.
- 읽지 않은 메시지를 수신한 클라이언트는 그 메시지들을 화면에 출력해 줍니다.
메시지를 읽은 사람/읽지 않은 사람 목록
앞에서 서버에 해당 클라이언트가 채팅방에서 제일 최근에 읽은 메시지를 저장하고 있다고 했습니다. message ID는 계속 오름차순으로 증가하는 자연수이기 때문에 이 값으로 채팅방에 있는 어떤 사람이 특정 메시지를 읽었는지, 읽지 않았는지 식별할 수 있습니다.
- 클라이언트가 "채팅방에 들어가거나(room socket 접속)", "채팅을 보내거나(
client:speakMessage
이벤트 발생)", "채팅을 받는 event가 발생 시(server:hearMessage
이벤트 발생 시)" 서버에서 해당 사용자가 제일 최근에 읽은 메시지를 갱신합니다. - 클라이언트가 어떤 메시지를 읽지 않은 사람 목록을 요청합니다.
- 서버에서 해당 채팅방에 있는 사용자들의 제일 최근에 읽은 메시지를 이용해 메시지를 읽은 사람, 읽지 않은 사람 목록을 돌려줍니다.
- 클라이언트가 해당 정보를 화면에 출력해 줍니다.
요약
클라이언트의 모든 채팅 로직은 실시간 온라인 일 경우에는 socket 이벤트를, 오프라인에서 온라인으로 되는 경우에는 API 요청을 합니다. 서버에서는 채팅과 관련된 socket 이벤트 수신과 동시에 DB에 저장합니다.
- 앱을 처음 켜면 global socket이 연결되면서 속한 모든 채팅방과 읽지 않은 메시지 개수를 받아옵니다. 해당 채팅방에서 메시지가 오면 읽지 않은 메시지 개수를 늘립니다.
- 클라이언트가 채팅방 진입 시 room socket이 연결되며, 동시에 해당 room에서 읽지 않은 메시지를 서버에 요청합니다. 이후 실시간 메시징은 room socket event로 처리합니다.
- 클라이언트가 메시지를 전송하면
client:speakMessage
이벤트를 emit하고, 서버가 해당 이벤트를 수신하면 DB에 메시지를 저장합니다. 동시에 해당 room의 클라이언트에게server:hearMessage
이벤트를 emit합니다. - 클라이언트가
server:hearMessage
이벤트를 수신하면 메시지를 렌더링하며, 해당 클라이언트가 제일 최근에 읽은 메시지를 갱신합니다.
- 클라이언트가 메시지를 전송하면
이외의 방법
이렇게 하는 방법 이외에도, 하나의 socket 연결만을 사용하는 방법도 있습니다. 서버에 redis같은 memory DB를 두고, socket 연결이 될 때 [CLIENT_ID]-[SOCKET-ID]를 key-value 쌍으로 저장하는 방식입니다. 이렇게 하면 클라이언트가 앱을 켜면 socket 연결이 일어나고, 서버는 클라이언트의 socket id를 저장합니다.
- 채팅방에 접속하면 해당 room에 socket 접속합니다. 나가면 disconnect합니다.
- room socket 접속 시 unread message fetch + 최신으로 읽은 message ID를 갱신해야 합니다.
- 채팅방에 들어가있지 않은 상태라면 서버에서 누군가가 보낸 메시지를 받으면, 채팅방에 있는 user의 socket status와 socket id를 조회해서 해당 사용자에게 메시지 알림을 줄 수 있습니다.
- 이렇게 하면 같은 방식으로, 실시간으로 채팅방에 초대되는 경우도 해결할 수 있습니다.
Scalability
읽기 전에
Redis Pub/Sub 구조에 대해 이해하고 있어야 합니다.
Pub/Sub 구조는 비동기 메시징 패러다임으로써, Publisher가 메시지를 송신할 때 특정 수신자를 타겟으로 하지 않습니다. 대신, Publisher를 구독한 Subscript에게만 전달됩니다.
Scale out의 필요성
Node.js는 비동기 처리로 많은 사용량을 처리할 수 있지만 사용자가 늘어나면 결국 부하를 분산시켜야만 합니다. 미어캣은 Node.js로 구현되었기 때문에 부하분산을 위해서는 Node가 지원하는 Cluster 기능을 이용해 여러 개의 Worker를 만들어 서버를 실행해야 합니다.
그러나 socket.io는 동일한 Worker가 아닌 경우 같은 room에 있더라도 데이터 통신이 불가능합니다. 각 Worker들은 단절되어 있기 때문입니다. 이를 해결하기 위해 Redis의 Pub/Sub 구조를 이용합니다.
Redis Pub/Sub 구조를 이용한 Scale out
Socket.IO는 Pub/Sub 구조를 구현하기 위해 Redis adapter를 사용합니다. 이를 이용할 경우 broadcast나 io.to().emit() 또는 socket.broadcast.emit()와 같이 여러 클라이언트에게 메시지를 전송할 때 유용합니다.
먼저 어떤 클라이언트가 서버에 접속할 경우, load balancer가 적당한 Worker를 할당해 줍니다. 클라이언트는 할당받은 Worker에 있는 socket에 접속됩니다.
이 상태에서 클라이언트가 socket 이벤트를 emit하면 접속되어 있는 Worker가 Redis Channel로 이벤트 발생 여부를 알립니다. Redis Channel은 모든 Worker에 붙어 있는 Redis adapter에게 이벤트 발생 여부를 알려주고, 이벤트를 수신받은 각 Worker의 socket.io가 해당하는 클라이언트에게 이벤트를 전송합니다.
이 상태에서 메시지를 발송했을 때 프로토콜을 아래 그림을 예시로 들어 설명하겠습니다.
load balancer가 사용자 A가 2번 Worker에 할당되었다고 가정하겠습니다.
- 사용자 A가 어떤 room에서 메시지를 전송하면 Redis Channel로 이벤트 발생 여부가 날아갑니다.
- Redis Channel에서는 모든 Cluster의 Worker에게 해당 이벤트를 전송합니다.
- 각 Worker은 자신의 socket에 해당 room이 있는지 봅니다. 있으면 이벤트를 전송하고, 없다면 전송하지 않습니다.
'Development > Socket.io' 카테고리의 다른 글
[Socket.io] socket.io 간단한 예제 (0) | 2022.10.04 |
---|---|
[Socket.io] 채팅 기능에 관한 고찰 (0) | 2022.06.24 |