- Published on
Go 고루틴 누수 잡기 - context·select로 종료
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버를 오래 돌리다 보면 메모리는 천천히 늘고, CPU는 간헐적으로 치솟고, 종료 시에는 어딘가에서 영원히 대기하는 고루틴 때문에 graceful shutdown 이 깔끔히 끝나지 않는 경우가 있습니다. 이런 증상은 대부분 고루틴 누수(goroutine leak) 로 이어집니다.
고루틴 누수는 “고루틴을 만들었는데, 종료 조건이 없거나 도달하지 못해 계속 살아있는 상태”를 의미합니다. Go 런타임은 고루틴을 자동으로 회수하지 않습니다. 즉, 개발자가 종료 경로를 설계해야 합니다.
이 글에서는 context 취소 전파와 select 를 이용해 고루틴을 안전하게 종료하는 패턴을 중심으로, 누수가 생기는 대표 원인과 점검 체크리스트까지 다룹니다. 운영 관점에서 “조용히 쌓이다가 장애로 터지는” 문제라는 점에서, 로그/리소스 폭주를 빠르게 다루는 습관도 중요합니다. 관련해서는 journalctl 로그 폭주로 디스크 찰 때 10분 해결 같은 글도 함께 참고하면 좋습니다.
고루틴 누수의 전형적인 패턴
1) 채널 send/recv가 영원히 블로킹
고루틴이 ch <- x 를 했는데 받는 쪽이 사라졌거나, 반대로 <-ch 로 기다리는데 보내는 쪽이 더 이상 값을 보내지 않으면 고루틴은 끝나지 않습니다.
2) for {} 루프에 종료 조건이 없음
특히 “폴링 루프”나 “워커 루프”에서 return 경로가 없으면 누수로 이어집니다.
3) 타임아웃/취소 없는 I/O 대기
네트워크 호출이 영원히 대기하거나, 내부 큐가 막혀서 진행이 안 되는 경우입니다. context 가 연결되지 않은 라이브러리 호출이 원인이 되기도 합니다.
4) time.Tick 사용
time.Tick 은 내부적으로 고루틴/타이머를 생성하며, 명시적으로 Stop 할 수 없습니다. 반복 작업은 time.NewTicker 로 만들고 defer ticker.Stop() 을 해야 합니다.
핵심 원칙: “고루틴은 반드시 끝나야 한다”를 코드로 강제하기
고루틴을 만들 때마다 다음 질문을 코드 리뷰 기준으로 삼는 것이 좋습니다.
- 이 고루틴은 어떤 조건에서 종료되는가?
- 그 조건이 외부에서 제어 가능한가? (취소, 타임아웃, 종료 신호)
- 블로킹 가능 지점(send/recv/lock/I-O)에 탈출구(
ctx.Done()등)가 있는가?
정답은 대부분 context 와 select 로 귀결됩니다.
context 취소 전파: 고루틴 생명주기의 표준 인터페이스
Go에서 고루틴 종료 신호를 전달하는 가장 표준적인 방법은 context.Context 입니다.
- 상위 요청/프로세스 종료 시
cancel()호출 - 하위 함수/고루틴은
ctx.Done()을 감시 - 블로킹 작업은 가능하면
ctx를 인자로 받아 취소 가능하게 구성
기본 패턴: 부모가 cancel, 자식은 Done을 select로 감시
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context, out chan<- int) {
defer close(out)
for i := 0; ; i++ {
select {
case <-ctx.Done():
// 종료 신호를 받으면 즉시 반환
return
case out <- i:
// 정상 처리
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
out := make(chan int)
go worker(ctx, out)
for v := range out {
fmt.Println(v)
if v == 3 {
cancel()
}
// cancel 이후에도 range는 close(out)로 정상 종료
}
time.Sleep(100 * time.Millisecond)
}
포인트는 두 가지입니다.
- 고루틴 내부 루프는
select로ctx.Done()을 항상 함께 감시합니다. - 송신 채널을 닫아 소비자가 자연스럽게 종료되도록 합니다(가능한 경우).
select로 “블로킹 지점”에 탈출구 만들기
고루틴 누수는 대개 블로킹 호출 때문에 발생합니다. send/recv, 락, 타이머, 외부 I/O 등에서 멈춥니다. 따라서 “블로킹할 수 있는 모든 지점”을 select 로 감싸는 습관이 중요합니다.
채널 send가 막히는 누수 예시와 해결
누수 코드
func leak(ch chan<- int) {
for i := 0; ; i++ {
ch <- i // 받는 쪽이 없으면 영원히 블로킹
}
}
해결: ctx.Done 또는 타임아웃을 함께 select
func safeSend(ctx context.Context, ch chan<- int, v int) error {
select {
case <-ctx.Done():
return ctx.Err()
case ch <- v:
return nil
}
}
func producer(ctx context.Context, ch chan<- int) {
for i := 0; ; i++ {
if err := safeSend(ctx, ch, i); err != nil {
return
}
}
}
이 패턴을 적용하면 “소비자가 사라져서 send가 막히는” 상황에서도 상위 취소로 빠져나올 수 있습니다.
채널 recv가 막히는 경우
func consumer(ctx context.Context, ch <-chan int) {
for {
select {
case <-ctx.Done():
return
case v, ok := <-ch:
if !ok {
return
}
_ = v
}
}
}
ok 체크는 “생산자가 종료하면서 채널을 닫는” 정상적인 종료 경로를 만들어 줍니다.
ticker/타이머: time.Tick 대신 NewTicker + Stop
주기 작업을 고루틴으로 돌릴 때 흔한 누수 포인트가 타이머입니다.
나쁜 예: time.Tick
func bad(ctx context.Context) {
for range time.Tick(1 * time.Second) {
select {
case <-ctx.Done():
return
default:
// 작업
}
}
}
time.Tick 은 중단할 수 없어, 고루틴이 끝나도 내부 리소스가 남는 형태로 문제가 커질 수 있습니다.
좋은 예: NewTicker 와 Stop
func good(ctx context.Context) {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
// 주기 작업
}
}
}
fan-out/fan-in에서의 누수: 워커 풀 종료 설계
병렬 처리에서 가장 많이 터지는 누수는 “입력 채널은 닫혔는데 워커가 끝나지 않음” 또는 “워커는 끝났는데 집계 고루틴이 끝나지 않음” 같은 형태입니다.
아래 예시는 다음을 만족하는 구조입니다.
- 상위
ctx취소로 전체 종료 가능 - 입력 채널이 닫히면 워커 종료
- 워커가 모두 종료되면 결과 채널 닫힘
package main
import (
"context"
"sync"
)
func startWorkers(ctx context.Context, n int, jobs <-chan int) <-chan int {
results := make(chan int)
var wg sync.WaitGroup
wg.Add(n)
worker := func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case j, ok := <-jobs:
if !ok {
return
}
// 작업 결과를 보내는 것도 블로킹 가능하므로 ctx와 함께
select {
case <-ctx.Done():
return
case results <- (j * 2):
}
}
}
}
for i := 0; i < n; i++ {
go worker()
}
go func() {
wg.Wait()
close(results)
}()
return results
}
여기서 중요한 디테일은 “워커가 결과를 results <- ... 로 보낼 때도 ctx.Done() 을 함께 select 한다”는 점입니다. 워커 풀에서 흔한 누수는 결과 채널이 막혀 워커가 종료하지 못하는 상황에서 발생합니다.
HTTP 서버/요청 처리에서의 누수: r.Context() 를 생명주기로 사용
Go의 net/http 는 요청마다 r.Context() 를 제공합니다. 클라이언트가 연결을 끊거나 서버가 종료되면 이 컨텍스트는 취소됩니다. 요청 처리 중에 만든 고루틴은 가능하면 이 컨텍스트를 그대로 물려받아야 합니다.
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// 요청과 생명주기를 같이하는 고루틴
go func() {
select {
case <-ctx.Done():
return
default:
// 백그라운드 작업
}
}()
w.WriteHeader(200)
}
주의할 점은 “요청 컨텍스트를 백그라운드 작업에 그대로 쓰면, 응답이 끝나면서 작업이 취소될 수 있다”는 것입니다. 의도한 동작이 아니라면 context.Background() 기반으로 별도 생명주기를 설계해야 합니다. 즉, 어떤 생명주기에 묶일지를 명확히 해야 합니다.
종료를 보장하는 실전 체크리스트
1) 모든 고루틴에 종료 신호를 제공했는가
ctx를 인자로 받도록 통일- 또는
quit := make(chan struct{})같은 종료 채널을 두되, 가능하면context로 표준화
2) 블로킹 가능 지점마다 select 로 Done 을 감시했는가
ch <- v,<-ch,ticker.C,time.After,select {}- 락(
mutex.Lock) 자체는select로 감쌀 수 없으니, 락 경합이 심하면 구조를 바꾸거나 채널 기반으로 설계를 재검토
3) 채널 close 책임이 명확한가
- “누가 닫는가”가 불명확하면 대부분 데드락 또는 패닉으로 이어집니다
- 원칙적으로 생산자가 닫는다
4) 타임아웃이 필요한 경계가 있는가
외부 의존성(HTTP, DB, 큐) 호출은 무한 대기하지 않도록 context.WithTimeout 을 적극적으로 사용합니다. 운영에서 이런 무한 대기는 스레드/고루틴/커넥션 풀을 잠식해 장애로 커집니다. 장애 진단 관점은 TorchServe 500/타임아웃 진단·튜닝 8선 같은 “타임아웃을 시스템적으로 다루는” 글과도 결이 같습니다.
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)
5) 반복 작업은 NewTicker 를 쓰고 Stop 했는가
defer ticker.Stop()
누수 의심 시 빠르게 확인하는 방법
1) goroutine 덤프 확인
운영 중 고루틴 수가 비정상적으로 늘면 원인을 찾아야 합니다.
net/http/pprof를 붙였다면/debug/pprof/goroutine?debug=2- 또는
runtime/pprof.Lookup("goroutine").WriteTo(...)로 덤프
덤프를 보면 많은 고루틴이 chan send 혹은 chan receive 에서 멈춰 있는 패턴이 자주 보입니다. 그 지점이 바로 select 로 ctx.Done() 을 끼워 넣어야 할 곳입니다.
2) 고루틴 수/메모리 추세를 지표로 보기
runtime.NumGoroutine()를 주기적으로 로깅/메트릭화- 메모리/FD 증가와 함께 보면 누수를 더 빨리 감지할 수 있습니다
리소스가 서서히 증가하다가 OOM으로 이어지는 패턴은 Go 서비스뿐 아니라 다양한 런타임에서 반복됩니다. 예를 들어 Ray Serve 배포 OOMKilled·콜드스타트 5분 줄이기 같은 사례처럼, “누적 리소스”는 결국 장애로 연결됩니다.
결론: context와 select는 ‘종료 설계’의 기본기
고루틴 누수는 문법 실수가 아니라 생명주기 설계 실패에서 발생합니다. 다음 3가지만 습관화해도 대부분의 누수를 예방할 수 있습니다.
- 고루틴을 만들면
ctx를 같이 넘긴다. - 블로킹 가능 지점은
select로ctx.Done()을 함께 기다린다. - 채널 close 책임과 종료 순서를 코드로 명확히 만든다(
wg.Wait후 close 등).
이 원칙을 코드베이스 전반에 적용하면, “언젠가 터질” 누수가 아니라 “항상 끝나는” 고루틴을 만들 수 있고, 서비스의 장기 안정성이 눈에 띄게 좋아집니다.