- Published on
Go 고루틴 누수 잡기 - context·select·pprof
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
고루틴 누수(goroutine leak)는 “메모리가 조금 늘었다” 수준에서 끝나지 않습니다. 살아있는 고루틴이 계속 쌓이면 스케줄러 런큐가 비대해지고, 채널/락 경합이 늘고, 요청 타임아웃이 증가하며, 결국은 FD(소켓)·타이머·버퍼까지 잠식합니다. 특히 서버는 트래픽이 올라갈수록 누수가 가속되기 때문에, 발생 원인 패턴을 구조적으로 차단하고 pprof로 재현-증명-수정까지 이어지는 루틴을 갖추는 게 중요합니다.
이 글은 다음 3가지를 한 흐름으로 묶습니다.
context로 취소와 데드라인을 끝까지 전파하기select로 종료 신호를 항상 우선순위 있게 받는 패턴 만들기pprof로 “어디서 누수가 시작되는지” 스택 단위로 역추적하기
관련해서 타임아웃/데드라인 전파 설계는 gRPC 환경에서 특히 중요합니다. 함께 보면 좋은 글: gRPC 타임아웃 지옥 탈출 - 데드라인 전파 설계
고루틴 누수의 전형적인 징후
운영에서 흔히 보는 징후는 다음과 같습니다.
runtime.NumGoroutine()이 트래픽이 없어도 내려오지 않고 계단식으로 증가- p99 레이턴시가 서서히 악화(특히 GC가 원인이 아닌데도)
- 커넥션 수/FD 수가 서서히 증가(HTTP 클라이언트/스트리밍에서 자주 발생)
- CPU는 낮은데 응답이 느려짐(대기 중인 고루틴이 과도하게 많아 스케줄링/락 경합)
핵심은 “고루틴이 종료되지 않는 이유”가 보통 아래 두 가지로 수렴한다는 점입니다.
- 블로킹 호출이 끝나지 않는다: 채널 receive, 락,
Read/Recv같은 I/O - 종료 신호를 받을 경로가 없다:
context.Done()을 보지 않거나, select 구조가 없다
누수 패턴 1: 채널 receive에서 영원히 대기
아래 코드는 요청마다 워커를 띄우고, 워커는 jobs 채널에서 계속 읽습니다. 문제는 요청이 끝나도 워커가 종료되지 않는다는 점입니다.
package main
import (
"context"
"time"
)
type Job struct{ ID int }
func startWorker(jobs <-chan Job) {
go func() {
for job := range jobs { // jobs가 close되지 않으면 영원히 대기
_ = job
}
}()
}
func main() {
jobs := make(chan Job)
startWorker(jobs)
// 누군가 jobs를 close하지 않으면 워커는 끝나지 않는다.
_ = time.Second
_ = context.Background()
}
해결: 종료 조건을 “채널 close” 또는 “context 취소”로 명시
- 프로듀서가 명확히
close(jobs)할 수 있으면 가장 단순합니다. - 요청 단위/서브시스템 단위로 종료를 통제해야 하면
context를 섞는 편이 안전합니다.
func startWorker(ctx context.Context, jobs <-chan Job) {
go func() {
for {
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok {
return
}
_ = job
}
}
}()
}
여기서 중요한 포인트는 “무조건 select 안에서 ctx.Done() 을 본다” 입니다. 그래야 채널이 영원히 닫히지 않는 버그가 있어도 워커가 시스템 종료/요청 취소에 반응합니다.
누수 패턴 2: select 없는 타이머/티커 루프
time.Ticker 는 멈추지 않으면 내부 리소스가 계속 유지됩니다. 또한 티커 루프에서 종료 신호를 받지 않으면 고루틴이 계속 살아있습니다.
func pollForever() {
t := time.NewTicker(500 * time.Millisecond)
for range t.C {
// ...
}
// t.Stop() 도 호출되지 않음
}
해결: defer t.Stop() + 종료 select
func poll(ctx context.Context) {
t := time.NewTicker(500 * time.Millisecond)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
// ...
}
}
}
이 패턴은 “주기 작업”, “백그라운드 동기화”, “캐시 리프레시” 같은 곳에서 누수를 많이 막아줍니다.
누수 패턴 3: fan-out 후 fan-in에서 영원히 대기
고루틴을 여러 개 띄우고 결과를 모으는 코드에서, 결과 채널을 닫지 않거나 WaitGroup 이 끝나지 않으면 수집 루프가 영원히 대기합니다.
잘못된 예
func fanOut(in []int) <-chan int {
out := make(chan int)
for _, v := range in {
go func(x int) {
out <- x * 2 // 수신자가 없으면 여기서 블로킹
}(v)
}
return out // close(out) 없음
}
해결: 버퍼/취소/close를 함께 설계
ctx로 취소를 걸고- 생산자 고루틴이 종료될 때
close(out)를 보장하고 - 필요하면
out에 버퍼를 주거나, send 시에도select로ctx.Done()을 봅니다.
func fanOut(ctx context.Context, in []int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, v := range in {
v := v
select {
case <-ctx.Done():
return
case out <- v * 2:
}
}
}()
return out
}
fan-out 을 “고루틴 여러 개”로 병렬화해야 한다면 sync.WaitGroup 으로 생산자 종료를 모아서 close(out) 하는 구조가 일반적입니다.
context 취소 전파: 누수 방지의 기본 골격
누수를 막는 가장 강력한 규칙은 이것입니다.
- 고루틴을 시작하면, 반드시 종료 경로를 함께 만든다.
- 종료 경로는 보통
context로 표현한다.
요청 스코프에서 파생 컨텍스트 만들기
func handler(reqCtx context.Context) error {
ctx, cancel := context.WithTimeout(reqCtx, 2*time.Second)
defer cancel()
// 이 ctx를 하위 고루틴/함수/클라이언트 호출까지 전달
return doWork(ctx)
}
여기서 defer cancel() 은 단순한 예의가 아니라, 타이머/자식 컨텍스트 리소스를 회수하는 중요한 동작입니다.
“컨텍스트를 받는 함수”를 기본 인터페이스로
- DB, 캐시, HTTP/gRPC 호출은 대부분
ctx를 받는 형태가 표준입니다. - 내부 유틸 함수도 습관적으로
ctx context.Context를 첫 인자로 두면 누수 방지에 유리합니다.
gRPC 환경에서는 특히 데드라인 전파가 누수와 직결됩니다. 서버가 이미 요청을 포기했는데 클라이언트 작업이 계속 진행되면, 스트림/리트라이/백오프 고루틴이 남습니다. 참고: gRPC 타임아웃 지옥 탈출 - 데드라인 전파 설계
select 종료 패턴: “블로킹 지점”마다 Done 을 넣기
고루틴 누수의 실체는 “어딘가에서 블로킹 중”입니다. 그러면 그 블로킹 지점마다 아래 질문을 던져야 합니다.
- 이 receive/send/락/I-O는 언제 끝나나?
- 안 끝나면 어떻게 빠져나오나?
채널 send/receive는 select 로 감쌀 수 있습니다.
select {
case <-ctx.Done():
return ctx.Err()
case msg := <-ch:
_ = msg
}
주의: default 는 신중히
select 에 default 를 넣으면 비블로킹이 되지만, 잘못 쓰면 busy loop 로 CPU를 태웁니다.
for {
select {
case <-ctx.Done():
return
default:
// 아무것도 없으면 계속 돈다: CPU 스핀
}
}
정말로 폴링이 필요하면 time.Ticker 나 time.Sleep 을 섞어 backoff를 주는 편이 안전합니다.
pprof로 “누수 고루틴의 스택” 잡아내기
패턴을 잘 지켜도, 실제 누수는 라이브러리/에러 경로/예외적인 타임아웃 조합에서 터집니다. 이때 가장 빠른 방법이 pprof 로 고루틴 덤프를 떠서 스택을 보는 것입니다.
1) net/http/pprof 엔드포인트 열기
운영 서버에 디버그 포트를 별도로 열어두는 구성이 흔합니다(인증/방화벽 필수).
package main
import (
"log"
"net/http"
_ "net/http/pprof"
)
func main() {
go func() {
// 내부망에서만 접근 가능하게 구성하는 것을 권장
log.Println(http.ListenAndServe("127.0.0.1:6060", nil))
}()
select {}
}
2) 현재 고루틴 수와 스택 확인
- 고루틴 수:
http://127.0.0.1:6060/debug/pprof/goroutine?debug=1 - 더 자세한 스택:
http://127.0.0.1:6060/debug/pprof/goroutine?debug=2
여기서 특정 함수(예: (*Client).readLoop, chan receive, select, time.Sleep)가 반복적으로 보이면 누수 후보입니다.
3) 스냅샷을 떠서 비교 분석
시간 간격을 두고 프로파일을 떠서 비교하면 “늘어나는 스택”이 보입니다.
curl -o goroutine_1.pb.gz http://127.0.0.1:6060/debug/pprof/goroutine
sleep 60
curl -o goroutine_2.pb.gz http://127.0.0.1:6060/debug/pprof/goroutine
go tool pprof -http=:0 goroutine_2.pb.gz
웹 UI에서 Top 과 Flame Graph 를 보면, 어떤 스택 프레임이 고루틴을 가장 많이 점유하는지 확인할 수 있습니다.
4) “증가하는 고루틴”만 골라내는 관점
pprof 자체는 고루틴 “개수”를 보여주지만, 누수는 보통 “특정 스택이 지속 증가”하는 형태입니다.
- 같은 스택이 계속 증가한다: 누수 가능성 높음
- 여러 스택이 동시에 증가한다: 취소 전파가 끊겼거나 공용 큐/브로커가 막혔을 가능성
이때 애플리케이션 로그에 주기적으로 runtime.NumGoroutine() 을 찍어두면, pprof 캡처 타이밍을 잡기 쉽습니다.
import (
"log"
"runtime"
"time"
)
func logGoroutines() {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
for range t.C {
log.Printf("num_goroutine=%d", runtime.NumGoroutine())
}
}
실전 디버깅 플레이북
운영에서 “고루틴이 새는 것 같다”는 신호를 받았을 때, 아래 순서가 효율적입니다.
1) 누수인지 먼저 확인
- 트래픽이 내려갔는데도
num_goroutine이 내려오지 않는가 - 배포/롤링 이후에도 동일하게 누적되는가
2) pprof goroutine 스택으로 상위 패턴 찾기
chan receive가 많은가net.(*conn).Read같은 I/O 대기가 많은가- 특정 패키지의 루프가 반복되는가
3) 해당 지점에 “종료 조건”을 추가
context를 전달하고 있는가- 블로킹 지점이
select로 감싸져 있는가 - 티커/타이머/스트림이
Stop또는Close되는가
4) 재현 테스트로 회귀 방지
간단한 부하 테스트/타임아웃 테스트를 만들어, 일정 시간 후 고루틴 수가 안정화되는지 확인합니다.
func waitStable(target int, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if runtime.NumGoroutine() <= target {
return nil
}
time.Sleep(200 * time.Millisecond)
}
return context.DeadlineExceeded
}
자주 놓치는 포인트 체크리스트
defer cancel()을 빼먹어 컨텍스트 타이머가 남는 경우http.Client사용 시resp.Body.Close()누락으로 커넥션/리드 루프가 남는 경우- 채널을 닫아야 하는 소유권(누가
close하는가)이 불명확한 경우 errgroup를 쓰면서ctx를 무시하고 별도 고루틴이 계속 도는 경우- “에러가 나면 return” 경로에서 정리 코드가 실행되지 않는 경우(특히
Stop/Close)
운영 장애의 형태는 다르지만, 원인은 결국 “종료 신호가 전달되지 않거나, 전달돼도 블로킹에서 빠져나오지 못함”으로 귀결됩니다. context 와 select 를 코드 구조의 기본값으로 만들고, pprof로 스택을 확인하는 습관을 들이면 고루틴 누수는 충분히 통제 가능한 범주가 됩니다.
추가로, 배포 후 특정 노드에서만 누수가 커지거나 재시작 루프와 결합되는 경우도 있습니다. 그런 상황에서는 시스템 레벨 관측(프로세스 재시작, 로그 추적)도 함께 봐야 하니, 필요하다면 systemd 서비스 무한 재시작 원인과 journalctl 추적 같은 관점도 같이 적용해보면 좋습니다.