Published on

Go 고루틴 누수 잡기 - context·채널 종료 패턴

Authors

서버를 운영하다 보면 “요청은 끝났는데 프로세스가 점점 무거워진다”, “커넥션 수가 줄지 않는다”, “메모리가 천천히 증가한다” 같은 증상을 만납니다. Go에서는 이런 현상이 고루틴 누수(goroutine leak) 로 자주 나타납니다. 고루틴 자체는 가볍지만, 고루틴이 붙잡고 있는 네트워크 소켓, 타이머, 채널 버퍼, 락, 파일 디스크립터, DB 커넥션 은 결코 가볍지 않습니다.

이 글에서는 누수의 전형적인 원인과 함께, 실무에서 가장 효과가 큰 두 축인 context 취소 전파채널 종료(닫기) 규칙 을 패턴으로 정리합니다. 마지막에는 점검 체크리스트까지 제공합니다.

참고로 장애를 만드는 “서서히 악화되는 문제”는 동시성 이슈와 결이 비슷합니다. 예를 들어 DB 데드락 원인 추적 글인 PostgreSQL 데드락(40P01) 원인·해결 9단계도 같이 보면, 관측 지표를 기반으로 원인을 좁혀가는 방식이 도움이 됩니다.

고루틴 누수의 정의와 흔한 징후

고루틴 누수는 보통 “고루틴이 영원히 종료되지 못하고 대기 상태로 남는 것”을 의미합니다. 대표적인 대기 지점은 다음입니다.

  • 채널 수신 대기: v := <-ch
  • 채널 송신 대기: ch <- v
  • select 에서 어떤 케이스도 진행되지 않음
  • sync.Cond.Wait, Mutex.Lock 대기
  • 네트워크 읽기/쓰기 블로킹
  • time.TickerStop 하지 않음

운영 관점의 징후는 다음이 많습니다.

  • runtime.NumGoroutine() 값이 지속적으로 증가
  • pprof에서 chan receive / chan send 스택이 상위에 지속 노출
  • 요청 트래픽이 줄어도 메모리, FD, 커넥션 수가 회복되지 않음

진단: pprof로 “어디서 기다리는지” 찾기

가장 빠른 방법은 pprof로 고루틴 덤프를 보는 것입니다.

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

func startPprof() {
  go func() {
    // 운영 환경에서는 바인딩/인증을 반드시 고려하세요.
    _ = http.ListenAndServe("127.0.0.1:6060", nil)
  }()
}

덤프 확인은 보통 다음처럼 합니다.

  • curl http://127.0.0.1:6060/debug/pprof/goroutine?debug=2
  • 또는 go tool pprof 로 goroutine profile 분석

덤프에서 chan receive 로 멈춘 고루틴이 특정 함수에서 반복된다면, 그 함수가 “종료 신호를 못 받는 구조”일 가능성이 높습니다.

누수 패턴 1: 종료 신호 없는 무한 루프

가장 흔한 형태입니다.

func pollForever(ch chan<- string) {
  for {
    // 외부 종료 신호가 없으면 영원히 실행
    msg := fetch()
    ch <- msg
  }
}

이 코드는 호출자가 더 이상 결과를 읽지 않거나, fetch() 가 블로킹이거나, ch <- msg 가 막히면 그대로 고착됩니다.

해결: context 를 루프에 주입하고 select 로 취소를 받기

func poll(ctx context.Context, ch chan<- string) {
  for {
    select {
    case <-ctx.Done():
      return
    default:
      msg := fetch()
      select {
      case <-ctx.Done():
        return
      case ch <- msg:
      }
    }
  }
}

핵심은 “루프가 반드시 ctx.Done() 을 관측할 수 있어야 한다”는 점입니다. default 를 남용하면 busy loop가 될 수 있으니, 실제로는 fetch() 를 컨텍스트 지원 호출로 바꾸는 편이 더 좋습니다.

누수 패턴 2: 채널 수신 대기에서 영원히 멈춤

func worker(ch <-chan int) {
  for {
    v := <-ch // ch가 닫히지 않으면 영원히 대기 가능
    process(v)
  }
}

해결: 채널 close를 이용한 종료 신호, 또는 ok 패턴

func worker(ch <-chan int) {
  for v := range ch { // ch가 닫히면 루프 종료
    process(v)
  }
}

또는 ok 를 직접 확인합니다.

func worker(ch <-chan int) {
  for {
    v, ok := <-ch
    if !ok {
      return
    }
    process(v)
  }
}

여기서 중요한 규칙이 있습니다.

  • 채널을 닫는 주체는 “송신자” 여야 합니다.
  • 수신자가 닫으면, 아직 송신 중인 고루틴이 panic 을 맞을 수 있습니다.

누수 패턴 3: 송신 대기에서 막혀서 종료 못 함

버퍼가 없는 채널이나, 소비자가 종료된 채널로 보내려다 막히는 케이스입니다.

func producer(ch chan<- int) {
  for i := 0; i < 1_000_000; i++ {
    ch <- i // 소비자가 느리거나 없으면 여기서 대기
  }
}

해결: 송신도 select 로 취소 가능하게 만들기

func producer(ctx context.Context, ch chan<- int) {
  for i := 0; i < 1_000_000; i++ {
    select {
    case <-ctx.Done():
      return
    case ch <- i:
    }
  }
}

이 패턴 하나로 “요청 취소됐는데 백그라운드 고루틴이 계속 대기” 문제를 상당수 제거할 수 있습니다.

누수 패턴 4: time.Ticker / time.After 오용

time.TickerStop() 하지 않으면 내부 리소스가 유지됩니다.

func loop() {
  t := time.NewTicker(1 * time.Second)
  for range t.C {
    do()
  }
  // Stop() 호출이 없고, 루프를 빠져나갈 방법도 없음
}

해결: 종료 조건 + defer t.Stop()

func loop(ctx context.Context) {
  t := time.NewTicker(1 * time.Second)
  defer t.Stop()

  for {
    select {
    case <-ctx.Done():
      return
    case <-t.C:
      do()
    }
  }
}

또 하나의 흔한 이슈는 time.After 를 반복문에서 계속 생성해 타이머가 누적되는 경우입니다. 반복 루프에서는 Ticker 를 재사용하거나, 필요하다면 time.NewTimer 를 만들고 Stop 및 drain 처리를 고려하세요.

패턴 1: context 로 취소 전파를 “끝까지” 연결하기

고루틴 누수를 줄이는 가장 강력한 규칙은 다음입니다.

  • 요청 핸들러에서 만든 ctx 를 하위 호출로 계속 전달한다
  • 고루틴을 띄울 때도 ctx 를 받게 한다
  • 블로킹 지점은 모두 ctx.Done() 과 함께 select 로 감싼다

예시로, HTTP 요청에서 워커를 띄우는 전형적인 구조를 보겠습니다.

func handler(w http.ResponseWriter, r *http.Request) {
  ctx := r.Context()

  out := make(chan Result)

  go func() {
    defer close(out)
    res, err := doWork(ctx)
    if err != nil {
      return
    }
    select {
    case <-ctx.Done():
      return
    case out <- res:
    }
  }()

  select {
  case <-ctx.Done():
    http.Error(w, "canceled", http.StatusRequestTimeout)
    return
  case res, ok := <-out:
    if !ok {
      http.Error(w, "failed", http.StatusInternalServerError)
      return
    }
    _ = json.NewEncoder(w).Encode(res)
  }
}

포인트는 3가지입니다.

  1. 고루틴 내부에서 결과 채널을 defer close(out) 로 닫아 수신 측이 영원히 기다리지 않게 함
  2. 결과를 보낼 때도 ctx.Done() 과 경쟁시켜 취소 시 빠져나감
  3. 핸들러도 ctx.Done() 을 기다려 타임아웃/클라이언트 끊김에 반응

패턴 2: 채널 종료 규칙을 “프로토콜”로 문서화하기

채널은 단순한 자료구조가 아니라 프로토콜 입니다. 다음을 팀 규칙으로 정해두면 누수가 크게 줄어듭니다.

  • 누가 close 하는가: 보통 “생산자(송신자) 단 한 곳”
  • 언제 close 하는가: 더 이상 값을 보내지 않을 때, 그리고 모든 송신이 끝났을 때
  • 수신자는 for v := range ch 로 종료를 자연스럽게 처리
  • 다중 생산자라면 생산자들이 직접 닫지 말고, “집계자”가 닫게 설계

다중 생산자 채널 닫기: WaitGroup + 집계자

func fanIn(ctx context.Context, inputs ...<-chan int) <-chan int {
  out := make(chan int)

  var wg sync.WaitGroup
  wg.Add(len(inputs))

  forward := func(ch <-chan int) {
    defer wg.Done()
    for {
      select {
      case <-ctx.Done():
        return
      case v, ok := <-ch:
        if !ok {
          return
        }
        select {
        case <-ctx.Done():
          return
        case out <- v:
        }
      }
    }
  }

  for _, ch := range inputs {
    go forward(ch)
  }

  go func() {
    wg.Wait()
    close(out) // 오직 집계자만 close
  }()

  return out
}

이 구조는 다음을 보장합니다.

  • 입력 채널이 모두 닫히면 out 도 닫힘
  • 컨텍스트 취소 시 모든 forwarder가 빠르게 반환
  • out 을 닫는 주체가 단일하므로 panic 위험 감소

패턴 3: 고루틴 생명주기를 “소유자(owner)”로 묶기

고루틴을 여기저기서 띄우기 시작하면 누수 추적이 어려워집니다. 실무에서는 “이 고루틴은 누가 시작하고 누가 끝내는가”를 구조로 강제하는 편이 좋습니다.

  • 컴포넌트 생성 시 Start(ctx)
  • 종료 시 Cancel() 또는 상위 ctx 취소
  • 내부 고루틴은 errgroup 으로 묶어 한 곳에서 관리

errgroup 로 고루틴을 묶고 첫 에러에 전체 취소

import "golang.org/x/sync/errgroup"

func run(ctx context.Context) error {
  g, ctx := errgroup.WithContext(ctx)

  g.Go(func() error {
    return serveHTTP(ctx)
  })
  g.Go(func() error {
    return consumeQueue(ctx)
  })
  g.Go(func() error {
    return refreshCache(ctx)
  })

  // 하나라도 에러가 나면 ctx가 취소되고 나머지도 종료 유도
  return g.Wait()
}

이 방식은 누수 방지뿐 아니라, 장애 전파(한 구성요소 실패 시 전체 셧다운) 정책을 코드로 표현하기 좋습니다.

누수 패턴 5: “취소 불가능한 블로킹 I/O”를 고루틴으로 감춰버림

예를 들어 어떤 라이브러리 호출이 컨텍스트를 지원하지 않아서, 별도 고루틴에서 호출하고 결과만 채널로 받는 형태가 있습니다.

func callLegacy(ctx context.Context) (string, error) {
  ch := make(chan string, 1)
  go func() {
    ch <- legacyBlockingCall()
  }()

  select {
  case <-ctx.Done():
    return "", ctx.Err()
  case v := <-ch:
    return v, nil
  }
}

겉보기엔 ctx 를 지원하는 것 같지만, ctx 가 취소돼도 legacyBlockingCall() 은 계속 돌 수 있습니다. 이런 경우 진짜 해결은 다음 중 하나입니다.

  • 컨텍스트를 지원하는 대체 API로 교체
  • 네트워크라면 net.DialerSetDeadline 같은 타임아웃/데드라인 적용
  • 프로세스 외부로 격리(워커 프로세스)해 강제 종료 가능하게 설계

운영에서의 방어선: 고루틴 수, 큐 길이, 타임아웃

누수는 “코드 패턴”으로 예방하는 게 1순위지만, 운영에서는 조기 탐지 장치가 필요합니다.

  • runtime.NumGoroutine() 를 메트릭으로 수집하고 상한 알람
  • 내부 큐(채널) 길이, 처리 지연, 타임아웃 비율을 같이 관측
  • 외부 API 호출에는 항상 타임아웃을 둠

재시도/백오프가 들어간 시스템이라면, 취소 전파가 잘못되어 고루틴이 쌓이는 경우가 많습니다. 재시도 설계 관점은 OpenAI 429 Rate Limit 재시도·백오프 설계 같은 글의 “중단 조건” 사고방식이 그대로 적용됩니다.

실무 체크리스트

  • 모든 고루틴은 종료 조건이 있는가
  • 블로킹 지점(채널 send/receive, I/O, 타이머)에 ctx.Done() 을 함께 두었는가
  • 채널을 누가 닫는지 단일 소유자가 명확한가
  • 다중 생산자 채널은 WaitGroup 이후 한 곳에서만 close 하는가
  • TickerStop() 하는가
  • time.After 를 루프에서 무한 생성하고 있지 않은가
  • pprof goroutine 덤프에서 동일 스택이 계속 누적되는가

마무리

Go 고루틴 누수는 “특정 함수가 느리다”보다 더 위험한 유형의 문제입니다. 시간이 지날수록 시스템 전체의 여유 자원이 줄어들고, 결국은 전혀 다른 곳에서 장애가 폭발합니다.

가장 효과적인 처방은 단순합니다.

  • context 취소를 끝까지 전파하고
  • 채널 종료 규칙을 프로토콜로 고정하며
  • 고루틴 생명주기를 소유자 단위로 묶어 관리하기

이 3가지를 코드 리뷰 체크 항목으로만 넣어도, 운영에서 마주치는 “원인 불명의 점진적 악화”를 크게 줄일 수 있습니다.