Published on

Go 고루틴 누수 원인 7가지와 차단 패턴

Authors

고루틴 누수(goroutine leak)는 “메모리가 계속 증가한다” 같은 단순 증상보다 먼저, 스케줄러 큐 정체, 타임아웃 증가, GC 압박, 네트워크/DB 커넥션 고갈로 이어지는 경우가 많습니다. 특히 요청당 고루틴을 쉽게 만드는 Go의 특성상, 종료 조건이 불명확한 고루틴이 하나둘 쌓이면 트래픽 피크에서 급격히 문제가 폭발합니다.

이 글에서는 실무에서 가장 자주 마주치는 고루틴 누수 원인 7가지와, 각 케이스를 구조적으로 차단하는 패턴을 코드 중심으로 정리합니다.

또한 “재시도/타임아웃/서킷브레이커” 같은 회복 전략은 고루틴 누수와도 강하게 연결됩니다. 외부 API 장애에서 재시도 고루틴이 쌓이는 문제는 OpenAI Responses API 500·503 대응 재시도 폴백 서킷브레이커 글의 관점과도 맞닿아 있습니다.

누수 진단: 먼저 무엇을 봐야 하나

1) goroutine 개수 추적

프로세스 내부에서 가장 간단히 볼 수 있는 지표는 runtime.NumGoroutine() 입니다. 메트릭으로 내보내고 “트래픽이 내려갔는데도 고루틴이 내려오지 않는가”를 확인하세요.

package main

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

func main() {
	for range time.Tick(5 * time.Second) {
		log.Printf("goroutines=%d", runtime.NumGoroutine())
	}
}

2) pprof로 스택 확인

HTTP 서버라면 net/http/pprof를 붙여 스택을 확인하는 것이 가장 빠릅니다.

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

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

스택에서 chan receive, select, time.Sleep, (*Client).Do 등에서 오래 멈춰 있는 고루틴이 반복적으로 보이면, 아래 원인들 중 하나일 확률이 높습니다.

원인 1) 채널 수신이 영원히 기다림(종료 신호 없음)

가장 흔한 형태입니다. 생산자가 더 이상 값을 보내지 않는데 소비자는 for { v := <-ch } 같은 형태로 영원히 기다립니다.

문제 예시

func consumer(ch <-chan int) {
	for {
		v := <-ch // 생산자가 중단되면 여기서 영원히 블록
		_ = v
	}
}

차단 패턴: 채널 close + for range

생산자가 종료할 때 채널을 닫고, 소비자는 for range로 자연 종료하게 만드세요.

func producer(ch chan<- int) {
	defer close(ch)
	for i := 0; i < 10; i++ {
		ch <- i
	}
}

func consumer(ch <-chan int) {
	for v := range ch { // close되면 루프 종료
		_ = v
	}
}

차단 패턴: context.Context로 취소 가능하게

채널을 닫기 어렵거나 여러 소비자가 있을 때는 ctx.Done()을 함께 두는 편이 안전합니다.

func consumer(ctx context.Context, ch <-chan int) {
	for {
		select {
		case <-ctx.Done():
			return
		case v, ok := <-ch:
			if !ok {
				return
			}
			_ = v
		}
	}
}

원인 2) 채널 송신이 영원히 막힘(수신자 부재)

반대로 송신자가 ch <- v에서 영원히 블록되는 케이스입니다. 수신자가 종료했거나, 버퍼가 꽉 찼거나, 수신 루틴이 느려졌을 때 발생합니다.

문제 예시

func producer(ch chan<- int) {
	for i := 0; ; i++ {
		ch <- i // 수신자가 없으면 영원히 블록
	}
}

차단 패턴: 송신에도 select로 취소/타임아웃

func producer(ctx context.Context, ch chan<- int) {
	for i := 0; ; i++ {
		select {
		case <-ctx.Done():
			return
		case ch <- i:
			// sent
		case <-time.After(500 * time.Millisecond):
			// backpressure 감지: 로그/드랍/버퍼 확장/워커 증설 등 정책 적용
		}
	}
}

여기서 time.After는 루프에서 반복 생성하면 타이머 객체가 누적될 수 있으니(원인 6 참고), 고빈도 루프에서는 time.NewTimer 재사용 패턴을 고려하세요.

원인 3) WaitGroup 카운트 불일치로 영원히 대기

wg.Add(1)은 했는데 wg.Done()이 보장되지 않으면, 누수라기보다 “종료되지 않는 대기”가 생기고 그로 인해 관련 고루틴들이 계속 살아남습니다.

문제 예시

var wg sync.WaitGroup

wg.Add(1)
go func() {
	// panic 또는 early return으로 Done 미호출 가능
	doWork()
	wg.Done()
}()

wg.Wait() // 영원히 대기

차단 패턴: 고루틴 시작 직후 defer wg.Done()

wg.Add(1)
go func() {
	defer wg.Done()
	doWork()
}()

차단 패턴: 에러 전파는 errgroup 사용

표준 x/sync/errgroup은 컨텍스트 취소까지 묶어 관리하기 좋아 누수 방지에 유리합니다.

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

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

	g.Go(func() error {
		return worker(ctx)
	})
	g.Go(func() error {
		return another(ctx)
	})

	return g.Wait()
}

원인 4) context 미전파로 요청이 끝나도 고루틴이 계속 동작

HTTP 요청 처리에서 가장 흔합니다. 핸들러는 응답을 끝냈는데, 내부 고루틴은 context.Background()로 외부 호출/루프를 계속 돌면서 남습니다.

문제 예시

func handler(w http.ResponseWriter, r *http.Request) {
	go func() {
		_ = callExternalAPI(context.Background()) // 요청 취소와 무관
	}()
	w.WriteHeader(http.StatusAccepted)
}

차단 패턴: r.Context() 전파 + 취소에 반응

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

	go func() {
		_ = callExternalAPI(ctx)
	}()

	w.WriteHeader(http.StatusAccepted)
}

외부 API/DB 호출은 반드시 타임아웃도 함께 설정하세요.

ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)

defer cancel()
_ = callExternalAPI(ctx)

재시도 로직을 붙일 때도 “취소 가능한 재시도”가 핵심입니다. 장애 시 무한 재시도 고루틴이 쌓이는 문제는 OpenAI Responses API 500·503 대응 재시도 폴백 서킷브레이커에서 다루는 것처럼, 제한된 재시도 횟수, 지수 백오프, 서킷브레이커로 반드시 상한을 둬야 합니다.

원인 5) fan-out 후 결과 수집 미흡(수신 안 해서 송신 고착)

여러 고루틴에 일을 분배(fan-out)하고 결과를 채널로 모을 때(fan-in), 에러가 나면 조기 리턴하면서 결과 채널을 더 이상 읽지 않는 패턴이 누수를 만듭니다. 작업 고루틴은 결과를 보내려다 막혀 종료하지 못합니다.

문제 예시

func searchAll(ctx context.Context, qs []string) (string, error) {
	results := make(chan string)

	for _, q := range qs {
		q := q
		go func() {
			res := doSearch(ctx, q)
			results <- res // 수집자가 리턴하면 여기서 블록
		}()
	}

	// 첫 결과만 받고 리턴
	select {
	case r := <-results:
		return r, nil
	case <-ctx.Done():
		return "", ctx.Err()
	}
}

차단 패턴 A: 결과 채널 버퍼를 작업 수만큼 확보

작업 수가 작고 상한이 명확하면 버퍼링으로 송신 블록을 제거할 수 있습니다.

results := make(chan string, len(qs))

차단 패턴 B: errgroup + 컨텍스트 취소 + 드레인 전략

가장 일반적인 안전 해법은 “조기 종료 시 컨텍스트 취소로 작업을 멈추게 하고, 송신은 취소를 존중”하도록 만드는 것입니다.

func searchAll(ctx context.Context, qs []string) (string, error) {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()

	results := make(chan string, 1) // 첫 결과만 필요
	g, ctx := errgroup.WithContext(ctx)

	for _, q := range qs {
		q := q
		g.Go(func() error {
			res, err := doSearch(ctx, q)
			if err != nil {
				return err
			}
			select {
			case results <- res:
				cancel() // 첫 결과 확보 시 나머지 취소
				return nil
			case <-ctx.Done():
				return ctx.Err()
			}
		})
	}

	select {
	case r := <-results:
		_ = g.Wait() // 정리
		return r, nil
	case <-ctx.Done():
		_ = g.Wait()
		return "", ctx.Err()
	}
}

핵심은 “결과 채널 송신도 취소 가능해야 한다”는 점입니다.

원인 6) 타이머/틱 사용 실수로 고루틴 또는 리소스가 남음

time.Tick는 내부적으로 멈출 수 없는 ticker를 만들기 때문에, 함수가 끝나도 참조가 남아 있으면 리소스가 계속 살아있습니다. 또한 루프에서 time.After를 무분별하게 만들면 타이머가 많이 생성되어 GC 압박이 커집니다.

문제 예시: time.Tick 남용

func poll() {
	for range time.Tick(1 * time.Second) { // Stop 불가
		do()
	}
}

차단 패턴: time.NewTicker + Stop

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

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

차단 패턴: 고빈도 루프에서 타이머 재사용

func loopWithTimeout(ctx context.Context, ch <-chan int) {
	t := time.NewTimer(500 * time.Millisecond)
	defer t.Stop()

	for {
		if !t.Stop() {
			select {
			case <-t.C:
			default:
			}
		}
		t.Reset(500 * time.Millisecond)

		select {
		case <-ctx.Done():
			return
		case <-t.C:
			// timeout
		case <-ch:
			// received
		}
	}
}

원인 7) 외부 리소스 블로킹(IO)에서 타임아웃/종료 미설정

네트워크, 파일, DB, 메시지 브로커 등 IO는 “언젠가 끝나겠지”가 가장 위험합니다. 타임아웃이 없으면 고루틴이 IO에 묶여 오래 살아남고, 커넥션 풀까지 고갈되며 연쇄 장애가 납니다.

문제 예시: HTTP 클라이언트 타임아웃 없음

client := &http.Client{} // Timeout 없음
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := client.Do(req) // 서버가 응답 안 하면 오래 블록 가능

차단 패턴: 클라이언트/요청 레벨 타임아웃

client := &http.Client{Timeout: 3 * time.Second}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := client.Do(req)
if resp != nil {
	defer resp.Body.Close()
}

DB도 동일합니다. 쿼리 컨텍스트를 쓰고, 풀 사이즈와 타임아웃을 함께 설계해야 합니다. 이 관점은 언어는 다르지만 “동시성 증가가 커넥션 고갈을 어떻게 만들고, 어떻게 상한을 두는가”라는 점에서 Spring Boot 3 가상스레드로 DB 커넥션 고갈 막기와도 동일한 문제를 다룹니다.

누수 차단을 위한 5가지 기본 규칙(체크리스트)

1) 모든 고루틴에 종료 조건을 넣기

  • ctx.Done() 또는 quit 채널을 반드시 하나는 둡니다.
  • 무한 루프는 “정상 종료 경로”가 코드로 보이게 작성합니다.

2) 송신/수신 모두 블로킹 가능성을 고려하기

  • 수신이 멈추면 송신이 막힙니다.
  • 송신이 멈추면 생산자 고루틴이 남습니다.
  • 따라서 select에 취소 케이스를 넣거나, 버퍼/드랍 정책을 설계합니다.

3) fan-out은 상한을 두고, 워커 풀로 제어하기

요청당 고루틴 무제한 생성 대신 워커 풀로 동시성 상한을 둡니다.

type Job func(context.Context) error

func StartPool(ctx context.Context, n int, jobs <-chan Job) {
	for i := 0; i < n; i++ {
		go func() {
			for {
				select {
				case <-ctx.Done():
					return
				case job, ok := <-jobs:
					if !ok {
						return
					}
					_ = job(ctx)
				}
			}
		}()
	}
}

4) defer로 정리 동작을 고정하기

  • defer cancel()
  • defer wg.Done()
  • defer ticker.Stop()
  • defer resp.Body.Close()

정리 코드는 “나중에 추가”가 아니라 “처음부터 강제”가 누수 방지에 효과적입니다.

5) 관측 가능성(Observability)을 넣기

  • runtime.NumGoroutine() 메트릭화
  • pprof 상시 노출(내부망)
  • 채널 적체/드랍 카운터
  • 타임아웃/취소 횟수 카운터

이런 지표가 있어야 “누수가 생겼는지”를 장애 전에 잡아낼 수 있습니다.

마무리: 누수는 버그라기보다 설계 부채다

고루틴 누수는 특정 함수 하나의 실수로도 생기지만, 대부분은 종료/취소/상한/백프레셔가 설계에 포함되지 않았을 때 반복적으로 발생합니다. 위 7가지 원인을 코드 리뷰 체크리스트로 만들고, 컨텍스트 전파와 워커 풀 상한, 타임아웃을 기본값으로 두면 누수 확률이 크게 줄어듭니다.

특히 외부 의존성이 불안정한 서비스라면 재시도 정책이 고루틴 누수로 번지지 않게 “취소 가능한 재시도 + 상한 + 서킷브레이커”까지 함께 고려하세요. 이는 OpenAI Responses API 500·503 대응 재시도 폴백 서킷브레이커에서 정리한 운영 패턴을 Go 코드로 옮길 때도 그대로 적용됩니다.