- Published on
Go 채널 데드락 원인 7가지와 재현·해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 사이드 Go를 운영하다 보면 fatal error: all goroutines are asleep - deadlock!를 한 번쯤은 마주칩니다. 이 메시지는 단순히 “느리다”가 아니라, 런타임이 더 이상 진행할 수 없는 상태(모든 고루틴이 채널 송수신/락 등으로 블록)라고 판단했을 때 터집니다.
채널 데드락은 특징이 있습니다.
- 재현이 쉽지 않거나(경쟁 조건),
- 로컬에서는 안 나고 프로덕션에서만 나거나(스케줄링/부하),
- 원인이 “채널”이 아니라 “종료 신호/취소/팬인 구조”일 때가 많습니다.
이 글에서는 현장에서 자주 나오는 채널 데드락 7가지를 재현 코드로 고정해두고, 각각을 해결 패턴으로 정리합니다. 고루틴 누수(영원히 안 끝나는 고루틴)와도 경계가 겹치므로, 함께 읽으면 좋은 글로 Go 고루틴 누수 잡기 - channel close·context 실수도 참고하세요.
데드락을 빠르게 확인하는 방법
1) 런타임 스택 덤프로 “누가 어디서 막혔는지” 보기
데드락이 나면 보통 스택이 출력되지만, 서비스가 멈췄는데 크래시가 안 나는 유형도 있습니다. 그땐 신호로 스택을 받는 방식이 유용합니다.
package main
import (
"log"
"os"
"os/signal"
"runtime/pprof"
"syscall"
)
func installStackDump() {
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGUSR1)
go func() {
for range ch {
log.Println("=== goroutine dump ===")
_ = pprof.Lookup("goroutine").WriteTo(os.Stdout, 2)
}
}()
}
func main() {
installStackDump()
select {}
}
운영 환경에서 kill -USR1 pid로 고루틴 덤프를 떠서, 어떤 채널 송수신에서 블록인지 확인합니다.
2) “데드락”과 “고루틴 누수”를 구분하기
- 데드락: 프로그램 전체가 진행 불가(모든 고루틴이 블록)라면 런타임이 패닉을 내는 경우가 많습니다.
- 누수: 일부 고루틴만 영원히 대기하고 나머지는 돌아가므로, 패닉 없이 메모리/커넥션/CPU가 갉아먹힙니다.
둘 다 원인은 비슷합니다. 특히 채널 종료 설계가 핵심입니다.
원인 1) 버퍼 없는 채널에 “받는 쪽이 없음”
가장 기본이면서도 가장 흔합니다. 버퍼 없는 채널은 송신과 수신이 동시에 만나야 진행됩니다.
재현
package main
func main() {
ch := make(chan int) // unbuffered
ch <- 1 // 수신자가 없으니 여기서 영원히 블록
}
해결
- 수신 고루틴을 먼저 띄우거나
- 버퍼를 두거나
- 구조적으로 송신자가 “수신자 존재”를 보장하도록 설계합니다.
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
fmt.Println(<-ch)
}()
ch <- 1
}
원인 2) 버퍼 채널이 “가득 참” + 소비자가 멈춤
버퍼가 있으면 안전할 것 같지만, 소비자가 어떤 이유로 멈추면 결국 생산자가 막힙니다. 특히 로그/메트릭/이벤트 큐를 채널로 구현할 때 자주 터집니다.
재현
package main
func main() {
ch := make(chan int, 1)
ch <- 1
ch <- 2 // 버퍼가 1이라 여기서 블록
}
해결
- 소비자 고루틴을 항상 살아있게 만들고(패닉 방지),
- 드롭 정책(버퍼 초과 시 버리기)을 도입하거나,
- backpressure를 의도한 설계라면 생산자에 타임아웃/취소를 넣어 “영원히” 기다리지 않게 합니다.
package main
import (
"context"
"time"
)
func sendWithTimeout(ctx context.Context, ch chan int, v int) bool {
select {
case ch <- v:
return true
case <-time.After(50 * time.Millisecond):
return false
case <-ctx.Done():
return false
}
}
원인 3) range ch를 돌려놓고 close를 안 함 (종료 신호 누락)
for v := range ch는 채널이 닫힐 때까지 끝나지 않습니다. 생산자가 더 이상 값을 보내지 않는데도 close를 안 하면, 소비자는 영원히 대기합니다.
재현
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
ch <- 1
// close(ch) 가 없음
}()
for v := range ch {
fmt.Println(v)
}
// 여기로 절대 오지 않음
}
해결
- “누가 close를 책임지는가”를 명확히 합니다.
- 원칙: 송신자(생산자)만 close 합니다. (여러 송신자가 있으면 별도 조정 필요)
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
defer close(ch)
ch <- 1
}()
for v := range ch {
fmt.Println(v)
}
}
원인 4) 여러 송신자가 있는데 “누군가가 일찍 close”
이건 데드락뿐 아니라 패닉까지 유발합니다. 한 송신자가 채널을 닫은 뒤 다른 송신자가 보내면 send on closed channel 패닉이 납니다. 반대로 close를 피하려고 아무도 close를 안 하면 원인 3처럼 소비자가 영원히 대기합니다.
재현(문제 상황)
package main
import "sync"
func main() {
ch := make(chan int)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
ch <- 1
close(ch) // 다른 송신자가 아직 보낼 수 있음
}()
go func() {
defer wg.Done()
ch <- 2 // 타이밍에 따라 패닉
}()
wg.Wait()
}
해결: 송신자들을 WaitGroup으로 모으고 “단 한 곳에서 close”
package main
import (
"fmt"
"sync"
)
func main() {
ch := make(chan int)
var senders sync.WaitGroup
senders.Add(2)
go func() {
defer senders.Done()
ch <- 1
}()
go func() {
defer senders.Done()
ch <- 2
}()
go func() {
senders.Wait()
close(ch)
}()
for v := range ch {
fmt.Println(v)
}
}
이 패턴은 팬인(fan-in)에서 특히 중요합니다.
원인 5) select에서 한 케이스만 영원히 기다림 (취소/타임아웃 부재)
select는 “여러 이벤트 중 하나를 기다린다”는 점에서 안전장치처럼 보이지만, 대기할 이벤트가 영원히 오지 않으면 그대로 멈춥니다.
재현
package main
func main() {
ch := make(chan int)
select {
case v := <-ch:
_ = v
}
// 수신이 영원히 오지 않으면 여기로 못 옴
}
해결: context.Done() 또는 타임아웃을 함께 둠
package main
import (
"context"
"time"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
ch := make(chan int)
select {
case <-ch:
// ...
case <-ctx.Done():
// 취소/타임아웃으로 빠져나감
}
}
운영 코드에서는 “상대가 언젠가 보내겠지”가 아니라 “안 보낼 수도 있다”를 기본값으로 두는 편이 안전합니다. API 호출 백오프/큐잉처럼 결국 “기다림”을 다루는 문제는 다른 영역에서도 반복되는데, 설계 관점은 OpenAI 429 Rate Limit 해결 - 백오프·큐·배치 같은 글의 패턴과도 닮아 있습니다.
원인 6) 요청-응답 패턴에서 “응답 채널을 읽지 않음”
작업 큐에 요청을 넣고, 요청마다 reply chan을 붙이는 RPC 스타일은 흔합니다. 그런데 소비자가 어떤 분기에서 응답을 보내지 않거나, 반대로 호출자가 응답을 읽지 않으면 교착이 발생합니다.
재현: 워커가 응답을 보내는데 호출자가 안 읽음
package main
type Req struct {
N int
Reply chan int
}
func main() {
jobs := make(chan Req)
go func() {
for r := range jobs {
// 호출자가 Reply를 안 읽으면 여기서 블록
r.Reply <- r.N * 2
}
}()
req := Req{N: 21, Reply: make(chan int)}
jobs <- req
// _ = <-req.Reply // 이걸 안 하면 워커가 멈추고, 결국 전체가 막힐 수 있음
select {}
}
해결
- 호출자는 반드시 응답을 읽거나,
Reply를 버퍼 1로 만들어 “응답 1개”는 적어도 적재되게 하거나,context로 취소되면 워커가 응답을 포기하도록 설계합니다.
package main
import "context"
type Req struct {
N int
Reply chan int
Ctx context.Context
}
func worker(jobs <-chan Req) {
for r := range jobs {
select {
case r.Reply <- r.N * 2:
case <-r.Ctx.Done():
// 호출자가 취소했으면 응답 보내지 않고 종료
}
}
}
원인 7) 팬인(fan-in)에서 입력 채널 중 하나가 “영원히 안 닫힘”
여러 입력 채널을 하나로 합치는 fan-in 구현에서 흔한 실수는, 합쳐진 출력 채널을 닫기 위해 WaitGroup을 쓰면서도 입력 채널을 닫지 않거나 특정 생산자가 종료되지 않아 Wait가 영원히 끝나지 않는 경우입니다.
재현: 한 입력이 닫히지 않아 fan-in이 끝나지 않음
package main
import "sync"
func fanIn(out chan int, ins ...<-chan int) {
var wg sync.WaitGroup
wg.Add(len(ins))
for _, in := range ins {
in := in
go func() {
defer wg.Done()
for v := range in { // in이 안 닫히면 여기서 영원히 대기
out <- v
}
}()
}
go func() {
wg.Wait()
close(out)
}()
}
func main() {
out := make(chan int)
in1 := make(chan int)
in2 := make(chan int)
fanIn(out, in1, in2)
go func() {
in1 <- 1
close(in1)
}()
go func() {
in2 <- 2
// close(in2) 가 없음
}()
for range out {
// out이 안 닫혀서 끝나지 않음
}
}
해결: 입력 채널의 생명주기를 context로 끊거나, 생산자 종료를 강제
fan-in의 핵심은 “입력 종료 조건”을 채널 close에만 의존하지 말고, 취소 신호를 같이 두는 것입니다.
package main
import (
"context"
"sync"
)
func fanInCtx(ctx context.Context, out chan int, ins ...<-chan int) {
var wg sync.WaitGroup
wg.Add(len(ins))
for _, in := range ins {
in := in
go func() {
defer wg.Done()
for {
select {
case v, ok := <-in:
if !ok {
return
}
select {
case out <- v:
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
}()
}
go func() {
wg.Wait()
close(out)
}()
}
이렇게 하면 “어떤 입력이 영원히 닫히지 않는다”는 최악의 조건에서도 시스템을 종료/회수할 수 있습니다.
실전 체크리스트: 데드락을 줄이는 채널 설계 규칙
1) close 책임자를 문서로 고정
- 단일 생산자면 생산자가
defer close(ch) - 다중 생산자면
WaitGroup으로 생산자 종료를 모으고 “한 곳에서만 close”
2) range는 종료 조건이 명확할 때만
range ch를 쓰는 순간 종료는 close에 종속됩니다. 종료 조건이 복잡하면 select에 ctx.Done()을 끼워 넣는 편이 낫습니다.
3) “영원히 기다림”을 기본값으로 두지 않기
- I/O, 워커 큐, 외부 시스템 연동은 반드시 타임아웃/취소를 둡니다.
- 특히 요청-응답(
Reply chan) 패턴은 응답 누락이 즉시 교착으로 연결됩니다.
4) 버퍼는 만능이 아니라 “정책”
버퍼 크기는 안전장치가 아니라 트래픽/지연/드롭 정책의 일부입니다. 버퍼가 가득 찼을 때 무엇을 할지(블록, 드롭, 백오프)를 결정해야 합니다.
5) 데드락을 “DB 데드락”처럼 다루기
채널 데드락도 결국 자원(송수신 슬롯)을 서로 기다리는 문제입니다. 원인 분석과 재현, 관측(스택/프로파일링), 그리고 락 순서/종료 조건을 정하는 접근은 DB 데드락 분석과 닮아 있습니다. 트랜잭션 데드락 관점이 익숙하다면 MySQL·PostgreSQL 데드락 분석과 트랜잭션·인덱스 튜닝도 사고방식을 확장하는 데 도움이 됩니다.
마무리
Go 채널 데드락의 본질은 “송신과 수신의 계약”이 깨졌을 때 발생합니다. 계약이 깨지는 지점은 대체로 7가지로 수렴합니다.
- 수신자 없는 송신
- 소비자 없는 버퍼 채널
range종료를 위한 close 누락- 다중 송신자 close 책임 혼선
select에서 영원히 기다림- 요청-응답에서 응답 미수신/미전송
- fan-in 종료 조건 붕괴
재현 코드를 팀 내 위키나 테스트로 남겨두면, 리뷰 때 “이 패턴은 데드락 난다”를 빠르게 합의할 수 있습니다. 운영에서는 스택 덤프와 context 기반 취소를 기본 장착해두는 것만으로도, 데드락과 누수를 큰 폭으로 줄일 수 있습니다.