- Published on
Go 채널 교착상태 5분만에 찾는 pprof·trace
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 멈춘 것처럼 보이는데 CPU는 낮고, 요청은 타임아웃이 나며, 로그는 더 이상 진행되지 않는 상황. Go 서비스에서 가장 흔한 원인 중 하나가 채널 기반 동기화의 교착상태(deadlock) 또는 그에 준하는 영구 블로킹입니다.
이 글은 “코드 리뷰로 찾기”가 아니라, pprof와 trace로 증거를 모아서 5분 안에 범인을 좁히는 절차를 목표로 합니다. 특히 운영 환경에서 “재현이 어렵다”는 제약을 전제로, 최소 침습적으로 관측하고 빠르게 판단하는 루틴을 소개합니다.
이미 기본적인 채널 데드락/고루틴 누수 체크리스트가 필요하다면 먼저 아래 글을 함께 보시면 맥락이 더 빨리 잡힙니다.
1) 교착상태를 “관측 가능한 현상”으로 바꾸기
채널 교착상태는 보통 다음 중 하나로 나타납니다.
- 특정 goroutine들이
chan send또는chan receive에서 영구 대기 sync.Mutex/sync.RWMutex와 채널이 섞여 락 순서가 꼬임- 버퍼 없는 채널에서 생산/소비 불균형으로 전체 파이프라인이 멈춤
select의 한 케이스가 영원히 준비되지 않는데, 다른 탈출 경로(ctx.Done()등)가 없음
핵심은 “어디서 막혔는지”를 스택과 타이밍으로 확인하는 것입니다.
pprof: 지금 이 순간 goroutine들이 어디에서 멈춰 있는지(스택) 확인runtime/trace: 시간 축에서 어떤 goroutine이 언제 블록되고, 어떤 이벤트로 깨어나는지 확인
둘은 역할이 다릅니다. pprof는 사진, trace는 영상에 가깝습니다.
2) 준비: net/http/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 {}
}
보안 팁:
127.0.0.1바인딩 후, 필요 시kubectl port-forward로 접근- 인그레스/로드밸런서로 외부 노출 금지
3) 5분 루틴: goroutine dump로 1차 범인 좁히기
가장 먼저 볼 것은 goroutine 프로파일입니다.
curl -s http://127.0.0.1:6060/debug/pprof/goroutine?debug=2 > goroutines.txt
goroutines.txt에서 우선순위로 찾을 패턴:
chan send/chan receive가 반복적으로 등장하는 스택- 동일 함수/라인에서 다수 goroutine이 동시에 대기
select에서 특정 케이스로만 빠지고 복귀하지 않는 흐름
예시(개념적으로 이런 형태를 찾습니다):
runtime.chanrecv또는runtime.chansend가 스택 상단- 그 아래로 여러분의 함수가 이어짐
이 단계에서 이미 “막힌 채널이 어디인지”, “누가 보내고/받아야 하는지” 후보가 나옵니다.
goroutine이 너무 많으면? (노이즈 줄이기)
goroutine 수가 폭증하면 텍스트 덤프가 읽기 어렵습니다. 이때는 pprof의 goroutine 프로파일을 도구로 보는 편이 빠릅니다.
go tool pprof -http=:0 http://127.0.0.1:6060/debug/pprof/goroutine
웹 UI에서 Top/Graph로 들어가서, chan receive/chan send 경로에 여러분 코드가 걸리는 지점을 봅니다.
4) “교착상태 vs 느린 외부 의존성” 구분하기
채널에서 막힌 것처럼 보여도 실제로는 아래가 원인일 수 있습니다.
- DB/HTTP 호출이 느려져서 소비자가 멈춤 → 생산자가 채널 send에서 대기
- 워커 풀 크기가 줄어들거나(설정 변경), 한 워커가 영구 블록 → 큐가 꽉 참
이때는 CPU 프로파일이 아니라 블로킹/뮤텍스 프로파일이 유효합니다.
Block profile 켜기
Block profile은 기본값이 꺼져 있습니다. 의심 구간에서 잠깐만 켜고 수집하세요.
import "runtime"
func enableBlockProfile() {
// 1은 매우 공격적일 수 있어 운영에서는 1000~10000 등으로 샘플링 권장
runtime.SetBlockProfileRate(1000)
}
수집:
curl -s http://127.0.0.1:6060/debug/pprof/block > block.pb.gz
go tool pprof -http=:0 block.pb.gz
여기서 “어디에서 얼마나 오래 블록되는지”가 집계됩니다. 채널 send/recv가 상위에 뜨면 교착상태/병목 가능성이 커집니다.
Mutex profile도 함께
락 경합이 채널 정체를 유발하는 경우가 많습니다.
import "runtime"
func enableMutexProfile() {
runtime.SetMutexProfileFraction(10)
}
수집:
curl -s http://127.0.0.1:6060/debug/pprof/mutex > mutex.pb.gz
go tool pprof -http=:0 mutex.pb.gz
5) trace로 “누가 누구를 기다리는지” 시간축으로 보기
pprof로 “어디서 막혔는지”를 봤다면, trace로는 “왜 못 깨어나는지”를 봅니다.
trace는 짧게 떠서(예: 3~10초) 재현 구간을 포착하는 방식이 좋습니다.
curl -s "http://127.0.0.1:6060/debug/pprof/trace?seconds=5" > trace.out
go tool trace trace.out
브라우저가 열리면 다음 화면을 주로 봅니다.
- Goroutine analysis: 오래 블록된 goroutine, 블록 이유
- View trace: 특정 goroutine을 클릭해서 상태 전이(실행/대기/블록)를 확인
- Network blocking profile: 네트워크 대기 때문에 채널이 막히는지 힌트
trace에서 채널 관련 블로킹은 보통 다음으로 드러납니다.
- goroutine이
chan send에서 Blocked 상태로 오래 머묾 - 그 채널을 받아야 할 goroutine은 스케줄되지 않거나, 다른 락/IO에서 막힘
이때 “교착상태”는 대개 다음 형태로 보입니다.
- A는 채널 X에 send하려고 대기
- B는 채널 Y에 send하려고 대기
- A가 깨어나려면 B가 진행해야 하고, B가 깨어나려면 A가 진행해야 함
즉, 사이클이 생깁니다. trace는 이 사이클을 시간축으로 확인하는 데 강합니다.
6) 실전 예제: 흔한 채널 교착상태 패턴과 수정
아래 코드는 “워커가 결과를 보내는데, 수신자가 특정 조건에서 조기 종료”하면서 발생하는 대표 패턴입니다.
문제 코드: 수신자가 먼저 종료
package main
import (
"context"
"fmt"
"time"
)
type Result struct {
ID int
}
func worker(ctx context.Context, id int, out chan<- Result) {
// 작업 시간이 길어질 수 있음
time.Sleep(200 * time.Millisecond)
// 수신자가 없으면 여기서 영구 블록 가능
out <- Result{ID: id}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
out := make(chan Result) // 버퍼 없음
for i := 0; i < 10; i++ {
go worker(ctx, i, out)
}
// 어떤 조건에서든 1개만 받고 종료한다고 가정
r := <-out
fmt.Println("got", r.ID)
// main이 끝나면 프로세스 종료라서 단순 예제에서는 티가 덜 나지만,
// 서버 핸들러 내부라면 이 패턴이 고루틴 누수/교착으로 이어짐
}
이 패턴은 서버 코드에서는 보통 다음처럼 변형됩니다.
- 요청 핸들러에서 일부 결과만 필요해서 조기 return
- 나머지 워커 goroutine들은
out <- ...에서 계속 대기 - 워커 수가 쌓이면 결국 전체 시스템이 멈춘 것처럼 보임
해결 1: 버퍼 채널로 “최대 생산량” 흡수
out := make(chan Result, 10)
단, 버퍼는 근본 해결이 아니라 “막힘을 뒤로 미루는” 경우가 있습니다. 생산량이 무한하거나 워커 수가 늘면 다시 터집니다.
해결 2: 컨텍스트 취소 + non-blocking send
func worker(ctx context.Context, id int, out chan<- Result) {
time.Sleep(200 * time.Millisecond)
select {
case out <- Result{ID: id}:
return
case <-ctx.Done():
return
}
}
그리고 수신자가 조기 종료할 때는 반드시 cancel()을 호출해 워커가 빠져나갈 길을 줍니다.
해결 3: out 채널 close와 drain 설계
결과를 모두 수집하는 구조라면 sync.WaitGroup으로 워커 종료를 기다린 뒤 close(out)하고, 수신자는 for range로 drain하는 패턴이 안전합니다.
package main
import (
"fmt"
"sync"
)
type Result struct{ ID int }
func worker(id int, out chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
out <- Result{ID: id}
}
func main() {
out := make(chan Result, 4)
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go worker(i, out, &wg)
}
go func() {
wg.Wait()
close(out)
}()
for r := range out {
fmt.Println(r.ID)
}
}
7) 운영에서 5분 안에 끝내는 체크리스트
아래 순서대로 하면 “지금 막힌 게 채널 교착상태 계열인지”를 빠르게 결론낼 확률이 높습니다.
1분: goroutine dump
curl로 goroutine 덤프chan send/receive스택이 특정 라인에 집중되는지 확인
2분: block/mutex 프로파일(필요 시)
- block에서 채널 블로킹이 상위인지
- mutex에서 특정 락 경합이 치명적인지
2분: trace 5초
- 오래 Blocked인 goroutine을 클릭
- 누가 깨워야 하는지(상대 goroutine/이벤트)가 존재하는지
- 사이클(교착)인지, 외부 IO 지연인지 구분
8) Kubernetes 환경 팁: 포트포워딩으로 안전하게 접근
EKS 같은 환경에서는 pprof를 외부에 노출하지 말고, 필요할 때만 포트포워딩하세요.
kubectl -n your-ns port-forward pod/your-pod 6060:6060
curl -s http://127.0.0.1:6060/debug/pprof/goroutine?debug=2 | head
리소스 압박으로 스케줄링/GC가 흔들리면 “교착처럼 보이는 정체”가 생기기도 합니다. 파드가 자주 Pending/재시작을 겪는다면 인프라 쪽도 같이 점검하세요.
9) 자주 하는 실수와 예방책
select {}로 영구 대기하는 고루틴을 무심코 만들기
테스트용으로 넣은 select {}가 서버 코드에 남아 있거나, 종료 신호를 받지 못하는 루프가 goroutine을 영구 점유하는 경우가 있습니다. 모든 장기 실행 goroutine에는 최소한 ctx.Done() 같은 탈출 조건을 두세요.
채널을 “락처럼” 쓰면서 close 규칙이 없는 경우
- 누가 close하는지
- close 이후 send가 발생하지 않는지
- 수신 측이 drain하는지
이 3가지를 문서화하지 않으면, 교착과 패닉이 번갈아 나타납니다.
버퍼 크기를 근거 없이 키우기
버퍼는 증상을 늦추지만, 병목이 해소되지 않으면 결국 같은 문제가 더 크게 터집니다. trace로 “왜 소비가 느린지”까지 확인하는 것이 정공법입니다.
마무리
채널 교착상태는 “코드를 오래 봐야만” 찾는 문제가 아니라, pprof로 스택을 찍고 trace로 시간축을 보면 빠르게 좁혀지는 문제입니다.
pprof goroutine으로 어디에서 멈췄는지 찾고block/mutex로 블로킹의 성격을 분류한 뒤trace로 누가 누구를 기다리는지 확인하면
대부분의 케이스는 5분 안에 “원인 후보 함수와 라인”까지 도달할 수 있습니다.
다음 단계로는, 위에서 찾은 라인에 ctx 기반 탈출 경로, 채널 close 규칙, 워커 풀/버퍼 설계를 적용해 재발을 막는 것이 핵심입니다.