책/가상 면접 사례로 배우는 대규모 시스템 설계 기초 정리

[가상면접 사례로 배우는 대규모 시스템 설계 기초] Chap12. 채팅 시스템

개발하는 민우 2023. 11. 24. 21:30

본 글은 가상면접 사례로 배우는 대규모 시스템 설계 기초를 읽고 정리한 글입니다.

 


페이스북 메신저와 유사한 채팅 앱을 설계해 볼 것이다.

  • 응답지연이 낮은 일대일 채팅 기능
  • 최대 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?

→ 데이터의 유형과 읽기/쓰기 연산의 패턴

  1. 사용자 프로파일, 설정, 친구 목록처럼 일반적인 데이터

→ 이런 데이터는 안정성을 보장하기 위해 관계형 데이터베이스에 저장(다중화, 샤딩기법)

(샤딩은 데이터의 분산저장을 위해 해시 샤딩을 이용함.)

  1. 채팅 시스템 고유 데이터(채팅 이력)

→ 채팅 이력 데이터의 양은 엄청나다.

→ 주로 최근 주고받은 메시지가 빈번하게 사용

→ 검색 기능, 특정 사용자가 언급(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단계 상세 설계

서비스 탐색

  • 클라이언트에게 가장 적합한 채팅 서버를 추천하는 것.
  • 기준(위치, 서버의 용량)
  • 서비스 탐색 기능을 구현하는데 널리 쓰이는 오픈소스 솔루션은 아파치 주키퍼
  • 주키퍼에 다량 서버를 등록해놓고, 사전에 정한 기준에 따라 최적의 채팅 서버를 골라줌.

  1. 사용자 A는 채팅 서버 1로 메시지 전송
  2. 채팅 서버 1은 ID 생성기를 사용해 해당 메시지의 ID 결정
  3. 채팅 서버 1은 해당 메시지를 메시지 동기화 큐로 전송
  4. 메시지가 키-값 저장소에 보관됨
  5. 사용자 B가 접속 중인 경우, 메시지는 사용자 B가 접속 중인 채팅 서버로 전송됨. 사용자 B가 접속 중이 아니라면, 푸시 알림 메시지를 푸시 알림 서버로 보냄.
  6. 채팅 서버 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)는 메시지 안정적 전송을 보장하기 위해 흔히 사용되는 기법.