- Published on
Go 채널 교착상태 8가지 패턴과 해법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 워커를 Go로 짜다 보면 “fatal error: all goroutines are asleep - deadlock!”를 한 번쯤은 만나게 됩니다. 특히 채널을 중심으로 파이프라인을 구성할 때는, 코드가 짧아도 닫힘(close) 규칙, 수신자/송신자 수의 불일치, select 사용 방식, 컨텍스트 취소 전파 같은 작은 틈에서 교착이 발생합니다.
이 글은 Go 채널 교착상태를 8가지 대표 패턴으로 분류하고, 각 패턴마다 “왜 멈추는지”와 “어떻게 고치는지”를 재현 가능한 코드로 설명합니다.
고루틴이 멈추는 원인이 채널만은 아닙니다. 외부 I/O나 컨텍스트 취소 누락으로 인한 hang도 매우 흔하니, 함께 보면 좋은 글: Go 고루틴 컨텍스트 취소 누락으로 멈춤 해결
교착상태를 보는 관점: 채널은 “계약”이다
채널은 단순한 큐가 아니라, 송신자와 수신자 사이의 동기화 계약입니다.
- unbuffered 채널: 송신과 수신이 동시에 만나야 진행
- buffered 채널: 버퍼가 찰 때까지는 송신이 진행되지만, 결국 버퍼가 꽉 차면 막힘
- close 규칙: 채널을 닫는 주체는 “더 이상 값을 보내지 않는 쪽(보통 producer)”이어야 함
- range 수신: 채널이 닫히기 전까지 끝나지 않음
이 계약이 깨지면 데드락으로 이어집니다.
패턴 1) 수신자가 없는데 송신하는 unbuffered 채널
가장 기본적인 데드락입니다. unbuffered 채널은 수신자가 준비되지 않으면 송신이 영원히 블록됩니다.
package main
func main() {
ch := make(chan int)
ch <- 1 // 수신자가 없음: 여기서 블록
}
해법
- 수신자를 먼저 띄우거나
ch := make(chan int)
go func() {
<-ch
}()
ch <- 1
- 정말 “큐”처럼 쓰고 싶다면 버퍼를 둡니다.
ch := make(chan int, 1)
ch <- 1 // 버퍼가 있으니 통과
패턴 2) 송신자가 없는데 수신만 하는 경우
반대로 수신만 하고 송신이 없으면 수신은 영원히 기다립니다.
package main
func main() {
ch := make(chan int)
_ = <-ch // 송신자가 없음: 영원히 대기
}
해법
- 송신 경로가 반드시 실행되는지 보장
- 또는 종료 조건을 넣어 빠져나올 수 있게
select와context/타임아웃을 사용
package main
import (
"context"
"time"
)
func main() {
ch := make(chan int)
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
select {
case v := <-ch:
_ = v
case <-ctx.Done():
// 타임아웃으로 탈출
}
}
패턴 3) range ch가 끝나지 않는 “close 누락”
파이프라인에서 가장 흔한 형태입니다. 소비자는 for v := range ch로 읽는데, 생산자가 채널을 닫지 않으면 소비자는 영원히 끝나지 않습니다.
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
for i := 0; i < 3; i++ {
ch <- i
}
// close(ch) 누락
}()
for v := range ch {
fmt.Println(v)
}
// 여기 도달 불가
}
해법: producer가 닫는다
go func() {
defer close(ch)
for i := 0; i < 3; i++ {
ch <- i
}
}()
for v := range ch {
fmt.Println(v)
}
원칙: “채널을 닫는 책임은 sender(생산자)에게” 두는 것이 가장 안전합니다.
패턴 4) WaitGroup을 기다리는데 goroutine이 채널에서 막힘
wg.Wait() 자체는 문제가 없지만, wg.Done()을 호출해야 할 고루틴이 채널 송/수신에서 막히면 메인 고루틴이 영원히 기다립니다.
package main
import "sync"
func main() {
ch := make(chan int)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
ch <- 1 // 수신자 없어서 블록, Done까지 못 감
}()
wg.Wait() // 영원히 대기
_ = ch
}
해법
- 채널 송/수신이 “막힐 수 있다”는 전제를 두고, 항상 대응되는 수신자/송신자 또는 버퍼를 마련
- 종료/취소 신호를 추가해 빠져나오게 설계
ch := make(chan int, 1) // 최소한의 버퍼로 교착 제거
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
ch <- 1
}()
wg.Wait()
또는 select로 취소를 받게 만들면, 외부 조건에서 안전하게 종료시킬 수 있습니다. (관련 심화: Go 고루틴 컨텍스트 취소 누락으로 멈춤 해결)
패턴 5) producer가 소비자보다 빠르고, 버퍼가 꽉 차서 막힘
버퍼 채널은 만능이 아닙니다. 버퍼가 가득 차면 송신은 다시 블록됩니다. 소비자가 특정 이유로 느려지거나 멈추면 전체가 교착처럼 보입니다.
package main
import "time"
func main() {
ch := make(chan int, 2)
go func() {
for i := 0; i < 10; i++ {
ch <- i // 소비가 느리면 결국 여기서 막힘
}
}()
// 소비를 일부러 느리게
for {
time.Sleep(1 * time.Second)
<-ch
}
}
해법
- 처리량을 맞추기 위해 worker 수를 늘리거나
- 백프레셔(backpressure)를 의도한 설계로 받아들이되, 취소/종료 경로를 반드시 준비
- 드롭 정책이 가능하면 non-blocking send도 고려
select {
case ch <- v:
// 정상 전송
default:
// 버퍼가 꽉 차면 드롭 또는 대체 경로
}
패턴 6) select에서 default 남용으로 “신호를 못 받는” 루프
select에 default를 넣으면 블록하지 않고 계속 돌아갑니다. 이게 수신을 기다려야 하는 이벤트 루프에서 들어가면, 다른 고루틴과의 동기화가 깨져 종료 신호를 놓치거나, CPU를 태우면서도 작업이 진행되지 않는 상태가 됩니다.
package main
import "time"
func main() {
done := make(chan struct{})
go func() {
time.Sleep(100 * time.Millisecond)
close(done)
}()
for {
select {
case <-done:
return
default:
// 바쁘게 돈다. 실전에서는 다른 채널 수신 타이밍을 망가뜨리기도 함
}
}
}
해법
- 정말로 폴링이 필요하면
time.Ticker로 주기를 제한 - 기본은
default를 빼고 “기다리게” 만들기
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
// 주기 작업
}
}
패턴 7) 양방향 의존(서로가 서로를 기다림)으로 사이클이 생김
두 고루틴이 서로에게 신호를 보내고, 둘 다 수신을 먼저 기다리면 교착이 됩니다. 실전에서는 “A가 B의 ack를 기다리고, B는 A의 ready를 기다리는” 식으로 나타납니다.
package main
func main() {
a := make(chan struct{})
b := make(chan struct{})
go func() {
<-b
a <- struct{}{}
}()
go func() {
<-a
b <- struct{}{}
}()
// 둘 다 a/b 수신부터 시작해서 영원히 멈춤
select {}
}
해법
- 프로토콜을 단방향으로 단순화 (한쪽만 시작 신호를 보냄)
- 초기 토큰을 하나 넣어 사이클을 끊기
- 또는 버퍼 1로 “첫 신호”가 지나가게 만들기
a := make(chan struct{}, 1)
b := make(chan struct{})
// 초기 토큰
a <- struct{}{}
go func() {
for {
<-a
b <- struct{}{}
}
}()
go func() {
for {
<-b
a <- struct{}{}
}
}()
// 실행 유지
select {}
핵심은 “서로가 서로의 수신을 전제로 하는 순간” 사이클이 생긴다는 점입니다.
패턴 8) 여러 수신자 중 일부만 읽고 나머지는 영원히 대기 (fan-out/merge 실수)
fan-out에서 작업을 여러 채널로 뿌리거나, 여러 producer를 하나로 merge할 때 “누군가가 끝났음을 알리는 close”가 누락되면 일부 고루틴이 영원히 기다립니다.
아래는 merge에서 흔한 실수: 두 입력 채널을 읽어 하나로 보내는데, 종료 조건이 불완전해 output을 닫지 못합니다.
package main
func merge(a, b <-chan int) <-chan int {
out := make(chan int)
go func() {
for {
select {
case v := <-a:
out <- v
case v := <-b:
out <- v
}
}
// close(out) 도달 불가
}()
return out
}
func main() {
_ = merge(make(chan int), make(chan int))
}
문제는 v := <-a 형태로 받으면, a가 닫혔을 때도 v는 제로값을 돌려주며 계속 진행한다는 점입니다(두 번째 반환값 ok를 확인하지 않으면 “닫힘”을 감지하지 못함).
해법: ok 확인 + 모든 입력 종료 후 out 닫기
package main
import "sync"
func merge(a, b <-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
forward := func(in <-chan int) {
defer wg.Done()
for v := range in {
out <- v
}
}
wg.Add(2)
go forward(a)
go forward(b)
go func() {
wg.Wait()
close(out)
}()
return out
}
이 패턴은 fan-in/merge에서 정석에 가깝고, “입력 채널은 range로 끝까지 읽고, 모든 forward가 끝나면 out을 닫는다”는 구조를 강제합니다.
디버깅 체크리스트: 데드락을 빠르게 찾는 방법
실무에서 데드락을 줄이려면, 재현 코드보다 관찰 도구가 더 중요할 때가 많습니다.
1) 스택 덤프 확인
프로세스에 SIGQUIT를 보내면(유닉스 계열) 고루틴 스택을 덤프할 수 있습니다. 어디서 채널 송/수신으로 멈췄는지 바로 보입니다.
- 실행 중 터미널에서
kill -QUIT PID - 또는 컨테이너 환경이면
docker kill --signal=QUIT 컨테이너명
(부등호 문자는 MDX에서 민감하니 위 명령의 리디렉션 같은 표기는 인라인 코드로만 다루는 습관을 추천합니다.)
2) go test에서 타임아웃을 걸어 hang을 빨리 실패시키기
go test ./... -run TestName -count=1 -timeout 5s
3) 채널 소유권(ownership) 문서화
- 누가
send하는가 - 누가
close하는가 - 누가
range하는가 - 취소는 어디서 시작해 어디까지 전파되는가
이걸 함수 주석/인터페이스로 강제하면 교착이 크게 줄어듭니다.
실전 설계 팁: 교착을 “구조적으로” 예방하기
정리하면, Go 채널 교착은 대부분 아래 원칙을 지키면 예방 가능합니다.
- 채널은 producer가 닫는다 (
defer close(ch)) range ch를 쓰면 “닫힘”이 설계에 포함돼야 한다- merge/fan-in은
WaitGroup으로 입력 종료를 집계하고 마지막에 output을 닫는다 - 무한 대기 가능성이 있으면
select에context.Done()또는 타임아웃을 둔다 default는 신중히: 이벤트 루프를 폴링으로 바꾸며 타이밍 버그를 만든다- 버퍼는 해결책이 아니라 “용량”이다. 결국 꽉 차면 블록되므로 취소/드롭/스케일 전략이 필요하다
동시성 버그는 데이터베이스 튜닝이나 인프라 디버깅처럼 “현상은 단순한데 원인은 복합적”인 경우가 많습니다. 비슷한 결로, 운영에서 원인을 체크리스트로 좁혀가는 방식이 도움이 됩니다. 예를 들어 DB가 멈춘 것처럼 보일 때 원인을 단계적으로 좁혀가는 글로는 PostgreSQL VACUUM 안 도는 이유와 튜닝 체크리스트도 참고할 만합니다.
마무리
Go의 채널은 강력하지만, “닫힘과 종료”를 설계에 포함하지 않으면 교착은 언젠가 터집니다. 이 글의 8가지 패턴을 팀 코드리뷰 체크리스트로 옮겨두면, 재현 어려운 hang/데드락의 상당수를 사전에 차단할 수 있습니다.
원한다면 다음 단계로, 실제 서비스 코드(워커 풀, 파이프라인, fan-in/out) 구조를 기준으로 “교착이 안 나는 템플릿” 형태로 정리해드릴 수도 있습니다.