Published on

Go 고루틴 누수 10분 진단 - context·채널 close

Authors
Binance registration banner

운영 중인 Go 서버가 어느 순간부터 메모리와 CPU가 서서히 오르고, goroutine 개수도 계속 증가한다면 대개는 고루틴 누수(goroutine leak) 입니다. 문제는 누수가 GC로 해결되지 않는다는 점입니다. 고루틴이 select 나 채널 수신에서 영원히 대기하면, 힙 객체와 타이머, 네트워크 커넥션까지 함께 붙잡고 서비스 전체를 잠식합니다.

이 글은 “원인 분석에 하루를 쓰지 말고, 10분 안에 의심 지점을 좁히는” 실전 진단 루틴을 목표로 합니다. 핵심은 두 가지입니다.

  • context 취소가 끝까지 전파되는가
  • 채널 close 규칙이 일관적인가(누가 닫고, 누가 읽고, 언제 종료되는가)

비슷한 성격의 운영 장애 진단 글로는 커넥션 누수 추적을 다룬 글도 참고가 됩니다. 관찰 지표를 먼저 잡고, 종료 경로를 강제한다는 관점이 유사합니다: Spring Boot 3.2 HikariCP 커넥션 누수 경고 추적법

10분 진단 루틴(체크리스트)

1) 지금 정말 “누수”인가: goroutine 수가 회복되지 않는가

가장 먼저 확인할 것은 “스파이크”가 아니라 “누적”인지입니다.

  • 트래픽이 줄어도 runtime.NumGoroutine() 이 내려오지 않는다
  • 배포 후 시간이 지날수록 완만하게 증가한다
  • 특정 API 호출, 특정 배치 작업 이후 증가가 가속된다

간단한 관측 코드를 임시로 넣는 것만으로도 방향이 잡힙니다.

package main

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

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

	select {}
}

운영에서는 pprof 가 정석입니다. HTTP 서버를 띄우고 /debug/pprof/goroutine?debug=2 를 봅니다.

import _ "net/http/pprof"

go func() {
	_ = http.ListenAndServe("127.0.0.1:6060", nil)
}()
  • debug=2 출력에서 동일한 스택이 수십, 수백 개 반복되면 그 지점이 유력한 누수 포인트입니다.

2) 고루틴 덤프에서 “영원 대기” 패턴을 찾는다

누수 스택에서 자주 보이는 키워드는 다음입니다.

  • chan receive / chan send
  • select 에서 특정 케이스만 기다림
  • time.Sleep 또는 time.NewTicker 로 루프 유지
  • (*net.Conn).Read 같은 블로킹 I/O

특히 채널 관련 대기는 “종료 신호가 없거나, 종료 신호가 도달하지 않는” 경우가 많습니다.

3) context가 진짜로 취소되는지 확인한다

다음 중 하나라도 빠지면 취소가 전파되지 않습니다.

  • context.WithCancel 또는 context.WithTimeoutcancel() 을 호출하지 않음
  • 고루틴 내부에서 selectctx.Done() 을 감시하지 않음
  • 하위 호출(HTTP, DB, gRPC)에 ctx 를 전달하지 않음

가장 흔한 실수는 “타임아웃을 만들었는데 cancel을 호출하지 않는 것”입니다. 타임아웃이 지나면 자동 취소되긴 하지만, 그 전까지는 내부 타이머와 리소스가 남습니다. 또한 조기 종료(에러 반환) 시 즉시 정리되지 않습니다.

func handler(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
	defer cancel() // 매우 중요

	// 반드시 ctx를 아래로 전달
	res, err := callDownstream(ctx)
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	_, _ = w.Write([]byte(res))
}

4) 채널 close 규칙이 “단일 소유자”로 정해져 있는지

채널 누수의 핵심은 누가 채널을 닫는가 입니다.

  • 원칙: 채널을 send 하는 쪽(생산자)이 close 한다
  • 여러 생산자가 있으면, 생산자가 닫지 말고 “조정자(aggregator)”가 닫는다
  • 소비자(수신자)가 임의로 닫으면 send on closed channel 패닉 위험이 커집니다

또한 수신 루프가 for v := range ch 형태라면, 채널이 닫히지 않으면 영원히 끝나지 않습니다.

고루틴 누수 대표 패턴 6가지와 즉시 처방

패턴 1) ctx를 안 보는 작업 루프

func worker(jobs <-chan Job) {
	for job := range jobs {
		do(job)
	}
}

jobs 가 닫히지 않으면 worker는 영원히 대기합니다. 종료 조건을 context 로 추가합니다.

func worker(ctx context.Context, jobs <-chan Job) {
	for {
		select {
		case <-ctx.Done():
			return
		case job, ok := <-jobs:
			if !ok {
				return
			}
			do(job)
		}
	}
}

패턴 2) 결과 채널을 읽지 않아 send가 막힘

func fetchAsync() <-chan Result {
	ch := make(chan Result)
	go func() {
		ch <- Result{Value: 1} // 소비자가 없으면 여기서 블로킹
		close(ch)
	}()
	return ch
}

이 패턴은 호출자가 결과를 안 읽는 순간 고루틴이 멈춥니다. 처방은 보통 둘 중 하나입니다.

  • 버퍼 채널로 “한 번은” 흘려보내게 만들기
  • ctx.Done() 을 함께 감시해 취소 시 포기
func fetchAsync(ctx context.Context) <-chan Result {
	ch := make(chan Result, 1)
	go func() {
		defer close(ch)
		res := Result{Value: 1}
		select {
		case ch <- res:
		case <-ctx.Done():
			return
		}
	}()
	return ch
}

패턴 3) fan-in에서 close를 안 해서 range가 끝나지 않음

여러 생산자 출력을 하나로 합칠 때 WaitGroupclose 타이밍이 어긋나면 소비자는 끝나지 않습니다.

func fanIn(ctx context.Context, ins ...<-chan int) <-chan int {
	out := make(chan int)
	var wg sync.WaitGroup
	wg.Add(len(ins))

	for _, in := range ins {
		in := in
		go func() {
			defer wg.Done()
			for v := range in {
				select {
				case out <- v:
				case <-ctx.Done():
					return
				}
			}
		}()
	}

	go func() {
		wg.Wait()
		close(out) // 단일 소유자(조정자)가 닫는다
	}()

	return out
}

핵심은 out 을 닫는 주체가 오직 하나라는 점입니다.

패턴 4) ticker를 멈추지 않아 누수처럼 보임

time.NewTicker 는 반드시 Stop() 해야 합니다. 안 그러면 내부 타이머가 계속 살아 있고, 루프가 종료되어도 리소스가 남을 수 있습니다.

func poll(ctx context.Context) {
	t := time.NewTicker(1 * time.Second)
	defer t.Stop()

	for {
		select {
		case <-ctx.Done():
			return
		case <-t.C:
			doPoll()
		}
	}
}

패턴 5) errgroup을 쓰는데 ctx를 무시하는 작업이 섞임

errgroup.WithContext 를 써도, 각 고루틴이 ctx.Done() 을 감시하지 않으면 취소가 의미가 없습니다.

func run(ctx context.Context) error {
	g, ctx := errgroup.WithContext(ctx)

	g.Go(func() error {
		return taskA(ctx)
	})
	g.Go(func() error {
		return taskB(ctx)
	})

	return g.Wait()
}

taskAtaskB 내부에서 블로킹 I/O를 한다면 반드시 ctx 를 전달하거나, 최소한 다음처럼 빠져나갈 구멍을 둬야 합니다.

func taskA(ctx context.Context) error {
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		default:
			// 작업
		}
	}
}

패턴 6) close를 “받는 쪽”에서 해버림

func consumer(ch chan int) {
	defer close(ch) // 위험: 생산자가 send 중이면 패닉
	for v := range ch {
		_ = v
	}
}

이 코드는 구조적으로 불안정합니다. 채널을 닫는 책임은 생산자 또는 조정자에게 있어야 합니다. 소비자는 종료 신호로 ctx 를 쓰거나, 별도의 done 채널을 받는 방식이 안전합니다.

10분 안에 원인 좁히는 실전 방법

1) goroutine 프로파일에서 “가장 많은 스택”부터 본다

  • /debug/pprof/goroutine?debug=2 를 열고 동일 스택이 반복되는 블록을 찾습니다.
  • 반복되는 함수가 select<-ch 에서 멈춰 있으면, 그 채널이 닫히는지 추적합니다.

2) 종료 조건을 코드로 강제한다: ctx 또는 close 중 하나는 반드시

작업 고루틴에는 다음 중 하나가 반드시 있어야 합니다.

  • case <-ctx.Done(): return
  • for v := range ch 인 경우, 채널이 정상적으로 close 되는 경로

둘 다 없으면, 누수 가능성이 매우 높습니다.

3) “누가 채널을 닫는지”를 주석이 아니라 구조로 보장한다

  • 생산자 1명: 생산자가 defer close(out)
  • 생산자 N명: 조정자가 WaitGroup 으로 기다렸다가 close(out)
  • 소비자: 절대 닫지 않음(원칙)

이 규칙이 지켜지면 send on closed channel 도 줄고, range 가 끝나지 않는 문제도 크게 감소합니다.

누수 방지 템플릿: 안전한 워커 풀

아래는 context 취소와 채널 close 규칙을 함께 만족하는 워커 풀 템플릿입니다.

type Job struct{ ID int }

type Result struct {
	JobID int
	Err   error
}

func StartPool(ctx context.Context, n int, jobs <-chan Job) <-chan Result {
	out := make(chan Result)
	var wg sync.WaitGroup
	wg.Add(n)

	worker := func() {
		defer wg.Done()
		for {
			select {
			case <-ctx.Done():
				return
			case job, ok := <-jobs:
				if !ok {
					return
				}
				err := doJob(ctx, job)
				select {
				case out <- Result{JobID: job.ID, Err: err}:
				case <-ctx.Done():
					return
				}
			}
		}
	}

	for i := 0; i < n; i++ {
		go worker()
	}

	go func() {
		wg.Wait()
		close(out) // 조정자만 close
	}()

	return out
}

func doJob(ctx context.Context, job Job) error {
	// 실제 작업에서 ctx를 하위 호출로 전달
	return nil
}

이 템플릿의 포인트는 다음입니다.

  • 워커는 ctx.Done()jobs 종료(ok == false) 둘 다로 종료 가능
  • 결과 전송도 ctx.Done() 을 함께 감시해 “읽는 쪽이 없어 막히는” 상황을 피함
  • out 은 워커가 아니라 조정 고루틴이 단일 소유자로 close

운영에서 자주 묻는 질문

채널을 닫는 대신 ctx만 써도 되나

가능한 경우가 많습니다. 다만 for v := range ch 패턴을 쓰고 있다면 채널 close 가 가장 자연스러운 종료 신호입니다. 반대로 “중간에 즉시 취소”가 중요한 경우는 ctx 가 더 적합합니다. 실전에서는 둘을 같이 씁니다.

  • 데이터 스트림 종료: 채널 close
  • 작업 취소/타임아웃/상위 요청 종료: ctx.Done()

버퍼 채널이면 누수가 사라지나

버퍼는 “막힐 확률”을 낮추지만, 구조적 해결은 아닙니다. 버퍼가 차는 순간 동일한 문제가 재현됩니다. 버퍼는 보조 수단이고, 종료 신호(ctx 또는 close)가 본질입니다.

마무리: 가장 효과 좋은 2가지 수정 포인트

고루틴 누수는 원인이 다양해 보여도, 실제로는 아래 두 가지로 수렴하는 경우가 많습니다.

  1. 모든 장수 고루틴에 ctx.Done() 종료 경로를 넣고, cancel()defer 로 보장한다
  2. 채널은 “단일 소유자 close” 규칙을 지키고, range 가 끝나는 경로를 코드로 보장한다

이 두 가지만 일관되게 적용해도 NumGoroutine 이 회복되지 않는 유형의 누수는 대부분 빠르게 잡힙니다. 다음 단계로는 pprof 의 goroutine/heap 프로파일을 주기적으로 수집해 “증가 추세”를 조기에 감지하는 운영 루틴을 붙이면, 장애로 커지기 전에 차단할 수 있습니다.