목차
채팅 시스템 설계는 개발자라면, 누구나 한 번쯤은 생각해본 주제다.
그러나, 채팅 앱을 설계할 때 1:1 채팅만을 고민한다면 면접관은 곤란할 것이다.
1단계 문제 이해 및 설계 범위 확장
디스코드 처럼 대규모 그룹 간의 소통이나 페이스북 메신저같은 1:1 앱도 존재한다.
정확한 요구사항을 파악해야 한다.
- Cross Platform을 지원해야 한다.
- 1:1 및 그룹 채팅을 모두 지원해야 한다.
- 그룹 채팅의 경우 최대 100명까지 지원한다.
- 채팅 접속 시, 사용자의 접속상태를 표시할 수 있어야 한다. 텍스트 메세지만 주고받을 수 있다.
- 메세지 길이는 100,000자 이하다.
- 종단 간 암호화는 지원하지 않는다.
- 채팅 이력은 영원히 보관한다.
나온 Application의 스펙은 다음과 같이 정의된다.
- 응답지연이 낮은 일대일 채팅 가능
- 최대 100명까지 참여할 수 있는 그룹 채팅 기능
- 사용자 접속상태 표시 기능
- 다양한 단말 지원
- Push 알림
- 일 5천만 DAU 처리 가능
2단계 개략적 설계안 제시 및 동의 구하기
기본적으로 채팅 서비스는 다음과 같은 기능을 지원해야 한다.
- 클라이언트로부터 메세지 수신
- 메세지 수신자 결정 및 전달
- 수신자가 접속 상태가 아닌 경우 접속할 때 까지 메세지 보관
둘 사이의 관계를 요약하면 이렇다.
채팅 서비스의 경우 어떤 프로토콜을 사용할지도 매우 중요한 요소다. 대부분 Client/Server Application에서 요청을 보내는 것은 클라이언트다. 보통은 수신자에게 요청을 보낼 때 가장 오래됐으며 대중적인 HTTP 프로토콜을 사용한다.
메세지 송신의 경우 채팅 서비스와의 접속에는 keep-alive 헤더를 사용하면 효율적이다. 클라이언트와 서버 간 연결을 끊지 않고 계속 유지할 수 있기 때문이다. TCP 헤더 간의 핸드셰이크 횟수를 줄일 수 있음도 물론이다.
메세지 수신의 경우 이것보다 더 복잡하다. HTTP는 클라이언트가 연결을 만드는 프로토콜이며,서버에서 클라이언트로 임의 시점에 보내는 데는 쉽게 쓰일 수 없다. 서버가 계속해서 연결을 만들어 메세지를 수신하고 다시 수신한 메세지를 보여주게 동작하는 방안으로 많은 기법이 제안됐는데, 폴링(polling), 롱 폴링(long polling), 웹 소켓(Web Socket)이 등이 대표적이다.
폴링(Polling)
폴링은 클라이언트가 주기적으로 서버에게 새 메세지가 있는지 요청하는 방법이다. 메세지가 없는 경우 자원을 낭비하는 단점이 있지만, 안정적이다.
롱 폴링(Long Polling)
폴링이 여러 가지로 비효율적이라 나온 것이 롱 폴링이다. 롱 폴링의 경우 클라이언트는 새 메세지가 반환되거나, 타임아웃 될 때까지 연결을 유지한다. 클라이언트는 새 메세지를 받으면 기존 연결을 종료하고 서버에 새로운 요청을 보내 모든 절차를 다시 시작한다.
- 메세지를 보내는 클라이언트와 수신하는 클라이언트가 같은 채팅 서버에 접속하지 않을 수도 있다. 로드밸런싱을 위해 라운드 로빈을 사용하는 경우, 메세지를 받은 서버는 메세지를 수신할 클라이언트와 같은 롱 폴링 연결을 가지고 있지 않은 서버일 수 있다.
- 서버 입장에선 클라이언트가 연결을 해제했는지 아닌지 알 좋은 방법이 없다.
- 여전히 비효율 적이다. 메세지를 많이 받지 않은 클라이언트도 타임아웃이 일어날 때마다 주기적으로 서버에 다시 접속할 것이다.
웹 소켓(Web Socket)
웹 소켓은 서버가 클라이언트에게 비동기 메세지를 보낼 때 널리 사용하는 기술이다.
소켓 연결은 클라이언트가 시작하며, 맺어진 연결은 항구적이며 양방향이다. 연결은 초기에는 HTTP지만 핸드셰이크 절차를 거지고 나서 웹 소켓 연결로 업그레이드된다.
일단 연결이 만들어지고 나면 서버는 클라이언트에 비동기적으로 메세지를 전송할 수 있다. HTTP와 HTTPS의 80 또는 443 포트를 그대로 사용하기 때문에 방화벽 환경에서도 동작한다.
웹 소켓은 양방향 메세지 전송을 가능하게 만들어주기 때문에 안정적으로 소켓 기반 서비스를 구축할 수 있다면 굳이 폴링 방식을 사용할 필요는 없다.
개략적 설계안
설계에서 웹 소켓으로 연결하기로 결정됐다. 무엇을 고민하고 결정해야 할까?
- 무상태 서비스(Stateless Service) / 상태 서비스(Stateful Service)
설계안은 무상태를 기준으로 설계 됐다. 무상태 서비스는 보통 로드밸런서 뒤쪽에 위치하는데, 채팅 서비스는 상태를 유지해야 한다.
그렇다면, 채팅 서비스의 연결을 유지한 채로 로드밸런서로 부하를 분산시킬 수 있어야 한다.
- 제3자 서비스 연동
Push 알림과 통합될 수 있어야 한다.
- 규모 확장성
- 채팅 서버는 클라이언트 사이의 메세지를 중계한다.
- 접속상태 서버는 사용자의 접속 여부를 관리한다.(투 머치 하다고 생각)
- API 서버는 로그인, 회원가입, 프로파일 변경 등 그 외 나머지 전부를 처리한다.
- 알림 서버는 푸시 알람을 보낸다.
- 키-값 저장소에는 채팅 이력을 보관한다. 시스템에 접속한 사용자는 이전 채팅 이력을 볼 수 있다.
대용량 트래픽이 몰리는 경우, 확장성에 대한 고민 없이는 몰려드는 트래픽에 대한 처리가 불가능 해 채팅 서버를 운영하기 어렵다.
이 때 따져봐야 하는 것은 서버 한 대로 얼마나 많은 양을 처리할 수 있는지다.
여기서는 동시 접속자가 1M이고, 접속당 10kb의 서버 메모리가 필요하다면 10GB의 메모리 정도면 모든 연결을 다 처리할 수 있다.
메모리 사용량 = 동시 접속자 수 × 접속자당 메모리 사용량 = 100만 × 10KB 이를 계산하면, 메모리 사용량 = 1000000 × 10KB = 10,000,000KB = 10,000MB = 10GB
하지만 모든 트래픽을 서버 한대로 처리하는 것은 정답이 아닐 것이다. SPOF 문제도 있고 갑자기 예상보다 트래픽이 더 몰릴 수도 있기 때문에 확장성에 대한 고민을 해야한다.
계속해서 유의할 점은, 실시간으로 메세지를 주고 받기 위해 웹 소켓으로 연결된 클라이언트와 서버는 연결을 끊지 않고 유지해야 한다는 점이다.
- 저장소
- 사용자 Profile 같은 일반적인 데이터
- 채팅 시스템의 고유 데이터, 채팅 이력
- 키-값 저장소는 수평적 규모확장이 쉽다.
- 키-값 저장소는 데이터 접근 Latency가 낮다.
- 관계형 데이터베이스는 롱 테일에 해당하는 부분을 잘 처리하지 못하는 경향이 있다. 인덱스가 커지면 무작위적인 접근 처리 비용이 늘어난다.
- 페이스북이나 디스코드 같은 서비스에서 키-값 저장소를 채택하고 있다.
어떤 데이터베이스를 사용하는 가는 중요한 문제다. 채팅 서비스에 있어 중요한 것은 I/O 연산의 패턴이다.
채팅 시스템은 일반적으로 2가지 데이터를 다룬다.
채팅 이력은 엄청난 데이터를 다루며, 최근 메세지들은 빈번하게 사용된다. 검색 기능이나 특정 사용자가 말한 내용만을 탐색하나거나 무작위적으로 채팅 이력에 접근할 수 있다.
본 설계안에선 키-값 저장소를 추천한다. 이유는 다음과 같다.
- 데이터 모델
저장소를 정했으니, 메세지 데이터를 어떻게 관리할지 정해야 한다.
1:1 채팅을 위한 메세지 테이블은 오른 쪽과 같이 정한다.
그룹 채팅을 위한 메세지 테이블은 다음과 같이 지정한다.
한 가지 주의깊게 살펴볼만한 것은, 메세지 id를 만드는 방법이다.
메세지 id는 고유해야 하며 정렬 가능해야 하고 시계열로도 정렬될 수 있어야 한다. 이전 장에서 살펴본 스노 플레이크도 있겠지만, 지역적 순서 번호 생성기라는 것도 있다. ID의 유일성을 같은 그룹 안에서만 보장하는 것이다. 혹은 1:1 채팅 세션 안에서만 유지되면 된다는 것이다.
3단계 상세 설계
채팅 시스템에선 서비스 탐색, 메세지 전달 흐름, 사용자 접속 상태 표시정도를 살펴볼 수 있다.
- 서비스 탐색
- 사용자가 A가 시스템에 로그인을 시도한다.
- 로드 밸런서가 로그인 요청을 API 서버들 중 하나로 보낸다.
- API 서버가 인증을 처리하고 나면, 서비스 탐색 기능이 작동하여 해당 사용자를 서비스할 최적 채팅 서버로 보낸다.
- 사용자 A는 채팅 서버 2와 웹 소켓 연결을 맺는다.
책에서는 아파치 주키퍼 같은 걸 사용했지만, 아파치 카프카나 Java나 Node.js를 통해 구현할 수도 있다.
- 메세지 흐름
- 1:1 메세지 처리 흐름
- 사용자 A가 채팅 서버 1로 메세지 전송
- 채팅 서버 1은 ID 생성기로 해당 메세지 ID 결정
- 채팅 서버 1은 해당 메세지를 메세지 동기화 큐로 전송
- 메세지가 키-값 저장소에 보관됨
- 사용자 B가 접속 중인 경우, B가 접속 중인 채팅 서버로 전송된다. 접속 중이 아니라면 Push 알람을 보낸다.
- 채팅 서버 2는 사용자 B에게 메세지를 전송, 사용자 B와 채팅 서버 2사이에는 웹 소켓으로 연결되있기 때문에 소켓 서버를 이용 한다.
- 여러 단말 사이의 메세지 동기화
- 수신자 ID가 현재 로그인한 사용자 ID랑 같다.
- 키-값 저장소에 보관된 메세지로서, 그 ID가 cur_max_message_id보다 크다.
- 소규모 그룹 채팅에서의 메세지 흐름
- 새로운 메세지가 왔는지 확인하려면 본인 큐만 보면 되니까 메세지 동기화가 단순해진다.
- 그룹이 크지 않다면 메세지를 수신자별로 복사하여 큐에 넣는 작업 비용이 문제가 되지 않는다. 위챗이 이런 접근법을 사용하고 있다. 그래서 그룹 크기를 500명으로 제한하고 있다.
각 종단 간 메세지 흐름을 이해하는 것은 중요한 일이다.
두 사용자 간 단말이 다를 때는 두 단말이 웹 소켓으로 연결되있다는 가정으로,
각 단말은 cur_max_message_id라는 변수를 유지하는데, 해당 단말에서 관측된 가장 최신 메세지 ID를 추적하는 용도다. 아래 두 조건을 만족한다면 메세지는 새 메세지로 간주한다.
이런식으로 두 단말간 메세지를 동기화하여 관리한다.
위 그림은 사용자의 한 채팅이 어떤 영향을 미치는지 보여준다.
사용자가 3명이라고 하면, A가 보낸 메세지가 사용자 B와 C의 메세지 동기화 큐에 복사된다. 이 큐는 사용자 각각마다 할당된 메세지 수신함이라고 생각하면 좋다.
왜 소규모 일까?
여러 사용자를 지원해야 한다면, 이런 접근법은 바람직하지 않을 것이다. 여태껏 설명한 관점에서 살펴보면 수신자는 사용자들로부터 오는 메세지를 수신할 수 있어야 한다. 메세지 동기화는 여러 사용자로부터 오는 메세지를 받을 수 있어야 한다.
- 접속상태 표시
- 사용자 로그인
- 로그아웃 키-값 저장소에 보관된 사용자 상태가 online에서 offline으로 바뀌게 된다는 점에 유의하자.
- 접속 장애
- 상태 정보의 전송
아마도 디스코드의 이런 표시를 보고 쉽게 이해할 수 있을 것이다. 사용자의 접속 상태 표시 또한 실시간이라는 점을 유의해야 한다.
실시간 서비스 사이에 웹 소켓이 연결되고 나면, 접속상태 서버는 A의 상태와 list_active_at 타임스탬프 값을 키-값 저장소에 보관한다.이 절차가 끝나면 사용자는 접속 중인 것으로 표시될 것이다.
이 절차가 끝나면 UI 상으로 접속 중이 아닌것으로 표시될 것이다.
인터넷이 끊어지면 접속 연결도 끊어질 것이다. 이런 장애 대응의 간단한 방법은 사용자를 오프라인 상태로 만들고 다시 연결이되면 온라인으로 만드는 것이다.
본 설계안에선 박동 검사를 통해 이 문제를 해결한다. 온라인 상태의 클라이언트로 하여금 주기적으로 박동 이벤트를 접속상태 서버로 보내도록 하고, 마지막 이벤트를 받은 지 n초가 지나면 오프라인으로 바꾸는 것이다.
상태정보 서버는 발행-구독 모델을 하고 있는데, 각각 친구마다 채널을 하나씩 두고
사용자 A가 접속했다고 치면, 그 사실을 세 개의 채널 A-B, A-C, A-D에 쓰는 것이다. A-B는 사용자 B가 구독하고, A-C는 사용자 C가 A-D는 사용자 D가 구독하도록 하는 것이다.
친구 관계의 접속 상태를 쉽고 빠르게 알아보도록 하는 것이다.
한편, 위 방식은 그룹 크기가 작다면 효과적이지만 크기가 더 커지면 이런 변화를 유지하는 것이 리소스 낭비로 이어질 수 있다.
따라서 직접 수동으로 갱신하거나, 주기가 길게 폴링하거나 하는 식으로 어느 정도 유연함을 보여줄 필요가 있다.
4단계 마무리
추가적으로 고민해 본다면,
- 사진이나 비디오 등 미디어 파일을 지원하는 방법
- 종단 간 암호화 : 메세지 전송은 당사자들 외에 아무도 볼 수 없는 것이 좋다.
- 캐시: 이미 읽은 메세지를 캐시해두면 데이터 양을 줄일 수 있다.
- 로딩 속도 개선 : 슬랙은 사용자의 데이터, 채널 등을 지역적으로 분산 네트워크를 구축하여 속도를 개선했다.
- 오류 처리 :
- 하나의 채팅 서버에 수십만이 접속했다고 가정한다면.. 재해복구가 필요하다.
- 메세지 재전송 : 재시도나 큐 기법을 통해 재시도 정책을 구비해두는 것이 좋다.
대부분의 사진 출처
Share article