- Published on
Go 채널 데드락 재현·진단 - trace·race 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Go에서 채널은 동시성 코드를 단순하게 만들지만, “영원히 기다리는” 형태의 데드락(deadlock)도 아주 쉽게 만들어냅니다. 문제는 데드락이 재현되기 전까지는 조용히 잠복하고, 재현된 뒤에도 원인이 select/버퍼/고루틴 수명/락과 섞여 있으면 추적이 까다롭다는 점입니다.
이 글에서는 다음 흐름으로 접근합니다.
- 데드락을 의도적으로 재현하는 최소 예제들
go test -race로 데이터 레이스를 먼저 제거/확인runtime/trace로 블로킹 원인(채널 송수신, 뮤텍스, 스케줄링) 을 시각적으로 확인- 실무에서 자주 쓰는 타임아웃, 컨텍스트 취소, 워커 종료 프로토콜 패턴
동시성 이슈를 “관측 가능하게 만들기”라는 관점은 장애 원인 추적 전반에 유효합니다. 비슷한 접근으로 리눅스에서 프로세스가 죽는 원인을 추적하는 글도 참고할 만합니다: 리눅스 OOM Killer로 프로세스 죽음 원인 추적
1) 데드락의 전형: 무버퍼 채널에서 서로 기다리기
무버퍼 채널은 송신과 수신이 동시에 만나야 진행됩니다. 이 특성을 잘못 사용하면 “서로가 서로를 기다리는” 데드락이 생깁니다.
아래 코드는 고루틴이 채널로 값을 보내지만, 메인 고루틴이 수신을 하지 않아서 영원히 블로킹됩니다.
package main
import "fmt"
func main() {
ch := make(chan int) // unbuffered
go func() {
ch <- 1 // blocks forever
fmt.Println("sent")
}()
// receive is missing
fmt.Println("done")
}
실행하면 프로그램은 종료될 수도 있고(메인이 먼저 끝나면), 다른 형태로는 fatal error: all goroutines are asleep - deadlock! 를 만들 수도 있습니다. “모든 고루틴이 블로킹 상태”이고 진행할 주체가 없을 때 런타임이 데드락으로 판단합니다.
재현을 확실히 만드는 버전
메인도 채널 수신을 기다리게 만들면 런타임 데드락 메시지를 안정적으로 재현할 수 있습니다.
package main
func main() {
ch := make(chan int)
go func() {
// 서로 반대 방향으로 기다리게 만들기
<-ch
}()
// main도 수신을 기다림
<-ch
}
2) select가 있어도 데드락이 나는 이유
많은 개발자가 select를 쓰면 안전하다고 느끼지만, default가 없고 모든 케이스가 블로킹이면 select 자체가 블로킹됩니다.
package main
func main() {
ch := make(chan int)
select {
case v := <-ch:
_ = v
}
}
이 코드는 단일 고루틴에서 영원히 기다립니다. 즉, select는 “여러 채널 중 준비된 것을 고르는 문”일 뿐, 준비된 것이 없다면 기다립니다.
타임아웃을 넣어 데드락을 장애로 승격시키기
실무에서는 “영원히 기다림”을 “관측 가능한 에러”로 바꾸는 게 중요합니다.
package main
import (
"errors"
"time"
)
func recvWithTimeout(ch <-chan int, d time.Duration) (int, error) {
select {
case v := <-ch:
return v, nil
case <-time.After(d):
return 0, errors.New("recv timeout")
}
}
time.After는 간단하지만, 대량 호출 시 타이머 객체가 많이 생길 수 있습니다. 고빈도 경로라면 time.NewTimer를 재사용하거나, 상위에서 context.Context로 타임아웃을 관리하는 편이 낫습니다.
3) “닫힌 채널”과 “송신자 종료” 프로토콜이 없을 때
채널 기반 워커/파이프라인에서 흔한 데드락은 수신자가 range로 끝나길 기다리는데, 송신자가 채널을 닫지 않는 케이스입니다.
package main
import "fmt"
func main() {
jobs := make(chan int)
go func() {
for i := 0; i < 3; i++ {
jobs <- i
}
// close(jobs) 가 없으면 수신자는 영원히 range에서 대기
}()
for j := range jobs {
fmt.Println(j)
}
}
올바른 종료 신호: close는 “송신자”가 한다
package main
import "fmt"
func main() {
jobs := make(chan int)
go func() {
defer close(jobs)
for i := 0; i < 3; i++ {
jobs <- i
}
}()
for j := range jobs {
fmt.Println(j)
}
}
규칙은 단순합니다.
- 채널을 닫는 주체는 마지막 송신자
- 수신자는
for v := range ch로 종료를 감지 - 여러 송신자가 있다면
WaitGroup으로 “마지막 송신자”를 결정하고 그때 닫기
4) go test -race로 “데드락 유사 증상”을 먼저 걷어내기
현장에서 데드락처럼 보이는 현상 중 일부는 사실 데이터 레이스로 인해 상태가 꼬여서 발생합니다. 예를 들어 종료 플래그가 경쟁 상태로 업데이트되면서 워커가 종료하지 않거나, 채널을 닫는 조건이 깨지는 식입니다.
레이스 디텍터는 테스트/벤치마크 실행 시점에 경쟁 접근을 잡아줍니다.
go test ./... -race -count=1
-count=1은 캐시된 테스트 결과를 피해서 재현성을 높입니다.- CI에서도
-race를 별도 잡으로 돌리면 동시성 버그를 조기에 잡는 데 효과가 큽니다.
레이스 디텍터가 잡아주는 건 “데드락” 자체라기보다 데드락으로 이어질 수 있는 잘못된 공유 상태입니다. 즉, trace로 들어가기 전에 race를 먼저 제거하면 분석 난이도가 확 내려갑니다.
5) runtime/trace로 채널 블로킹을 “눈으로” 보기
데드락/지연의 핵심은 “어디서 블로킹되는가”입니다. pprof가 CPU/메모리 중심이라면, runtime/trace는 고루틴 스케줄링, 네트워크, syscall, 채널 송수신 블로킹을 타임라인으로 보여줍니다.
최소 트레이스 수집 코드
package main
import (
"os"
"runtime/trace"
"time"
)
func main() {
f, err := os.Create("trace.out")
if err != nil {
panic(err)
}
defer f.Close()
if err := trace.Start(f); err != nil {
panic(err)
}
defer trace.Stop()
ch := make(chan int)
go func() {
time.Sleep(50 * time.Millisecond)
ch <- 1
}()
<-ch
}
실행 후 아래 명령으로 확인합니다.
go run .
go tool trace trace.out
브라우저 UI에서 다음을 집중적으로 봅니다.
- Goroutine analysis: 어떤 고루틴이 오래 대기하는지
- Network blocking / Syscall blocking: 외부 요인인지
- User regions / Tasks: 구간을 나눠서 추적할 수 있는지
트레이스에 “업무 단위” 이름 붙이기
트레이스가 유용해지려면, 고루틴이 무엇을 하다 막혔는지 맥락이 필요합니다. trace.WithRegion을 쓰면 특정 구간을 라벨링할 수 있습니다.
package main
import (
"context"
"os"
"runtime/trace"
"time"
)
func main() {
f, _ := os.Create("trace.out")
defer f.Close()
_ = trace.Start(f)
defer trace.Stop()
ctx := context.Background()
ch := make(chan int)
go func() {
trace.WithRegion(ctx, "worker-send", func() {
time.Sleep(100 * time.Millisecond)
ch <- 1
})
}()
trace.WithRegion(ctx, "main-recv", func() {
<-ch
})
}
이렇게 해두면, “채널 송신이 막혔다”를 넘어서 “어느 워커의 어떤 구간에서 막혔다”로 좁힐 수 있습니다.
6) 데드락을 만드는 실전 패턴 3가지와 해결책
패턴 A: 워커가 에러를 보내는데 수신자가 먼저 종료
errCh := make(chan error) // unbuffered
for i := 0; i < n; i++ {
go func() {
// ...
errCh <- someErr // 수신자가 없으면 여기서 블로킹
}()
}
return nil // 호출자가 먼저 리턴해버리면 워커는 영원히 대기
해결책은 상황에 따라 다릅니다.
errCh를 버퍼드로 만들어 “최소 1개”는 흡수context로 취소를 전파해서 워커가 송신을 포기- 에러 수집 전담 고루틴을 두고, 종료 시점에 drain
버퍼드 채널로 완화하는 가장 단순한 형태는 아래처럼 “워커 수만큼” 버퍼를 주는 것입니다.
errCh := make(chan error, n)
패턴 B: WaitGroup과 채널 close 순서 꼬임
여러 생산자가 있는 경우, close(ch) 타이밍을 잘못 잡으면 데드락 또는 패닉으로 갑니다.
정석은 “생산자들을 WaitGroup으로 기다린 뒤 close”입니다.
var wg sync.WaitGroup
out := make(chan int)
for i := 0; i < n; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
// produce
out <- id
}(i)
}
go func() {
wg.Wait()
close(out)
}()
for v := range out {
_ = v
}
패턴 C: select로 빠져나가지만 상대는 송신을 계속함
수신자 쪽에서 타임아웃/취소로 빠져나가면, 송신자는 계속 보내다 막힐 수 있습니다. 이때는 “취소 신호를 송신자도 보게” 만들어야 합니다.
func producer(ctx context.Context, out chan<- int) {
defer close(out)
for i := 0; i < 1000; i++ {
select {
case <-ctx.Done():
return
case out <- i:
}
}
}
이 패턴이 중요한 이유는 데드락이 “채널”이 아니라 “수명 관리”에서 시작되는 경우가 많기 때문입니다.
7) 진단 체크리스트: trace로 무엇을 확인할까
데드락/정체를 재현했다면, 아래 순서로 범위를 좁히는 것이 효율적입니다.
- 블로킹 타입: 채널 송신 대기인지, 채널 수신 대기인지, 뮤텍스 대기인지
- 누가 마지막으로 진행했나: 해당 채널의 반대편(송신자/수신자)이 살아 있는지
- 채널 버퍼 상태: 버퍼가 가득 찼는지, 비었는지
- 고루틴 누수 여부: 취소 후에도 남아 있는 고루틴이 있는지
- 종료 프로토콜:
close주체,WaitGroup순서,context전파가 일관적인지
이 과정은 인프라에서 상태가 꼬여 무한 반복이 일어나는 문제를 끊어내는 접근과도 닮았습니다. 재현 후 관측 지점을 늘리고, 원인을 한 단계씩 제거합니다: Terraform EKS 상태 꼬임으로 apply 무한 반복 끊기
8) 마무리: “재현 가능한 데드락”을 “관측 가능한 이벤트”로
Go 채널 데드락을 잘 잡는 팀은 보통 다음을 습관처럼 합니다.
- 데드락을 숨기지 말고 타임아웃/취소로 에러화한다
- 동시성 버그 분석 전
go test -race로 경쟁 상태를 제거한다 runtime/trace로 블로킹 지점을 시각화하고,trace.WithRegion으로 업무 단위를 라벨링한다- 채널 설계 시 “누가 닫는가”, “언제 종료되는가”를 코드 구조로 강제한다
채널은 강력하지만, “안전장치 없는 무한 대기”를 만들기 쉽습니다. 이번 글의 예제처럼 작게 재현하고, race로 정리하고, trace로 확인하는 루틴을 갖추면 데드락은 더 이상 운에 맡기는 문제가 아니라 디버깅 가능한 문제가 됩니다.