Published on

Go goroutine 누수 잡기 - pprof+context 실전

Authors

서버가 느려지고 메모리가 조금씩 오르는데, GC 튜닝이나 캐시 최적화로는 해결이 안 되는 경우가 있습니다. 이런 상황에서 자주 등장하는 진짜 범인은 goroutine 누수입니다. 누수는 보통 “메모리 누수”처럼 눈에 띄지 않습니다. 대신 goroutine 수가 계속 증가하면서 스케줄러 부담, 락 경합, 커넥션/FD 고갈, 타임아웃 폭증 같은 증상으로 터집니다.

이 글은 다음 순서로 진행합니다.

  • pprof로 goroutine 누수를 측정하고, 스택 트레이스로 원인 지점을 찾는 방법
  • context를 중심으로 “취소 전파”를 설계해 누수를 구조적으로 차단하는 방법
  • 실무에서 자주 터지는 누수 패턴(채널, select, net/http, worker)과 체크리스트

참고로 비슷한 결의 “런타임/비동기 리소스가 정리되지 않아 장애로 이어지는” 사례는 Rust에서도 자주 보이며, 개념적으로 통하는 부분이 많습니다. 필요하면 Rust Tokio - runtime dropped 패닉 원인과 해결도 같이 보면 좋습니다.

goroutine 누수의 전형적인 증상

아래 중 2개 이상이면 goroutine 누수를 의심해볼 가치가 큽니다.

  • 프로세스 재시작 전까지 runtime.NumGoroutine() 값이 계속 우상향
  • 트래픽이 줄어도 goroutine 수가 줄지 않음
  • pprof의 goroutine 프로파일에서 특정 스택이 대량으로 반복
  • TIME_WAIT/CLOSE_WAIT 소켓 증가, DB 커넥션 고갈, 큐 컨슈머가 멈춤
  • “요청은 끝났는데 백그라운드 작업이 계속 돈다” 류의 로그

핵심은 “요청/작업의 생명주기”가 끝났는데도 goroutine이 종료되지 않는 것입니다.

pprof로 누수 증명하기: goroutine 프로파일

1) pprof 엔드포인트 붙이기

운영 서버에 바로 붙이기 어렵다면, 먼저 스테이징이나 로컬 재현 환경에 붙여도 됩니다.

package main

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

func main() {
	go func() {
		// 보안상 운영에서는 내부망/인증/포트 제한 필수
		log.Println(http.ListenAndServe("127.0.0.1:6060", nil))
	}()

	// ... your server
	select {}
}

운영 적용 시에는 다음을 반드시 고려하세요.

  • 바인딩을 127.0.0.1 또는 사설망으로 제한
  • Ingress나 프록시에서 Basic Auth 등 인증 추가
  • 필요 시 pprof 서버를 별도 포트/별도 프로세스로 분리

2) goroutine 덤프 확인

브라우저 또는 curl로 확인합니다.

  • http://127.0.0.1:6060/debug/pprof/goroutine?debug=2

debug=2는 스택을 텍스트로 길게 보여줘서 “어디에서 막혔는지”를 빠르게 파악하기 좋습니다.

3) pprof로 스택 집계 보기

CLI로 집계하면 반복되는 스택이 더 선명합니다.

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

여기서 중요한 관찰 포인트는 다음입니다.

  • 특정 함수 조합이 goroutine의 대부분을 차지하는가
  • select에서 영원히 대기, chan receive에서 대기, net.(*pollDesc).wait에서 대기 등 “대기 상태”가 과도한가
  • 요청 처리 경로와 무관한 백그라운드 루프가 쌓이는가

재현 가능한 “누수 샘플”로 감 잡기

아래 코드는 요청이 들어올 때마다 goroutine을 만들고, 취소 신호 없이 채널 수신을 기다리게 만들어 누수시키는 전형입니다.

package main

import (
	"log"
	"net/http"
	"runtime"
	"time"
)

func leakHandler(w http.ResponseWriter, r *http.Request) {
	ch := make(chan struct{})

	go func() {
		// 여기서 영원히 대기: 누수
		<-ch
	}()

	w.WriteHeader(http.StatusOK)
	_, _ = w.Write([]byte("ok"))
}

func main() {
	http.HandleFunc("/leak", leakHandler)

	go func() {
		for range time.Tick(2 * time.Second) {
			log.Println("goroutines:", runtime.NumGoroutine())
		}
	}()

	log.Fatal(http.ListenAndServe(":8080", nil))
}

/leak를 여러 번 호출하면 goroutine 수가 줄지 않고 계속 증가합니다. 이런 형태가 실무에서는 다음과 같이 변형되어 숨어 있습니다.

  • “요청마다 이벤트 구독 goroutine 생성” 후 해제 없음
  • “요청마다 DB watch” 또는 “pubsub subscribe” 생성 후 cancel 없음
  • “worker에 작업 던지고 결과 기다리는데, worker가 안 받으면 영원히 대기”

context로 누수 제거하기: 취소 전파 설계

goroutine 누수 방지의 핵심 규칙은 단순합니다.

  • goroutine을 만들었으면, 끝나는 조건이 반드시 있어야 한다
  • 그 조건은 보통 context.Done() 또는 “채널 close”다
  • 요청 기반 작업이라면 r.Context()를 최상위로 사용해 취소를 전파한다

1) 요청 컨텍스트를 goroutine에 전달

위의 누수 코드를 context 기반으로 바꾸면 다음처럼 됩니다.

package main

import (
	"context"
	"net/http"
)

func safeHandler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	go func(ctx context.Context) {
		select {
		case <-ctx.Done():
			// 요청이 끝나거나 클라이언트가 끊기면 종료
			return
		}
	}(ctx)

	w.WriteHeader(http.StatusOK)
	_, _ = w.Write([]byte("ok"))
}

이제 클라이언트가 연결을 끊거나 서버가 타임아웃으로 요청을 종료하면 goroutine도 함께 정리됩니다.

2) WithTimeout으로 “최대 생명주기”를 강제

외부 의존성 호출(HTTP, DB, gRPC)은 “상대가 영원히 응답 안 하는” 상황이 반드시 생깁니다. 이때 timeout이 없으면 goroutine이 대기 상태로 쌓입니다.

ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
// 매우 중요: cancel을 호출해야 타이머 리소스도 정리됨
// `defer cancel()`이 기본값

defer cancel()

req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := http.DefaultClient.Do(req)

포인트는 defer cancel()입니다. timeout이 만료되지 않더라도 정상 완료 시 타이머가 정리되어야 합니다.

3) select에는 항상 “탈출구”를 넣기

다음 패턴은 누수의 씨앗입니다.

  • for { select { case x := <-ch: ... } } 처럼 Done이 없음
  • select { case <-ch: ... } 단일 케이스

안전한 기본형은 다음입니다.

for {
	select {
	case <-ctx.Done():
		return
	case msg, ok := <-ch:
		if !ok {
			return
		}
		_ = msg
	}
}

pprof 스택에서 자주 보이는 누수 패턴 5가지

1) 채널 송신/수신 블로킹

  • chan send에서 대기: 받는 쪽이 죽었거나 느림
  • chan receive에서 대기: 보내는 쪽이 더 이상 보내지 않는데 close도 안 함

해결책:

  • 채널 close 규약 정하기(누가 close 하는가)
  • 버퍼 크기 재검토(무조건 키우는 게 답은 아님)
  • 송신 측에 timeout 또는 ctx.Done() 추가
select {
case out <- v:
	// sent
case <-ctx.Done():
	return ctx.Err()
case <-time.After(500 * time.Millisecond):
	return errors.New("send timeout")
}

time.After는 반복 루프에서 쓰면 타이머가 많이 생길 수 있으니, 장기 루프라면 time.NewTimer 재사용을 고려하세요.

2) http.Client 타임아웃 미설정

Go의 http.Client는 기본적으로 “전체 요청 타임아웃”이 없습니다. DNS, TLS, 서버 응답 지연 등 다양한 지점에서 오래 걸리면 goroutine이 붙잡힙니다.

권장:

  • 요청마다 NewRequestWithContext
  • 클라이언트에도 상한선(Timeout) 부여
client := &http.Client{Timeout: 3 * time.Second}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := client.Do(req)

3) resp.Body 미종료로 커넥션/고루틴 정체

HTTP 응답 바디를 닫지 않으면 커넥션 재사용이 깨지고 리소스가 쌓입니다.

resp, err := client.Do(req)
if err != nil {
	return err
}

defer resp.Body.Close()

바디를 끝까지 읽지 않는 경우에도 문제가 될 수 있으니, 필요 시 io.Copy(io.Discard, resp.Body)로 drain 전략을 쓰기도 합니다.

4) worker 풀에서 “결과를 안 받는” 패턴

요청마다 작업을 던지고 결과 채널을 기다리는데, 요청이 취소되면 결과를 받을 사람이 없어집니다. worker는 결과를 보내다 막혀 누수됩니다.

해결책:

  • 결과 채널을 버퍼로 만들거나
  • worker가 결과 송신 시 ctx.Done()을 함께 본다
type Job struct {
	ctx context.Context
	res chan<- Result
}

func worker(jobs <-chan Job) {
	for job := range jobs {
		r := doWork(job.ctx)
		select {
		case job.res <- r:
		case <-job.ctx.Done():
			// 받는 쪽이 사라졌으면 버림
		}
	}
}

5) time.Ticker/time.Tick 방치

time.Tick은 stop할 수 없어 장기적으로 누수 원인이 됩니다. 루프를 종료할 수 있는 구조라면 NewTicker를 쓰고 반드시 Stop() 하세요.

ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()

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

실전 디버깅 플로우: “증상”에서 “원인 커밋”까지

1) 관측 지표 추가

가장 먼저 “증명 가능한 숫자”를 남깁니다.

  • runtime.NumGoroutine()을 주기적으로 로그/메트릭으로 기록
  • 가능하면 endpoint별 in-flight 요청 수, 큐 길이, 커넥션 풀 사용량도 함께

예: Prometheus를 쓴다면 goroutine 수는 기본 런타임 메트릭으로도 수집 가능합니다.

2) 증가 구간에서 goroutine 프로파일 수집

  • 증가 시작 시점과 충분히 증가한 시점, 최소 2번 이상 뜹니다.
  • 두 스냅샷에서 “늘어난 스택”이 누수 후보입니다.

운영에서 장애 분석을 하다 보면 “원인은 하나인데 증상이 여러 개”인 경우가 많습니다. 이런 다면적인 실패 분석 접근은 CI/CD 트러블슈팅과도 닮아 있습니다. 예를 들어 GitHub Actions 캐시가 안 먹힐 때 원인 9가지처럼 원인을 분해해 체크리스트로 좁히는 방식이 유효합니다.

3) 스택에서 “대기 지점”을 읽는 법

goroutine 프로파일에서 자주 보이는 키워드:

  • select 대기
  • chan receive 또는 chan send
  • net.(*pollDesc).wait (네트워크 I/O 대기)
  • sync.(*Mutex).Lock (락 경합)

대기 자체가 나쁜 게 아니라, “끝나야 하는데 끝나지 않는 대기”가 문제입니다. 이때는 해당 goroutine이 어떤 생명주기에 묶여야 하는지(요청, 배치, 구독 세션 등)를 먼저 정의하고, 그 생명주기의 종료 신호를 context로 연결합니다.

4) 수정 시 “구조적 방지”까지 포함

단순히 한 군데에 return을 추가하는 식의 핫픽스는 재발하기 쉽습니다. 아래 항목 중 2개 이상을 같이 적용하는 편이 안전합니다.

  • goroutine 생성 함수에 ctx를 필수 인자로 만들기
  • 외부 호출은 무조건 timeout을 갖게 하기
  • 채널 close 책임자를 명시하기
  • 결과 채널 송신은 ctx.Done()과 함께 select 하기
  • 테스트에서 goroutine 수 증가를 감지하기

테스트로 누수 회귀 방지하기

goroutine 누수는 “테스트에서 잡히면 가장 싸게 해결”됩니다. 아래는 단순하지만 효과적인 형태입니다.

package leaktest

import (
	"runtime"
	"testing"
	"time"
)

func TestNoGoroutineLeak(t *testing.T) {
	before := runtime.NumGoroutine()

	// 여기에 테스트 대상 로직을 여러 번 실행
	for i := 0; i < 100; i++ {
		// callSomething()
	}

	// goroutine 정리 시간을 약간 줌
	time.Sleep(200 * time.Millisecond)
	after := runtime.NumGoroutine()

	if after > before+5 {
		t.Fatalf("goroutine leak suspected: before=%d after=%d", before, after)
	}
}

주의할 점:

  • 테스트 환경에서도 goroutine은 잡음이 있습니다. 허용 오차를 조금 둡니다.
  • 타이밍 의존이 생길 수 있으니, 가능하면 종료 신호를 명확히 보내는 구조로 바꾸는 게 우선입니다.

체크리스트: 코드 리뷰에서 바로 쓰는 규칙

  • goroutine을 띄우는 함수는 ctx context.Context를 첫 인자로 받는가
  • context.WithCancel 또는 context.WithTimeout을 만들면 defer cancel()이 있는가
  • selectctx.Done() 또는 종료 케이스가 있는가
  • 채널을 누가 close하는지 주석/규약이 있는가
  • http.Client에 timeout이 있고, 요청은 NewRequestWithContext를 쓰는가
  • resp.Body.Close()가 누락되지 않았는가
  • time.Tick 대신 NewTicker를 쓰고 Stop() 하는가

마무리

goroutine 누수는 “어디선가 return을 안 했다” 수준의 문제가 아니라, 작업 생명주기 모델링이 어긋났을 때 생깁니다. pprof는 그 어긋남을 스택으로 드러내고, context는 그 생명주기를 코드로 강제하는 도구입니다.

정리하면 실전에서 가장 효과적인 조합은 다음입니다.

  • pprof goroutine 프로파일로 누수를 증명하고
  • 반복되는 스택을 기준으로 누수 지점을 특정한 뒤
  • context 취소 전파, timeout, 채널 종료 규약으로 구조적으로 제거한다

이 흐름을 팀의 디버깅 플레이북으로 만들어두면, “재현 어려운 간헐 장애”의 해결 속도가 확실히 빨라집니다.