Published on

Go 고루틴 누수 원인 8가지와 진단법

Authors

서버가 “점점 느려지는데 CPU는 애매하고, 메모리는 조금씩 오르며, 재시작하면 잠깐 괜찮아지는” 증상을 보일 때 가장 먼저 의심해야 할 것 중 하나가 고루틴 누수입니다. 고루틴은 가볍지만 무한히 가볍지는 않습니다. 누수는 단순히 runtime.NumGoroutine() 값이 증가하는 현상에 그치지 않고, 다음과 같은 2차 문제로 이어집니다.

  • 스케줄러 부하 증가로 tail latency 악화
  • 채널/뮤텍스 경합 증가
  • 네트워크 커넥션, 파일 디스크립터가 해제되지 않아 고갈
  • 타이머/티커가 쌓여 힙 증가 및 GC 부담 증가

이 글은 고루틴 누수의 대표 원인 8가지와, 이를 재현하고 스택으로 특정하는 진단 루틴을 정리합니다. 운영 환경에서 재시작 루프가 도는 상황이라면, 원인 파악을 위해 프로세스가 죽기 전 상태를 잡아내는 방법도 함께 참고하세요: systemd 재시작 루프 - ExecStart 디버깅 가이드

고루틴 누수의 정의: “끝나야 하는데 끝나지 않는 것”

고루틴이 계속 살아있는 것이 모두 누수는 아닙니다. 예를 들어 워커 풀의 워커, 서버의 accept 루프처럼 의도적으로 영구 실행되는 고루틴은 정상입니다.

누수(leak)는 보통 다음 중 하나를 의미합니다.

  • 요청 단위로 생성된 고루틴이 요청 종료 후에도 살아남는다
  • 특정 작업이 실패/타임아웃되었는데 고루틴이 종료되지 않는다
  • 종료 신호(컨텍스트 취소, 채널 close)를 받았는데도 블로킹 상태로 남는다

따라서 진단의 핵심은 “누수 고루틴이 어디에서 무엇을 기다리는지”를 스택으로 확인하는 것입니다.

진단 준비: 최소한의 계측 3종 세트

1) runtime.NumGoroutine() 주기 기록

가장 싸고 빠른 조기 경보입니다.

package diag

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

func StartGoroutineGauge(interval time.Duration) {
	go func() {
		t := time.NewTicker(interval)
		defer t.Stop()
		for range t.C {
			log.Printf("goroutines=%d", runtime.NumGoroutine())
		}
	}()
}

운영에서 goroutines가 선형으로 증가하면 거의 확실히 누수입니다.

2) net/http/pprof로 goroutine dump 확보

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

func startDebugServer() {
	go http.ListenAndServe("127.0.0.1:6060", nil)
}
  • http://127.0.0.1:6060/debug/pprof/goroutine?debug=2 를 호출하면 고루틴 스택을 텍스트로 바로 볼 수 있습니다.
  • 누수가 의심될 때는 시간을 두고 2회 이상 덤프를 떠서, 같은 스택이 계속 남아있는지 비교하세요.

3) 테스트에서 누수 방지: go.uber.org/goleak

import (
	"testing"
	"go.uber.org/goleak"
)

func TestMain(m *testing.M) {
	goleak.VerifyTestMain(m)
}

CI에서 고루틴 누수를 조기에 차단할 수 있습니다.

원인 1: 컨텍스트 취소를 무시한 고루틴

요청 핸들러에서 고루틴을 띄워놓고 ctx.Done()을 체크하지 않으면, 클라이언트가 끊기거나 타임아웃이 발생해도 고루틴이 계속 실행될 수 있습니다.

나쁜 예

func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	go func() {
		// ctx를 전혀 보지 않음
		heavyWork()
	}()
	w.WriteHeader(http.StatusAccepted)
	_ = ctx // 사용하지 않음
}

개선 패턴

func heavyWork(ctx context.Context) error {
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		default:
			// 작은 단위로 쪼개서 진행
			doOneStep()
		}
	}
}

진단 포인트

goroutine dump에서 select 없이 특정 함수에서 계속 돌거나, 네트워크/채널에서 영구 대기하는 스택이 반복적으로 등장합니다.

원인 2: 채널 send가 영구 블로킹(수신자 부재)

채널에 값을 보내는 쪽이 수신자를 확신하지 못하면, send는 영구 블로킹이 됩니다.

전형적인 누수

func fanout(ch chan<- int) {
	go func() {
		ch <- 1 // 수신자가 없으면 여기서 영원히 멈춤
	}()
}

해결책 1: 버퍼 채널

ch := make(chan int, 1)

해결책 2: 컨텍스트 기반 non-blocking send

func trySend(ctx context.Context, ch chan<- int, v int) error {
	select {
	case ch <- v:
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

진단 포인트

pprof 스택에서 chan send 상태가 다수 보이며, 호출자가 요청 단위로 반복 생성되는 패턴이면 누수 가능성이 큽니다.

원인 3: 채널 receive가 영구 블로킹(종료 신호 없음)

worker가 for v := range ch로 돌고 있는데, 생산자 쪽에서 채널을 닫지 않으면 worker는 끝나지 않습니다.

나쁜 예

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

개선 패턴: close 책임을 명확히

func producer(ch chan<- int) {
	defer close(ch)
	for i := 0; i < 10; i++ {
		ch <- i
	}
}

또는 컨텍스트를 함께 사용합니다.

func worker(ctx context.Context, ch <-chan int) {
	for {
		select {
		case <-ctx.Done():
			return
		case v, ok := <-ch:
			if !ok {
				return
			}
			_ = v
		}
	}
}

원인 4: time.Ticker / time.After 남발로 인한 타이머 누수

time.NewTickerStop()을 호출하지 않으면 내부 리소스가 계속 남습니다. 또한 루프에서 time.After를 반복 호출하면 타이머 객체가 계속 생성됩니다.

나쁜 예: 티커 Stop 누락

func poll() {
	t := time.NewTicker(1 * time.Second)
	for range t.C {
		do()
	}
	// t.Stop() 호출되지 않음
}

개선

func poll(ctx context.Context) {
	t := time.NewTicker(1 * time.Second)
	defer t.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-t.C:
			do()
		}
	}
}

time.After 대체

timer := time.NewTimer(500 * time.Millisecond)
defer timer.Stop()

for {
	timer.Reset(500 * time.Millisecond)
	select {
	case <-timer.C:
		// ...
	case <-ctx.Done():
		return
	}
}

원인 5: sync.WaitGroup 사용 실수로 영구 대기

AddDone의 불일치, 또는 Add를 고루틴 시작 이후에 호출하는 경쟁 조건은 흔한 장애 포인트입니다.

전형적 실수

var wg sync.WaitGroup

for i := 0; i < 10; i++ {
	go func() {
		defer wg.Done() // Done은 있는데
		work()
	}()
	wg.Add(1) // Add가 늦음: 경쟁 조건
}

wg.Wait() // 영원히 기다릴 수 있음

개선

var wg sync.WaitGroup

for i := 0; i < 10; i++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		work()
	}()
}

wg.Wait()

진단 포인트

goroutine dump에서 sync.runtime_Semacquire 또는 WaitGroup.Wait로 대기하는 스택이 다수 보입니다.

원인 6: 뮤텍스/조건변수 데드락 또는 unlock 누락

에러 경로에서 Unlock가 빠지거나, 락 순서가 꼬여 데드락이 나면 관련 고루틴이 모두 멈춰 “누수처럼” 보입니다.

개선 팁

  • 락을 잡자마자 defer mu.Unlock()
  • 가능한 한 락 구간을 최소화
  • 락 순서(락 계층)를 문서화
mu.Lock()
defer mu.Unlock()

if err := do(); err != nil {
	return err
}

진단 포인트

pprof에서 sync.Mutex.Lock에서 대기하는 고루틴이 늘어나며, 특정 락을 잡은 고루틴이 어디서 멈췄는지 스택으로 추적합니다.

원인 7: 네트워크/HTTP 바디 미정리로 인한 커넥션 고갈과 연쇄 블로킹

고루틴 누수가 직접 원인이 아니라도, 커넥션이 반환되지 않아 다른 고루틴들이 네트워크에서 대기하며 “누수처럼” 쌓일 수 있습니다.

전형적 실수: resp.Body.Close() 누락

resp, err := http.Get(url)
if err != nil {
	return err
}
// resp.Body.Close() 누락
_, _ = io.ReadAll(resp.Body)

개선

resp, err := http.Get(url)
if err != nil {
	return err
}
defer resp.Body.Close()

_, err = io.Copy(io.Discard, resp.Body)
return err

또한 클라이언트 타임아웃을 반드시 설정하세요.

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

외부 API 호출이 많고 429 재시도 로직이 있다면, 재시도 고루틴이 폭증하지 않도록 백오프와 상한을 설계해야 합니다. 관련 패턴은 OpenAI 429 rate_limit_exceeded 재시도·백오프 설계도 함께 참고하면 좋습니다.

원인 8: 무제한 고루틴 생성(팬아웃 폭발)과 큐 부재

요청마다, 이벤트마다 무조건 go func()를 찍는 구조는 트래픽 스파이크에서 고루틴 수가 폭발합니다. 이 경우는 “누수”라기보다 “고루틴 과다 생성”이지만, 결과적으로 회수되지 못하고 장시간 남아 누수처럼 관측됩니다.

나쁜 예

for _, job := range jobs {
	go process(job) // 동시성 상한 없음
}

개선: 세마포어로 동시성 제한

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

for _, job := range jobs {
	wg.Add(1)
	sem <- struct{}{}
	go func(j Job) {
		defer wg.Done()
		defer func() { <-sem }()
		process(j)
	}(job)
}

wg.Wait()

또는 고정 워커 풀로 큐잉합니다.

실전 진단 절차: “증상 확인 → 스택 확보 → 분류 → 재현”

1) 증상 확인 체크리스트

  • runtime.NumGoroutine()가 시간에 따라 계속 증가하는가
  • p99 지연이 점진적으로 악화되는가
  • FD(파일 디스크립터) 수가 증가하는가
  • 외부 API 호출이 느려질 때 고루틴이 같이 증가하는가

컨테이너 환경(예: Cloud Run)에서는 인스턴스가 재활용되며 누수가 누적되기 쉬우므로, 롱런 프로세스에서의 누수 감지가 더 중요합니다. 운영 지연/오류가 503과 함께 나타난다면 인프라 튜닝도 병행해서 보세요: GCP Cloud Run 503·콜드스타트 줄이는 설정 7가지

2) goroutine dump에서 “상태”로 분류하기

/debug/pprof/goroutine?debug=2 출력에서 다음 키워드를 찾으면 빠르게 분류됩니다.

  • chan send / chan receive: 채널 블로킹
  • select: 컨텍스트/타임아웃 처리 여부 확인
  • sync.Mutex.Lock, WaitGroup.Wait: 락/대기열
  • net.(*conn).Read, crypto/tls: 네트워크 대기
  • time.Sleep, time.Ticker: 타이머 기반 루프

그리고 중요한 것은 “같은 스택이 계속 남아있나”입니다. 한 번의 덤프는 스파이크일 수도 있으니, 30초 간격으로 2~3회 떠서 비교하세요.

3) pprof로 누수 지점을 좁히기

텍스트 덤프가 충분히 강력하지만, 더 체계적으로 보려면 pprof를 사용합니다.

curl -s http://127.0.0.1:6060/debug/pprof/goroutine > goroutine.pb.gz

go tool pprof -http=:0 goroutine.pb.gz

웹 UI에서 상위 스택을 보면 “어떤 함수 경로의 고루틴이 가장 많이 쌓였는지”가 드러납니다.

4) 재현 테스트 작성: 누수는 테스트로 잡는 게 최선

요청 단위 누수는 부하 테스트나 단위 테스트로 재현할 수 있습니다.

func TestHandler_NoGoroutineLeak(t *testing.T) {
	defer goleak.VerifyNone(t)

	srv := newTestServer()
	for i := 0; i < 100; i++ {
		resp, err := http.Get(srv.URL)
		if err != nil {
			t.Fatal(err)
		}
		_ = resp.Body.Close()
	}
}

이 테스트가 실패한다면, “요청 100번 후에도 남는 고루틴”이 있다는 뜻이고, goleak이 스택을 제공해줍니다.

누수 방지 설계 원칙 6가지

  1. 고루틴을 만들 때는 종료 조건을 같이 만든다: context.Context 또는 done 채널
  2. 채널은 close 책임자를 명확히 한다
  3. send/receive는 가능하면 select로 취소 경로를 둔다
  4. TickerStop(), 타이머는 재사용하거나 수명을 제한한다
  5. 외부 I/O는 타임아웃을 기본값으로 강제하고, 바디/커넥션을 반드시 정리한다
  6. 무제한 팬아웃 금지: 동시성 상한(세마포어) 또는 워커 풀

마무리: “고루틴 수”가 아니라 “스택”을 보자

고루틴 누수는 숫자만 보면 막연하지만, 스택을 보면 대부분 한두 가지 패턴으로 수렴합니다. 운영에서는 NumGoroutine로 조기 경보를 걸고, 의심 시점에 goroutine dump를 확보해 “무엇을 기다리는지”를 확인한 뒤, 위 8가지 원인군에 매칭해 수정하는 흐름이 가장 빠릅니다.

마지막으로, 누수 수정 후에는 반드시 다음을 자동화하세요.

  • CI에서 goleak로 회귀 방지
  • 운영에서 pprof 엔드포인트(내부망) 또는 on-demand dump 수집
  • 외부 호출 재시도는 백오프와 상한을 둬 고루틴 폭증을 차단