- Published on
Go 고루틴 누수 6패턴 - 채널 close·context 취소
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
고루틴은 가볍지만 공짜가 아닙니다. 요청당 고루틴을 몇 개씩 띄우는 서버에서, close 누락이나 context 미전파 같은 작은 실수가 누적으로 이어지면 CPU가 유휴인데도 메모리와 고루틴 수가 계속 증가합니다. 이런 누수는 GC가 해결해주지 못합니다. “끝나지 않는 고루틴”은 참조가 남아있든 아니든 스스로 종료하지 않으면 계속 살아있기 때문입니다.
이 글은 실무에서 특히 많이 보는 고루틴 누수 6패턴을 정리하고, 각 패턴별로 close 규칙과 context 취소를 어떻게 적용해야 하는지 코드로 보여줍니다. (동시성 누수는 DB 커넥션 고갈 같은 리소스 고갈로도 이어질 수 있는데, 유사한 관점은 Spring Boot HikariCP 커넥션 고갈 원인과 해결에서도 참고할 만합니다.)
고루틴 누수의 전형적 증상과 빠른 확인
증상
runtime.NumGoroutine()값이 시간에 따라 단조 증가- pprof에서
goroutine프로파일에chan receive,chan send,select대기 스택이 다수 - 요청이 끝났는데도 백그라운드 작업이 계속 남아있음
최소한의 계측
package main
import (
"log"
"runtime"
"time"
)
func main() {
go func() {
for range time.Tick(2 * time.Second) {
log.Printf("goroutines=%d", runtime.NumGoroutine())
}
}()
select {}
}
이 값이 “부하가 끝났는데도” 계속 증가한다면, 아래 패턴 중 하나일 확률이 높습니다.
패턴 1) 송신자가 있는데 수신자가 사라져서 send 블로킹
문제
요청 처리 중 고루틴이 결과를 채널에 보내려는데, 상위 로직이 타임아웃으로 먼저 리턴해버리면 수신자가 없어집니다. 버퍼가 없거나 버퍼가 꽉 찼다면 송신 고루틴은 ch <- v에서 영원히 멈춥니다.
func handler() error {
ch := make(chan string) // unbuffered
go func() {
// 외부 호출이 끝났다고 가정
ch <- "ok" // 수신자가 없으면 여기서 영원히 블로킹
}()
// 어떤 이유로든 조기 리턴
return nil
}
해결 1: context로 송신을 취소 가능하게 만들기
func handler(ctx context.Context) error {
ch := make(chan string)
go func() {
select {
case ch <- "ok":
// delivered
case <-ctx.Done():
// receiver gone
return
}
}()
select {
case <-ch:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
해결 2: 상황에 따라 버퍼 채널로 “단발 결과”를 흡수
단발 결과는 make(chan T, 1)이 누수를 줄여줍니다. 다만 버퍼가 “근본 해결”은 아니고, 생산량이 1을 초과하거나, 소비가 아예 없는 구조라면 결국 막힐 수 있습니다.
패턴 2) 수신자가 있는데 송신자가 사라져서 receive 블로킹
문제
작업 고루틴이 for v := range ch로 읽는데, 송신자가 더 이상 보내지 않으면서 채널도 닫지 않으면 수신 고루틴은 영원히 대기합니다.
func worker(ch <-chan int) {
for v := range ch { // ch가 close되지 않으면 종료 불가
_ = v
}
}
func main() {
ch := make(chan int)
go worker(ch)
// 실수: 더 이상 send도 안 하고 close도 안 함
select {}
}
해결: “닫는 주체”를 명확히 하고 송신 완료 시 close
채널 close는 “더 이상 값이 오지 않는다”는 신호입니다. 관례적으로 송신자가 닫습니다. 송신자가 여러 명이면 WaitGroup으로 송신 완료를 모아 한 곳에서 닫습니다.
func fanIn(sources ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
forward := func(ch <-chan int) {
defer wg.Done()
for v := range ch {
out <- v
}
}
wg.Add(len(sources))
for _, ch := range sources {
go forward(ch)
}
go func() {
wg.Wait()
close(out) // 모든 송신이 끝났을 때 단 한 번
}()
return out
}
패턴 3) context를 만들고 cancel()을 호출하지 않음
문제
context.WithCancel, context.WithTimeout는 내부 타이머/자식 컨텍스트 관리가 걸립니다. cancel을 호출하지 않으면 자식 컨텍스트가 불필요하게 오래 살아남고, 그 컨텍스트를 기다리는 고루틴도 정리되지 않을 수 있습니다.
func do(ctx context.Context) error {
ctx2, _ := context.WithTimeout(ctx, 3*time.Second)
// 실수: cancel을 호출하지 않음
return call(ctx2)
}
해결: defer cancel()은 기본값
func do(ctx context.Context) error {
ctx2, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
return call(ctx2)
}
추가로, 하위 고루틴이 있다면 반드시 동일한 ctx2를 전달해야 합니다. context.Background()로 새로 시작하면 상위 취소와 단절되어 누수의 씨앗이 됩니다.
패턴 4) select에 종료 조건이 없고 영원히 대기
문제
이벤트 루프 형태의 고루틴이 select { case ... }로만 구성되어 있고, 종료 신호(채널 close 또는 ctx.Done())가 없으면 종료할 방법이 없습니다.
func loop(events <-chan string) {
for {
select {
case e := <-events:
_ = e
}
}
}
해결: ctx.Done() 또는 done 채널을 반드시 포함
func loop(ctx context.Context, events <-chan string) {
for {
select {
case e, ok := <-events:
if !ok {
return
}
_ = e
case <-ctx.Done():
return
}
}
}
이 패턴은 프론트엔드의 중복 fetch나 캐시 꼬임처럼 “끝나는 조건이 없는 비동기 작업”이 누적되는 현상과도 닮아 있습니다. 비슷한 사고방식의 디버깅 글로 Next.js 14 RSC 캐시 꼬임·중복 fetch 7가지도 함께 보면 좋습니다.
패턴 5) time.Tick 사용으로 티커가 해제되지 않음
문제
time.Tick(d)는 내부적으로 티커를 만들고 채널을 반환하지만, 명시적으로 Stop()할 수 없습니다. 짧은 생명주기 함수에서 time.Tick를 쓰면, 함수는 끝났는데 티커는 계속 살아남아 누수처럼 보입니다.
func poll() {
for range time.Tick(1 * time.Second) {
// ...
}
}
해결: time.NewTicker와 defer ticker.Stop()
func poll(ctx context.Context) {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
// ...
case <-ctx.Done():
return
}
}
}
패턴 6) errgroup나 WaitGroup을 쓰지만 “중단 전파”가 없음
문제
여러 고루틴을 띄워 병렬 처리할 때, 하나가 실패하면 나머지도 중단되어야 하는데 계속 대기/재시도/블로킹 상태로 남는 경우가 많습니다. 특히 “한 고루틴이 결과를 기다리는데 다른 고루틴이 이미 실패해서 더 이상 결과를 만들지 않는” 구조가 위험합니다.
해결: errgroup.WithContext로 실패 시 취소 전파
import "golang.org/x/sync/errgroup"
func runAll(ctx context.Context) error {
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error {
return jobA(ctx)
})
g.Go(func() error {
return jobB(ctx)
})
g.Go(func() error {
return jobC(ctx)
})
// 어느 하나라도 에러가 나면 ctx가 취소되고, 나머지는 ctx를 보고 빠져나와야 함
return g.Wait()
}
func jobA(ctx context.Context) error {
select {
case <-time.After(2 * time.Second):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
핵심은 “취소를 전파하는 컨텍스트를 만들었다”에서 끝나면 안 되고, 각 작업이 블로킹 지점에서 ctx.Done()을 실제로 감시해야 한다는 점입니다. 네트워크 호출, 채널 send/receive, 락 대기, 티커 대기 같은 모든 블로킹 포인트에 취소 경로를 만들어야 합니다.
채널 close 규칙: 누수를 막는 실전 원칙
- 채널은 “소유자(owner)”가 닫습니다. 보통 생산자(송신자) 쪽이 소유합니다.
- 다중 생산자면
WaitGroup으로 생산 종료를 모은 뒤 단 한 곳에서 닫습니다. - 소비자가 임의로
close하면 경쟁 상태에서panic이 나기 쉽습니다. close(ch)는 “종료 신호”이지 “리소스 해제”가 아닙니다. 종료 신호가 있어야 고루틴이 루프를 빠져나갈 수 있습니다.
누수 디버깅 체크리스트
- 고루틴 생성 지점마다 “종료 조건”이 있는가
ctx.Done()또는done채널 또는 입력 채널close
- 채널 send/receive가 블로킹될 수 있는가
- 버퍼 크기, 소비자 생명주기, 조기 리턴
context.WithCancel/Timeout을 만들면defer cancel()을 했는가time.Tick를 쓰고 있지 않은가- 병렬 작업은 실패 시 중단이 전파되는가
errgroup.WithContext사용, 혹은 직접 cancel 호출
마무리: “끝나는 경로”를 코드에 강제하라
고루틴 누수는 대개 복잡한 버그가 아니라, 종료 시나리오가 설계에서 빠진 결과입니다. 채널에는 close를, 작업에는 context 취소를, 반복에는 ticker.Stop()을 붙여서 “끝나는 경로”를 코드로 강제하세요.
동시성에서 누수와 교착은 종종 함께 나타납니다. 다른 런타임이지만 비슷한 함정과 해결 사고방식은 Rust Tokio join! 교착? spawn·Mutex 오용 해결도 참고하면 비교 관점에서 도움이 됩니다.