Published on

Go goroutine 누수 잡기 - context·select 패턴

Authors

서비스가 오래 돌수록 메모리와 goroutine 수가 서서히 증가한다면, 높은 확률로 goroutine leak(고루틴 누수)을 의심해야 합니다. Go의 goroutine은 가볍지만 “공짜”는 아닙니다. 블로킹된 채로 영원히 깨어나지 못하는 goroutine이 쌓이면, 스택·타이머·채널 대기열·네트워크 커넥션·파일 디스크립터 같은 리소스가 함께 묶여 장애로 이어집니다.

이 글은 다음 두 축에 집중합니다.

  • context로 취소 신호를 전파하는 방법
  • select로 블로킹 지점을 중단 가능(cancellable) 하게 만드는 패턴

또한 프로파일링과 운영 환경에서의 징후까지 함께 다룹니다. (리소스 부족 증상은 종종 쿠버네티스에서 Pending이나 OOM 같은 형태로 나타나기도 합니다. 운영 체크리스트 관점은 EKS Pod가 Pending(Insufficient memory)일 때 점검법 글도 같이 참고하면 좋습니다.)

goroutine 누수의 전형적인 형태

goroutine은 대개 아래 중 하나로 “영원히” 살아남습니다.

  1. 채널 송수신이 영원히 블로킹
    • 받는 쪽이 종료됐는데 보내는 쪽이 계속 ch <- x에서 대기
    • 보내는 쪽이 종료됐는데 받는 쪽이 계속 <-ch에서 대기
  2. time.Tick 사용으로 타이머가 해제되지 않음
    • time.Tick은 내부적으로 ticker를 만들고, GC가 회수하기 전까지 계속 신호를 생성
  3. http.Client 요청이 컨텍스트 없이 무한 대기
    • 서버가 응답을 안 주거나 네트워크가 끊겼는데 타임아웃/취소가 없음
  4. fan-out 후 fan-in에서 수거(Join) 누락
    • 워커를 띄워놓고 결과를 끝까지 읽지 않거나, 종료를 기다리지 않음
  5. selectdefault를 잘못 넣어 busy loop
    • 누수라기보다 CPU를 태우며 “살아있는” goroutine이 폭증

이 문제의 공통점은 “중단 조건이 없다”는 점입니다. 그래서 해법도 일관됩니다.

  • 중단 조건을 context.Done()으로 통일한다
  • 블로킹 연산을 select로 감싸서 Done을 함께 기다린다
  • 종료 시점에 채널을 닫거나, 종료를 기다리는 join을 만든다

핵심 원칙: 컨텍스트는 최상단에서 만들고 아래로 흘린다

컨텍스트는 보통 요청의 경계(HTTP 핸들러, 메시지 소비 루프, 배치 잡)에서 생성하고, 그 아래로 내려보냅니다.

  • 누수 방지 관점에서 중요한 점은 “하위 goroutine이 부모의 취소를 반드시 관찰할 수 있어야 한다”는 것입니다.

나쁜 예: 취소 경로가 없는 goroutine

아래 코드는 채널 수신에서 영원히 블로킹될 수 있습니다.

package main

import "fmt"

func worker(jobs <-chan int) {
	for {
		j := <-jobs // jobs가 닫히지 않으면 영원히 대기 가능
		fmt.Println("job", j)
	}
}

func main() {
	jobs := make(chan int)
	go worker(jobs)
	// jobs에 아무것도 보내지 않으면 worker는 계속 살아있음
	select {}
}

좋은 예: context로 종료 신호를 전파

package main

import (
	"context"
	"fmt"
	"time"
)

func worker(ctx context.Context, jobs <-chan int) {
	for {
		select {
		case <-ctx.Done():
			return
		case j, ok := <-jobs:
			if !ok {
				return
			}
			fmt.Println("job", j)
		}
	}
}

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

	jobs := make(chan int)
	go worker(ctx, jobs)

	// 데모를 위해 아무것도 안 보냄
	<-ctx.Done()
	close(jobs)
}

여기서 중요한 포인트는 두 가지입니다.

  • 수신은 j, ok := <-jobs로 닫힘을 처리
  • 취소는 case <-ctx.Done()로 즉시 탈출

select 패턴 1: “블로킹 연산 + Done”을 항상 한 세트로

goroutine 누수는 대부분 “블로킹 연산을 취소할 수 없어서” 발생합니다. 채널 송수신, 타이머 대기, 네트워크 I/O 같은 블로킹 지점에 Done을 붙이는 습관이 중요합니다.

송신도 취소 가능하게 만들기

받는 쪽이 느리거나 이미 죽었는데, 보내는 쪽이 무한 대기하는 케이스가 흔합니다.

func sendOrCancel(ctx context.Context, out chan<- []byte, b []byte) error {
	select {
	case <-ctx.Done():
		return ctx.Err()
	case out <- b:
		return nil
	}
}

이 패턴을 쓰면 “소비자가 사라져도 생산자가 함께 종료”할 수 있습니다.

수신도 취소 가능하게 만들기

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

주의: 제네릭 표기 T가 들어간 코드는 본문에서 부등호가 노출되면 MDX 빌드 에러가 날 수 있으므로, 반드시 코드 블록 안에서만 사용합니다.

select 패턴 2: time.Tick 대신 time.NewTicker + Stop

time.Tick은 편하지만, ticker를 명시적으로 멈출 수 없어 장기 실행 서비스에서 누수의 원인이 됩니다. 반복 작업은 아래처럼 작성합니다.

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

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

defer t.Stop()이 핵심입니다.

select 패턴 3: fan-out/fan-in에서 “수거”를 보장

워커 여러 개를 띄우고 결과를 합치는 구조에서 누수가 자주 발생합니다.

  • 워커는 결과를 보내려다 블로킹
  • 수집기는 중간에 에러로 리턴하면서 결과 채널을 더 이상 읽지 않음
  • 결과적으로 워커 goroutine들이 송신에서 멈춘 채로 남음

해결: errgroup으로 취소 전파 + join

표준 라이브러리는 아니지만 사실상 표준에 가까운 golang.org/x/sync/errgroup을 추천합니다.

package main

import (
	"context"
	"fmt"
	"golang.org/x/sync/errgroup"
	"time"
)

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

	g, ctx := errgroup.WithContext(ctx)
	results := make(chan int)

	// producer workers
	for i := 0; i < 3; i++ {
		i := i
		g.Go(func() error {
			defer fmt.Println("worker done", i)
			for j := 0; j < 10; j++ {
				select {
				case <-ctx.Done():
					return ctx.Err()
				case results <- i*100 + j:
				}
			}
			return nil
		})
	}

	// closer: all workers done then close results
	g.Go(func() error {
		defer close(results)
		return g.Wait() // Wait를 한 번 더 호출하는 구조는 피해야 하지만, 데모용
	})

	// consumer
	for r := range results {
		fmt.Println("got", r)
	}
}

위 코드는 데모 목적이라 Wait 호출 구조가 깔끔하지 않습니다. 실전에서는 보통 아래 형태가 더 안전합니다.

  • 워커들은 g.Go
  • 별도의 goroutine에서 g.Wait()close(results)
  • 소비자는 range results

핵심은 “소비자가 반드시 끝까지 읽을 수 있게” 하거나, “소비가 중단되면 생산도 취소되게” 만드는 것입니다.

흔한 실수: default로 인한 바쁜 루프

다음 코드는 언뜻 안전해 보이지만, 채널이 비어 있을 때 default가 즉시 실행되어 CPU를 태웁니다.

for {
	select {
	case msg := <-ch:
		_ = msg
	case <-ctx.Done():
		return
	default:
		// 아무 일도 안 함: tight loop
	}
}

대안은 아래 중 하나입니다.

  • default를 제거하고 블로킹 수신
  • default가 필요하면 time.Sleep 같은 백오프를 둠
  • 더 나은 구조로는 “이벤트 기반”으로 설계

HTTP/외부 I/O에서의 누수: 타임아웃과 컨텍스트

외부 API 호출, DB 쿼리, 스트리밍 처리에서 컨텍스트가 없으면 goroutine이 네트워크 I/O에 묶일 수 있습니다.

http.NewRequestWithContext 사용

req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
	return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
	return err
}
defer resp.Body.Close()

여기에 더해 http.Client{Timeout: ...}을 함께 설정하면 “서버가 컨텍스트 취소를 무시하는” 경우에도 상한을 둘 수 있습니다.

스트리밍은 특히 누수/중복 처리 이슈가 함께 터지기 쉽습니다. SSE 같은 장기 연결은 재시도와 취소가 한 몸이므로, 패턴을 따로 정리한 글인 OpenAI SSE 스트리밍 끊김·중복 토큰 재시도 패턴도 참고할 만합니다.

디버깅: “goroutine이 어디서 멈췄는지”를 먼저 본다

1) 런타임에서 goroutine 수를 지표로 박기

운영에서 가장 빠른 감지는 “goroutine 수가 시간에 따라 증가하는지”입니다.

  • Prometheus를 쓴다면 go_goroutines를 대시보드에 올리세요.
  • 급증이 아니라 “계단식 증가 후 내려오지 않음”이 더 전형적인 누수 패턴입니다.

2) pprof로 goroutine 덤프

서버에 net/http/pprof를 붙여두면 goroutine 스택을 바로 볼 수 있습니다.

import _ "net/http/pprof"

go func() {
	_ = http.ListenAndServe("127.0.0.1:6060", nil)
}()

그리고 다음처럼 확인합니다.

  • curlhttp://127.0.0.1:6060/debug/pprof/goroutine?debug=2

스택에서 자주 보이는 누수 지점은 다음 키워드들입니다.

  • chan send / chan receive
  • select에서 특정 case만 대기
  • time.Sleep 혹은 ticker 관련
  • (*Client).Do 같은 네트워크 호출

3) “누수는 결국 리소스 고갈”로 나타난다

goroutine 자체는 메모리만 먹는 것처럼 보여도, 실제로는 연결/락/큐를 함께 쥐고 있는 경우가 많습니다.

  • 커넥션 풀 고갈, 타임아웃 폭증, 큐 적체
  • 쿠버네티스에서는 메모리 압박이 Pending이나 OOMKill로 표면화

리소스 고갈을 추적하는 관점은 Go만의 문제가 아니라 공통입니다. 예를 들어 커넥션 고갈 패턴은 Spring Boot HikariCP 커넥션 고갈 원인과 해결처럼 다른 런타임에서도 동일한 형태로 반복됩니다.

실전 체크리스트: 누수 방지 코딩 규칙

아래 규칙을 팀 규약처럼 가져가면 누수의 80%를 예방할 수 있습니다.

  1. goroutine을 띄울 때는 “어떻게 끝나는지”를 코드로 같이 적는다
    • go func(){...}()를 쓰기 전에 종료 조건을 먼저 설계
  2. 블로킹 연산(채널, 타이머, I/O)에는 항상 ctx.Done()을 붙인다
    • 송신도 예외가 아님
  3. 채널 수신은 v, ok := <-ch로 닫힘을 처리한다
  4. time.Tick 금지, time.NewTickerStop 사용
  5. fan-out/fan-in은 errgroup.WithContext로 취소 전파 + join
  6. 소비자가 중간에 리턴할 수 있다면, 생산자가 막히지 않게 해야 한다
    • 버퍼 채널로 “일시적 완충”을 두거나
    • 생산도 컨텍스트 취소를 관찰하도록
  7. default는 매우 신중히
    • 필요하면 백오프를 넣어 busy loop를 막는다

마무리

goroutine 누수는 “코드가 틀렸다”기보다 “종료 설계가 빠졌다”에 가깝습니다. context는 종료 신호의 표준 경로를 만들고, select는 모든 블로킹 지점을 취소 가능하게 만드는 도구입니다.

새 기능을 추가할 때마다 다음 질문을 습관처럼 던져보면 좋습니다.

  • 이 goroutine은 어떤 조건에서 종료되는가
  • 종료 신호는 어디서 만들어져 어디까지 전파되는가
  • 채널 송수신이 막히면 취소로 빠져나올 수 있는가

이 세 가지가 코드에 명확히 드러나면, 누수는 대부분 사라집니다.