Published on

Go 고루틴 누수 7원인 - 채널·컨텍스트 점검

Authors

서버가 “느려진다”는 제보가 들어왔는데 CPU는 애매하고 메모리는 그럭저럭, 그런데 프로세스의 고루틴 수가 계속 증가한다면 거의 확실하게 고루틴 누수를 의심해야 합니다. Go의 고루틴은 가볍지만 “공짜”는 아닙니다. 고루틴이 종료되지 않으면 그 고루틴이 잡고 있는 채널, 타이머, 네트워크 커넥션, 파일 디스크립터, 락 대기열까지 함께 누적됩니다.

이 글은 채널과 컨텍스트를 중심으로, 실무에서 반복적으로 발생하는 고루틴 누수 7가지 원인과 점검 체크리스트를 제공합니다. 또한 각 원인별로 재현 코드와 수정 패턴을 함께 제시합니다.

관련해서 리소스가 고갈되는 증상은 OS 레벨에서 EMFILE 같은 형태로도 튀어나옵니다. 고루틴 누수와 함께 FD 누수가 결합되면 장애가 훨씬 빨리 옵니다. 필요하면 Linux EMFILE(Too many open files) 원인과 해결도 같이 보세요.

고루틴 누수의 “정의”부터 맞추기

고루틴 누수는 메모리 누수와 조금 다릅니다.

  • 메모리 누수: 더 이상 접근할 수 없는 객체가 GC에 의해 회수되지 않는 상태
  • 고루틴 누수: “업무적으로는 끝났어야 하는데” 종료 조건을 잃고 영원히 블록되거나 루프를 도는 고루틴이 남아 있는 상태

특히 Go에서는 다음 두 가지가 핵심 트리거입니다.

  • 채널 송수신이 영원히 블록됨
  • context.Context 취소를 전파하지 못해 작업이 끝나지 않음

진단 기본기: 어디서 새는지 빠르게 찾는 법

1) pprof로 고루틴 스택 확인

프로덕션에서 가장 빠른 방법은 goroutine profile을 보는 것입니다.

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

func init() {
  go func() {
    // 내부망에서만 열거나, 인증을 반드시 붙이세요.
    _ = http.ListenAndServe("127.0.0.1:6060", nil)
  }()
}

이후 go tool pprof로 goroutine dump를 보고, 동일한 스택이 계속 쌓이는 지점을 찾습니다.

2) 카운터로 “증가”를 감시

고루틴 수가 계속 증가하는지부터 봐야 합니다.

import (
  "runtime"
  "time"
  "log"
)

func logGoroutines() {
  t := time.NewTicker(10 * time.Second)
  defer t.Stop()

  for range t.C {
    log.Printf("goroutines=%d", runtime.NumGoroutine())
  }
}

이 지표가 톱니 형태로 오르내리며 안정화되지 않고 우상향한다면, 아래 7원인을 순서대로 의심하면 됩니다.

원인 1: 수신자가 없는데 채널에 send 해서 영구 블록

가장 흔한 누수입니다. 어떤 고루틴이 결과를 채널로 보내려는데, 받는 쪽이 이미 종료됐거나 애초에 없으면 송신 고루틴이 영원히 멈춥니다.

재현 코드

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

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

  // 함수는 바로 리턴
}

해결 패턴

  • 버퍼 채널로 “최소한의 완충”을 제공
  • 또는 select로 취소나 타임아웃 경로를 제공
func fixedSend(ctx context.Context) {
  ch := make(chan int, 1)

  go func() {
    select {
    case ch <- 1:
    case <-ctx.Done():
      return
    }
  }()
}

버퍼 채널은 만능이 아닙니다. “언젠가 받겠지”라는 가정이 깨지면 결국 막힙니다. 그래서 컨텍스트 취소 경로를 함께 두는 것이 안전합니다.

원인 2: 송신자가 끝났는데 채널을 닫지 않아 range 수신이 영원히 대기

for v := range ch는 채널이 닫힐 때까지 끝나지 않습니다. 생산자가 종료되는데 채널을 닫지 않으면 소비자는 영원히 대기합니다.

재현 코드

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

  go func() {
    ch <- 1
    // close(ch) 를 안 함
  }()

  go func() {
    for v := range ch {
      _ = v
    }
    // 여기에 도달하지 못함
  }()
}

해결 패턴

  • 생산자가 “채널 소유자”라면 반드시 닫기
  • 여러 생산자라면 sync.WaitGroup으로 생산자 종료를 모아 한 곳에서 닫기
func fixedRange() {
  ch := make(chan int)
  var wg sync.WaitGroup

  producers := 2
  wg.Add(producers)

  for i := 0; i < producers; i++ {
    go func(i int) {
      defer wg.Done()
      ch <- i
    }(i)
  }

  go func() {
    wg.Wait()
    close(ch)
  }()

  for v := range ch {
    _ = v
  }
}

핵심은 “누가 닫을 책임이 있는가”를 코드 구조로 강제하는 것입니다.

원인 3: context.WithCancel 또는 WithTimeout를 만들고 cancel을 호출하지 않음

컨텍스트는 GC가 알아서 정리해 주지 않습니다. 특히 context.WithTimeout은 내부 타이머를 가지고 있어 cancel을 호출하지 않으면 타이머가 만료될 때까지 리소스가 남습니다.

재현 코드

func leakByContext() {
  ctx, _ := context.WithTimeout(context.Background(), 30*time.Second)
  _ = ctx
  // cancel 미호출
}

해결 패턴

항상 defer cancel()을 기본값으로 두세요.

func fixedContext() {
  ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
  defer cancel()

  _ = ctx
}

또한 “고루틴 내부에서만 쓰는 컨텍스트”라면, 그 고루틴이 종료될 때 cancel이 호출되도록 구조화하는 것이 좋습니다.

원인 4: 컨텍스트를 무시한 blocking I/O 또는 라이브러리 호출

고루틴이 ctx.Done()을 체크하지 않으면 취소가 와도 끝나지 않습니다. 특히 다음이 자주 문제를 만듭니다.

  • time.Sleep로 긴 대기
  • net.Conn.Read 같은 블로킹 I/O를 컨텍스트 없이 호출
  • 외부 라이브러리 함수가 컨텍스트를 받지 않음

나쁜 예: sleep로 대기

func worker(ctx context.Context) {
  for {
    time.Sleep(10 * time.Second)
    // ctx 취소 확인이 늦어짐
  }
}

개선: select로 취소 가능하게 만들기

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

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

네트워크 I/O는 http.NewRequestWithContext 같은 “컨텍스트를 지원하는 API”를 우선 사용하고, 지원하지 않으면 데드라인 설정(SetDeadline) 같은 우회가 필요합니다.

원인 5: time.Tick 사용으로 틱커가 영구 유지

time.TickTicker를 반환하지만 stop할 방법이 없습니다. 함수가 끝나도 내부 리소스가 남아 누수처럼 보일 수 있고, 고루틴이 틱을 소비하지 못하면 더 악화됩니다.

나쁜 예

func leakByTick() {
  for range time.Tick(1 * time.Second) {
    // 영원히 실행, stop 불가
  }
}

해결: time.NewTickerStop

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

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

이 패턴은 주기 작업의 기본형으로 외워두는 편이 좋습니다.

원인 6: 팬아웃 작업에서 결과 채널을 충분히 소비하지 않음

여러 고루틴이 결과를 하나의 채널로 보내는 구조에서, 소비자가 일부만 읽고 중단하면 나머지 송신자들이 블록되어 누수로 이어집니다.

재현 코드

func leakByFanout() {
  results := make(chan int)

  for i := 0; i < 10; i++ {
    go func(i int) {
      results <- i // 소비자가 멈추면 여기서 블록
    }(i)
  }

  // 1개만 읽고 종료
  _ = <-results
}

해결 패턴 1: 버퍼를 “최대 송신자 수” 이상으로

func fixedFanoutBuffered() {
  n := 10
  results := make(chan int, n)

  for i := 0; i < n; i++ {
    go func(i int) {
      results <- i
    }(i)
  }

  _ = <-results
}

해결 패턴 2: 컨텍스트 취소와 select를 결합

버퍼로도 해결이 안 되는 경우가 많습니다. 소비자가 중단할 수 있다면, 생산자도 중단할 수 있어야 합니다.

func fixedFanoutWithCancel() {
  ctx, cancel := context.WithCancel(context.Background())
  defer cancel()

  results := make(chan int)

  for i := 0; i < 10; i++ {
    go func(i int) {
      select {
      case results <- i:
      case <-ctx.Done():
        return
      }
    }(i)
  }

  // 조건 만족하면 더 이상 필요 없음
  _ = <-results
  cancel()
}

핵심은 “소비자가 멈출 수 있으면 생산자도 멈출 수 있어야” 한다는 점입니다.

원인 7: errgroup 또는 WaitGroup 사용 시 취소 전파/대기 순서가 꼬임

동시 작업을 묶는 도구를 쓰더라도, 다음 실수가 누수를 만듭니다.

  • WaitGroup.Addgo 호출 순서가 어긋나 panic 또는 영구 대기
  • errgroup을 쓰면서도 내부 고루틴이 ctx.Done()을 체크하지 않아 취소가 전파되지 않음
  • Wait()를 호출하는 고루틴이 결과 채널 소비를 멈춰 교착

권장: errgroup.WithContext 기본형

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

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

  g.Go(func() error {
    select {
    case <-time.After(100 * time.Millisecond):
      return nil
    case <-ctx.Done():
      return ctx.Err()
    }
  })

  g.Go(func() error {
    // 다른 작업도 동일하게 ctx를 체크
    select {
    case <-ctx.Done():
      return ctx.Err()
    default:
      return nil
    }
  })

  return g.Wait()
}

errgroup을 쓰면 “에러가 나면 나머지를 멈춘다”는 기대를 하게 되는데, 실제로는 각 고루틴이 컨텍스트 취소를 존중해야만 멈춥니다. 즉, errgroup은 취소 신호를 제공할 뿐 “강제 종료”를 해주지 않습니다.

채널·컨텍스트 점검 체크리스트

운영 장애를 줄이려면 코드 리뷰에서 아래를 체크리스트로 고정하는 것이 효과적입니다.

  1. 채널 송신은 항상 select로 취소 경로가 있는가
  2. range ch를 쓰면 채널을 닫는 주체가 명확한가
  3. WithCancelWithTimeout은 항상 defer cancel()이 있는가
  4. 장시간 대기는 Sleep 대신 Tickerctx.Done()을 쓰는가
  5. time.Tick을 쓰지 않고 NewTickerStop을 쓰는가
  6. 팬아웃 구조에서 소비 중단 시 생산자도 중단 가능한가
  7. errgroup 사용 시 모든 작업이 ctx.Done()을 존중하는가

고루틴 누수는 “동시성 코드의 리소스 누수”입니다. 네트워크 커넥션이나 FD 누수로 이어지면 OS 레벨 에러로 폭발하기도 합니다. egress 실패가 포트 고갈로 이어지는 케이스처럼, 한 리소스의 누수가 다른 리소스의 고갈로 전이되는 패턴은 매우 흔합니다. 네트워크 리소스 관점은 GCP Cloud NAT 포트 고갈로 egress 실패 진단법도 참고할 만합니다.

마무리: “끝나는 조건”을 코드로 강제하라

고루틴 누수를 막는 가장 현실적인 방법은 “이 고루틴이 언제 끝나는지”를 코드 구조로 강제하는 것입니다.

  • 채널에는 닫는 주체를 정한다
  • 모든 블로킹 지점에는 취소 경로를 둔다
  • 컨텍스트는 만들었으면 반드시 cancel한다
  • 주기 작업은 TickerStop한다

이 4가지만 팀 규칙으로 고정해도 누수의 대부분이 사라집니다. 그 다음 단계로는 pprof 기반의 회귀 테스트(특정 시나리오 실행 후 고루틴 수가 안정화되는지)를 CI에 넣어, 누수가 다시 들어오지 못하게 막는 방식이 효과적입니다.