100개의 고루틴이 gRPC 서버를 과도하게 호출하는 것을 막는 방법 — Loom 파트 2

발행: (2026년 6월 7일 PM 11:16 GMT+9)
6 분 소요
원문: Dev.to

이 시리즈는 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 요청)전체 시간
캐시 없음1005000 ms
뮤텍스만 사용15000 ms
singleflight1≈ 52 ms

96 % 빠른 속도!


핵심 정리

  • 캐시 스탬피드는 실제 문제입니다. 백엔드를 압도할 수 있습니다.
  • singleflight는 최고의 친구 – 직접 구현하지 마세요.
  • -race 옵션으로 테스트하면 데드락을 쉽게 잡을 수 있습니다.
  • 캐시 히트 시에는 읽기 락(RLock) 을 사용해 경쟁을 최소화하세요.
  • 직접 Loom을 사용해 보세요.

Loom

gRPC 디버깅 프록시입니다. 백엔드 앞에 두고, 클라이언트를 Loom에 연결하면 모든 호출을 브라우저 탭에서 실시간으로 디코딩해 볼 수 있습니다.

Your gRPC Client  →  Loom (:9999)  →  Your Backend (:50051)

                    Web Inspector
                  http://localhost:9998

Go Version License: MIT

왜 만들었나요?

gRPC 트래픽은 바이너리 형태라 Wireshark로는 읽을 수 없습니다. grpcurl 은 일회성 호출에 좋지만 흐름을 전체적으로 관찰하기엔 부족합니다. 서비스 간에 무슨 일이 일어나는지 파악하려고 계속 반복해서 실행했었습니다.

Loom은 클라이언트와 백엔드 사이에 투명하게 끼어들어 Server Reflection 을 이용해 실시간으로 모든 프레임을 디코딩합니다. .proto 파일이 전혀 필요 없으며, 결과를 브라우저 UI 로 스트리밍합니다. JSON 페이로드, 상태 코드, 호출 소요 시간, 그리고 재실행용 grpcurl 명령까지 바로 복사해서 사용할 수 있습니다.

주요 기능

  • 네 가지 gRPC 스트림 타입 모두 가로채기 — unary, server‑streaming, client‑streaming, bidi
  • Server Reflection 기반 자동 디코딩 (proto 파일 불필요)
  • 호출당 JSON 페이로드, 상태 코드, 지연 시간 표시
  • 클릭 한 번으로 grpcurl 재생성 가능

Loom을 직접 사용해 보시고, gRPC 디버깅을 한층 편리하게 경험해 보세요.

0 조회
Back to Blog

관련 글

더 보기 »