이 글의 위치: 기술 블로그 → Backend · 채팅 백엔드 설계 시리즈 2편. 게이트웨이를 일부러 멍청하게 만들었다고 했다. 하지만 "DB가 없다"는 것과 "쉽다"는 건 다르다. 이 얇은 릴레이 안에 이 백엔드에서 가장 밀도 높은 동시성 코드가 들어 있다.
문제는 고전적이다. 방이 많고 연결이 많다. (1) 단일 mutex로 방 맵을 지키면 lock 경합이 생기고, (2) 느린 클라이언트 하나가 broadcast를 붙잡으면 같은 방의 모두가 밀린다. 이 둘을 각각 다른 방법으로 푼다.
1. 방 맵을 32개 샤드로 — lock 경합 줄이기
모든 방을 하나의 map[string]*Room + 하나의 mutex로 관리하면, 방 A에 접속하는 동안 방 B의 조회까지 그 lock을 기다린다. 그래서 방 맵을 32개 샤드로 쪼갰다. 방 ID를 FNV-1a로 해시해 샤드를 정하고, 샤드마다 독립적인 lock을 둔다. 서로 다른 샤드의 방은 완전히 병렬로 다뤄진다.
const (
shardCount = 32
workerPoolSize = 10
broadcastBuffer = 10000
)
2. 방마다 워커풀 + 버퍼 큐 — broadcast를 비동기로
broadcast를 호출 스레드에서 직접 모든 연결에 쓰면, 연결 하나가 느릴 때 그 뒤가 전부 밀린다. 그래서 방마다 워커 goroutine 10개 + 버퍼 큐(10000) 를 둔다. broadcast는 "큐에 job을 넣는" 것으로 끝나고, 실제 전송은 워커가 가져가 처리한다.
핵심은 큐에 넣는 방식이다 — non-blocking. 큐가 꽉 차면 기다리지 않고 버리고 카운트만 한다.
// Broadcast: 큐가 꽉 차면 blocking 하지 않고 drop
select {
case room.queue <- job:
// 정상 적재
default:
rm.metrics.WriteDropped() // 버리고 카운트
}
클라이언트별 Send 채널도 같은 정책이다. 버퍼 256을 두고, 그것도 꽉 차면 그 클라이언트로의 전송을 drop 한다. 즉 느린 소비자 하나가 방 전체(혹은 게이트웨이 전체)를 막지 못한다.
3. 왜 blocking이 아니라 drop인가
여기가 이 설계의 진짜 결정이다. 큐가 꽉 찼을 때 선택지는 셋이다.
| 정책 | 결과 |
|---|---|
| blocking | 느린 클라이언트가 broadcast를 잡아 방 전체가 함께 느려진다 |
| 무한 버퍼 | 느린 클라이언트 때문에 메모리가 무한정 증가(OOM 위험) |
| drop-on-full (채택) | 느린 클라이언트만 메시지를 잃고, 나머지는 정상 속도 유지 |
채팅 게이트웨이는 at-most-once 실시간 릴레이로 설계됐다. 신뢰성이 필요한 부분은 dumb pipe 편에서 말한 REST 영속화 경로가 담당한다. 실시간에서 몇 프레임 흘리더라도, 클라이언트는 저장된 기록을 다시 조회해 채운다. "실시간은 빠르게, 신뢰성은 다른 경로로"라는 이중 쓰기 철학이 여기서도 관철된다.
4. 상수로 노출된 튜닝 손잡이
이 설계의 좋은 점은 조절 포인트가 전부 상수로 드러나 있다는 것이다. 부하 특성에 따라 이렇게 움직인다.
shardCount(32) ↑ → lock 경합 ↓, 대신 샤드별 오버헤드 ↑workerPoolSize(10) ↑ → 방당 전송 병렬성 ↑, 대신 goroutine 수 ↑broadcastBuffer(10000)·client buffer(256) ↑ → drop율 ↓, 대신 메모리 ↑ + 지연 시 오래된 메시지 유지
그리고 방이 비면 close(quit)으로 워커를 종료하고 맵에서 GC 한다. 유령 방이 goroutine을 붙잡지 않게 하는 정리(cleanup)까지 포함해야 이 구조가 완성된다.
정리
- 샤딩(32) 으로 방 맵의 lock 경합을 낮춘다.
- 워커풀 + 버퍼 큐 로 broadcast를 비동기화한다.
- drop-on-full 로 느린 소비자를 격리한다 — 한 명이 전체를 막지 못한다.
- 신뢰성은 게이트웨이가 아니라 영속화 경로가 책임진다.
다음 편은 이 게이트웨이를 여러 pod로 늘렸을 때 생기는 문제 — 같은 방의 두 사람이 서로 다른 인스턴스에 붙었을 때 어떻게 메시지를 주고받나. 크로스 인스턴스 fan-out과 멀티테넌시로 이어진다.
답글 남기기