- Published on
Go select에서 채널 nil로 데드락 막는 패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 워커를 Go로 만들다 보면 select 안에서 여러 채널을 동시에 기다리는 코드가 흔합니다. 그런데 런타임 상태에 따라 특정 채널은 “지금은 받으면 안 됨” 혹은 “지금은 보낼 수 없음” 같은 조건이 생기고, 이를 잘못 처리하면 데드락(전체가 멈춤)이나 고루틴 누수(영원히 블로킹)로 이어집니다.
이 글에서는 select에서 채널을 nil로 만들어 case를 비활성화하는 패턴을 중심으로, 왜 이게 데드락 방지에 유효한지, 어떤 함정이 있는지, 그리고 실무에서 바로 쓸 수 있는 예제를 정리합니다.
장애를 빨리 진단하는 관점은 인프라/운영 글에서도 동일합니다. 예를 들어 K8s CrashLoopBackOff 원인 10분 추적법처럼 “증상→원인 후보→검증” 흐름을 갖추면 동시성 이슈도 훨씬 빨리 좁힐 수 있습니다.
select와 nil 채널의 핵심 규칙
Go 스펙에서 중요한 규칙은 다음 하나로 요약됩니다.
nil채널에 대한send/recv는 영원히 블로킹한다.select에서 어떤 case가nil채널을 사용하면, 그 case는 선택될 수 없으므로 사실상 비활성화된다.
즉, select에서 nil 채널은 “이 case는 지금은 고려하지 마”라는 스위치로 쓸 수 있습니다.
이 규칙을 모르면, nil 채널을 실수로 만들어 놓고 “왜 select가 특정 case를 안 타지?” 같은 디버깅 지옥을 겪습니다. 반대로 이 규칙을 의도적으로 활용하면, 조건 분기를 늘리지 않고도 select를 깔끔하게 유지하면서 데드락을 예방할 수 있습니다.
왜 데드락이 생기나: 동적으로 변하는 조건
대표적인 데드락 시나리오는 다음과 같습니다.
- 소비자가 멈췄는데 생산자는 계속 보내려 한다.
- 보낼 데이터가 없는데도
sendcase를 계속 열어둔다. - 닫힌 채널/종료 신호를 처리하지 못해 루프가 빠져나오지 못한다.
특히 “버퍼가 비었을 때는 보내면 안 되고, 버퍼가 찼을 때는 받으면 안 된다” 같은 상태 기반 흐름 제어가 필요할 때, select 안에 조건문을 덕지덕지 붙이면 실수하기 쉽습니다.
nil 채널 패턴은 이런 상태 제어를 select의 구조 자체로 표현하게 해줍니다.
패턴 1: 조건에 따라 send/recv case 끄기 (가장 흔함)
아래는 내부 큐(queue)를 가진 단일 고루틴이 입력을 받아 큐에 쌓고, 출력 채널로 내보내는 “펌프/브로커” 예제입니다.
핵심은 다음 두 줄입니다.
- 큐가 비면
outCh = nil로 만들어sendcase를 끈다. - 큐가 차면(또는 더 받으면 안 되면)
inCh = nil로 만들어recvcase를 끈다.
package main
import (
"context"
"fmt"
"time"
)
func broker(ctx context.Context, in <-chan int, out chan<- int, max int) {
defer close(out)
queue := make([]int, 0, max)
for {
var (
outCh chan<- int
next int
inCh <-chan int
)
// recv는 기본적으로 켜두되, 큐가 가득 차면 끈다.
inCh = in
if len(queue) >= max {
inCh = nil
}
// send는 큐가 비었으면 끈다.
if len(queue) > 0 {
outCh = out
next = queue[0]
} else {
outCh = nil
}
select {
case <-ctx.Done():
return
case v, ok := <-inCh:
if !ok {
// 입력이 닫히면 더 이상 받지 않고, 남은 큐만 배출
in = nil
inCh = nil
if len(queue) == 0 {
return
}
continue
}
queue = append(queue, v)
case outCh <- next:
queue = queue[1:]
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
in := make(chan int)
out := make(chan int)
go broker(ctx, in, out, 3)
go func() {
defer close(in)
for i := 0; i < 10; i++ {
in <- i
}
}()
for v := range out {
fmt.Println(v)
time.Sleep(50 * time.Millisecond)
}
}
이 패턴이 데드락을 막는 이유
- 큐가 비었는데도
out <- next를 열어두면,next가 의미 없거나(제로값) 로직이 꼬입니다. - 큐가 가득 찼는데도
in <-을 계속 받으려 하면, 큐가 무한히 커지거나(메모리 폭증) 다른 곳에서 backpressure를 못 걸어 시스템이 불안정해집니다. nil로 case를 꺼두면select는 가능한 이벤트만 기다리므로, “불가능한 send/recv를 기다리다 멈추는” 상황을 구조적으로 제거합니다.
패턴 2: 타이머/틱을 동적으로 끄기 (time.After 남발 방지)
select에서 흔히 하는 실수는 루프 안에서 time.After(d)를 매번 호출하는 것입니다. 이는 타이머 객체가 계속 생성되고, 조건에 따라서는 GC 부담이나 지연을 유발할 수 있습니다.
time.Timer를 재사용하면서, 필요 없을 때는 타이머 채널을 nil로 만들어 case를 끄는 방식이 안전합니다.
package main
import (
"fmt"
"time"
)
func main() {
work := make(chan struct{})
quit := make(chan struct{})
t := time.NewTimer(0)
if !t.Stop() {
<-t.C
}
var timeoutCh <-chan time.Time = nil
go func() {
// 작업이 들어오면 200ms 타임아웃을 켠다.
for i := 0; i < 3; i++ {
work <- struct{}{}
time.Sleep(100 * time.Millisecond)
}
close(quit)
}()
for {
select {
case <-work:
// 타임아웃 활성화
t.Reset(200 * time.Millisecond)
timeoutCh = t.C
fmt.Println("work received, timeout armed")
case <-timeoutCh:
// 타임아웃 비활성화
timeoutCh = nil
fmt.Println("timeout fired")
case <-quit:
return
}
}
}
포인트는 timeoutCh만 nil로 바꿔 끄고 켠다는 점입니다. 타이머 자체를 매번 새로 만들 필요가 없습니다.
패턴 3: fan-in에서 입력 채널을 nil로 “제거”하기
여러 입력 채널을 하나로 합치는 fan-in을 만들 때, 어떤 채널이 닫히면 해당 case를 더 이상 타지 않도록 nil로 바꿔 제거하는 방식이 깔끔합니다.
func fanIn(a, b <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for a != nil || b != nil {
select {
case v, ok := <-a:
if !ok {
a = nil
continue
}
out <- v
case v, ok := <-b:
if !ok {
b = nil
continue
}
out <- v
}
}
}()
return out
}
닫힌 채널에서 수신은 즉시 반환되므로, a = nil로 바꾸지 않으면 닫힌 채널 case가 계속 선택되어 루프가 바쁘게 돌 수 있습니다(일종의 CPU 스핀). 이 패턴은 그런 문제도 예방합니다.
흔한 함정과 체크리스트
1) nil 채널을 “원본 변수”에 대입하지 말고, 보조 변수로 제어하라
위 예제들처럼 inCh, outCh, timeoutCh 같은 보조 변수를 두고 nil을 넣는 편이 안전합니다. 원본 채널 변수 자체를 nil로 만들어버리면, 다른 코드 경로에서 그 채널을 다시 써야 할 때 복구가 어렵고, 디버깅도 힘들어집니다.
2) 종료 조건을 반드시 명시하라
select는 이벤트가 없으면 영원히 대기합니다. ctx.Done()이나 quit 같은 종료 채널을 항상 포함시키는 습관이 좋습니다. 이는 고루틴 누수를 막는 가장 직접적인 안전장치입니다.
이 관점은 리소스 고갈 문제와도 닮았습니다. 예를 들어 파일 디스크립터가 누수되면 결국 EMFILE로 터지듯이, 고루틴도 누수되면 결국 시스템이 둔해집니다. 관련해서는 Linux EMFILE(Too many open files) 원인과 해결 같은 글의 “자원은 회수 경로가 있어야 한다”는 원칙을 떠올리면 좋습니다.
3) default를 남발하지 말라
default가 있으면 select는 블로킹하지 않고 계속 루프를 돌 수 있습니다. 이는 의도치 않은 busy loop를 만들기 쉽습니다. 정말로 폴링이 필요하다면 time.Ticker 또는 위에서 소개한 “타이머를 켰다/껐다” 패턴을 사용하세요.
4) 닫힌 채널 처리와 nil 비활성화는 세트로 생각하라
- 닫힘 감지:
v, ok := <-ch - 닫혔다면:
ch = nil
이 두 단계가 함께 있어야 fan-in/fan-out 루프가 안정적으로 종료하거나 다른 채널만 계속 처리할 수 있습니다.
언제 이 패턴을 쓰면 좋은가
- 단일 고루틴이 상태(큐, 워터마크, 모드)를 갖고 있고, 그 상태에 따라
select의 가능 행동이 바뀌는 경우 - 다중 입력을 합치거나(fan-in) 다중 출력으로 분배할 때(fan-out) 일부 채널이 종료/일시중지되는 경우
- 타임아웃/재시도/하트비트 같은 시간 이벤트를 “필요할 때만” 활성화하고 싶은 경우
반대로, 단순히 if로 분기해도 충분한데 무리하게 nil 패턴을 쓰면 가독성이 떨어질 수 있습니다. “select의 case를 동적으로 끄고 켜야 하는가?”가 도입 기준입니다.
마무리: nil 채널은 동시성 스위치다
정리하면, select에서 nil 채널은 단순한 실수가 아니라 의도적으로 case를 비활성화하는 도구입니다. 이 도구를 사용하면
- 불가능한 send/recv를 기다리며 멈추는 데드락을 줄이고
- 닫힌 채널로 인한 스핀을 방지하며
- 조건 분기 지옥 없이 상태 기반 동시성 흐름을 깔끔하게 유지할 수 있습니다.
실무에서는 위의 “브로커(큐) 패턴” 하나만 제대로 익혀도, 워커 풀/이벤트 루프/스트림 처리 코드의 안정성이 크게 올라갑니다. 동시성 버그는 재현이 어렵고 운영에서야 드러나는 경우가 많으니, 처음부터 ctx.Done()과 nil 채널 스위치를 함께 설계하는 습관을 추천합니다.