요약: Go의 goroutine leak은 채널 전송이 영원히 블로킹되거나 context 취소가 전파되지 않아 고루틴이 종료되지 못할 때 발생합니다. 버퍼 채널(크기 1), context.WithTimeout과 defer cancel(), 종료 조건이 있는 select를 쓰면 해결되고, pprof의 goroutine 프로파일로 누수 위치를 찾을 수 있습니다.
🔍 검색 키워드: Go goroutine leak, 고루틴 누수 해결, Go pprof goroutine 분석, Go context cancel 누수, Go 메모리 계속 증가, goroutine 개수 증가 원인
증상: 서버 메모리와 goroutine 수가 계속 늘어난다
Go 서비스를 배포한 뒤 시간이 지날수록 메모리 사용량이 우상향하고, 재시작하면 잠시 정상으로 돌아오는 패턴이 나타납니다. 이때 가장 먼저 의심해야 할 것이 goroutine leak(고루틴 누수)입니다. pprof 엔드포인트로 확인하면 아래와 같이 고루틴 수가 비정상적으로 큽니다.
$ curl http://localhost:6060/debug/pprof/goroutine?debug=1
goroutine profile: total 10432
10380 @ 0x43a5f6 0x4066bc 0x4062f5 0x6f1a2b 0x46f8c1
# 0x6f1a2a main.fetchAll.func1+0x4a /app/main.go:42정상 서비스라면 요청이 끝난 뒤 고루틴 수가 원래 수준으로 돌아와야 하는데, 요청 수에 비례해 계속 쌓인다면 어딘가에서 고루틴이 영원히 블로킹되어 있다는 뜻입니다.
원인: 아무도 받지 않는 채널, 끝나지 않는 대기
고루틴은 스스로 종료되지 않으면 GC가 회수하지 못합니다. 대표적인 누수 원인은 다음과 같습니다.
- 수신자 없는 채널 전송: 언버퍼드 채널에 값을 보내는데 받는 쪽이 이미 타임아웃으로 빠져나간 경우
- context 미전파: HTTP 요청이 취소되어도 하위 고루틴에 ctx를 넘기지 않아 계속 실행되는 경우
- time.Ticker 미정지: Stop()을 호출하지 않아 고루틴과 타이머가 남는 경우
- 무한 for-select 루프: 종료 조건(done 채널, ctx.Done())이 없는 워커
아래는 가장 흔한 누수 코드입니다. 타임아웃이 발생하면 fetch 고루틴은 ch에 값을 보내려고 영원히 대기합니다.
func fetchWithTimeout(url string) (string, error) {
ch := make(chan string) // 언버퍼드 채널
go func() {
ch <- slowRequest(url) // 받는 쪽이 없으면 영원히 블로킹
}()
select {
case res := <-ch:
return res, nil
case <-time.After(1 * time.Second):
return "", errors.New("timeout") // 여기서 반환하면 위 고루틴은 누수
}
}해결방법 1: 버퍼 채널로 전송 블로킹 제거
채널 버퍼를 1로 두면 받는 쪽이 사라져도 고루틴은 값을 넣고 정상 종료됩니다. 결과가 하나뿐인 경우 가장 간단한 해결책입니다.
ch := make(chan string, 1) // 버퍼 1: 수신자가 없어도 전송이 완료됨해결방법 2: context로 취소 전파
요청 단위 작업은 반드시 context를 받아 하위 호출까지 전달하고, 블로킹 지점마다 ctx.Done()을 함께 select 합니다.
func fetchWithTimeout(ctx context.Context, url string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel() // 반드시 호출해서 타이머 리소스 해제
ch := make(chan string, 1)
go func() { ch <- slowRequestCtx(ctx, url) }()
select {
case res := <-ch:
return res, nil
case <-ctx.Done():
return "", ctx.Err()
}
}해결방법 3: 워커 종료 조건과 Ticker 정리
func worker(ctx context.Context) {
t := time.NewTicker(time.Minute)
defer t.Stop() // Ticker는 반드시 Stop
for {
select {
case <-t.C:
doWork()
case <-ctx.Done():
return // 종료 조건 필수
}
}
}누수 진단 방법: pprof와 테스트
운영 중에는 net/http/pprof를 열어 goroutine 프로파일을 두 번 떠서 비교하면 어느 코드 위치에서 고루틴이 쌓이는지 바로 보입니다. 개발 단계에서는 go.uber.org/goleak 라이브러리로 테스트 종료 시점에 남은 고루틴을 자동 검출할 수 있습니다.
go tool pprof http://localhost:6060/debug/pprof/goroutine
(pprof) top
(pprof) list main.fetchAll정리표
| 누수 원인 | 증상 | 해결 |
|---|---|---|
| 수신자 없는 채널 전송 | chan send 상태로 고루틴 누적 | 버퍼 채널(1) 또는 select+ctx |
| context 미전파 | 요청 취소 후에도 작업 지속 | ctx 전달, WithTimeout+defer cancel |
| Ticker 미정지 | 타이머와 고루틴 잔존 | defer t.Stop() |
| 종료 조건 없는 루프 | 워커가 영구 실행 | ctx.Done() 케이스 추가 |
핵심은 모든 고루틴은 종료 경로를 가져야 한다는 원칙입니다. 고루틴을 시작하는 코드를 쓸 때 “이 고루틴은 언제 끝나는가?”를 먼저 답할 수 없다면 누수 후보입니다.
자주 묻는 질문(FAQ)
goroutine leak은 GC가 자동으로 정리해 주나요?
아니요. Go의 가비지 컬렉터는 블로킹 중인 고루틴을 회수하지 않습니다. 고루틴은 스스로 함수를 끝내야 사라지므로, 모든 고루틴에 종료 경로(ctx.Done(), 버퍼 채널, done 채널)를 만들어야 합니다.
고루틴이 몇 개부터 누수로 봐야 하나요?
절대적인 기준 숫자는 없습니다. 트래픽이 줄어도 고루틴 수가 내려가지 않고 시간에 따라 계속 증가한다면 누수 신호입니다. runtime.NumGoroutine() 값을 메트릭으로 수집해 추세를 확인하세요.
테스트 단계에서 goroutine leak을 자동으로 잡을 수 있나요?
go.uber.org/goleak 패키지의 goleak.VerifyNone(t)를 테스트 끝에 호출하면 종료되지 않은 고루틴이 있을 때 테스트가 실패합니다.
time.After를 select에서 써도 누수가 생기나요?
time.After가 만든 타이머는 만료되기 전까지 메모리에 남습니다. 반복문 안에서 자주 쓴다면 time.NewTimer를 만들고 Stop()으로 정리하거나 context.WithTimeout을 쓰는 편이 안전합니다.
관련 글: FastAPI async 블로킹 완벽 해결 — 이벤트 루프 막힘 해결법
참고 문서: net/http/pprof · context 패키지 · uber-go/goleak
최종 업데이트: 2026-10-01 · 작성: 내멋스레 사는 이야기