- Published on
Go 고루틴 누수? context 취소·채널 close 7패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 오래 돌수록 메모리와 고루틴 수가 천천히 증가한다면, 대부분은 고루틴 누수(goroutine leak) 입니다. Go의 고루틴은 가볍지만, 종료 조건이 없거나 블로킹이 풀리지 않으면 영원히 남아 런타임 스케줄러와 힙을 계속 압박합니다.
누수는 단순히 go func(){ ... }()를 많이 띄워서가 아니라, 취소 신호가 도달하지 않는 구조(컨텍스트 미사용, 채널 close 규약 부재, fan-in/out에서 종료 합의 실패)에서 자주 발생합니다.
이 글은 "왜 누수되는지"를 원인별로 분해하고, 실무에서 바로 적용 가능한 context 취소·채널 close 7가지 패턴으로 정리합니다.
운영에서 증상이 비슷하게 나타나는 케이스(요청 폭주, 타임아웃, 백그라운드 워커 정체)는 다른 스택에서도 자주 나옵니다. 예를 들어 502/게이트웨이 오류를 원인별로 쪼개듯이, 고루틴 누수도 "어디서 블로킹이 풀리지 않는가"로 분해해야 합니다. 참고: OpenAI Responses API 502 Bad Gateway 원인과 해결
고루틴 누수의 전형적 징후
런타임 지표로 빠르게 감 잡기
runtime.NumGoroutine()가 트래픽이 줄어도 내려오지 않음- pprof에서
goroutine프로파일이 특정 함수에서 대량으로 대기 - 메모리도 같이 증가(특히 채널 버퍼/큐/타이머/클로저 캡처)
간단한 관측 코드는 다음처럼 넣을 수 있습니다.
package main
import (
"log"
"runtime"
"time"
)
func main() {
go func() {
for range time.Tick(10 * time.Second) {
log.Printf("goroutines=%d", runtime.NumGoroutine())
}
}()
select {}
}
time.Tick 자체도 정리하지 않으면 누수 포인트가 될 수 있으니(뒤에서 다룹니다), 운영 코드에서는 time.NewTicker와 Stop()을 권장합니다.
누수가 생기는 핵심 메커니즘
고루틴이 종료되려면 결국 둘 중 하나입니다.
- 함수가
return한다 - 패닉/프로세스 종료
문제는 고루틴이 return 하지 못하게 만드는 블로킹 지점이 있다는 것입니다.
chanreceive/send에서 상대가 영원히 오지 않음select에 취소 케이스가 없음WaitGroup.Wait()가 영원히 풀리지 않음(미완료 Done)- 네트워크/IO가 context 없이 대기
- ticker/timer가 계속 이벤트를 만들어 고루틴이 살아있음
이제부터는 실전에서 가장 많이 쓰는 7가지 패턴으로 정리합니다.
패턴 1) 모든 고루틴에 ctx.Done()을 "항상" 꽂기
가장 기본이면서 가장 자주 빠지는 규칙입니다.
- 고루틴을 띄우는 함수가
context.Context를 받는다 - 루프/블로킹 포인트마다
select로ctx.Done()을 함께 기다린다
func worker(ctx context.Context, jobs <-chan Job) {
for {
select {
case <-ctx.Done():
return
case j, ok := <-jobs:
if !ok {
return
}
handle(ctx, j)
}
}
}
여기서 포인트는 두 가지입니다.
jobs채널이 닫혀도 종료ctx가 취소돼도 종료
둘 중 하나라도 빠지면, 호출자가 어떤 방식으로 종료를 시도하든 누수 가능성이 생깁니다.
패턴 2) 채널 close는 "보내는 쪽"만 한다 (종료 규약 고정)
채널 close 관련 누수/패닉은 대부분 "누가 닫는가"가 합의되지 않아서 발생합니다.
- 원칙: 채널을 닫는 주체는 그 채널에 값을 보내는 주체(생산자)
- 소비자가 닫으면 생산자가 send 중 패닉이 날 수 있고, 그 복구 과정에서 고루틴이 꼬이거나(재시도 루프 등) 종료되지 않는 구조가 생깁니다.
생산자가 단일이면 간단합니다.
func producer(ctx context.Context) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for i := 0; i < 10; i++ {
select {
case <-ctx.Done():
return
case out <- i:
}
}
}()
return out
}
defer close(out)은 단순하지만 효과가 큽니다. 생산자 고루틴이 어떤 경로로 끝나든 채널이 닫혀 소비자가 빠져나올 수 있습니다.
패턴 3) fan-in(merge)에서는 WaitGroup + close를 "단 한 번"만
여러 입력 채널을 하나로 합칠 때 누수가 아주 흔합니다.
- 입력 중 하나가 멈추면 merge 고루틴이 계속 대기
- 출력 채널 close를 여러 곳에서 시도
- cancel 시점에 forwarder가 send에서 막혀 종료 못함
정석은 다음입니다.
- 입력마다 forwarder 고루틴을 띄운다
- forwarder는
ctx.Done()을 본다 - 모든 forwarder가 끝나면 하나의 곳에서만
close(out)한다
func merge(ctx context.Context, ins ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
wg.Add(len(ins))
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 ins {
go forward(ch)
}
go func() {
wg.Wait()
close(out)
}()
return out
}
중요한 디테일은 forwarder에서 receive와 send 모두에 취소 케이스를 넣는 것입니다. send가 막혀도 취소로 빠져나와야 합니다.
패턴 4) fan-out(worker pool)에서는 "입력 close" + "결과 drain"을 설계에 포함
워커 풀에서 누수는 보통 두 군데에서 터집니다.
- 워커가
jobsreceive에서 영원히 대기 - 워커가
resultssend에서 영원히 대기(소비자가 종료)
따라서 워커 풀은 다음 종료 규약이 필요합니다.
- 생산자가
close(jobs)한다 - 소비자는
results를 끝까지 drain하거나, 취소 시그널로 워커가 send를 포기할 수 있어야 한다
func startPool(ctx context.Context, n int, jobs <-chan Job) <-chan Result {
results := make(chan Result)
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case j, ok := <-jobs:
if !ok {
return
}
r := doWork(ctx, j)
select {
case <-ctx.Done():
return
case results <- r:
}
}
}
}()
}
go func() {
wg.Wait()
close(results)
}()
return results
}
results를 버퍼링한다고 해결되지 않는 경우가 많습니다. 버퍼는 "지연"일 뿐, 소비자가 사라지면 결국 send 블로킹이 발생합니다. 그래서 send에도 ctx.Done()이 있어야 합니다.
패턴 5) errgroup.WithContext로 "첫 에러에서 전체 취소"를 표준화
여러 고루틴을 동시에 돌리고 하나라도 실패하면 전체를 중단해야 하는 경우가 많습니다.
- 수동으로
cancel()관리 WaitGroup과 에러 채널 조합
을 직접 짜면 종료 경로가 늘어나고 누수 확률이 커집니다.
errgroup.WithContext는 다음을 보장합니다.
- 어떤 고루틴이 에러를 반환하면 context가 취소됨
Wait()로 모든 고루틴 종료를 기다릴 수 있음
import "golang.org/x/sync/errgroup"
func runAll(parent context.Context) error {
g, ctx := errgroup.WithContext(parent)
g.Go(func() error {
return producerLoop(ctx)
})
g.Go(func() error {
return consumerLoop(ctx)
})
g.Go(func() error {
return metricsLoop(ctx)
})
return g.Wait()
}
실무 팁은 "모든 루프가 ctx.Done()을 본다"를 전제로 errgroup을 쓰는 것입니다. 그렇지 않으면 Wait()가 영원히 안 끝날 수 있습니다.
패턴 6) 타이머/티커는 Stop()으로 정리하고, 가능하면 time.After 남발을 피하기
고루틴 누수처럼 보이지만 실제로는 타이머 리소스 누적인 경우가 있습니다.
time.Tick은 GC 되기 전까지 내부 리소스가 계속 유지될 수 있어 장수 프로세스에 부적합- 루프에서
time.After를 매번 만들면 타이머 객체가 계속 생성
권장 패턴:
- 주기 작업은
time.NewTicker+defer ticker.Stop() - 타임아웃은 가능하면
context.WithTimeout으로 통일
func periodic(ctx context.Context) {
t := time.NewTicker(5 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
doSomething(ctx)
}
}
}
네트워크 호출도 타임아웃을 context.WithTimeout으로 걸면, 상위 취소와 함께 정리되기 쉬워집니다.
func fetch(ctx context.Context, url string) error {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
패턴 7) "보내는 쪽이 멈추면" 소비자가 영원히 기다리지 않게: done 채널/컨텍스트를 API에 포함
라이브러리/패키지 레벨 API에서 누수가 나는 가장 흔한 이유는, 호출자가 종료를 지시할 방법이 없기 때문입니다.
func Subscribe() <-chan Event같은 API는 "언제 끝나나"가 불명확- 호출자가 채널 receive 루프를 빠져나오려면 생산자가 close 해주길 기다려야 함
해결은 간단합니다.
context.Context를 인자로 받거나done <-chan struct{}를 인자로 받습니다.
func Subscribe(ctx context.Context) <-chan Event {
out := make(chan Event)
go func() {
defer close(out)
for {
select {
case <-ctx.Done():
return
case out <- nextEvent():
}
}
}()
return out
}
이 패턴은 HTTP 핸들러, gRPC 스트리밍, 메시지 컨슈머 등 거의 모든 장수 루프에 적용됩니다.
디버깅 체크리스트: "어디서 블로킹인가"를 한 번에 찾기
1) goroutine dump로 대기 지점을 확인
프로세스에 SIGQUIT를 보내면 스택 덤프를 얻을 수 있습니다(리눅스 기준). 덤프에서 chan receive, chan send, select 대기를 확인하세요.
또는 pprof를 붙여서 확인합니다.
import _ "net/http/pprof"
go func() {
_ = http.ListenAndServe("127.0.0.1:6060", nil)
}()
그 다음:
curl http://127.0.0.1:6060/debug/pprof/goroutine?debug=2
2) 종료 규약을 문서화
팀 코드에서 특히 중요한 것은 "이 채널은 누가 닫는가", "취소는 누가/언제 하는가"를 함수 주석이나 README에 남기는 것입니다. 타입 시스템으로 계약을 강화하는 습관은 다른 언어에서도 효과가 큽니다. 참고: TS 5.4 satisfies로 타입 깨짐 잡는 법
자주 하는 실수 5가지 (짧게 교정)
for v := range ch만 쓰고ctx.Done()을 안 봄- 소비자가 임의로
close(ch)함 - merge/fan-in에서
close(out)를 여러 고루틴에서 시도 - send 블로킹을 고려하지 않고 "버퍼 주면 되겠지"로 끝냄
time.Tick을 장수 고루틴에서 사용
결론: 누수를 막는 건 "코드 한 줄"이 아니라 "종료 설계"
고루틴 누수는 대부분 다음 질문에 답이 없어서 생깁니다.
- 이 고루틴은 언제 끝나야 하는가
- 끝내기 위한 신호는 무엇인가(
ctx.Done(), 채널 close) - 신호가 왔을 때 블로킹 지점을 어떻게 빠져나오는가(receive뿐 아니라 send도)
이 글의 7패턴을 팀 규약으로 정리해두면, 고루틴 수가 안정적으로 유지되고 장애 시에도 "정상 종료"가 가능해집니다. 특히 fan-in/out과 타이머/티커 정리는 장수 서비스에서 체감 효과가 큽니다.
추가로 운영 환경에서 리버스 프록시/게이트웨이 타임아웃과 결합되면 취소 전파가 더 중요해집니다(요청은 끊겼는데 백엔드 작업이 계속 도는 형태). 이런 류의 "취소/타임아웃 불일치"는 다른 스택에서도 자주 등장합니다. 참고: Proxy 뒤 Nginx에서 OAuth 리다이렉트 URI 불일치 해결