Published on

Go 채널 데드락 진단 - pprof·trace로 원인 찾기

Authors

운영에서 Go 서비스가 갑자기 멈춘 것처럼 보일 때, CPU 사용률은 낮고 요청은 타임아웃만 늘어나는 패턴이 자주 나옵니다. 이때 원인이 채널 송수신 블로킹, 뮤텍스 경합, 워커 풀 고갈 같은 동시성 교착(Deadlock) 또는 사실상 정지(Livelock/Starvation) 인 경우가 많습니다.

Go는 런타임 차원에서 많은 단서를 제공합니다. 특히 net/http/pprof로 고루틴 덤프를 얻고, runtime/trace로 “누가 언제 어디서 왜 블로킹됐는지”를 이벤트 단위로 따라가면, 채널 데드락의 원인을 꽤 빠르게 특정할 수 있습니다.

이 글은 채널 데드락을 pprof·trace로 진단하는 최소 절차와, 현장에서 자주 만나는 함정(버퍼 크기, 컨텍스트 취소, fan-in/fan-out, 워커 풀, errgroup)을 중심으로 정리합니다. 분산 환경에서 재현이 어려울 때는 OpenTelemetry로 MSA 분산 트랜잭션 추적 실전처럼 상위 레벨 트레이싱으로 “어느 요청이 멈췄는지”를 먼저 좁히고, 그다음 pprof·trace로 프로세스 내부를 파고드는 흐름이 잘 맞습니다.

1) 채널 데드락의 전형적인 증상과 오해

증상 체크리스트

  • 처리량이 0에 가깝게 떨어지는데 프로세스는 살아 있음
  • CPU는 낮고, 고루틴 수가 비정상적으로 늘거나(누수) 특정 값에서 고정
  • 로그가 특정 지점 이후 더 이상 진행되지 않음
  • 타임아웃이 늘고, 재시작하면 잠깐 회복되다가 재발

흔한 오해

  • “Go는 데드락이면 패닉이 난다”
    • 런타임이 감지하는 것은 프로세스 전체가 더 이상 진행 불가능한 상태(모든 고루틴이 블로킹) 에 가깝습니다.
    • 실제 운영 장애는 “일부 고루틴만 교착” 또는 “워크 큐가 막혀서 더 이상 작업이 진행되지 않음” 같은 부분 교착이 더 흔합니다. 이 경우 패닉 없이 조용히 멈춥니다.

2) 최소 침습으로 pprof부터 붙이기

운영 장애에서 가장 먼저 필요한 건 “지금 고루틴들이 어디서 멈춰 있나”입니다. 이를 위해 pprof 엔드포인트를 노출합니다.

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

MDX 렌더링 환경에서 본문에 부등호가 노출되면 빌드 에러가 날 수 있으니, 제네릭이나 -> 같은 표기는 반드시 인라인 코드로 처리하세요.

장애 시점에 goroutine dump 확보

가장 유용한 1차 자료는 goroutine 프로파일입니다.

curl -s "http://127.0.0.1:6060/debug/pprof/goroutine?debug=2" > goroutines.txt
  • debug=2는 스택 트레이스를 텍스트로 길게 보여줍니다.
  • 여기서 핵심은 동일한 함수/라인에서 멈춘 고루틴이 다수인지, 그리고 채널 송수신 라인이 어디인지입니다.

pprof로 goroutine 프로파일을 인터랙티브하게 보기

go tool pprof -http=:0 "http://127.0.0.1:6060/debug/pprof/goroutine"
  • 웹 UI에서 TopFlame Graph로 “어떤 스택이 고루틴을 가장 많이 점유하는지”를 봅니다.
  • 채널 데드락은 보통 runtime.chanrecv 또는 runtime.chansend 근처가 상위에 보입니다.

3) goroutine dump에서 채널 데드락 패턴 읽는 법

아래는 “fan-in 수집기가 결과 채널을 끝까지 읽지 못해 생산자가 막히는” 전형적인 예시입니다.

문제 코드: 결과 채널 소비가 중단됨

package main

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

func worker(ctx context.Context, out chan<- int, id int) {
	// 작업이 길어지면 out 송신에서 막힐 수 있음
	t := time.NewTimer(100 * time.Millisecond)
	defer t.Stop()
	select {
	case <-ctx.Done():
		return
	case <-t.C:
		out <- id // 리시버가 없으면 여기서 영원히 블로킹
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	out := make(chan int) // unbuffered
	for i := 0; i < 100; i++ {
		go worker(ctx, out, i)
	}

	// 실수: 일부만 읽고 종료
	for i := 0; i < 1; i++ {
		fmt.Println(<-out)
	}

	// main이 끝나지 않고 다른 작업을 한다고 가정하면,
	// 나머지 worker는 out 송신에서 줄줄이 막힘
	select {}
}

이 상황에서 goroutine dump를 보면 대략 이런 힌트를 얻습니다.

  • 다수 고루틴이 같은 라인(예: out <- id)에서 runtime.chansend로 멈춤
  • 소비자(리시버) 고루틴은 이미 종료했거나, 다른 채널에서 대기 중

해결 방향

  • 결과 채널을 끝까지 소비하거나
  • 컨텍스트 취소 시 송신을 중단하거나
  • 버퍼링을 주거나(근본 해결은 아닐 수 있음)
  • 생산자 수와 소비자 처리량을 맞추는 구조로 변경

예를 들어 컨텍스트 취소를 송신에도 반영하면 “영원히 블로킹”을 피할 수 있습니다.

func worker(ctx context.Context, out chan<- int, id int) {
	select {
	case <-ctx.Done():
		return
	case out <- id:
		return
	}
}

4) trace로 “언제부터 막혔는지” 타임라인으로 확인

pprof의 goroutine dump는 “현재 상태” 스냅샷입니다. 하지만 데드락은 종종 특정 이벤트 이후 점진적으로 악화됩니다.

  • 특정 요청이 들어온 뒤 워커 풀이 고갈된다
  • 특정 에러 경로에서 채널 close가 누락된다
  • 특정 락 순서가 바뀌어 교착이 발생한다

이런 경우 runtime/trace가 강력합니다.

4.1) runtime/trace 수집 코드

운영에선 항상 켜기보다 “문제 재현 시점에만 짧게” 수집하는 방식을 권합니다.

package main

import (
	"log"
	"net/http"
	"os"
	"runtime/trace"
	"time"
)

func main() {
	http.HandleFunc("/debug/trace10s", func(w http.ResponseWriter, r *http.Request) {
		f, err := os.CreateTemp("", "trace-*.out")
		if err != nil {
			http.Error(w, err.Error(), 500)
			return
		}
		defer f.Close()

		if err := trace.Start(f); err != nil {
			http.Error(w, err.Error(), 500)
			return
		}
		time.Sleep(10 * time.Second)
		trace.Stop()

		w.Header().Set("Content-Type", "text/plain")
		_, _ = w.Write([]byte(f.Name()))
	})

	log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}
  • 위 예시는 임시 파일에 trace를 저장하고 경로를 반환합니다.
  • 실제 운영에서는 파일을 안전하게 다운로드하거나, 오브젝트 스토리지로 업로드하는 방식이 더 낫습니다.

4.2) trace 분석

# trace 파일이 trace.out 라고 가정
go tool trace trace.out

브라우저 UI에서 다음을 중점적으로 봅니다.

  • Goroutine analysis: 고루틴 상태 변화(실행, runnable, syscall, blocked)
  • Network blocking profile: 네트워크 대기와 구분
  • User regions / tasks: 코드에 region을 심어두면 원인 좁히기가 쉬움

채널 데드락에서 trace로 확인할 것

  • 특정 시점 이후 blocked on chan send 또는 blocked on chan recv가 급증
  • 특정 고루틴(예: aggregator)이 더 이상 runnable 상태로 돌아오지 않음
  • GC나 syscall이 아니라 순수 동기화 대기로 시간이 소비됨

5) pprof·trace를 “원인”으로 연결하는 실전 절차

현장에서 잘 먹히는 순서를 제안합니다.

1단계: goroutine dump로 블로킹 종류 분류

goroutine?debug=2에서 다음 키워드를 찾습니다.

  • chan send / chan receive 관련 런타임 함수
  • sync.Mutex / sync.RWMutex 관련 대기
  • select에서 특정 케이스로만 대기

여기서 “채널 송신에서 막힌 고루틴이 많다”처럼 방향을 잡습니다.

2단계: 블로킹 라인의 소스코드 맥락 확인

  • 해당 라인이 out <- x인지, x := <-in인지
  • 그 채널이 어디서 만들어지고, 누가 닫는지
  • 버퍼 크기와 생산자/소비자 수
  • 컨텍스트 취소가 송신/수신에 반영되는지

3단계: trace로 시간 축 상의 트리거 찾기

  • “언제부터 막혔는지”를 찾으면 재현 조건이 보입니다.
  • 예: 특정 핸들러가 시작된 뒤 워커 풀이 모두 busy가 되고, 결과 채널 수신이 멈춘다.

4단계: 재현 테스트 작성

  • -race는 데드락 자체를 직접 해결해주진 않지만, 공유 상태 경쟁으로 인한 이상 동작을 잡는 데 도움됩니다.
  • 단위 테스트에 타임아웃을 걸어 “멈추면 실패”로 만들면 회귀 방지가 됩니다.
func TestPipelineDoesNotDeadlock(t *testing.T) {
	t.Parallel()
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	done := make(chan struct{})
	go func() {
		defer close(done)
		// 파이프라인 실행
	}()

	select {
	case <-ctx.Done():
		t.Fatalf("timeout: possible deadlock")
	case <-done:
		// ok
	}
}

6) 자주 발생하는 채널 데드락 원인 6가지

6.1) 수신자가 사라졌는데 송신이 계속됨

  • 가장 흔한 형태
  • 해결: 컨텍스트 취소를 송신 select에 포함, 혹은 생산자 종료 조건을 명확히

6.2) close 누락 또는 close 순서 오류

  • fan-in에서 range ch로 읽고 있는데 누가 close(ch)를 안 함
  • 여러 생산자가 하나의 채널을 닫으려다 패닉을 피하려고 아예 안 닫는 경우도 있음
  • 해결: WaitGroup으로 생산자 종료를 모으고 “단 한 곳에서만” close
var wg sync.WaitGroup
out := make(chan int)

for i := 0; i < n; i++ {
	wg.Add(1)
	go func(id int) {
		defer wg.Done()
		// produce...
		out <- id
	}(i)
}

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

for v := range out {
	_ = v
}

6.3) unbuffered 채널로 fan-out을 과신

  • unbuffered는 동기화 지점이 강제됩니다.
  • 처리량이 낮거나 소비자가 잠깐 멈추면 생산자가 줄줄이 막힙니다.
  • 해결: 버퍼링은 완화책이지만, 구조적으로 backpressure를 설계해야 합니다.

6.4) 워커 풀이 결과 전송 때문에 자기 자신을 막음

  • 워커가 jobs를 받자마자 results로 보내는데, results를 읽는 쪽이 같은 워커 풀에 의존하면 순환 의존이 생깁니다.
  • 해결: results 소비자를 분리하거나, 워커가 결과를 비동기로 넘기고 빠져나오게 설계

6.5) select에서 default를 넣어 “바쁜 대기”로 악화

  • 데드락은 아니지만, 진행이 안 되는 상태에서 CPU만 태우는 형태로 변합니다.
  • trace에서는 runnable이 계속인데 진전이 없는 모습으로 보입니다.

6.6) 컨텍스트 취소를 수신에만 적용

  • case <-ctx.Done()은 수신에도 송신에도 모두 필요할 수 있습니다.
  • 한쪽만 넣으면 반대쪽에서 영원히 막힙니다.

7) 운영에서 안전하게 적용하는 팁

pprof 노출은 반드시 보호

  • 바인딩을 127.0.0.1로 제한하고, 필요하면 SSH 터널로 접근
  • 쿠버네티스라면 포트포워딩으로 일시 접근

trace는 짧게, 자주가 아니라 “필요할 때”

  • trace는 오버헤드가 있으므로 5초에서 15초 정도 짧게
  • 장애 징후 감지 시에만 켜도록 토글 엔드포인트를 두는 방식이 좋습니다.

로그와 함께 “고루틴 스택 덤프”를 자동 수집

  • 치명적 타임아웃이 발생하면 pprof.Lookup("goroutine").WriteTo(...)로 스택을 남기면, 사후 분석이 훨씬 쉬워집니다.

8) 마무리: pprof는 위치, trace는 흐름을 보여준다

  • pprof goroutine dump는 “지금 어디서 막혔는지”를 가장 빠르게 보여줍니다.
  • trace는 “어떤 사건 이후로 막히기 시작했는지”를 타임라인으로 밝혀줍니다.

채널 데드락은 대개 한 줄의 out <- x가 원인이 아니라, 채널의 생명주기(누가 닫고, 누가 끝까지 읽고, 누가 취소를 전파하는지) 가 불명확해서 생깁니다. pprof로 블로킹 라인을 찾고, trace로 트리거를 찾은 뒤, close·취소·소비 보장을 코드 구조로 강제하면 재발을 크게 줄일 수 있습니다.

추가로, 장애를 “어떤 요청부터 멈췄는지” 상위 관점에서 먼저 좁히고 싶다면 OpenTelemetry로 MSA 분산 트랜잭션 추적 실전도 함께 참고하면 디버깅 동선이 좋아집니다.