- Published on
Go 채널 select 데드락 5분 디버깅 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 워커를 Go로 짜다 보면 select를 넣었으니 “이제 블로킹은 없겠지”라고 생각하기 쉽습니다. 그런데 운영에서 갑자기 처리량이 0이 되고, CPU도 안 쓰고, 로그도 더 이상 안 찍히는 상태가 옵니다. 고루틴 덤프를 떠보면 여러 고루틴이 select 안에서 멈춰 있습니다.
이 글은 select 기반 코드에서 발생하는 데드락을 5분 안에 분류하고 고치는 흐름으로 정리합니다. 더 넓은 패턴(고루틴 leak 포함)은 아래 글도 함께 참고하면 좋습니다.
0분: 증상 정의부터 (진짜 데드락인가?)
Go에서 흔히 “데드락”이라고 부르는 증상은 크게 3가지로 나뉩니다.
- 런타임 패닉 데드락: 모든 고루틴이 블로킹이면
fatal error: all goroutines are asleep - deadlock! - 논리적 데드락: 일부 고루틴은 살아있지만, 중요한 경로가 영원히 기다려서 서비스가 멈춘 것처럼 보임
- 무한 대기/기아(starvation):
select가 특정 케이스만 계속 타서 다른 작업이 진행되지 않음
이번 글의 핵심은 2번과 3번을 빠르게 분류하는 것입니다.
1분: 고루틴 덤프를 가장 빨리 확보하는 방법
운영/스테이징에서 멈췄을 때 가장 먼저 필요한 건 “지금 어디서 기다리는지”입니다.
방법 A: pprof HTTP 엔드포인트
import (
"log"
"net/http"
_ "net/http/pprof"
)
func startPprof() {
go func() {
log.Println(http.ListenAndServe(":6060", nil))
}()
}
덤프 확인:
curl http://localhost:6060/debug/pprof/goroutine?debug=2
방법 B: SIGQUIT로 즉시 스택 덤프
리눅스에서 프로세스에 SIGQUIT를 보내면 Go 런타임이 고루틴 스택을 표준에러로 출력합니다.
kill -QUITpid``
여기서 pid처럼 꺾쇠를 쓰는 표기는 MDX에서 빌드 에러가 날 수 있으니 문서/런북에는 항상 백틱 처리하는 습관이 안전합니다.
2분: 덤프에서 select 데드락을 분류하는 5가지 체크포인트
덤프를 보면 대개 이런 형태가 반복됩니다.
select에서chan receiveselect에서chan sendsemacquire(뮤텍스/WaitGroup)IO wait(네트워크/파일)
select가 보인다면 아래 5가지를 우선순위로 봅니다.
체크 1) nil 채널이 섞였는가
Go에서 nil 채널에 대한 송수신은 영원히 블로킹합니다. select에 nil 채널 케이스가 들어가면, 그 케이스는 “절대 선택되지 않는” 상태가 됩니다. 의도적으로 케이스를 비활성화할 때 쓰기도 하지만, 실수로 nil이 들어오면 논리적 데드락의 단골 원인입니다.
문제 코드 예:
var stopCh chan struct{} // nil
func worker(jobs <-chan int) {
for {
select {
case j := <-jobs:
_ = j
case <-stopCh: // 영원히 대기
return
}
}
}
수정 방향:
stopCh는 반드시make로 초기화하거나context.Context로 종료를 표준화하거나- 케이스를 껐다 켤 거면
nil이 될 수 있음을 명시적으로 관리
예:
func worker(ctx context.Context, jobs <-chan int) {
for {
select {
case <-ctx.Done():
return
case j, ok := <-jobs:
if !ok {
return
}
_ = j
}
}
}
체크 2) 채널 close 규약이 깨졌는가 (특히 fan-in/fan-out)
select 기반 파이프라인에서 자주 생기는 문제는 “누가 채널을 닫는가”가 불명확해져서, 소비자는 영원히 기다리고 생산자는 종료해버리는 상황입니다.
안티패턴:
- 여러 producer가 같은 채널을 닫으려 함(패닉 위험)
- 아무도 채널을 닫지 않음(consumer가 종료 조건을 못 맞춤)
안전한 규약:
- 채널을 만드는 쪽이 닫는다
- producer가 여러 개면, 별도의
WaitGroup으로 producer 종료를 모아 단 한 곳에서 close
예:
func fanIn(ctx context.Context, ins ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
forward := func(ch <-chan int) {
defer wg.Done()
for v := range ch {
select {
case <-ctx.Done():
return
case out <- v:
}
}
}
wg.Add(len(ins))
for _, ch := range ins {
go forward(ch)
}
go func() {
wg.Wait()
close(out)
}()
return out
}
체크 3) default가 있어서 “진행”하는데 실제로는 멈췄는가
select에 default가 있으면 블로킹이 사라져서 “데드락이 아닌 것처럼” 보입니다. 하지만 이 경우는 바쁜 루프(busy loop) 또는 기아로 이어져 시스템이 멈춘 것처럼 보일 수 있습니다.
문제 코드 예:
for {
select {
case msg := <-ch:
handle(msg)
default:
// 아무 것도 없으면 그냥 계속 돈다
}
}
이 코드는 메시지가 없을 때 CPU를 태우며, 다른 고루틴이 스케줄링을 덜 받아 처리 지연이 커질 수 있습니다.
수정 방향:
- 정말 폴링이 필요하면
time.Ticker로 간격을 둔다 - 아니면
default를 제거하고 블로킹을 허용한다
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
for {
select {
case msg := <-ch:
handle(msg)
case <-ticker.C:
// 주기 작업
case <-ctx.Done():
return
}
}
체크 4) unbuffered 채널에서 송신자가 수신자를 영원히 못 만나는가
unbuffered 채널은 송신과 수신이 “동시에 만나야” 진행됩니다. select로 감싸도 본질은 바뀌지 않습니다.
대표 케이스:
- 수신 고루틴이 종료됐는데 송신이 계속됨
- 송신이 특정 조건에서만 발생하는데 수신은 그 조건을 기다림(서로 조건 의존)
빠른 진단:
- 덤프에서
chan send가 많이 쌓였는지 - 해당 채널의 consumer 고루틴이 살아있는지
해결 방향:
- 이벤트 성격이면 버퍼를 주고 드롭/백프레셔 정책을 명확히
- 종료 시에는 송신 루프도
ctx.Done()을 보게 만들기
예(백프레셔 + 종료):
func publish(ctx context.Context, out chan<- Event, ev Event) error {
select {
case <-ctx.Done():
return ctx.Err()
case out <- ev:
return nil
}
}
체크 5) 타임아웃이 “타임아웃처럼” 동작하지 않는가
time.After를 루프에서 매번 만들면 타이머 객체가 계속 생성됩니다. GC가 처리하긴 하지만, 고부하에서 지연/메모리 압박으로 이어져 “멈춘 것처럼” 보일 수 있습니다. 또한 타임아웃을 걸었는데도 종료 경로가 다른 락/채널에서 막히면 체감상 데드락이 됩니다.
권장:
- 반복 루프에서는
time.NewTimer재사용 - 타임아웃은 “관측/로그”가 아니라 “중단/정리”까지 이어져야 의미가 있음
예:
t := time.NewTimer(0)
if !t.Stop() {
<-t.C
}
defer t.Stop()
for {
t.Reset(2 * time.Second)
select {
case <-ctx.Done():
return
case v := <-ch:
_ = v
if !t.Stop() {
select {
case <-t.C:
default:
}
}
case <-t.C:
// 타임아웃 시: 로그만 찍지 말고 상태 전환/취소를 수행
// 예: cancel(), 재연결, 워커 재시작 등
}
}
3분: 재현 가능한 최소 코드로 축소하는 요령
5분 디버깅의 핵심은 “운영 증상”을 “로컬에서 재현되는 최소 사례”로 줄이는 겁니다.
- 문제 채널 1개만 남기기
- producer/consumer 고루틴 수를 1로 줄이기
- 종료 시나리오(취소, close, 에러)를 강제로 발생시키기
예를 들어 “종료 시 멈춘다”면 아래처럼 테스트에 종료를 강제합니다.
func TestShutdownDeadlock(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
jobs := make(chan int)
done := make(chan struct{})
go func() {
defer close(done)
worker(ctx, jobs)
}()
cancel() // 종료를 먼저 걸고
close(jobs) // 채널도 닫아보고
select {
case <-done:
case <-time.After(1 * time.Second):
t.Fatal("shutdown stuck")
}
}
이런 식으로 “종료 순서”를 바꿔가며 막히는 조합을 찾으면, 원인이 select 자체가 아니라 종료 규약에 있음을 빠르게 확인할 수 있습니다.
4분: 덤프에서 자주 보이는 패턴별 처방전
패턴 A) for range ch가 끝나지 않는다
- 원인: 채널이 닫히지 않음
- 처방: 채널 close 책임자를 단일화, 또는
ctx.Done()케이스 추가
for {
select {
case <-ctx.Done():
return
case v, ok := <-ch:
if !ok {
return
}
_ = v
}
}
패턴 B) select에 종료 케이스는 있는데 실제로는 못 탄다
- 원인: 종료 채널이
nil이거나, cancel이 호출되지 않거나, cancel은 됐는데 다른 곳에서 이미 막힘 - 처방: 종료 신호를
context로 통일하고, 모든 블로킹 지점이ctx.Done()을 보게 만들기
패턴 C) select로 여러 채널을 받는데 한쪽이 영원히 굶는다
- 원인: 특정 채널이 계속 ready라서 다른 케이스가 밀림(특히 버퍼 채널)
- 처방: 우선순위가 필요하면 2단
select로 설계하거나, 공정성에 의존하지 않기
우선순위 예:
select {
case hi := <-highPrio:
handleHi(hi)
continue
default:
}
select {
case hi := <-highPrio:
handleHi(hi)
case lo := <-lowPrio:
handleLo(lo)
case <-ctx.Done():
return
}
5분: 운영에서 다시 안 터지게 만드는 가드레일
1) 고루틴 수, 채널 길이, 처리율을 메트릭으로 고정
runtime.NumGoroutine()를 주기적으로 기록- 버퍼 채널이면
len(ch)를 관측(단, 경쟁 상태라 절대값은 참고용) - 처리율이 0으로 떨어질 때 알람
2) “종료는 컨트롤 플레인”으로 표준화
- 종료는
context.Context하나로 통일 - 채널 close는 데이터 플레인 규약으로만 사용
- 워커는
ctx.Done()을 모든 블로킹 경로에서 확인
3) 덤프/프로파일링 엔드포인트를 기본 포함
Kubernetes 환경이라면 장애 시 진단이 네트워크 이슈로도 섞여 보일 수 있습니다. 애플리케이션이 멈춘 것인지, 외부 의존성 타임아웃인지 같이 봐야 합니다. 네트워크 기반 “멈춤” 진단 흐름은 아래 글도 참고가 됩니다.
실전 체크리스트(요약)
- 덤프 확보:
pprof또는kill -QUITpid`` select데드락 1순위:nil채널, close 규약, unbuffered 송수신 불일치default는 데드락을 숨기고 기아/바쁜 루프를 만든다- 타임아웃은 타이머 재사용 + 타임아웃 후 상태 전환까지 포함
- 종료는
context로 통일하고, 모든 블로킹 지점에ctx.Done()을 연결
select는 만능이 아니라 “여러 블로킹 이벤트 중 하나를 고르는 도구”입니다. 데드락의 본질은 대개 select 바깥에 있는 종료·소유권·백프레셔 규약이 무너졌을 때 생깁니다. 위 5분 루틴대로 덤프를 보고 원인을 분류하면, 대부분은 코드 몇 줄로 재발을 막을 수 있습니다.