- Published on
Go 고루틴 누수 진단 - context·채널 close 7패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 “느려졌다”는 제보를 받고 CPU나 메모리를 먼저 보지만, Go에서는 고루틴 누수가 더 교묘하게 성능을 갉아먹습니다. 고루틴은 가볍지만 공짜는 아니고, 누수는 보통 select 블로킹, 채널 미종료, 컨텍스트 미전파 같은 “종료 신호의 부재”로 발생합니다.
이 글은 고루틴 누수를 진단하는 절차와, 특히 context 와 채널 close 를 둘러싼 실전 7패턴을 코드로 정리합니다. 운영 환경에서 재시작 루프를 타는 경우라면 증상 관찰 관점에서 systemd 서비스가 반복 재시작될 때 원인 추적법도 함께 참고하면 좋습니다.
고루틴 누수의 “정의”를 먼저 맞추기
고루틴 누수는 크게 두 종류가 있습니다.
- 영구 블로킹: 더 이상 진행할 수 없는 상태로
chan recv나chan send에 걸려 영원히 살아있음 - 의도치 않은 장기 생존: 요청 단위로 끝나야 할 작업이 타임아웃/취소를 못 받아 오래 살아있음
전자는 대개 채널 프로토콜 문제, 후자는 대개 context 전파/취소 문제입니다.
0단계: 누수 진단 툴체인 (pprof, goroutine dump)
pprof로 고루틴 수와 스택을 본다
HTTP 서버가 있다면 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 {}
}
- 고루틴 덤프:
curl http://127.0.0.1:6060/debug/pprof/goroutine?debug=2 - pprof:
go tool pprof http://127.0.0.1:6060/debug/pprof/goroutine
스택에서 chan receive , select , sync.Cond.Wait 같은 패턴이 반복되면 누수 후보입니다.
운영에서 “재시작만 반복”될 때
고루틴 누수는 단독으로 크래시를 만들기보다, 메모리/FD 고갈로 이어져 재시작 루프를 유발하기도 합니다. K8s라면 CrashLoopBackOff 분석이 필요할 수 있는데, 이때는 Kubernetes CrashLoopBackOff 원인별 로그·Probe·리소스 디버깅처럼 로그와 리소스 한도를 함께 봐야 원인이 좁혀집니다.
패턴 1: context.Background() 를 요청 경로에 박아버림
증상
- 요청이 끊겨도 DB/외부 API 호출이 계속 진행
- 고루틴이 “언젠간 끝나겠지” 하며 쌓임
나쁜 예
func handler(w http.ResponseWriter, r *http.Request) {
ctx := context.Background() // 요청 취소 신호가 사라짐
go doWork(ctx)
w.WriteHeader(http.StatusAccepted)
}
고치는 법
- 요청 스코프는
r.Context()를 시작점으로 삼고 - 내부 호출로
ctx를 끝까지 전달
func handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
go doWork(ctx)
w.WriteHeader(http.StatusAccepted)
}
핵심은 “고루틴이 종료될 수 있는 신호선”을 반드시 갖게 하는 것입니다.
패턴 2: time.After 를 루프에서 남발해 타이머 누수
time.After 는 내부적으로 타이머를 만들고, 채널에서 값을 읽지 못하면 타이머 객체가 GC까지 살아있을 수 있습니다. 특히 select 루프에서 매번 만들면 누수처럼 보이는 메모리 증가가 생깁니다.
나쁜 예
for {
select {
case msg := <-in:
handle(msg)
case <-time.After(200 * time.Millisecond):
// tick
}
}
고치는 법: time.NewTimer 재사용
timer := time.NewTimer(200 * time.Millisecond)
defer timer.Stop()
for {
timer.Reset(200 * time.Millisecond)
select {
case msg := <-in:
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
handle(msg)
case <-timer.C:
// tick
}
}
타이머는 “만들고 버리는” 방식보다 “재사용”이 안전합니다.
패턴 3: select 에 취소 케이스가 없어 영구 블로킹
고루틴은 대부분 select 로 대기합니다. 그런데 취소 케이스가 없으면 입력이 오지 않는 순간 영원히 잠듭니다.
나쁜 예
func worker(ctx context.Context, in <-chan Job) {
for {
select {
case job := <-in:
process(job)
}
}
}
고치는 법
func worker(ctx context.Context, in <-chan Job) {
for {
select {
case <-ctx.Done():
return
case job, ok := <-in:
if !ok {
return
}
process(job)
}
}
}
여기서도 핵심은 “종료 신호를 항상 select에 포함”입니다.
패턴 4: 채널을 닫지 않아 수신자가 끝나지 않음
채널을 닫는 주체는 원칙적으로 송신자(생산자) 입니다. 생산자가 종료되었는데 채널을 닫지 않으면 소비자는 range ch 에서 영원히 대기합니다.
나쁜 예
func fanIn(inputs []<-chan int) <-chan int {
out := make(chan int)
for _, ch := range inputs {
go func(c <-chan int) {
for v := range c {
out <- v
}
}(ch)
}
return out // out을 닫지 않음
}
고치는 법: WaitGroup 으로 마지막에 close(out)
func fanIn(ctx context.Context, inputs []<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
wg.Add(len(inputs))
for _, ch := range inputs {
go func(c <-chan int) {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case v, ok := <-c:
if !ok {
return
}
select {
case <-ctx.Done():
return
case out <- v:
}
}
}
}(ch)
}
go func() {
wg.Wait()
close(out)
}()
return out
}
close(out)는 단 한 번, “모든 생산 고루틴이 끝났을 때”만 수행ctx.Done()으로 강제 종료도 가능하게 설계
패턴 5: 닫힌 채널로 보내서 패닉, 회복 로직이 누수로 번짐
send on closed channel 패닉을 recover 로 덮는 코드를 종종 보는데, 이 경우 “누가 닫는가”에 대한 프로토콜이 무너져 고루틴이 어정쩡하게 살아남거나 재시도 루프를 돌며 누수로 이어질 수 있습니다.
나쁜 예
func producer(out chan<- int, stop <-chan struct{}) {
defer func() { _ = recover() }() // 문제를 숨김
for i := 0; ; i++ {
select {
case <-stop:
return
case out <- i:
}
}
}
고치는 법: close 책임을 명확히
- 생산자가
close(out)를 소유 - 소비자는 절대 닫지 않음
- 종료는
context로 통일
func producer(ctx context.Context) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for i := 0; ; i++ {
select {
case <-ctx.Done():
return
case out <- i:
}
}
}()
return out
}
패턴 6: 버퍼 없는 채널로 “상호 대기” 데드락성 누수
고루틴 누수는 데드락처럼 프로그램 전체가 멈추지 않아도, 일부 고루틴만 서로 기다리며 영원히 살아있는 형태로 나타납니다.
전형적인 상황
- A는
out <- x를 기다림 - B는 A가 보내기 전에 다른 조건을 기다림
- 둘 다 취소 신호가 없으면 영구 대기
고치는 법: 전송에도 취소를 건다
select {
case <-ctx.Done():
return
case out <- x:
}
또는 설계적으로
- 버퍼 채널로 완충을 두거나
- 전송 전용 고루틴을 두고 큐잉하거나
- 작업 큐를
errgroup과 함께 묶어 생명주기를 통제
같은 방식으로 “막히더라도 빠져나갈 구멍”을 만듭니다.
패턴 7: errgroup 를 쓰면서도 컨텍스트 취소를 무시
errgroup.WithContext 는 첫 에러에서 컨텍스트를 취소해 나머지 고루틴을 정리하기 쉽게 해줍니다. 그런데 내부 루프가 ctx.Done() 을 확인하지 않으면 효과가 없습니다.
나쁜 예
func run(ctx context.Context) error {
g, _ := errgroup.WithContext(ctx)
g.Go(func() error {
for msg := range stream() { // ctx 취소와 무관
_ = msg
}
return nil
})
return g.Wait()
}
고치는 법: 스트림 소비에 취소를 섞는다
func run(ctx context.Context) error {
g, ctx := errgroup.WithContext(ctx)
msgs := stream() // 예: 외부에서 오는 채널
g.Go(func() error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case msg, ok := <-msgs:
if !ok {
return nil
}
_ = msg
}
}
})
return g.Wait()
}
errgroup 는 “취소를 전파하는 도구”일 뿐, 루프가 그 신호를 수신하도록 작성해야 누수가 사라집니다.
실전 점검 체크리스트
아래 질문에 예 가 많을수록 누수 가능성이 큽니다.
- 요청 처리 코드에서
context.Background()를 쓰고 있나 - 모든 고루틴 루프에
case <-ctx.Done()이 있나 - 채널을
range로 읽는 곳에서 채널이 닫힌다는 보장이 있나 - 채널
close책임자가 문서/코드로 명확한가 - 전송(
ch <- x)이 막힐 때 빠져나갈 수 있나 time.After를 루프에서 매번 만들고 있나- 에러가 발생했을 때 나머지 고루틴이 종료되는 구조인가
스택 덤프로 “어디서 새는지” 빠르게 찾는 요령
pprof 고루틴 덤프에서 자주 보이는 누수 스택 키워드:
chan receive가 특정 함수에서 반복select한 케이스만 있는 형태net.(*pollDesc).wait와 함께 컨텍스트 없는 네트워크 대기time.Sleep로 폴링하는 고루틴이 요청 수만큼 증가
이런 스택이 보이면, 위 7패턴 중 하나로 거의 귀결됩니다.
마무리: “종료 프로토콜”이 설계의 일부다
Go에서 고루틴 누수는 버그라기보다 종료 프로토콜 부재에서 시작합니다. context 는 “취소 신호선”, 채널 close 는 “데이터 스트림의 끝”입니다. 둘을 섞어 쓸 때는 다음 원칙만 지켜도 대부분의 누수를 예방할 수 있습니다.
- 고루틴을 만들 때는 항상 “어떻게 끝나는가”를 같이 만든다 (
ctx.Done()) - 채널은 생산자가 닫고, 소비자는 닫지 않는다
- 송신/수신 모두 블로킹 지점에 취소 케이스를 둔다
- 타이머는 루프에서 재사용하고, 필요 시 drain 한다
운영에서 증상이 재시작 루프나 리소스 고갈로 나타난다면, 서비스 매니저나 오케스트레이터 관점의 디버깅도 병행해야 합니다. 그런 경우 위에서 언급한 systemd 및 K8s 디버깅 글을 함께 보면 “누수의 결과”까지 한 번에 추적할 수 있습니다.