100개의 고루틴이 gRPC 서버를 과도하게 호출하는 것을 막는 방법 — Loom 파트 2
이 시리즈는 Loom을 만드는 두 번째 파트입니다.
👉 1부를 놓치셨나요? 여기서 읽어보세요
오늘: 리플렉션 캐시, 스탬피드 방지, 그리고 밤 11시까지 나를 깨우던 데드락.
문제
50개의 goroutine이 동시에 같은 메서드 디스크립터가 필요하면, 내가 처음에 짠 단순한 코드는 모두 백엔드에 요청을 보내게 됩니다.
func (c *ReflectionCache) GetMethod(method string) (*MethodDescriptor, error) {
return c.fetchFromBackend(method) // 🔥 50번 RPC 호출
}
결과: 동일한 호출이 50번 발생 → 50배 부하, 50배 지연. 좋지 않죠.
해결책: singleflight
Go 표준 라이브러리 외부에 있는 golang.org/x/sync/singleflight 를 사용하면 하나의 goroutine만 실제 fetch를 수행하고, 나머지는 그 결과를 기다리게 할 수 있습니다.
최종 코드
import "golang.org/x/sync/singleflight"
type ReflectionCache struct {
cache map[string]*MethodDescriptor
mu sync.RWMutex
group singleflight.Group
}
func (c *ReflectionCache) GetMethod(method string) (*MethodDescriptor, error) {
// Fast path: 이미 캐시돼 있나요?
c.mu.RLock()
if desc, ok := c.cache[method]; ok {
c.mu.RUnlock()
return desc, nil
}
c.mu.RUnlock()
// Slow path: 한 번만 fetch, 모두가 결과를 기다림
result, err, _ := c.group.Do(method, func() (interface{}, error) {
desc, err := c.fetchFromBackend(method)
if err != nil {
return nil, err
}
c.mu.Lock()
c.cache[method] = desc
c.mu.Unlock()
return desc, nil
})
return result.(*MethodDescriptor), err
}
무엇이 달라졌나요? 백엔드 호출이 50번이 아니라 1번만 발생합니다. 50개의 goroutine이 약 50 ms 안에 결과를 받게 되고, 전체 지연은 2500 ms가 아닌 50 ms 정도가 됩니다.
당황스러운 데드락
먼저 직접 구현해 보면서 3시간을 잡아먹은 버그가 있습니다.
// ⚠️ DEADLOCK — 이렇게 하지 마세요
func (c *ReflectionCache) GetMethod(method string) (*MethodDescriptor, error) {
c.mu.Lock()
defer c.mu.Unlock() // ❌ 나중에 실행됩니다
// ... 캐시 확인 ...
c.mu.Unlock() // 수동 언락
desc, _ := c.fetchFromBackend(method)
c.mu.Lock() // 다시 락
return desc, nil // defer가 아직도 언락을 시도 → panic
}
교훈: defer와 수동 Lock/Unlock을 섞어 쓰면 안 됩니다. 그리고 직접 구현하기보다 singleflight를 활용하세요.
성능 비교
| 접근 방식 | 백엔드 호출 수 (100 요청) | 전체 시간 |
|---|---|---|
| 캐시 없음 | 100 | 5000 ms |
| 뮤텍스만 사용 | 1 | 5000 ms |
| singleflight | 1 | ≈ 52 ms |
96 % 빠른 속도!
핵심 정리
- 캐시 스탬피드는 실제 문제입니다. 백엔드를 압도할 수 있습니다.
singleflight는 최고의 친구 – 직접 구현하지 마세요.-race옵션으로 테스트하면 데드락을 쉽게 잡을 수 있습니다.- 캐시 히트 시에는 읽기 락(RLock) 을 사용해 경쟁을 최소화하세요.
- 직접 Loom을 사용해 보세요.
Loom
gRPC 디버깅 프록시입니다. 백엔드 앞에 두고, 클라이언트를 Loom에 연결하면 모든 호출을 브라우저 탭에서 실시간으로 디코딩해 볼 수 있습니다.
Your gRPC Client → Loom (:9999) → Your Backend (:50051)
↓
Web Inspector
http://localhost:9998
왜 만들었나요?
gRPC 트래픽은 바이너리 형태라 Wireshark로는 읽을 수 없습니다. grpcurl 은 일회성 호출에 좋지만 흐름을 전체적으로 관찰하기엔 부족합니다. 서비스 간에 무슨 일이 일어나는지 파악하려고 계속 반복해서 실행했었습니다.
Loom은 클라이언트와 백엔드 사이에 투명하게 끼어들어 Server Reflection 을 이용해 실시간으로 모든 프레임을 디코딩합니다. .proto 파일이 전혀 필요 없으며, 결과를 브라우저 UI 로 스트리밍합니다. JSON 페이로드, 상태 코드, 호출 소요 시간, 그리고 재실행용 grpcurl 명령까지 바로 복사해서 사용할 수 있습니다.
주요 기능
- 네 가지 gRPC 스트림 타입 모두 가로채기 — unary, server‑streaming, client‑streaming, bidi
- Server Reflection 기반 자동 디코딩 (proto 파일 불필요)
- 호출당 JSON 페이로드, 상태 코드, 지연 시간 표시
- 클릭 한 번으로
grpcurl재생성 가능
Loom을 직접 사용해 보시고, gRPC 디버깅을 한층 편리하게 경험해 보세요.