Published on

Go 채널 데드락·고루틴 누수 5분 진단법

Authors

서버가 갑자기 멈췄는데 CPU는 낮고, 요청은 타임아웃만 나고, 메모리는 조금씩 늘어납니다. 이때 가장 흔한 원인 두 가지가 채널 데드락고루틴 누수입니다. 둘은 증상이 비슷하지만, 진단 접근은 다릅니다. 이 글은 “5분 안에” 원인을 좁히고, 재현과 증거 수집까지 끝내는 실전 루틴을 제공합니다.

아래 내용은 운영 환경에서 바로 써먹기 좋은 순서로 구성했습니다. 더 깊게는 고루틴 누수 패턴을 정리한 글도 함께 참고하세요: Go 고루틴 누수 진단 - context·채널 close 7패턴, 그리고 컨텍스트 취소 누락으로 멈추는 케이스는 Go 고루틴 컨텍스트 취소 누락으로 멈춤 해결도 같이 보면 좋습니다.

0분~1분: “멈춤”의 형태를 분류하기

먼저 멈춤이 다음 중 어디에 가까운지 분류합니다.

  • 데드락(또는 교착에 준하는 정지)
    • 요청 처리 고루틴들이 채널 송신/수신에서 서로 기다림
    • sync.Mutexsync.RWMutex에서 잠금 대기
    • WaitGroup.Wait()가 끝나지 않음
  • 고루틴 누수(서서히 악화)
    • 요청은 처리되지만 고루틴 수가 계속 증가
    • 메모리/FD(파일 디스크립터)/커넥션이 서서히 증가
    • 특정 경로에서 select가 영원히 깨어나지 않음

즉시 확인할 지표는 2개입니다.

  1. 프로세스 고루틴 수 추세
  • 짧은 시간 내 급증이면 누수 가능성이 큼
  1. 스택 트레이스에서 “어디서 대기 중인지”
  • 채널 대기, mutex 대기, 네트워크 read 대기 등으로 갈립니다

1분~2분: pprof를 “지금” 켜서 증거 확보하기

운영에서 가장 빠른 증거 수집은 net/http/pprof입니다. 이미 붙어 있다면 바로 뜯어보면 되고, 없다면 다음처럼 최소 변경으로 붙입니다.

// main.go
import (
  "log"
  "net/http"
  _ "net/http/pprof"
)

func startPprof() {
  go func() {
    // 내부망에서만 접근 가능하게 하거나, 인증 프록시 뒤에 두세요.
    addr := "127.0.0.1:6060"
    log.Println("pprof listening on", addr)
    _ = http.ListenAndServe(addr, nil)
  }()
}

이제 다음을 바로 뜹니다.

  • 고루틴 덤프
curl -s http://127.0.0.1:6060/debug/pprof/goroutine?debug=2 > goroutines.txt
  • 프로파일(고루틴/힙)
go tool pprof -top http://127.0.0.1:6060/debug/pprof/heap

goroutines.txt가 5분 진단에서 가장 중요합니다. “대기하는 지점”이 그대로 나옵니다.

2분~3분: goroutine dump에서 패턴 3개만 찾기

goroutines.txt를 열고, 다음 키워드만 먼저 찾습니다.

1) chan send 또는 chan receive

채널 송수신에서 대기 중인 고루틴이 다수면, 데드락 또는 채널 설계 문제일 확률이 큽니다.

  • chan send가 많다: 받는 쪽이 죽었거나 느리거나, 버퍼가 꽉 찼거나, 아예 수신자가 없음
  • chan receive가 많다: 보내는 쪽이 종료되었는데 채널을 닫지 않았거나, 특정 조건에서 송신이 빠짐

2) sync.(*WaitGroup).Wait

누군가 Done()을 호출하지 않았거나, Add()/Done() 균형이 깨졌거나, 고루틴이 중간에 return/panic으로 빠졌을 수 있습니다.

3) select에서 영원히 대기

select { case ... }에서 모든 케이스가 막혀 있고 default도 없으며, 취소 신호(ctx.Done())도 없으면 누수로 이어집니다.

운영에서 특히 자주 보는 형태는 “네트워크/외부 API 대기 + 컨텍스트 취소 없음” 조합입니다.

3분~4분: 30줄 미니 재현 코드로 원인을 확정하기

현장에서 제일 빠른 확정은 “의심 패턴을 축소 재현”하는 것입니다. 아래는 대표적인 데드락/누수 패턴을 1분 내 재현하는 코드들입니다.

케이스 A: 수신자 없는 채널 송신으로 고정 (데드락성 정지)

package main

import "time"

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

  go func() {
    // 수신자가 없으면 여기서 영원히 블록
    ch <- 1
  }()

  time.Sleep(10 * time.Second)
}

운영에서는 “수신 루프가 종료되었는데 송신은 계속” 같은 형태로 나타납니다.

케이스 B: 채널 close 누락으로 수신자 무한 대기 (누수)

package main

import "fmt"

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

  go func() {
    // 어떤 조건에서 return 되어 close가 호출되지 않는다고 가정
    return
  }()

  // 수신자는 끝을 모르고 기다림
  v := <-ch
  fmt.Println(v)
}

실전에서는 “producer가 에러로 중간 종료”되었는데 close(ch)가 보장되지 않아 소비자가 멈추는 경우가 많습니다.

케이스 C: 컨텍스트 없는 select 대기 (누수)

package main

func worker(ch <-chan int) {
  for {
    select {
    case <-ch:
      // 처리
    }
  }
}

func main() {
  ch := make(chan int)
  go worker(ch)
  select {} // 프로그램 유지
}

이 형태는 “종료 조건이 아예 없는 고루틴”을 만들기 쉽습니다. 반드시 종료 신호를 설계해야 합니다.

4분~5분: 즉시 적용 가능한 6가지 응급 처방

원인을 100퍼센트 고치기 전에, 장애를 완화하고 재발 가능성을 줄이는 안전장치부터 넣는 게 현실적입니다.

1) 모든 블로킹 지점에 context를 통과시키기

채널/락/네트워크 대기 모두 “취소 가능”해야 합니다.

func recvWithContext[T any](ctx context.Context, ch <-chan T) (T, bool) {
  var zero T
  select {
  case v, ok := <-ch:
    return v, ok
  case <-ctx.Done():
    return zero, false
  }
}

제네릭 T는 꼭 백틱으로 감싸거나, MDX에서는 위처럼 코드 블록 안에만 두는 습관이 안전합니다.

2) 송신은 “영원히” 기다리지 않게 만들기

버퍼 채널이라도 꽉 차면 송신은 블록합니다. 최소한 타임아웃을 둡니다.

func sendWithTimeout[T any](ch chan<- T, v T, d time.Duration) bool {
  t := time.NewTimer(d)
  defer t.Stop()

  select {
  case ch <- v:
    return true
  case <-t.C:
    return false
  }
}

3) 채널 close 책임자를 단 하나로 고정하기

close는 “보내는 쪽”에서, 그리고 “딱 한 군데”에서만 하도록 규칙화합니다.

  • 여러 producer가 있다면 sync.WaitGroup으로 producer 종료를 모은 뒤, aggregator가 close(out)
  • consumer가 close하면 거의 항상 레이스나 패닉으로 이어집니다

4) WaitGroupdefer Done()을 기본값으로

wg.Add(1)
go func() {
  defer wg.Done()
  // 중간 return, panic 가능 지점이 있어도 Done 보장
  work()
}()

5) “고루틴을 띄우는 함수”는 종료 계약을 문서화하기

고루틴을 시작하는 함수는 다음 중 하나를 반드시 제공해야 합니다.

  • ctx를 받는다
  • stop() 함수를 반환한다
  • Close()를 제공한다

이 계약이 없으면 누수는 언젠가 터집니다.

6) 프로덕션에 최소한의 관측 가능성 추가

  • /debug/pprof는 내부 접근만 허용
  • 고루틴 수를 메트릭으로 수집 (runtime.NumGoroutine())
  • 특정 워커 풀의 큐 길이/드롭 수를 메트릭으로 수집

고루틴 수 메트릭은 누수의 조기 경보로 매우 강력합니다.

// 예: 주기적으로 로그만 찍어도 초기에 도움이 됩니다.
for range time.Tick(10 * time.Second) {
  log.Println("goroutines=", runtime.NumGoroutine())
}

흔한 오해 3가지

“버퍼 채널이면 데드락이 없다”

버퍼는 시간을 벌어줄 뿐입니다. 버퍼가 꽉 차는 순간 송신은 동일하게 멈춥니다. 오히려 문제를 늦게 드러내 운영에서 더 크게 터질 수 있습니다.

range ch는 안전하다”

range ch는 채널이 닫혀야 끝납니다. 닫히지 않는 채널을 range로 받으면 소비자 고루틴은 종료하지 못합니다.

“고루틴은 가볍다 = 마음대로 만들어도 된다”

고루틴은 스택이 작게 시작하지만, 누수되면 메모리뿐 아니라 스케줄링 비용과 외부 리소스(커넥션, 타이머 등)까지 같이 누적됩니다.

5분 진단 체크리스트 요약

    1. pprof goroutine dump 확보: curlgoroutine?debug=2
    1. 덤프에서 chan send/receive, WaitGroup.Wait, select 대기 패턴 탐색
    1. “누가 누구를 기다리는지” 관계를 그림으로 단순화
    1. 재현 최소 코드를 만들고, 종료 조건(ctx.Done(), close, timeout)을 강제
    1. 응급 처방: 블로킹에 컨텍스트/타임아웃, close 책임자 단일화, defer Done()

채널 데드락과 고루틴 누수는 대부분 “종료 계약이 없는 비동기”에서 시작합니다. 5분 안에 덤프를 뜨고, 대기 지점을 찾고, 종료 신호를 설계하는 습관만 들여도 장애 대응 속도가 크게 올라갑니다.