상황
1:1 채팅과 그룹 채팅을 지원하는 채팅 시스템을 설계해보자.
첨부파일은 제외하고 텍스트로만 주고받는다는 가정하에 진행한다.
사용자의 접속 상태 표시 기능이 포함되어야하며,
하나의 계정으로 여러 단말에 동시 접속을 지원한다.
푸시 알림도 지원해야한다.
채팅 서버는 3가지 기능을 제공해야한다.
- 클라이언트들로부터 메시지 수신
- 메시지 수신자 결정 및 전달
- 수신자가 접속 상태가 아닌 경우에는 접속할 때까지 해당 메시지를 보관
채팅 서비스
채팅 서비스와의 접속에는 keep-alive(Http/2 이상부터는 안붙여도됨) 헤더를 사용하면 효율적인데, 클라이언트와 서버 사이의 연결을 끊지 않고 계속 유지할 수 있어서다. TCP 접속 과정에서 생기는 행드셰이크 횟수를 줄일 수 있음은 물론이다. 하지만 메시지 수신 시나리오는 이것보다 복잡하다. HTTP는 클라이언트가 연결을 만드는 프로토콜이며, 서버에서 클라이언트로 임의 시점에 메시지를 보내는 데는 쉽게 쓰일 수 없다. 폴링과 롱 폴링이 있지만 여러 가지로 비효율적이라고 판단하고, 소켓을 사용하도록 한다.
실시간으로 메시지를 주고받기 위해 클라이언트는 채팅 서버와 웹 소켓 연결을 끊지 않고 유지해야한다.
- 채팅 서버는 클라이언트 사이에 메시지를 중계하는 역할을 담당한다.
- 접속 상태 서버는 사용자의 접속 여부를 관리한다.
- api서버는 로그인, 회원가입, 프로파일 변경 등 나머지 전부를 처리한다.
- 알림 서버는 푸시 알림을 보낸다
- 키-값 저장소에는 채팅 이력을 보관한다. 시스템에 접속한 사용자는 이전 채팅 이력을 전부 보게된다.
저장소
채팅 시스템이 다루는 데이터는 보통 두 가지다. 첫 번쨰는 사용자 프로파일, 설정, 친구 목록처럼 일반적인 데이터다. 이런 데이터는 안정성을 보장하는 관계형 DB에 보관한다.
두 번쨰 유형의 데이터는 채팅 시스템에 고유한 데이터로, 채팅 이력이다. 이 데이터를 어떻게 보관할지 결정하려면 읽기/쓰기 연상 패턴을 이해해야 한다.
- 채팅 이력 데이터의 양은 엄청나게 많다. 왓츠앱은 매일 600억개의 메시지를 처리한다.
- 이 데이터 가운데 빈번하게 사용되는 것은 주로 최근에 주고받은 메시지다.
- 사용자는 대체로 최근에 주고받은 메시지 데이터만 보는게 사실이나, 검색 기능을 이용하거나, 언급된 메시지를 보거나, 특정 메시지로 점프한다.
- 1:1 채팅 앱의 경우 읽기:쓰기 비율은 대략 1:1 정도다.
여기서는 키-값 저장소를 추천할 것인데, 그이유는 다음과 같다.
- 키-값 저장소는 수평적 규모확장이 쉽다
- 키-값 저장소는 데이터 접근 지연시간이 낮다
- 관계형 DB는 데이터 가운데 롱테일에 해당하는 부분을 잘 처리하지 못하는 경향이 있다.
- 이미 많은 안정적인 채팅 시스템이 키-값 저장소를 채택하고 있다. (페북 - HBase, 디스코드 - 카산드라)
롱테일(Long Tail)은 데이터의 분포 특성을 설명하는 개념. 구체적으로 말하면, 접근 빈도는 낮지만 전체로 보면 많은 양을 차지하는 데이터를 의미
메시지 테이블
1:1 채팅을 위한 메시지 테이블
message_id bigint
message_from bigint
message_to bigint
content text
create_at timestamp
그룹 채팅을 위한 메시지 테이블
channel_id bigint
message_id bigint
message_to bigint
content text
create_at timestamp
그룹에서 message_to는 왜 필요할까?
해당 예제에서는 하나의 메시지를 사용자별로 구분하고있기때문
메시지 흐름
1대1 채팅 메시지 처리 흐름
- 사용자 A가 채팅 서버 1로 메시지 전송
- 채팅 서버 1은 ID 생성기를 사용해 해당 메시지의 ID 결정
- 채팅 서버 1은 해당 메시지를 메시지 동기화 큐로 전송
- 메시지가 키-값 저장소에 보관됨
- 사용자 B가 접속 중인 경우 메시지는 사용자 B가 접속 중인 채팅 서버로 전송됨, 사용자 B가 접속 중이 아니 라면 푸시 알림 메시지를 푸시 알림 서버로 보냄
- 채팅 서버 2는 메시지를 사용자 B에게 전송. 사용자 B와 채팅 서버 2 사이 에는 웹소켓 연결이 있는 상태이므로 그것을 이용
여러 단말 사이의 메시지 동기화
┌────────────┐
│ 사용자 A │
└─────┬──────┘
│
▼
┌────────────┐
│ 채팅 서버 1 │
└─────┬──────┘
│
┌──────┴────────┐
│ │
▼ ▼
┌────────────────┐ ┌────────────────┐
│ 메시지 동기화 큐 │ │ 메시지 동기화 큐 │
└──────┬─────────┘ └──────┬─────────┘
▼ ▼
┌────────────┐ ┌────────────┐
│ 사용자 B │ │ 사용자 C │
└────────────┘ └────────────┘
해당 그룹에 3명의 사용자가 있다고 가정.
우선 사용자 A가 보낸 메시지가 사용자 B,C의 메시지 동기화 큐에 복사된다. 이 큐를 각각에 할당된 메시지 수신함 같은것으로 생각해도 무방하다. 이 설계안은 소규모 그룹 채팅에 적합한데, 이유는 아래와 같다.
- 새로운 메시지가 왔는지 확인하려면 자기 큐만 보면 되니까 메시지 동기화 플로가 단순하다.
- 그룹이 크지않으면 메시지를 수신자별로 복사해서 큐에 넣는 작업의 비용이 문제가 되지 않는다.
위쳇이 이런 접근법을 쓰고있으며, 그룹의 크기는 500명으로 제한하고있다.
하지만 많은 사용자를 지원해야 하는 경우라면 똑같은 메시지를 모든 사용자의 큐에 복사하는게 바람직하지 않을 것이다.
접속 상태 표시
사용자 로그인
사용자 로그인 절차에 대해서는 "서비스 탐색" 절에서 설명한 바 있다. 클라이 언트와 실시간 서비스(real-time service) 사이에 웹소켓 연결이 맺어지고 나면 접속상태 서버는 A의 상태와 last_active_at 타임스탬프 값을 키-값 저장소에 보관한다. 이 절차가 끝나고 나면 해당 사용자는 접속 중인 것으로 표시될 것 이다.
로그아웃
키-값 저장소에 보관된 사용자 상태가 online에서 ofline으로 바뀌게 된다면 UI상에서 사용자의 상태는 접속 중이 아닌 것으로 표시될 것 이다.
접속 장애
사용자의 인 터넷 연결이 끊어지면 클라이언트와 서버 사이에 맺어진 웹소켓 같은 지속성 연결도 끊어진다. 이런 장애에 대응하는 간단한 방법은 사용자를 오프라인 상 태로 표시하고 연결이 복구되면 온라인 상태로 변경하는 것이다. 하지만 짧은 시간 동안 인터넷 연결이 끊어졌다 복구되는 일은 흔하다. 이런 일이 벌어질 때마다 사용자의 접속 상태를 변경한다면 그것은 지나친 일일 것 이고, 사용자 경험 측면에서도 바람직하지 않을 것이다.
이는 health check를 통해 이 문제를 해결하면 된다. 주기적으로 접속상태를 서버로 보내도록 하고, 마지막 이벤트를 받은 지 x초 이내에 또 다 른 박동 이벤트 메시지를 받으면 해당 사용자의 접속상태를 계속 온라인으로 유지하는 것이다. 그렇지 않을 경우에만 오프라인으로 바꾸는 것이다.
상태 정보의 전송
그렇다면 사용자 A와 친구관계에 있는 사용자들은 어떻게 해당 사용자의 상태변화를 알게될까?
그 방법은 각각의 친구관계마다 채널을 하나씩 두는 것이다. A가 B,C,D와 친구라고했을때, A-B, A-C, A-D 채널을 만들어 각각 구독하는 형식이다. 이 방식은 500명으로 제한되어있기 때문에 가능한 설계이고 그 이상의 경우는 다른 방안을 모색해야할것이다.
예를들어 사용자가 10만이 있다고한다면, 사용자가 그룹채팅에 입장하는 순간에만 상태정보를 읽어가게 한다거나, 친구 리스트에 있는 사용자의 접속 상태를 갱신하고 싶으면 수동으로 하도록 유도하는 방법이 있다.
'Web' 카테고리의 다른 글
| 유튜브 설계 (가상 면접 사례로 배우는 대규모 시스템 설계) (1) | 2025.06.04 |
|---|---|
| 검색어 자동완성 시스템 설계 (가상 면접 사례로 배우는 대규모 시스템 설계) (0) | 2025.06.02 |
| 알림 시스템 설계 (가상 면접 사례로 배우는 대규모 시스템 설계) (0) | 2025.05.30 |
| 분산 시스템을 위한 유일 ID 생성기 설계 (가상 면접 사례로 배우는 대규모 시스템 설계) (0) | 2025.05.29 |
| Spring batch란? (1) | 2025.05.29 |