goroutine 수가 계속 올라가고 내려오지 않는다면, 핵심은 goroutine이 새어 나간다는 표현보다 종료되지 못한 작업이 어디에 쌓이고 있는지 찾는 것입니다.
그래서 goroutine leak은 timeout, queue 적체, 메모리 압박, 종료 지연과 같이 보이는 경우가 많습니다. goroutine 수 증가는 눈에 보이는 현상일 뿐이고, 실제 원인은 blocked communication, cancellation 누락, worker 종료 경로 부재인 경우가 많습니다.
이 글은 실전 순서에 집중합니다.
- 지금 보고 있는 현상이 일시적인 spike인지 실제 leak인지 구분하는 방법
- 어떤 goroutine이 어디서 멈춰 있는지 확인하는 방법
- Go 서비스에서 자주 나오는 leak 패턴과 수정 포인트
짧게 말하면 부하가 빠진 뒤에도 goroutine 수가 높은지 먼저 확인하고, 그다음 어떤 대기 상태가 반복되는지 수집한 뒤, channel ownership과 cancellation, worker shutdown 경로를 추적하면 됩니다.
Go 안에서 더 큰 분기부터 다시 보고 싶다면 Golang 트러블슈팅 가이드로 먼저 돌아가세요.
goroutine leak은 보통 무엇을 뜻하나
goroutine leak이라고 할 때는 보통 이런 상황을 뜻합니다.
- send나 receive가 영원히 끝나지 않아 goroutine이 block된 경우
- 요청이 끝난 뒤에도 background 작업이 계속 살아남는 경우
- ticker, watcher, retry loop가 종료 조건 없이 계속 도는 경우
- worker pool이나 queue consumer에 정상 종료 경로가 없는 경우
이런 경우 프로세스는 살아 있고 코드도 겉으로는 멀쩡해 보입니다. 하지만 시간이 갈수록 같은 대기 상태의 goroutine이 조금씩 누적됩니다.
그래서 총 개수만 보는 것보다 goroutine dump 하나가 더 큰 힌트를 주는 경우가 많습니다. count는 이상 징후를 보여주고, state는 원인을 보여줍니다.
먼저 실제 leak인지 확인하기
일시적인 spike와 실제 leak은 다릅니다.
코드를 바로 고치기 전에, 부하가 올라갈 때 잠깐 늘었다가 다시 내려오는지, 아니면 부하가 빠진 뒤에도 기준선이 계속 높아지는지부터 보세요. 정상적인 서비스도 순간 부하에서는 goroutine 수가 꽤 늘 수 있습니다.
가장 빠른 첫 신호는 runtime.NumGoroutine()입니다.
package main
import (
"log"
"runtime"
"time"
)
func logGoroutineCount() {
for range time.Tick(30 * time.Second) {
log.Printf("goroutines=%d", runtime.NumGoroutine())
}
}
이 코드만으로 원인을 알 수는 없지만, 첫 질문에는 답할 수 있습니다. 부하가 끝난 뒤 count가 원래 수준으로 돌아오는지, 아니면 기준선이 조금씩 위로 밀리는지 확인할 수 있습니다.
작업이 끝난 뒤에도 count가 계속 높게 남는다면 leak 가능성이 커집니다.
멈춘 goroutine은 어디서 봐야 하나
count가 수상하다면 이제 대기 중인 goroutine이 무엇을 하고 있는지 봐야 합니다.
실전에서는 보통 아래 도구들이 가장 유용합니다.
- 추세 확인용
runtime.NumGoroutine() - goroutine profile과 blocking profile을 볼 수 있는
net/http/pprof - 장애 시점 전후의 stack dump
간단한 pprof 노출만 있어도 출발점으로 충분합니다.
package main
import (
"log"
"net/http"
_ "net/http/pprof"
)
func main() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
}
그다음 goroutine profile을 수집합니다.
go tool pprof http://localhost:6060/debug/pprof/goroutine
먼저 텍스트로 빠르게 보고 싶다면 아래처럼 확인해도 됩니다.
curl http://localhost:6060/debug/pprof/goroutine?debug=2
여기서 중요한 것은 반복입니다. 같은 함수, 같은 channel operation, 같은 wait path에 멈춘 goroutine이 많이 보인다면 그 스택이 가장 먼저 좁혀야 할 leak 후보입니다.
Go 공식 진단 문서도 함께 보면 범위를 넓히는 데 도움이 됩니다: https://go.dev/doc/diagnostics
자주 나오는 leak 패턴
1. 받을 쪽이 불확실한 blocked send
가장 흔한 패턴 중 하나는 receiver가 더 이상 읽지 않는데 sender 쪽 goroutine이 채널로 값을 보내려는 경우입니다.
func startWorker(ch chan<- int) {
go func() {
result := expensiveWork()
ch <- result
}()
}
겉보기에는 단순하지만, receiver가 먼저 종료되거나 채널을 더 이상 소비하지 않으면 send가 영원히 block될 수 있습니다.
이때는 다음을 확인하세요.
- channel close를 누가 책임지는지
- receiver가 읽기 전에 먼저 return할 수 있는지
- send가
ctx.Done()을 함께 보도록 해야 하는지
더 안전한 패턴은 보통 이렇게 생깁니다.
func startWorker(ctx context.Context, ch chan<- int) {
go func() {
result := expensiveWork()
select {
case ch <- result:
case <-ctx.Done():
return
}
}()
}
2. background 작업의 cancellation 누락
request-scoped 작업이 background goroutine을 띄우는데 cancellation을 물려주지 않아서 leak이 생기는 경우도 많습니다.
func handle() {
go syncData(context.Background())
}
이 작업이 요청과 함께 끝나야 한다면 context.Background()는 대개 잘못된 시작점입니다.
요청 context를 넘기고, 하위 호출까지 전파하고, cancellation이 발생하면 worker가 빠져나오도록 해야 합니다.
func handle(ctx context.Context) {
go syncData(ctx)
}
func syncData(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
doOneStep()
}
}
}
3. stop 경로가 없는 ticker 또는 watcher loop
장수 루프는 원래 오래 살아야 하니 놓치기 쉽습니다. 문제는 “오래”가 어느 순간 “영원히”가 되는 경우입니다.
func startWatcher(ctx context.Context) {
ticker := time.NewTicker(10 * time.Second)
go func() {
for range ticker.C {
refresh()
}
}()
}
여기에는 두 가지 문제가 있습니다.
- ticker를 멈추지 않음
- cancellation 시 goroutine이 종료되지 않음
조금 더 안전한 형태는 아래와 같습니다.
func startWatcher(ctx context.Context) {
ticker := time.NewTicker(10 * time.Second)
go func() {
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
refresh()
}
}
}()
}
4. worker shutdown과 queue drain 버그
wait group, worker pool, queue consumer도 종료 순서가 어긋나면 goroutine leak으로 이어질 수 있습니다.
자주 보이는 실패 패턴은 이렇습니다.
- worker 종료가 시작됐는데 producer는 계속 queue에 push함
- queue가 닫히지 않아 worker가 영원히 대기함
- shutdown은 worker 종료를 기다리는데, worker는 이미 해제된 자원을 기다리며 block됨
worker 함수에서 대기 중인 goroutine이 많이 보인다면 startup 경로와 shutdown 경로를 나란히 비교해 보세요. leak 버그는 시작 흐름은 분명한데 멈추는 책임이 흐릿한 경우가 많습니다.
운영 중에 잘 먹히는 점검 순서
장애가 진행 중일 때는 아래 순서가 가장 실전적입니다.
- 부하가 빠진 뒤에도 goroutine 수가 높은지 확인
- 장애 시점 근처에서 goroutine dump를 한두 번 수집
- 반복되는 stack을 대기 위치별로 묶기
- 해당 stack의 channel ownership과 cancellation 경로 확인
- 장수 루프, worker lifecycle, 최근 동시성 변경 사항 검토
이 순서가 좋은 이유는, 실제로 많이 하는 실수를 막아주기 때문입니다. 어디에 쌓였는지 확인하기도 전에 코드부터 훑기 시작하면 시간만 오래 걸리는 경우가 많습니다.
timeout도 함께 보인다면 Golang context deadline exceeded와 같이 비교해 보세요. timeout 중심 장애는 cancellation 누락이나 의존성 block과 겹치는 경우가 많습니다.
일시적 spike와 baseline 상승을 구분하는 법
정상 서비스도 아래 상황에서는 goroutine이 늘 수 있습니다.
- fan-out 요청 처리
- batch 작업
- connection churn
- 짧은 retry storm
이 자체가 곧 leak은 아닙니다.
leak 가능성이 더 큰 신호는 아래와 같습니다.
- burst가 끝난 뒤에도 count가 내려오지 않음
- 트래픽 파동이 한 번 지나갈 때마다 baseline이 조금씩 높아짐
- shutdown이 끝나지 않아 drain이 느려짐
- 여러 dump에서 같은 stack trace가 반복됨
한 시점만 보면 burst와 leak을 헷갈릴 수 있습니다. 부하 전, 부하 중, 부하 후 몇 분 뒤를 같이 비교하면 패턴이 훨씬 또렷해집니다.
FAQ
Q. goroutine spike가 보이면 다 leak인가요?
아닙니다. 부하 구간에서 잠깐 늘었다가 다시 회복되면 정상일 수 있습니다. 핵심은 baseline이 원래 수준으로 돌아오는지입니다.
Q. 보통 어디에 많이 숨어 있나요?
대부분 blocked channel, context cancellation 누락, 장수 루프, worker 종료 경로에 숨어 있습니다.
Q. 장애 때 가장 먼저 무엇을 수집해야 하나요?
goroutine count와 goroutine dump 하나부터 시작하세요. 그 두 가지 정보만으로도 다음에 어떤 코드를 봐야 할지 꽤 빨리 결정할 수 있습니다.
Read Next
- Go 전체 분기부터 다시 보고 싶다면 Golang 트러블슈팅 가이드로 돌아가세요.
- timeout이 더 눈에 띄는 증상이라면 다음으로 Golang context deadline exceeded를 보세요.
- 다른 포화형 동시성 문제와 비교하고 싶다면 Java thread pool queue가 계속 쌓일 때로 이어가세요.
Related Posts
- Golang 트러블슈팅 가이드
- Golang context deadline exceeded
- Java thread pool queue가 계속 쌓일 때
- RabbitMQ queue가 계속 쌓일 때
Sources:
심사 대기 중에는 광고 대신 관련 가이드를 먼저 보여줍니다.
먼저 읽어볼 가이드
검색 유입이 많은 핵심 글부터 이어서 보세요.
- 미들웨어 트러블슈팅 가이드: Redis vs RabbitMQ vs Kafka 개발자를 위한 미들웨어 트러블슈팅 허브 글입니다. Redis, RabbitMQ, Kafka 중 어떤 증상부터 먼저 봐야 하는지와 어떤 문제 패턴이 각 시스템에 가까운지 정리합니다.
- Kubernetes CrashLoopBackOff: 먼저 볼 것들 startup failure, probe, config, resource limit 관점에서 CrashLoopBackOff를 어떻게 나눠서 봐야 하는지 정리한 가이드입니다.
- Kafka consumer lag가 계속 늘 때: 트러블슈팅 가이드 Kafka consumer lag가 계속 늘어날 때 무엇부터 봐야 하는지 정리합니다. poll 주기, 처리 속도, rebalance, consumer 설정까지 실전 기준으로 다룹니다.
- Kafka Rebalancing Too Often 가이드 Kafka consumer group에서 rebalance가 너무 자주 일어날 때 membership flapping, poll timing, protocol, assignment churn을 어떤 순서로 봐야 하는지 설명하는 실전 가이드입니다.
- Docker container가 계속 재시작될 때: 먼저 확인할 것들 exit code, command failure, environment mistake, health check 관점에서 Docker restart loop를 푸는 실전 가이드입니다.
심사 대기 중에는 광고 대신 관련 가이드를 먼저 보여줍니다.