- Published on
Go 고루틴 누수 잡기 - channel close·context 실수
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
고루틴 누수는 흔히 “메모리가 조금 늘었다”로 시작하지만, 실제 장애는 그보다 먼저 옵니다. 대기 중인 고루틴이 소켓을 붙잡거나(커넥션 풀 고갈), 타이머/티커가 계속 깨어나 CPU를 쓰거나, 채널 send/recv에서 영원히 블록되면서 처리량이 떨어집니다. 특히 HTTP 핸들러나 워커 풀처럼 요청 수에 비례해 고루틴이 생성되는 구조에서는, 누수가 곧 CrashLoopBackOff 같은 운영 장애로 이어지기 쉽습니다(원인별 진단 흐름은 Kubernetes CrashLoopBackOff 원인별 로그·해결 9가지도 함께 참고할 만합니다).
이 글은 Go에서 누수의 80%를 만드는 두 축인 channel close 규칙 위반과 context 취소 전파 실수를, “어떻게 재현되는지”와 “어떻게 고치는지” 중심으로 정리합니다.
고루틴 누수의 전형적 증상과 빠른 확인법
증상
- 시간이 지날수록
runtime.NumGoroutine()값이 계속 증가 - pprof에서
goroutine프로파일에chan send,chan receive,select대기 스택이 누적 - 요청이 끝났는데도 핸들러 내부 고루틴이 살아 있음(로그가 계속 찍힘)
- 커넥션 풀/FD 고갈: DB, Redis, HTTP client에서 타임아웃이 늘어남
가장 빠른 확인 코드
운영 코드에 상시 넣기보다는, 재현/스테이징에서 누수 의심 시점에 임시로 넣어 추세를 확인하는 용도로 좋습니다.
package main
import (
"log"
"runtime"
"time"
)
func main() {
go func() {
for range time.Tick(2 * time.Second) {
log.Printf("goroutines=%d", runtime.NumGoroutine())
}
}()
select {}
}
time.Tick 자체도 내부적으로 티커를 계속 유지하므로, 라이브러리/장기 실행 루프에서는 time.NewTicker와 Stop()를 쓰는 습관이 더 안전합니다(뒤에서 다룹니다).
1) channel close 실수로 생기는 누수
채널 관련 누수는 대부분 “누가 언제 닫는가” 규칙이 불명확해서 생깁니다. 핵심 원칙은 단순합니다.
- 닫는 주체는 send를 끝내는 생산자(producer)
- receiver는 채널을 닫지 않는다
- 여러 producer가 하나의 채널로 send한다면, producer가 직접 close하면 거의 항상 위험
실수 A: receiver가 채널을 닫아 panic 또는 교착으로 이어짐
receiver가 “이제 그만 받고 싶다”는 이유로 채널을 닫으면, 다른 producer가 send하는 순간 panic: send on closed channel이 납니다. 운영에서는 panic을 recover하더라도, 그 과정에서 다른 고루틴이 빠져나오지 못해 누수/정지로 이어질 수 있습니다.
// 잘못된 예: receiver가 close를 호출
func consumer(ch chan int) {
for v := range ch {
_ = v
// "이제 충분" 같은 조건이 생기면...
close(ch) // 위험: 다른 producer가 send 중이면 panic
return
}
}
해결 패턴: receiver는 close 대신 “중단 신호”를 별도 채널이나 context로 전달합니다.
func consumer(ctx context.Context, ch <-chan int) {
for {
select {
case <-ctx.Done():
return
case v, ok := <-ch:
if !ok {
return
}
_ = v
}
}
}
여기서 중요한 건 채널 방향입니다. ch <-chan int로 선언하면 consumer가 실수로 send/close를 시도하는 것을 컴파일 타임에 막을 수 있습니다.
실수 B: producer가 끝났는데 close를 안 해서 receiver가 영원히 대기
for v := range ch는 채널이 닫힐 때까지 끝나지 않습니다. producer가 더 이상 값을 보내지 않는데도 close를 하지 않으면 receiver는 영원히 대기합니다.
func producer(ch chan<- int) {
for i := 0; i < 3; i++ {
ch <- i
}
// close(ch) 를 빼먹으면 consumer는 range에서 영원히 대기
}
func consumer(ch <-chan int) {
for v := range ch {
_ = v
}
}
해결: producer가 “send 종료”를 보장할 수 있는 지점에서 defer close(ch)를 사용합니다.
func producer(ch chan<- int) {
defer close(ch)
for i := 0; i < 3; i++ {
ch <- i
}
}
실수 C: fan-in 구조에서 여러 producer가 close 경쟁
여러 producer가 하나의 out 채널로 보낼 때, 각 producer가 out을 닫으면 경쟁 조건으로 panic이 나거나, 누군가 close를 포기하면서 receiver가 range에서 빠져나오지 못합니다.
정석 해결: sync.WaitGroup으로 모든 producer 종료를 기다린 뒤, 단 하나의 고루틴이 close(out)을 수행합니다.
func fanIn(ctx context.Context, inputs ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
wg.Add(len(inputs))
forward := func(ch <-chan int) {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case v, ok := <-ch:
if !ok {
return
}
select {
case <-ctx.Done():
return
case out <- v:
}
}
}
}
for _, ch := range inputs {
go forward(ch)
}
go func() {
wg.Wait()
close(out)
}()
return out
}
여기서도 누수 방지 포인트는 두 가지입니다.
- 입력 채널이 멈췄을 때
ok체크로 빠져나오기 out <- v가 막힐 수 있으니, send에도ctx.Done()을 걸어 탈출 경로를 마련하기
2) context 실수로 생기는 누수
context는 “취소 신호의 전파” 도구입니다. 그런데 실수 패턴은 대부분 다음 중 하나입니다.
context.Background()를 무심코 사용해 요청 취소가 전파되지 않음WithCancel/WithTimeout을 만들고cancel()을 호출하지 않음select에서ctx.Done()을 빼먹어 블로킹 호출에서 영원히 대기
실수 D: 요청 스코프에서 Background를 써서 고루틴이 요청 밖으로 새어 나감
HTTP 핸들러에서 흔한 실수입니다.
func handler(w http.ResponseWriter, r *http.Request) {
go func() {
// 잘못된 예: 요청이 끊겨도 이 고루틴은 계속 돈다
ctx := context.Background()
_ = ctx
doWorkForever()
}()
w.WriteHeader(http.StatusAccepted)
}
해결: 반드시 r.Context()를 기반으로 파생시키고, 내부 루프에서 ctx.Done()을 관찰합니다.
func handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
go func() {
for {
select {
case <-ctx.Done():
return
default:
doOneStep()
}
}
}()
w.WriteHeader(http.StatusAccepted)
}
default를 쓰면 바쁜 루프가 될 수 있으니, 실제로는 time.Ticker나 작업 채널 기반으로 블로킹하면서 select로 취소를 함께 받는 형태가 더 좋습니다.
실수 E: WithTimeout을 만들고 cancel을 호출하지 않아 타이머 리소스가 누적
context.WithTimeout은 내부적으로 타이머를 잡습니다. 타임아웃 전에 작업이 끝났다면 cancel()로 타이머를 해제해야 합니다.
func fetch(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel() // 매우 중요
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.com", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
defer cancel()은 “취소를 발생시키기 위해서”가 아니라 “리소스를 회수하기 위해서”라는 점이 핵심입니다.
실수 F: send/recv 한쪽에만 ctx를 걸어 반쪽짜리 취소가 됨
아래 코드는 in에서 받는 쪽은 취소를 보지만, out으로 보내는 send가 막히면 영원히 대기할 수 있습니다.
func transform(ctx context.Context, in <-chan int, out chan<- int) {
for {
select {
case <-ctx.Done():
return
case v, ok := <-in:
if !ok {
return
}
out <- (v * 2) // 여기서 블록되면 ctx 취소돼도 못 나감
}
}
}
해결: send에도 select를 한 번 더 둡니다.
func transform(ctx context.Context, in <-chan int, out chan<- int) {
for {
select {
case <-ctx.Done():
return
case v, ok := <-in:
if !ok {
return
}
select {
case <-ctx.Done():
return
case out <- (v * 2):
}
}
}
}
이 패턴은 파이프라인/워커에서 누수를 막는 가장 실전적인 형태입니다.
3) 채널/컨텍스트 누수를 줄이는 설계 체크리스트
채널 설계
- 채널은 가능하면 단방향 타입으로 노출:
chan<- T,<-chan T range ch를 쓰면 close 책임자가 누구인지 반드시 문서화- fan-in/out에서 close는 딱 한 곳에서만
- 버퍼 채널은 “성능 최적화”가 아니라 “역압 완화” 도구로 쓰고, 버퍼가 있다고 누수가 사라진다고 믿지 않기
context 설계
- 요청/작업의 루트는
context.Background()가 아니라, 가능한 한 상위 ctx를 인자로 받는 함수 시그니처로 시작 WithCancel,WithTimeout,WithDeadline을 만들면 즉시defer cancel()- 블로킹 가능 지점(채널 send/recv, 네트워크 I/O, 락 대기)에
ctx.Done()탈출구를 마련
4) pprof로 “어디서 새는지” 찾는 실전 루틴
누수는 코드 리뷰만으로 잡기 어렵고, 결국 “대기 스택”을 봐야 빨리 끝납니다. Go에서는 pprof가 가장 확실합니다.
net/http/pprof 활성화
import (
_ "net/http/pprof"
"net/http"
)
func main() {
go func() {
_ = http.ListenAndServe("127.0.0.1:6060", nil)
}()
// ... your server
}
이후 로컬/스테이징에서 다음을 확인합니다.
- goroutine 덤프:
http://127.0.0.1:6060/debug/pprof/goroutine?debug=2 - 누적 비교: 일정 시간 간격으로 덤프를 떠서 같은 스택이 계속 쌓이는지 확인
pprof에서 chan receive나 select로 대기 중인 스택이 특정 함수에 몰리면, 그 함수가 close/취소 전파를 제대로 하지 않는 지점일 확률이 큽니다. 비동기 런타임이 “멈춘 것처럼 보일 때 블로킹을 추적한다”는 관점은 Rust Tokio에서도 유사합니다. 진단 사고방식은 Rust Tokio runtime 멈춤? 블로킹 I/O 진단법과도 맞닿아 있습니다.
5) 자주 놓치는 주변 누수: ticker, timer, errgroup
time.Tick 대신 time.NewTicker + Stop
time.Tick은 채널만 반환하고 stop할 방법이 없습니다. 짧게 쓰고 버리는 코드가 아니라면 다음처럼 씁니다.
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
doPeriodic()
}
}
errgroup.WithContext로 취소 전파를 표준화
여러 고루틴을 묶어 “하나 실패하면 전체 취소”를 만들 때 직접 WaitGroup과 cancel을 엮다 보면 실수가 나기 쉽습니다.
func run(ctx context.Context) error {
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error {
return workerA(ctx)
})
g.Go(func() error {
return workerB(ctx)
})
return g.Wait()
}
errgroup을 쓰면 취소 전파가 강제되고, 각 worker는 ctx.Done()만 잘 보면 됩니다.
결론: 누수는 “close와 cancel의 소유권” 문제다
Go 고루틴 누수의 본질은 대개 복잡한 메모리 문제가 아니라, 종료 신호의 소유권과 전파 경로가 불명확한 설계입니다.
- 채널은 “누가 닫는가”를 코드로 강제(단방향 채널, 단일 closer)
- 컨텍스트는 “누가 취소하는가”와 “어디까지 전파되는가”를 코드로 강제(
defer cancel(), send/recv 양쪽 select) - 누수 의심 시에는 pprof로 대기 스택을 보고, 가장 많이 쌓이는
chan send/receive지점을 먼저 고친다
이 원칙만 지켜도, 운영에서 고루틴 수가 서서히 증가하다가 결국 리소스 고갈로 터지는 유형의 장애는 대부분 예방할 수 있습니다.