Skip to content

fix(realtime): 멈춘 SSE 구독자가 전체 fan-out 을 세우던 문제 - #270

Merged
i3months merged 1 commit into
devfrom
fix/realtime-slow-consumer-headline
Sep 29, 2026
Merged

i3months merged 1 commit into
devfrom
fix/realtime-slow-consumer-headline

Conversation

@i3months

Copy link
Copy Markdown
Member

RealTime(Go)을 9/8 이후 처음 다시 봤다. 서로 물린 결함 둘을 찾았다.

1. SSE 쓰기에 데드라인이 없었다

WS 는 처음부터 WSWriteTimeout(10s)이 있었는데 SSE 는 0건이었다.

$ grep -c "SetWriteDeadline\|ResponseController" internal/transport/sse.go
0

멈춘 클라이언트(면접 중 노트북 덮개를 닫는 경우)는 TCP 를 열어둔 채 수신 창을 막으므로
Fprintf 가 무기한 블록된다 — OS 가 포기할 때까지 수 분이 걸릴 수 있다. 그 고루틴이
sub.Ch 를 비우지 못하면 버퍼(16)가 차고, 그 채널로 가는 모든 Dispatch 가 slow-consumer
타임아웃을 문다.

2. slowTimeout 이 구독자마다 리셋됐다

timer.Reset(slowTimeout)   // ← 구독자마다

멈춘 탭 3개면 5초 × 3 = 15초. 그리고 이게 중요한 이유:

// messaging/consumer.go
const qosPrefetchCount = 1
...
if err := c.Handler.Handle(d.Body); err != nil {   // 동기 호출

AMQP 컨슈머는 단일 스레드 + prefetch=1 이다. 여기서 막히는 시간이 곧 다른 세션들의
이벤트 지연
이다.

realtime/CLAUDE.md §9 는 이렇게 적고 있었다:

slow consumer 처리: Dispatch가 slowTimeout을 초과하면 그 구독자만 drop, 다른 구독자는 영향 없음.

사실이 아니었다. 같은 채널의 뒷 구독자도, 다른 채널의 세션도 같이 밀렸다.

고친 방법

SSE http.NewResponseController 로 쓰기마다 데드라인 (REALTIME_SSE_WRITE_TIMEOUT, 기본 10s)
Dispatch slowTimeout 을 호출 전체의 예산으로 — 멈춘 구독자가 몇이든 메시지당 최악 한 번
Dispatch 예산을 다 써도 남은 구독자에게 논블로킹 전송은 시도

마지막 항목이 중요하다: 앞사람이 멈췄다는 이유로 건강한 구독자가 이벤트를 잃으면 안 된다.

데드라인을 지원하지 않는 ResponseWriter(테스트 recorder, 일부 미들웨어)에서는 기존 동작을
유지한다 — 관측 기능 때문에 스트림을 실패시키지 않는다.

테스트

테스트 왜
예산이 구독자 수만큼 곱해지지 않는다 (멈춘 구독자 3명 × 150ms) 이게 핵심 회귀
앞이 멈춰도 뒤의 건강한 구독자는 받는다 고치면서 새로 만들 수 있는 버그
구독자 없는 채널은 기다리지 않는다

가드가 실제로 회귀를 잡는지 확인했다 — 예산을 구독자마다 리셋하도록 되돌리니 첫 테스트가
실패했다.

go test -race ./... 전부 통과. 컨슈머 동시성이 본업인 서버라 -race 는 필수다.

함께 확인한 것 (문제 없음)

  • sub.Ch 를 닫는 코드가 없다 → Unsubscribe 와 Dispatch 가 겹쳐도 send-on-closed 패닉이 없다
  • Dispatch 가 락 밖에서 전송한다 (복사 후 RUnlock)
  • 타이머를 재사용한다 (time.After 였다면 (구독자 × 이벤트)마다 타이머 고루틴 누수)
  • AMQP 컨슈머는 재연결·DLQ·graceful shutdown 모두 처리한다

RealTime(Go) 을 9/8 이후 처음 다시 봤다. 서로 물린 결함 둘을 찾았다.

## 1. SSE 쓰기에 데드라인이 없었다

WS 는 처음부터 WSWriteTimeout(10s)이 있었는데 SSE 는 없다. 멈춘 클라이언트
(면접 중 노트북 덮개를 닫는 경우)는 TCP 를 열어둔 채 수신 창을 막으므로
Fprintf 가 무기한 블록된다 — OS 가 포기할 때까지 수 분이 걸릴 수 있다.

그 고루틴이 sub.Ch 를 비우지 못하면 버퍼(16)가 차고, 그 채널로 가는 모든
Dispatch 가 slow-consumer 타임아웃을 문다.

## 2. slowTimeout 이 구독자마다 리셋됐다

Dispatch 가 구독자마다 타이머를 새로 세서, 멈춘 탭 3개면 5초 × 3 = 15초가
걸렸다. **AMQP 컨슈머는 단일 스레드 + prefetch=1** 이라(messaging.Consumer)
여기서 막히는 시간이 곧 다른 세션들의 이벤트 지연이다.

realtime/CLAUDE.md §9 는 "그 구독자만 drop, 다른 구독자는 영향 없음" 이라고
적고 있었는데 사실이 아니었다. 같은 채널의 뒷 구독자도, 다른 채널의 세션도
같이 밀렸다.

## 고친 방법

- SSE: http.NewResponseController 로 쓰기마다 데드라인(REALTIME_SSE_WRITE_TIMEOUT,
  기본 10s). 데드라인을 지원하지 않는 ResponseWriter(테스트 recorder 등)에서는
  기존 동작을 유지한다 — 스트림을 실패시키지 않는다.
- Dispatch: slowTimeout 을 **호출 전체의 예산**으로. 멈춘 구독자가 몇이든 한
  메시지당 최악 한 번의 slowTimeout 이다.
- 예산을 다 써도 남은 구독자에게 논블로킹 전송은 시도한다. 앞사람이 멈췄다는
  이유로 건강한 구독자가 이벤트를 잃으면 안 된다.

## 테스트

  예산이 구독자 수만큼 곱해지지 않는다 (멈춘 구독자 3명 × 150ms 예산)
  앞이 멈춰도 뒤의 건강한 구독자는 이벤트를 받는다
  구독자 없는 채널은 기다리지 않는다

가드가 실제로 회귀를 잡는지 확인했다 — 예산을 구독자마다 리셋하도록 되돌리니
첫 테스트가 실패했다.

go test -race ./... 전부 통과. 컨슈머 동시성이 본업인 서버라 -race 는 필수다.
@i3months
i3months merged commit beeff04 into dev Sep 29, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant