- Published on
Go 채널 데드락·goroutine 누수 재현과 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 배치에서 Go 동시성을 쓰다 보면, 어느 날 갑자기 fatal error: all goroutines are asleep - deadlock!가 터지거나, 프로세스는 살아 있는데 메모리/스레드가 서서히 늘어나는 현상을 만납니다. 전자는 채널/락의 교착 상태(데드락), 후자는 대부분 goroutine 누수입니다. 둘은 별개의 문제처럼 보이지만, 실제로는 같은 뿌리(종료 조건 부재, 블로킹 I/O, 소비자/생산자 불균형)에서 함께 발생하는 경우가 많습니다.
이 글에서는 (1) 데드락·누수를 의도적으로 재현하고, (2) 왜 그런지 실행 흐름으로 설명하고, (3) 현업에서 통하는 해결 패턴(컨텍스트, 채널 종료, fan-in/fan-out, 에러 전파, 워커풀)을 코드로 정리합니다.
참고로 Go 네트워크/서비스에서 타임아웃과 취소는 거의 필수입니다. gRPC 컨텍스트/데드라인 튜닝은 아래 글도 함께 보면 좋습니다.
데드락과 goroutine 누수의 차이
- 데드락: 모든 실행 흐름이 어떤 자원(채널 송수신, 뮤텍스,
WaitGroup.Wait)에서 서로를 기다리며 멈춘 상태. Go 런타임은 “모든 goroutine이 잠들었다”고 판단하면 패닉을 냅니다. - goroutine 누수: 특정 goroutine이 종료되지 못하고 영원히 대기(채널 수신, 송신, select 대기, 블로킹 I/O)하면서 누적되는 상태. 런타임이 즉시 패닉을 내지 않을 수 있어 더 위험합니다.
실무에서는 “요청이 끝났는데도 goroutine이 남아 있다”가 누수의 전형적인 징후입니다. 그리고 누수는 결국 데드락/리소스 고갈로 이어지기도 합니다.
재현 1: 수신자가 없는 채널 송신 데드락
가장 단순한 데드락은 수신자가 없는 unbuffered 채널에 송신하는 경우입니다.
package main
func main() {
ch := make(chan int) // unbuffered
ch <- 1 // 수신자가 없어 영원히 블로킹
}
이 코드는 곧바로 데드락 패닉이 납니다. 원인은 명확합니다.
- unbuffered 채널의 송신은 수신자가 “동시에” 있어야 완료됩니다.
maingoroutine이 송신에서 멈추면, 프로그램 전체가 멈춥니다.
해결
- 수신 goroutine을 만들거나
- 버퍼 채널로 바꾸거나
- 송신을
select로 감싸 타임아웃/취소를 넣습니다.
package main
import (
"context"
"time"
)
func main() {
ch := make(chan int)
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
select {
case ch <- 1:
// sent
case <-ctx.Done():
// give up
}
}
select의 취소 케이스는 데드락을 “없애는” 게 아니라, 영원히 기다리지 않게 만들어 장애 전파/복구가 가능해집니다.
재현 2: 닫힌 채널이 아닌데 range로 기다리다 멈추는 누수
for v := range ch는 채널이 close될 때까지 계속 기다립니다. 생산자가 종료하면서 채널을 닫지 않으면 소비자는 끝나지 않습니다.
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int)
go func() {
for i := 0; i < 3; i++ {
ch <- i
}
// close(ch) 를 안 하면 소비자는 영원히 대기
}()
go func() {
for v := range ch {
fmt.Println("recv:", v)
}
fmt.Println("consumer done")
}()
time.Sleep(500 * time.Millisecond)
fmt.Println("main done")
}
실행하면 main done은 찍히지만 consumer done은 찍히지 않습니다. 소비 goroutine이 range에서 대기하며 누수가 발생합니다.
해결: 생산자가 “종료 신호”를 책임지고 close
채널 종료는 “더 이상 값이 오지 않는다”는 프로토콜입니다. 일반적으로 송신자(생산자)가 닫는 것이 규칙입니다.
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int)
go func() {
defer close(ch)
for i := 0; i < 3; i++ {
ch <- i
}
}()
for v := range ch {
fmt.Println("recv:", v)
}
time.Sleep(50 * time.Millisecond)
fmt.Println("main done")
}
핵심은 “누가 close할 책임이 있는가”를 코드 구조로 강제하는 것입니다. 생산자가 여러 명이면 더 설계가 필요합니다(아래 fan-in에서 다룹니다).
재현 3: WaitGroup 데드락(카운트 불일치)
WaitGroup.Add(1)을 했는데 어떤 경로에서 Done()이 호출되지 않으면 Wait()는 영원히 기다립니다.
package main
import (
"sync"
)
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
// 실수: return 경로에서 Done 누락
return
// wg.Done()
}()
wg.Wait() // 영원히 대기
}
해결: goroutine 시작 직후 defer wg.Done()
package main
import "sync"
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
// 어떤 return 경로든 Done 보장
}()
wg.Wait()
}
실무에서는 Add를 goroutine 안에서 하는 실수도 많습니다. Add는 보통 goroutine을 띄우기 “직전”에 하고, 띄운 뒤에는 Done만 책임지게 두는 편이 안전합니다.
재현 4: fan-in에서 결과 채널 close를 잘못 처리해 발생하는 누수
여러 워커가 결과를 하나의 채널로 모으는 fan-in에서 흔한 실수는 다음 중 하나입니다.
- 결과 채널을 아무도 닫지 않아 소비자가
range에서 멈춤 - 반대로, 여러 송신자가 결과 채널을 각각 닫아
panic: close of closed channel
잘못된 예(채널을 닫지 않음)
package main
import (
"fmt"
"sync"
)
func main() {
jobs := make(chan int)
results := make(chan int)
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs {
results <- j * 2
}
}()
}
go func() {
for j := 0; j < 5; j++ {
jobs <- j
}
close(jobs)
wg.Wait()
// 실수: results를 close 안 함
}()
for r := range results {
fmt.Println(r)
}
}
해결: “마지막 종료 지점”에서 단 한 번 close(results)
results는 워커들이 송신하지만, 닫는 책임은 워커가 아니라 **조정자(coordinator)**가 가져가는 게 일반적입니다. 즉, 워커 종료를 WaitGroup으로 확인한 뒤 한 번만 닫습니다.
package main
import (
"fmt"
"sync"
)
func main() {
jobs := make(chan int)
results := make(chan int)
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for j := range jobs {
results <- j * 2
}
}(i)
}
go func() {
for j := 0; j < 5; j++ {
jobs <- j
}
close(jobs)
wg.Wait()
close(results) // 오직 여기서 한 번
}()
for r := range results {
fmt.Println(r)
}
}
이 패턴은 fan-in에서 가장 많이 쓰이며, 누수/패닉을 동시에 방지합니다.
누수의 본질: “취소가 전파되지 않는다”
goroutine 누수의 대부분은 다음 형태입니다.
- 어떤 goroutine이
select없이 채널 수신을 기다림 - 상위 요청은 이미 실패/타임아웃/클라이언트 종료
- 하지만 하위 goroutine에게 “이제 그만”이 전달되지 않아 계속 대기
그래서 실무에서는 채널 설계만큼 컨텍스트 취소가 중요합니다. 특히 HTTP 핸들러, gRPC 핸들러, 큐 컨슈머처럼 “요청/작업 단위”가 명확한 곳에서는 context.Context를 루트로 두고 하위 goroutine까지 전달하는 것이 정석입니다.
해결 패턴 1: select에 ctx.Done()을 넣어 블로킹을 끊기
채널 수신/송신 모두 취소 가능하게 만들면 누수가 급감합니다.
package main
import (
"context"
"time"
)
func worker(ctx context.Context, jobs <-chan int, results chan<- int) {
for {
select {
case <-ctx.Done():
return
case j, ok := <-jobs:
if !ok {
return
}
// 결과 송신도 취소 가능하게
select {
case <-ctx.Done():
return
case results <- j * 2:
}
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
_ = ctx
}
포인트는 “수신만 취소”가 아니라, 송신도 취소해야 한다는 점입니다. 결과 채널이 꽉 차거나 소비자가 죽으면 송신에서 누수가 납니다.
해결 패턴 2: 버퍼 채널은 만능이 아니다(하지만 완충재는 된다)
버퍼 채널은 생산자/소비자의 순간적인 속도 차를 흡수해 데드락 가능성을 줄입니다. 하지만 다음을 해결하지는 못합니다.
- 소비자가 영원히 죽어 있으면 결국 버퍼는 가득 차고 송신은 블로킹
- 종료 조건/close/취소가 없으면 누수는 그대로
버퍼는 “스파이크 완충” 정도로 보고, 종료 프로토콜과 함께 쓰는 게 안전합니다.
해결 패턴 3: 에러 그룹으로 조기 종료와 누수 방지
여러 goroutine 중 하나라도 실패하면 나머지를 취소해야 합니다. 표준 라이브러리만으로도 가능하지만, errgroup(Go 팀이 제공하는 golang.org/x/sync/errgroup)을 쓰면 패턴이 깔끔해집니다.
package main
import (
"context"
"fmt"
"golang.org/x/sync/errgroup"
"time"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error {
select {
case <-time.After(50 * time.Millisecond):
return fmt.Errorf("worker failed")
case <-ctx.Done():
return ctx.Err()
}
})
g.Go(func() error {
// 첫 번째 goroutine이 실패하면 ctx가 취소되어 여기서 빠져나옴
<-ctx.Done()
return ctx.Err()
})
_ = g.Wait()
}
이 방식은 “실패 시 다른 goroutine이 빠져나올 출구”를 보장하므로 누수 방지에 강력합니다.
운영에서의 관측/디버깅: 누수는 숫자로 드러난다
1) goroutine 수를 메트릭으로 보기
가장 단순하지만 효과가 큽니다.
runtime.NumGoroutine()을 주기적으로 로깅/메트릭화- 요청량과 무관하게 우상향이면 누수 의심
package main
import (
"log"
"runtime"
"time"
)
func main() {
go func() {
for range time.NewTicker(2 * time.Second).C {
log.Println("goroutines:", runtime.NumGoroutine())
}
}()
select {}
}
2) pprof로 스택 트레이스 보기
HTTP 서버에서 net/http/pprof를 열어두면, 누수 지점이 “어디에서 블로킹 중인지” 스택으로 보입니다.
import (
_ "net/http/pprof"
"net/http"
)
go func() {
_ = http.ListenAndServe("127.0.0.1:6060", nil)
}()
그 다음 curl로 http://127.0.0.1:6060/debug/pprof/goroutine?debug=2를 보면, chan receive나 chan send에서 멈춘 지점이 그대로 나옵니다.
체크리스트: 채널/고루틴 설계에서 지켜야 할 규칙
range ch를 쓰면close(ch)책임자를 반드시 정한다.- 송신/수신이 블로킹될 수 있는 모든 지점에
select와ctx.Done()또는 타임아웃을 고려한다. - 워커풀에서는 결과 채널을 워커가 닫지 말고, 조정자가
WaitGroup이후 한 번만 닫는다. WaitGroup은 goroutine 시작 직후defer Done()을 기본으로 한다.- 버퍼 채널은 완충재일 뿐, 종료/취소 프로토콜을 대체하지 못한다.
- 운영에서는
runtime.NumGoroutine()와 pprof를 기본 장착한다.
마무리
Go의 채널은 강력하지만 “종료”를 자동으로 처리해주지 않습니다. 데드락은 즉시 폭발하지만, goroutine 누수는 조용히 진행되며 결국 장애를 크게 만듭니다. 해결의 핵심은 하나로 요약됩니다.
- 종료 조건을 설계에 포함하고(누가 close하는가)
- 취소를 전파하며(ctx.Done)
- 조정자(coordinator)가 생명주기를 관리한다(WaitGroup, errgroup)
이 3가지만 일관되게 지키면, 채널 기반 파이프라인과 워커풀은 훨씬 예측 가능해지고 운영 안정성도 크게 올라갑니다.