Published on

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

Authors

Go에서 고루틴은 가볍지만, 종료 조건을 잃는 순간 누수(leak) 가 됩니다. 누수된 고루틴은 CPU를 계속 쓰지 않더라도 메모리, 타이머, 네트워크 소켓, 채널 버퍼, 락 대기열 등 리소스를 점유하고, 결국 지연과 장애로 이어집니다.

이 글은 "왜 고루틴이 끝나지 않는가"를 7가지 전형 패턴으로 분해하고, channel closecontext 취소를 중심으로 누수를 차단하는 방법을 코드로 설명합니다.

또한 분산 환경에서 흔히 같이 터지는 타임아웃 이슈는 Go gRPC 데드라인 초과 원인 7가지와 해결도 함께 참고하면 원인 분리가 빨라집니다.

고루틴 누수의 정의: 종료 경로가 없는 실행

고루틴 누수는 보통 아래 중 하나입니다.

  • 고루틴이 select 또는 채널 수신에서 영원히 블록
  • 타이머/티커가 Stop되지 않아 내부 런타임 타이머 큐에 남음
  • WaitGroup영원히 Done되지 않아 상위 요청이 대기
  • 네트워크 호출이 취소되지 않아 커넥션/스레드풀을 묶음

핵심은 간단합니다.

  • 고루틴은 반드시 종료 신호를 가져야 함
  • 종료 신호는 보통 context.Done() 또는 done chan struct{}
  • 채널은 누가 닫는지(소유권) 가 명확해야 함

원인 1) 채널 수신이 영원히 대기: close가 안 됨

가장 흔한 누수는 "생산자가 사라졌는데 소비자는 계속 기다리는" 형태입니다.

재현 코드

package main

import (
	"fmt"
	"time"
)

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

	go func() {
		for v := range ch { // close가 안 되면 영원히 대기
			fmt.Println(v)
		}
	}()

	// 생산자가 아무것도 보내지 않고 종료
	time.Sleep(2 * time.Second)
}

해결 원칙

  • range ch를 쓰는 소비자는 채널이 닫히는 것을 종료 조건으로 삼습니다.
  • 그러므로 생산자(또는 생산자를 관리하는 상위)가 반드시 close(ch) 해야 합니다.

올바른 패턴: 생산자 소유권 기반 close

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

원인 2) 채널 송신이 영원히 대기: 수신자가 사라짐

송신(ch <- v)은 수신자가 없고 버퍼도 꽉 차면 블록합니다. 특히 요청 스코프에서 파생된 고루틴이 "결과를 보고하는 채널"에 쓰다가, 상위 요청이 타임아웃으로 사라지면 누수가 됩니다.

재현 코드

func leakSend() {
	ch := make(chan int) // unbuffered
	go func() {
		ch <- 1 // 수신자가 없으면 영원히 블록
	}()
}

해결 1: context로 송신 탈출 경로 만들기

func sendWithCancel(ctx context.Context, ch chan<- int, v int) bool {
	select {
	case ch <- v:
		return true
	case <-ctx.Done():
		return false
	}
}

해결 2: 버퍼는 "완화"일 뿐, 근본 해결이 아님

버퍼(make(chan T, N))는 순간 폭주를 완화하지만, 수신자가 영구히 사라지면 결국 꽉 차고 같은 문제가 재발합니다. 종료 신호가 우선입니다.

원인 3) context를 안 쓰거나, 취소를 전달하지 않음

고루틴은 외부 호출(HTTP, gRPC, DB)을 수행할 때가 많습니다. 이때 상위 요청이 끝났는데도 하위 고루틴이 계속 수행되면 누수입니다.

흔한 실수

  • context.Background()를 하위로 내려보냄
  • WithTimeout을 만들고도 cancel()을 호출하지 않음

재현 코드: cancel을 호출하지 않는 타이머 누수

func bad() {
	ctx, _ := context.WithTimeout(context.Background(), 3*time.Second)
	_ = ctx
	// cancel을 호출하지 않으면 타이머가 만료될 때까지 리소스가 남을 수 있음
}

해결: defer cancel()은 기본값

func good(parent context.Context) error {
	ctx, cancel := context.WithTimeout(parent, 3*time.Second)
	defer cancel()

	// ctx를 모든 I/O 호출에 전달
	return doWork(ctx)
}

추가로, gRPC에서는 데드라인/취소가 서버까지 어떻게 전파되는지에 따라 증상이 달라집니다. 데드라인 쪽 원인 분리는 위에서 링크한 글을 같이 보면 좋습니다.

원인 4) time.Ticker / time.After를 잘못 써서 타이머가 남음

타이머는 런타임 내부 구조에 등록됩니다. 고루틴이 종료되지 않거나, 티커를 멈추지 않으면 누수로 이어질 수 있습니다.

흔한 누수: Ticker Stop 누락

func pollForever(ctx context.Context) {
	t := time.NewTicker(500 * time.Millisecond)
	// defer t.Stop()가 없으면 누수 가능
	for {
		select {
		case <-t.C:
			// polling
		case <-ctx.Done():
			return
		}
	}
}

해결

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

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

time.After 반복 사용 주의

루프에서 time.After(d)를 계속 만들면 타이머 객체가 계속 생깁니다. 가능하면 NewTicker 또는 time.NewTimer 재사용을 고려하세요.

원인 5) WaitGroup 불균형: AddDone 경로가 다름

WaitGroup은 "고루틴이 끝나길 기다리는" 장치인데, Done()이 호출되지 않으면 상위가 영원히 대기합니다. 이때 상위 요청 고루틴도 함께 묶이면서 연쇄 누수가 됩니다.

재현 코드: 에러 경로에서 Done 누락

var wg sync.WaitGroup

wg.Add(1)
go func() {
	// panic 또는 early return이 있으면 Done이 호출되지 않을 수 있음
	if somethingBad() {
		return
	}
	wg.Done()
}()

wg.Wait()

해결: 고루틴 시작 직후 defer wg.Done()

wg.Add(1)
go func() {
	defer wg.Done()
	if err := work(); err != nil {
		return
	}
}()

추가로 wg.Add는 고루틴 시작 전에 호출하는 것이 안전합니다. 실행 중인 고루틴에서 Add를 호출하면 경쟁 조건으로 패닉이 날 수 있습니다.

원인 6) fan-in / fan-out에서 "결과 채널" close 책임이 불명확

여러 worker가 하나의 결과 채널로 보내는 fan-in 구조에서, 누수는 두 방향으로 발생합니다.

  • 결과 채널을 아무도 닫지 않아 소비자가 range에서 블록
  • 소비자가 먼저 종료되어 worker가 send에서 블록

안전한 fan-in 템플릿

  • worker는 ctx.Done()을 보고 빠져나올 수 있어야 함
  • 결과 채널은 "모든 worker 종료"를 관측한 단 하나의 고루틴이 닫음
func fanIn(ctx context.Context, inputs []int) <-chan int {
	out := make(chan int)
	var wg sync.WaitGroup

	worker := func(v int) {
		defer wg.Done()
		select {
		case out <- v * 2:
		case <-ctx.Done():
			return
		}
	}

	wg.Add(len(inputs))
	for _, v := range inputs {
		go worker(v)
	}

	go func() {
		wg.Wait()
		close(out) // close는 여기서 단 한 번
	}()

	return out
}

이 패턴에서 핵심은 close(out)가 worker 내부가 아니라 집계자(closer) 고루틴에 있다는 점입니다. 여러 sender가 같은 채널을 닫으면 패닉이 납니다.

원인 7) 락/조건변수/세마포어 대기에서 탈출이 없음

고루틴이 MutexCond, 또는 토큰 채널(세마포어)에서 대기하다가 영원히 깨어나지 못하면 누수입니다. 특히 토큰 채널로 동시성 제한을 걸 때 defer로 반납하지 않으면 쉽게 발생합니다.

재현 코드: 토큰 반납 누락

var sem = make(chan struct{}, 10)

func handle(ctx context.Context) {
	sem <- struct{}{} // 획득
	// 반납을 빼먹으면 다른 고루틴이 영원히 대기

	// 작업...
}

해결: 획득 직후 defer로 반납

func handle(ctx context.Context) {
	select {
	case sem <- struct{}{}:
		defer func() { <-sem }()
	case <-ctx.Done():
		return
	}

	// 작업...
}

누수 방지 체크리스트: 설계 단계에서 강제하기

아래 규칙을 팀 컨벤션으로 두면 누수 확률이 급격히 줄어듭니다.

  1. 고루틴을 만들면 종료 신호도 함께 만든다: ctx 또는 done chan struct{}
  2. select에는 가능하면 case <-ctx.Done()을 포함한다
  3. WithCancel / WithTimeout / WithDeadline를 만들면 defer cancel()
  4. range ch를 쓰면 누가 close(ch)하는지 소유권을 문서화한다
  5. WaitGroup은 고루틴 시작 직후 defer wg.Done()
  6. Tickerdefer t.Stop()
  7. 동시성 제한 토큰은 획득 직후 defer로 반납

관측과 재현: "고루틴 수"는 가장 싼 조기 경보

운영에서 누수는 보통 "느려짐"으로 먼저 나타납니다. 아래처럼 고루틴 수를 지표로 잡아두면 조기 탐지가 쉽습니다.

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

func logGoroutines() {
	for range time.Tick(10 * time.Second) {
		log.Printf("goroutines=%d", runtime.NumGoroutine())
	}
}
  • 배포 후 특정 트래픽 패턴에서 runtime.NumGoroutine()계단식으로 상승하면 거의 누수입니다.
  • pprof를 붙여서 goroutine 덤프를 보면 어디서 블록되는지 바로 보입니다.

마무리: channel close와 context 취소는 "종료 계약"이다

고루틴 누수는 대부분 문법 실수가 아니라 종료 계약의 부재에서 시작합니다.

  • 채널은 데이터 파이프이면서 동시에 종료 신호입니다. close의 소유권을 명확히 하세요.
  • context는 요청 스코프의 생명주기를 표현합니다. 하위 고루틴과 I/O에 반드시 전파하세요.

위 7가지를 코드 리뷰 체크리스트로 고정해두면, "가끔 메모리가 오른다" 같은 애매한 장애가 눈에 띄게 줄어듭니다.