- Published on
Go 고루틴 누수 잡기 - context·채널 close 패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버를 며칠만 돌려도 메모리나 고루틴 수가 꾸준히 증가하는데, 트래픽이 줄어도 다시 내려오지 않는 경우가 있습니다. 이런 현상의 대표 원인이 고루틴 누수(goroutine leak) 입니다. 고루틴 자체는 가볍지만, 누적되면 스케줄링 오버헤드와 힙 점유(버퍼, 클로저 캡처, 타이머 등)로 결국 장애로 이어집니다.
이 글에서는 누수의 전형적인 형태를 짚고, 실무에서 가장 효과적인 두 축인 context 취소 전파와 채널 close/종료 신호 패턴을 결합해 “끝이 보장되는” 동시성 구조를 만드는 방법을 다룹니다.
관련해서 요청 단위 타임아웃/취소가 왜 중요한지 더 넓은 관점은 Go gRPC context deadline exceeded 원인 7가지도 함께 참고하면 좋습니다.
고루틴 누수란 무엇인가
고루틴 누수는 단순히 “고루틴이 오래 돈다”가 아니라, 더 이상 결과를 소비하지 않거나 필요가 없는데도 종료되지 못하고 블로킹된 채 남는 상태를 말합니다.
대표적인 누수 형태는 다음과 같습니다.
- 채널 송신이 영원히 블로킹됨: 수신자가 없어졌는데 송신을 계속 기다림
- 채널 수신이 영원히 블로킹됨: 생산자가 종료됐는데 수신자는 계속 대기
select에서 취소 케이스가 없음: 타임아웃/취소가 와도 탈출 불가time.Tick남발: 내부 타이머가 해제되지 않아 장기적으로 리소스 누수- 워커 풀에서 작업 채널을
close하지 않음: 워커가 영원히 대기
핵심은 **“종료 조건이 코드로 표현되어 있지 않다”**는 점입니다.
증상과 빠른 진단 방법
런타임 지표로 의심하기
runtime.NumGoroutine()값이 트래픽과 무관하게 계속 증가- GC 이후에도 힙이 일정 수준 아래로 내려오지 않음
- pprof에서 특정 함수가
chan send/chan receive상태로 많이 쌓임
pprof로 누수 스택 찾기
운영/스테이징에서 pprof를 켜고 goroutine dump를 확인합니다.
import _ "net/http/pprof"
func main() {
go func() {
_ = http.ListenAndServe("127.0.0.1:6060", nil)
}()
// ...
}
그리고 다음을 확인합니다.
curl http://127.0.0.1:6060/debug/pprof/goroutine?debug=2go tool pprof -http=:0 http://127.0.0.1:6060/debug/pprof/goroutine
스택에 chan send 또는 chan receive로 멈춰 있는 지점이 누수 후보입니다.
누수를 만드는 나쁜 패턴 3가지
1) 수신자 없는 채널로 송신
func leak() {
ch := make(chan int)
go func() {
ch <- 1 // 수신자가 없으면 영원히 블로킹
}()
return
}
고루틴은 ch <- 1에서 영원히 멈춥니다.
2) 취소 전파 없는 무한 루프
func worker(ch <-chan int) {
for {
x := <-ch // 생산자가 종료되면 여기서 영원히 대기 가능
_ = x
}
}
채널이 close 되지 않으면 워커는 종료할 방법이 없습니다.
3) time.Tick 사용
time.Tick은 내부적으로 해제되지 않는 티커를 만들 수 있어 장기 실행 서비스에서 문제가 됩니다. 대신 time.NewTicker와 Stop()을 사용하세요.
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
// ...
}
}
원칙 1: “누가 종료를 책임지는가”를 먼저 정하라
고루틴을 만들 때는 다음 질문에 답이 있어야 합니다.
- 이 고루틴은 언제 종료해야 하는가
- 종료 신호는 무엇인가:
context.Done()인가, 채널close인가, 별도stopCh인가 - 종료 시 대기(Join) 가 필요한가:
sync.WaitGroup또는errgroup
실무에서는 대부분 다음 규칙이 안전합니다.
- 요청 스코프 작업:
context가 종료를 책임진다 - 파이프라인/브로드캐스트: 생산자가 채널
close로 종료를 알린다 - 여러 고루틴을 한 덩어리로 관리:
errgroup.WithContext로 묶는다
원칙 2: context는 “취소 신호”, 채널 close는 “생산 종료”
둘은 비슷해 보이지만 의미가 다릅니다.
context는 중단 요청입니다. “이제 그만해”에 가깝습니다.- 채널
close는 더 이상 값이 오지 않음을 보장합니다. “생산이 끝났어”입니다.
따라서 파이프라인에서는 보통 다음 조합이 가장 깔끔합니다.
- 생산자:
ctx를 보고 중단할 수 있어야 함 - 생산자: 정상 종료 시 출력 채널을
close - 소비자: 채널
range로 종료를 자연스럽게 감지
패턴 1: select에 반드시 ctx.Done()을 포함하기
채널 송수신이 블로킹 가능한 지점에는 취소 케이스를 넣습니다.
func sendWithCancel(ctx context.Context, out chan<- int, v int) error {
select {
case out <- v:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func recvWithCancel(ctx context.Context, in <-chan int) (int, error) {
select {
case v, ok := <-in:
if !ok {
return 0, io.EOF
}
return v, nil
case <-ctx.Done():
return 0, ctx.Err()
}
}
이 한 가지 습관만으로도 “송신/수신 대기 누수”의 상당수를 제거합니다.
패턴 2: 생산자가 close(out)를 책임지는 파이프라인
아래 예시는 gen이 값을 만들고, square가 변환하는 2단 파이프라인입니다.
func gen(ctx context.Context, nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
select {
case out <- n:
case <-ctx.Done():
return
}
}
}()
return out
}
func square(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for {
select {
case v, ok := <-in:
if !ok {
return
}
select {
case out <- v * v:
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
}()
return out
}
포인트는 다음입니다.
- 각 단계는 자기 출력 채널을 반드시
close - 입력 채널이 닫히면 다음 단계도 자연 종료
- 모든 블로킹 지점에
ctx.Done()을 둬서 조기 중단 가능
이 구조는 “정상 완료”와 “취소” 모두에서 종료가 보장됩니다.
패턴 3: errgroup.WithContext로 고루틴 생명주기 묶기
여러 고루틴이 함께 움직여야 할 때는 errgroup이 강력합니다. 하나가 실패하면 컨텍스트가 취소되고, 나머지도 빠르게 정리되게 만들 수 있습니다.
import "golang.org/x/sync/errgroup"
func run(ctx context.Context) error {
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error {
// 작업 A
select {
case <-time.After(100 * time.Millisecond):
return nil
case <-ctx.Done():
return ctx.Err()
}
})
g.Go(func() error {
// 작업 B
select {
case <-time.After(200 * time.Millisecond):
return errors.New("boom")
case <-ctx.Done():
return ctx.Err()
}
})
return g.Wait()
}
g.Wait()는 join 역할을 하므로, 호출자 입장에서 “정리 완료” 시점을 명확히 가질 수 있습니다.
패턴 4: 워커 풀에서 close(jobs)와 결과 채널 종료 규약
워커 풀은 누수가 가장 자주 발생하는 영역입니다. 규약을 명확히 하세요.
- 작업 생산자는
jobs를close - 워커는
range jobs로 자연 종료 - 결과 채널은 워커들이 모두 끝난 뒤 한 곳에서
close(results)
func worker(ctx context.Context, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for {
select {
case j, ok := <-jobs:
if !ok {
return
}
// 실제 처리
select {
case results <- j * 2:
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
}
func runPool(ctx context.Context, items []int, n int) ([]int, error) {
jobs := make(chan int)
results := make(chan int)
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go worker(ctx, jobs, results, &wg)
}
go func() {
defer close(jobs)
for _, it := range items {
select {
case jobs <- it:
case <-ctx.Done():
return
}
}
}()
go func() {
wg.Wait()
close(results)
}()
var out []int
for {
select {
case v, ok := <-results:
if !ok {
return out, ctx.Err()
}
out = append(out, v)
case <-ctx.Done():
return out, ctx.Err()
}
}
}
주의할 점:
results를 워커가 닫으면 경쟁 조건이 생깁니다. 닫는 책임은 단 한 곳이어야 합니다.ctx.Done()으로 중단되면 생산자/워커/수집자 모두 빠져나올 수 있어야 합니다.
패턴 5: “드레인(drain)”이 필요한 경우를 구분하기
취소 시 즉시 반환하면, 백그라운드 고루틴이 results <- ... 같은 송신에서 막힐 수 있습니다. 이때 선택지는 두 가지입니다.
- 결과 채널에 버퍼를 두고, 취소 시에도 일정량을 흡수 가능하게 만들기
- 취소 직후에도 잠깐 드레인해서 송신자를 풀어주기
간단한 드레인 예시는 다음과 같습니다.
func drain[T any](ch <-chan T) {
for {
select {
case <-ch:
// 버림
default:
return
}
}
}
제네릭 표기 T any 같은 코드는 반드시 백틱으로 감싸지 않으면 안 되지만, 여기서는 코드 블록이므로 안전합니다.
드레인은 만능이 아닙니다. “취소 후에도 일부 고루틴이 안전하게 빠져나오도록” 설계가 안 되어 있을 때 임시방편이 되기 쉽습니다. 가능하면 앞선 패턴처럼 송신 자체가 ctx.Done()을 보게 만드는 편이 더 견고합니다.
흔한 실수: 채널을 닫아야 할 때와 닫지 말아야 할 때
- 닫아야 함: “더 이상 이 채널로 값이 오지 않는다”를 소비자에게 알려야 할 때
- 닫지 말아야 함: 단순 신호용
done채널을 여러 곳에서 닫을 위험이 있을 때(중복close패닉)
신호용이라면 context가 대체재가 됩니다. doneCh를 직접 만들기보다 ctx를 전달하는 쪽이 안전합니다.
실전 체크리스트
아래 항목 중 하나라도 “아니다”가 나오면 누수 가능성이 큽니다.
- 모든 고루틴은 종료 조건이 명확한가
- 블로킹 가능한 채널 송수신에
ctx.Done()케이스가 있는가 - 생산자는 출력 채널을
close하는가 - 채널을 닫는 주체가 단 하나로 고정되어 있는가
- 워커 풀에서
jobs는 반드시close되는가 time.NewTicker를 쓰고Stop()을 호출하는가- 에러가 나면 관련 고루틴이 함께 정리되는가(필요 시
errgroup)
마무리
Go에서 고루틴 누수는 대개 “작은 부주의”가 아니라 종료 프로토콜이 없는 동시성 설계에서 발생합니다. context는 취소를 전파하고, 채널 close는 생산 종료를 알립니다. 이 둘의 역할을 구분해 설계하면, 고루틴은 자연스럽게 끝나고 서비스는 오래 실행되어도 안정적으로 유지됩니다.
네트워크 호출과 함께 동시성을 구성할 때는 타임아웃/취소가 특히 중요합니다. 요청 체인에서 context가 어디서 끊기는지 점검하는 관점으로는 Go gRPC context deadline exceeded 원인 7가지도 같이 읽어보면 누수 방지에 도움이 됩니다.