Published on

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

Authors

운영 중인 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 프로파일을 주기적으로 수집해 “증가 추세”를 조기에 감지하는 운영 루틴을 붙이면, 장애로 커지기 전에 차단할 수 있습니다.