Published on

Go 채널 데드락 all goroutines asleep 재현·해결

Authors

서버나 배치 코드를 Go로 작성하다 보면 어느 날 갑자기 프로그램이 멈추고 다음과 같은 메시지를 만나게 됩니다.

fatal error: all goroutines are asleep - deadlock!

이 에러는 “고루틴이 전부 잠들어 있고(블로킹 상태) 앞으로 진행할 수 있는 실행 흐름이 없다”는 런타임의 판단입니다. 채널 송수신, sync.WaitGroup, select 사용 방식이 조금만 어긋나도 재현되며, 특히 로컬에서는 잘 안 터지다가 운영에서만 터지는 경우도 많습니다.

이 글에서는 (1) 가장 흔한 데드락 패턴을 재현하고 (2) 스택 트레이스를 읽어 원인을 좁히고 (3) 코드 레벨에서 안전하게 해결하는 체크리스트까지 정리합니다.

에러 메시지의 의미와 판정 조건

Go 런타임은 일정 시점에 실행 가능한 고루틴이 없고, 모든 고루틴이 아래 같은 “대기 상태”에 있다고 판단하면 패닉을 냅니다.

  • 채널 송신 대기: ch <- v
  • 채널 수신 대기: v := <-ch
  • select에서 모든 케이스가 블로킹이고 default가 없음
  • sync.WaitGroup.Wait()가 영원히 풀리지 않음
  • sync.Mutex.Lock()에서 락을 영원히 기다림

핵심은 “언젠가 누가 깨워줄 것이냐”입니다. 깨워줄 주체가 없으면 데드락이며, 런타임이 이를 감지하면 위 메시지가 발생합니다.

재현 1: 버퍼 없는 채널에 송신만 하고 끝내기

가장 단순한 데드락은 “받는 쪽이 없는데 보내는 쪽이 송신을 시도”할 때입니다.

package main

func main() {
	ch := make(chan int) // unbuffered
	ch <- 1              // receiver가 없으므로 영원히 대기
}

해결

  1. 고루틴으로 보내고 메인에서 받기
package main

import "fmt"

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

	go func() {
		ch <- 1
	}()

	fmt.Println(<-ch)
}
  1. 버퍼 채널로 “일시 저장” 허용하기
package main

func main() {
	ch := make(chan int, 1)
	ch <- 1 // 버퍼에 저장되고 블로킹되지 않음
	_ = <-ch
}

버퍼 채널은 데드락을 “숨길” 수도 있으니, 단순히 패닉을 없애는 용도로만 쓰면 안 됩니다. 설계적으로 송수신 타이밍이 맞는지 먼저 확인하세요.

재현 2: 수신자가 없는데 range ch로 끝까지 읽으려 하기

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)
	}
}

해결: 생산자 쪽에서 반드시 close

package main

import "fmt"

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

	go func() {
		defer close(ch)
		ch <- 1
		ch <- 2
	}()

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

언제 누가 닫아야 하나

  • “보내는 쪽(생산자)”이 닫습니다.
  • 여러 생산자가 하나의 채널에 보내는 구조라면, 개별 생산자가 닫으면 패닉이 나기 쉬우므로 “집계자(closer) 고루틴”을 두고 마지막에 한 번만 닫아야 합니다.

재현 3: WaitGroup 카운트 불일치

WaitGroup 데드락은 현장에서 매우 흔합니다. 특히 Add(1)을 하고 Done() 호출이 누락되면 Wait()가 영원히 풀리지 않습니다.

package main

import "sync"

func main() {
	var wg sync.WaitGroup
	wg.Add(1)

	go func() {
		// wg.Done() 누락
	}()

	wg.Wait() // 영원히 대기
}

해결: 고루틴 시작 직후 defer wg.Done()을 습관화

package main

import "sync"

func main() {
	var wg sync.WaitGroup
	wg.Add(1)

	go func() {
		defer wg.Done()
		// 작업
	}()

	wg.Wait()
}

추가 함정: Add를 고루틴 내부에서 호출

다음 패턴은 레이스 컨디션을 유발합니다. 메인이 먼저 Wait()에 들어가면, 고루틴이 Add(1) 하기 전에 Wait()가 끝나버리거나(조기 종료) 반대로 예상치 못한 동작이 생깁니다.

wg := sync.WaitGroup{}

go func() {
	wg.Add(1)
	defer wg.Done()
	// ...
}()

wg.Wait()

정석은 “고루틴 시작 전에 Add”입니다.

재현 4: select가 전부 블로킹인데 default가 없음

select는 준비된 케이스가 없으면 블로킹합니다. 모든 고루틴이 이런 select에 갇히면 데드락이 됩니다.

package main

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

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

해결 1: 타임아웃 추가

package main

import "time"

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

	select {
	case v := <-ch:
		_ = v
	case <-time.After(200 * time.Millisecond):
		// timeout 처리
	}
}

해결 2: 취소 가능한 컨텍스트 사용

서버 코드에서는 context로 취소를 전파하는 방식이 더 일반적입니다.

package main

import (
	"context"
	"time"
)

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
	defer cancel()

	ch := make(chan int)

	select {
	case v := <-ch:
		_ = v
	case <-ctx.Done():
		// ctx.Err() 확인
	}
}

이 패턴은 “무한 대기”를 “명시적 실패”로 바꾸는 데 의미가 있습니다.

재현 5: 팬아웃-팬인에서 결과 채널을 닫지 않아 수신자가 멈춤

실무에서 가장 자주 터지는 형태는 “여러 워커가 결과를 보내고, 메인은 결과 채널을 range로 읽는다” 구조입니다. 결과 채널을 닫는 주체가 없으면 메인은 영원히 기다립니다.

문제 코드

package main

import (
	"fmt"
	"sync"
)

func main() {
	jobs := []int{1, 2, 3, 4}
	results := make(chan int)

	var wg sync.WaitGroup
	for _, j := range jobs {
		wg.Add(1)
		go func(x int) {
			defer wg.Done()
			results <- x * 2
		}(j)
	}

	// results를 닫지 않으므로 range가 끝나지 않음
	for r := range results {
		fmt.Println(r)
	}
}

해결: “클로저 고루틴”으로 마지막에 한 번만 close(results)

package main

import (
	"fmt"
	"sync"
)

func main() {
	jobs := []int{1, 2, 3, 4}
	results := make(chan int)

	var wg sync.WaitGroup
	for _, j := range jobs {
		wg.Add(1)
		go func(x int) {
			defer wg.Done()
			results <- x * 2
		}(j)
	}

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

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

이 구조가 익숙해지면, “누가 닫을지 애매한 채널” 문제를 대부분 해결할 수 있습니다.

스택 트레이스로 원인 좁히는 법

데드락 패닉이 나면 보통 고루틴 스택이 함께 출력됩니다. 여기서 확인할 포인트는 다음입니다.

  • chan send 또는 chan receive로 멈춘 위치가 어디인지
  • sync.(*WaitGroup).Wait 또는 sync.(*Mutex).Lock에서 멈춘 고루틴이 있는지
  • 메인 고루틴이 어디에서 블로킹 중인지

실전에서는 “메인이 range results에서 멈췄고, 워커는 이미 종료했는데 results가 닫히지 않았다” 같은 패턴이 자주 보입니다.

추가로, 교착 상태는 분산 시스템의 장애 대응과 비슷하게 “관측 가능성”이 중요합니다. 무한 대기 대신 타임아웃, 취소, 로깅을 넣어야 원인을 좁힐 수 있습니다. 이런 관점은 재시도와 큐잉 설계에서도 동일하게 적용됩니다. 예를 들어 Claude API 529 과부하 대응 - 재시도·큐잉 설계에서 말하는 “무한 재시도 대신 제한과 관측을 둔다”는 원칙은 채널 대기에도 그대로 통합니다.

데드락을 줄이는 설계 체크리스트

1) 채널의 소유권을 명확히 하기

  • 누가 보내는가
  • 누가 닫는가
  • 누가 읽고 언제 종료되는가

특히 close는 “방송”이라서, 닫히면 모든 수신자가 종료 조건을 맞습니다. 따라서 닫는 주체는 하나여야 합니다.

2) range ch를 쓰면 종료 조건을 반드시 함께 설계

  • 생산자가 defer close(ch)를 하는지
  • 다중 생산자라면 WaitGroup 기반의 클로저 고루틴이 있는지

3) 버퍼 채널은 용량을 근거로 정하기

버퍼를 크게 잡으면 일시적으로는 멈추지 않지만, 결국 소비가 따라가지 못하면 메모리와 지연이 누적됩니다. 이는 캐시가 “미스가 나면 끝까지 느려지는” 문제와 닮아 있습니다. 캐시 키 설계가 중요하듯, 채널 버퍼도 “왜 이 크기인가”가 있어야 합니다. 관련해서는 GitHub Actions 캐시가 안 먹을 때 key 전략과 디버깅처럼 원인을 추적 가능한 형태로 만드는 접근이 도움이 됩니다.

4) 무한 대기는 피하고 타임아웃 또는 취소를 넣기

  • 외부 I/O, RPC, 작업 큐 대기에는 context를 기본으로
  • 내부 파이프라인에서도 “최대 대기 시간”을 두면 장애 분석이 빨라짐

5) 워커 풀은 “입력 종료”와 “출력 종료”를 분리

권장 패턴은 다음 3요소입니다.

  • jobs 채널: 생산자가 닫음
  • 워커들: for j := range jobs로 종료
  • results 채널: 워커가 직접 닫지 않고, 별도 고루틴이 wg.Wait() 후 닫음

이 패턴은 파이프라인이 길어져도 안정적으로 확장됩니다.

실전 예제: 안전한 워커 풀 템플릿

아래 코드는 데드락 방지 요소를 모두 포함한 “기본형”입니다.

  • jobs는 생산자가 닫음
  • 워커는 range jobs로 자연 종료
  • results는 클로저 고루틴이 닫음
  • 메인은 range results로 수집
package main

import (
	"fmt"
	"sync"
)

func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
	defer wg.Done()
	for j := range jobs {
		results <- j * 10
	}
}

func main() {
	jobs := make(chan int)
	results := make(chan int)

	var wg sync.WaitGroup
	workerN := 3
	wg.Add(workerN)
	for i := 0; i < workerN; i++ {
		go worker(i, jobs, results, &wg)
	}

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

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

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

이 템플릿을 기준으로 변경할 때는, “닫는 주체가 여전히 하나인지”, “종료 조건이 여전히 존재하는지”만 확인해도 데드락의 상당수를 예방할 수 있습니다.

마무리

all goroutines asleep - deadlock은 단순히 “채널을 잘못 썼다”가 아니라, 동시성 설계에서 종료 조건과 소유권이 불명확하다는 신호인 경우가 많습니다.

  • 송수신이 반드시 짝을 이루는지
  • range를 쓴다면 close가 보장되는지
  • WaitGroup 카운트가 정확히 맞는지
  • 무한 대기 대신 타임아웃과 취소가 있는지

위 체크리스트와 워커 풀 템플릿을 기준으로 코드를 정리하면, 데드락 재현이 쉬워지고 해결도 빨라집니다. 또한 운영 장애 분석 관점에서는 “관측 가능하게 만든다”가 핵심이므로, 무한 대기 지점을 줄이고 실패를 명시적으로 드러내는 방향으로 설계를 바꾸는 것이 장기적으로 가장 효과적입니다.