🔍 검색 키워드: 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() 케이스 추가 |
핵심은 모든 고루틴은 종료 경로를 가져야 한다는 원칙입니다. 고루틴을 시작하는 코드를 쓸 때 “이 고루틴은 언제 끝나는가?”를 먼저 답할 수 없다면 누수 후보입니다.