,

[채팅 백엔드 설계] 느린 클라이언트 하나가 방 전체를 막지 않게 — 샤딩·워커풀·drop-on-full

이 글의 위치: 기술 블로그 → 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과 멀티테넌시로 이어진다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다