Published on

Go 고루틴 누수 5분 진단 - pprof·trace로 잡기

Authors

서버가 멀쩡히 트래픽을 처리하던 중 메모리와 CPU가 서서히 오르고, 재시작하면 잠깐 정상으로 돌아오는 유형의 장애는 흔히 goroutine leak에서 시작합니다. 고루틴은 가볍지만 무한정 쌓이면 스케줄링 비용, 힙 증가, GC 압박으로 이어지고, 결국 지연이나 OOM으로 번집니다.

이 글은 “원인 분석을 길게”가 아니라 5분 내에 누수 후보를 특정하는 데 초점을 둡니다. 핵심 도구는 net/http/pprofgo tool trace이며, 마지막에는 누수를 만드는 대표 패턴과 방지 템플릿을 제공합니다.

운영에서 재현이 어려운 누수는 종종 인프라 증상으로 먼저 보입니다. 예를 들어 파드가 자주 죽는다면 K8s CrashLoopBackOff 원인 10가지·즉시 진단법처럼 “즉시 점검” 루틴과 함께 보면 원인 좁히기가 빨라집니다.

고루틴 누수의 전형적 증상

다음 중 하나라도 보이면 의심해야 합니다.

  • runtime.NumGoroutine()가 시간에 따라 단조 증가
  • pprof에서 goroutine 프로파일을 보면 같은 스택이 수백~수천 개 반복
  • 요청 타임아웃이 늘어나고, GC 주기가 짧아지며, 메모리 사용량이 계단형으로 상승
  • 외부 의존성(DB, Kafka, HTTP) 장애 시 고루틴이 급증하고, 장애가 해소돼도 줄지 않음

고루틴 누수는 단독으로 발생하기도 하지만, DB 락/풀 고갈 같은 병목이 누수를 “폭발”시키는 트리거가 되기도 합니다. DB 쪽이 의심되면 MySQL InnoDB 히든 병목 7종 - 잠금·버퍼·Redo 튜닝 같은 체크리스트와 함께 보는 것도 실무적으로 도움이 됩니다.

0단계: 1분 준비(운영에 안전한 pprof 노출)

가장 빠른 진단은 pprof를 붙이는 것입니다. 보통 서비스 포트와 분리해 로컬/내부망에서만 접근 가능한 디버그 포트로 띄웁니다.

package main

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

func startDebugServer() {
	// 운영에서는 127.0.0.1 바인딩 또는 내부망/인증 프록시를 권장
	go func() {
		addr := "127.0.0.1:6060"
		log.Printf("pprof listening on %s", addr)
		_ = http.ListenAndServe(addr, nil)
	}()
}

func main() {
	startDebugServer()
	// ... main server
	select {}
}

접속 확인:

  • curl http://127.0.0.1:6060/debug/pprof/

1단계(2분): 고루틴 프로파일로 “누수 스택 Top 3” 뽑기

고루틴 누수는 대부분 같은 위치에서 생성된 고루틴이 종료되지 않고 대기합니다. 그래서 goroutine 프로파일이 직관적입니다.

# 현재 고루틴 스택 덤프(텍스트)
curl -s "http://127.0.0.1:6060/debug/pprof/goroutine?debug=2" > goroutines.txt

# pprof로 요약 보기(Top)
go tool pprof -top "http://127.0.0.1:6060/debug/pprof/goroutine"

# 웹 UI로 보기(그래프)
go tool pprof -http=:0 "http://127.0.0.1:6060/debug/pprof/goroutine"

여기서 확인할 포인트는 3가지입니다.

  1. 같은 스택이 반복되는가: 동일 함수 체인이 수백 개면 그 지점이 1순위
  2. 상태 키워드: chan receive, chan send, select, IO wait, semacquire 같은 블로킹
  3. 생성 경로: 스택에 created by가 나오면 “누가 만들었는지” 역추적 가능

debug=2 덤프에서 흔히 보게 되는 패턴 예시는 다음과 같습니다.

  • goroutine ... [chan receive] : 채널 수신 대기(생산자 종료/close 누락 가능)
  • goroutine ... [select] : select {} 혹은 취소 없는 대기
  • goroutine ... [IO wait] : 네트워크/파일 IO에서 영구 대기(타임아웃 부재)

5분 루틴의 핵심: “증가하는 스택”만 잡아도 절반은 끝

운영에서는 스냅샷을 두 번 떠서 비교하면 더 확실합니다.

  • t0에 덤프 저장
  • 1~3분 후 t1에 다시 저장
  • t1에서 특정 스택이 눈에 띄게 늘었다면 그게 누수 후보

2단계(2분): trace로 “왜 스케줄링이 막히는지” 확인

pprof가 “어디서 많이 멈춰 있나”를 보여준다면, trace는 “언제부터 어떤 이벤트 때문에 막혔나”를 보여줍니다.

# 5초 정도만 짧게 떠도 충분한 경우가 많습니다.
curl -s "http://127.0.0.1:6060/debug/pprof/trace?seconds=5" -o trace.out

go tool trace trace.out

브라우저 UI에서 주로 보는 곳:

  • Goroutines: 특정 고루틴이 Runnable에서 Waiting으로 가는 지점
  • Network blocking profile: 네트워크 블로킹이 많은지
  • Synchronization blocking profile: 뮤텍스/채널 동기화 대기

trace에서 “대기 이벤트가 길게 이어지고, 같은 코드 경로가 반복”되면 누수의 실마리가 됩니다.

3단계(선택): heap과 allocs로 “누수의 2차 피해” 확인

고루틴 자체도 스택/메타데이터를 쓰지만, 더 큰 문제는 고루틴이 붙잡고 있는 버퍼/요청 컨텍스트/응답 바디 같은 객체입니다.

# 현재 힙 스냅샷
go tool pprof -http=:0 "http://127.0.0.1:6060/debug/pprof/heap"

# 할당(allocs) 관점으로 보기
go tool pprof -http=:0 "http://127.0.0.1:6060/debug/pprof/allocs"

고루틴 누수가 힙 누수처럼 보이는 경우가 많아서, goroutineheap을 같이 보면 “대기 중인 고루틴이 어떤 객체를 붙잡는지” 감이 잡힙니다.

누수를 만드는 대표 패턴 6가지와 즉시 수정 템플릿

이 섹션이 재발 방지의 핵심입니다. pprof로 위치를 찾았다면, 아래 패턴 중 무엇인지 매칭해 고치면 됩니다.

1) context 취소를 전파하지 않음

요청이 끝났는데 백그라운드 작업이 계속 돌면 누수입니다.

나쁜 예:

func handler(w http.ResponseWriter, r *http.Request) {
	go doWork(context.Background()) // 요청 취소와 무관
	w.WriteHeader(http.StatusAccepted)
}

좋은 예:

func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	go doWork(ctx)
	w.WriteHeader(http.StatusAccepted)
}

func doWork(ctx context.Context) {
	select {
	case <-ctx.Done():
		return
	case <-time.After(200 * time.Millisecond):
		// ...
	}
}

2) 채널 송신이 영구 블로킹(수신자 없음)

버퍼가 없는 채널에서 수신자가 사라지면 송신 고루틴은 영원히 멈춥니다.

func producer(ch chan<- int) {
	for i := 0; ; i++ {
		ch <- i // 수신자 없으면 영구 블로킹
	}
}

해결 방향:

  • 컨텍스트로 종료 조건 추가
  • 버퍼 채널로 완화하되 “무한 버퍼”로 착각하지 않기
  • 드롭 전략(샘플링) 도입
func producer(ctx context.Context, ch chan<- int) {
	for i := 0; ; i++ {
		select {
		case <-ctx.Done():
			return
		case ch <- i:
		}
	}
}

3) 채널 수신이 영구 대기(close 누락)

작업자 고루틴이 range ch로 돌고 있는데, 생산자가 종료되면서 close(ch)를 안 하면 작업자는 영원히 기다립니다.

func worker(ch <-chan Job) {
	for job := range ch {
		_ = job
	}
	// ch가 close되지 않으면 여기로 못 옴
}

해결:

  • 생산자 종료 시 close(ch)
  • 여러 생산자면 sync.WaitGroup으로 마지막 생산자가 닫기

4) time.Tick 사용으로 타이머 누수

time.Tick은 내부적으로 ticker를 만들고, 명시적으로 멈출 방법이 없습니다. 루프/요청 단위로 만들면 누수로 이어질 수 있습니다.

나쁜 예:

func doPeriodic() {
	for range time.Tick(1 * time.Second) {
		// ...
	}
}

좋은 예:

func doPeriodic(ctx context.Context) {
	t := time.NewTicker(1 * time.Second)
	defer t.Stop()

	for {
		select {
		case <-ctx.Done():
			return
		case <-t.C:
			// ...
		}
	}
}

5) http.Client 타임아웃/바디 정리 누락

외부 HTTP 호출에서 타임아웃이 없으면 IO wait 고루틴이 쌓입니다. 응답 바디를 닫지 않아 커넥션이 풀로 돌아가지 않는 문제도 흔합니다.

client := &http.Client{Timeout: 3 * time.Second}

req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := client.Do(req)
if err != nil {
	return err
}
defer resp.Body.Close()

추가로, 큰 바디를 끝까지 읽지 않으면 커넥션 재사용이 제한될 수 있어 다음처럼 드레인하는 습관이 안전합니다.

io.Copy(io.Discard, resp.Body)
resp.Body.Close()

6) 워커풀에서 “작업 큐 정체”가 누수처럼 보이는 경우

엄밀히는 누수가 아니라 백프레셔가 없는 무제한 고루틴 생성이 문제입니다.

나쁜 예:

for _, item := range items {
	go process(item) // 입력이 많으면 고루틴 폭증
}

좋은 예(세마포어로 동시성 제한):

sem := make(chan struct{}, 32)
var wg sync.WaitGroup

for _, item := range items {
	sem <- struct{}{}
	wg.Add(1)
	go func(it Item) {
		defer wg.Done()
		defer func() { <-sem }()
		process(it)
	}(item)
}

wg.Wait()

“5분 진단” 체크리스트(현장용)

1) 고루틴 수가 증가하는지 확인

log.Printf("goroutines=%d", runtime.NumGoroutine())
  • 배포 직후 대비 지속 증가면 누수 가능성 높음

2) goroutine?debug=2로 스택 덤프

  • 반복되는 스택 Top 3를 찾고
  • chan receive/IO wait/semacquire 같은 블로킹 상태를 체크

3) trace 5초 떠서 블로킹 유형 분류

  • 네트워크 블로킹인지
  • 동기화(채널/뮤텍스) 블로킹인지
  • 특정 이벤트 이후부터 누적되는지

4) 코드 수정 방향을 패턴에 매핑

  • 취소 전파(context) 추가
  • 타임아웃 추가
  • 채널 close/종료 조건 정리
  • 무제한 고루틴 생성 제거(동시성 제한)

운영에 적용할 “누수 방지 기본 템플릿”

서버 내부 백그라운드 루프는 아래 형태로 표준화하면 누수 확률이 크게 줄어듭니다.

type Runner struct {
	wg sync.WaitGroup
}

func (r *Runner) Go(ctx context.Context, fn func(context.Context)) {
	r.wg.Add(1)
	go func() {
		defer r.wg.Done()
		fn(ctx)
	}()
}

func (r *Runner) Wait() { r.wg.Wait() }

func backgroundLoop(ctx context.Context) {
	t := time.NewTicker(1 * time.Second)
	defer t.Stop()

	for {
		select {
		case <-ctx.Done():
			return
		case <-t.C:
			// do periodic work
		}
	}
}
  • ctx.Done()로 종료 가능
  • ticker는 Stop()으로 정리
  • WaitGroup으로 셧다운 시점이 명확

마무리: pprof는 “어디”, trace는 “왜”를 답한다

고루틴 누수 대응은 길게 디버깅하기보다, **반복 스택을 찾고(어디), 블로킹 원인을 분류(왜), 종료 조건을 추가(해결)**하는 루틴을 굳히는 게 가장 빠릅니다.

특히 goroutine 프로파일은 비용 대비 효과가 매우 좋아서, 장애가 아니라도 성능 점검 때 주기적으로 떠서 비교해보면 “서서히 쌓이는 문제”를 조기에 잡을 수 있습니다. 운영에서 재현이 어려운 유형일수록, 짧은 trace 한 번이 결정적 단서가 되는 경우가 많습니다.