가상면접 사례로 배우는 시스템 설계 시리즈 15편
개요
REST API 설계에 자신감이 붙은 백엔드 3~5년 차 엔지니어라면 이런 순간을 만난다. 면접관이 말한다. "카카오톡 같은 채팅 시스템을 설계해 보세요. 동시 접속 수백만 명." 평소에는 무상태(Stateless) 세계에서 살던 엔지니어는 여기서 당황한다. 무상태를 버리는 게 미덕이던 환경에서, 갑자기 수백만 개의 TCP 커넥션을 메모리에 꽉 쥐고 절대 놓지 않는 상태 유지의 늪으로 들어가야 하기 때문이다.
채팅의 본질을 한 문장으로 말하면 이렇다. 상대방이 메시지를 보냈을 때, 그 메시지는 서버가 먼저 말을 걸어서 내 화면에 떠야 한다. 그런데 HTTP는 태생적으로 클라이언트가 먼저 입을 열어야만 응답할 수 있는 단방향 프로토콜이다. 이 모순을 어떻게 우회하고, 결국 어떤 구조로 극복하는지가 이 장의 전체 내용이다. 이 글은 HTTP의 한계에서 출발해 폴링·롱 폴링·웹소켓의 진화를 거쳐, 메시지 영속화와 온라인 상태 관리까지 따라간다.
1. 문제 이해 및 설계 범위 확정
면접관의 프롬프트는 "카카오톡 같은 채팅 시스템을 설계해 보세요"다. 그리고 곧바로 규모가 따라붙는다. "동시 접속 수백만 명." 범위를 좁히지 않고 달려들면, 1:1만 원했는지 그룹까지 원했는지도 모른 채 웹소켓부터 그리게 된다.
| 확인할 것 | 확정 |
|---|---|
| 1:1인가, 그룹인가? | 둘 다. 그룹은 최대 100명 |
| 메시지 크기 제한은? | 텍스트 위주, 최대 100,000자 |
| 지원 기기는? | 모바일과 웹 앱 모두 |
| 규모는? | 일간 능동 사용자(DAU) 5천만 명 |
| 다중 기기 로그인을 지원하는가? | 지원한다. 한 계정이 여러 기기에 동시에 붙는다 |
| 온라인 상태 표시가 필요한가? | 필요하다(프레즌스) |
| 오프라인 수신자에게는? | 푸시 알림으로 대체한다 |
| 메시지 이력은? | 영속 저장하고 재조회할 수 있다 |
이 대화가 확정한 요구사항은 다음과 같다.
| 구분 | 요구사항 |
|---|---|
| 기능 | 1:1 채팅 · 그룹 채팅(≤100명) · 온라인 상태 · 다중 기기 · 푸시 알림 |
| 지연 | 채팅은 저지연이 핵심이다 |
| 영속성 | 메시지를 저장하고 이력으로 재조회할 수 있다 |
| 순서 | 같은 대화에서 메시지 순서가 보장된다 |
| 규모 | DAU 5천만 명 |
범위 밖(OUT OF SCOPE): 그룹 통화·화상, 파일 첨부의 대용량 처리, 메시지 검색·번역. 이 글은 텍스트 기반 실시간 채팅과 그 연결 관리에 집중한다.
비유: 수백만 개의 전화선을 한 건물에서 관리하는 교환원과 같다. 통화 내용보다 어려운 건, 끊기지 않게 선을 물고 있는 일(연결 유지)과 상대가 지금 받을 수 있는지 아는 일(프레즌스)이다.
2. HTTP의 태생적 한계와 세 번의 시도
HTTP가 실시간 채팅에 무너지는 이유는 단방향성이다. 서버는 클라이언트가 요청하지 않는 한 스스로 말을 걸 수 없다. 이 한계를 우회하기 위한 시도가 시대순으로 세 번 있었다.
| 방식 | 동작 | 한계 |
|---|---|---|
| 폴링(Polling) | 클라이언트가 주기적으로 "새 메시지 있어?"를 요청 | 무의미한 왕복 + TCP 핸드셰이크 반복 + 헤더가 본문보다 큼 + 99% 빈 응답을 처리하는 스레드 낭비 |
| 롱 폴링(Long Polling) | 서버가 응답을 일정 시간 쥐고 있다가 메시지가 오면 반환, 이후 재연결 | sticky session 필요(송신·수신 서버가 달라질 수 있음) + 대기 커넥션마다 스레드·파일 디스크립터를 잡아먹어 커넥션 고갈 |
| 웹소켓(WebSocket) | 핸드셰이크 후 양방향 상시 연결, 서버가 능동 push | 연결 상태 관리 자체가 곧 아키텍처가 됨 |
폴링의 문제는 직관적이다. 경비실 문을 10분마다 두드려 "저 아직 살아있나요?"를 확인하는 것과 같아서, 네트워크와 CPU 모두에 부하를 준다.
롱 폴링은 개선처럼 보이지만 두 개의 벽에 부딪힌다. 첫째, 로드 밸런서가 라운드 로빈으로 트래픽을 나누면 메시지를 보낸 사용자가 붙은 서버와 메시지를 받을 사용자가 대기 중인 서버가 달라질 확률이 거의 100%에 가깝다. 이걸 맞추려면 특정 사용자를 항상 특정 서버로 보내는 고정 세션(Sticky Session)이 필요한데, 이는 스케일 아웃의 고질적 병목이 된다. 둘째, 대기 중인 커넥션 하나하나가 서버의 스레드와 파일 디스크립터를 잡고 있어서, 메시지가 오지도 않았는데 서버 메모리가 꽉 찬다.
결론은 명확하다. 무상태로 설계된 HTTP 프로토콜 틀 안에서 상태 유지를 억지로 흉내 내는 것은 한계가 있다. 그래서 프로토콜 자체를 바꾼다.
3. 웹소켓: 항상 열려 있는 전화선
웹소켓은 최초 연결 시에만 HTTP를 빌린다. 클라이언트가 요청 헤더에 Upgrade: websocket을 달아 보내면, 서버가 이를 수락하는 순간 그 연결은 영구적인 양방향 통신 채널로 변모한다. 80번·443번 포트를 그대로 쓰므로 까다로운 기업망 방화벽도 우회할 수 있다.
핵심 변화는 두 가지다. 더 이상 매 요청에 무거운 HTTP 헤더를 달 필요가 없고, 서버가 먼저 말을 걸 수 있다. 채팅에 필요한 조건이 정확히 이 두 가지다.
비유: 매번 전화를 걸었다 끊는 대신, 전화선 하나를 계속 물고 있는 것과 같다. 건너편에서 아무 때나 말을 걸 수 있고, 우리도 언제든 응답할 수 있다.
하지만 이 시점에서 진짜 문제가 시작된다. 수백만 명이 웹소켓 파이프를 뚫고 나가지 않으면, 기존의 무상태 웹 서버 구조로는 절대 감당할 수 없다. 연결을 유지한다는 것 자체가 곧 시스템의 아키텍처가 되는 순간이다.
4. 개략 설계: 연결 관리와 무상태 서비스의 분리
채팅 시스템의 개략 설계는 크게 두 개의 클러스터로 시작한다.
| 클러스터 | 담당 | 성격 |
|---|---|---|
| 무상태 API 서버 | 로그인, 회원가입, 프로필 변경, 검색 등 | 기존처럼 스케일 아웃 — 짧은 요청만 처리 |
| 상태유지 채팅 서버 | 클라이언트와의 웹소켓 연결 유지 | 연결을 끊지 않는 전용 서비스 |
역할이 나뉘면 라우팅 딜레마가 생긴다. 수천 대의 채팅 서버 중 클라이언트는 어디에 붙어야 하는가. 무상태 서버처럼 아무 데나 트래픽을 던지면 세션이 날아간다. 여기에 서비스 디스커버리(Service Discovery)가 투입된다.
[클라이언트] → [무상태 API 서버] → [서비스 디스커버리(예: ZooKeeper): "15번 서버가 여유 있음"]
↓ IP 반환
[클라이언트] ───── WebSocket 직접 연결 ─────→ [15번 채팅 서버]서비스 디스커버리는 살아있는 서버 목록, 각 서버의 커넥션 수, 부하 상태를 실시간으로 추적하고 "가장 쾌적한 서버"의 IP를 클라이언트에 알려준다. 클라이언트가 그 IP로 직접 웹소켓을 맺는다. 서버가 죽으면 디스커버리가 목록에서 빼고, 클라이언트는 재접속을 시도한다.
이 구조가 중요한 이유는 확장의 단위가 달라지기 때문이다. 무상태 서비스는 "요청 수"에 맞춰 늘리고 줄이면 되지만, 채팅 서버는 "연결 수"에 맞춰 자원을 잡아야 한다. 연결은 요청처럼 짧게 끝나지 않고 계속 살아 있으므로, 연결을 맺은 뒤의 실제 비즈니스 로직(메시지 저장·팬아웃·푸시)은 무상태 서비스와 큐가 나눠 맡는다. 채팅 서버는 "수백만 개의 파이프를 관리하는 전용 하드웨어"에 가깝고, 그 뒤의 모든 판단은 밖으로 위임된다.
이제 메시지가 흐르는 경로를 그린다. 채팅 서버에 도착한 메시지는 스노우플레이크류의 고유·정렬 가능한 메시지 ID를 부여받은 뒤, 메시지 큐를 거쳐 두 갈래로 나뉜다. 하나는 DB 저장 경로, 다른 하나는 수신자 배달 경로다.
[사용자 A] → [15번 채팅 서버] → [메시지 큐]
├→ [영속 저장소] (유실 방지)
└→ [수신자 배달]
├─ 온라인 → 수신자의 채팅 서버로 직접 전달
└─ 오프라인 → 푸시 알림 서버로 위임5. 메시지 영속화와 팬아웃: 유실은 허용되지 않는다
채팅 메시지는 결제 기록만큼 유실되면 안 되는 데이터다. 그래서 메시지는 캐시와 DB에 영속화한다. 최근 메시지는 읽기 성능을 위해 캐시에 두고, 전체 이력은 DB에 저장한다. 저장소 선택도 트래픽 패턴이 결정한다. 메시지의 읽기는 최신 메시지에 극단적으로 몰리고 오래된 메시지는 거의 읽히지 않는 롱테일(Long-tail) 패턴이다. 이 패턴에서 RDBMS는 인덱스가 깊어지고 랜덤 I/O가 폭발하므로, 메모리에 모았다가 디스크에 순차 쓰는 방식의 키-값/와이드 컬럼 저장소가 정석으로 꼽힌다.
메시지 저장과 전달을 하나로 합친 것이 팬아웃이다. 여기서 1:1과 그룹의 차이가 갈린다.
| 구분 | 팬아웃 방식 | 비용 |
|---|---|---|
| 1:1 채팅 | 발신자 큐 → 수신자 저장소에 직접 저장 | 저장 1건 + 전달 1건 — 단순 |
| 그룹 채팅 | 수신자 수만큼 메시지를 각 수신자의 큐·저장소에 복사 | 그룹 인원 N만큼 쓰기 N건 |
그룹 채팅은 수신자 입장에서는 자기 큐만 쳐다보면 되도록 복잡성을 줄이는 대신, 발신 쪽에서 데이터를 마구 복제하는 비용을 지불한다. 실제 서비스가 그룹 인원에 상한을 두는 것도 이 비용 때문으로, 채팅 앱의 그룹 인원 제한 사례 가 늘 인용된다.
메시지 저장은 "한 번 쓰면 거의 수정되지 않는" 데이터라는 점도 설계를 단순하게 만든다. 대화 이력은 추가만 되고 덮어쓰기가 드물어, 쓰기와 읽기가 모두 최신 메시지에 몰리는 패턴을 그대로 따른다. 그래서 저장소는 최근 메시지를 메모리 캐시에 두어 즉시 읽히게 하고, 오래된 메시지는 순차 쓰기에 유리한 저장소로 밀어낸다. 또 유실 방지 측면에서는, 메시지가 큐에 들어간 뒤 DB 저장이 끝나기 전에 서버가 죽는 경우를 대비해 발신자에게 저장 완료를 확인해 주는 절차(수신 확인)를 설계에 포함한다.
6. 온라인 상태와 확장: 상태를 판정하고 연결을 늘리는 법
온라인 상태(Presence)
"상대방이 지금 온라인인가"는 웹소켓 연결 여부로는 정확히 판정할 수 없다. 모바일은 네트워크가 순간적으로 끊기고 다시 붙기를 반복하기 때문이다. 그래서 표준 설계는 두 신호를 조합한다. 웹소켓 연결 상태 + 최근 활동 타임스탬프. 클라이언트가 하트비트(주기적 신호)를 보내면 서버는 마지막 수신 시각을 갱신하고, 일정 유예 기간(예: 수십 초)이 지나도록 신호가 없으면 오프라인으로 판정한다. 네트워크 단절이라는 일시적 노이즈를 유예 시간이라는 버퍼로 흡수하는 것이다.
온라인 상태를 정확히 유지하는 것만으로는 부족하다. 변화를 친구들에게 알려야 하기 때문이다. 표준 방식은 발행-구독(Pub/Sub)으로, 상태가 바뀌면 친구들의 채널에 이벤트를 발행한다. 그런데 이 방식에는 한계가 있다. 수만~십만 명이 있는 대형 방에서 누군가 상태가 바뀔 때마다 그 수만큼 이벤트가 터지면 시스템이 이벤트 폭풍에 휩싸인다. 실전에서는 완벽한 실시간을 포기하고 타협한다. 방에 입장할 때 한 번 상태를 일괄 읽거나, 친구 목록을 수동으로 새로 고칠 때만 갱신하는 식이다.
확장: 파일 디스크립터의 벽
상태 유지 서비스의 확장에는 물리적 벽이 하나 있다. 하나의 프로세스가 열 수 있는 연결 수는 POSIX 파일 디스크립터(FD) 한계에 묶여 있다. 리눅스 기본 소프트 리밋은 수천 개 수준이고, 상향 조정해도 서버 한 대가 쥘 수 있는 커넥션은 수만 개가 현실적인 한계다. 수백만 연결은 어림잡아도 수십~수백 대의 상태유지 서버가 필요하다는 뜻이다. 그래서 채팅 서버는 "연결을 관리하는 일"에 전문화되고, 그 위에서 메시지 큐가 서버 간 상태 전파와 팬아웃을 담당하는 구조가 확장의 정석이 된다.
참고 자료
- Alex Xu, 『가상면접 사례로 배우는 대규모 시스템 설계 기초』(System Design Interview – An Insider's Guide), Design Gurus·ByteByteGo, 2021 — 12장 채팅 시스템 설계
- IETF, RFC 6455 "The WebSocket Protocol", 2011
다음 편 예고
다음 편은 검색어 자동완성 설계(16편) — "검색창에 '시'를 치면 '시스템 설계'가 뜨는" 트라이(Trie) 기반 시스템을 다룬다. 채팅이 상태 유지의 늪이었다면, 자동완성은 데이터 수집과 쿼리 서비스의 분리, 그리고 에지 캐시로 로딩 속도를 사는 문제다. 실시간 전달을 버틴 설계가, 이번에는 "첫 글자 하나를 치는 순간"의 응답 속도 앞에서 다시 한번 최적화의 압박을 받게 된다.
7. 실무에서 자주 만나는 함정
채팅 시스템은 "실시간"이라는 단어 한 마디에 숨어 있는 함정이 많다.
| 함정 | 증상 | 회피법 |
|---|---|---|
| 웹소켓 단일 노드 함정 | 한 노드에 10만 연결이 쌓여 메모리·FD 한계 초과 | 무상태 채팅 게이트웨이 + 메시지 라우팅 서비스를 분리, 연결은 게이트웨이에만 상주 |
| 메시지 순서 뒤바뀜 | 같은 채널에서 A→B→C가 B→A→C로 도착 | 채널/사용자 단위 순서 보장 키(partition key)로 메시지 큐 파티셔닝 + 단조 증가 시퀀스 번호 |
| 그룹 채팅 팬아웃 폭탄 | 10만 명 그룹에서 한 메시지 10만 번 복사 → 큐 적체 | 수신자 풀 사전 분산(사용자별 inbox 테이블) + 큐 대신 inbox에 한 번만 쓰기 |
| 온라인 표시(Presence) 비용 폭주 | 모든 사용자의 접속 상태를 메모리에 유지 → 메모리·네트워크 폭발 | 샤딩된 Presence 서비스 + 하트비트 주기 조정(예: 30초). 강한 일관성보다 최종 일관성으로 |
| 연결 끊김 재접속 폭주 | 네트워크 일시 장애 후 수만 명이 동시 재접속 → 핸드셰이크 폭탄 | 지수 백오프 재접속 + 서버 측 Jitter(랜덤 지연)로 동시 재접속 분산 |
면접에서 깊이를 더하는 질문: "메시지 유실 방지"가 나오면 메시지 큐에 영속화(Disk-backed)한 뒤 ACK 패턴, "온라인 표시의 일관성"이 나오면 Presence는 강한 일관성이 필요 없다는 점을 짚는 게 차이를 만든다.
8. 30초 면접 답변 예시
"채팅은 본질적으로 상태 유지(상시 연결) 시스템이라 HTTP로는 한계가 있어 웹소켓을 씁니다. 구성은 무상태 채팅 게이트웨이 + 메시지 라우팅/팬아웃 서비스 + 영구 저장소로 분리합니다. 1:1은 수신자 저장소에 직접 쓰고, 그룹은 사용자별 inbox 테이블에 한 번씩만 써서 팬아웃 부하를 줄입니다. 메시지 순서는 채널 단위 partition key로 보장하고, 온라인 표시는 Presence 서비스를 샤딩해 하트비트로 관리합니다. 핵심 트레이드오프는 메시지 유실 방지 vs 지연이며, 보통 큐 영속화 + ACK 패턴으로 유실을 막습니다."
'CS > 시스템 설계' 카테고리의 다른 글
| 지표 모니터링 및 경보 시스템 설계 : Prometheus · Datadog, 시계열 데이터와 Gorilla 압축 (0) | 2026.10.14 |
|---|---|
| Distributed Message Queue 설계 : Kafka · RabbitMQ · RocketMQ (0) | 2026.10.14 |
| 뉴스피드 시스템 설계, 0.1초의 화면 뒤에 숨은 팬아웃 전쟁 (0) | 2026.10.14 |
| 한 서버의 메모리로는 부족하다, 분산 키-값 저장소(Key-Value Store) 설계 (0) | 2026.10.14 |
| 서버 한 대를 추가했더니 캐시가 통째로 사라졌다, 안정 해시(Consistent Hashing) 설계 (0) | 2026.10.14 |
댓글