- Published on
Go 채널 데드락 10분 디버깅 - select+context
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 워커를 Go로 만들다 보면, 어느 순간부터 요청이 멈추고 CPU는 조용한데 고루틴 수만 증가하는 상황을 만나게 됩니다. 로그도 멈추고, pprof를 떠도 특정 함수에서 모두 대기 상태로 보이며, 결국 "채널 데드락"로 귀결됩니다.
이 글은 "10분 디버깅"을 목표로 합니다. 데드락을 재현 가능한 최소 코드로 줄이고, 스택/메트릭으로 원인을 특정한 다음, select + context로 안전한 종료 경로를 추가해 문제를 끝내는 흐름입니다.
운영에서 장애를 빠르게 좁히는 방식은 다른 글의 진단 루틴과도 닮아 있습니다. 예를 들어 K8s CrashLoopBackOff 원인 10분 진단법처럼, "증상 수집 → 재현/가설 → 최소 수정"의 순서를 갖추면 Go 데드락도 빠르게 끝낼 수 있습니다.
1) 데드락이란: "진짜" 데드락과 "무한 대기"를 구분
Go에서 흔히 말하는 데드락은 두 가지로 나뉩니다.
- 런타임이 감지하는 데드락:
fatal error: all goroutines are asleep - deadlock!- 메인 고루틴 포함 모든 고루틴이 더 이상 진행할 수 없을 때 발생
- 운영에서 더 흔한 형태: 런타임 에러 없이 특정 고루틴들이 영원히 블로킹
- 예: 수신자가 없는 채널 send, 닫히지 않는 채널 recv, 락이 영원히 풀리지 않음
실무에서 골치 아픈 건 두 번째입니다. "서비스는 살아있는데 특정 요청만 영원히 대기" 같은 형태로 나타납니다.
2) 10분 디버깅 체크리스트
2.1 1분: 증상 수집(고루틴 스택 덤프)
가장 먼저 고루틴 덤프를 확보합니다.
- 로컬/컨테이너에서 즉시:
kill -QUIT <pid>
stderr로 모든 고루틴 스택이 출력됩니다. 여기서 핵심은 다음을 찾는 겁니다.
chan send또는chan receive에서 멈춘 고루틴이 다수인가- 같은 함수/라인에서 반복적으로 멈추는가
select가 없는 단일 채널 대기(<-ch)가 긴 경로에 있는가
운영 서비스라면 net/http/pprof를 켜두는 게 좋습니다.
import _ "net/http/pprof"
go func() {
_ = http.ListenAndServe("127.0.0.1:6060", nil)
}()
그리고:
go tool pprof -http=:0 http://127.0.0.1:6060/debug/pprof/goroutine?debug=2
2.2 3분: "누가 보내고 누가 받는가"를 그래프로 그리기
채널 문제는 대부분 "수신자 부재" 또는 "종료 신호 부재"입니다.
- 어떤 고루틴이
ch <- x를 호출하는가 - 그
ch를 읽는 고루틴은 언제 시작되고, 언제 종료되는가 - 종료 시점에 채널을
close하는 주체는 누구인가
여기서 자주 터지는 패턴이 "워커가 조용히 죽었는데 생산자는 계속 send" 입니다.
2.3 3분: 재현 가능한 최소 코드로 축소
재현이 되면 해결은 거의 끝입니다. 다음과 같은 최소 재현을 만듭니다.
- 버퍼 0인 채널(언버퍼드)로 시작
- 워커를 일부러 종료시키거나, 수신을 일부러 늦춤
WaitGroup이나range ch로 종료를 걸어둠
2.4 3분: 해결 방향 결정
해결은 보통 아래 중 하나입니다.
- 채널 송수신에 타임아웃/취소 경로 추가(
select+context) - 종료 시그널을 명확히 하고, 채널
close책임을 한 곳으로 모으기 - 생산자/소비자 속도 불일치면 버퍼/백프레셔 설계(드롭, 큐잉, 제한)
이 글의 핵심은 첫 번째: select + context 입니다.
3) 문제 예시: 수신자가 사라져 send가 영원히 대기
아래 코드는 단순하지만 실무에서 자주 보이는 형태입니다.
producer는 계속 작업을 만들고jobs채널에 넣음worker는 어떤 이유로 종료됨(에러, return, 패닉 복구 등)- 그 순간부터
producer는jobs <- job에서 영원히 멈춤
package main
import (
"fmt"
"time"
)
type Job struct{ ID int }
func worker(jobs <-chan Job) {
for j := range jobs {
fmt.Println("work", j.ID)
if j.ID == 3 {
// 버그: 특정 조건에서 워커가 조용히 종료
return
}
time.Sleep(50 * time.Millisecond)
}
}
func producer(jobs chan<- Job) {
for i := 1; ; i++ {
jobs <- Job{ID: i} // 워커가 사라지면 여기서 영원히 블로킹
time.Sleep(10 * time.Millisecond)
}
}
func main() {
jobs := make(chan Job)
go worker(jobs)
producer(jobs)
}
이 코드는 런타임이 즉시 데드락을 띄우지 않을 수도 있습니다(상황에 따라 다름). 하지만 핵심은 동일합니다. send가 빠져나올 길이 없습니다.
4) 해결 1: select + context로 "빠져나올 구멍" 만들기
고루틴이 채널에서 영원히 기다리는 걸 막으려면, 대기 지점에 취소 가능성을 주입해야 합니다.
ctx.Done()이 닫히면 즉시 종료- 필요하면
time.After로 타임아웃도 추가
4.1 producer에 context 적용
package main
import (
"context"
"errors"
"fmt"
"time"
)
type Job struct{ ID int }
func worker(ctx context.Context, jobs <-chan Job) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case j, ok := <-jobs:
if !ok {
return nil
}
fmt.Println("work", j.ID)
if j.ID == 3 {
return errors.New("worker stopped unexpectedly")
}
time.Sleep(50 * time.Millisecond)
}
}
}
func producer(ctx context.Context, jobs chan<- Job) error {
for i := 1; ; i++ {
select {
case <-ctx.Done():
return ctx.Err()
case jobs <- Job{ID: i}:
// 정상 전송
}
time.Sleep(10 * time.Millisecond)
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
jobs := make(chan Job)
// 워커가 죽으면 cancel로 전체를 종료시키는 방향
go func() {
if err := worker(ctx, jobs); err != nil {
fmt.Println("worker error:", err)
cancel()
}
// close 책임은 한 곳에서만
close(jobs)
}()
if err := producer(ctx, jobs); err != nil {
fmt.Println("producer exit:", err)
}
}
포인트는 두 가지입니다.
jobs <- ...를 단독으로 호출하지 않고select로 감싼다
- 이제 워커가 사라져도, 외부에서
cancel()만 호출되면 producer는 즉시 빠져나옵니다.
- 채널
close책임을 "단일 주체"로 둔다
- 여러 고루틴이
close하면 패닉이 날 수 있습니다. - 보통은 "생산자가 닫는다" 또는 "오케스트레이터가 닫는다" 중 하나로 고정합니다.
4.2 타임아웃을 섞고 싶다면
context.WithTimeout을 쓰면 전체 작업의 데드라인을 강제할 수 있습니다.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
혹은 특정 send 구간만 타임아웃을 두고 싶다면:
select {
case <-ctx.Done():
return ctx.Err()
case jobs <- job:
// ok
case <-time.After(200 * time.Millisecond):
return errors.New("send timeout")
}
time.After는 빈번히 호출되면 타이머 할당 비용이 생길 수 있으니, 고빈도 루프라면 time.NewTimer 재사용을 고려합니다.
5) 해결 2: range ch를 쓸 때 종료 설계를 명확히
for v := range ch는 채널이 닫혀야 끝납니다. 즉, 종료 조건을 채널 close에 전적으로 의존합니다. 이게 깔끔할 때도 많지만, 다음 조건이면 위험합니다.
- close 책임자가 불명확함
- 에러/취소가 발생해도 close가 호출되지 않음
이럴 때는 range 대신 select 기반 루프가 더 안전합니다.
for {
select {
case <-ctx.Done():
return ctx.Err()
case v, ok := <-ch:
if !ok {
return nil
}
_ = v
}
}
6) 실무에서 자주 밟는 함정 5가지
6.1 nil 채널이 섞이면 select가 "영원히" 멈춘다
nil 채널은 send/recv 모두 영원히 블로킹합니다. select에서는 해당 case가 비활성화됩니다.
- 의도적으로
nil로 case를 끄는 패턴도 있지만 - 실수로
var ch chan T상태로 사용하면 디버깅이 매우 어렵습니다
6.2 버퍼 채널은 "시간차"로 문제를 숨긴다
버퍼가 있으면 당장은 send가 성공합니다. 하지만 소비가 멈추면 결국 버퍼가 차고 그때부터 send가 멈춥니다. 장애가 "몇 분 뒤"에 나타나는 이유가 됩니다.
6.3 default가 들어간 select는 바쁜 루프를 만든다
select {
case v := <-ch:
_ = v
default:
// 아무 것도 없으면 즉시 빠져나옴
}
이 패턴은 대기 대신 폴링을 하며 CPU를 태웁니다. 정말 필요하면 time.Sleep 또는 타이머 기반으로 조절해야 합니다.
6.4 컨텍스트를 만들고도 "전파"를 안 한다
context.Background()를 함수 안에서 새로 만들면 취소가 전파되지 않습니다. 상위에서 받은 ctx를 하위로 계속 전달하고, 블로킹 지점에서 ctx.Done()을 반드시 함께 기다리게 만들어야 합니다.
6.5 로그가 멈추는 문제는 채널만이 원인이 아니다
가끔은 로그/출력 자체가 블로킹의 원인이 되기도 합니다(예: 동기식 출력, 꽉 찬 버퍼, 느린 I/O). 디스크/파일 핸들이 원인일 수도 있는데, 이런 류의 "막힘"은 시스템 레벨에서도 자주 보입니다. 예를 들어 리눅스에서 파일이 삭제됐는데 프로세스가 열고 있으면 용량이 안 줄어드는 현상처럼, 리소스가 해제되지 않아 문제가 장기화됩니다. 관련해서는 리눅스 디스크 100%인데 용량이 안 줄 때 - deleted-but-open(lsof)도 함께 참고하면 좋습니다.
7) 디버깅을 더 빠르게: 재현 테스트와 레이스 탐지
채널 데드락은 테스트로 재현해두면 재발 방지에 큰 도움이 됩니다.
7.1 타임아웃 기반 테스트(데드락 방지)
func TestProducerDoesNotHang(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
jobs := make(chan Job)
// 소비자를 일부러 시작하지 않는다
err := producer(ctx, jobs)
if err == nil {
t.Fatal("expected timeout or cancellation")
}
}
핵심은 "테스트가 영원히 멈추지 않게" 테스트 자체에도 타임아웃을 거는 것입니다.
7.2 -race는 데드락의 직접 해결책은 아니지만 단서가 된다
go test -race ./...
채널 데드락과 함께 발생하는 공유 변수 경쟁(종료 플래그, 상태 머신)이 같이 잡히는 경우가 많습니다.
8) 결론: select + context는 "탈출로"를 표준화한다
Go의 채널은 강력하지만, "누군가 반드시 받는다" 또는 "언젠가 닫힌다" 같은 가정이 깨지는 순간, 고루틴은 조용히 멈춥니다. 그래서 실무에서는 다음을 팀 규칙처럼 가져가면 효과가 큽니다.
- 블로킹 가능 지점(
send,recv,Wait,Lock)에는ctx.Done()을 함께 기다린다 - 채널
close책임자는 한 곳으로 고정한다 range ch는 종료 설계가 명확할 때만 쓴다- 재현 테스트에 타임아웃을 걸어 "테스트 데드락"을 방지한다
이 패턴을 한 번 코드베이스에 정착시키면, 다음 번 "서비스가 조용히 멈춤" 류의 장애에서 원인 파악과 복구가 훨씬 빨라집니다. 운영에서 빠른 진단 루틴을 갖추는 관점에서는, 인프라 이슈를 다룬 EKS에서 Pod는 Running인데 503가 뜰 때 점검 같은 글의 접근과도 결이 같습니다: 결국 "대기"를 만드는 지점을 찾아, 빠져나오는 경로를 설계하는 것이 핵심입니다.