- Published on
Go 고루틴 채널 데드락 5분 재현·해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 배치에서 Go를 쓰다 보면, 어느 날 갑자기 로그가 멈추고 CPU도 안 쓰는데 프로세스가 끝나지 않는 상황을 만납니다. goroutine 이 살아 있고, 채널 송수신이 서로를 기다리며 멈춘 전형적인 데드락입니다.
이 글은 “5분 재현”을 목표로, 가장 흔한 채널 데드락 패턴을 빠르게 만들어 보고, 같은 구조를 안전하게 고치는 실전 패턴(컨텍스트 취소, 채널 close 책임, fan-in 종료)을 정리합니다.
참고로, 네트워크 호출이 섞인 경우에는 데드락처럼 보이지만 사실은 타임아웃/데드라인 문제인 경우도 많습니다. RPC가 걸려 있다면 Go gRPC 데드라인 초과 원인 7가지와 해결도 함께 확인해 두면 좋습니다.
1) 데드락이란: “진행 가능한 goroutine이 0개”
Go 런타임은 모든 goroutine 이 채널 송수신/락/select 대기 등으로 블로킹되고, 더 이상 진행할 수 없으면 아래와 같은 메시지로 패닉을 냅니다.
fatal error: all goroutines are asleep - deadlock!
하지만 항상 패닉이 나는 것은 아닙니다. 예를 들어 메인 고루틴이 select {} 로 영원히 대기하면 런타임은 “프로그램이 계속 살아있다”고 판단하고 패닉을 내지 않습니다. 그래서 “멈춘 것처럼 보이는” 무한 대기가 더 위험합니다.
2) 5분 재현 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
}
3) 5분 재현 2: range ch 가 끝나지 않는 데드락(닫지 않음)
생산자가 값을 다 보냈는데 채널을 닫지 않으면, 소비자는 range 에서 영원히 다음 값을 기다립니다.
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)
}
fmt.Println("done") // 도달하지 않음
}
해결: close 책임을 “생산자”에게
채널을 닫는 주체는 원칙적으로 “더 이상 보내지 않는 쪽(생산자)”입니다.
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
defer close(ch)
for i := 0; i < 3; i++ {
ch <- i
}
}()
for v := range ch {
fmt.Println(v)
}
fmt.Println("done")
}
자주 하는 실수
- 소비자에서
close(ch)를 호출하기- 생산자가 아직 보내는 중이면
send on closed channel패닉으로 이어집니다.
- 생산자가 아직 보내는 중이면
4) 5분 재현 3: WaitGroup 과 채널이 서로를 기다리는 교착
아래 패턴은 실제 서비스 코드에서 꽤 자주 나옵니다.
- 메인:
wg.Wait()로 “모든 워커 종료”를 기다림 - 워커: 결과를 채널로 보내는데, 메인이
Wait()중이라 수신을 안 함 - 워커: 송신에서 블로킹 →
Done()을 못 함 → 메인은 영원히Wait()
package main
import (
"fmt"
"sync"
)
func main() {
results := make(chan int) // unbuffered
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
results <- id // 메인이 받지 않으면 여기서 멈춤
}(i)
}
wg.Wait() // 워커는 송신에서 막혀 Done 불가
close(results) // 도달 불가
for v := range results {
fmt.Println(v)
}
}
해결 1: 수신 루프를 먼저 돌리고, close는 별도 고루틴에서
핵심은 “수신이 진행되도록” 메인을 막지 않는 것입니다.
package main
import (
"fmt"
"sync"
)
func main() {
results := make(chan int)
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
results <- id
}(i)
}
go func() {
wg.Wait()
close(results)
}()
for v := range results {
fmt.Println(v)
}
}
해결 2: 버퍼 채널로 “완화” (주의)
results := make(chan int, N) 처럼 버퍼를 주면 워커가 일단 보낼 수 있어 데드락이 사라질 수 있습니다. 하지만 이는 구조적 문제를 숨길 뿐, 결과가 N 을 넘거나 처리량이 흔들리면 다시 멈출 수 있습니다.
버퍼는 “성능/스파이크 흡수” 용도로 쓰고, 종료 조건과 close 책임은 명확히 두는 편이 안전합니다.
5) 5분 재현 4: fan-in에서 입력 채널 close를 안 해서 종료가 안 됨
여러 워커가 각자 채널로 결과를 내고, 이를 하나로 합치는 fan-in을 만들 때도 데드락이 납니다.
문제는 보통 이렇습니다.
- fan-in은 모든 입력 채널이 닫혀야 종료
- 그런데 워커가 채널을 닫지 않거나, 누가 닫아야 하는지 모호
잘못된 예(종료 조건 불명확)
package main
import "fmt"
func main() {
in1 := make(chan int)
in2 := make(chan int)
out := make(chan int)
go func() {
for v := range in1 {
out <- v
}
}()
go func() {
for v := range in2 {
out <- v
}
}()
// out 을 range 하는 소비자
go func() {
for v := range out {
fmt.Println(v)
}
fmt.Println("out closed")
}()
// in1, in2 를 닫지 않으면 out 도 닫힐 기회가 없음
select {}
}
해결: WaitGroup 으로 fan-in 종료를 정의하고 out 을 닫기
입력 채널은 각 생산자가 닫고, fan-in은 “모든 forwarding 고루틴이 끝나면 out을 닫는다”로 책임을 분리합니다.
package main
import (
"fmt"
"sync"
)
func fanIn(cs ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
wg.Add(len(cs))
for _, c := range cs {
go func(ch <-chan int) {
defer wg.Done()
for v := range ch {
out <- v
}
}(c)
}
go func() {
wg.Wait()
close(out)
}()
return out
}
func main() {
in1 := make(chan int)
in2 := make(chan int)
go func() {
defer close(in1)
for i := 0; i < 3; i++ {
in1 <- i
}
}()
go func() {
defer close(in2)
for i := 10; i < 13; i++ {
in2 <- i
}
}()
for v := range fanIn(in1, in2) {
fmt.Println(v)
}
}
6) 실전 해결 패턴: context 로 “취소 가능”하게 만들기
데드락의 또 다른 얼굴은 “영원히 기다리는 송수신”입니다. 특히 외부 I/O, 큐, DB, RPC를 섞으면 어떤 고루틴은 영원히 블로킹될 수 있습니다. 이때는 context 로 취소 신호를 전달하고, 송수신을 select 로 감싸 “탈출로”를 만들어야 합니다.
아래는 작업 큐에서 워커가 일을 받아 처리하고 결과를 보내는 구조를 안전하게 만든 예입니다.
package main
import (
"context"
"fmt"
"sync"
"time"
)
type Job int
type Result struct {
job Job
err error
}
func worker(ctx context.Context, id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case j, ok := <-jobs:
if !ok {
return
}
// 작업 처리(예시)
time.Sleep(50 * time.Millisecond)
select {
case <-ctx.Done():
return
case results <- Result{job: j, err: nil}:
}
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
jobs := make(chan Job)
results := make(chan Result)
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go worker(ctx, i, jobs, results, &wg)
}
go func() {
defer close(jobs)
for i := 0; i < 100; i++ {
select {
case <-ctx.Done():
return
case jobs <- Job(i):
}
}
}()
go func() {
wg.Wait()
close(results)
}()
for r := range results {
fmt.Println("done", r.job)
}
fmt.Println("exit:", ctx.Err())
}
이 패턴의 포인트는 다음과 같습니다.
jobs는 생산자가 닫는다.results는 “모든 워커 종료”를 확인한 뒤 닫는다.- 송신/수신은
select로ctx.Done()을 함께 본다.
이렇게 하면 “어느 한쪽이 사라져서 무한 대기” 같은 상황을 크게 줄일 수 있습니다.
7) 데드락 디버깅 체크리스트
문제 재현이 됐다면, 아래 순서로 보면 대부분 빠르게 원인이 좁혀집니다.
- 채널 방향 확인: 누가 보내고 누가 받는지, 버퍼 크기는 얼마인지
- 종료 조건 확인:
range를 쓰면 반드시close경로가 있는지 - close 책임 확인: 생산자가 닫는가, 소비자가 닫고 있지는 않은가
- WaitGroup 사용 위치:
Wait()때문에 수신 루프가 멈추지 않는가 - select 탈출로:
context나 타임아웃 없이 영원히 기다리는select가 있는가 - 고루틴 누수: 요청 단위로 만든 고루틴이 종료되지 않고 쌓이지 않는가
고루틴/리소스 누수는 DB 커넥션 누수처럼 “증상이 늦게 나타나는” 형태로 터집니다. 원인 추적 방식은 비슷한 점이 많아서, 누수 경고를 추적하는 관점이 필요하다면 Spring Boot 3.2 HikariCP 커넥션 누수 경고 추적법도 접근 방식 참고가 됩니다.
8) 마무리: 안전한 채널 설계 규칙 4가지
정리하면, 채널 데드락을 피하는 데 가장 효과적인 규칙은 아래 4개입니다.
- 채널을 닫는 주체는 생산자로 고정한다.
range ch를 쓰면 close 경로를 코드로 보장한다.WaitGroup으로 워커를 기다릴 때, 메인이 수신을 멈추지 않게close(results)를 별도 고루틴에서 처리한다.- 장기 실행/외부 I/O가 섞이면
context를 도입하고 송수신을select로 감싸 취소 가능하게 만든다.
위 패턴대로만 구성해도 “가끔 멈추는” 채널 데드락의 대부분은 설계 단계에서 사라집니다. 다음에 멈췄을 때는, 먼저 close 책임과 Wait() 위치부터 의심해 보세요.