- Published on
Go 채널 데드락 재현·진단 - goroutine leak 잡기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
프로덕션에서 가장 골치 아픈 장애 중 하나는 “CPU는 낮은데 요청이 멈추는” 유형입니다. 로그도 조용하고, 메모리도 안정적인데, 특정 시점부터 응답이 지연되다가 결국 타임아웃이 쌓입니다. Go 서비스라면 이 증상은 종종 channel 데드락 또는 goroutine leak(고루틴이 종료되지 못하고 영원히 대기)로 귀결됩니다.
이 글에서는 다음을 목표로 합니다.
- 채널 기반 데드락을 의도적으로 재현해 증상을 눈으로 확인 n- 데드락이 goroutine leak으로 커지는 전형적인 패턴 정리
pprof,runtime/trace,SIGQUIT덤프, 테스트 타임아웃으로 진단 루틴 만들기- 재발 방지를 위한 구조적 설계 체크리스트 제시
추가로, 고루틴 누수 자체를 빠르게 진단하는 방법은 이전 글인 Go 고루틴 누수 5분 진단 - pprof·trace로 잡기도 함께 보면 연결이 잘 됩니다.
1) 데드락과 goroutine leak의 관계
Go에서 “데드락”은 두 층위로 나타납니다.
- 런타임이 감지하는 전체 데드락: 모든 고루틴이 블로킹 상태라 더 이상 진행 불가할 때
fatal error: all goroutines are asleep - deadlock!로 바로 크래시 - 부분 데드락: 일부 고루틴만 영원히 블로킹. 프로세스는 살아있지만 특정 요청/작업이 멈춤. 이게 실무에서 더 흔하고, 결국 누적되어
goroutine leak을 만든 뒤 메모리 증가, FD 고갈, 큐 적체로 이어집니다.
특히 채널은 “동기화”와 “데이터 전달”을 동시에 담당하기 때문에, 종료 신호(close), 컨텍스트 취소, 버퍼 크기, 소비자/생산자 수가 엇갈리면 아주 쉽게 영구 대기가 발생합니다.
2) 재현 1: 수신자가 사라진 채널 send 블로킹
가장 흔한 누수는 “받는 쪽이 더 이상 없는데 보내는 쪽이 계속 send” 하는 상황입니다.
재현 코드
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context, out chan int) {
// 실수: ctx가 취소되어도 out으로 send를 계속 시도
for i := 0; ; i++ {
out <- i // 수신자가 없으면 여기서 영원히 블로킹
select {
case <-ctx.Done():
return
default:
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
out := make(chan int) // unbuffered
go worker(ctx, out)
// 잠깐만 읽고 수신을 중단
for i := 0; i < 3; i++ {
fmt.Println(<-out)
}
// 수신자 중단 + 컨텍스트 취소
cancel()
// 프로세스는 살아있지만 worker는 send에서 멈출 수 있음
time.Sleep(3 * time.Second)
fmt.Println("done")
}
왜 누수가 되나
out <- i가 먼저 실행되고, 그 다음에ctx.Done()을 확인합니다.- 수신자가 사라지면 send가 영원히 깨어나지 못하므로
ctx.Done()을 볼 기회가 없습니다.
수정 패턴
send를 할 때도 반드시 select로 취소/타임아웃 경로를 함께 둡니다.
func worker(ctx context.Context, out chan int) {
for i := 0; ; i++ {
select {
case out <- i:
// sent
case <-ctx.Done():
return
}
}
}
이 한 줄 구조 차이가 “취소가 먹히는 고루틴”과 “영구 블로킹 고루틴”을 가릅니다.
3) 재현 2: close를 누가 하느냐(채널 소유권) 문제
채널 데드락/패닉의 80%는 “close 책임이 불명확”해서 생깁니다.
- 소비자가
close(ch)를 해버림 - 생산자가 여러 명인데 각자
close(ch)를 시도 - 생산자가 종료되었는데 소비자는
for range ch에서 영원히 대기
재현 코드: 생산자가 종료되는데 close가 없다
package main
import (
"fmt"
"time"
)
func producer(ch chan int) {
for i := 0; i < 3; i++ {
ch <- i
}
// 실수: close(ch) 없음
}
func main() {
ch := make(chan int)
go producer(ch)
// 소비자는 range로 종료 신호를 기다리지만, 영원히 끝나지 않음
for v := range ch {
fmt.Println(v)
}
time.Sleep(1 * time.Second)
}
이 코드는 메인 고루틴이 멈추며, 상황에 따라 런타임 전체 데드락으로 크래시할 수도 있습니다.
해결 원칙: “채널을 만든 쪽(생산자 측)이 닫는다”
close는 송신자만 한다close는 정확히 한 번만 한다
func producer(ch chan int) {
defer close(ch)
for i := 0; i < 3; i++ {
ch <- i
}
}
여러 생산자가 하나의 채널로 보낼 때는 sync.WaitGroup으로 생산자 종료를 모은 뒤 “중앙에서 한 번만 close”합니다.
package main
import (
"fmt"
"sync"
)
func main() {
ch := make(chan int)
var wg sync.WaitGroup
producers := 3
wg.Add(producers)
for p := 0; p < producers; p++ {
go func(id int) {
defer wg.Done()
for i := 0; i < 2; i++ {
ch <- id*10 + i
}
}(p)
}
go func() {
wg.Wait()
close(ch) // close는 여기서 단 한 번
}()
for v := range ch {
fmt.Println(v)
}
}
4) 재현 3: 버퍼 채널의 “조용한” 데드락
버퍼 채널은 처음엔 잘 돌아가다가 트래픽이 늘면 갑자기 멈추는 형태를 만들기 쉽습니다.
- 버퍼가 찰 때까지는 send가 통과
- 어느 순간 소비가 느려지면 send가 블로킹
- 블로킹된 고루틴이 락을 잡고 있거나, 종료 신호를 못 받으면 연쇄 정지
재현 코드: 버퍼가 차면 핸들러가 멈춤
package main
import (
"net/http"
"time"
)
var q = make(chan []byte, 10)
func slowConsumer() {
for range q {
time.Sleep(500 * time.Millisecond)
}
}
func handler(w http.ResponseWriter, r *http.Request) {
payload := make([]byte, 1024)
q <- payload // 버퍼가 차면 요청 핸들러가 여기서 블로킹
w.WriteHeader(http.StatusAccepted)
}
func main() {
go slowConsumer()
_ = http.ListenAndServe(":8080", http.HandlerFunc(handler))
}
이 코드는 부하가 커지면 handler가 채널 send에서 멈춰서, 결국 서버 스레드풀(정확히는 고루틴)이 대기하며 요청이 줄줄이 타임아웃됩니다.
대응 전략
- 큐가 가득 찼을 때의 정책을 명시: 드롭, 백프레셔, 동기 처리, 에러 반환
- “무한 대기”를 기본값으로 두지 않기
예: 큐가 꽉 차면 즉시 503을 반환
func handler(w http.ResponseWriter, r *http.Request) {
payload := make([]byte, 1024)
select {
case q <- payload:
w.WriteHeader(http.StatusAccepted)
default:
http.Error(w, "queue full", http.StatusServiceUnavailable)
}
}
5) 진단 1: SIGQUIT로 goroutine dump 떠서 채널 대기 확인
프로세스가 살아있는데 멈춘 느낌이면, 가장 먼저 “어디서 블로킹 중인지”를 봐야 합니다.
리눅스에서 Go 프로세스에 SIGQUIT를 보내면 표준 에러로 모든 고루틴 스택이 출력됩니다.
kill -QUIT `pidof your-binary`
덤프에서 자주 보이는 패턴:
chan send또는chan receive로 멈춤select에서 한 케이스만 영원히 대기sync.Mutex.Lock대기와 채널 대기가 섞여 교착
운영 환경에서 systemd로 띄웠다면, 덤프가 저널에 쌓이므로 장애 시점의 단서가 됩니다. systemd 환경에서 재시작/로그 수집을 다루는 글인 systemd 서비스 재시작 반복? ExecStart 디버깅도 함께 참고하면 “장애 시점에 덤프를 남기는” 운영 루틴을 만들기 좋습니다.
6) 진단 2: net/http/pprof로 goroutine 프로파일 보기
goroutine leak은 “시간이 지날수록 goroutine 수가 증가”하는 형태로 나타납니다. pprof의 goroutine 프로파일은 누수의 1차 스모킹 건입니다.
pprof 노출
import (
_ "net/http/pprof"
"net/http"
)
func main() {
go func() {
_ = http.ListenAndServe("127.0.0.1:6060", nil)
}()
// ... your server
}
확인 커맨드
curl -s http://127.0.0.1:6060/debug/pprof/goroutine?debug=2 | head
여기서 chan send로 대기 중인 스택이 반복적으로 쌓이면 “수신자가 사라진 send” 가능성이 큽니다.
또는 pprof를 파일로 받아서 비교합니다.
go tool pprof -http=:0 http://127.0.0.1:6060/debug/pprof/goroutine
- 같은 함수 스택이 수백/수천 개면 누수
select에서 컨텍스트 케이스가 없는 send/receive가 반복되면 설계 문제
7) 진단 3: runtime/trace로 “누가 누구를 막는지” 시간축으로 보기
pprof가 “어디서 멈췄는지”를 보여준다면, trace는 “언제부터 막혔는지”와 스케줄링 상태를 보여줍니다.
trace 수집 예시
package main
import (
"os"
"runtime/trace"
"time"
)
func main() {
f, _ := os.Create("trace.out")
defer f.Close()
_ = trace.Start(f)
defer trace.Stop()
// 문제 재현 구간 실행
time.Sleep(2 * time.Second)
}
분석
go tool trace trace.out
Goroutines 뷰에서 특정 고루틴이 chan send로 장시간 블로킹되는지, runnable 상태가 되지 못하는지 확인합니다. 특히 “버퍼가 찬 채널”은 초반엔 정상처럼 보이다가 어느 순간부터 send가 길게 늘어지는 타이밍이 드러납니다.
8) 테스트로 데드락을 “빨리” 터뜨리는 방법
부분 데드락은 CI에서 놓치기 쉽습니다. 다음 장치를 넣으면 재현과 탐지가 쉬워집니다.
8.1 테스트 타임아웃 강제
go test ./... -run TestSomething -count=1 -timeout 3s
8.2 goroutine 수 가드(간이 누수 체크)
package leaktest
import (
"runtime"
"testing"
"time"
)
func TestNoLeak(t *testing.T) {
before := runtime.NumGoroutine()
// 테스트 대상 호출
time.Sleep(200 * time.Millisecond) // 정리 시간
after := runtime.NumGoroutine()
if after > before+5 {
t.Fatalf("goroutine leak suspected: before=%d after=%d", before, after)
}
}
정교하진 않지만, 채널/컨텍스트 실수로 고루틴이 쌓이는 류는 초기에 잡히는 경우가 많습니다.
9) 실전 체크리스트: 채널 데드락을 줄이는 설계 규칙
9.1 채널 소유권을 문서화
- 누가
close하는가 - 몇 명이 send 하는가
- 소비자는
range로 끝나는가, 별도 종료 신호가 있는가
규칙을 코드로 강제하려면 “송신 전용/수신 전용” 타입으로 인자를 제한합니다.
func producer(out chan<- int) { /* send only */ }
func consumer(in <-chan int) { /* recv only */ }
9.2 모든 블로킹 지점에 취소 경로 제공
select { case ch <- v: case <-ctx.Done(): }select { case v := <-ch: case <-ctx.Done(): }
그리고 “취소를 확인하는 코드가 send/receive 뒤에 있는지”를 항상 의심합니다.
9.3 버퍼 크기는 성능 튜닝 값이 아니라 “정책”
버퍼는 임시 완충일 뿐, 소비가 지속적으로 느리면 언젠가 찹니다.
- 꽉 찼을 때 드롭할지
- 호출자에게 에러로 돌려줄지
- 디스크/외부 큐로 넘길지
이 정책이 없으면, 언젠가 handler가 채널에서 멈춥니다.
9.4 fan-in, fan-out은 패턴대로 구현
여러 입력을 하나로 합치거나, 하나를 여러 소비자에게 뿌릴 때는 “검증된 패턴”을 쓰는 게 안전합니다. 임기응변으로 select를 덕지덕지 붙이면 close 타이밍이 어긋나기 쉽습니다.
9.5 장애는 앱 내부만의 문제가 아니다
운영 환경에서는 종료 시그널 처리, sidecar, 프록시, 로드밸런서와 맞물리며 “취소가 제때 전달되지 않아” 고루틴이 남는 경우도 많습니다. 예를 들어 종료 순서가 꼬이면 요청이 끊기지 않고 애매하게 남아 블로킹이 길어질 수 있습니다. 쿠버네티스 환경이라면 Kubernetes 사이드카 종료 순서 버그 해결 가이드처럼 종료 시나리오 자체를 점검하는 것도 중요합니다.
10) 결론: 데드락은 “재현 가능한 형태”로 만들면 잡힌다
채널 데드락과 goroutine leak은 운이 나쁘면 “가끔”만 터져서 미궁에 빠지지만, 패턴은 반복적입니다.
- 수신자 없는 send
- close 책임 불명확
- 버퍼 채널이 꽉 찼을 때 정책 부재
- 블로킹 연산에 취소 경로 없음
이 네 가지를 재현 코드로 축소하고, SIGQUIT 덤프와 pprof goroutine 프로파일, runtime/trace를 조합하면 대부분의 누수는 원인 함수까지 좁혀집니다.
다음 액션으로는,
- 장애 시그널에서
SIGQUIT덤프를 남기는 운영 런북 만들기 pprof를 안전하게 노출(내부망 또는 인증)하고 정기 스냅샷 비교- 채널 소유권/close 정책을 팀 규칙으로 문서화
를 추천합니다. 이런 기본기가 쌓이면 “멈춘 Go 서비스”를 더 이상 감으로 디버깅하지 않게 됩니다.