대규모 오픈채팅 시스템 설계 - Cassandra 도입과 아키텍처 트레이드오프

릴리김·2026년 3월 23일

study

목록 보기
4/7
post-thumbnail

들어가며

이전 글에서 DAU 5천만, 일일 30억 메시지를 처리하는 오픈채팅 시스템을 설계했습니다. 기존 설계에서 Aurora(MySQL)와 읽기 레플리카 15개로 DB 계층을 구성했는데, 스터디를 진행하면서 구조적인 한계가 논의됐습니다.

이번 글에서는 Aurora에서 Cassandra로 전환하게 된 이유, 데이터 모델링 결정 과정, 그리고 쓰기/읽기 분리 아키텍처까지의 트레이드오프를 정리합니다.


1. 왜 Aurora를 버리고 Cassandra를 선택했나

1.1 Aurora의 구조적 한계

기존 설계에서 Aurora는 다음과 같이 구성됐습니다.

클라이언트 → WebSocket 서버 → Kafka → Aurora (마스터 1대 + 읽기 레플리카 15대)

문제는 쓰기 노드가 1개라는 점입니다.

  • 30억 메시지/일 = 평균 초당 약 35,000 INSERT
  • 핫챗 시나리오: 방 1개에서 초당 5,000 메시지 집중
  • 읽기 레플리카를 아무리 늘려도 INSERT는 항상 마스터 1대가 처리

이전 글에서 미해결 과제로 남겨뒀던 두 가지가 바로 이 한계에서 비롯됩니다.

❶ Fetch API 대량 호출 시 DB 부하 관리
❷ 쓰기/읽기 분리 아키텍처 검토 필요

1.2 Cassandra가 이 문제를 해결하는 이유

Cassandra는 마스터 노드가 없는 P2P 분산 구조입니다.

Aurora:    쓰기 → 마스터 1대 (병목)
Cassandra: 쓰기 → 모든 노드가 수신 (분산)

채팅 메시지의 특성이 Cassandra에 딱 맞습니다.

채팅 메시지 특성Cassandra와의 궁합
Append-only (수정 거의 없음)LSM Tree 쓰기 최적화
방 ID + 시간순 조회Partition Key + Clustering Key
JOIN, 집계 없음NoSQL 제약 문제없음
수평 확장 필요노드 추가 = 선형 성능 향상

1.3 Cassandra 핵심 알고리즘 4가지

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억 메시지/일 처리 성능의 근거가 여기에 있습니다.


2. Cassandra 데이터 모델링

Cassandra에서 가장 중요한 원칙은 쿼리 패턴을 먼저 결정하고, 테이블을 그에 맞게 설계하는 것입니다.

2.1 Partition Key 설계 과정

후보 1: room_id 단독

Partition Key: room_id

❌ 문제: 방의 메시지가 평생 하나의 파티션에 쌓입니다. Cassandra 파티션이 수GB를 넘으면 성능이 급락하고, 활성 방은 수년간 수십억 건이 쌓입니다.

후보 2: 날짜/시간 단독

Partition Key: bucket_date

❌ 문제: 모든 방의 메시지가 "오늘 날짜" 파티션 하나에 몰립니다. 방별 조회 시 전체 파티션을 스캔해야 합니다.

최종 선택: (room_id, bucket_date) 복합키

Partition Key: (room_id, bucket_date)

✅ 이유:

  • 방별로 데이터가 격리되어 A방 조회가 B방에 영향을 주지 않음
  • 날짜별로 파티션 크기가 제한됨 (하루치 메시지만 쌓임)
  • "오늘 메시지 조회" = 파티션 1개 읽기

2.2 Clustering Key: TIMEUUID

Clustering Key: message_id (TIMEUUID DESC)

TIMEUUID는 시간 기반 UUID로, 파티션 안에서 자동으로 시간순 정렬됩니다. 별도의 created_at 컬럼 없이도 순서가 보장되고, 클라이언트가 마지막 message_id를 커서로 사용해 갭 감지도 가능합니다.

2.3 최종 테이블 구조

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);

2.4 주요 쿼리 패턴

최근 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;

→ 멀티 파티션이지만 "과거 조회"는 실시간이 아니라 허용 범위


3. Hot Partition 문제와 샤딩 전략

3.1 문제 인식

Cassandra는 마스터가 없으니 트래픽이 무제한일까요? 아닙니다.

room_id를 Partition Key로 쓰면, 핫챗 방의 모든 메시지가 항상 같은 토큰값으로 해싱되어 같은 노드로 쏠립니다. 노드를 추가해도 그 노드만 폭발하는 상황이 생길 수 있습니다.

3.2 동적 샤딩으로 해결

Partition Key: (room_id, bucket_date, shard_id)
상황shard_id동작
평상시0 (단일)단순, 읽기 빠름
핫챗 감지0 ~ N-1N개 파티션으로 쓰기 분산

샤드 수 관리

Redis: room:{room_id}:shard_count = N

핫챗 감지 시 이 값을 N으로 올리면, 이후 쓰기가 shard_id % N으로 분산됩니다.

읽기 시

N개 파티션 병렬 조회 → TIMEUUID 기준 merge sort → 클라이언트 반환

읽기 복잡도가 올라가므로, Redis Sorted Set 캐싱으로 흡수합니다.

3.3 파티션이 많아지면 생기는 단점

단점원인
멀티 파티션 조회 비용 증가파티션 수만큼 노드 네트워크 왕복
Scatter-Gather 병목코디네이터가 모든 응답 취합 후 반환
캐시 효율 저하파티션 많으면 메모리 캐시 히트율 하락
토큰 링 편향 가능성설계 나쁘면 특정 노드 쏠림

핵심 판단 기준: 파티션 개수가 아니라, 쿼리가 몇 개 파티션을 건드리냐입니다. 채팅 앱에서 대부분의 쿼리는 "최근 50개"이므로 파티션 1~2개 이내에서 끝납니다.


4. 쓰기/읽기 분리 아키텍처

4.1 전체 구조

[쓰기 경로]
클라이언트
  → REST API 서버 (인증 / Rate Limit / 필터링 / 탐지)
  → Kafka
  → Cassandra (샤딩 저장)
       ↓
  Redis Pub/Sub

[읽기 경로]
Redis Pub/Sub
  → WebSocket 서버 (팬아웃 전담)
  → 클라이언트 Push

[캐시 계층]
Redis Sorted Set (최근 메시지 버퍼)
  → 캐시 히트 시 Cassandra 미조회

4.2 왜 쓰기를 REST API로 분리했나

WebSocket에서 쓰기까지 처리하면 두 가지 상충되는 책임이 충돌합니다.

  • WebSocket이 잘하는 것: 빠른 연결 유지, 팬아웃 Push
  • 쓰기에 필요한 것: 인증, Rate Limit, 필터링, 탐지 (무거운 미들웨어)

REST API로 분리하면:

① 독립적 수평 확장

Stateless라 로드밸런서가 어느 서버로 보내도 무방합니다. Cassandra 샤딩이 DB 부하를 흡수하므로 쓰기 서버는 그냥 늘리면 됩니다.

② 미들웨어 파이프라인 자연스럽게 구성

클라이언트 → [인증] → [Rate Limit] → [필터링/탐지] → Kafka

HTTP 인터셉터 형태로 각 레이어를 독립적으로 붙이고 스케일링할 수 있습니다.

③ 연결 오버헤드는 생각보다 작다

핫챗 5,000 메시지/초는 5,000명이 각자 1msg/초 보내는 것입니다. 유저 1명 기준 초당 1번의 REST 호출은 HTTP/2 Keep-Alive 환경에서 문제없는 수준입니다.

4.3 WebSocket vs SSE 논의

쓰기를 REST로 분리했으니, 읽기(서버→클라이언트 Push)에 단방향인 SSE를 쓰자는 제안이 나왔습니다.

SSE(Server-Sent Events)의 특징

  • HTTP 기반 서버→클라이언트 단방향 스트리밍
  • 자동 재연결 내장
  • Last-Event-ID 헤더로 재연결 시 갭 감지 자동화

그러나 채팅 시스템에서 SSE가 부정적 반응을 받은 이유:

이유설명
채팅은 단방향이 아님타이핑 표시, 읽음 확인, 온라인 상태 등 양방향 필요
인프라 장점 과장SSE도 Long-lived connection → Sticky Session 여전히 필요
팬아웃 병목 미해결5,000명 Push는 WebSocket이든 SSE든 동일
업계 표준과 역행Discord, Slack, LINE 모두 WebSocket

결론: 쓰기가 REST로 분리되더라도, 읽기는 WebSocket이 적합합니다.


5. 읽기 병목 해결 전략

Cassandra 도입으로 쓰기 병목은 해결됐습니다. 그러나 읽기 병목의 정체는 DB 조회 속도가 아닙니다.

메시지 1개를 5,000명에게 WebSocket으로 동시에 Push하는 것 자체가 병목

Cassandra로 바꿔도 이 팬아웃은 그대로입니다.

5.1 선택적 구독

WebSocket 서버 400대 중 40대만 특정 방을 구독
→ 서버 1대가 받는 팬아웃 부하 10배 감소

5.2 동적 배칭

메시지를 1~10초 단위로 묶어서 Push
→ 2,500만 Push/초 → 5,000 Push/초 수준으로 감소

5.3 Polyglot Persistence: Redis Sorted Set 캐싱

서로 다른 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 조회를 시도합니다. 대책:

  • 캐시 미스 시 요청 1개만 Cassandra 조회, 나머지는 대기 (뮤텍스)
  • TTL 분산으로 동시 만료 방지

6. 최종 아키텍처 요약

[쓰기 병목] 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

7. 트레이드오프 정리

결정선택포기한 것
Aurora → Cassandra쓰기 수평 확장, SPOF 없음트랜잭션, JOIN, 복잡한 쿼리
REST 쓰기 분리미들웨어 파이프라인, 독립 스케일링연결 재사용 (HTTP/2로 완화)
WebSocket 읽기 유지양방향, 업계 표준, 타이핑/상태 지원SSE의 자동 재연결 편의성
Redis Sorted Set 추가버스트 흡수, 빠른 최근 조회메모리 비용, 캐시 미스 처리 복잡도
동적 샤딩Hot Partition 해소읽기 시 멀티 파티션 조회 복잡도

마무리

이번 스터디를 통해 단순히 "어떤 DB가 빠르냐"가 아니라, 시스템의 병목이 정확히 어디에 있는지 파악하고, 각 레이어가 제 역할에 최적화되도록 설계하는 것이 중요함을 다시 확인했습니다.

  • 쓰기 병목 → Cassandra 샤딩
  • 읽기(팬아웃) 병목 → 선택적 구독, 배칭, 캐싱의 조합
  • 구조 복잡도 → 각 서버가 하나의 책임만 갖도록 분리

완벽한 아키텍처는 없지만, 각 트레이드오프를 이해하고 선택하는 것이 시스템 설계의 핵심입니다.


이전 글: LINE 오픈채팅 시스템 설계

profile
경험과 삽질, 모든 것을 기록합니다.

0개의 댓글