Published on

Go goroutine 누수 추적 - pprof+trace로 잡기

Authors

서비스가 점점 느려지고, CPU는 높지 않은데 메모리와 핸들 수가 오르고, 배포 후 몇 시간 지나면 타임아웃이 늘어난다면 goroutine 누수를 의심할 만합니다. Go에서 goroutine은 가볍지만 공짜가 아닙니다. 누수가 지속되면 다음 문제가 연쇄적으로 터집니다.

  • 스케줄러 큐가 불어나 컨텍스트 스위칭 비용 증가
  • net/http 커넥션, 타이머, 채널 대기 등으로 간접 리소스 고갈
  • GC 부담 증가로 tail latency 악화
  • 결국 context deadline exceeded가 폭발적으로 증가

이 글은 pprof로 “무엇이 얼마나 쌓였는지”를 확인하고, runtime/trace로 “왜 끝나지 않는지”를 시간축으로 파헤치는 방법을 하나의 디버깅 플로우로 묶습니다. 네트워크 타임아웃 계열 증상과도 맞물리므로, 원인 분석 관점은 Go gRPC context deadline exceeded 원인 7가지 글과 함께 보면 더 빠르게 좁힐 수 있습니다.

goroutine 누수의 전형적인 패턴

누수는 “goroutine을 만들었는데 종료 조건이 영원히 오지 않는” 상태입니다. 대표 패턴은 다음과 같습니다.

1) for { select { ... } }에서 ctx.Done() 미처리

select에 종료 브랜치가 없거나, ctx를 전달받고도 무시하는 경우입니다.

2) 채널 송수신이 영원히 블록

  • 받는 쪽이 사라졌는데 보내는 쪽이 계속 send
  • 보내는 쪽이 사라졌는데 받는 쪽이 계속 recv
  • 버퍼 크기 산정 실패로 특정 시점부터 정체

3) time.Tick 사용

time.Tick은 내부 타이머를 해제할 방법이 없어 장기 실행 프로세스에서 누수로 이어질 수 있습니다. time.NewTickerStop()을 쓰는 게 안전합니다.

4) HTTP 바디 미정리

resp.Body.Close() 누락이나 바디를 끝까지 읽지 않아 커넥션이 풀로 반환되지 않으면, 외형상 goroutine 누수처럼 보이는 정체가 생깁니다.

1단계: pprof로 “쌓이는지”부터 증명하기

먼저 관측 가능한 지표로 누수를 증명해야 합니다.

  • goroutine 수가 시간에 따라 단조 증가
  • 특정 스택이 반복적으로 상위에 등장

pprof 엔드포인트 붙이기

운영에서 바로 붙이기 어렵다면, 내부망/관리 포트로 분리해 노출하는 패턴이 흔합니다.

package main

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

func main() {
	go func() {
		// 운영에서는 127.0.0.1 바인딩 + 보안그룹/프록시로 제한 권장
		log.Println(http.ListenAndServe("127.0.0.1:6060", nil))
	}()

	// ... 실제 서버 로직
	select {}
}

이제 다음 URL이 활성화됩니다.

  • http://127.0.0.1:6060/debug/pprof/
  • goroutine 프로파일: http://127.0.0.1:6060/debug/pprof/goroutine?debug=2

goroutine 덤프를 텍스트로 빠르게 보기

curl -s "http://127.0.0.1:6060/debug/pprof/goroutine?debug=2" | head -n 80

여기서 같은 스택이 수백, 수천 개 반복되면 “누수가 맞다”는 1차 결론을 낼 수 있습니다.

pprof로 스택 집계 보기

go tool pprof -http=:0 "http://127.0.0.1:6060/debug/pprof/goroutine"

브라우저 UI에서 다음을 중점적으로 봅니다.

  • Top: goroutine을 가장 많이 보유한 함수
  • Graph: 어떤 호출 경로에서 생성되어 어디서 멈췄는지

핵심은 “생성 지점”과 “대기 지점”을 분리해 보는 것입니다. 예를 들어 생성은 (*Server).handleConn인데 대기는 chan receiveselect라면, 종료 조건이 없거나 채널이 닫히지 않는 구조일 확률이 큽니다.

2단계: trace로 “왜 안 끝나는지”를 시간축으로 확인하기

pprof는 스냅샷에 강합니다. 하지만 누수는 시간에 따라 누적되는 현상이라, 다음 질문이 남습니다.

  • 어떤 이벤트 이후 goroutine이 급증했나
  • 특정 락/채널에서 장시간 대기하는가
  • 네트워크/타이머/GC가 어떤 순서로 영향을 주는가

이때 runtime/trace가 결정적입니다.

trace 파일을 생성하는 HTTP 핸들러

운영에서 무작정 trace를 오래 뜨면 오버헤드가 커질 수 있으니, 짧게 3초에서 10초 정도만 떠서 재현 구간을 잡는 전략이 안전합니다.

package debug

import (
	"net/http"
	"runtime/trace"
)

func TraceHandler(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/octet-stream")
	w.Header().Set("Content-Disposition", "attachment; filename=trace.out")

	if err := trace.Start(w); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	defer trace.Stop()

	// 짧은 구간만 캡처
	// time.Sleep(5 * time.Second)
}

위처럼 작성하면 trace.Start부터 trace.Stop까지 요청이 유지되는 동안만 trace가 기록됩니다. 일반적으로는 time.Sleep로 고정 시간을 두거나, 클라이언트가 일정 시간 뒤 연결을 끊는 방식으로 캡처 구간을 제어합니다.

trace 수집 및 보기

curl -o trace.out "http://127.0.0.1:6060/debug/trace"
go tool trace trace.out

브라우저 뷰어에서 다음을 봅니다.

  • Goroutines: goroutine 생성/종료 타임라인
  • Network blocking profile: 네트워크로 인한 블로킹
  • Sync blocking profile: 뮤텍스/채널 등 동기화 블로킹
  • User regions를 넣었다면 특정 요청 구간의 이벤트 상관관계

pprof에서 “스택이 같은 goroutine이 많다”를 확인했다면, trace에서는 그 goroutine들이 실제로 언제 생성되고 어떤 이유로 runnable 상태를 못 벗어나는지까지 볼 수 있습니다.

3단계: 재현 가능한 최소 예제로 누수 만들고 잡아보기

누수 디버깅은 재현성이 생명입니다. 아래는 매우 흔한 실수인 “워커 goroutine이 종료 신호를 못 받고 영원히 select로 대기”하는 예시입니다.

누수 예제

package main

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

func leak(ctx context.Context, ch <-chan int) {
	for {
		select {
		case <-ch:
			// 작업 처리
			return
		// 버그: ctx.Done()이 없음. 호출자가 취소해도 끝나지 않음.
		}
	}
}

func main() {
	go func() {
		log.Println(http.ListenAndServe("127.0.0.1:6060", nil))
	}()

	ch := make(chan int)

	for i := 0; i < 100000; i++ {
		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
		_ = cancel
		go leak(ctx, ch)
	}

	select {}
}

위 코드는 ctx가 만료되어도 goroutine이 종료되지 않습니다. pprof goroutine 덤프에는 select 대기 스택이 대량으로 쌓일 것입니다.

수정 예제: 종료 브랜치 추가

func leakFixed(ctx context.Context, ch <-chan int) {
	for {
		select {
		case <-ch:
			return
		case <-ctx.Done():
			return
		}
	}
}

이 수정만으로도 goroutine 수가 단조 증가하는 현상이 사라집니다.

4단계: pprof와 trace를 함께 읽는 실전 해석법

pprof에서 먼저 볼 것: “대기 지점”

goroutine 프로파일에서 상위에 자주 등장하는 대기 지점은 보통 다음 중 하나입니다.

  • chan receive 또는 chan send
  • select
  • sync.(*Mutex).Lock
  • net.(*pollDesc).wait 같은 네트워크 대기

이 대기 지점이 나쁘다는 뜻이 아니라, “왜 이 대기가 풀리지 않는지”를 찾아야 합니다.

trace에서 확인할 것: “막히는 순서”

trace는 다음을 밝히는 데 강합니다.

  • 특정 요청이 들어온 뒤 goroutine 생성이 폭증
  • 어떤 락 경합 때문에 요청들이 줄줄이 대기
  • 타이머 이벤트가 과도하게 생성
  • 네트워크 블로킹이 길어지고 컨텍스트 취소가 전파되지 않음

특히 컨텍스트 취소가 전파되지 않으면, 겉으로는 네트워크 지연처럼 보여도 실제 원인은 “취소 신호를 무시하는 내부 goroutine”인 경우가 많습니다.

5단계: 운영에서 안전하게 적용하는 팁

pprof 노출 최소화

  • 바인딩을 127.0.0.1로 제한
  • 별도 관리 포트 사용
  • 인그레스/프록시 뒤에 붙인다면 인증/ACL 적용

trace는 짧게, 필요한 순간에만

trace는 정보가 많은 대신 비용도 있습니다. 다음 원칙을 권장합니다.

  • 3초에서 10초 캡처로 충분한 경우가 많음
  • 재현 트래픽이 들어오는 타이밍에 맞춰 캡처
  • 캡처 전후로 goroutine 수, 요청 수, 에러율을 함께 기록

배포 환경 이슈와 함께 볼 것

goroutine 누수는 프로세스 내부 문제지만, 운영에서는 재시작 정책과 결합되어 “원인 미상 재시작 루프”처럼 보이기도 합니다. 서비스가 비정상 종료하거나 OOM으로 죽었다면 systemd 로그와 함께 상관관계를 확인하는 게 좋습니다.

체크리스트: 누수 방지 코딩 규칙

  • goroutine을 만들 때 “종료 조건”을 함수 시그니처로 강제하기: ctx context.Context를 받게 하고 ctx.Done()을 반드시 처리
  • 채널을 닫는 주체를 명확히 하고, 닫힘을 수신 측에서 처리하기
  • time.Tick 대신 ticker := time.NewTicker(d)defer ticker.Stop()
  • http.Response.Bodydefer resp.Body.Close() + 필요 시 바디를 끝까지 읽어 커넥션 재사용 보장
  • fan-out 패턴에서는 errgroup 같은 구조로 수명 관리를 일원화

마무리: “스냅샷”과 “시간축”을 같이 써야 잡힌다

goroutine 누수는 대부분 “어딘가에서 끝나지 않는 대기”입니다. pprof는 어떤 스택이 쌓이는지 빠르게 증명해 주고, trace는 그 대기가 어떤 이벤트 순서로 고착되는지 보여줍니다. 둘을 합치면 다음 루프가 완성됩니다.

  • pprof로 누수 스택 후보를 찾는다
  • trace로 생성과 블로킹의 타임라인을 확인한다
  • 최소 수정으로 종료 조건을 추가하거나 취소 전파를 보장한다
  • 다시 pprof로 goroutine 수가 안정화되는지 검증한다

이 과정을 습관화하면, “간헐적 타임아웃”이나 “점진적 성능 저하”처럼 애매한 장애도 재현 가능한 증거로 바꿔서 해결할 수 있습니다.