Published on

Go 고루틴 누수 잡기 - pprof로 원인 추적

Authors

서버가 멀쩡히 동작하는데 메모리 사용량이 조금씩 오르고, 어느 순간부터 레이턴시가 튀거나 GC가 과도해지는 경우가 있습니다. Go 서비스라면 가장 먼저 의심할 것 중 하나가 고루틴 누수(goroutine leak) 입니다. 고루틴 자체는 가볍지만, “끝나지 않는 고루틴”이 계속 쌓이면 스케줄러 부담, 힙 증가(고루틴 스택/참조 유지), 커넥션/타이머/채널 리소스 점유로 이어집니다.

이 글에서는 pprof로 고루틴 누수를 ‘증거 기반’으로 추적하는 방법을 단계별로 정리합니다. 단순히 runtime.NumGoroutine() 수치만 보는 것을 넘어, 어떤 코드 경로에서 고루틴이 끝나지 않는지를 스택으로 특정하고, 수정 후 재발 방지까지 연결합니다.

관련해서 채널 select에서 케이스를 안전하게 비활성화하는 패턴도 누수 예방에 자주 쓰입니다. 필요하면 함께 참고하세요: Go select에서 채널 nil로 데드락 막는 패턴

1) 고루틴 누수의 전형적인 증상

다음 신호가 함께 나타나면 누수를 강하게 의심할 수 있습니다.

  • runtime.NumGoroutine() 값이 시간에 따라 단조 증가하거나, 트래픽이 줄어도 감소하지 않음
  • 메모리 RSS가 서서히 증가(특히 피크 후 회복이 안 됨)
  • pprof 힙에서 특정 타입(버퍼, 타이머, 요청 컨텍스트 등)이 계속 남아 있음
  • 커넥션 수/파일 디스크립터 수가 증가(네트워크 read 고루틴이 반환 못 하는 케이스)
  • 장애는 아니지만 레이턴시가 점진적으로 악화

핵심은 “고루틴이 늘었다”가 아니라 늘어난 고루틴이 어디서 무엇을 기다리며 멈췄는지를 찾아내는 것입니다.

2) pprof 준비: 운영에 안전하게 노출하기

Go에서 pprof는 보통 net/http/pprof로 노출합니다. 가장 단순한 방식은 별도 포트로 디버그 서버를 띄우는 것입니다.

package main

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

func main() {
	go func() {
		// 운영에서는 내부망/VPN/인증 프록시 뒤에 두는 것을 권장
		log.Println(http.ListenAndServe(":6060", nil))
	}()

	// ... 실제 서비스 서버 구동
	select {}
}

운영 환경 체크리스트

  • pprof 엔드포인트는 반드시 보호(내부망, mTLS, BasicAuth, Ingress 제한 등)
  • 수집 시 CPU/메모리 오버헤드 고려(특히 profile CPU 30초 수집은 부하)
  • 컨테이너 환경에서는 포트 노출/포워딩 전략 준비

3) “고루틴이 늘었다”를 수치로 확인

우선 경량 지표로 증가 추세를 확인합니다.

import (
	"log"
	"runtime"
	"time"
)

func logGoroutines() {
	t := time.NewTicker(10 * time.Second)
	defer t.Stop()

	for range t.C {
		log.Printf("goroutines=%d", runtime.NumGoroutine())
	}
}

이 값이 증가한다면 이제 pprof로 “누가 살아남았는지”를 봐야 합니다.

4) pprof로 고루틴 스택 덤프 수집

고루틴 누수 분석의 1차 목표는 goroutine profile 입니다.

4-1) 웹에서 바로 보기

브라우저에서 다음 URL로 접근할 수 있습니다(예시).

  • http://localhost:6060/debug/pprof/goroutine?debug=1
  • http://localhost:6060/debug/pprof/goroutine?debug=2

여기서 debug=2는 스택 트레이스를 더 자세히 출력합니다.

4-2) go tool pprof로 가져와 분석

CLI로 가져오면 검색/집계가 편합니다.

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

브라우저가 열리면 Top, Graph, Flame Graph고루틴이 많이 걸린 함수를 확인할 수 있습니다.

4-3) “시간 차” 비교가 핵심

고루틴 누수는 순간 스냅샷만 보면 정상처럼 보일 수 있습니다. 따라서 두 시점을 떠서 비교합니다.

  • 트래픽 낮을 때 A 스냅샷
  • 일정 시간 후 B 스냅샷(또는 부하 후)

그리고 B에서 특정 스택이 더 많이 쌓였는지 확인합니다.

5) 재현용 누수 예제: 컨텍스트/채널 설계 실수

아래는 흔한 누수 패턴을 단순화한 예시입니다. 요청마다 워커를 띄우지만, 종료 신호가 없거나 수신자가 사라져 고루틴이 영원히 블록됩니다.

package leak

import (
	"context"
	"time"
)

// 문제: out을 받는 쪽이 사라지면 send에서 영원히 블록
func StartWorker(ctx context.Context, out chan<- int) {
	go func() {
		t := time.NewTicker(100 * time.Millisecond)
		defer t.Stop()

		for {
			select {
			case <-ctx.Done():
				return
			case <-t.C:
				out <- 1 // 여기서 누수: out이 막히면 ctx.Done()도 못 보고 멈춤
			}
		}
	}()
}

이 코드는 언뜻 ctx.Done()을 보니 안전해 보이지만, out <- 1에서 블록되면 다음 루프로 못 돌아가므로 ctx.Done()을 관찰할 기회가 없습니다.

pprof에서 보이는 전형적인 스택

goroutine 덤프에 다음과 비슷한 형태가 반복됩니다.

  • chan send
  • select
  • time.Ticker 루프
  • 특정 패키지의 워커 함수

즉, 채널 send/recv 블로킹은 누수의 대표적인 형태입니다.

6) 수정 전략 1: 채널 send에 컨텍스트를 “같이” 걸기

send 자체가 블록될 수 있으니, send도 select로 감싸서 탈출 가능하게 만듭니다.

func StartWorker(ctx context.Context, out chan<- int) {
	go func() {
		t := time.NewTicker(100 * time.Millisecond)
		defer t.Stop()

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

이렇게 하면 수신자가 사라져도 ctx.Done()으로 빠져나갈 수 있습니다.

7) 수정 전략 2: 버퍼링/드롭/백프레셔 정책을 명시

채널을 무조건 버퍼로 키우는 것은 임시방편일 수 있습니다. 중요한 건 정책입니다.

  • 반드시 전달되어야 하는 이벤트인가
  • 지연 허용 가능한가
  • 과부하 시 드롭해도 되는가

드롭이 가능하다면 다음처럼 비블로킹 send를 선택할 수 있습니다.

select {
case out <- v:
	// 전달
default:
	// 드롭 (메트릭 증가 권장)
}

드롭이 불가하다면, 호출자에게 백프레셔를 주거나(요청 실패/큐잉), 워커 수를 제한하는 풀 패턴을 씁니다.

8) 흔한 누수 패턴과 pprof에서의 단서

8-1) time.Tick 사용

time.Tick은 내부적으로 ticker를 만들고, 참조를 잃어도 GC가 회수하기 어려운 형태로 남을 수 있어 장수 서비스에서 문제를 만들 수 있습니다. 가능하면 time.NewTicker를 쓰고 Stop() 하세요.

t := time.NewTicker(time.Second)
defer t.Stop()

pprof 단서: time.(*Ticker).C를 기다리는 고루틴이 계속 남음.

8-2) http.Response.Body 미종료

HTTP 클라이언트 호출 후 resp.Body.Close()를 빼먹으면 커넥션이 반환되지 않고, 내부 read 루틴이 남거나 FD가 증가할 수 있습니다.

resp, err := http.DefaultClient.Do(req)
if err != nil {
	return err
}
defer resp.Body.Close()

pprof 단서: net/http 내부에서 readLoop 또는 persistConn 관련 고루틴이 증가.

8-3) WaitGroup/채널 종료 설계 오류

종료 시그널이 누락되면 워커가 영원히 range ch에서 대기합니다.

for v := range ch {
	_ = v
}

pprof 단서: chan receive가 특정 함수에서 대량으로 쌓임.

8-4) 락 경쟁으로 인한 “사실상 누수”

고루틴이 종료되긴 하지만 매우 오래 sync.Mutex.Lock에 막혀 쌓이면 누수처럼 보일 수 있습니다.

pprof 단서: sync.runtime_SemacquireMutex 또는 sync.(*Mutex).Lock가 상위에 등장.

9) pprof 해석 요령: “같은 스택이 반복”을 찾아라

고루틴 누수는 대개 다음 형태로 나타납니다.

  • 상위 N개 스택이 대부분 동일
  • 특정 함수(예: StartWorker, Subscribe, Watch, Consume)에서 생성
  • 하위에서 chan send, chan receive, select, IO wait 중 하나로 멈춤

따라서 pprof UI에서 다음을 우선 확인합니다.

  • Top에서 고루틴 수를 많이 차지하는 함수
  • Flame Graph에서 폭이 넓은 경로
  • 그래프에서 생성 지점(고루틴 시작 함수)을 역추적

추적이 어려우면 “고루틴에 이름표”를 붙이는 것도 도움이 됩니다.

10) 추적 보강: pprof 라벨과 로그 상관관계

pprof는 라벨을 지원합니다. 요청 타입/테넌트/핸들러 이름 등을 라벨로 달아두면, 특정 라벨에서만 누수가 생기는지 확인하기 좋습니다.

import "runtime/pprof"

pprof.Do(ctx, pprof.Labels("handler", "import", "tenant", "a"), func(ctx context.Context) {
	// 이 안에서 실행되는 작업은 라벨이 붙음
	doWork(ctx)
})

운영에서 “특정 API 호출 후 고루틴이 늘어난다” 같은 상황에서 원인 범위를 빠르게 좁힐 수 있습니다.

11) 수정 후 검증: 누수는 ‘재발 방지’가 중요

수정했다면 다음을 확인합니다.

  1. 부하 테스트 또는 재현 시나리오 실행
  2. runtime.NumGoroutine()가 증가 후 원래 수준으로 회복되는지 확인
  3. pprof goroutine 스냅샷을 다시 떠서 문제 스택이 사라졌는지 확인
  4. 메트릭에 경고 조건 추가(예: 고루틴 수, FD 수)

특히 고루틴 수는 절대값보다 기준선 대비 증가율이 더 유용합니다.

12) 운영 팁: 타임아웃과 취소 전파는 누수 예방의 기본

고루틴 누수는 많은 경우 “끝나야 하는데 끝나지 못한 작업”입니다. 네트워크/외부 의존성이 있는 곳에는 타임아웃을 기본으로 두세요.

ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()

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

또한 fan-out 구조(요청 하나가 여러 고루틴을 생성)에서는 반드시 취소 전파가 되도록 설계해야 합니다. gRPC 기반 서비스라면 타임아웃 전파가 누수 예방과 직결됩니다. 분산 환경에서 타임아웃 이슈를 체계적으로 점검하는 관점은 다음 글도 참고할 만합니다: gRPC MSA에서 DEADLINE_EXCEEDED 원인 9가지

13) 자주 쓰는 점검 명령 모음

# goroutine 프로파일을 로컬 UI로 보기
go tool pprof -http=:0 http://localhost:6060/debug/pprof/goroutine

# 힙도 함께 확인(고루틴 누수는 힙 증가로 이어지는 경우가 많음)
go tool pprof -http=:0 http://localhost:6060/debug/pprof/heap

# 30초 CPU 프로파일(부하가 있을 때만 의미 있음)
go tool pprof -http=:0 http://localhost:6060/debug/pprof/profile?seconds=30

14) 정리

  • 고루틴 누수는 “고루틴 수 증가”가 아니라 “끝나지 않는 고루틴이 같은 스택으로 반복”되는 현상이다.
  • pprof의 goroutine 프로파일은 누수의 정확한 대기 지점(채널 send/recv, IO wait, 락 등)을 보여준다.
  • 스냅샷 1회보다 두 시점 비교가 훨씬 강력하다.
  • 해결은 대개 컨텍스트 취소 전파, 채널 send/recv의 블로킹 제어, ticker/HTTP 리소스 정리 같은 기본기에서 나온다.

다음 단계로는, 실제 서비스 코드에서 고루틴을 생성하는 지점을 “요청 경로별로” 분류하고, pprof 라벨과 메트릭을 결합해 재발을 막는 운영 체계를 만드는 것을 권장합니다.