Published on

Go 채널 데드락 - select·close·range 함정 9가지

Authors

서버나 워커를 Go로 만들다 보면 CPU는 놀고 있는데 프로세스가 멈춰 있는 상황을 자주 만납니다. 로그는 더 이상 찍히지 않고, go test 는 끝나지 않으며, 결국 fatal error: all goroutines are asleep - deadlock! 같은 메시지로 마무리되죠.

채널 데드락은 대부분 값 흐름과 종료 신호의 설계가 어긋난 것에서 시작합니다. 특히 select, close, range 의 조합은 “작동하는 것처럼 보이지만 특정 타이밍에만 멈추는” 함정을 만들기 쉽습니다.

이 글은 실무에서 자주 터지는 9가지 패턴을 재현 코드로 보여주고, 각각을 어떻게 고쳐야 하는지까지 정리합니다.

참고: 운영 환경에서 데드락은 단독으로 오지 않습니다. 디스크 압박이나 OOM 같은 리소스 이슈로 워커가 죽으면서 채널이 영원히 읽히지 않는 상황도 함께 발생합니다. 쿠버네티스 환경이라면 K8s OOMKilled 반복? cgroup v2 메모리 진단, EKS DiskPressure로 Pod Evicted 폭주 해결 10가지도 같이 점검해보세요.

데드락을 보는 기본 관점

Go 채널에서 데드락은 크게 3가지로 나뉩니다.

  1. 영원히 보내는 쪽: 수신자가 없어서 send 가 블록
  2. 영원히 받는 쪽: 송신자가 없거나 종료 신호가 없어 recv 가 블록
  3. 종료 설계 실패: 일부 고루틴만 끝나고 나머지가 채널/WaitGroup 에서 대기

이 글의 9가지 함정은 모두 위 분류 중 하나에 들어갑니다.

함정 1: select 에서 default 로 바쁜 루프 만들기

default 는 “즉시 실행 가능한 case가 없으면 실행”입니다. 종료 조건이 없거나, 종료 신호를 제대로 확인하지 않으면 CPU를 태우면서도 작업은 진행되지 않는 상태가 됩니다.

package main

import (
	"fmt"
	"time"
)

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

	go func() {
		time.Sleep(200 * time.Millisecond)
		ch <- 1
	}()

	for {
		select {
		case v := <-ch:
			fmt.Println("got", v)
			return
		default:
			// 아무 것도 못 받는 동안 계속 돈다
		}
	}
}

해결

  • default 를 제거하고 블로킹으로 기다리기
  • 또는 time.Tickertime.Sleep 로 백오프
  • 또는 context 로 종료를 명시
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()

for {
	select {
	case v := <-ch:
		fmt.Println("got", v)
		return
	case <-ctx.Done():
		return
	}
}

함정 2: select 에서 nil 채널을 모르고 기다리기

nil 채널의 send/recv 는 영원히 블록됩니다. select 에서도 마찬가지라, 특정 case를 nil로 만들어 “비활성화”하려는 의도가 아니라면 치명적입니다.

var ch chan int // nil

select {
case v := <-ch:
	_ = v
	// 절대 실행되지 않음
case <-time.After(100 * time.Millisecond):
	// 여기로만 빠진다
}

해결

  • 채널 생성 누락을 테스트에서 잡기
  • 의도적 비활성화라면 주석과 함께 패턴을 명확히
if ch == nil {
	return fmt.Errorf("channel is nil")
}

함정 3: range chclose 없으면 끝나지 않는다

for v := range 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)
	}
}

해결

  • “누가 닫는가”를 명확히: 송신자(생산자) 단 하나만 닫는다
go func() {
	defer close(ch)
	ch <- 1
	ch <- 2
}()

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

함정 4: 여러 송신자가 close 를 경쟁해서 패닉 또는 교착으로 번지기

채널은 한 번만 닫을 수 있습니다. 여러 고루틴이 각자 close(ch) 를 시도하면 panic: close of closed channel 이 나고, 복구 로직이 잘못되면 데드락으로 이어질 수 있습니다.

ch := make(chan int)

for i := 0; i < 2; i++ {
	go func() {
		defer close(ch) // 경쟁
		ch <- 1
	}()
}

for range ch {
}

해결

  • 닫는 책임을 “집계자(aggregator)” 한 곳으로 모으기
  • 송신자는 종료를 WaitGroup 으로 알리고, 집계자가 close 수행
var wg sync.WaitGroup
ch := make(chan int)

for i := 0; i < 2; i++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		ch <- 1
	}()
}

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

for v := range ch {
	_ = v
}

함정 5: close 후에 send 해서 패닉, 그리고 수신자는 영원히 대기

닫힌 채널로 send 하면 패닉입니다. 문제는 패닉으로 송신 고루틴이 죽고, 다른 경로에서 close 가 호출되지 않거나 WaitGroupDone 되지 않으면 남은 고루틴이 영원히 기다리게 됩니다.

ch := make(chan int)
close(ch)
ch <- 1 // panic

해결

  • 채널 close 는 “더 이상 send 하지 않는다”는 계약
  • 종료 신호용 채널과 데이터 채널을 분리
  • context 를 종료 전파에 사용

권장 패턴은 데이터 채널은 닫거나, 혹은 닫지 않고 context 로 종료시키되 한 가지 방식으로 통일하는 것입니다.

함정 6: 수신자가 사라졌는데 송신자가 블록되는 팬아웃

워커 풀에서 흔합니다. 소비자가 조기 종료하거나 에러로 리턴했는데 생산자는 계속 send 하다가 막힙니다.

jobs := make(chan int)

go func() {
	for i := 0; i < 100; i++ {
		jobs <- i // 워커가 없으면 여기서 블록
	}
	close(jobs)
}()

// 워커를 안 띄우거나, 도중에 리턴해버리면 데드락 가능

해결

  • 워커가 언제든 종료될 수 있음을 가정하고, 생산자도 context 를 본다
  • 또는 버퍼 채널로 순간 폭주를 흡수하되, 근본적으로는 종료 전파가 필요
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

jobs := make(chan int)

go func() {
	defer close(jobs)
	for i := 0; i < 100; i++ {
		select {
		case jobs <- i:
		case <-ctx.Done():
			return
		}
	}
}()

함정 7: select 로 에러를 먼저 리턴하면서 결과 채널을 안 비우기

여러 고루틴이 결과를 보내는데, 메인 고루틴이 에러를 받자마자 리턴하면 나머지 송신자들이 결과 채널 send 에서 블록됩니다.

results := make(chan int)
errs := make(chan error)

// 여러 워커가 results 로 보냄

select {
case err := <-errs:
	_ = err
	return // results 를 더 이상 읽지 않음
case v := <-results:
	_ = v
}

해결

  • “조기 종료” 시에도 송신자들이 빠져나오도록 context cancel 을 전파
  • 또는 별도의 드레이너 goroutine 으로 results 를 끝까지 소비
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

// 에러를 받으면 cancel
select {
case err := <-errs:
	_ = err
	cancel()
case v := <-results:
	_ = v
}

함정 8: time.After 를 루프에서 매번 생성해 누수처럼 쌓이고 타이밍 데드락 유발

time.After 는 타이머 객체를 만들고 채널을 반환합니다. 루프에서 고빈도로 만들면 GC 압박과 함께, 타이머 채널이 쌓여 스케줄링이 꼬이면서 “왜 멈춘 것처럼 보이지” 같은 증상을 만듭니다.

for {
	select {
	case v := <-ch:
		_ = v
	case <-time.After(10 * time.Millisecond):
		// 반복 생성
	}
}

해결

  • time.NewTicker 를 만들고 재사용
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()

for {
	select {
	case v := <-ch:
		_ = v
	case <-ticker.C:
		// 주기 작업
	}
}

함정 9: WaitGroup 과 채널 종료 순서가 뒤집혀 range 가 영원히 대기

전형적인 팬인 패턴에서 wg.Wait() 를 메인에서 먼저 기다리고, 채널 close 가 그 뒤에 오면 range 는 끝나지 않습니다. 혹은 반대로 close 를 너무 일찍 해서 송신자가 패닉이 나기도 합니다.

잘못된 예:

var wg sync.WaitGroup
out := make(chan int)

for i := 0; i < 3; i++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		out <- 1
	}()
}

wg.Wait()
close(out)

for v := range out { // 이미 wg.Wait 에서 막히면 여기 못 옴
	_ = v
}

해결

  • close 는 별도 고루틴에서 wg.Wait() 후 수행
  • 메인 고루틴은 range 로 소비만 담당
var wg sync.WaitGroup
out := make(chan int)

for i := 0; i < 3; i++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		out <- 1
	}()
}

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

sum := 0
for v := range out {
	sum += v
}
_ = sum

데드락 예방 체크리스트

  • 채널마다 역할을 문서화: 데이터 채널인지, 종료 신호 채널인지
  • close 책임자는 단 하나로 고정
  • range 를 쓸 때는 “반드시 닫힌다”가 증명되어야 함
  • 조기 리턴이 가능한 경로에는 context cancel 을 넣어 송신자 블록을 방지
  • selectdefault 는 성능 최적화가 아니라 “폴링”임을 인지
  • 루프에서 time.After 남발 금지, Ticker 재사용
  • 테스트에서 데드락을 빨리 잡기: go test -race 와 타임아웃을 기본으로

실전 디버깅 팁

  • runtime/pprof 로 goroutine dump 를 떠서 어떤 고루틴이 어떤 채널에서 막혔는지 확인
  • SIGQUIT 로 스택 덤프를 받아 채널 send/recv 지점을 찾기
  • 쿠버네티스라면 리소스 이슈로 워커가 죽어 채널이 영원히 비워지지 않는지 함께 점검

동시성 버그는 “재현이 어렵다”가 핵심 난이도입니다. 하지만 위 9가지 패턴을 코드 리뷰 체크리스트로 만들어두면, 채널 데드락의 상당수를 설계 단계에서 차단할 수 있습니다.