채팅 시스템 설계 — 실시간성과 순서 보장을 동시에
HTTP 폴링으로는 시작조차 할 수 없다
채팅 시스템 설계에서 첫 번째로 나와야 하는 결정은 클라이언트가 새 메시지를 어떻게 받는가입니다. 클라이언트가 몇 초마다 "새 메시지 있어?"라고 물어보는 폴링(polling)은 구현이 가장 쉽지만, 실시간성을 포기하는 대가로 얻는 게 별로 없습니다. 사용자 수만큼 불필요한 요청이 계속 나가고, 그럼에도 메시지는 몇 초씩 늦게 도착합니다. 실시간 채팅이라는 요구사항 자체와 상충하므로, 이 문제에서 폴링은 초반에 언급하고 바로 기각하는 게 맞습니다.
실제로 쓰는 건 WebSocket입니다. 클라이언트와 서버가 한 번 연결을 맺으면 그 연결을 계속 유지하면서 양방향으로 메시지를 주고받습니다. 이 선택이 이후 설계 전체의 성격을 바꿉니다. HTTP 요청-응답 모델과 달리, WebSocket 연결은 오래 유지되는 상태를 서버가 붙들고 있어야 합니다. 앞서 로드밸런싱 챕터에서 강조한 "서버는 무상태로"라는 원칙이 여기서는 정면으로 깨집니다.
연결 상태를 어디에 둘 것인가
사용자 A가 서버 3번에 WebSocket으로 연결돼 있습니다. 사용자 B가 서버 7번에서 A에게 메시지를 보냅니다. 서버 7번은 A가 지금 어느 서버에 연결돼 있는지 알아야 그 서버로 메시지를 전달할 수 있습니다. 이 "누가 어디에 연결돼 있는가"라는 매핑이 채팅 시스템 설계의 핵심 상태입니다.
sequenceDiagram participant B as 사용자 B participant S7 as 서버 7 participant Registry as 연결 레지스트리 (Redis) participant S3 as 서버 3 participant A as 사용자 A B->>S7: "A에게 메시지 전송" S7->>Registry: A가 연결된 서버 조회 Registry-->>S7: "서버 3" S7->>S3: 메시지 전달 (내부 라우팅) S3->>A: WebSocket으로 푸시
이 매핑을 Redis 같은 공유 저장소에 두면, 어떤 서버로든 A가 연결되는 즉시 그 정보를 갱신하고, 어떤 서버든 그 정보를 조회할 수 있습니다. 서버 간 메시지 전달은 내부 메시지 브로커(Kafka나 Redis Pub/Sub)를 거치는 경우가 많습니다. "서버 7이 서버 3에게 직접 TCP 연결을 열어 전달한다"는 것도 가능하지만, 서버 수가 늘어날수록 서버 간 연결 개수가 N² 로 늘어나므로 실무에서는 중앙 브로커를 거치는 편이 관리하기 쉽습니다.
메시지 순서 보장이 왜 어려운가
그룹 채팅에서 "메시지가 순서대로 도착해야 한다"는 요구사항은 말은 쉽지만 분산 환경에서는 은근히 까다롭습니다. 여러 서버가 동시에 메시지를 처리하면, 네트워크 지연 차이로 인해 실제로 먼저 보낸 메시지가 나중에 도착할 수 있습니다. 이 문제를 푸는 일반적인 방법은 메시지에 서버 타임스탬프가 아니라 논리적 순서(예: 대화방마다 증가하는 시퀀스 번호)를 부여하는 겁니다.
class ConversationSequencer:
def __init__(self):
self.sequences = {} # conversation_id -> last_sequence
def next_sequence(self, conversation_id):
current = self.sequences.get(conversation_id, 0) + 1
self.sequences[conversation_id] = current
return current이 시퀀서를 대화방 하나에 대해서는 반드시 한 곳(단일 소스)에서만 발급해야 순서가 깨지지 않습니다. 여러 서버가 각자 시퀀스 번호를 매기면 이 보장이 무너집니다. 그래서 실무에서는 대화방 ID를 기준으로 특정 파티션(또는 특정 서버)에 라우팅해서, 같은 대화방의 메시지는 항상 같은 곳에서 순서가 매겨지도록 설계합니다. 이건 데이터베이스 샤딩 챕터에서 다룬 "샤드 키를 뭘로 잡을지"와 본질적으로 같은 결정입니다 — 여기서는 대화방 ID가 그 역할을 합니다.
오프라인 사용자에게는 메시지를 어떻게 전달하는가
사용자가 앱을 꺼둔 상태라면 WebSocket 연결 자체가 없습니다. 이럴 때는 메시지를 DB에 저장해두고, 사용자가 다시 접속하면 그 시점부터 못 받은 메시지를 한꺼번에 내려줍니다. 이 지점에서 "메시지를 영구 저장할 것인가"라는 앞선 요구사항 정리 질문이 다시 중요해집니다. 저장한다면 메시지 테이블은 쓰기가 매우 잦고 대화방별로 조회되는 패턴이라, 대화방 ID를 파티션 키로 쓰는 샤딩이 자연스러운 다음 단계입니다. 채팅 시스템은 이렇게 앞선 챕터들의 개념 — 캐싱, 샤딩, 메시지 큐, 일관성 모델 — 이 한 문제 안에 전부 얽혀 있는 좋은 예입니다. 어느 하나만 잘 안다고 풀리지 않고, 이 개념들이 서로 어떻게 맞물리는지를 보여줘야 좋은 답이 됩니다.