Published on

Go 채널 데드락 5패턴과 runtime 진단법

Authors

서버에서 간헐적으로 멈춤이 발생할 때, 로그는 더 이상 찍히지 않고 CPU도 높지 않다면 가장 먼저 의심해야 할 축이 goroutine 블로킹입니다. 특히 채널(chan)을 중심으로 설계한 파이프라인은 “정상일 때는 단순하고 안전해 보이지만”, 예외 경로 하나로 전체가 정지되는 경우가 흔합니다.

이 글에서는 Go 채널 데드락을 유발하는 대표 5가지 패턴을 코드로 재현하고, runtime 및 표준 도구(pprof/trace)를 통해 원인을 진단하는 방법을 정리합니다. 운영 환경에서의 관측 포인트와, 재발 방지용 설계 체크리스트까지 같이 다룹니다.

참고로 동시성/병렬화의 함정은 언어를 가리지 않습니다. Java 쪽 병렬 스트림에서의 경쟁조건/블로킹 이슈도 유사한 결을 가지므로 함께 보면 도움이 됩니다: Java Stream 병렬화 함정 - 성능·경쟁조건 7가지

데드락이란: Go에서의 의미

Go에서 흔히 말하는 “데드락”은 크게 두 종류로 나타납니다.

  1. 전역 데드락(프로그램이 더 이상 진행 불가)

    • 모든 goroutine이 블로킹 상태에 빠져 실행 가능한 것이 없을 때
    • 보통 fatal error: all goroutines are asleep - deadlock! 메시지와 함께 패닉
  2. 부분 데드락(서비스는 살아있지만 일부 경로가 영원히 대기)

    • 특정 요청/작업만 멈추고, 프로세스는 계속 실행
    • 운영에서는 이 케이스가 더 위험합니다(헬스체크는 통과, 요청은 타임아웃)

채널은 “동기화 + 데이터 전달”을 동시에 수행합니다. 이 장점이 곧 위험이기도 합니다. 송신/수신/종료(close) 규약이 조금만 어긋나도, 블로킹이 전파되어 파이프라인 전체가 멈춥니다.

패턴 1: 버퍼 없는 채널에서 송신자가 먼저 고립

가장 기본적인 케이스입니다. 버퍼 없는 채널은 송신과 수신이 동시에 만나야 진행됩니다.

package main

func main() {
	ch := make(chan int) // unbuffered
	ch <- 1              // 수신자가 없으므로 여기서 영원히 블로킹
}

실전에서의 변형

  • 고루틴을 띄웠다고 믿었지만, 실제로는 조건문/에러로 인해 수신 루프가 시작되지 않음
  • 워커 풀에서 워커가 모두 종료된 뒤에도 생산자가 계속 송신

예방 포인트

  • 생산자/소비자 라이프사이클을 명확히(누가 언제 시작/종료하는지)
  • “수신자가 없을 수 있는” 경로라면 버퍼를 두거나, select에 타임아웃/취소를 넣기

패턴 2: 버퍼 채널이 가득 찼는데 소비자가 멈춘 경우

버퍼 채널은 “일정량까지는 비동기”지만, 결국 가득 차면 송신이 블로킹됩니다.

package main

import "time"

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

	// 소비자(예: 어떤 이유로 조기 종료)
	go func() {
		_ = <-ch
		return // 여기서 끝나버리면 이후 소비가 없음
	}()

	ch <- 1
	ch <- 2
	ch <- 3 // 버퍼가 꽉 차고 소비자가 없어서 블로킹

	time.Sleep(1 * time.Second)
}

실전에서의 변형

  • 소비자 고루틴이 패닉으로 죽었는데 상위에서 복구하지 않음
  • 소비자가 외부 I/O에서 영원히 대기(네트워크/디스크)하면서 채널 drain이 멈춤

예방 포인트

  • 소비자 고루틴은 “죽으면 안 되는 역할”이면 반드시 상위에서 재시작/에러 전파
  • 버퍼 크기는 “평균 처리량”이 아니라 “최악의 지연/스파이크”를 기준으로 잡고, 초과 시 드롭/백프레셔 정책을 명시

패턴 3: range ch가 끝나지 않는 문제(채널 close 누락)

for v := range ch는 채널이 close되기 전까지 끝나지 않습니다. 생산자가 종료 신호를 주지 않으면 소비자는 영원히 대기합니다.

package main

import "fmt"

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

	go func() {
		for i := 0; i < 3; i++ {
			ch <- i
		}
		// close(ch) 를 빠뜨리면 소비자는 range에서 끝나지 않음
	}()

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

실전에서의 변형

  • fan-out/fan-in에서 생산자 여러 개 중 일부만 종료되고 close가 호출되지 않음
  • 에러 경로에서 close가 실행되지 않음(정상 경로에만 존재)

예방 포인트

  • “누가 close를 책임지는가”를 규약으로 박기
  • 일반적으로는 송신자(생산자) 측에서만 close
  • 생산자가 여러 개면 sync.WaitGroup으로 모두 종료 후 단일 지점에서 close

예시(다중 생산자 종료 후 close):

package main

import (
	"sync"
)

func main() {
	ch := make(chan int)
	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 < 10; i++ {
				ch <- base*100 + i
			}
		}(p)
	}

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

	for range ch {
		// consume
	}
}

패턴 4: select에서 nil 채널/잘못된 종료 조합으로 영구 대기

Go에서 nil 채널에 대한 송신/수신은 영원히 블로킹합니다. 이 특성은 상태 머신에서 “case 비활성화” 용도로도 쓰이지만, 실수하면 데드락으로 이어집니다.

package main

func main() {
	var ch chan int // nil

	select {
	case ch <- 1:
		// 절대 실행되지 않음
	default:
		// default가 있으면 빠져나가지만,
		// default가 없으면 여기서 영원히 대기
	}
}

실전에서의 변형

  • 초기화 실패(설정 로딩 실패 등)로 채널이 nil인 채로 사용
  • 종료 신호 채널(done)을 잘못 전달해서 특정 워커만 영원히 대기
  • selectdefault를 넣어 “바쁜 루프(busy loop)”를 만들고, 결과적으로 소비가 따라가지 못해 다른 곳에서 교착

예방 포인트

  • 채널을 구조체 필드로 들고 다니면 생성 시점에 무조건 초기화되도록 생성자 패턴 사용
  • nil 채널을 의도적으로 쓰는 경우라도, 상태 전이를 테스트로 고정

패턴 5: 락과 채널을 섞어 잠금 순서 역전(숨은 데드락)

채널만으로도 데드락이 나지만, 더 까다로운 건 mutex와 채널을 섞을 때입니다. “락을 잡은 채로 채널 송신/수신”을 하면, 상대가 같은 락을 필요로 하는 순간 교착이 됩니다.

package main

import "sync"

type S struct {
	mu sync.Mutex
	ch chan int
}

func main() {
	s := &S{ch: make(chan int)}

	go func() {
		s.mu.Lock()
		defer s.mu.Unlock()
		s.ch <- 1 // 수신자가 필요하지만, 수신자는 mu가 필요할 수도 있음
	}()

	// 수신자가 mu를 먼저 잡아야 한다면 교착
	s.mu.Lock()
	defer s.mu.Unlock()
	<-s.ch
}

예방 포인트

  • 원칙: 락을 잡은 상태로 블로킹 가능한 연산(채널, I/O, WaitGroup.Wait)을 하지 말기
  • 불가피하면 락 범위를 최소화하고, 데이터 복사 후 락 해제 뒤 채널로 전달

runtime 기반 진단: “어디서” 막혔는지 빠르게 찾기

부분 데드락은 패닉이 없기 때문에, 결국 “현재 모든 고루틴이 어디서 멈춰 있는가”를 봐야 합니다. Go는 이를 위한 도구가 매우 강력합니다.

1) SIGQUIT로 전체 goroutine 스택 덤프

리눅스/컨테이너 환경에서 프로세스에 SIGQUIT를 보내면, 표준 에러로 모든 고루틴 스택이 출력됩니다.

  • 실행: kill -QUIT <pid> 를 백틱으로 감싸면 kill -QUIT <pid>

스택에서 다음 키워드를 집중적으로 봅니다.

  • chan send / chan receive
  • select
  • sync.(*Mutex).Lock
  • sync.(*WaitGroup).Wait
  • 네트워크 I/O (net.(*pollDesc).wait 등)

이 덤프는 “지금 당장 멈춘 요청”의 원인을 찾는 데 가장 빠른 방법입니다.

2) runtime/pprof로 goroutine 프로파일 수집

서비스에 디버그 엔드포인트를 붙일 수 있다면, net/http/pprof가 가장 실용적입니다.

package main

import (
	"log"
	"net/http"
	_ "net/http/pprof"
)

func main() {
	go func() {
		log.Println(http.ListenAndServe("127.0.0.1:6060", nil))
	}()

	select {} // 예시용
}

수집 예:

  • goroutine 덤프: curl http://127.0.0.1:6060/debug/pprof/goroutine?debug=2
  • pprof 분석: go tool pprof http://127.0.0.1:6060/debug/pprof/goroutine

여기서도 chan receive가 특정 함수에서 과도하게 쌓여 있으면, close 누락/소비자 중단/버퍼 포화 등을 의심할 수 있습니다.

운영에서 파드가 죽거나 재시작되는 상황이라면, 장애 진단 흐름 자체를 체계화해두는 게 중요합니다. 컨테이너 환경 트러블슈팅 관점은 다음 글도 참고할 만합니다: EKS Pod CrashLoopBackOff? OOMKilled 진단법

3) runtime/trace로 블로킹 타임라인 확인

pprof가 “어디서 많이 쌓였는지”를 보여준다면, trace는 “언제부터 무엇 때문에 멈췄는지”를 타임라인으로 보여줍니다.

간단 예시(파일로 trace 저장):

package main

import (
	"os"
	"runtime/trace"
	"time"
)

func main() {
	f, _ := os.Create("trace.out")
	defer f.Close()

	_ = trace.Start(f)
	defer trace.Stop()

	// 여기서 워크로드 실행
	time.Sleep(2 * time.Second)
}

분석: go tool trace trace.out

trace 뷰어에서 다음을 봅니다.

  • Goroutine이 blocked on chan send/recv로 전환되는 지점
  • 특정 고루틴이 장시간 runnable이 아닌 상태로 누적되는 패턴
  • 네트워크 I/O나 GC 때문에 지연되는지, 순수 동기화 문제인지 구분

4) GODEBUG와 런타임 파라미터(보조 수단)

상황에 따라 다음도 도움됩니다.

  • GODEBUG=schedtrace=1000,scheddetail=1 로 스케줄러 상태를 주기적으로 출력
  • (락 경합 의심 시) mutex 프로파일링을 켜서 경합 hotspot 확인

다만 운영에서 로그 폭발을 유발할 수 있으니, 재현 환경이나 제한된 기간에만 사용하세요.

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

채널 종료 규약

  • close는 송신자만 한다
  • 다중 송신자면 단일 조정자(aggregator)가 WaitGroup 이후 close
  • 소비자는 close를 “종료 이벤트”로 해석하고 정상 종료한다

취소/타임아웃 기본 탑재

context.Context를 파이프라인 전반에 흘려 보내고, 채널 송수신도 취소 가능하게 만듭니다.

select {
case ch <- item:
	// sent
case <-ctx.Done():
	return ctx.Err()
}

이 패턴 하나만으로 “영원히 멈춤”이 “취소 가능한 대기”로 바뀝니다.

버퍼/백프레셔 정책 명시

  • 버퍼는 임시 완충일 뿐, 무한 큐가 아니다
  • 가득 찼을 때의 정책을 정한다: 드롭, 최신값 유지, 생산자 차단, 별도 디스크 큐 등

락과 블로킹 연산 분리

  • 락은 데이터 일관성을 위한 최소 구간에만
  • 채널/I/O/대기는 락 밖에서

마무리: 재현 + 스택 덤프가 가장 빠른 길

Go 채널 데드락은 “코드가 짧아서” 오히려 눈에 잘 안 띄는 경우가 많습니다. 따라서 접근 순서가 중요합니다.

  1. 멈춘 시점의 goroutine 스택을 확보한다(SIGQUIT 또는 pprof goroutine)
  2. 스택에서 chan send/receive가 걸린 위치를 찾는다
  3. 해당 채널의 소유권(누가 send/close/receive 책임인지)과 종료 규약을 확인한다
  4. close 누락, 소비자 중단, 버퍼 포화, nil 채널, 락-채널 혼합 중 어떤 패턴인지 분류한다
  5. context 취소/타임아웃과 종료 규약을 설계에 반영해 재발을 줄인다

운영 장애는 대개 “예외 경로”에서 시작합니다. 정상 흐름만으로 채널 파이프라인을 증명하지 말고, 취소/에러/부분 실패를 포함한 종료 시나리오를 테스트로 고정해두면 데드락의 상당수를 사전에 제거할 수 있습니다.