이전 글에서 DAU 5천만, 일일 30억 메시지를 처리하는 오픈채팅 시스템을 설계했습니다. 기존 설계에서 Aurora(MySQL)와 읽기 레플리카 15개로 DB 계층을 구성했는데, 스터디를 진행하면서 구조적인 한계가 논의됐습니다.
이번 글에서는 Aurora에서 Cassandra로 전환하게 된 이유, 데이터 모델링 결정 과정, 그리고 쓰기/읽기 분리 아키텍처까지의 트레이드오프를 정리합니다.
기존 설계에서 Aurora는 다음과 같이 구성됐습니다.
클라이언트 → WebSocket 서버 → Kafka → Aurora (마스터 1대 + 읽기 레플리카 15대)
문제는 쓰기 노드가 1개라는 점입니다.
이전 글에서 미해결 과제로 남겨뒀던 두 가지가 바로 이 한계에서 비롯됩니다.
❶ Fetch API 대량 호출 시 DB 부하 관리
❷ 쓰기/읽기 분리 아키텍처 검토 필요
Cassandra는 마스터 노드가 없는 P2P 분산 구조입니다.
Aurora: 쓰기 → 마스터 1대 (병목)
Cassandra: 쓰기 → 모든 노드가 수신 (분산)
채팅 메시지의 특성이 Cassandra에 딱 맞습니다.
| 채팅 메시지 특성 | Cassandra와의 궁합 |
|---|---|
| Append-only (수정 거의 없음) | LSM Tree 쓰기 최적화 |
| 방 ID + 시간순 조회 | Partition Key + Clustering Key |
| JOIN, 집계 없음 | NoSQL 제약 문제없음 |
| 수평 확장 필요 | 노드 추가 = 선형 성능 향상 |
Cassandra가 어떻게 동작하는지 알아야 올바른 설계 결정을 내릴 수 있습니다.
① Consistent Hashing (Token Ring)
Partition Key를 해싱해서 토큰값으로 변환하고, 링(0 ~ 2^127) 위에서 담당 노드를 결정합니다. 노드 추가/제거 시 인접한 토큰 범위만 이동하면 되므로 전체 재분배가 필요 없습니다.
② Virtual Nodes (vnodes)
물리 노드 1개가 링 위에 여러 토큰 범위를 보유(기본 256개)합니다. 노드 성능에 따라 토큰 수를 조정해 부하를 균등하게 배분할 수 있습니다.
③ Gossip Protocol
각 노드가 주변 노드에 주기적으로 상태 정보를 전파합니다. 중앙 코디네이터 없이도 노드 장애를 자동으로 감지할 수 있고, 이것이 마스터 없이 클러스터가 유지되는 이유입니다.
④ LSM Tree (Log-Structured Merge Tree)
쓰기 → 메모리 (Memtable) → 디스크 순차 flush (SSTable)
랜덤 I/O가 없어 쓰기 성능이 극단적으로 빠릅니다. 30억 메시지/일 처리 성능의 근거가 여기에 있습니다.
Cassandra에서 가장 중요한 원칙은 쿼리 패턴을 먼저 결정하고, 테이블을 그에 맞게 설계하는 것입니다.
후보 1: room_id 단독
Partition Key: room_id
❌ 문제: 방의 메시지가 평생 하나의 파티션에 쌓입니다. Cassandra 파티션이 수GB를 넘으면 성능이 급락하고, 활성 방은 수년간 수십억 건이 쌓입니다.
후보 2: 날짜/시간 단독
Partition Key: bucket_date
❌ 문제: 모든 방의 메시지가 "오늘 날짜" 파티션 하나에 몰립니다. 방별 조회 시 전체 파티션을 스캔해야 합니다.
최종 선택: (room_id, bucket_date) 복합키
Partition Key: (room_id, bucket_date)
✅ 이유:
Clustering Key: message_id (TIMEUUID DESC)
TIMEUUID는 시간 기반 UUID로, 파티션 안에서 자동으로 시간순 정렬됩니다. 별도의 created_at 컬럼 없이도 순서가 보장되고, 클라이언트가 마지막 message_id를 커서로 사용해 갭 감지도 가능합니다.
CREATE TABLE messages (
room_id UUID,
bucket DATE, -- 날짜 단위 버킷
message_id TIMEUUID, -- 시간 기반 UUID (자동 시간순 정렬)
sender_id UUID,
content TEXT,
PRIMARY KEY ((room_id, bucket), message_id)
) WITH CLUSTERING ORDER BY (message_id DESC);
최근 N개 메시지 조회
SELECT * FROM messages
WHERE room_id = ? AND bucket = '2026-03-20'
ORDER BY message_id DESC
LIMIT 50;
→ 파티션 1개, 인덱스 끝에서부터 읽기 = 매우 빠름
갭 감지 후 누락 메시지 Pull
SELECT * FROM messages
WHERE room_id = ? AND bucket = '2026-03-20'
AND message_id > {마지막으로_받은_id};
→ 커서 기반 조회, 파티션 1개 이내
과거 메시지 페이징 (어제로 스크롤)
-- bucket을 하루씩 낮춰가며 순차 조회
SELECT * FROM messages
WHERE room_id = ? AND bucket = '2026-03-19'
ORDER BY message_id DESC
LIMIT 50;
→ 멀티 파티션이지만 "과거 조회"는 실시간이 아니라 허용 범위
Cassandra는 마스터가 없으니 트래픽이 무제한일까요? 아닙니다.
room_id를 Partition Key로 쓰면, 핫챗 방의 모든 메시지가 항상 같은 토큰값으로 해싱되어 같은 노드로 쏠립니다. 노드를 추가해도 그 노드만 폭발하는 상황이 생길 수 있습니다.
Partition Key: (room_id, bucket_date, shard_id)
| 상황 | shard_id | 동작 |
|---|---|---|
| 평상시 | 0 (단일) | 단순, 읽기 빠름 |
| 핫챗 감지 | 0 ~ N-1 | N개 파티션으로 쓰기 분산 |
샤드 수 관리
Redis: room:{room_id}:shard_count = N
핫챗 감지 시 이 값을 N으로 올리면, 이후 쓰기가 shard_id % N으로 분산됩니다.
읽기 시
N개 파티션 병렬 조회 → TIMEUUID 기준 merge sort → 클라이언트 반환
읽기 복잡도가 올라가므로, Redis Sorted Set 캐싱으로 흡수합니다.
| 단점 | 원인 |
|---|---|
| 멀티 파티션 조회 비용 증가 | 파티션 수만큼 노드 네트워크 왕복 |
| Scatter-Gather 병목 | 코디네이터가 모든 응답 취합 후 반환 |
| 캐시 효율 저하 | 파티션 많으면 메모리 캐시 히트율 하락 |
| 토큰 링 편향 가능성 | 설계 나쁘면 특정 노드 쏠림 |
핵심 판단 기준: 파티션 개수가 아니라, 쿼리가 몇 개 파티션을 건드리냐입니다. 채팅 앱에서 대부분의 쿼리는 "최근 50개"이므로 파티션 1~2개 이내에서 끝납니다.
[쓰기 경로]
클라이언트
→ REST API 서버 (인증 / Rate Limit / 필터링 / 탐지)
→ Kafka
→ Cassandra (샤딩 저장)
↓
Redis Pub/Sub
[읽기 경로]
Redis Pub/Sub
→ WebSocket 서버 (팬아웃 전담)
→ 클라이언트 Push
[캐시 계층]
Redis Sorted Set (최근 메시지 버퍼)
→ 캐시 히트 시 Cassandra 미조회
WebSocket에서 쓰기까지 처리하면 두 가지 상충되는 책임이 충돌합니다.
REST API로 분리하면:
① 독립적 수평 확장
Stateless라 로드밸런서가 어느 서버로 보내도 무방합니다. Cassandra 샤딩이 DB 부하를 흡수하므로 쓰기 서버는 그냥 늘리면 됩니다.
② 미들웨어 파이프라인 자연스럽게 구성
클라이언트 → [인증] → [Rate Limit] → [필터링/탐지] → Kafka
HTTP 인터셉터 형태로 각 레이어를 독립적으로 붙이고 스케일링할 수 있습니다.
③ 연결 오버헤드는 생각보다 작다
핫챗 5,000 메시지/초는 5,000명이 각자 1msg/초 보내는 것입니다. 유저 1명 기준 초당 1번의 REST 호출은 HTTP/2 Keep-Alive 환경에서 문제없는 수준입니다.
쓰기를 REST로 분리했으니, 읽기(서버→클라이언트 Push)에 단방향인 SSE를 쓰자는 제안이 나왔습니다.
SSE(Server-Sent Events)의 특징
Last-Event-ID 헤더로 재연결 시 갭 감지 자동화그러나 채팅 시스템에서 SSE가 부정적 반응을 받은 이유:
| 이유 | 설명 |
|---|---|
| 채팅은 단방향이 아님 | 타이핑 표시, 읽음 확인, 온라인 상태 등 양방향 필요 |
| 인프라 장점 과장 | SSE도 Long-lived connection → Sticky Session 여전히 필요 |
| 팬아웃 병목 미해결 | 5,000명 Push는 WebSocket이든 SSE든 동일 |
| 업계 표준과 역행 | Discord, Slack, LINE 모두 WebSocket |
결론: 쓰기가 REST로 분리되더라도, 읽기는 WebSocket이 적합합니다.
Cassandra 도입으로 쓰기 병목은 해결됐습니다. 그러나 읽기 병목의 정체는 DB 조회 속도가 아닙니다.
메시지 1개를 5,000명에게 WebSocket으로 동시에 Push하는 것 자체가 병목
Cassandra로 바꿔도 이 팬아웃은 그대로입니다.
WebSocket 서버 400대 중 40대만 특정 방을 구독
→ 서버 1대가 받는 팬아웃 부하 10배 감소
메시지를 1~10초 단위로 묶어서 Push
→ 2,500만 Push/초 → 5,000 Push/초 수준으로 감소
서로 다른 DB를 목적에 맞게 섞어 쓰는 전략(Polyglot Persistence)으로, Redis Sorted Set을 최근 메시지 버퍼로 사용합니다.
Key: room:{room_id}:messages
Score: timestamp
Member: message_id (또는 직렬화된 메시지)
읽기 흐름
① Redis에 캐시 있음 → 바로 반환 (Cassandra 미조회)
② 캐시 없음 → shard_count 조회 → N개 파티션 병렬 조회
→ merge sort → Redis 재적재 → 반환
Redis Sorted Set이 타임슬라이스 버스트를 흡수하는 이유
핫챗 초당 5,000 메시지는 메모리 연산으로 처리됩니다. Redis 단일 스레드지만 초당 수십만 건 처리가 가능해 폭발적 순간 트래픽을 흡수합니다.
계층 구조
| 레이어 | 역할 | 보존 기간 |
|---|---|---|
| Redis Sorted Set | 최근 메시지 버퍼, 버스트 흡수 | 최근 1~2시간 |
| Cassandra | 전체 히스토리 영구 저장 | 무제한 |
주의: Thundering Herd
Redis 캐시가 만료되면 수만 명이 동시에 Cassandra 조회를 시도합니다. 대책:
[쓰기 병목] Aurora 단일 마스터
→ Cassandra 샤딩으로 해결 ✅
[쓰기 구조] REST API (Stateless)
→ 인증/Rate Limit/필터링 미들웨어 파이프라인
→ 독립적 수평 확장
[팬아웃 병목] WebSocket 2,500만 Push/초
→ 선택적 구독 + 동적 배칭 + Redis Sorted Set으로 해결 중 🔧
[데이터 계층]
Partition Key: (room_id, bucket_date, shard_id)
Clustering Key: message_id (TIMEUUID DESC)
캐시: Redis Sorted Set (score = timestamp)
영구 저장: Cassandra
메시지 버퍼: Kafka
| 결정 | 선택 | 포기한 것 |
|---|---|---|
| Aurora → Cassandra | 쓰기 수평 확장, SPOF 없음 | 트랜잭션, JOIN, 복잡한 쿼리 |
| REST 쓰기 분리 | 미들웨어 파이프라인, 독립 스케일링 | 연결 재사용 (HTTP/2로 완화) |
| WebSocket 읽기 유지 | 양방향, 업계 표준, 타이핑/상태 지원 | SSE의 자동 재연결 편의성 |
| Redis Sorted Set 추가 | 버스트 흡수, 빠른 최근 조회 | 메모리 비용, 캐시 미스 처리 복잡도 |
| 동적 샤딩 | Hot Partition 해소 | 읽기 시 멀티 파티션 조회 복잡도 |
이번 스터디를 통해 단순히 "어떤 DB가 빠르냐"가 아니라, 시스템의 병목이 정확히 어디에 있는지 파악하고, 각 레이어가 제 역할에 최적화되도록 설계하는 것이 중요함을 다시 확인했습니다.
완벽한 아키텍처는 없지만, 각 트레이드오프를 이해하고 선택하는 것이 시스템 설계의 핵심입니다.
이전 글: LINE 오픈채팅 시스템 설계