Published on

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

Authors
Binance registration banner

운영에서 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 분산 트랜잭션 추적 실전도 함께 참고하면 디버깅 동선이 좋아집니다.