- Published on
Go 채널 데드락 - select+nil 채널로 5분 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 워커를 Go로 운영하다 보면, 어느 날 갑자기 프로세스가 멈춘 듯 보이면서 로그도 더 이상 안 찍히는 상황을 만납니다. CPU는 낮고, 메모리는 안정적인데 요청은 타임아웃이 나고, goroutine 수는 계속 늘거나 특정 지점에서 정지합니다. 이런 증상의 상당수는 채널 데드락(정확히는 goroutine들이 서로를 기다리며 진행 불가)이 원인입니다.
이 글은 데드락을 “완벽히 근절”하는 거창한 이야기보다, 실무에서 가장 자주 터지는 패턴을 select + nil 채널로 5분 안에 정리하는 방법에 집중합니다. 특히 다음 상황에 즉효가 있습니다.
- 어떤 채널은 상황에 따라 “받을 수도/안 받을 수도” 있어야 하는데
select가 계속 그 채널을 기다리는 경우 - producer가 닫히거나 멈췄는데 consumer가 영원히
range ch또는case v := <-ch에 갇히는 경우 - 여러 입력 중 일부만 활성화하고 싶지만
selectcase를 동적으로 켜고 끄기 어려운 경우
운영 관점에서 “원인이 데드락인지”를 먼저 확인하는 방법도 짧게 다룹니다. (프로세스가 멈춘 듯 보일 때는 앱 내부 문제뿐 아니라 런타임/환경 문제도 섞이기 때문에, 유사한 진단 글로는 systemd 서비스 재시작 루프 10분 진단 가이드도 같이 참고하면 좋습니다.)
1) 데드락의 전형: select가 영원히 깨어나지 않는다
Go의 select는 준비된 case가 없으면 블록됩니다. “그렇지, 당연하지”라고 생각하지만, 실무에서는 준비될 수 없는 채널을 무심코 넣어두는 순간 문제가 됩니다.
예를 들어, 아래 코드는 얼핏 그럴듯하지만 특정 조건에서 영원히 멈출 수 있습니다.
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
jobs := make(chan int)
results := make(chan int)
go func() {
// 실제 코드에서는 조건에 따라 jobs를 더 이상 보내지 않을 수 있음
// close(jobs)를 깜빡하면 consumer는 영원히 기다릴 수 있다.
for i := 0; i < 3; i++ {
jobs <- i
}
// close(jobs) // 이게 없으면 아래 워커가 끝나지 않을 수 있음
}()
go worker(ctx, jobs, results)
// results를 읽는 쪽도 종료 조건이 없으면 멈춘다.
for i := 0; i < 3; i++ {
fmt.Println(<-results)
}
fmt.Println("done")
}
func worker(ctx context.Context, jobs <-chan int, results chan<- int) {
for {
select {
case <-ctx.Done():
return
case j := <-jobs:
results <- j * 2
}
}
}
문제점은 두 가지입니다.
jobs가 더 이상 오지 않는데close(jobs)도 안 하면j := <-jobs는 영원히 대기할 수 있습니다.jobs가 닫혀도 위 코드는j := <-jobs에서ok를 확인하지 않아, 닫힌 채널에서0을 계속 읽는 논리 버그로 이어질 수 있습니다.
이 글의 핵심은 “닫아라/ok 체크해라”를 넘어, 상황에 따라 case를 아예 비활성화하는 패턴입니다.
2) 핵심 원리: nil 채널은 영원히 블록된다
Go에서 nil 채널에 대해 send/recv를 시도하면 해당 연산은 영원히 블록됩니다. 이 특성은 단독으로 쓰면 위험하지만, select 안에서는 강력한 도구가 됩니다.
select는 준비 가능한 case 중 하나를 고릅니다.- 어떤 case의 채널을
nil로 만들면, 그 case는 절대 선택되지 않습니다.
즉, select의 case를 동적으로 켜고 끄는 “스위치”로 nil 채널을 사용할 수 있습니다.
3) 5분 해결 패턴 1: 입력 채널을 조건부로 비활성화하기
가장 흔한 요구는 이겁니다.
- 평소에는
jobs를 받는다. - 특정 조건이 되면 더 이상
jobs를 받지 않고, 다른 이벤트(예: 종료 신호, 타임아웃, flush)만 기다린다.
이때 select에 case j := <-jobs:를 넣어두면, jobs가 더 이상 오지 않는 순간 select는 다른 case가 준비되지 않는 한 계속 블록될 수 있습니다.
해결은 간단합니다. “받고 싶지 않을 때” jobs를 nil로 바꿉니다.
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
jobs := make(chan int)
go func() {
for i := 0; i < 5; i++ {
jobs <- i
}
close(jobs)
}()
process(ctx, jobs)
}
func process(ctx context.Context, jobs <-chan int) {
var in <-chan int = jobs
idleTimer := time.NewTimer(500 * time.Millisecond)
defer idleTimer.Stop()
for {
select {
case <-ctx.Done():
fmt.Println("stop: ctx")
return
case j, ok := <-in:
if !ok {
// 채널이 닫히면 더 이상 이 case가 선택되지 않게 비활성화
in = nil
fmt.Println("jobs closed; disable input")
continue
}
fmt.Println("job", j)
// 활동이 있었으니 idle 타이머 리셋
if !idleTimer.Stop() {
select {
case <-idleTimer.C:
default:
}
}
idleTimer.Reset(500 * time.Millisecond)
case <-idleTimer.C:
fmt.Println("idle tick")
// 입력이 이미 닫혔다면, idle만 기다릴 이유가 없으니 종료
if in == nil {
fmt.Println("stop: no input")
return
}
}
}
}
포인트는 var in <-chan int = jobs로 별도 변수를 두고, 닫힘을 감지하면 in = nil로 바꿔 해당 case를 영구적으로 비활성화하는 것입니다.
이 패턴은 단순하지만 효과가 큽니다.
- 닫힌 채널에서 0값을 무한히 읽는 버그 방지
- 더 이상 오지 않는 입력 때문에 다른 종료 조건이 묻히는 상황 방지
- 선택 로직이 명시적으로 바뀌어 디버깅이 쉬움
4) 5분 해결 패턴 2: “optional channel”을 nil로 표현하기
실무에서는 설정에 따라 특정 기능이 꺼져 있을 수 있습니다.
- rate limit을 켜면 tick 채널을 받는다.
- 끄면 tick 채널을 받지 않는다.
이때 흔히 if enabled { select { case ... } } else { select { ... } }처럼 select 자체를 중복 작성하게 됩니다. 더 나쁜 경우, 비활성 기능의 채널을 만들어두고 아무도 보내지 않아 데드락을 유발합니다.
nil 채널로 깔끔하게 해결할 수 있습니다.
package main
import (
"fmt"
"time"
)
func main() {
enabled := false
var tick <-chan time.Time
if enabled {
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
tick = ticker.C
} else {
// 비활성화: nil 채널로 case 자체를 제거
tick = nil
}
deadline := time.After(650 * time.Millisecond)
for {
select {
case <-tick:
fmt.Println("tick")
case <-deadline:
fmt.Println("deadline")
return
}
}
}
enabled가 false면 tick은 nil이므로 case <-tick:은 절대 선택되지 않습니다. 결과적으로 deadline만 기다리는 루프가 됩니다.
5) 5분 해결 패턴 3: fan-in에서 특정 입력이 멎을 때
여러 입력을 하나로 합치는 fan-in은 흔합니다. 그런데 입력 중 하나가 “일시적으로 멎음”이 아니라 “영구적으로 멎음” 상태가 되면, 잘못 짠 select는 전체를 멈춰 세우기도 합니다.
아래는 두 입력을 합치는 예시입니다. 각 입력이 닫히면 nil로 비활성화하고, 둘 다 닫히면 종료합니다.
package main
import "fmt"
func fanIn(a, b <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
var ca <-chan int = a
var cb <-chan int = b
for ca != nil || cb != nil {
select {
case v, ok := <-ca:
if !ok {
ca = nil
continue
}
out <- v
case v, ok := <-cb:
if !ok {
cb = nil
continue
}
out <- v
}
}
}()
return out
}
func main() {
a := make(chan int)
b := make(chan int)
go func() {
defer close(a)
for i := 0; i < 3; i++ {
a <- i
}
}()
go func() {
defer close(b)
for i := 100; i < 102; i++ {
b <- i
}
}()
for v := range fanIn(a, b) {
fmt.Println(v)
}
}
이 패턴을 쓰면, 한쪽 입력이 먼저 닫혀도 나머지 입력은 계속 흘러가며, 둘 다 끝났을 때만 정상 종료합니다.
6) 왜 이게 데드락 “치료”로 먹히는가
운영에서 만나는 데드락은 보통 아래 둘 중 하나입니다.
- 기다릴 필요가 없는 채널을 계속 기다림: 이미 닫혔거나, 더 이상 오지 않거나, 기능이 비활성인데도
select에 남아 있음 - 종료 조건이 있지만 도달하지 못함: 어떤 case가 영원히 블록되어 다른 case가 실행될 기회를 못 얻는 구조(예: 단일 goroutine이 모든 것을 처리하는데 입력이 영원히 안 옴)
nil 채널은 “이 case는 이제 게임에서 제외”를 코드로 표현합니다. 그래서 로직이 단순해지고, 종료 조건이 살아납니다.
7) 실전 디버깅 팁: goroutine 덤프로 막힌 지점 확인
프로세스가 멈춘 듯할 때는 먼저 goroutine 덤프를 떠서 어디서 블록되는지 확인하는 게 가장 빠릅니다.
- HTTP 서버를 쓰면
net/http/pprof를 붙여goroutine프로파일을 봅니다. - 또는
SIGQUIT로 스택 덤프를 출력합니다.
예를 들어 pprof를 붙이는 최소 코드는 다음과 같습니다.
import (
_ "net/http/pprof"
"net/http"
)
go func() {
// 운영에서는 반드시 인증/네트워크 제한을 걸 것
_ = http.ListenAndServe("127.0.0.1:6060", nil)
}()
덤프에서 chan receive 또는 select로 오래 멈춘 goroutine이 보이면, “그 채널이 영원히 준비되지 않는가?”를 의심하고, 이 글의 nil 비활성화 패턴을 적용해볼 수 있습니다.
운영 장애가 “프로세스가 죽었다/재시작된다” 형태로 보일 때는 데드락과 반대로 크래시나 OOM일 수도 있으니, 환경 레벨 진단은 K8s CrashLoopBackOff 원인별 로그·Probe 해결 가이드도 함께 보면 좋습니다.
8) 주의사항: nil 채널은 만능이 아니다
select + nil 채널은 강력하지만, 남용하면 가독성이 떨어질 수 있습니다. 다음을 지키면 안전합니다.
- 반드시 별도 변수로 받기: 원본 채널 변수 자체를
nil로 만들면 다른 코드가 영향을 받을 수 있습니다.var in <-chan T = ch처럼 로컬에서 스위칭하세요. - 닫힘은
ok로 처리:case v, ok := <-ch:형태로 닫힘을 감지하고ch = nil로 비활성화하세요. - 종료 조건을 명시:
for in != nil || other != nil처럼 “언제 끝나는지”를 루프 조건에 드러내면 디버깅이 쉬워집니다. - 타이머 리셋은 안전하게:
time.Timer는Stop과 drain 처리를 제대로 하지 않으면 의도치 않은 즉시 tick이 발생할 수 있습니다. 위 예제처럼 drain 패턴을 지키세요.
9) 체크리스트: 데드락이 보이면 5분 안에 보는 것들
select에 들어간 채널 중, 실제로는 “영원히 준비되지 않을 수 있는” 채널이 있는가- 닫힌 채널을
ok체크 없이 계속 읽고 있지는 않은가 - 입력이 끝났을 때 해당 case를
nil로 바꿔 비활성화할 수 있는가 - fan-in/fan-out에서 한쪽 종료가 전체 정지를 유발하지 않는가
- 종료 신호(
ctx.Done)가 있는데도 루프가 빠져나오지 못하는가
추가로, 장애가 “응답 지연/타임아웃”으로 관측될 때는 애플리케이션 데드락뿐 아니라 인프라 타임아웃도 함께 의심해야 합니다. 예를 들어 게이트웨이/런타임 제한이 섞이면 증상이 비슷해질 수 있는데, 그런 경우는 Cloud Run 504 Timeout 원인·해결 9가지 같은 타임아웃 진단 글도 같이 보면 원인 분리가 빨라집니다.
마무리
Go 채널 데드락은 대부분 “채널을 닫아라”로 끝나지 않습니다. 문제는 보통 select가 더 이상 의미 없는 채널을 계속 case로 들고 있는 구조에서 시작합니다. nil 채널을 이용해 case를 동적으로 비활성화하면, 로직이 단순해지고 종료 조건이 살아나며, fan-in 같은 동시성 파이프라인도 안정적으로 동작합니다.
다음에 select에서 멈춰 있는 goroutine을 발견하면, 그 채널을 nil로 만들 수 있는지부터 먼저 떠올려 보세요. 5분 안에 증상이 사라지는 경우가 생각보다 많습니다.