Published on

Go 채널 데드락 7패턴 - select·close·nil

Authors

서버나 워커를 Go로 만들다 보면, CPU도 놀고 있고 GC도 조용한데 프로세스가 멈춘 듯 보이는 순간이 있습니다. 로그는 끊기고, 요청은 쌓이고, goroutine 수는 애매하게 유지되다가 결국 fatal error: all goroutines are asleep - deadlock! 같은 메시지로 끝나기도 합니다.

채널 기반 동시성에서 데드락은 대개 “누가 언제 보내고, 누가 언제 닫고, 누가 언제 빠져나가는가”라는 계약이 어긋나서 생깁니다. 특히 select, close, nil 채널은 강력하지만, 잘못 쓰면 조용히 시스템을 얼립니다.

아래는 실무에서 자주 등장하는 Go 채널 데드락 7패턴과, 각각의 재현 코드안전한 대안입니다.

동시성 장애는 재현이 어렵습니다. 운영에서 타임아웃·재시도·자동복구 전략도 같이 보세요: Python 데코레이터로 async 타임아웃·재시도 공통화, 프로세스 레벨 복구는 systemd 서비스 자동 재시작 - 죽었다 깨도 복구

0. 데드락을 보는 관점: “채널 상태 머신”

채널은 크게 아래 상태로 이해하면 디버깅이 쉬워집니다.

  • 버퍼드 채널: len(ch)cap(ch) 사이에서 send/recv가 결정됨
  • 언버퍼드 채널: send와 recv가 동시에 만나야 진행됨
  • close(ch): 이후 recv는 즉시 진행(제로값, ok=false), send는 패닉
  • nil 채널: send/recv 모두 영원히 블록

이 글의 7패턴은 결국 이 네 가지 규칙의 조합에서 나옵니다.

1) 패턴 1: 언버퍼드 채널에 “받는 쪽이 없음”

가장 기본적인 데드락입니다. 언버퍼드 채널 send는 상대 recv가 없으면 진행되지 않습니다.

package main

func main() {
	ch := make(chan int) // unbuffered
	ch <- 1              // recv가 없으니 여기서 영원히 대기
}

해결

  • recv 고루틴을 먼저 띄우거나
  • 버퍼를 주거나
  • context 기반 타임아웃을 건다
package main

import (
	"context"
	"fmt"
	"time"
)

func main() {
	ch := make(chan int)
	go func() { fmt.Println(<-ch) }()

	ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
	defer cancel()

	select {
	case ch <- 1:
		// ok
	case <-ctx.Done():
		fmt.Println("send timeout")
	}
}

2) 패턴 2: 버퍼드 채널 “가득 찼는데 소비가 멈춤”

버퍼드 채널은 일시적인 속도 차이를 흡수하지만, 소비자가 멈추면 언젠가 꽉 차서 send가 블록됩니다.

package main

func main() {
	ch := make(chan int, 1)
	ch <- 1
	ch <- 2 // cap=1 이라 여기서 블록
}

실무에서의 전형적인 형태

  • 워커가 에러로 조기 리턴
  • select 에서 특정 케이스만 계속 선택되어 소비 루프가 굶주림
  • 다운스트림이 멈춰서 파이프라인 전체가 역압으로 정지

해결

  • 소비 루프의 생존 보장(에러 처리 후 계속)
  • 취소 신호를 모든 단계에 전파
  • send 경로에 타임아웃 또는 드롭 정책(최신만 유지 등)
select {
case ch <- item:
	// enqueue
default:
	// 버퍼가 꽉 찼으면 드롭하거나, 메트릭 증가
}

3) 패턴 3: select 에서 “모든 케이스가 블록”되고 default 가 없음

select 는 준비된 케이스가 없으면 그 자리에서 대기합니다. 종료 신호도 없고, 어떤 채널도 준비되지 않으면 영원히 멈춥니다.

package main

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

	select {
	case v := <-ch:
		_ = v
	}
	// ch에 send가 없으니 select가 영원히 대기
}

해결

  • 종료 채널(done)을 반드시 둔다
  • 또는 context.Done() 을 케이스로 포함한다
  • 혹은 default 로 폴링을 하되 CPU 스핀에 주의
package main

import (
	"context"
	"fmt"
	"time"
)

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

	select {
	case v := <-ch:
		fmt.Println(v)
	case <-ctx.Done():
		fmt.Println("canceled")
	}
}

4) 패턴 4: nil 채널을 select 에 넣고 “영원히 비활성화”

nil 채널 send/recv는 영원히 블록입니다. 이 특성은 select 에서 특정 케이스를 동적으로 끄는 패턴에 쓰이지만, 잘못하면 영구 정지가 됩니다.

package main

func main() {
	var ch chan int // nil
	select {
	case ch <- 1:
		// 절대 실행되지 않음
	case v := <-ch:
		_ = v
	}
}

흔한 실수: 조건부로 채널을 nil 로 만들고 되돌리지 않음

예: “구독이 없으면 입력을 끈다” 같은 로직에서, 다시 구독이 생겨도 채널을 복구하지 않아 파이프라인이 영구 정지.

해결

  • nil 을 쓰는 경우, 상태 전이를 명확히 관리
  • select 에 반드시 종료 경로를 둔다
package main

import "fmt"

func main() {
	in := make(chan int)
	done := make(chan struct{})

	var activeIn <-chan int = in
	close(done) // 예시: 종료 신호

	select {
	case v := <-activeIn:
		fmt.Println(v)
	case <-done:
		fmt.Println("done")
	}
}

5) 패턴 5: close 를 “여러 고루틴이 경쟁”해서 누군가 패닉, 누군가는 대기

엄밀히 말해 패닉 자체는 데드락이 아니지만, 패닉을 복구하거나 상위에서 잡아버리면 일부 고루틴은 신호를 못 받고 대기 상태로 남아 데드락처럼 보이는 정지가 생깁니다.

package main

import "sync"

func main() {
	ch := make(chan struct{})
	var wg sync.WaitGroup
	wg.Add(2)

	go func() {
		defer wg.Done()
		close(ch)
	}()
	go func() {
		defer wg.Done()
		close(ch) // panic: close of closed channel
	}()

	wg.Wait()
}

해결: close의 소유권을 단일 고루틴으로 고정

  • “송신자가 닫는다” 규칙을 강제하거나
  • sync.Once 로 보호
package main

import "sync"

func main() {
	ch := make(chan struct{})
	var once sync.Once

	closeSafe := func() {
		once.Do(func() { close(ch) })
	}

	closeSafe()
	closeSafe()
}

6) 패턴 6: range ch 가 끝나길 기다리는데, 아무도 close 를 안 함

수신 측에서 for v := range ch 는 채널이 닫히기 전까지 끝나지 않습니다. 송신 측이 close 를 호출하지 않으면, 수신 측은 영원히 대기합니다.

package main

import "fmt"

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

	go func() {
		ch <- 1
		// close(ch) 를 안 함
	}()

	for v := range ch {
		fmt.Println(v)
	}
	// 여기 절대 도달하지 않음
}

해결

  • 생산이 끝나는 지점을 명확히 하고 close 를 보장
  • 생산자가 여러 명이면 “마지막 생산자만 닫기”가 어려우므로 WaitGroup 으로 팬인 종료를 만든다
package main

import (
	"fmt"
	"sync"
)

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

	producers := 2
	wg.Add(producers)

	for i := 0; i < producers; i++ {
		go func(id int) {
			defer wg.Done()
			ch <- id
		}(i)
	}

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

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

7) 패턴 7: selectdone 을 섞었지만, “송신이 done을 무시”해서 종료가 걸림

종료 신호를 만들었는데도 데드락이 나는 대표 케이스는, 수신 측만 done 을 보고 송신 측은 계속 send로 블록되는 경우입니다.

package main

import "time"

func main() {
	ch := make(chan int)
	done := make(chan struct{})

	go func() {
		// 수신자는 done을 보고 종료
		select {
		case <-done:
			return
		case <-ch:
			return
		}
	}()

	close(done)
	// 송신자는 done을 안 보고 send 시도
	ch <- 1 // 여기서 블록될 수 있음

	time.Sleep(10 * time.Millisecond)
}

해결: 송신/수신 모두 종료 신호를 동일한 방식으로 관찰

채널 기반 파이프라인에서는 “모든 블로킹 연산은 selectdone 과 함께”가 안전합니다.

package main

import (
	"context"
	"time"
)

func main() {
	ch := make(chan int)
	ctx, cancel := context.WithCancel(context.Background())

	go func() {
		select {
		case <-ctx.Done():
			return
		case <-ch:
			return
		}
	}()

	cancel()

	select {
	case ch <- 1:
		// sent
	case <-ctx.Done():
		// 종료면 send 시도 자체를 포기
	}

	time.Sleep(10 * time.Millisecond)
}

데드락 예방 체크리스트

실무에서 코드 리뷰 때 아래만 지켜도 채널 데드락이 급감합니다.

  1. 채널 close의 소유권을 명확히: 누가 닫는지 한 곳으로 고정 (sync.Once 또는 전용 closer 고루틴)
  2. range ch 를 쓰면 close 보장이 있는지 확인: 생산 종료 조건이 설계에 들어가야 함
  3. 모든 블로킹 send/recv는 done 또는 context.Done() 과 함께 select
  4. 버퍼드 채널은 “무한 큐”가 아님: 꽉 찼을 때 정책(드롭, 백프레셔, 타임아웃)을 문서화
  5. nil 채널로 케이스를 끄는 패턴은 상태 전이를 테스트로 고정
  6. select 에서 default를 넣을 때는 스핀 방지: 필요하면 time.Ticker 또는 작은 time.Sleep 고려
  7. 고루틴 누수 감시: 테스트에서 runtime.NumGoroutine() 변화나 pprof로 누수 확인

디버깅 팁: “멈춘 고루틴이 어디서 블록됐는가”

데드락이 의심되면, 다음 순서가 빠릅니다.

  • 스택 트레이스 확인: runtime/pprof 의 goroutine 덤프
  • 블로킹 지점 확인: chan send 또는 chan receive 로 표시되는 라인
  • 해당 채널의 생명주기 확인: 누가 만들고, 누가 닫고, 누가 소비하는지
  • 종료 경로 확인: done 이 모든 단계에 전파되는지

운영 관점에서는 “멈췄을 때 자동 복구”도 중요합니다. 프로세스가 특정 조건에서 멈추는 유형이라면 systemd로 재시작 정책을 걸어 평균 복구 시간을 줄일 수 있습니다: systemd 서비스 자동 재시작 - 죽었다 깨도 복구

마무리

Go 채널 데드락은 대개 복잡한 알고리즘 때문이 아니라, 채널 소유권과 종료 계약이 अस्प명확해서 생깁니다. select 는 안전장치가 될 수도 있고, 모든 케이스를 동시에 블록시키는 함정이 될 수도 있습니다. close 는 강력한 브로드캐스트지만, 소유권이 흐려지면 패닉과 정지를 낳습니다. nil 채널은 동적 제어에 유용하지만, 한 번 잘못 들어가면 영구적으로 케이스를 꺼버립니다.

위 7패턴을 팀의 코드 리뷰 체크리스트로 만들고, 모든 블로킹 연산에 context 기반 종료 경로를 넣는 것만으로도 동시성 장애의 대부분을 예방할 수 있습니다.