- Published on
Go 고루틴 누수 진단 - context 취소·채널 close
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 느려지거나 메모리가 조금씩 증가하는데 GC 로그는 정상, CPU도 애매하게 올라가는 상황이라면 고루틴 누수를 의심해야 합니다. 고루틴 누수는 흔히 “고루틴이 끝나지 못하고 영원히 기다리는 상태”로 발생하며, 대개 두 축에서 시작합니다.
context취소가 끝까지 전파되지 않음- 채널을 닫지 않거나, 닫는 주체가 불명확하여 수신/송신이 영원히 블록됨
이 글은 pprof 나 trace 같은 도구 진단 전에, 코드 구조만으로 누수를 줄이는 핵심 패턴인 context 취소와 채널 close 규칙을 중심으로 정리합니다. 고루틴 스택을 실제로 추적하는 방법은 별도 글인 Go 고루틴 leak 추적 - pprof·trace로 10분 진단도 함께 보시면 연결이 잘 됩니다.
고루틴 누수의 전형적 증상과 “대기 지점”
고루틴은 보통 아래 지점에서 멈춥니다.
- 채널 수신:
v := <-ch - 채널 송신:
ch <- v select에서 어떤 케이스도 준비되지 않음sync.Cond,WaitGroup,Mutex등 동기화 프리미티브 대기- 네트워크/IO 블록 (특히 타임아웃 없는 read)
이 중 실무에서 가장 흔한 조합은 “채널 수신 대기 + 취소 미전파”입니다. 즉, 요청은 끝났는데 워커 고루틴은 for { select { case x := <-ch: ... } } 에서 계속 살아있는 형태입니다.
핵심 원칙 1: 모든 고루틴은 종료 조건을 가져야 한다
고루틴을 만들 때마다 스스로에게 질문해야 합니다.
- 이 고루틴은 언제 끝나는가
- 끝나게 하는 신호는 누가 보내는가
- 그 신호가 전달되지 않으면 어디에서 영원히 대기하는가
종료 조건은 보통 두 가지 중 하나로 구현됩니다.
context.Done()을 통한 취소- 채널
close를 통한 종료
중요한 점은 “둘 중 하나만” 고집하기보다, 데이터 스트림에는 채널 close, 요청 생명주기에는 context 취소를 섞어 쓰는 것이 자연스럽다는 것입니다.
핵심 원칙 2: context 는 “요청 생명주기”를 끝까지 전파한다
흔한 실수: context.Background() 로 끊어버리기
아래 코드는 누수의 씨앗입니다. 상위 요청이 취소되어도 내부 작업은 계속 진행될 수 있습니다.
func handler(w http.ResponseWriter, r *http.Request) {
// 나쁜 예: 요청 컨텍스트를 버리고 Background를 사용
go doWork(context.Background())
w.WriteHeader(202)
}
func doWork(ctx context.Context) {
// ctx가 절대 취소되지 않으면 내부 대기/재시도/IO가 영원히 지속될 수 있음
for {
select {
case <-ctx.Done():
return
default:
// ...
}
}
}
개선: 상위 ctx 를 전달하고, 필요하면 WithCancel 로 하위 생명주기 관리
func handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithCancel(r.Context())
defer cancel() // handler 종료 시점에 하위 작업도 정리
go doWork(ctx)
w.WriteHeader(202)
}
다만 handler 가 바로 끝나는데 백그라운드 작업은 계속해야 하는 요구(예: 비동기 작업 큐)가 있다면, 그 작업은 요청 컨텍스트와 분리하되 “영원히” 돌지 않도록 별도 타임아웃/종료 정책이 있어야 합니다.
- 작업 큐에 넣고 워커가 처리한다
- 워커는 프로세스 종료 시점에 정리된다
- 각 작업은
context.WithTimeout같은 상한을 가진다
WithTimeout 은 누수 방지의 안전벨트
네트워크 호출, 외부 API, DB 쿼리 등은 타임아웃이 없으면 고루틴이 IO에서 묶일 수 있습니다.
func fetchSomething(parent context.Context) error {
ctx, cancel := context.WithTimeout(parent, 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
}
이 패턴은 “정상 종료”뿐 아니라 “비정상 상황에서도 결국 끝난다”는 보장을 줍니다.
핵심 원칙 3: 채널 close 는 “송신자”가 책임진다
채널 종료 규칙은 단순하지만 어기기 쉽습니다.
- 채널은 송신자(sender) 가 닫는다
- 수신자(receiver) 가 닫지 않는다
- 여러 송신자가 있는 채널은 “누가 닫는가”를 별도로 설계해야 한다
흔한 실수: 수신자가 close 하거나, 닫힌 채널에 송신
닫힌 채널에 송신하면 패닉이 발생합니다. 그래서 “아무나 닫지 말자”로 흐르면, 이번엔 수신자가 영원히 대기하며 누수가 납니다.
종료 신호로서의 채널 close: for range ch
채널 기반 파이프라인에서는 for range 가 가장 깔끔한 종료 형태입니다.
func producer(ctx context.Context, out chan<- int) {
defer close(out) // 송신자가 닫는다
for i := 0; i < 10; i++ {
select {
case <-ctx.Done():
return
case out <- i:
}
}
}
func consumer(ctx context.Context, in <-chan int) {
for {
select {
case <-ctx.Done():
return
case v, ok := <-in:
if !ok {
return
}
_ = v
}
}
}
여기서 포인트는 2가지입니다.
producer는 반드시defer close(out)로 종료를 보장consumer는ok체크 또는for range로 종료를 감지
context 취소 vs 채널 close: 무엇을 언제 쓰나
둘을 역할로 나누면 설계가 쉬워집니다.
context는 “중단/타임아웃/요청 스코프”에 적합- 채널
close는 “데이터 스트림이 끝남”을 알리는 데 적합
문제는 둘을 섞지 않고 한 쪽만으로 모든 것을 해결하려 할 때 생깁니다.
context만으로 스트림 종료를 표현하면, 수신 루프가Done을 놓치거나 데이터 처리가 꼬일 수 있음- 채널
close만으로 요청 취소를 표현하면, 타임아웃/데드라인/원인 전달이 어려움
따라서 실무적으로는 아래 조합이 안정적입니다.
- 파이프라인의 데이터 채널은 생산자가
close - 모든 단계는
select로ctx.Done()을 함께 감시
채널 대기에서 빠져나오지 못해 데드락처럼 보이는 케이스는 Go 고루틴 채널 데드락 5분 재현·해결도 같이 보면 원인 분류가 더 빨라집니다.
누수를 만드는 대표 패턴 5가지와 처방
1) select 에서 취소 케이스가 없음
func worker(ch <-chan int) {
for v := range ch { // ch가 닫히지 않으면 영원히 대기
_ = v
}
}
처방: ctx.Done() 을 함께 보거나, 반드시 닫히는 채널만 range 하도록 계약을 명확히 합니다.
func worker(ctx context.Context, ch <-chan int) {
for {
select {
case <-ctx.Done():
return
case v, ok := <-ch:
if !ok {
return
}
_ = v
}
}
}
2) 무한 재시도 루프에 취소/상한이 없음
for {
err := call()
if err == nil {
break
}
time.Sleep(100 * time.Millisecond)
}
처방: ctx 와 최대 시도 횟수 또는 데드라인을 둡니다.
for attempt := 0; attempt < 5; attempt++ {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if err := call(); err == nil {
return nil
}
time.Sleep(100 * time.Millisecond)
}
return errors.New("retry exhausted")
3) fan-out 후 fan-in 에서 일부 결과를 버려 고루틴이 막힘
아래는 결과를 다 읽지 않으면 송신자가 블록되어 누수가 되는 전형적인 형태입니다.
results := make(chan int)
for i := 0; i < 10; i++ {
go func() {
results <- 1 // 수신자가 다 읽지 않으면 여기서 블록
}()
}
// 일부만 읽고 return 하면 나머지 고루틴이 송신에서 멈춤
<-results
return
처방은 3가지 중 하나입니다.
results를 충분히 버퍼링- 컨텍스트 취소로 송신을 포기하게 만들기
WaitGroup으로 생산자 종료를 기다리고 수신 루프를 끝까지 소비
아래는 ctx + WaitGroup + close(results) 조합 예시입니다.
func fanIn(ctx context.Context) (int, error) {
results := make(chan int)
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
select {
case <-ctx.Done():
return
case results <- 1:
}
}()
}
go func() {
wg.Wait()
close(results)
}()
sum := 0
for {
select {
case <-ctx.Done():
return 0, ctx.Err()
case v, ok := <-results:
if !ok {
return sum, nil
}
sum += v
}
}
}
4) time.Tick 사용으로 타이머 리소스가 누적
time.Tick 은 멈출 수 없는 ticker를 만들기 때문에, 고루틴이 종료돼도 내부 타이머가 남아 누수처럼 작동할 수 있습니다.
처방: time.NewTicker 를 쓰고 Stop() 합니다.
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
// ...
}
}
5) 채널을 닫지 않는 생산자
생산자가 에러로 조기 리턴하면서 close(out) 를 빼먹는 케이스가 많습니다.
처방: 생산자 함수 시작 직후 defer close(out) 를 습관화하고, “정상 종료/에러/취소” 모든 경로에서 닫히게 만듭니다.
실전 진단 체크리스트: 코드 리뷰에서 바로 잡는 포인트
서비스에서 누수가 의심될 때, 도구를 켜기 전에 코드만으로도 빠르게 확률을 줄일 수 있습니다.
go func를 검색하고, 모든 고루틴이ctx.Done()또는 채널 종료를 감시하는지 확인context.Background()또는context.TODO()가 요청 경로에 섞여 컨텍스트 전파를 끊는지 확인for range ch를 쓰는 곳에서 채널이 “반드시 닫히는 계약”인지 확인- 여러 송신자가 있는 채널이라면
close책임자가 별도로 존재하는지 확인 time.Tick사용 여부 확인 후NewTicker로 교체- 결과 채널에 송신하는 고루틴이 있는데 수신 측이 조기 리턴하는지 확인
이 체크리스트로도 해결이 안 되면, 고루틴 스택에서 “어디에서 대기 중인지”를 확인해야 합니다. 이때는 Go 고루틴 leak 추적 - pprof·trace로 10분 진단처럼 pprof 의 goroutine dump, block profile, trace 로 병목 지점을 찾는 단계로 넘어가면 됩니다.
결론: 누수는 버그가 아니라 “종료 설계 부재”에서 온다
Go의 고루틴 누수는 대개 특정 API의 함정이라기보다, 종료 신호가 명확히 설계되지 않은 상태에서 동시성을 얹을 때 발생합니다.
- 요청 단위 작업은
context로 생명주기를 끝까지 전파하고, IO에는WithTimeout을 기본값으로 둡니다. - 스트림 처리 파이프라인은 “송신자가 닫는다” 규칙을 지키고, 수신자는
ok또는range로 종료를 감지합니다. - fan-out/fan-in 구조에서는 “결과를 끝까지 소비하거나, 취소로 송신을 포기하게 하거나, 버퍼/정리 고루틴으로 배수” 중 하나를 반드시 선택합니다.
이 원칙만 지켜도, 운영에서 마주치는 고루틴 누수의 상당수를 예방하고 진단 시간을 크게 줄일 수 있습니다.