Published on

Go 채널 데드락 5분 진단 - pprof·trace

Authors

서버가 멈춘 듯 보이는데 CPU는 낮고, 요청은 쌓이며, 로그는 더 이상 찍히지 않는 상황. Go 서비스에서 가장 흔한 원인 중 하나가 channel 기반 동기화의 데드락(또는 사실상 데드락에 가까운 무한 대기)입니다. 특히 select 없이 블로킹 송수신을 하거나, range ch 종료 조건을 놓치거나, WaitGroup과 채널을 섞어 쓰다 신호가 유실되면 증상이 비슷하게 나타납니다.

이 글은 “원인 추정”이 아니라 5분 안에 증거를 확보하는 절차에 집중합니다. 핵심 도구는 두 가지입니다.

  • net/http/pprof로 고루틴 스택을 즉시 덤프해 누가 어디서 막혔는지 확인
  • runtime/trace로 일정 구간을 기록해 언제부터 무엇을 기다리기 시작했는지 타임라인으로 확인

또한 데드락이라는 단어가 DB에서도 자주 쓰이듯, Go의 동시성 데드락도 “증거 기반”으로 좁혀야 합니다. DB 데드락을 로그로 추적하듯이(예: MySQL InnoDB 데드락 로그로 범인 쿼리 찾기), Go도 pprof/trace가 사실상의 데드락 로그 역할을 합니다.

1) 5분 진단 체크리스트

장애가 났을 때 아래 순서대로만 해도, 원인 고립 속도가 확 올라갑니다.

  1. pprof 엔드포인트 열기(이미 열려 있으면 바로 덤프)
  2. goroutine 프로파일로 대기 유형을 본다
  3. 동일 스택이 수십~수백 개면 팬아웃 병목(한 채널/락에 몰림)을 의심
  4. trace를 5~10초만 떠서 대기 시작 지점스케줄링 상태를 본다
  5. 코드에서 “종료 신호/채널 close/버퍼 용량/컨슈머 수”를 점검

이 글의 나머지는 위 1~4를 빠르게 수행하는 방법과, 자주 나오는 패턴을 코드로 재현하며 설명합니다.

2) pprof 준비: 장애 시 바로 고루틴 덤프

2.1 운영에서 최소 설정으로 pprof 노출

가장 흔한 방식은 별도 포트로 pprof 서버를 띄우는 것입니다(메인 트래픽과 분리).

package main

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

func main() {
	go func() {
		// 운영에서는 내부망/인증/네트워크 정책으로 보호하세요.
		addr := ":6060"
		log.Println("pprof listening on", addr)
		_ = http.ListenAndServe(addr, nil)
	}()

	// TODO: main server
	select {}
}

주의: 본문에 부등호 문자가 그대로 노출되면 MDX에서 JSX로 오인될 수 있습니다. 이후 예제에서 제네릭/화살표 등은 모두 인라인 코드로 감쌉니다.

2.2 장애 순간에 바로 보는 URL

  • 고루틴 덤프(텍스트): http://HOST:6060/debug/pprof/goroutine?debug=2
  • 고루틴 덤프(요약): http://HOST:6060/debug/pprof/goroutine?debug=1

서버에 직접 접속이 어렵다면, 포트 포워딩으로도 충분합니다.

kubectl port-forward deploy/myapp 6060:6060
curl -s "http://127.0.0.1:6060/debug/pprof/goroutine?debug=2" | head

(쿠버네티스 환경에서 장애 원인 추적이 필요한 경우 메모리/OOM 이슈도 함께 엮이는 일이 많습니다. OOM이 동반되면 Kubernetes OOMKilled 진단과 메모리 누수 추적 실전도 같이 보는 편이 좋습니다.)

3) pprof goroutine에서 데드락을 읽는 법

고루틴 덤프를 열면 대개 아래 같은 키워드가 보입니다.

  • chan send / chan receive: 채널 송신/수신에서 블록
  • select: select에서 블록(대기 케이스만 존재)
  • sync.(*Mutex).Lock: 뮤텍스 대기
  • sync.(*WaitGroup).Wait: WaitGroup이 끝나지 않음

3.1 전형적인 채널 데드락 예제: 수신자 없는 송신

package main

func main() {
	ch := make(chan int) // unbuffered
	ch <- 1              // 수신자가 없어 영원히 블록
}

이 경우 스택에는 보통 chan send가 찍힙니다. 중요한 포인트는 “어디서 send 했는지”가 아니라, 누가 receive를 해야 하는데 없느냐입니다.

pprof 덤프에서 같은 채널 송신 스택이 여러 개 반복되면, 다음을 의심하세요.

  • 컨슈머 고루틴이 시작되지 않았거나 조기 종료
  • 컨슈머가 다른 곳에서 또 막힘(예: 또 다른 채널 receive)
  • 버퍼 없는 채널에 팬아웃 송신을 과도하게 수행

3.2 range ch가 끝나지 않는 케이스: close 누락

func consumer(ch <-chan int, done chan<- struct{}) {
	for v := range ch {
		_ = v
	}
	done <- struct{}{}
}

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

	// 생산자 로직이 종료됐지만 close(ch)를 하지 않음
	// close(ch)
	<-done // consumer가 끝나지 않아 여기서 영원히 대기
}

pprof에서는 chan receive가 보이지만, 실제 원인은 close(ch) 누락입니다. 이 패턴은 “정상 종료 시그널”을 채널 close로 전달하는 설계에서 특히 흔합니다.

3.3 WaitGroup과 채널 혼용: Done 미호출

var wg sync.WaitGroup

func worker(ch <-chan int) {
	defer wg.Done()
	for range ch {
		// 작업
	}
}

func main() {
	ch := make(chan int)
	wg.Add(1)
	go worker(ch)

	// close(ch)를 안 하면 worker가 for-range를 못 빠져나가 Done 미호출
	// close(ch)
	wg.Wait()
}

pprof에는 sync.(*WaitGroup).Waitchan receive가 동시에 등장합니다. 이때는 “WaitGroup이 문제”가 아니라, worker가 종료 조건을 못 만나서 Done이 호출되지 않는 구조가 문제입니다.

4) runtime trace로 ‘언제부터’ 막혔는지 확인

pprof가 “어디서 막혔는지”를 준다면, trace는 “언제부터, 어떤 순서로, 무엇을 기다렸는지”를 줍니다. 특히 간헐적 데드락(특정 타이밍에서만 발생)에는 trace가 압도적으로 유리합니다.

4.1 코드에 트레이스 토글 훅 심기

운영에서 항상 trace를 켜면 비용이 있습니다. 보통은 관리자 엔드포인트로 몇 초만 켜서 파일로 저장합니다.

package diag

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

func TraceHandler(w http.ResponseWriter, r *http.Request) {
	// 예: /debug/trace?sec=5
	sec := 5 * time.Second

	f, err := os.Create("trace.out")
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	defer f.Close()

	if err := trace.Start(f); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	time.Sleep(sec)
	trace.Stop()

	w.Write([]byte("trace captured: trace.out"))
}
  • 파일 저장 위치는 컨테이너 환경이면 볼륨/임시 디렉터리 전략이 필요합니다.
  • 보안상 외부 노출 금지, 내부망/인증 필수입니다.

4.2 trace 파일 보는 법

go tool trace trace.out

브라우저 UI에서 다음을 우선 봅니다.

  • Goroutine analysis: 오래 블록된 고루틴, 블록 원인
  • View trace: 타임라인에서 특정 고루틴이 Blocked로 바뀌는 시점
  • Network blocking profile: 네트워크 I/O로 인한 블록과 채널 블록을 구분

채널 데드락은 보통 “한 고루틴이 송신 블록”과 “다른 고루틴이 수신 블록”이 맞물리거나, 컨슈머가 사라져 송신만 쌓이는 형태로 나타납니다. trace에서는 그 전 단계(컨슈머가 종료된 시점, 마지막으로 메시지가 소비된 시점)를 역추적할 수 있습니다.

5) pprof와 trace를 함께 쓰는 실전 흐름

장애 대응에서 추천하는 최소 루틴은 아래입니다.

5.1 1분: goroutine 덤프 확보

curl -s "http://HOST:6060/debug/pprof/goroutine?debug=2" > goroutines.txt

goroutines.txt에서 다음을 검색합니다.

  • chan send
  • chan receive
  • WaitGroup).Wait
  • select

그리고 “동일 스택이 반복되는지”를 봅니다. 반복된다면 병목 지점 후보가 거의 확정됩니다.

5.2 2~3분: trace 5초 캡처

관리 엔드포인트를 만들어뒀다면:

curl -s "http://HOST:8080/debug/trace?sec=5"
# trace.out 수거 후

go tool trace trace.out

trace에서 pprof에서 본 고루틴 함수명을 검색해, Blocked로 전환된 최초 시점을 확인합니다. 그 직전에 어떤 이벤트(채널 close, 컨텍스트 취소, 고루틴 종료)가 있었는지 보면 원인이 보입니다.

5.3 1분: 코드 패턴 대조

아래 패턴 중 하나로 귀결되는 경우가 많습니다.

  • 종료 시그널 설계 미스: close(ch) 누락, done 채널 송신/수신 불일치
  • 버퍼/컨슈머 수 미스매치: 버퍼 0인데 팬아웃 송신, 컨슈머 1개에 생산자 N개
  • select의 기본값 남용: default로 인해 소비 루프가 바빠지고 다른 고루틴이 굶음
  • 컨텍스트 취소 전파 누락: ctx.Done()을 무시해서 고루틴이 영구 생존

6) 자주 터지는 채널 데드락 패턴과 처방

6.1 “에러 먼저 리턴”으로 컨슈머가 사라지는 경우

아래는 흔한 실수입니다. 에러가 나면 함수가 리턴해 컨슈머가 사라지는데, 생산자는 계속 send를 시도합니다.

func startConsumer(ch <-chan int) error {
	for v := range ch {
		if v < 0 {
			return fmt.Errorf("bad value") // 여기서 컨슈머 종료
		}
	}
	return nil
}

처방:

  • 컨슈머가 종료되면 생산자도 멈추게 context로 수명 연결
  • 또는 “컨슈머 종료 시그널”을 명시적으로 만들어 생산자들이 감지
func consumer(ctx context.Context, ch <-chan int) error {
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case v, ok := <-ch:
			if !ok {
				return nil
			}
			_ = v
		}
	}
}

6.2 select에서 한쪽만 영원히 기다리는 경우

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

케이스가 하나뿐이면 결국 <-ch와 동일하게 블록됩니다. 타임아웃이나 취소를 넣어 “영원히”를 제거해야 합니다.

select {
case v := <-ch:
	_ = v
case <-time.After(2 * time.Second):
	return errors.New("timeout")
case <-ctx.Done():
	return ctx.Err()
}

6.3 버퍼 채널로 임시 완화했는데 더 큰 장애로 번지는 경우

버퍼를 키우면 당장 데드락이 사라진 것처럼 보일 수 있습니다. 하지만 컨슈머가 느린 구조라면 결국 메모리 사용량이 늘고, 지연이 누적되고, 최종적으로는 OOM이나 타임아웃으로 터집니다.

  • 버퍼는 “스파이크 흡수” 용도이지 “컨슈머 부재”를 가리는 용도가 아닙니다.
  • trace에서 송신 블록이 사라졌는데, 대신 GC/메모리 압력이 커졌다면 버퍼 확장은 독입니다.

7) 운영 팁: 재현 어려운 데드락을 줄이는 설계 습관

  • 고루틴은 반드시 수명 관리: context로 부모-자식 취소 전파
  • 채널은 소유권을 명확히: 누가 close하는지 규칙화(보통 생산자 1곳)
  • 종료를 close로 표현할지 done 메시지로 표현할지 일관성 유지
  • “영원히 대기” 금지: 중요한 경로에는 타임아웃/서킷브레이커
  • 장애 시 덤프 루틴 자동화: goroutine 덤프와 trace 캡처를 런북에 포함

동시성 문제는 시스템 전체의 병목과도 연결됩니다. 예를 들어 메시징 처리에서 중복/정확성 보장이 얽히면, 고루틴이 대기하는 동안 재시도 폭풍이 생겨 더 악화될 수 있습니다. 그런 경우에는 처리 의미론까지 함께 점검하는 것이 좋습니다(Kafka EOS로 분산 트랜잭션 중복 처리 막기).

8) 마무리: “데드락 같다”를 “여기서 막혔다”로 바꾸기

Go 채널 데드락은 감으로 때려 맞추기보다, pprof로 스택 증거를 확보하고 trace로 시간축을 복원하면 빠르게 해결됩니다.

  • pprof goroutine은 “무엇을 기다리는지”를 1분 안에 보여줍니다.
  • trace는 “언제부터 기다렸는지”를 보여줘 간헐적 문제를 고립합니다.

다음 장애 때는 로그를 더 찍기 전에, 먼저 goroutine?debug=2 한 번 떠보세요. 대개 답은 스택 안에 이미 있습니다.