
설계에 앞서 다음 사항을 먼저 확인해야 함.
어떤 앱을 설계해야 하는가
처리해야 하는 트래픽 규모는 어느 정도인가(DAU)
그룹 채팅의 참여 인원 제한은 몇 명인가
어떤 기능이 필요한가
메시지 길이 제한은 어떻게 되는가
종단 간 암호화를 지원하는가
채팅 이력을 얼마나 보관하는가
어떤 통신 프로토콜을 사용할 것인가
클라이언트끼리 직접 통신하는 것이 아니라, 위의 기능을 모두 지원하는 채팅 서비스를 거쳐 통신함.
채팅 서비스는 다음 기능을 제공해야 함.

송신 클라이언트에서 채팅 서비스로 메시지를 보낼 때는 HTTP 프로토콜을 사용함.
이때 keep-alive 헤더를 사용하는 것이 효율적임.
채팅 서비스가 수신 클라이언트에게 메시지를 보낼 때는 일반 HTTP를 사용할 수 없음.
HTTP는 클라이언트가 서버에 요청하는 방식이지, 서버가 클라이언트에게 먼저 보내는 방식이 아니기 때문임.
이를 해결하기 위한 기법으로 폴링, 롱 폴링, 웹소켓이 있음.
폴링, 롱 폴링
→ HTTP 프로토콜 위에서 동작
웹소켓
→ HTTP로 연결을 시작한 뒤 다른 프로토콜로 전환
클라이언트가 주기적으로 서버에 새 메시지가 있는지 물어보는 방법임.
폴링을 변형한 방법으로, 새 메시지가 반환되거나 타임아웃될 때까지 연결을 유지함.
클라이언트는 새 메시지를 받으면 기존 연결을 종료하고 새 요청을 보내어 모든 절차를 다시 시작함.
단점:
HTTP 서버는 보통 무상태이므로, 라운드로빈 방식으로 로드밸런싱하면 송신 클라이언트와 수신 클라이언트가 서로 다른 채팅 서버에 접속할 수 있음
서버 입장에서 클라이언트가 연결을 해제했는지 알아낼 좋은 방법이 없음
메시지를 많이 받지 않는 클라이언트도 타임아웃마다 주기적으로 접속하므로 여전히 비효율적임
서버가 클라이언트에게 비동기 메시지를 보낼 때 가장 널리 사용하는 기술임.
연결이 한 번 맺어지면 항구적으로 유지됨
80번(HTTP), 443번(HTTPS) 포트를 그대로 사용하므로 방화벽이 있는 환경에서도 잘 동작함
ws://, wss://를 사용함
폴링
→ 요청 → 없음 → 대기 → 요청 → 없음 ...
→ 빈 응답이 대부분, 자원 낭비
롱 폴링
→ 요청 → (메시지 올 때까지 대기) → 응답 → 재요청
→ 빈 응답은 줄지만 재연결 반복
웹소켓
→ 연결 1회 → 양방향 송수신 지속
→ 서버가 먼저 보낼 수 있음
웹소켓이 모든 상황에서 우월한 것은 아니며, 다음과 같은 비용이 따름.
서버가 상태를 갖게 됨
유휴 연결도 자원을 소모함
재배포와 장애에 취약함
HTTP의 이점을 잃음
신뢰성을 직접 구현해야 함
HTTP
→ 무상태 / 확장 쉬움 / 서버가 먼저 못 보냄
웹소켓
→ 상태 유지 / 확장 복잡 / 실시간 양방향
즉 실시간 양방향 통신이 필요한 구간에만 웹소켓을 쓰고, 나머지는 일반 HTTP로 처리하는 것이 적절함.
채팅 시스템은 크게 세 부분으로 나눌 수 있음.
로그인, 회원가입, 사용자 프로파일 표시 등을 처리하는 전통적인 요청/응답 서비스임.
많은 웹사이트와 앱이 보편적으로 제공하는 기능에 해당함.
각 클라이언트가 채팅 서버와 독립적인 네트워크 연결을 유지해야 하므로 필요함.
서버가 살아 있는 한 다른 서버로 연결을 변경하지 않음.
앱이 실행 중이지 않더라도 알림을 받아야 하므로 푸시 알림 서비스와의 통합이 중요함.

채팅 서버
접속상태 서버
API 서버
알림 서버
키-값 저장소
RDB와 NoSQL 중 무엇을 쓸지 결정하려면 데이터의 유형과 읽기/쓰기 연산 패턴을 함께 고려해야 함.
일반적인 데이터
사용자 프로파일, 설정, 친구 목록 등이 해당함.
채팅 이력
원래는 통계 그래프의 모양을 가리키는 용어임.

머리(head)
꼬리(tail)
채팅 시스템에서는 채팅방 하나에 쌓이는 메시지 개수의 분포를 가리킴.
대부분의 1:1 대화, 소규모 채팅방
→ 메시지 수십~수백 개
극소수의 대규모 그룹, 활성 커뮤니티
→ 메시지 수백만 개
인덱스가 커질수록 무작위 접근 비용이 증가함
핫스팟 문제
chat_id 같은 키로 샤딩하면 이 소수의 채팅방 때문에 특정 샤드에만 부하가 집중됨
메시지 ID를 기본 키로 사용하면서 메시지 순서를 정하는 역할도 맡김.
작성 일시는 서로 다른 메시지가 동시에 생성될 수 있어 순서를 정할 수 없는 경우가 있기 때문임.

채널 ID와 메시지 ID를 기본 키로 사용함.
채널은 채팅 그룹과 같은 뜻이며, 메시지 ID는 파티션 키로도 사용함.
메시지 ID가 만족해야 할 조건은 다음과 같음.
auto_increment를 쓰면 간단히 해결될 것 같지만, NoSQL에서는 보통 해당 기능을 제공하지 않음.
따라서 다음 방법을 사용함.
스노플레이크 같은 전역적 순서 번호 생성기
지역적 순서 번호 생성기
클라이언트에게 가장 적합한 채팅 서버를 추천하는 역할임.
기준으로는 클라이언트의 위치, 서버의 용량 등을 사용함.
오픈소스 솔루션으로는 아파치 주키퍼가 널리 쓰임.


한 사용자가 여러 단말을 사용하는 경우, 각 단말은 저마다 독립적인 웹소켓 연결을 유지함.
따라서 어떤 단말에서 접속하더라도 동일한 메시지를 빠짐없이 받아야 함.
이를 위해 각 단말은 자신이 마지막으로 확인한 메시지의 ID를 보관함.
단말이 보관하는 값
→ cur_max_message_id
새 메시지 판별 조건
→ 수신자가 나이고
→ message_id > cur_max_message_id
message_id가 시간 순서와 일치하는 값이므로, 이 조건만으로 각 단말이 놓친 메시지를 판별할 수 있음.
단말 A: cur_max_message_id = 1005
단말 B: cur_max_message_id = 1002
→ 단말 B는 1003~1005를 추가로 내려받아 동기화
즉 서버가 단말별 상태를 일일이 관리하지 않아도, 단말이 보관한 값 하나로 동기화가 성립함.
그룹 채팅에서는 메시지를 그룹 구성원 각각의 메시지 동기화 큐에 사본으로 넣음.
사용자 A가 메시지 전송
→ A를 제외한 구성원 각각의 큐에 사본 저장
→ 각 사용자는 자기 큐만 확인
이 방식의 장점은 다음과 같음.
수신자 관점
→ 큐 1개
→ 발신자가 누구든 동일하게 처리
다만 사본의 개수가 그룹 크기에 비례하므로, 인원이 매우 많은 그룹에는 적합하지 않음.
이 경우에는 사본을 만들지 않고 채널 단위로 메시지를 보관한 뒤 읽는 시점에 조회하는 방식을 사용함.
사용자가 로그인하여 웹소켓 연결이 맺어지면, 접속상태 서버는 해당 사용자의 상태와 last_active_at 타임스탬프를 키-값 저장소에 보관함.
이 절차가 끝나면 접속 중으로 표시됨.
클라이언트가 보내는 Heartbeat Event를 사용하여 접속 상태를 판단함.

매우 짧은 시간 동안 발생한 접속 장애를 모두 반영해 상태를 변경하면 지나칠 뿐 아니라 UX에도 좋지 않기 때문임.
발행-구독 모델을 사용하여 접속 상태 변경을 알림.
다만 이 방법은 소규모 그룹에서만 효과적이고, 대규모 그룹에서는 비용과 시간이 많이 듦.
따라서 대규모 그룹에서는 다음과 같은 방법을 사용함.
채팅 시스템의 전체 구조는 다음과 같음.
로그인, 회원가입, 프로파일
→ API 서버(무상태)
메시지 송수신
→ 채팅 서버(웹소켓, 상태 유지)
→ 메시지 동기화 큐
→ 키-값 저장소
접속 상태
→ 접속상태 서버 + 발행-구독
앱 미실행 시
→ 알림 서버 → 푸시 알림
핵심은 다음과 같음.
message_id는 고유하면서 시간 순서와 일치해야 하며, 이 값 하나로 다중 단말 동기화가 가능함