채팅 시스템 설계 — 카카오톡을 직접 만들어보자
한 줄 요약: 채팅 시스템의 핵심은 WebSocket으로 양방향 실시간 연결을 유지하고, Kafka로 메시지를 라우팅하며, HBase로 수 페타바이트 메시지를 저장하는 것이다.
실제 문제: 카카오톡 5억 명이 동시에 채팅하면?
2022년 카카오 데이터센터 화재로 카카오톡이 127시간 부분 마비됐습니다. 국내 MAU 4700만 명이 한꺼번에 접근하는 서비스가 단일 데이터센터에 의존하고 있었던 것입니다. 이 사건은 채팅 시스템 설계에서 고가용성과 멀티 데이터센터가 얼마나 중요한지를 극명하게 보여줬습니다.
채팅 시스템은 단순해 보이지만, 내부적으로 수많은 복잡한 문제를 해결해야 합니다:
- 실시간 양방향 통신 (HTTP는 단방향)
- 초당 70만 건의 메시지 처리
- 메시지 순서 보장 (동시 전송 시)
- 오프라인 사용자에게 Push 알림
- 수 페타바이트의 메시지 영구 저장
1. 요구사항 분석 및 규모 추정
기능 요구사항
- 1:1 채팅 (실시간)
- 그룹 채팅 (최대 100명)
- 온라인/오프라인 상태 표시 (Presence)
- 메시지 전송 확인 (1체크: 전송됨, 2체크: 읽음)
- 미디어 파일 전송 (이미지, 동영상, 문서)
- 앱 백그라운드 시 푸시 알림
비기능 요구사항
- 지연시간: 메시지 전달 100ms 미만 (P99)
- 가용성: 99.99% (연간 52분 이하 다운타임)
- 일관성: 메시지 순서 보장, 유실 없음
- 내구성: 메시지 영구 저장 (최소 5년)
규모 추정
DAU: 5억명
메시지/일: 5억 × 40개 = 200억건
메시지 QPS = 200억 / 86,400 ≈ 231,000 QPS
피크 QPS ≈ 700,000 QPS (피크는 평균의 3배)
메시지 크기: 텍스트 평균 100B
일일 저장: 200억 × 100B = 2TB/일
5년 저장: 2TB × 365 × 5 ≈ 3.65PB (미디어 제외)
미디어 포함: × 50배 = 182PB
WebSocket 연결 수: DAU의 30% 동시 접속 = 1억 5천만 연결
서버당 WebSocket 연결: 최대 10만개
필요 서버: 1억5천만 / 10만 = 1,500대
2. 핵심 기술: WebSocket
HTTP Polling vs Long Polling vs WebSocket 비교
기존 HTTP는 우체국 방식입니다. 내가 편지를 보내야(요청) 답장이 옵니다(응답). 하지만 채팅은 전화 방식이어야 합니다. 상대방이 말하면 즉시 내 귀에 들려야 합니다.
sequenceDiagram
participant C as 클라이언트
participant S as 서버
Note over C,S: 1️⃣ Short Polling (매 3초마다 확인 - 낭비 심함)
loop 매 3초
C->>S: 새 메시지 있어? (GET /messages)
S-->>C: 없음 (200 OK, empty)
end
Note over C,S: 2️⃣ Long Polling (서버가 메시지 올 때까지 대기)
C->>S: 새 메시지 있어? (30초 타임아웃)
Note over S: 28초 대기...
S-->>C: 메시지 왔어! [데이터]
C->>S: 다시 대기 (즉시 재연결)
Note over C,S: 3️⃣ WebSocket (진짜 양방향 - 채팅에 적합)
C->>S: HTTP Upgrade 요청
S-->>C: 101 Switching Protocols
Note over C,S: 영구 연결 유지 (heartbeat ping/pong)
S->>C: 새 메시지! [데이터] (서버가 먼저 전송)
C->>S: 메시지 전송 [데이터]
S->>C: 또 다른 메시지!
| 방식 | 지연시간 | 서버 부하 | 실시간성 | 사용 케이스 |
|---|---|---|---|---|
| Short Polling | 최대 3초 | 매우 높음 (폴링 요청) | 낮음 | 거의 사용 안 함 |
| Long Polling | 수백ms | 높음 (연결 유지) | 중간 | 구형 채팅, 알림 |
| WebSocket | 수십ms | 낮음 (연결당 메모리만) | 높음 | 채팅, 게임, 실시간 |
| SSE | 수십ms | 낮음 | 서버→클라이언트만 | 알림, 피드 업데이트 |
WebSocket 핸드셰이크 상세
# 클라이언트 → 서버 (HTTP Upgrade)
GET /chat HTTP/1.1
Host: chat.kakao.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
# 서버 → 클라이언트 (프로토콜 전환 완료)
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
# 이후 HTTP가 아닌 WebSocket 프레임으로 통신
왜 이게 중요한가?
WebSocket 연결은 TCP 연결을 유지하므로, 서버 당 동시 연결 수가 핵심 병목입니다. Linux 기본 설정에서 파일 디스크립터 제한이 1024개이므로, /etc/security/limits.conf에서 nofile=1000000으로 늘려야 합니다. 또한 로드밸런서가 L7이 아닌 L4 TCP여야 WebSocket 연결이 끊기지 않습니다.
3. 전체 아키텍처
graph TD
subgraph "클라이언트 계층"
Mobile["모바일 앱\niOS/Android"]
Web["웹 브라우저"]
end
subgraph "로드밸런서 계층"
LB["1️⃣ L4 TCP 로드밸런서\nWebSocket 친화적"]
end
subgraph "채팅 서버 계층"
CS1["2️⃣ 채팅 서버 1\n(WebSocket 처리)"]
CS2["2️⃣ 채팅 서버 2"]
CS3["2️⃣ 채팅 서버 N\n(1500대)"]
end
subgraph "메시지 계층"
MQ["3️⃣ Kafka 클러스터\n(메시지 라우팅)"]
MsgWorker["메시지 처리 워커"]
MsgDB[("4️⃣ HBase\n메시지 영구 저장\n(3.65PB)")]
end
subgraph "부가 서비스"
Presence["Presence 서비스\n온라인 상태"]
PushSvc["Push 알림 서비스"]
UserSvc["사용자/그룹 서비스"]
end
subgraph "인프라 계층"
Redis_P["Redis\n온라인 상태 저장"]
Redis_WS["Redis\n사용자-서버 매핑"]
UserDB[("MySQL\n사용자/그룹 정보")]
APNs["Apple APNs"]
FCM["Google FCM"]
end
Mobile -->|WebSocket| LB
Web -->|WebSocket| LB
LB --> CS1
LB --> CS2
LB --> CS3
CS1 --> MQ
CS2 --> MQ
CS3 --> MQ
MQ --> MsgWorker
MsgWorker --> MsgDB
CS1 --> Presence
CS1 --> PushSvc
CS1 --> UserSvc
CS1 --> Redis_WS
Presence --> Redis_P
UserSvc --> UserDB
PushSvc --> APNs
PushSvc --> FCM
classDef client fill:#e3f2fd,stroke:#1976d2
classDef server fill:#e8f5e9,stroke:#388e3c
classDef db fill:#fff3e0,stroke:#f57c00
class Mobile,Web client
class LB,CS1,CS2,CS3,MQ,MsgWorker,Presence,PushSvc,UserSvc server
class MsgDB,Redis_P,Redis_WS,UserDB,APNs,FCM db
4. 메시지 전송 흐름 상세
1:1 채팅 메시지 완전 흐름
sequenceDiagram
participant A as 사용자A (서버1 연결)
participant S1 as 채팅서버1
participant Kafka as Kafka
participant S2 as 채팅서버2
participant B as 사용자B (서버2 연결)
participant Push as Push서버
participant DB as HBase
A->>S1: 1️⃣ WS: {"to":"userB","msg":"안녕!","client_id":"uuid"}
S1->>S1: 2️⃣ 메시지 ID 생성 (Snowflake), 타임스탬프
S1->>Kafka: 3️⃣ 메시지 발행 (topic: chat-userB)
S1-->>A: 4️⃣ ACK: {"msg_id":"...","status":"sent"} (1체크)
par 병렬 처리
Kafka->>DB: 5️⃣ 메시지 HBase 영구 저장
Kafka->>S2: 5️⃣ 사용자B 연결 서버로 라우팅
end
alt B가 온라인 (서버2에 WebSocket 연결됨)
S2->>B: 6️⃣ WS: {"from":"userA","msg":"안녕!","msg_id":"..."}
B-->>S2: 7️⃣ ACK: 수신 확인
S2->>Kafka: 8️⃣ 읽음 이벤트 발행
Kafka->>S1: 9️⃣ 읽음 이벤트 전달
S1->>A: 🔟 WS: {"msg_id":"...","status":"read"} (2체크 파란색)
else B가 오프라인
Kafka->>Push: 6️⃣ 푸시 알림 요청
Push->>FCM: 7️⃣ FCM 발송
FCM->>B: 8️⃣ 푸시 알림 도착
Note over B: 앱 열면 HBase에서 미읽음 메시지 조회
end
서버 간 메시지 라우팅 문제
A가 서버1에, B가 서버2에 연결되어 있을 때 어떻게 메시지를 전달할까요?
graph TD
Problem["문제: A는 서버1, B는 서버2\n어떻게 B에게 전달?"]
Problem --> Sol1["방법1: 서버간 직접 P2P 통신"]
Problem --> Sol2["방법2: Redis Pub/Sub"]
Problem --> Sol3["방법3: Kafka 메시지 큐"]
Sol1 --> D1["단점: N×(N-1) 연결 필요\n1500대면 225만개 연결\n서버 추가 시 전체 재연결"]
Sol2 --> D2["Redis Pub/Sub\n채널 구독 방식\n빠르지만 메시지 유실 가능\n소규모 적합"]
Sol3 --> D3["Kafka\n내구성+확장성+순서 보장\n약간의 지연 추가\n대규모 적합"]
Sol3 --> Rec["추천: Kafka\n(700K QPS 처리 가능)"]
classDef client fill:#e3f2fd,stroke:#1976d2
classDef server fill:#e8f5e9,stroke:#388e3c
classDef db fill:#fff3e0,stroke:#f57c00
class Problem client
class Sol1,Sol2,Sol3 server
class D1,D2,D3,Rec db
5. 메시지 ID 설계 — Snowflake ID
메시지 순서 보장과 전역 유일성을 위해 Snowflake ID를 사용합니다. Twitter가 2010년에 설계한 분산 ID 생성 알고리즘입니다.
graph TD
Snow["Snowflake ID\n64비트 정수"]
Snow --> T["41비트: 타임스탬프(ms)\n약 69년 사용 가능"]
Snow --> M["10비트: 머신 ID\n최대 1024대 서버"]
Snow --> S["12비트: 시퀀스\n같은 ms에 4096개 생성"]
T --> Benefit1["시간순 정렬 가능\n(정렬 = ID 비교)"]
M --> Benefit2["전역 유일성 보장\n(서버별 독립 생성)"]
S --> Benefit3["초당 4096 × 1024 = 419만 TPS"]
classDef client fill:#e3f2fd,stroke:#1976d2
classDef server fill:#e8f5e9,stroke:#388e3c
classDef db fill:#fff3e0,stroke:#f57c00
class Snow client
class T,M,S server
class Benefit1,Benefit2,Benefit3 db
public class SnowflakeIdGenerator {
private static final long EPOCH = 1609459200000L; // 2021-01-01 기준
private static final long WORKER_ID_BITS = 10L;
private static final long SEQUENCE_BITS = 12L;
private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS); // 1023
private static final long MAX_SEQUENCE = ~(-1L << SEQUENCE_BITS); // 4095
private final long workerId;
private long lastTimestamp = -1L;
private long sequence = 0L;
public SnowflakeIdGenerator(long workerId) {
if (workerId < 0 || workerId > MAX_WORKER_ID) {
throw new IllegalArgumentException("workerId must be 0~1023");
}
this.workerId = workerId;
}
public synchronized long nextId() {
long timestamp = currentTime();
if (timestamp < lastTimestamp) {
// 시계 역행 감지 — 치명적 버그 방지
throw new RuntimeException("Clock moved backwards by "
+ (lastTimestamp - timestamp) + "ms");
}
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & MAX_SEQUENCE;
if (sequence == 0) {
// 같은 ms에 4096개 소진 → 다음 ms까지 스핀 대기
timestamp = waitNextMillis(lastTimestamp);
}
} else {
sequence = 0;
}
lastTimestamp = timestamp;
return ((timestamp - EPOCH) << 22)
| (workerId << 12)
| sequence;
}
private long waitNextMillis(long lastTimestamp) {
long timestamp = currentTime();
while (timestamp <= lastTimestamp) {
timestamp = currentTime();
}
return timestamp;
}
private long currentTime() {
return System.currentTimeMillis();
}
}
실무에서 자주 하는 실수
Snowflake ID는 각 서버(Worker)가 독립적으로 생성하므로 워커 ID가 겹치면 안 됩니다. Kubernetes Pod 재시작 시 워커 ID가 바뀌거나 중복될 수 있습니다. ZooKeeper 또는 Redis를 통해 워커 ID를 동적으로 할당해야 합니다.
6. 메시지 저장소 설계
왜 RDBMS가 아닌 HBase/Cassandra인가?
graph TD
Q["채팅 메시지 저장소\n선택 기준"]
Q --> Access["접근 패턴 분석"]
Access --> A1["최근 N개 메시지 조회\n(채팅방 열기)"]
Access --> A2["특정 시간 이후 메시지\n(읽지 않은 메시지)"]
Access --> A3["쓰기: 초당 23만 건\n(엄청난 Write 부하)"]
A3 --> Choice{"DB 선택"}
Choice -->|"Write 최적화 필요\n수평 확장 필수"| NoSQL["HBase / Cassandra"]
Choice -->|"복잡한 JOIN 필요\n강한 일관성"| RDBMS["MySQL / PostgreSQL"]
NoSQL --> Pros["장점: LSM Tree로 쓰기 최적화\n수평 확장 용이\n시간 기반 조회 최적화"]
RDBMS --> Cons["단점: 수십 PB 처리 어려움\n쓰기 병목\nSharding 복잡"]
classDef client fill:#e3f2fd,stroke:#1976d2
classDef server fill:#e8f5e9,stroke:#388e3c
classDef db fill:#fff3e0,stroke:#f57c00
class Q,Access client
class A1,A2,A3,Choice server
class NoSQL,RDBMS,Pros,Cons db
HBase 스키마 설계
테이블: messages
RowKey: {channel_id}_{reversed_timestamp}
→ reversed_timestamp = Long.MAX_VALUE - timestamp
→ 최신 메시지가 앞에 위치 (HBase는 RowKey 순으로 저장)
컬럼 패밀리: msg
- msg:id → Snowflake 메시지 ID
- msg:sender → 발신자 ID
- msg:type → text / image / video / file
- msg:content → 텍스트 내용 (미디어는 S3 URL)
- msg:status → sent / delivered / read
예시 RowKey:
ch001_9223372036854775807 → 가장 최신 메시지 (역순)
ch001_9223372036854775806
ch001_9223372036854775805
채팅방 최근 50개 메시지 조회:
Scan(startRow="ch001_", limit=50)
→ 역순으로 최신 50개 반환
대화 목록 스키마 (MySQL)
채팅방 목록, 멤버 정보 같은 관계형 데이터는 MySQL에 저장합니다.
-- 대화방 정보
CREATE TABLE conversations (
id BIGINT PRIMARY KEY,
type ENUM('direct', 'group') NOT NULL,
name VARCHAR(200), -- 그룹 채팅명
created_at DATETIME NOT NULL,
last_message_id BIGINT,
last_message_at DATETIME,
INDEX idx_last_msg_at (last_message_at)
);
-- 대화방 멤버
CREATE TABLE conversation_members (
conversation_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
joined_at DATETIME NOT NULL,
last_read_msg_id BIGINT DEFAULT 0, -- 읽음 위치 추적
notification_enabled BOOLEAN DEFAULT TRUE,
PRIMARY KEY (conversation_id, user_id),
INDEX idx_user_conversations (user_id, last_read_msg_id)
);
7. 온라인 상태 서비스 (Presence)
온라인 상태 추적 — Redis TTL 활용
sequenceDiagram
participant App as 모바일 앱
participant WS as WebSocket 서버
participant Redis as Redis (TTL 30초)
participant Friends as 친구들
Note over App,Friends: 1️⃣ 앱 포그라운드 진입
App->>WS: WebSocket 연결
WS->>Redis: SET presence:userA "online" EX=30
WS->>Friends: 2️⃣ Kafka: userA online 이벤트 발행
Note over App,Friends: 3️⃣ 하트비트 (매 25초)
loop 매 25초
App->>WS: ping 프레임
WS->>Redis: EXPIRE presence:userA 30 (TTL 갱신)
WS-->>App: pong 프레임
end
Note over App,Friends: 4️⃣ 연결 끊김 (정상 종료)
App->>WS: WebSocket close
WS->>Redis: DEL presence:userA
WS->>Friends: Kafka: userA offline 이벤트
Note over App,Friends: 5️⃣ 비정상 종료 (앱 크래시)
Note over Redis: 30초 후 TTL 만료 → 자동 오프라인
Presence 최적화 — 대규모에서의 팬아웃 문제
친구가 100명이면 온라인 상태 변경 시 100번의 이벤트를 발행해야 합니다. DAU 5억 명이 평균 100명의 친구를 가지면 상태 변경 이벤트만 초당 수백만 건입니다.
해결 전략:
1. Lazy Loading: 채팅방을 열 때만 상대방 상태를 조회 (실시간 구독 안 함)
2. 구독 그룹 제한: 최근 대화한 20명만 실시간 상태 구독
3. 배치 갱신: 상태 변경을 1초 단위로 묶어서 전파
4. 그룹 채팅에서는 Presence 비활성화: 100명 그룹에서 개별 상태 표시 불필요
8. 그룹 채팅 설계
1:1 채팅과의 차이점
그룹 채팅은 1:1 채팅의 단순한 확장이 아닙니다. 메시지 하나를 보내면 최대 99명에게 동시에 전달해야 하므로 팬아웃 문제가 핵심입니다.
sequenceDiagram
participant A as 사용자A
participant S1 as 채팅서버1
participant Kafka as Kafka
participant S2 as 채팅서버2
participant S3 as 채팅서버3
participant B as 사용자B (서버2)
participant C as 사용자C (서버3)
participant D as 사용자D (오프라인)
participant Push as Push서버
A->>S1: 1️⃣ WS: {"group":"G1","msg":"회의 5시입니다"}
S1->>S1: 2️⃣ 메시지 ID 생성, 그룹 멤버 조회 (Redis 캐시)
S1->>Kafka: 3️⃣ 그룹 메시지 발행 (topic: chat-group-G1)
par 팬아웃: 멤버별 라우팅
Kafka->>S2: 4️⃣ 사용자B 서버로 전달
S2->>B: 5️⃣ WS 전송
Kafka->>S3: 4️⃣ 사용자C 서버로 전달
S3->>C: 5️⃣ WS 전송
Kafka->>Push: 4️⃣ 사용자D 오프라인 → Push
Push->>D: 5️⃣ FCM/APNs
end
팬아웃 전략: Write-time vs Read-time
Write-time 팬아웃 (카카오톡 방식):
메시지 전송 시 각 멤버의 수신함에 메시지를 복사
장점: 읽기가 빠름 (자기 수신함만 조회)
단점: 쓰기 증폭 (100명 그룹이면 100배)
적합: 소규모 그룹 (최대 100명)
Read-time 팬아웃 (Twitter Timeline 방식):
메시지는 원본 1개만 저장, 읽을 때 그룹 메시지를 조회
장점: 쓰기 효율적
단점: 읽기 시 조회 비용 증가
적합: 대규모 채널 (수만 명)
카카오톡 선택: Write-time (그룹 최대 100명 제한이므로 쓰기 증폭 허용 가능)
읽음 확인 (그룹)
1:1에서는 “읽음/안읽음”이 전부이지만, 그룹에서는 100명 중 몇 명이 읽었는지 추적해야 합니다.
데이터 구조 (Redis Hash):
Key: read:{message_id}
Field: {user_id}
Value: {read_timestamp}
예시:
read:msg_12345 → { "userB": 1717200000, "userC": 1717200005 }
읽지 않은 수 = 그룹 멤버 수 - Hash 크기
최적화:
- 읽음 이벤트는 실시간 전파하지 않고 5초 배치로 묶어 전송
- 오래된 메시지(7일 이후)의 읽음 상태는 HBase로 아카이브
9. 미디어 전송 설계
이미지/동영상 전송 흐름
텍스트 메시지와 달리 미디어 파일은 크기가 수 MB~수 GB입니다. WebSocket으로 직접 보내면 연결이 오래 점유되어 다른 메시지가 지연됩니다.
sequenceDiagram
participant A as 사용자A
participant S as 채팅서버
participant Upload as 업로드 서버
participant S3 as Object Storage (S3)
participant CDN as CDN
participant B as 사용자B
A->>Upload: 1️⃣ HTTP POST /upload (multipart, 10MB 이미지)
Upload->>Upload: 2️⃣ 이미지 검증 (크기, 포맷, 악성코드 스캔)
Upload->>Upload: 3️⃣ 썸네일 생성 (200×200)
Upload->>S3: 4️⃣ 원본 + 썸네일 저장
S3-->>Upload: 5️⃣ URL 반환
Upload-->>A: 6️⃣ {"media_url": "cdn.kakao.com/img/abc123"}
A->>S: 7️⃣ WS: {"type":"image","media_url":"...","thumbnail_url":"..."}
S->>B: 8️⃣ WS: 메시지 전달 (썸네일 URL만 포함)
B->>CDN: 9️⃣ 썸네일 로드 (즉시)
B->>CDN: 🔟 원본 로드 (탭 시 lazy load)
미디어 최적화 전략
1. 업로드와 메시지를 분리:
미디어는 HTTP로 업로드 서버에, 메시지는 WebSocket으로 채팅 서버에
→ WebSocket 연결을 대용량 파일 전송으로 점유하지 않음
2. 썸네일 즉시, 원본 지연 로드:
채팅 목록에서는 200×200 썸네일만 표시 (수 KB)
사용자가 탭하면 원본(수 MB) 로드 → 대역폭 90% 절감
3. CDN 캐싱:
자주 공유되는 이미지는 CDN 엣지에 캐싱
원본 S3까지 갈 필요 없이 가까운 CDN에서 응답
4. 파일 크기 제한:
이미지: 최대 20MB
동영상: 최대 300MB (업로드 후 서버 측 트랜스코딩)
해상도 자동 조정: 원본 보존 + 저해상도 버전 생성
5. 만료 정책:
1:1 미디어: 영구 보관
그룹 미디어: 1년 후 S3 Glacier로 이동 (비용 절감)
극한 시나리오
시나리오 1: 서버 장애 시 메시지 유실 방지
상황: 사용자A가 메시지를 보냈고 채팅서버1이 Kafka에 발행 직후 크래시
사용자B의 채팅서버2는 Kafka에서 메시지를 소비해서 B에게 전달 완료
하지만 채팅서버1이 죽었으므로 A에게 전송 확인(1체크)을 못 보냄
방어:
1. 클라이언트 측 재시도: A의 앱이 ACK를 못 받으면 client_id(UUID)와 함께
재전송. 서버는 client_id로 중복 감지 → 멱등성 보장
2. Kafka에 이미 저장됐으므로 B에게는 정상 전달됨
3. A가 재연결하면 미전송 확인 메시지 일괄 전달
시나리오 2: 네트워크 파티션 — 멀티 데이터센터 분리
상황: 한국 IDC와 미국 IDC 간 네트워크 단절
한국 사용자끼리는 정상, 미국 사용자끼리도 정상
한국 → 미국 메시지가 전달 안 됨
방어:
1. 각 IDC에 독립 Kafka 클러스터 + MirrorMaker2로 cross-DC 복제
2. 네트워크 복구 시 MirrorMaker2가 밀린 메시지를 순서대로 동기화
3. 사용자에게 "일부 메시지가 지연될 수 있습니다" UI 표시
4. 메시지 ID가 Snowflake(타임스탬프 기반)이므로 동기화 후 정렬 가능
시나리오 3: 초대형 그룹 메시지 폭풍 — 팬아웃 폭발
상황: 100명 그룹에서 80명이 동시에 메시지 전송 (새해 자정 인사)
1초에 80건 × 99명 팬아웃 = 7,920건의 전달 이벤트
그룹이 수만 개면 팬아웃이 초당 수백만 건으로 폭발
방어:
1. 메시지 배치: 같은 그룹의 메시지를 100ms 윈도우로 묶어 한 번에 팬아웃
2. 그룹 크기별 전략 분리:
- 소규모(~20명): Write-time 팬아웃 (각 멤버 수신함에 복사)
- 대규모(20~100명): 그룹 채널 기반 구독 (멤버가 채널을 poll)
3. 핫 그룹 감지: 초당 메시지 수가 임계값 초과 시 rate limiting 적용
4. 클라이언트 측 배치 렌더링: 수백 건의 메시지를 한 번에 받아 묶어 표시
시나리오 4: 카카오 사태 재발 — 단일 데이터센터 장애
상황: 메인 데이터센터 화재/정전으로 전체 서비스 마비
2022년 카카오 127시간 장애의 재현
방어:
1. Active-Active 멀티 DC: 최소 2개 데이터센터에서 동시 서빙
2. DNS 기반 페일오버: DC1 장애 시 DNS가 DC2로 자동 전환 (TTL 60초)
3. 데이터 동기화: Kafka MirrorMaker2로 DC 간 실시간 복제
4. 정기 DR 훈련: 분기 1회 DR 전환 훈련으로 실제 전환 시간 검증
5. 핵심 데이터 3중 복제: 메시지는 2개 DC + S3 백업
비용 vs 리스크:
Active-Active는 인프라 비용 2배이지만,
127시간 장애의 비즈니스 손실(광고 수익, 사용자 이탈)에 비하면 보험료 수준
11. 핵심 설계 결정 요약
| 설계 항목 | 선택 | 이유 |
|---|---|---|
| 실시간 프로토콜 | WebSocket | 양방향, 저지연, 서버 push 가능 |
| 로드밸런서 | L4 TCP | WebSocket 연결 유지 (L7은 HTTP 종료) |
| 메시지 라우팅 | Kafka | 내구성, 순서 보장, 700K QPS 처리 |
| 메시지 저장소 | HBase | 쓰기 최적화(LSM Tree), 수평 확장, PB 스케일 |
| 관계형 데이터 | MySQL | 사용자/그룹 정보, 강한 일관성 |
| 메시지 ID | Snowflake | 시간순 정렬, 분산 생성, 초당 419만 TPS |
| 온라인 상태 | Redis TTL | 30초 TTL로 자동 만료, 빠른 조회 |
| 미디어 저장 | S3 + CDN | 비용 효율적, 글로벌 배포, 내구성 99.999999999% |
| 그룹 팬아웃 | Write-time | 그룹 최대 100명 제한으로 쓰기 증폭 허용 |
| 읽음 확인 | Redis Hash | 멤버별 읽음 시각 추적, 빠른 카운팅 |
| Push 알림 | FCM/APNs | 오프라인 사용자 도달, 플랫폼 표준 |
| 고가용성 | Active-Active 멀티 DC | 단일 DC 장애 시에도 서비스 유지 |
댓글