Published on

Go gRPC 메모리 누수? bufconn·pprof 추적법

Authors

운영 중인 Go gRPC 서버가 시간이 지날수록 RSS가 꾸준히 증가하거나, 트래픽이 빠진 뒤에도 힙이 잘 내려오지 않는다면 보통은 두 가지가 섞여 있습니다.

  • 실제 누수(참조가 끊기지 않아 GC가 회수 못 함)
  • 누수처럼 보이는 정상 동작(캐시, 풀, 고루틴/버퍼의 일시적 보유, OS에 반환되지 않는 메모리 등)

이 글은 bufconn으로 네트워크 변수를 제거해 재현성을 높이고, pprof로 “무엇이 힙을 붙잡는지”를 증거 기반으로 좁혀가는 절차를 설명합니다. 결론부터 말하면, gRPC 자체가 누수를 일으키는 경우보다 애플리케이션 코드(스트리밍 처리, 컨텍스트/채널 사용, 인터셉터, 로깅, 메트릭, 캐시)가 누수의 원인인 경우가 훨씬 많습니다.

1) 먼저 확인할 것: 정말 누수인가

GC 관점에서의 체크리스트

  • 힙 사용량(HeapAlloc)이 계속 증가하고, 강제 GC 후에도 비슷한 수준을 유지하는가
  • 고루틴 수(runtime.NumGoroutine)가 계속 증가하는가
  • 특정 요청/스트리밍 이후 객체가 계속 살아남는가(프로파일에서 동일 타입이 누적)

운영에서는 OOM으로 죽기 전 징후가 먼저 나타납니다. 리눅스에서 실제로 OOM Killer가 개입했는지, cgroup 제한과 RSS 추이를 함께 보는 것도 중요합니다.

“OS에 메모리를 안 돌려준다” 착시

Go 런타임은 힙을 OS에 즉시 반환하지 않을 수 있습니다. 따라서 RSS만 보고 누수로 단정하기 어렵습니다. 대신 pprof의 heap 프로파일에서 “여전히 살아있는 객체(inuse)”가 증가하는지 확인하는 게 핵심입니다.

2) bufconn으로 재현 환경을 고정하기

bufconn은 in-memory net.Listener로, 실제 TCP 소켓을 열지 않고도 gRPC 서버/클라이언트를 연결할 수 있습니다. 이 방식의 장점은 다음과 같습니다.

  • 네트워크 지연, 커널 버퍼, 커넥션 재시도 등 변수를 제거
  • 테스트에서 서버를 같은 프로세스 안에 띄워 빠르게 반복
  • 누수 재현을 “단위 테스트 수준”으로 격리 가능

bufconn 기반 최소 재현 스켈레톤

아래 예시는 gRPC 서버를 bufconn 위에 올리고, 클라이언트가 Dial 할 때 WithContextDialer로 in-memory 연결을 사용합니다.

package leaktest

import (
  "context"
  "net"
  "testing"
  "time"

  "google.golang.org/grpc"
  "google.golang.org/grpc/credentials/insecure"
  "google.golang.org/grpc/test/bufconn"
)

const bufSize = 1024 * 1024

func newBufconnServer(t *testing.T, register func(*grpc.Server)) (*grpc.Server, *bufconn.Listener) {
  t.Helper()

  lis := bufconn.Listen(bufSize)
  s := grpc.NewServer(
    // 필요 시 Unary/Stream interceptor를 여기에 추가
  )
  register(s)

  go func() {
    _ = s.Serve(lis)
  }()

  t.Cleanup(func() {
    s.Stop()
    _ = lis.Close()
  })

  return s, lis
}

func bufDialer(lis *bufconn.Listener) func(context.Context, string) (net.Conn, error) {
  return func(ctx context.Context, _ string) (net.Conn, error) {
    return lis.Dial()
  }
}

func newBufconnClientConn(t *testing.T, lis *bufconn.Listener) *grpc.ClientConn {
  t.Helper()

  ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  t.Cleanup(cancel)

  cc, err := grpc.DialContext(
    ctx,
    "bufnet",
    grpc.WithContextDialer(bufDialer(lis)),
    grpc.WithTransportCredentials(insecure.NewCredentials()),
  )
  if err != nil {
    t.Fatalf("dial: %v", err)
  }

  t.Cleanup(func() { _ = cc.Close() })
  return cc
}

핵심은 “반복 가능한 부하를 테스트에서 만든다”입니다. 누수는 재현이 되면 절반은 해결된 겁니다.

3) pprof를 붙여서 ‘증거’ 만들기

(A) HTTP로 pprof 엔드포인트 노출

운영 서버에 바로 붙이기 어렵다면, 동일 설정의 스테이징이나 로컬에서 먼저 재현하세요.

import (
  "log"
  "net/http"
  _ "net/http/pprof"
)

func startPprof() {
  go func() {
    // 내부망에서만 접근되게 하거나 포트포워딩으로 접근 권장
    log.Println(http.ListenAndServe("127.0.0.1:6060", nil))
  }()
}

(B) 힙 프로파일: allocs vs heap 차이

  • heap은 “현재 살아있는(inuse)” 메모리 중심
  • allocs는 “누적 할당” 중심(많이 할당하지만 잘 회수되는 코드 찾기)

실제 누수는 보통 heap의 inuse가 내려오지 않는 형태로 나타납니다.

(C) 스냅샷 2개를 떠서 비교하기

  1. 부하 전 스냅샷
  2. 부하 후 스냅샷
# 부하 전
curl -sS http://127.0.0.1:6060/debug/pprof/heap > heap_before.pb.gz

# 부하(테스트 또는 로드툴)

# 부하 후
curl -sS http://127.0.0.1:6060/debug/pprof/heap > heap_after.pb.gz

그리고 pprof에서 diff를 봅니다.

go tool pprof -http=:0 -diff_base heap_before.pb.gz heap_after.pb.gz

웹 UI에서 “Top”, “Flame Graph”로 올라온 함수의 경로를 따라가면, 누수가 의심되는 보유 지점을 빠르게 찾을 수 있습니다.

(D) 고루틴 누수 의심 시 goroutine 프로파일

스트리밍 RPC나 백그라운드 워커가 누수의 단골입니다.

curl -sS http://127.0.0.1:6060/debug/pprof/goroutine?debug=2 > goroutines.txt

여기서 같은 스택이 계속 늘어나는지(예: recv 루프, select 대기, 채널 send/recv 대기)를 확인합니다.

4) bufconn 부하 테스트로 누수 재현하기

다음은 “요청을 많이 보내고, GC를 유도하고, 힙을 관찰”하는 패턴입니다. 실제로는 여러분의 서비스 메서드를 호출하도록 바꾸면 됩니다.

func TestMemoryGrowth(t *testing.T) {
  _, lis := newBufconnServer(t, func(s *grpc.Server) {
    // pb.RegisterYourServiceServer(s, impl)
  })
  cc := newBufconnClientConn(t, lis)
  _ = cc

  // client := pb.NewYourServiceClient(cc)

  // 부하: 요청을 반복
  for i := 0; i < 20000; i++ {
    // _, err := client.YourUnary(ctx, &pb.Req{...})
    // if err != nil { t.Fatal(err) }
  }

  // 여기서 바로 결론 내리지 말고, pprof로 비교하거나
  // runtime.ReadMemStats로 지표를 찍어 추세를 확인
}

테스트만으로 “누수다/아니다”를 단정하긴 어렵지만, 적어도 특정 호출 패턴이 힙을 계속 붙잡는지 빠르게 좁힐 수 있습니다.

5) gRPC에서 자주 나오는 ‘진짜 누수’ 패턴

아래는 pprof에서 자주 적발되는 사례들입니다.

5-1) 스트리밍 RPC에서 컨텍스트 취소를 무시

클라이언트가 끊겼는데도 서버가 계속 루프를 돌면 고루틴과 버퍼가 살아남습니다.

func (s *Server) Chat(stream pb.Svc_ChatServer) error {
  ctx := stream.Context()

  for {
    select {
    case <-ctx.Done():
      return ctx.Err()
    default:
      msg, err := stream.Recv()
      if err != nil {
        return err
      }
      _ = msg
    }
  }
}

포인트는 stream.Context().Done()을 루프의 종료 조건으로 확실히 포함하는 것입니다.

5-2) 受信 메시지나 큰 바이트 슬라이스를 전역/캐시에 보관

요청 바디를 그대로 캐시에 넣거나, 로깅/메트릭 태그에 원문을 넣는 경우가 많습니다.

  • []byte를 맵에 저장
  • 에러/로그에 큰 문자열로 변환해 보관
  • span/trace attribute에 payload를 실어 메모리 체류

해결은 “필요한 필드만 복사해 저장” 또는 “크기 제한”입니다.

// 나쁜 예: payload 전체를 키로/값으로 저장
cache[key] = req.Payload

// 개선: 필요한 부분만, 또는 해시만 저장
cache[key] = sha256.Sum256(req.Payload)

5-3) 인터셉터에서 요청/응답을 캡처해 누적

디버깅을 위해 unary interceptor에서 요청을 슬라이스에 append 하거나, ring buffer 없이 무한히 쌓는 형태가 흔합니다.

var capturedMu sync.Mutex
var captured = make([][]byte, 0, 1000)

func captureInterceptor(
  ctx context.Context,
  req any,
  info *grpc.UnaryServerInfo,
  handler grpc.UnaryHandler,
) (any, error) {
  // req를 직렬화해서 저장하는 순간, 큰 메모리가 오래 살아남을 수 있음
  b, _ := json.Marshal(req)

  capturedMu.Lock()
  captured = append(captured, b) // 무한 증가 위험
  capturedMu.Unlock()

  return handler(ctx, req)
}

개선안은 “샘플링”, “최대 크기 제한”, “ring buffer”, “운영에서는 비활성화”입니다.

5-4) 커넥션/클라이언트 재사용 실패

서버 내부에서 다른 gRPC 서비스를 호출하는 경우, 요청마다 grpc.Dial을 새로 만들고 닫지 않으면 누수처럼 보이는 커넥션/고루틴 증가가 발생합니다.

  • grpc.ClientConn은 재사용이 기본
  • 프로세스 라이프사이클에서 1개 또는 대상별 소수만 유지
// 권장: 앱 시작 시 Dial, 종료 시 Close
cc, err := grpc.Dial(target, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil { return err }
defer cc.Close()

테스트에서는 t.Cleanup으로 닫히는지 확인하세요.

5-5) 채널 send로 막혀 고루틴이 쌓임

비동기 로깅/이벤트 전송에서 버퍼가 꽉 차면, producer 고루틴이 send에서 영원히 대기할 수 있습니다.

func publish(ch chan []byte, b []byte) {
  // 위험: 소비자가 느리면 여기서 고루틴이 적체
  ch <- b
}

// 개선: non-blocking 또는 타임아웃
func publishWithDrop(ch chan []byte, b []byte) {
  select {
  case ch <- b:
  default:
    // drop 또는 카운트
  }
}

pprof goroutine에서 chan send 대기가 대량으로 보이면 이 케이스를 의심합니다.

6) pprof에서 무엇을 봐야 하는가: 실전 체크 포인트

힙(Heap)에서 보는 것

  • inuse_space 기준으로 상위 타입/함수
  • 특정 타입(예: []byte, map[...]..., *bytes.Buffer)이 지속 증가
  • gRPC 프레임워크 내부보다 “내 코드” 경로가 위에 있는지

go tool pprof에서 자주 쓰는 명령은 아래 정도면 충분합니다.

go tool pprof heap_after.pb.gz

# pprof 콘솔에서
# (pprof) top
# (pprof) top -cum
# (pprof) list YourFunction

고루틴(Goroutine)에서 보는 것

  • 동일 스택의 고루틴이 반복적으로 증가
  • Recv/Send 루프가 종료되지 않는 패턴
  • select {}로 영원히 대기하는 고루틴

7) 운영 적용 팁: 안전하게 관찰하고, 재현하고, 고친다

  • pprof는 접근 제어가 필수입니다. 로컬 바인딩(127.0.0.1) 후 포트포워딩을 권장합니다.
  • “부하 전/후 diff”가 가장 강력합니다. 단일 스냅샷은 해석이 흔들립니다.
  • bufconn으로 재현 테스트를 만들면, 수정 후 회귀 방지가 됩니다.

쿠버네티스 환경이라면, 메모리 증가가 실제로는 재시작 루프나 제한 설정 문제와 얽혀 있을 수도 있습니다.

8) 빠른 결론: 추천 추적 루트

  1. bufconn으로 “문제 RPC 호출 패턴”을 테스트로 격리
  2. pprof heap 스냅샷을 부하 전/후로 2개 수집
  3. -diff_base로 증가분만 확인
  4. goroutine 프로파일로 고루틴 적체 여부 확인
  5. 흔한 패턴(스트리밍 종료, 인터셉터 누적, 채널 블로킹, Dial 재사용 실패, 큰 payload 보관)을 우선 점검

이 흐름대로 하면 “gRPC가 누수다” 같은 막연한 결론 대신, 어떤 타입이 어떤 경로로 살아남는지까지 좁혀서 수정할 수 있습니다.