Go에서 channel 주변에서 진행이 멈춘다면, 문제의 중심은 channel primitive 자체보다 ownership, coordination, shutdown 동작에 있는 경우가 많습니다.
그래서 channel deadlock은 처음 보면 헷갈리기 쉽습니다. 코드 한 조각만 보면 멀쩡해 보이는데, 실제 런타임 경로에서는 sender, receiver, closer 중 하나가 조용히 사라져 전체 흐름이 멈추는 경우가 많기 때문입니다.
이 글은 실전 순서에 집중합니다.
- 막힌 지점이 send인지, receive인지, close coordination인지 구분하는 방법
- channel ownership을 어떻게 명시적으로 볼지
- worker와 fan-out 코드에서 자주 나오는 deadlock 패턴을 어떻게 고칠지
짧게 말하면 먼저 blocked send나 receive 지점을 찾고, 누가 send/receive/close를 책임지는지 적어본 뒤, 그 ownership 모델이 실제 shutdown 경로와 맞는지 비교해야 합니다.
더 넓은 Go 분기부터 다시 보고 싶다면 Golang 트러블슈팅 가이드로 가세요.
먼저 channel ownership부터 보기
가장 빠른 질문은 단순합니다. 누가 보내고, 누가 받고, 누가 닫는가입니다.
ownership이 명확해지면 deadlock은 훨씬 설명하기 쉬워집니다. 반대로 ownership이 흐리면, 같은 blocked line만 계속 들여다보다가 실제 문제인 구조적 책임 부재를 놓치기 쉽습니다.
하나의 channel에 대해 아래 네 가지를 답해 보세요.
- 누가 값을 보낸다
- 누가 값을 받는다
- 누가 channel을 닫는다
- 어떤 조건에서 루프가 끝난다
이 답변 중 하나라도 흐리면 deadlock 가능성이 크게 올라갑니다.
가장 자주 보는 deadlock 형태 3가지
1. receiver가 없는 send
보내는 쪽은 준비되어 있는데 받을 쪽이 이미 없어진 경우입니다.
ch := make(chan int)
ch <- 1
unbuffered channel에서는 다른 goroutine이 받아줄 때까지 block됩니다. receiver가 아예 시작되지 않거나, 먼저 종료되었거나, 다른 대기 상태에 빠져 있으면 send는 영원히 멈출 수 있습니다.
2. sender가 없는 receive
반대 방향도 자주 나옵니다.
ch := make(chan int)
value := <-ch
_ = value
ch에 값을 보내줄 sender가 없다면 receive는 영원히 block됩니다. 큰 시스템에서는 파이프라인의 앞단이 먼저 종료됐는데 뒷단은 여전히 기다리는 경우가 많습니다.
3. close ownership이 틀렸거나 불분명함
deadlock과 stuck loop는 channel을 아무도 닫지 않거나, 누군가가 나중에 닫아줄 것이라고 막연히 가정할 때도 자주 생깁니다.
보통 이런 패턴으로 보입니다.
- queue를
range하는 worker가 channel이 닫히지 않아 영원히 기다림 - sender가 completion signal 없이 먼저 return함
- 여러 sender가 있는데
close(ch)책임자가 명확하지 않음
이 문제는 단순 문법 문제가 아니라 ownership clarity가 없는 문제입니다.
실전 코드에서 자주 나오는 원인
1. early return이 receive 경로를 깨뜨림
에러 시 함수가 일찍 return하면서 원래 예정된 receive가 사라질 수 있습니다.
func run(ch <-chan int) error {
if err := check(); err != nil {
return err
}
value := <-ch
_ = value
return nil
}
다른 goroutine이 이 receive를 전제로 send하고 있다면, early return이 런타임 계약을 깨고 sender를 block 상태로 남길 수 있습니다.
2. queue가 닫히지 않아 worker가 영원히 대기함
worker pool 코드에서 자주 보이는 형태입니다.
for job := range jobs {
process(job)
}
이 루프는 누군가가 jobs를 확실히 닫아줄 때만 안전합니다. shutdown 중 close 경로가 빠지면 worker는 살아남고 서비스는 멈춘 것처럼 보이거나 goroutine leak으로 이어질 수 있습니다.
3. fan-out / fan-in coordination이 불완전함
동시성 파이프라인에서는 한 분기가 먼저 끝났는데 다른 분기는 여전히 send나 receive를 기대하는 경우가 많습니다. deadlock은 한 줄 코드보다, 기대한 coordination 모델과 실제 실행 경로가 어긋나는 데서 생기는 경우가 많습니다.
자주 보이는 단서는 아래와 같습니다.
- 하나의 channel을 여러 goroutine이 공유하는데 close owner가 없음
- error path에서 receive나 completion signal을 건너뜀
- 한 분기는 context cancellation로 끝났는데 다른 분기는 계속 기다림
실전 점검 순서
channel 관련 작업이 멈췄을 때는 아래 순서가 도움이 됩니다.
- blocked send 또는 receive 지점 찾기
- 어떤 goroutine이 어디서 기다리는지 stack trace로 확인
- sender, receiver, closer ownership 정리
- 정상 흐름과 error/shutdown 흐름 비교
- 해당 channel 주변 fan-out, fan-in, worker coordination 점검
이 순서가 잘 먹히는 이유는 deadlock이 한 줄의 문법 실수보다 lifecycle의 빠진 경로에서 생기는 경우가 많기 때문입니다.
blocked channel 때문에 goroutine 수도 같이 늘었다면 Goroutine leak 찾는 법과 같이 비교해 보세요.
조금 더 안전한 ownership 패턴
도움이 되는 규칙 중 하나는 close ownership을 명확하고 로컬하게 두는 것입니다.
예를 들어 하나의 producer가 모든 send를 책임진다면, 그 producer가 close(ch)도 함께 책임지는 편이 보통 더 이해하기 쉽습니다.
func produce(ch chan<- int) {
defer close(ch)
for i := 0; i < 10; i++ {
ch <- i
}
}
그러면 receiver는 더 안전하게 range할 수 있습니다.
func consume(ch <-chan int) {
for v := range ch {
_ = v
}
}
이 패턴이 모든 동시성 문제를 해결해 주는 것은 아니지만, lifecycle을 이해하기는 훨씬 쉬워집니다.
deadlock과 leak이 같이 보이는 이유
channel deadlock과 goroutine leak은 운영에서 자주 겹칩니다.
goroutine이 send나 receive에서 영원히 block되면, process가 끝나기 전까지는 사실상 leak처럼 남아 있기 때문입니다. 그래서 아래 증상이 같이 보일 수 있습니다.
- 작업이 더 이상 진행되지 않음
- goroutine 수가 계속 증가함
- shutdown이 예상보다 오래 걸림
- queue나 worker backlog가 커짐
빠르게 나누면 이렇게 볼 수 있습니다.
- 핵심 증상이 한 channel 경로가 멈춘 것이라면 channel deadlock부터
- 핵심 증상이 많은 goroutine이 blocked state로 누적되는 것이라면 goroutine leak도 바로 비교
FAQ
Q. buffered channel이면 deadlock이 안 생기나요?
아닙니다. buffer는 block을 늦출 뿐입니다. downstream이 더 이상 drain하지 않으면 buffer가 찬 뒤 결국 같은 문제가 생길 수 있습니다.
Q. channel은 누가 닫아야 하나요?
보통은 send를 책임지는 쪽이 close도 책임지는 편이 안전합니다. 특히 single producer 구조에서는 더 그렇습니다. 핵심은 ownership이 명시적이어야 한다는 점입니다.
Q. 운영에서 무엇부터 보는 게 가장 빠른가요?
막힌 stack부터 찾고, 그 정확한 channel에 대해 sender, receiver, closer ownership을 정리하는 것이 가장 빠릅니다.
Read Next
- Go 전체 분기부터 다시 보고 싶다면 Golang 트러블슈팅 가이드로 가세요.
- blocked channel 때문에 goroutine 수까지 늘었다면 Goroutine leak 찾는 법을 보세요.
- 멈춘 경로에 timeout 압박도 함께 보인다면 Golang context deadline exceeded도 같이 보세요.
Related Posts
Sources:
심사 대기 중에는 광고 대신 관련 가이드를 먼저 보여줍니다.
먼저 읽어볼 가이드
검색 유입이 많은 핵심 글부터 이어서 보세요.
- 미들웨어 트러블슈팅 가이드: Redis vs RabbitMQ vs Kafka 개발자를 위한 미들웨어 트러블슈팅 허브 글입니다. Redis, RabbitMQ, Kafka 중 어떤 증상부터 먼저 봐야 하는지와 어떤 문제 패턴이 각 시스템에 가까운지 정리합니다.
- Kubernetes CrashLoopBackOff: 먼저 볼 것들 startup failure, probe, config, resource limit 관점에서 CrashLoopBackOff를 어떻게 나눠서 봐야 하는지 정리한 가이드입니다.
- Kafka consumer lag가 계속 늘 때: 트러블슈팅 가이드 Kafka consumer lag가 계속 늘어날 때 무엇부터 봐야 하는지 정리합니다. poll 주기, 처리 속도, rebalance, consumer 설정까지 실전 기준으로 다룹니다.
- Kafka Rebalancing Too Often 가이드 Kafka consumer group에서 rebalance가 너무 자주 일어날 때 membership flapping, poll timing, protocol, assignment churn을 어떤 순서로 봐야 하는지 설명하는 실전 가이드입니다.
- Docker container가 계속 재시작될 때: 먼저 확인할 것들 exit code, command failure, environment mistake, health check 관점에서 Docker restart loop를 푸는 실전 가이드입니다.
심사 대기 중에는 광고 대신 관련 가이드를 먼저 보여줍니다.