- Published on
Go 고루틴 누수 10분 진단 - context·채널 close
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
운영 중인 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 sendselect에서 특정 케이스만 기다림time.Sleep또는time.NewTicker로 루프 유지(*net.Conn).Read같은 블로킹 I/O
특히 채널 관련 대기는 “종료 신호가 없거나, 종료 신호가 도달하지 않는” 경우가 많습니다.
3) context가 진짜로 취소되는지 확인한다
다음 중 하나라도 빠지면 취소가 전파되지 않습니다.
context.WithCancel또는context.WithTimeout의cancel()을 호출하지 않음- 고루틴 내부에서
select로ctx.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가 끝나지 않음
여러 생산자 출력을 하나로 합칠 때 WaitGroup 과 close 타이밍이 어긋나면 소비자는 끝나지 않습니다.
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()
}
taskA 나 taskB 내부에서 블로킹 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(): returnfor 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가지 수정 포인트
고루틴 누수는 원인이 다양해 보여도, 실제로는 아래 두 가지로 수렴하는 경우가 많습니다.
- 모든 장수 고루틴에
ctx.Done()종료 경로를 넣고,cancel()을defer로 보장한다 - 채널은 “단일 소유자 close” 규칙을 지키고,
range가 끝나는 경로를 코드로 보장한다
이 두 가지만 일관되게 적용해도 NumGoroutine 이 회복되지 않는 유형의 누수는 대부분 빠르게 잡힙니다. 다음 단계로는 pprof 의 goroutine/heap 프로파일을 주기적으로 수집해 “증가 추세”를 조기에 감지하는 운영 루틴을 붙이면, 장애로 커지기 전에 차단할 수 있습니다.