Published on

Go 채널 데드락 3종 - nil·버퍼·range 종료

Authors

서버를 돌리다 보면 “가끔 멈춘다”는 증상은 대개 두 가지로 수렴합니다. CPU 0%로 멎어 있고, goroutine은 계속 살아 있으며, goroutine dump를 보면 채널 송수신에서 서로 기다리는 상태입니다. 즉 데드락(deadlock)입니다.

Go의 채널 데드락은 복잡한 알고리즘보다 사소한 설계/관례 누락에서 많이 발생합니다. 특히 아래 3종은 실무에서 빈도도 높고, 재현도 쉽고, 한 번 걸리면 디버깅 비용이 큽니다.

  • nil 채널: 의도치 않게 영원히 블록
  • 버퍼 채널: 생산/소비 속도 불균형으로 교착 또는 정지
  • range 종료: close 누락으로 소비자가 끝나지 않음

아래에서 각각을 “재현 코드 → 왜 멈추는지 → 안전한 해결 패턴” 순서로 정리합니다.

운영 환경에서 이런 문제가 “goroutine 누수”로 확장되는 경우가 많습니다. 채널 블록이 컨텍스트 취소와 결합되지 않으면 영구 대기 상태가 쌓입니다. 관련 디버깅 흐름은 Go goroutine 누수 잡기 - pprof+context 실전도 함께 참고하면 좋습니다.

1) nil 채널 데드락: 선택적으로 쓰려다 영구 블록

증상

  • 송신 ch <- v 또는 수신 <-ch절대 깨어나지 않음
  • select에서 해당 case가 영원히 선택되지 않음

Go에서 nil 채널은 특별합니다.

  • nil 채널에 대한 송신/수신은 영원히 블록
  • select에서 nil 채널 case는 비활성화(disabled) 된 것처럼 동작

이 특성은 “조건부로 채널을 켜고 끄기”에 유용하지만, 반대로 초기화 누락이나 조건 분기 실수가 있으면 즉시 데드락으로 이어집니다.

재현 코드: 초기화 누락

package main

import "fmt"

func main() {
	var ch chan int // nil
	ch <- 1         // 영원히 블록: fatal error: all goroutines are asleep - deadlock!
	fmt.Println("unreachable")
}

재현 코드: select로 조건부 처리하다가 “아무 것도 안 함” 상태

package main

import (
	"fmt"
	"time"
)

func main() {
	var out chan int // nil
	in := make(chan int)

	go func() {
		in <- 42
		close(in)
	}()

	for v := range in {
		select {
		case out <- v: // out이 nil이면 이 case는 비활성화
			fmt.Println("sent")
		default:
			// out이 nil이면 항상 default로 빠진다.
			// 문제는 여기서 v를 버리거나, 다른 종료 조건이 없으면 의도와 다르게 정지/유실이 발생.
			fmt.Println("dropped", v)
		}
	}

	time.Sleep(100 * time.Millisecond)
}

이 코드는 데드락 대신 “조용한 유실”로 끝나지만, 실무에서는 보통 default가 없거나, for 루프가 다른 곳에서 값을 기다리며 교착을 만들기도 합니다.

해결 패턴

(1) 채널은 생성 시점 명확히: make를 늦추지 말기

ch := make(chan int) // 또는 make(chan int, N)

(2) “선택적 송신”은 nil 채널 토글을 의도적으로 쓰되, 상태를 명시

var out chan int
if enable {
	out = make(chan int, 16)
}

select {
case out <- v:
	// enable일 때만 가능
case <-time.After(200 * time.Millisecond):
	// 타임아웃으로 빠져나올 구멍을 만든다.
}

(3) 블로킹 연산은 context 또는 타임아웃과 함께

채널 블록이 “영원히” 지속되는 게 문제이므로, 종료 신호를 같이 설계합니다.

select {
case ch <- v:
case <-ctx.Done():
	return ctx.Err()
}

2) 버퍼 채널 데드락: “버퍼가 있으니 안전”이라는 착각

버퍼 채널은 비동기처럼 보이지만, 버퍼가 꽉 차는 순간부터는 동기 채널과 동일하게 블록합니다. 따라서 생산자가 소비자보다 빠르면 결국 멈춥니다.

재현 코드: 소비자가 없는데 계속 생산

package main

func main() {
	ch := make(chan int, 2)
	ch <- 1
	ch <- 2
	ch <- 3 // 버퍼가 가득 차서 블록 -> 데드락
}

실무형 재현: 워커 풀에서 결과 수집이 막혀 전체 정지

아래는 흔한 패턴입니다.

  • 작업 goroutine들이 results로 결과를 보냄
  • 메인 goroutine이 어떤 이유로 results를 충분히 빨리 읽지 못함
  • results 버퍼가 차면 작업 goroutine이 막히고, WaitGroup이 끝나지 않아 전체가 멈춤
package main

import (
	"fmt"
	"sync"
)

func main() {
	jobs := make(chan int)
	results := make(chan int, 4) // 작다고 가정

	var wg sync.WaitGroup
	for i := 0; i < 8; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for j := range jobs {
				// 결과 전송이 막히면 여기서 워커가 정지
				results <- (j * 2)
			}
		}()
	}

	go func() {
		for j := 0; j < 100; j++ {
			jobs <- j
		}
		close(jobs)
	}()

	// (의도적 버그) 결과를 다 읽지 않음
	for i := 0; i < 3; i++ {
		fmt.Println(<-results)
	}

	// wg.Wait()는 워커가 results에 막혀 끝나지 않을 수 있다.
	wg.Wait()
}

왜 이런 일이 생기나

  • 버퍼는 “속도 차이를 잠시 흡수”할 뿐
  • 생산량이 무제한이면 언젠가 버퍼는 찬다
  • 특히 wg.Wait()와 결합되면, “워커가 결과 전송에서 막힘 → wg가 끝나지 않음 → 종료 루틴도 못 감” 형태로 교착이 된다

해결 패턴

(1) 결과 수집 루프를 끝까지 돌린다

가장 기본은 “보낸 만큼 반드시 받는다”입니다. range results로 끝까지 읽으려면 누군가 close(results)를 해야 합니다.

go func() {
	wg.Wait()
	close(results)
}()

for r := range results {
	fmt.Println(r)
}

(2) results를 소비하는 전용 goroutine을 둔다

메인 goroutine이 다른 일을 하느라 결과를 못 읽는 구조라면, 아예 소비 전용 루틴을 두고 내부 큐/배치로 넘깁니다.

sinkDone := make(chan struct{})

go func() {
	defer close(sinkDone)
	for r := range results {
		_ = r // 저장/집계/로그
	}
}()

// ... 작업 진행 ...

<-sinkDone

(3) 백프레셔(backpressure)를 설계한다

버퍼를 크게 키우는 건 임시 처방일 뿐입니다. “막히는 게 정상”인 지점을 설계해야 합니다.

  • 생산자가 무한히 밀어넣지 않도록 ctx.Done()을 반영
  • 배치 처리, rate limit, worker 수 제한
  • 드롭 정책이 필요하면 select + default로 명시적 드롭(단, 유실을 허용하는 경우에만)
select {
case results <- r:
case <-ctx.Done():
	return
}

3) range 종료 데드락: close를 안 해서 영원히 기다림

for v := range ch는 채널에서 값을 계속 읽다가 채널이 닫히고(close) 버퍼가 비면 종료합니다.

핵심은 하나입니다.

  • 송신자가 close(ch)를 하지 않으면, 수신자는 “더 올 값이 있나?”를 영원히 기다릴 수 있음

재현 코드: close 누락

package main

import "fmt"

func main() {
	ch := make(chan int)

	go func() {
		ch <- 1
		ch <- 2
		// close(ch) 누락
	}()

	for v := range ch {
		fmt.Println(v)
	}

	fmt.Println("done") // 도달하지 않음
}

자주 나오는 실수: 여러 송신자가 있을 때 누가 닫나?

  • 채널은 송신자 쪽에서 닫는 것이 원칙
  • 하지만 송신자가 여러 명이면 “누가 닫지?”가 애매해지고, 그 결과 아무도 안 닫아서 데드락이 난다
  • 혹은 여러 명이 닫으려다 panic: close of closed channel이 난다

해결 패턴

(1) 닫는 책임을 “단 한 곳”으로 모은다 (fan-in / coordinator)

여러 생산자가 있을 때는 보통 다음 구조가 안전합니다.

  • 생산자들은 값만 보낸다
  • 코디네이터가 생산자 WaitGroup을 기다렸다가 채널을 닫는다
package main

import (
	"fmt"
	"sync"
)

func main() {
	out := make(chan int, 8)

	var wg sync.WaitGroup
	producers := 3
	wg.Add(producers)

	for p := 0; p < producers; p++ {
		go func(base int) {
			defer wg.Done()
			for i := 0; i < 5; i++ {
				out <- base*10 + i
			}
		}(p)
	}

	go func() {
		wg.Wait()
		close(out) // close는 오직 여기서만
	}()

	for v := range out {
		fmt.Println(v)
	}
}

(2) 종료 신호가 필요하면 close(done)를 브로드캐스트로 사용

값 채널과 종료 채널을 분리하면, “값 채널은 닫기 애매한데 종료는 알려야” 하는 상황을 깔끔하게 처리할 수 있습니다.

done := make(chan struct{})

select {
case v := <-ch:
	_ = v
case <-done:
	return
}

// 종료 시점
close(done)

(3) range 대신 수신 + ok로 종료 조건을 명시

range는 간결하지만, 종료 조건이 숨겨져 있습니다. 디버깅이 어려운 코드라면 아래처럼 드러내는 것도 방법입니다.

for {
	v, ok := <-ch
	if !ok {
		break
	}
	_ = v
}

데드락을 빨리 찾는 체크리스트

실무에서 “왜 멈췄지?”를 빠르게 좁히는 질문들입니다.

  1. 이 채널이 nil일 가능성이 있는가? (조건부 초기화, struct 필드 기본값)
  2. 누가 close를 호출해야 하는가? 그 책임이 코드에서 단 한 곳으로 보이는가?
  3. 버퍼 크기는 “평균”이 아니라 “최악의 순간”을 견딜 수 있는가?
  4. 생산/소비 루프에 ctx.Done() 같은 탈출구가 있는가?
  5. WaitGroup이 채널 송수신 블록과 엮여 있지는 않은가?

채널 데드락은 단독으로 끝나지 않고 goroutine이 쌓여 메모리/FD/커넥션 같은 2차 장애로 번지는 경우가 많습니다. 특히 네트워크 스트리밍이나 RPC 핸들러에서 채널로 파이프라인을 만들었다면, 프로파일링/덤프 기반으로 원인을 좁히는 습관이 중요합니다. 이 흐름은 Go gRPC 메모리 누수? bufconn·pprof 추적법에서도 유사한 방식으로 접근합니다.

마무리: 3종을 “규칙”으로 외우면 예방된다

정리하면, Go 채널 데드락은 아래 3가지 규칙으로 상당 부분 예방됩니다.

  • nil 채널은 영원히 블록한다: 조건부 토글은 의도적으로, 초기화는 명시적으로
  • 버퍼는 안전장치가 아니라 지연 장치다: 생산/소비 균형과 종료 경로를 같이 설계
  • rangeclose가 있어야 끝난다: close 책임을 한 곳으로 모으고, 필요하면 done 채널로 종료를 브로드캐스트

이 3가지만 팀 규약으로 잡아도 “가끔 멈춤” 류의 장애가 체감상 크게 줄어듭니다.