- Published on
Go 채널 데드락 5패턴과 runtime 진단법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 간헐적으로 멈춤이 발생할 때, 로그는 더 이상 찍히지 않고 CPU도 높지 않다면 가장 먼저 의심해야 할 축이 goroutine 블로킹입니다. 특히 채널(chan)을 중심으로 설계한 파이프라인은 “정상일 때는 단순하고 안전해 보이지만”, 예외 경로 하나로 전체가 정지되는 경우가 흔합니다.
이 글에서는 Go 채널 데드락을 유발하는 대표 5가지 패턴을 코드로 재현하고, runtime 및 표준 도구(pprof/trace)를 통해 원인을 진단하는 방법을 정리합니다. 운영 환경에서의 관측 포인트와, 재발 방지용 설계 체크리스트까지 같이 다룹니다.
참고로 동시성/병렬화의 함정은 언어를 가리지 않습니다. Java 쪽 병렬 스트림에서의 경쟁조건/블로킹 이슈도 유사한 결을 가지므로 함께 보면 도움이 됩니다: Java Stream 병렬화 함정 - 성능·경쟁조건 7가지
데드락이란: Go에서의 의미
Go에서 흔히 말하는 “데드락”은 크게 두 종류로 나타납니다.
전역 데드락(프로그램이 더 이상 진행 불가)
- 모든
goroutine이 블로킹 상태에 빠져 실행 가능한 것이 없을 때 - 보통
fatal error: all goroutines are asleep - deadlock!메시지와 함께 패닉
- 모든
부분 데드락(서비스는 살아있지만 일부 경로가 영원히 대기)
- 특정 요청/작업만 멈추고, 프로세스는 계속 실행
- 운영에서는 이 케이스가 더 위험합니다(헬스체크는 통과, 요청은 타임아웃)
채널은 “동기화 + 데이터 전달”을 동시에 수행합니다. 이 장점이 곧 위험이기도 합니다. 송신/수신/종료(close) 규약이 조금만 어긋나도, 블로킹이 전파되어 파이프라인 전체가 멈춥니다.
패턴 1: 버퍼 없는 채널에서 송신자가 먼저 고립
가장 기본적인 케이스입니다. 버퍼 없는 채널은 송신과 수신이 동시에 만나야 진행됩니다.
package main
func main() {
ch := make(chan int) // unbuffered
ch <- 1 // 수신자가 없으므로 여기서 영원히 블로킹
}
실전에서의 변형
- 고루틴을 띄웠다고 믿었지만, 실제로는 조건문/에러로 인해 수신 루프가 시작되지 않음
- 워커 풀에서 워커가 모두 종료된 뒤에도 생산자가 계속 송신
예방 포인트
- 생산자/소비자 라이프사이클을 명확히(누가 언제 시작/종료하는지)
- “수신자가 없을 수 있는” 경로라면 버퍼를 두거나,
select에 타임아웃/취소를 넣기
패턴 2: 버퍼 채널이 가득 찼는데 소비자가 멈춘 경우
버퍼 채널은 “일정량까지는 비동기”지만, 결국 가득 차면 송신이 블로킹됩니다.
package main
import "time"
func main() {
ch := make(chan int, 2)
// 소비자(예: 어떤 이유로 조기 종료)
go func() {
_ = <-ch
return // 여기서 끝나버리면 이후 소비가 없음
}()
ch <- 1
ch <- 2
ch <- 3 // 버퍼가 꽉 차고 소비자가 없어서 블로킹
time.Sleep(1 * time.Second)
}
실전에서의 변형
- 소비자 고루틴이 패닉으로 죽었는데 상위에서 복구하지 않음
- 소비자가 외부 I/O에서 영원히 대기(네트워크/디스크)하면서 채널 drain이 멈춤
예방 포인트
- 소비자 고루틴은 “죽으면 안 되는 역할”이면 반드시 상위에서 재시작/에러 전파
- 버퍼 크기는 “평균 처리량”이 아니라 “최악의 지연/스파이크”를 기준으로 잡고, 초과 시 드롭/백프레셔 정책을 명시
패턴 3: range ch가 끝나지 않는 문제(채널 close 누락)
for v := range ch는 채널이 close되기 전까지 끝나지 않습니다. 생산자가 종료 신호를 주지 않으면 소비자는 영원히 대기합니다.
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
for i := 0; i < 3; i++ {
ch <- i
}
// close(ch) 를 빠뜨리면 소비자는 range에서 끝나지 않음
}()
for v := range ch {
fmt.Println(v)
}
}
실전에서의 변형
- fan-out/fan-in에서 생산자 여러 개 중 일부만 종료되고
close가 호출되지 않음 - 에러 경로에서
close가 실행되지 않음(정상 경로에만 존재)
예방 포인트
- “누가 close를 책임지는가”를 규약으로 박기
- 일반적으로는 송신자(생산자) 측에서만 close
- 생산자가 여러 개면
sync.WaitGroup으로 모두 종료 후 단일 지점에서 close
예시(다중 생산자 종료 후 close):
package main
import (
"sync"
)
func main() {
ch := make(chan int)
var wg sync.WaitGroup
producers := 3
wg.Add(producers)
for p := 0; p < producers; p++ {
go func(base int) {
defer wg.Done()
for i := 0; i < 10; i++ {
ch <- base*100 + i
}
}(p)
}
go func() {
wg.Wait()
close(ch)
}()
for range ch {
// consume
}
}
패턴 4: select에서 nil 채널/잘못된 종료 조합으로 영구 대기
Go에서 nil 채널에 대한 송신/수신은 영원히 블로킹합니다. 이 특성은 상태 머신에서 “case 비활성화” 용도로도 쓰이지만, 실수하면 데드락으로 이어집니다.
package main
func main() {
var ch chan int // nil
select {
case ch <- 1:
// 절대 실행되지 않음
default:
// default가 있으면 빠져나가지만,
// default가 없으면 여기서 영원히 대기
}
}
실전에서의 변형
- 초기화 실패(설정 로딩 실패 등)로 채널이
nil인 채로 사용 - 종료 신호 채널(
done)을 잘못 전달해서 특정 워커만 영원히 대기 select에default를 넣어 “바쁜 루프(busy loop)”를 만들고, 결과적으로 소비가 따라가지 못해 다른 곳에서 교착
예방 포인트
- 채널을 구조체 필드로 들고 다니면 생성 시점에 무조건 초기화되도록 생성자 패턴 사용
nil채널을 의도적으로 쓰는 경우라도, 상태 전이를 테스트로 고정
패턴 5: 락과 채널을 섞어 잠금 순서 역전(숨은 데드락)
채널만으로도 데드락이 나지만, 더 까다로운 건 mutex와 채널을 섞을 때입니다. “락을 잡은 채로 채널 송신/수신”을 하면, 상대가 같은 락을 필요로 하는 순간 교착이 됩니다.
package main
import "sync"
type S struct {
mu sync.Mutex
ch chan int
}
func main() {
s := &S{ch: make(chan int)}
go func() {
s.mu.Lock()
defer s.mu.Unlock()
s.ch <- 1 // 수신자가 필요하지만, 수신자는 mu가 필요할 수도 있음
}()
// 수신자가 mu를 먼저 잡아야 한다면 교착
s.mu.Lock()
defer s.mu.Unlock()
<-s.ch
}
예방 포인트
- 원칙: 락을 잡은 상태로 블로킹 가능한 연산(채널, I/O,
WaitGroup.Wait)을 하지 말기 - 불가피하면 락 범위를 최소화하고, 데이터 복사 후 락 해제 뒤 채널로 전달
runtime 기반 진단: “어디서” 막혔는지 빠르게 찾기
부분 데드락은 패닉이 없기 때문에, 결국 “현재 모든 고루틴이 어디서 멈춰 있는가”를 봐야 합니다. Go는 이를 위한 도구가 매우 강력합니다.
1) SIGQUIT로 전체 goroutine 스택 덤프
리눅스/컨테이너 환경에서 프로세스에 SIGQUIT를 보내면, 표준 에러로 모든 고루틴 스택이 출력됩니다.
- 실행:
kill -QUIT <pid>를 백틱으로 감싸면kill -QUIT <pid>
스택에서 다음 키워드를 집중적으로 봅니다.
chan send/chan receiveselectsync.(*Mutex).Locksync.(*WaitGroup).Wait- 네트워크 I/O (
net.(*pollDesc).wait등)
이 덤프는 “지금 당장 멈춘 요청”의 원인을 찾는 데 가장 빠른 방법입니다.
2) runtime/pprof로 goroutine 프로파일 수집
서비스에 디버그 엔드포인트를 붙일 수 있다면, net/http/pprof가 가장 실용적입니다.
package main
import (
"log"
"net/http"
_ "net/http/pprof"
)
func main() {
go func() {
log.Println(http.ListenAndServe("127.0.0.1:6060", nil))
}()
select {} // 예시용
}
수집 예:
- goroutine 덤프:
curl http://127.0.0.1:6060/debug/pprof/goroutine?debug=2 - pprof 분석:
go tool pprof http://127.0.0.1:6060/debug/pprof/goroutine
여기서도 chan receive가 특정 함수에서 과도하게 쌓여 있으면, close 누락/소비자 중단/버퍼 포화 등을 의심할 수 있습니다.
운영에서 파드가 죽거나 재시작되는 상황이라면, 장애 진단 흐름 자체를 체계화해두는 게 중요합니다. 컨테이너 환경 트러블슈팅 관점은 다음 글도 참고할 만합니다: EKS Pod CrashLoopBackOff? OOMKilled 진단법
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
trace 뷰어에서 다음을 봅니다.
- Goroutine이
blocked on chan send/recv로 전환되는 지점 - 특정 고루틴이 장시간 runnable이 아닌 상태로 누적되는 패턴
- 네트워크 I/O나 GC 때문에 지연되는지, 순수 동기화 문제인지 구분
4) GODEBUG와 런타임 파라미터(보조 수단)
상황에 따라 다음도 도움됩니다.
GODEBUG=schedtrace=1000,scheddetail=1로 스케줄러 상태를 주기적으로 출력- (락 경합 의심 시) mutex 프로파일링을 켜서 경합 hotspot 확인
다만 운영에서 로그 폭발을 유발할 수 있으니, 재현 환경이나 제한된 기간에만 사용하세요.
데드락을 “설계로” 줄이는 체크리스트
채널 종료 규약
- close는 송신자만 한다
- 다중 송신자면 단일 조정자(aggregator)가
WaitGroup이후 close - 소비자는 close를 “종료 이벤트”로 해석하고 정상 종료한다
취소/타임아웃 기본 탑재
context.Context를 파이프라인 전반에 흘려 보내고, 채널 송수신도 취소 가능하게 만듭니다.
select {
case ch <- item:
// sent
case <-ctx.Done():
return ctx.Err()
}
이 패턴 하나만으로 “영원히 멈춤”이 “취소 가능한 대기”로 바뀝니다.
버퍼/백프레셔 정책 명시
- 버퍼는 임시 완충일 뿐, 무한 큐가 아니다
- 가득 찼을 때의 정책을 정한다: 드롭, 최신값 유지, 생산자 차단, 별도 디스크 큐 등
락과 블로킹 연산 분리
- 락은 데이터 일관성을 위한 최소 구간에만
- 채널/I/O/대기는 락 밖에서
마무리: 재현 + 스택 덤프가 가장 빠른 길
Go 채널 데드락은 “코드가 짧아서” 오히려 눈에 잘 안 띄는 경우가 많습니다. 따라서 접근 순서가 중요합니다.
- 멈춘 시점의 goroutine 스택을 확보한다(
SIGQUIT또는 pprof goroutine) - 스택에서
chan send/receive가 걸린 위치를 찾는다 - 해당 채널의 소유권(누가 send/close/receive 책임인지)과 종료 규약을 확인한다
- close 누락, 소비자 중단, 버퍼 포화,
nil채널, 락-채널 혼합 중 어떤 패턴인지 분류한다 context취소/타임아웃과 종료 규약을 설계에 반영해 재발을 줄인다
운영 장애는 대개 “예외 경로”에서 시작합니다. 정상 흐름만으로 채널 파이프라인을 증명하지 말고, 취소/에러/부분 실패를 포함한 종료 시나리오를 테스트로 고정해두면 데드락의 상당수를 사전에 제거할 수 있습니다.