Goroutine leak을 찾는 방법
마지막 업데이트

Goroutine leak을 찾는 방법


goroutine 수가 계속 올라가고 내려오지 않는다면, 핵심은 goroutine이 새어 나간다는 표현보다 종료되지 못한 작업이 어디에 쌓이고 있는지 찾는 것입니다.

그래서 goroutine leak은 timeout, queue 적체, 메모리 압박, 종료 지연과 같이 보이는 경우가 많습니다. goroutine 수 증가는 눈에 보이는 현상일 뿐이고, 실제 원인은 blocked communication, cancellation 누락, worker 종료 경로 부재인 경우가 많습니다.

이 글은 실전 순서에 집중합니다.

  • 지금 보고 있는 현상이 일시적인 spike인지 실제 leak인지 구분하는 방법
  • 어떤 goroutine이 어디서 멈춰 있는지 확인하는 방법
  • Go 서비스에서 자주 나오는 leak 패턴과 수정 포인트

짧게 말하면 부하가 빠진 뒤에도 goroutine 수가 높은지 먼저 확인하고, 그다음 어떤 대기 상태가 반복되는지 수집한 뒤, channel ownership과 cancellation, worker shutdown 경로를 추적하면 됩니다.

Go 안에서 더 큰 분기부터 다시 보고 싶다면 Golang 트러블슈팅 가이드로 먼저 돌아가세요.


goroutine leak은 보통 무엇을 뜻하나

goroutine leak이라고 할 때는 보통 이런 상황을 뜻합니다.

  • send나 receive가 영원히 끝나지 않아 goroutine이 block된 경우
  • 요청이 끝난 뒤에도 background 작업이 계속 살아남는 경우
  • ticker, watcher, retry loop가 종료 조건 없이 계속 도는 경우
  • worker pool이나 queue consumer에 정상 종료 경로가 없는 경우

이런 경우 프로세스는 살아 있고 코드도 겉으로는 멀쩡해 보입니다. 하지만 시간이 갈수록 같은 대기 상태의 goroutine이 조금씩 누적됩니다.

그래서 총 개수만 보는 것보다 goroutine dump 하나가 더 큰 힌트를 주는 경우가 많습니다. count는 이상 징후를 보여주고, state는 원인을 보여줍니다.


먼저 실제 leak인지 확인하기

일시적인 spike와 실제 leak은 다릅니다.

코드를 바로 고치기 전에, 부하가 올라갈 때 잠깐 늘었다가 다시 내려오는지, 아니면 부하가 빠진 뒤에도 기준선이 계속 높아지는지부터 보세요. 정상적인 서비스도 순간 부하에서는 goroutine 수가 꽤 늘 수 있습니다.

가장 빠른 첫 신호는 runtime.NumGoroutine()입니다.

package main

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

func logGoroutineCount() {
	for range time.Tick(30 * time.Second) {
		log.Printf("goroutines=%d", runtime.NumGoroutine())
	}
}

이 코드만으로 원인을 알 수는 없지만, 첫 질문에는 답할 수 있습니다. 부하가 끝난 뒤 count가 원래 수준으로 돌아오는지, 아니면 기준선이 조금씩 위로 밀리는지 확인할 수 있습니다.

작업이 끝난 뒤에도 count가 계속 높게 남는다면 leak 가능성이 커집니다.


멈춘 goroutine은 어디서 봐야 하나

count가 수상하다면 이제 대기 중인 goroutine이 무엇을 하고 있는지 봐야 합니다.

실전에서는 보통 아래 도구들이 가장 유용합니다.

  • 추세 확인용 runtime.NumGoroutine()
  • goroutine profile과 blocking profile을 볼 수 있는 net/http/pprof
  • 장애 시점 전후의 stack dump

간단한 pprof 노출만 있어도 출발점으로 충분합니다.

package main

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

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

그다음 goroutine profile을 수집합니다.

go tool pprof http://localhost:6060/debug/pprof/goroutine

먼저 텍스트로 빠르게 보고 싶다면 아래처럼 확인해도 됩니다.

curl http://localhost:6060/debug/pprof/goroutine?debug=2

여기서 중요한 것은 반복입니다. 같은 함수, 같은 channel operation, 같은 wait path에 멈춘 goroutine이 많이 보인다면 그 스택이 가장 먼저 좁혀야 할 leak 후보입니다.

Go 공식 진단 문서도 함께 보면 범위를 넓히는 데 도움이 됩니다: https://go.dev/doc/diagnostics


자주 나오는 leak 패턴

1. 받을 쪽이 불확실한 blocked send

가장 흔한 패턴 중 하나는 receiver가 더 이상 읽지 않는데 sender 쪽 goroutine이 채널로 값을 보내려는 경우입니다.

func startWorker(ch chan<- int) {
	go func() {
		result := expensiveWork()
		ch <- result
	}()
}

겉보기에는 단순하지만, receiver가 먼저 종료되거나 채널을 더 이상 소비하지 않으면 send가 영원히 block될 수 있습니다.

이때는 다음을 확인하세요.

  • channel close를 누가 책임지는지
  • receiver가 읽기 전에 먼저 return할 수 있는지
  • send가 ctx.Done()을 함께 보도록 해야 하는지

더 안전한 패턴은 보통 이렇게 생깁니다.

func startWorker(ctx context.Context, ch chan<- int) {
	go func() {
		result := expensiveWork()
		select {
		case ch <- result:
		case <-ctx.Done():
			return
		}
	}()
}

2. background 작업의 cancellation 누락

request-scoped 작업이 background goroutine을 띄우는데 cancellation을 물려주지 않아서 leak이 생기는 경우도 많습니다.

func handle() {
	go syncData(context.Background())
}

이 작업이 요청과 함께 끝나야 한다면 context.Background()는 대개 잘못된 시작점입니다.

요청 context를 넘기고, 하위 호출까지 전파하고, cancellation이 발생하면 worker가 빠져나오도록 해야 합니다.

func handle(ctx context.Context) {
	go syncData(ctx)
}

func syncData(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			return
		default:
			doOneStep()
		}
	}
}

3. stop 경로가 없는 ticker 또는 watcher loop

장수 루프는 원래 오래 살아야 하니 놓치기 쉽습니다. 문제는 “오래”가 어느 순간 “영원히”가 되는 경우입니다.

func startWatcher(ctx context.Context) {
	ticker := time.NewTicker(10 * time.Second)
	go func() {
		for range ticker.C {
			refresh()
		}
	}()
}

여기에는 두 가지 문제가 있습니다.

  • ticker를 멈추지 않음
  • cancellation 시 goroutine이 종료되지 않음

조금 더 안전한 형태는 아래와 같습니다.

func startWatcher(ctx context.Context) {
	ticker := time.NewTicker(10 * time.Second)
	go func() {
		defer ticker.Stop()
		for {
			select {
			case <-ctx.Done():
				return
			case <-ticker.C:
				refresh()
			}
		}
	}()
}

4. worker shutdown과 queue drain 버그

wait group, worker pool, queue consumer도 종료 순서가 어긋나면 goroutine leak으로 이어질 수 있습니다.

자주 보이는 실패 패턴은 이렇습니다.

  • worker 종료가 시작됐는데 producer는 계속 queue에 push함
  • queue가 닫히지 않아 worker가 영원히 대기함
  • shutdown은 worker 종료를 기다리는데, worker는 이미 해제된 자원을 기다리며 block됨

worker 함수에서 대기 중인 goroutine이 많이 보인다면 startup 경로와 shutdown 경로를 나란히 비교해 보세요. leak 버그는 시작 흐름은 분명한데 멈추는 책임이 흐릿한 경우가 많습니다.


운영 중에 잘 먹히는 점검 순서

장애가 진행 중일 때는 아래 순서가 가장 실전적입니다.

  1. 부하가 빠진 뒤에도 goroutine 수가 높은지 확인
  2. 장애 시점 근처에서 goroutine dump를 한두 번 수집
  3. 반복되는 stack을 대기 위치별로 묶기
  4. 해당 stack의 channel ownership과 cancellation 경로 확인
  5. 장수 루프, worker lifecycle, 최근 동시성 변경 사항 검토

이 순서가 좋은 이유는, 실제로 많이 하는 실수를 막아주기 때문입니다. 어디에 쌓였는지 확인하기도 전에 코드부터 훑기 시작하면 시간만 오래 걸리는 경우가 많습니다.

timeout도 함께 보인다면 Golang context deadline exceeded와 같이 비교해 보세요. timeout 중심 장애는 cancellation 누락이나 의존성 block과 겹치는 경우가 많습니다.


일시적 spike와 baseline 상승을 구분하는 법

정상 서비스도 아래 상황에서는 goroutine이 늘 수 있습니다.

  • fan-out 요청 처리
  • batch 작업
  • connection churn
  • 짧은 retry storm

이 자체가 곧 leak은 아닙니다.

leak 가능성이 더 큰 신호는 아래와 같습니다.

  • burst가 끝난 뒤에도 count가 내려오지 않음
  • 트래픽 파동이 한 번 지나갈 때마다 baseline이 조금씩 높아짐
  • shutdown이 끝나지 않아 drain이 느려짐
  • 여러 dump에서 같은 stack trace가 반복됨

한 시점만 보면 burst와 leak을 헷갈릴 수 있습니다. 부하 전, 부하 중, 부하 후 몇 분 뒤를 같이 비교하면 패턴이 훨씬 또렷해집니다.


FAQ

Q. goroutine spike가 보이면 다 leak인가요?

아닙니다. 부하 구간에서 잠깐 늘었다가 다시 회복되면 정상일 수 있습니다. 핵심은 baseline이 원래 수준으로 돌아오는지입니다.

Q. 보통 어디에 많이 숨어 있나요?

대부분 blocked channel, context cancellation 누락, 장수 루프, worker 종료 경로에 숨어 있습니다.

Q. 장애 때 가장 먼저 무엇을 수집해야 하나요?

goroutine count와 goroutine dump 하나부터 시작하세요. 그 두 가지 정보만으로도 다음에 어떤 코드를 봐야 할지 꽤 빨리 결정할 수 있습니다.


Sources:

먼저 읽어볼 가이드

검색 유입이 많은 핵심 글부터 이어서 보세요.