Published on

Go 채널 데드락 - select·close·range 실전 디버깅

Authors

서버나 워커 풀을 Go로 짜다 보면, 어느 날 갑자기 로그가 멈추고 CPU는 조용한데 프로세스가 끝나지 않는 상황을 만나게 됩니다. 대개는 채널 송수신이 서로를 기다리는 데드락입니다. 문제는 select, close, range가 섞이기 시작하면 “어디서 막혔는지”가 잘 안 보인다는 점입니다.

이 글은 채널 데드락을 재현 가능한 대표 패턴으로 분해하고, select·close·range 조합에서 자주 터지는 함정을 실전 디버깅 루틴으로 정리합니다. 데이터베이스 데드락을 로그로 좁혀가듯, Go도 스택과 이벤트를 통해 범인을 특정할 수 있습니다. 비슷한 접근으로는 MySQL InnoDB 데드락(1213) 로그로 범인 찾기 글의 “증거 기반” 흐름이 참고가 됩니다.

채널 데드락이 생기는 핵심 메커니즘

Go에서 데드락은 보통 아래 셋 중 하나로 귀결됩니다.

  1. 송신자만 있고 수신자가 없다: 버퍼가 꽉 찼거나(버퍼드 채널), 언버퍼드 채널에서 수신자가 사라짐
  2. 수신자만 있고 송신자가 없다: range ch가 끝나지 않는데 송신자가 더 이상 보내지 않음(그리고 close도 안 함)
  3. 종료 신호의 소유권이 불명확: 누가 close를 하는지, 누가 워커 종료를 책임지는지 애매해서 서로 기다림

여기에 select가 들어오면 더 복잡해집니다. select는 “여러 케이스 중 준비된 것 하나”를 고르는데, 준비된 것이 없으면 블록합니다. 이 블록이 의도인지 버그인지가 관건입니다.

가장 흔한 패턴 1: range가 끝나지 않는 데드락

증상

  • 생산자 goroutine은 이미 종료
  • 소비자는 for v := range ch에서 영원히 대기
  • 프로그램이 끝나지 않거나, WaitGroupWait()에서 멈춤

재현 코드

package main

import (
	"fmt"
	"sync"
)

func main() {
	ch := make(chan int)
	var wg sync.WaitGroup

	wg.Add(1)
	go func() {
		defer wg.Done()
		for v := range ch { // close가 없으면 절대 끝나지 않음
			fmt.Println(v)
		}
	}()

	// 생산자: 3개 보내고 종료하지만 close를 안 함
	for i := 0; i < 3; i++ {
		ch <- i
	}

	wg.Wait() // 여기서 멈출 수 있음
}

원인

range ch채널이 닫힐 때까지 반복합니다. “더 이상 보낼 게 없다”와 “채널이 닫혔다”는 다른 상태입니다.

해결

  • 생산자가 단일이면 생산자가 close(ch)를 책임진다.
  • 생산자가 여러 개면 close는 생산자들이 아니라 집계자(aggregator) 가 책임지고, 생산자 완료를 WaitGroup 등으로 모은 뒤 한 번만 닫는다.
var prodWG sync.WaitGroup
ch := make(chan int)

for p := 0; p < 3; p++ {
	prodWG.Add(1)
	go func(id int) {
		defer prodWG.Done()
		ch <- id
	}(p)
}

go func() {
	prodWG.Wait()
	close(ch) // 단 한 곳에서만 close
}()

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

가장 흔한 패턴 2: close 소유권 혼란과 send on closed channel

데드락만큼 자주 함께 등장하는 것이 panic: send on closed channel 입니다. “데드락을 고치려고 close를 넣었더니 panic이 난다”는 전형적인 수순이죠.

원칙: 채널은 보통 “보내는 쪽”이 닫는다

  • 수신자는 닫지 않는다: 수신자가 닫으면, 아직 보내려던 송신자들이 터진다.
  • 예외적으로, 수신자가 송신자에게 “그만 보내라”는 의미로 닫는 패턴은 권장되지 않는다. 대신 context나 별도 done 채널을 쓴다.

잘못된 예

// 소비자가 "이제 충분"하다고 채널을 닫아버림
for v := range ch {
	if v == 10 {
		close(ch) // 다른 송신자가 있으면 panic 위험
		break
	}
}

권장 패턴: 취소는 context, 데이터 채널은 생산자가 close

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 <-ctx.Done():
			return
		case jobs <- i:
		}
	}
}()

// 소비자
for j := range jobs {
	if j == 10 {
		cancel() // "그만"은 cancel로
	}
}

패턴 3: select에서 nil 채널이 섞이며 영원히 대기

nil 채널은 송수신이 영원히 블록됩니다. 이 특성은 동적 select에서 케이스를 “비활성화”할 때 유용하지만, 의도치 않게 nil이 들어오면 데드락이 됩니다.

재현 코드

var ch chan int // nil
select {
case v := <-ch:
	_ = v
default:
	// default가 없으면 여기서 영원히 블록
}

실전에서 흔한 형태

  • 조건에 따라 입력 채널을 바인딩하다가, 어떤 분기에서 채널을 초기화하지 않음
  • 맵에서 채널을 꺼내는데 키가 없어서 nil이 반환

디버깅 포인트

  • select에 들어가기 전, 채널 변수가 nil인지 로그로 확인
  • “동적으로 끄기”를 의도했다면, 반드시 default 또는 타임아웃 케이스를 둔다
select {
case v := <-ch:
	_ = v
case <-time.After(2 * time.Second):
	// 타임아웃으로 교착 감지
}

패턴 4: select + default로 인한 바쁜 루프와 종료 누락

default는 “준비된 케이스가 없으면 즉시 실행”입니다. 이것이 종료 신호를 놓치게 만들거나, CPU를 태우는 바쁜 루프가 되기도 합니다.

나쁜 예: default로 폴링

for {
	select {
	case v := <-ch:
		_ = v
	case <-done:
		return
	default:
		// 아무 것도 없으면 계속 회전
	}
}

이 코드는 데드락이라기보다 “멈춘 것처럼 보이는데 실제로는 루프가 돌며 다른 goroutine이 진행을 못 하는” 상황을 만들 수 있습니다(특히 로그/락/스케줄링이 얽히면).

개선

  • 폴링이 필요하면 time.Ticker로 속도를 제한
  • 진짜로 블록해도 되는 구조라면 default를 제거
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()

for {
	select {
	case v, ok := <-ch:
		if !ok {
			return
		}
		_ = v
	case <-done:
		return
	case <-ticker.C:
		// 주기적 작업
	}
}

패턴 5: 버퍼드 채널이 꽉 차면서 생산자가 멈추고, 소비자는 다른 곳에서 대기

버퍼드 채널은 “약간의 비동기”를 주지만, 결국 버퍼가 차면 송신은 블록합니다. 소비자가 select에서 다른 케이스만 기다리거나, WaitGroup 순서가 꼬이면 교착이 됩니다.

전형적인 꼬임

  • 메인 goroutine이 wg.Wait()를 먼저 호출
  • 워커는 결과를 results <- x로 보내야 종료
  • 그런데 결과를 받는 소비자가 아직 시작되지 않음
results := make(chan int, 1)

wg.Add(1)
go func() {
	defer wg.Done()
	results <- 1 // 버퍼가 차면 여기서 블록
}()

wg.Wait() // 소비자가 results를 읽기 전에 기다림
fmt.Println(<-results)

해결

  • 결과 소비를 먼저 시작하거나
  • 결과 채널을 충분히 버퍼링하거나
  • 아예 워커가 결과 송신을 하지 않아도 종료 가능하도록 구조를 바꾼다
results := make(chan int, 1)

wg.Add(1)
go func() {
	defer wg.Done()
	results <- 1
}()

v := <-results // 먼저 수신
wg.Wait()
fmt.Println(v)

closerange의 실전 규칙 정리

close 규칙

  • 닫는 주체는 “더 이상 값을 보내지 않을 것을 보장할 수 있는” 송신 측
  • 여러 송신자라면 송신자가 닫지 말고, 완료를 모아 한 곳에서 닫기
  • close는 한 번만 가능. 중복 closepanic.

range 규칙

  • range chclose가 없으면 끝나지 않는다
  • range로 읽는 쪽은 ok를 직접 체크할 수 없으니, 종료 조건이 close에만 의존한다
  • 중간에 빠져나가야 한다면 break만으로는 송신자를 멈추지 못한다. context 또는 done으로 생산자도 멈추게 해야 한다

데드락 디버깅 루틴: 재현, 스택, 증거 수집

채널 데드락은 “감”으로 보면 오래 걸립니다. 아래 순서로 증거를 모으면 빠르게 좁혀집니다.

1) 런타임 데드락 메시지 확인

모든 goroutine이 잠들면 Go 런타임이 종종 아래와 같은 메시지를 냅니다.

  • fatal error: all goroutines are asleep - deadlock!

이 메시지가 나오면 “어디선가 블록 중”이라는 사실은 확정입니다.

2) goroutine 스택 덤프 보기

가장 강력한 1차 증거는 goroutine 덤프입니다.

  • SIGQUIT를 보내면 표준 에러로 goroutine 스택이 출력됩니다
kill -QUIT $(pidof your-binary)

출력에서 다음을 찾습니다.

  • chan send 또는 chan receive로 멈춘 지점
  • 동일한 채널 변수(대개 파일명/라인이 힌트)
  • select 내부에서 멈춘 goroutine

3) 타임아웃을 “가드레일”로 넣어 교착을 조기 감지

영구 블록이 가능한 지점(특히 select 또는 채널 송수신)에 제한 시간을 두면, 장애가 “무한 대기”가 아니라 “명시적 에러”로 바뀝니다.

select {
case v := <-ch:
	_ = v
case <-time.After(3 * time.Second):
	return fmt.Errorf("receive timeout")
}

4) 채널 방향을 타입으로 고정해 실수를 줄이기

API 경계에서 송신 전용/수신 전용을 타입으로 강제하면, close 주체나 송수신 방향 혼동이 줄어듭니다.

func producer(out chan<- int) {
	defer close(out)
	for i := 0; i < 10; i++ {
		out <- i
	}
}

func consumer(in <-chan int) {
	for v := range in {
		_ = v
	}
}

5) 경쟁 조건과 교착을 같이 본다

교착은 종종 레이스와 동반됩니다. 예를 들어 “어떤 goroutine이 먼저 close 하느냐” 같은 문제는 타이밍에 따라 데드락 또는 panic으로 바뀝니다.

go test -race ./...

레이스가 잡히면 교착의 원인도 함께 드러나는 경우가 많습니다.

select를 안전하게 쓰는 패턴들

패턴 A: 종료 신호를 항상 우선순위로 처리

select는 우선순위를 보장하지 않습니다. 하지만 “매 반복마다 done을 먼저 확인”하는 구조로 종료 지연을 줄일 수 있습니다.

for {
	select {
	case <-ctx.Done():
		return
	default:
	}

	select {
	case v, ok := <-ch:
		if !ok {
			return
		}
		_ = v
	case <-ctx.Done():
		return
	}
}

패턴 B: fan-in에서 close를 한 곳에서만

여러 입력을 하나로 합치는 fan-in은 close 책임이 특히 중요합니다.

func fanIn(ctx context.Context, ins ...<-chan int) <-chan int {
	out := make(chan int)
	var wg sync.WaitGroup

	forward := func(in <-chan int) {
		defer wg.Done()
		for {
			select {
			case <-ctx.Done():
				return
			case v, ok := <-in:
				if !ok {
					return
				}
				select {
				case <-ctx.Done():
					return
				case out <- v:
				}
			}
		}
	}

	wg.Add(len(ins))
	for _, in := range ins {
		go forward(in)
	}

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

	return out
}

여기서 핵심은 out을 닫는 곳이 오직 한 군데라는 점입니다.

체크리스트: 데드락을 발견했을 때 바로 확인할 것

  • range ch가 끝나야 하는데, 채널을 누가 닫는가
  • 송신자가 여러 개인데 누군가가 close를 하고 있지는 않은가
  • selectdefault가 있어 바쁜 루프가 되거나, 종료 신호 처리가 지연되지 않는가
  • nil 채널이 select에 섞여 영원히 블록되지 않는가
  • 버퍼드 채널이 꽉 찬 뒤 송신자가 블록되고, 소비자는 Wait() 등 다른 곳에서 대기하고 있지 않은가
  • goroutine 덤프에서 chan send 또는 chan receive가 어디서 멈췄는가

마무리: 설계로 예방하고, 스택으로 단번에 잡기

Go 채널 데드락은 “채널을 썼다”가 아니라 “종료와 소유권을 명확히 정의하지 않았다”에서 시작하는 경우가 많습니다. close는 단 한 곳에서, rangeclose가 보장될 때만, select는 nil 채널과 default의 의미를 정확히 알고 쓰는 것이 핵심입니다.

장애가 이미 났다면, 추측 대신 goroutine 스택 덤프와 타임아웃 가드레일로 증거를 모으세요. 데이터베이스 데드락을 로그로 추적하듯(MySQL InnoDB 데드락(1213) 로그로 범인 찾기), Go도 “멈춘 지점”을 보면 대부분 한 번에 결론이 납니다.