From 581d9aeb70e965db6c866afda9eab9f7a5253f7d Mon Sep 17 00:00:00 2001 From: jmj Date: Mon, 24 Aug 2026 01:50:13 +0900 Subject: [PATCH] =?UTF-8?q?test(realtime):=20=EB=A0=88=EC=A7=80=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20+=20CI=20race=20detector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit realtime/CLAUDE.md §11 은 동시성 검증에 `go test -race` 를 규정하는데 CI 는 `go test ./...` 로만 돌고 있었다. 본업이 동시 fan-out 인 컴포넌트에서 경합은 여기서 못 잡으면 운영에서 간헐적 패닉·유실로만 드러난다. 그런데 -race 를 켜도 잡을 게 있으려면 동시 접근을 실제로 만드는 테스트가 있어야 한다. Registry 는 서버 전체의 공유 가변 상태이고 운영에서는 AMQP 컨슈머가 Dispatch 하는 동안 HTTP 핸들러들이 Subscribe/Unsubscribe 하는데, 기존 테스트 2개는 모두 순차 실행이었다. Dispatch 는 의도적으로 락을 놓은 뒤 채널에 쓴다(느린 구독자가 락을 잡으면 다른 구독자까지 막히므로). 그 설계상 "복사한 목록"과 "지금 살아있는 구독자"가 어긋나는 창이 생긴다 — 그 창에서 자료 경합이 없는지, 그리고 churn 중 id 기반 제거가 어긋나 해제된 구독자가 목록에 남지 않는지 확인한다. Makefile 에 test-race 타깃도 추가 — push 전 로컬 확인용. --- .github/workflows/ci.yml | 5 +- realtime/Makefile | 6 +- realtime/internal/session/registry_test.go | 67 ++++++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2b5d832..556d58ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,6 +125,9 @@ jobs: working-directory: ./realtime run: go vet ./... + # -race 로 돌린다. RealTime 은 본업이 동시 fan-out(AMQP 컨슈머가 Dispatch 하는 동안 + # HTTP 핸들러들이 Subscribe/Unsubscribe)이라, 경합은 여기서 못 잡으면 운영에서 + # 간헐적 패닉·유실로만 드러난다. realtime/CLAUDE.md §11 도 -race 를 규정한다. - name: Test working-directory: ./realtime - run: go test ./... + run: go test -race ./... diff --git a/realtime/Makefile b/realtime/Makefile index fc26360a..8d96cdbc 100644 --- a/realtime/Makefile +++ b/realtime/Makefile @@ -1,4 +1,4 @@ -.PHONY: build run test fmt lint tidy clean +.PHONY: build run test test-race fmt lint tidy clean GO ?= go PKG ?= ./... @@ -12,6 +12,10 @@ run: test: $(GO) test $(PKG) +# CI 가 도는 것과 같은 형태. 동시성 변경을 했다면 push 전에 이걸로 확인한다. +test-race: + $(GO) test -race $(PKG) + fmt: $(GO) fmt $(PKG) diff --git a/realtime/internal/session/registry_test.go b/realtime/internal/session/registry_test.go index ed0f12b8..48c61f43 100644 --- a/realtime/internal/session/registry_test.go +++ b/realtime/internal/session/registry_test.go @@ -1,6 +1,7 @@ package session import ( + "sync" "testing" "time" ) @@ -44,3 +45,69 @@ func TestUnsubscribeRemovesChannelEntry(t *testing.T) { t.Errorf("delivered = %d, want 0 after unsubscribe", n) } } + +// Registry 는 RealTime 서버 전체의 공유 가변 상태다. 운영에서는 AMQP 컨슈머 고루틴이 +// Dispatch 하는 동안 HTTP 핸들러들이 Subscribe/Unsubscribe 한다 — 그런데 이 조합을 +// 검증하는 테스트가 없었다. +// +// Dispatch 는 의도적으로 락을 놓은 뒤 채널에 쓴다(느린 구독자가 락을 잡고 있으면 다른 +// 구독자까지 막히므로). 그 설계 때문에 "복사한 구독자 목록"과 "지금 살아있는 구독자"가 +// 어긋나는 창이 생기고, 여기서 어긋남이 자료 경합이 되지 않는지 확인한다. +// +// `-race` 와 함께 돌 때 의미가 있다 (CI 의 `go test -race ./...`). +func TestRegistryConcurrentSubscribeDispatchUnsubscribe(t *testing.T) { + r := NewRegistry() + target := ch(ChannelSession, 1) + + const ( + churnGoroutines = 8 + churnIterations = 200 + dispatchers = 4 + ) + + stop := make(chan struct{}) + var dispatchWG, churnWG sync.WaitGroup + + for i := 0; i < dispatchers; i++ { + dispatchWG.Add(1) + go func() { + defer dispatchWG.Done() + ev := Event{ID: "1", Type: "SESSION_MESSAGE", Data: []byte(`{"a":1}`)} + for { + select { + case <-stop: + return + default: + } + // 느린 구독자 타임아웃은 짧게 — 버퍼가 찬 구독자 때문에 테스트가 늘어지지 않게. + r.Dispatch(target, ev, time.Millisecond) + } + }() + } + + for i := 0; i < churnGoroutines; i++ { + churnWG.Add(1) + go func() { + defer churnWG.Done() + for j := 0; j < churnIterations; j++ { + sub := r.Subscribe(target, 2) + // 한 건 정도 읽어 Dispatch 가 항상 타임아웃으로만 끝나지 않게 한다. + select { + case <-sub.Ch: + default: + } + r.Unsubscribe(target, sub) + } + }() + } + + churnWG.Wait() + close(stop) + dispatchWG.Wait() + + // 모든 구독이 해제됐으면 전달 대상이 남아 있으면 안 된다. id 기반 제거가 churn 중 + // 어긋나면 해제된 구독자가 목록에 남아 여기서 0 이 아니게 된다. + if delivered := r.Dispatch(target, Event{ID: "x"}, time.Millisecond); delivered != 0 { + t.Fatalf("expected no subscribers after full unsubscribe, delivered=%d", delivered) + } +}