Goroutine Leak: Golang의 숨겨진 메모리 누수

1. 개요

Goroutine은 Go를 “멋있게” 만드는 것입니다 — 가볍고, 만들기 쉽고, 각각 겨우 몇 KB입니다. 하지만 정확히 그 가벼움 때문에 이들을 만들고도 쉽게 잊어버립니다… 정리하는 것을요. Goroutine leak은 goroutine이 무한정 block 되고, 절대 종료되지 않으며, 시간이 지나면서 계속 쌓여서 결국 RAM이 바닥나거나 시스템이 다운되는 상태입니다.

이 글을 다 읽고 나면 다음을 알게 됩니다: leak을 일으키는 가장 흔한 패턴들, pprof와 runtime metrics로 이를 발견하는 방법, 그리고 context, buffered channel, timeout이 있는 select로 이를 고치는 방법들입니다.

2. Goroutine leak란 무엇인가

일반적인 메모리 누수(object가 여전히 reference를 가지고 있어서 GC되지 않는)와 달리, goroutine leak은 goroutine이 특정 지점에서 무한정 block 되는 상태입니다 — 보통 절대 일어나지 않을 channel 읽기/쓰기를 기다리고 있는 경우입니다. 그 goroutine은 죽지 않고, GC 대상이 되지 않으며, 그것의 stack(및 그것이 유지하는 모든 closure 변수)은 heap에 영구적으로 존재합니다.

가장 위험한 문제는: 프로그램이 즉시 crash하지 않는다는 것입니다. 정상적으로 실행되고, 요청은 여전히 올바른 결과를 반환하지만, RSS (resident memory)는 매 요청마다 서서히 증가합니다. 몇 시간 또는 며칠 후에 컨테이너가 OOMKilled됩니다.

3. Pattern leak 가장 흔한 #1: 아무도 읽지 않는 channel로 전송

func fetchWithTimeout(url string, timeout time.Duration) (string, error) {
	resultCh := make(chan string)

	go func() {
		result := slowHTTPCall(url) // 5초 소요된다고 가정
		resultCh <- result          // (1) buffer 없는 channel로 전송
	}()

	select {
	case res := <-resultCh:
		return res, nil
	case <-time.After(timeout):
		return "", fmt.Errorf("timeout after %v", timeout)
	}
}

timeout이(예: 1초) slowHTTPCall 실행 시간(5초)보다 짧다면, fetchWithTimeout 함수는 timeout 오류를 반환하고 case <-time.After 분기에서 즉시 종료됩니다. 하지만 (1)줄의 자식 goroutine은 여전히 실행 중입니다. 5초 후 실행이 완료되면, 그것은 result를 resultCh로 보내려고 시도합니다 — 이미 부모 함수가 return했으므로 아무도 이 channel을 읽지 않습니다. resultCh가 buffer가 없으므로, 아무도 읽지 않는 channel로 보내는 것은 무한정 block됩니다. 그 goroutine은 영구적으로 leak됩니다.

이 함수를 짧은 timeout으로 초당 1000번 호출하면, 매초 1000개의 goroutine leak이 생깁니다.

3.1. 해결책: buffered channel

func fetchWithTimeout(url string, timeout time.Duration) (string, error) {
	resultCh := make(chan string, 1) // (1) buffer size 1

	go func() {
		result := slowHTTPCall(url)
		resultCh <- result // buffer가 있으므로 절대 block되지 않음
	}()

	select {
	case res := <-resultCh:
		return res, nil
	case <-time.After(timeout):
		return "", fmt.Errorf("timeout after %v", timeout)
	}
}

(1)줄은 channel에 buffer 1을 할당합니다. 자식 goroutine이 result를 보낼 때, buffer에 쓴 후 즉시 종료됩니다 — 누군가 읽을 필요가 없습니다. Goroutine이 정상적으로 종료되고 leak되지 않습니다. 이것은 빠른 해결책이지만 완전하지는 않습니다: caller가 더 이상 결과에 신경 쓰지 않아도 slowHTTPCall은 여전히 5초 동안 실행되어 CPU/network를 낭비합니다.

4. Pattern leak #2: context가 cancel될 때 goroutine 정리를 잊음

func worker(ch <-chan int) {
	go func() {
		for v := range ch { // (1) ch가 닫히지 않으면 영원히 대기
			process(v)
		}
	}()
}

만약 아무도 close(ch)를 호출하지 않으면, (1)줄의 goroutine은 forever channel을 읽으려고 기다립니다. 이 패턴은 worker가 request scope에서 생성되지만 channel 소스가 다른 lifecycle에 연결되어 있을 때 흔히 발생합니다(예: 절대 닫히지 않는 global queue).

4.1. context.Context로 해결

func worker(ctx context.Context, ch <-chan int) {
	go func() {
		for {
			select {
			case <-ctx.Done(): // (1) cancel 신호 수신
				return
			case v, ok := <-ch:
				if !ok { // (2) channel이 닫힘
					return
				}
				process(v)
			}
		}
	}()
}

(1)줄은 ctx.Done()을 listening합니다 — caller가 cancel()을 호출하면, ch가 읽을 것이 없어도 goroutine이 즉시 종료됩니다. (2)줄은 channel이 정상적으로 닫혀있는 경우를 처리합니다. 이것이 표준 패턴입니다: 오래 실행되는 모든 goroutine은 context.Context를 받아야 하고 ctx.Done()을 통한 항상 exit 경로를 가져야 합니다.

ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
worker(ctx, dataCh)

5. Pattern leak #3: sync.WaitGroup과 절대 Done()을 호출하지 않는 goroutine

func processAll(items []Item) {
	var wg sync.WaitGroup
	for _, item := range items {
		wg.Add(1)
		go func(it Item) {
			defer wg.Done()
			if err := validate(it); err != nil {
				return // return 전에 Done()을 호출하는 것을 잊었나요? 아니요, defer는 여전히 실행됩니다
			}
			save(it)
		}(item)
	}
	wg.Wait() // (1) 모든 goroutine이 Done()을 호출할 때까지 대기
}

이 예제에서 defer wg.Done()은 안전합니다. return이 빨라도 defer는 항상 실행되기 때문입니다. 하지만 실제 leak은 goroutine이 return할 기회를 얻기 전에 내부에서 block될 때 발생합니다 — 예를 들어 save(it)이 timeout 없는 API를 호출하고 forever hang되는 경우입니다. 그러면 (1)줄의 wg.Wait()도 무한정 block되고, 결국 processAll을 호출한 goroutine도 leak됩니다.

주의: defer wg.Done()은 Done() 호출을 잊는 실수로부터만 당신을 구합니다. 그것은 body 내부에서 goroutine이 hang되는 것으로부터는 당신을 구하지 않습니다. 모든 I/O(HTTP client, DB query, gRPC call)에 항상 timeout을 설정하세요.

6. Pattern leak #4: time.Ticker를 Stop() 하지 않아 발생하는 누수

func startMonitor() {
	ticker := time.NewTicker(5 * time.Second)
	go func() {
		for range ticker.C { // (1) 영원히 실행됨
			checkHealth()
		}
	}()
}

time.NewTicker은 runtime 내부에서 goroutine을 생성하여 ticker.C로 tick을 보냅니다. 만약 절대 ticker.Stop()을 호출하지 않으면, (1)줄의 읽는 goroutine과 ticker의 내부 goroutine 모두 forever 살아있습니다 — startMonitor가 여러 번 호출되어도(예: config reload할 때마다), 병렬로 실행되는 여러 ticker를 축적하게 됩니다.

6.1. 해결책: lifecycle을 context에 연결

func startMonitor(ctx context.Context) {
	ticker := time.NewTicker(5 * time.Second)
	go func() {
		defer ticker.Stop() // (1) 항상 ticker를 정리합니다
		for {
			select {
			case <-ctx.Done():
				return
			case <-ticker.C:
				checkHealth()
			}
		}
	}()
}

(1)줄은 goroutine이 어떤 경로로 종료되든 ticker.Stop()이 호출됨을 보장합니다. ctx.Done()을 통해 caller는 더 이상 필요하지 않을 때 monitor를 능동적으로 중지할 수 있습니다.

7. pprof를 사용하여 goroutine leak 탐지

leak을 탐지하는 최선의 방법은 코드를 눈으로 읽는 것이 아니라 직접 측정하는 것입니다.

import (
	"net/http"
	_ "net/http/pprof" // (1) 자동으로 debug route를 등록합니다
)

func main() {
	go func() {
		http.ListenAndServe("localhost:6060", nil)
	}()
	// ... app의 나머지 부분
}

(1)줄은 빈 이름(_)으로 net/http/pprof를 import합니다 — /debug/pprof/* route를 등록하는 side-effect만 필요하고, 이 package에서 함수를 호출할 필요가 없습니다.

앱을 실행한 후 다음을 호출합니다:

curl http://localhost:6060/debug/pprof/goroutine?debug=1
goroutine profile: total 15234
14998 @ 0x43f1 0x454a 0x4a2b ...
#	0x4a2b	main.fetchWithTimeout.func1+0x5b	/app/main.go:12

236 @ 0x43f1 0x454c ...

숫자 14998은 명확한 신호입니다: 거의 15,000개의 goroutine이 코드의 정확히 한 지점에서 hang되어 있습니다(main.go:12). 이것이 정확히 goroutine leak입니다 — 반복되는 패턴이지, 정상적인 traffic이 아닙니다.

7.1. 시간 경과에 따른 goroutine 개수 모니터링

func logGoroutineCount() {
	ticker := time.NewTicker(10 * time.Second)
	for range ticker.C {
		log.Printf("goroutines: %d", runtime.NumGoroutine())
	}
}

runtime.NumGoroutine()은 살아있는 goroutine의 수를 반환합니다. 만약 이 숫자가 계속 증가하고 traffic이 일정해도 절대 감소하지 않는다면, 이것은 매우 신뢰할 수 있는 leak 지표입니다. 이 metric을 Prometheus를 통해 노출해야 합니다(go_goroutines — 실제로 Go Prometheus client는 이미 이 metric을 제공하고 있습니다).

8. 조기에 leak을 잡기 위한 테스트 작성

라이브러리 go.uber.org/goleak은 production에서 발견될 때까지 기다리는 대신 leak을 자동으로 감지하는 테스트를 작성하도록 도와줍니다.

<!-- Maven 대신 go.mod를 사용합니다 -->
go get go.uber.org/goleak
package fetcher_test

import (
	"testing"
	"time"

	"go.uber.org/goleak"
)

func TestFetchWithTimeout_NoLeak(t *testing.T) {
	defer goleak.VerifyNone(t) // (1) 테스트 실행 후 확인

	_, err := fetchWithTimeout("http://slow-endpoint.local", 100*time.Millisecond)

	if err == nil {
		t.Fatal("expected timeout error")
	}

	time.Sleep(200 * time.Millisecond) // (2) 자식 goroutine이 완료될 기회를 줍니다
}

(1)줄은 테스트 종료 후 실행되는 확인을 등록합니다: goleak은 그 시점의 살아있는 goroutine 목록을 기준과 비교하고, “이상한” goroutine이 남아있으면(runtime/test framework이 아닌), 테스트가 실패하고 goroutine leak의 stack trace를 출력합니다. (2)줄은 자식 goroutine에게 확인 전에 완료될 충분한 시간을 줍니다 — 이 줄이 없으면 테스트가 false positive일 수 있습니다.

섹션 3에서 buffer가 없는 channel 버전의 fetchWithTimeout을 사용하면, 이 테스트는 다음과 같은 형태의 output으로 실패합니다:

--- FAIL: TestFetchWithTimeout_NoLeak (0.30s)
    goleak.go:39: found unexpected goroutines:
    [Goroutine 18 in state chan send, with fetcher.fetchWithTimeout.func1 on top of the stack:
    goroutine 18 [chan send]:
    fetcher.fetchWithTimeout.func1()
        /app/fetcher.go:9 +0x65
    ]

Stack trace는 leak을 일으키는 정확한 줄을 가리킵니다 — debug할 때 매우 유용합니다.

9. Goroutine leak 방지 체크리스트

  • 오래 실행되는 I/O를 수행하는 모든 goroutine은 context.Context를 받아야 하고 <-ctx.Done() 분기를 가져야 합니다.
  • buffer 없는 channel을 caller에게 결과를 보내는 데 사용했는데 caller가 무시할 가능성이 있다면 — buffer size 1을 고려하거나 context를 사용하세요.
  • time.Ticker, time.Timer는 더 이상 필요하지 않을 때 Stop()되어야 합니다.
  • sync.WaitGroup.Wait()은 모든 자식 goroutine에 확실한 exit 경로가 있을 때만 안전합니다(내부 I/O에 대한 timeout).
  • goroutine을 생성하는 함수에 대해, 특히 timeout/cancel 로직이 있는 함수에 대해 goleak.VerifyNone(t)를 사용하는 테스트를 작성하세요.
  • production dashboard에서 runtime.NumGoroutine() 또는 go_goroutines metric을 모니터링하세요.

주의: 오래 살아있는 모든 goroutine이 leak은 아닙니다. 애플리케이션 수명 동안 실행되는 worker pool은 유효한 설계입니다. 문제는 goroutine의 수가 traffic에 따라 무한정 증가하지만 해당하지 않을 때 감소하지 않을 때만 발생합니다.

10. 결론

Goroutine leak은 위험합니다. 즉시 오류를 일으키지 않기 때문입니다 — 애플리케이션은 여전히 올바른 결과를 반환하는 동안 뒤에서 RAM이 점차 증가합니다. 근본적인 원인은 거의 항상: 아무도 읽지 않는 channel로 보내기, 또는 caller가 포기할 때 goroutine의 exit 경로 부족(timeout, cancel). 방지 방법은 간단합니다: 오래 실행되는 goroutine에 항상 context.Context를 전달하고, 결과 channel을 buffer하고, goleak로 테스트를 작성하여 production OOMKilled를 기다리는 대신 code review 단계에서 오류를 잡으세요.

golanggoroutineconcurrencymemory-leakperformance