[가상면접 사례로 배우는 대규모 시스템 설계 기초] Chap12. 채팅 시스템
본 글은 가상면접 사례로 배우는 대규모 시스템 설계 기초를 읽고 정리한 글입니다.
페이스북 메신저와 유사한 채팅 앱을 설계해 볼 것이다.
- 응답지연이 낮은 일대일 채팅 기능
- 최대 100명까지 참여할 수 있는 그룹 채팅 기능
- 사용자의 접속상태 표시 기능
- 다양한 단말 지원. 하나의 계정으로 여러 단말에 동시 접속 지원
- 푸시 알림
- 5천만 DAU(Daily Acitve User)를 처리해야 함
2단계 개략적 설계안 제시 및 동의 구하기
클라이언트는 서로 직접 통신하지 않는다. 각 클라이언트는 채팅 서비스와 통신한다.
채팅 서비스는 아래 기능을 제공해야 함.
- 클라이언트로부터 메시지 수신
- 메시지 수신자(recipient) 결정 및 전달
- 수신자가 접속 상태가 아닌 경우, 접속할 때까지 해당 메시지 보관
- 채팅을 시작하려는 클라이언트는 네트워크 통신 프로토콜을 이용하여 서비스에 접속
- 송신 클라이언트는 수신 클라이언트에게 전달할 메시지를 채팅 서비스를 보낼 떄, 검증된 HTTP 프로토콜을 사용한다.
- 채팅 서비스와의 접속에는 keep-alive 헤더를 사용하면 효율적**(클라이언트와 서버 사이의 연결을 끊지 않고 계속 유지할 수 있어서 & TCP 접속 과정에서 발생하는 핸드쉐이크 횟수를 줄일 수 있음)**
- BUT HTTP는 클라이언트가 연결을 만드는 프로토콜이며, 서버에서 클라이언트로 임의 시점에 보내는데는 쉽게 쓰일 수 없음. 서버가 연결을 만드는 것처럼 동작할 수 있도록 개발하는 기법? EX) 폴링(polling), 롱 폴링(long poll-ing), 웹소켓(WebSocket)
폴링(polling)
- 클라이언트가 주기적으로 서버에게 새 메시지가 있는지 물어보는 방법
- 폴링 비용은 폴링을 자주하면 할수록 올라감.
- 답해줄 메시지가 없는 경우 서버 자원이 불필요하게 낭비된다는 문제.
롱폴링
롱 폴링의 경우, 클라이언트는 새 메시지가 반환되거나 타임아웃 될때까지 연결 유지.
클라이언트는 새 메시지를 받으면 기존 연결을 종료하고, 서버에 새로운 요청을 보내어 모든 절차 다시 시작.
Weakness
- 메시지를 보내는 클라이언트와 수신하는 클라이언트가 같은 채팅 서버에 접속하게 되지 않을 수 있음. Http 서버는 보통 무상태(stateless) 서버다. 로드 밸런싱을 위해 라운드 로빈(round robin) 알고리즘을 사용하는 경우, 메시지를 받은 서버는 해당 메시지를 수신할 클라이언트와의 롱 폴링 연결을 가지고 있지 않은 서버일 수 있음.
- 서버 입장에서 클라이언트가 연결을 해체했는지 아닌지 알 수 없음.
- 타임 아웃이 일어날때마다, 주기적으로 서버에 다시 접속할 것임
웹소켓
웹소켓(Websocket)은 서버가 클라이언트에게 비동기(async) 메시지를 보낼때 널리 사용되는 기술
- 웹소켓 연결은 클라이언트가 시작함. 한번 맺어진 연결은 양방향이다. 처음 연결은 HTTP 연결이지만 특정 핸드셰이크 절차를 거쳐 웹소켓 연결로 됨.
- 서버는 클라이언트에게 비동기적으로 메시지 전송 가능
- 웹소켓은 일반적으로 방화벽이 있는 환경에서도 잘 동작 ( 80, 443처럼 HTTP, HTTPS 프로토콜이 사용하는 기본 포트번호를 그대로 쓰기 때문임.)
무상태 서비스
- 로그인, 회원가입, 사용자 프로파일 표시
- 무상태 서비스는 로드밸런서 뒤에 위치함. 로드밸런서가 하는 일은 요청을 그 경로에 맞는 서비스로 정확히 전달함.
- 서비스 탐색 서비스는 클라이언트가 접속할 채팅 서버의 DNS 호스트명을 클라이언트에게 알려주는 역할을 함.
상태 유지 서비스
- 채팅 서비스
- 각 클라이언트가 채팅 서버와 독립적인 네트워크 연결을 유지해야 함.
제3자 서비스 연동
- 푸시 알림
- 새 메시지를 받았다면 설사 앱이 실행 중이지 않더라도 알림을 받아야 함.
규모 확장성
- 동시 접속자가 1M 인 경우?
→ 트래픽을 서버 한 대로 처리하면 SPOF가 발생할 수 있음.
- 채팅 서버는 클라이언트 사이에 메시지를 중계하는 역할을 담당한다.
- 접속상태 서버(presence server)는 사용자의 접속 여부를 관리한다.
- API 서버는 로그인, 회원가입, 프로파일 변경 등 그 외 나머지 전부를 처리한다.
- 알림 서버는 푸시 알림을 보낸다.
- 키-값 저장소(key-value store)는 채팅 이력(chat history)를 보관한다.
저장소
- 관계형 데이터베이스? NOSQL?
→ 데이터의 유형과 읽기/쓰기 연산의 패턴
- 사용자 프로파일, 설정, 친구 목록처럼 일반적인 데이터
→ 이런 데이터는 안정성을 보장하기 위해 관계형 데이터베이스에 저장(다중화, 샤딩기법)
(샤딩은 데이터의 분산저장을 위해 해시 샤딩을 이용함.)
- 채팅 시스템 고유 데이터(채팅 이력)
→ 채팅 이력 데이터의 양은 엄청나다.
→ 주로 최근 주고받은 메시지가 빈번하게 사용
→ 검색 기능, 특정 사용자가 언급(mention)된 메시지를 보거나, 특정 메시지로 점프(jump)하거나 하여 무작위적인 데이터 접근을 하게 되는 일도 있음.
→ 1:1 채팅 앱의 경우 읽기:쓰기 비율은 대략 1:1이다.
2번 데이터는 키-값 저장소를 추천함.
→ 키-값 저장소는 수평적 규모 확장(horizontal scaling)이 쉽다.
→ 키-값 저장소는 데이터 접근 지연시간(latency)이 낮다.
→ 관계형 데이터베이스는 롱 테일에 해당하는 부분을 잘 처리하지 못하는 경향이 있음. 인덱스가 커지면 데이터에 대한 무작위 접근(random access)을 처리하는 비용이 늘어남
→ 페이스북 메신저는 HBase를 사용, 디스코드는 카산드라(Cassandra)를 이용함.
데이터 모델
1:1 채팅을 위한 메시지 테이블
기본키는 message_id
created_at을 사용하여 메시지 순서를 정할 수 없음, 서로 다른 두 메시지가 동시에 만들어질 수 있기 때문에.
그룹 채팅을 위한 메시지 테이블
(channel_id, message_id)의 복합 키(composite key)를 기본 키로 사용함.
채널(channel)은 채팅 그룹과 같은 뜻
channel_id는 파티션 키로 지정.
메시지 ID
- message_id의 값은 uniqueness 해야 함.
- ID 값은 정렬 가능해야 하며, 시간 순서와 일치해야 한다. 즉 새로운 id는 이전 id보다 큰 값.
→ RDBMS의 경우 auto_increment가 대안, but NoSQL은 해당 기능 제공 X
→ 스노플레이크 같은 전역적 64-BIT 순서 번호 생성기를 이용
→ 지역적 순서 번호 생성기(local sequence number generator)를 이용
(메시지 사이의 순서는 같은 채널, 혹은 같은 1:1 채팅 세션 안에서만 유지되면 충분하기 때문)
3단계 상세 설계
서비스 탐색
- 클라이언트에게 가장 적합한 채팅 서버를 추천하는 것.
- 기준(위치, 서버의 용량)
- 서비스 탐색 기능을 구현하는데 널리 쓰이는 오픈소스 솔루션은 아파치 주키퍼
- 주키퍼에 다량 서버를 등록해놓고, 사전에 정한 기준에 따라 최적의 채팅 서버를 골라줌.
- 사용자 A는 채팅 서버 1로 메시지 전송
- 채팅 서버 1은 ID 생성기를 사용해 해당 메시지의 ID 결정
- 채팅 서버 1은 해당 메시지를 메시지 동기화 큐로 전송
- 메시지가 키-값 저장소에 보관됨
- 사용자 B가 접속 중인 경우, 메시지는 사용자 B가 접속 중인 채팅 서버로 전송됨. 사용자 B가 접속 중이 아니라면, 푸시 알림 메시지를 푸시 알림 서버로 보냄.
- 채팅 서버 2는 메시지를 사용자 B에게 전송, 사용자 B와 채팅 서버 2 사이에는 웹소켓 연결이 있는 상태이므로 그것을 이용.
여러 단말 사이의 메시지 동기화
사용자 A는 전화기와 랩톱의 두대 단말 이용
각 단말은 cur_max_message_id라는 변수를 유지, 해당 단말에서 관측된 가장 최신 메시지의 id를 추적하는 용도. 아래 두 조건을 만족하는 메시지는 새 메시지로 간주
- 수신자 ID가 현재 로그인한 사용자 ID와 같다.
- 키-값 저장소에 보관된 메시지로서, 그 ID가 cur_max_message_id 보다 크다.
소규모 그룹 채팅에서의 메시지 흐름
- 그룹 채팅에서 사용자 A,B,C가 있다고 가정.
- 사용자 A가 보낸 메시지가 사용자 B와 C의 메시지 동기화 큐에 복사.
- 새로운 메시지가 왔는지 확인하려면 자기 큐만 보면 되니까, 메시지 동기화 플로가 단순
- 그룹이 크지 않으면 메시지를 수신자별로 복사해서 큐에 넣는 작업의 비용이 문제가 되지 X
- 위챗이 이렇게 사용함!
접속상태 표시
- 접속상태 서버는 클라이언트와 웹소켓으로 통신하는 실시간 서비스의 일부
사용자 로그인
클라이언트와 실시간 서비스(real-time service) 사이에 웹소켓 연결이 맺어지고 나면 접속상태 서버는 a의 상태와 last_active_at 타임스탬프 값을 키-값 저장소에 보관함.
로그아웃
- 유저 A가 API 서버에 요청시 접속 상태서버에 연결후, 키-벨류 저장소에 offline으로 표시
접속 장애
- 장애 발생시 간단한 방법, 사용자를 오프라인 상태로 표시하고, 연결이 복구되면 온라인 상태로 변경
- 박동(heartbeat) 검사 → 즉 온라인 상태의 클라이언트로 하여금 주기적으로 박동 이벤트를 접속 상태 서버로 보내고, 마지막 이벤트를 받은 지 x초 이내에 또 다른 박동 이벤트 메시지를 받으면 해당 사용자의 접속 상태를 계속 온라인 유지
상태 정보의 전송
- 사용자 a와 친구 관계에 있는 사용자들은 어떻게 해당 사용자의 상태 변화를 알게 될까?
- 상태정보 서버는 발행-구독 모델(publish-subscribe model)을 사용, 즉 각각의 친구관계마다 채널을 하나씩 두는 것(A-B, A-C, A-D)
- BUT, 위 방안은 그룹 크기가 작을 때는 효과적, 그룹 크기가 더 커지면 이런 식으로 접속 상태 변화를 알려서는 비용이나 시간이 많이 들게 되므로 좋지 X
- 대형 규모일때는 사용자가 그룹 채팅에 입장하는 순간에만 상태 정보를 읽어가게 하거나, 친구 리스트에 있는 사용자의 접속상태를 갱신하고 싶으면 수동(manual) 하도록 유도.
마무리
- 채팅 앱을 확장하여 사진이나 비디오 등의 미디어를 지원하도록 하는 방법:
- 미디어 파일은 텍스트에 비해 크기가 크다. 압축 방식, 클라우드 저장소, 섬네일(thumbnail) 생성 등을 논의해보면 재미있을 것이다.
- 종단 간 암호화: 왓츠앱은 메시지 전송에 있어 종단 간 암호화를 지원한다. 메시지 발신인과 수신자 이외에는 아무도 메시지 내용을 볼 수 없다.
- 캐시: 클라이언트에 이미 읽은 메시지를 캐시해 두면 서버와 주고받은 데이터 양을 줄일 수 있음.
- 로딩 속도 개선: 사용자의 데이터, 채널 등을 지역적으로 분산하는 네트워크를 처리하여 앱 로딩 속도를 개선함.
- 오류 처리
- 채팅 서버 오류: 채팅 서버 하나에 수십만 사용자가 접속해 있는 상황을 생각해보자. 서버 하나가 죽으면 주키퍼가 동작하여 클라이언트에게 새로운 서버를 배정하고 다시 접속할 수 있도록 해야 함.
- 메시지 재전송: 재시도(retry), 큐(queue)는 메시지 안정적 전송을 보장하기 위해 흔히 사용되는 기법.