Published on

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

Authors

서버를 며칠만 돌려도 메모리나 고루틴 수가 꾸준히 증가하는데, 트래픽이 줄어도 다시 내려오지 않는 경우가 있습니다. 이런 현상의 대표 원인이 고루틴 누수(goroutine leak) 입니다. 고루틴 자체는 가볍지만, 누적되면 스케줄링 오버헤드와 힙 점유(버퍼, 클로저 캡처, 타이머 등)로 결국 장애로 이어집니다.

이 글에서는 누수의 전형적인 형태를 짚고, 실무에서 가장 효과적인 두 축인 context 취소 전파채널 close/종료 신호 패턴을 결합해 “끝이 보장되는” 동시성 구조를 만드는 방법을 다룹니다.

관련해서 요청 단위 타임아웃/취소가 왜 중요한지 더 넓은 관점은 Go gRPC context deadline exceeded 원인 7가지도 함께 참고하면 좋습니다.

고루틴 누수란 무엇인가

고루틴 누수는 단순히 “고루틴이 오래 돈다”가 아니라, 더 이상 결과를 소비하지 않거나 필요가 없는데도 종료되지 못하고 블로킹된 채 남는 상태를 말합니다.

대표적인 누수 형태는 다음과 같습니다.

  • 채널 송신이 영원히 블로킹됨: 수신자가 없어졌는데 송신을 계속 기다림
  • 채널 수신이 영원히 블로킹됨: 생산자가 종료됐는데 수신자는 계속 대기
  • select 에서 취소 케이스가 없음: 타임아웃/취소가 와도 탈출 불가
  • time.Tick 남발: 내부 타이머가 해제되지 않아 장기적으로 리소스 누수
  • 워커 풀에서 작업 채널을 close 하지 않음: 워커가 영원히 대기

핵심은 **“종료 조건이 코드로 표현되어 있지 않다”**는 점입니다.

증상과 빠른 진단 방법

런타임 지표로 의심하기

  • runtime.NumGoroutine() 값이 트래픽과 무관하게 계속 증가
  • GC 이후에도 힙이 일정 수준 아래로 내려오지 않음
  • pprof에서 특정 함수가 chan send/chan receive 상태로 많이 쌓임

pprof로 누수 스택 찾기

운영/스테이징에서 pprof를 켜고 goroutine dump를 확인합니다.

import _ "net/http/pprof"

func main() {
	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 -http=:0 http://127.0.0.1:6060/debug/pprof/goroutine

스택에 chan send 또는 chan receive로 멈춰 있는 지점이 누수 후보입니다.

누수를 만드는 나쁜 패턴 3가지

1) 수신자 없는 채널로 송신

func leak() {
	ch := make(chan int)
	go func() {
		ch <- 1 // 수신자가 없으면 영원히 블로킹
	}()
	return
}

고루틴은 ch <- 1에서 영원히 멈춥니다.

2) 취소 전파 없는 무한 루프

func worker(ch <-chan int) {
	for {
		x := <-ch // 생산자가 종료되면 여기서 영원히 대기 가능
		_ = x
	}
}

채널이 close 되지 않으면 워커는 종료할 방법이 없습니다.

3) time.Tick 사용

time.Tick은 내부적으로 해제되지 않는 티커를 만들 수 있어 장기 실행 서비스에서 문제가 됩니다. 대신 time.NewTickerStop()을 사용하세요.

ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
	select {
	case <-ticker.C:
		// ...
	}
}

원칙 1: “누가 종료를 책임지는가”를 먼저 정하라

고루틴을 만들 때는 다음 질문에 답이 있어야 합니다.

  • 이 고루틴은 언제 종료해야 하는가
  • 종료 신호는 무엇인가: context.Done() 인가, 채널 close 인가, 별도 stopCh 인가
  • 종료 시 대기(Join) 가 필요한가: sync.WaitGroup 또는 errgroup

실무에서는 대부분 다음 규칙이 안전합니다.

  • 요청 스코프 작업: context가 종료를 책임진다
  • 파이프라인/브로드캐스트: 생산자가 채널 close로 종료를 알린다
  • 여러 고루틴을 한 덩어리로 관리: errgroup.WithContext로 묶는다

원칙 2: context는 “취소 신호”, 채널 close는 “생산 종료”

둘은 비슷해 보이지만 의미가 다릅니다.

  • context중단 요청입니다. “이제 그만해”에 가깝습니다.
  • 채널 close더 이상 값이 오지 않음을 보장합니다. “생산이 끝났어”입니다.

따라서 파이프라인에서는 보통 다음 조합이 가장 깔끔합니다.

  • 생산자: ctx를 보고 중단할 수 있어야 함
  • 생산자: 정상 종료 시 출력 채널을 close
  • 소비자: 채널 range로 종료를 자연스럽게 감지

패턴 1: select에 반드시 ctx.Done()을 포함하기

채널 송수신이 블로킹 가능한 지점에는 취소 케이스를 넣습니다.

func sendWithCancel(ctx context.Context, out chan<- int, v int) error {
	select {
	case out <- v:
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

func recvWithCancel(ctx context.Context, in <-chan int) (int, error) {
	select {
	case v, ok := <-in:
		if !ok {
			return 0, io.EOF
		}
		return v, nil
	case <-ctx.Done():
		return 0, ctx.Err()
	}
}

이 한 가지 습관만으로도 “송신/수신 대기 누수”의 상당수를 제거합니다.

패턴 2: 생산자가 close(out)를 책임지는 파이프라인

아래 예시는 gen이 값을 만들고, square가 변환하는 2단 파이프라인입니다.

func gen(ctx context.Context, nums ...int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for _, n := range nums {
			select {
			case out <- n:
			case <-ctx.Done():
				return
			}
		}
	}()
	return out
}

func square(ctx context.Context, in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for {
			select {
			case v, ok := <-in:
				if !ok {
					return
				}
				select {
				case out <- v * v:
				case <-ctx.Done():
					return
				}
			case <-ctx.Done():
				return
			}
		}
	}()
	return out
}

포인트는 다음입니다.

  • 각 단계는 자기 출력 채널을 반드시 close
  • 입력 채널이 닫히면 다음 단계도 자연 종료
  • 모든 블로킹 지점에 ctx.Done()을 둬서 조기 중단 가능

이 구조는 “정상 완료”와 “취소” 모두에서 종료가 보장됩니다.

패턴 3: errgroup.WithContext로 고루틴 생명주기 묶기

여러 고루틴이 함께 움직여야 할 때는 errgroup이 강력합니다. 하나가 실패하면 컨텍스트가 취소되고, 나머지도 빠르게 정리되게 만들 수 있습니다.

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

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

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

	g.Go(func() error {
		// 작업 B
		select {
		case <-time.After(200 * time.Millisecond):
			return errors.New("boom")
		case <-ctx.Done():
			return ctx.Err()
		}
	})

	return g.Wait()
}

g.Wait()는 join 역할을 하므로, 호출자 입장에서 “정리 완료” 시점을 명확히 가질 수 있습니다.

패턴 4: 워커 풀에서 close(jobs)와 결과 채널 종료 규약

워커 풀은 누수가 가장 자주 발생하는 영역입니다. 규약을 명확히 하세요.

  • 작업 생산자는 jobsclose
  • 워커는 range jobs로 자연 종료
  • 결과 채널은 워커들이 모두 끝난 뒤 한 곳에서 close(results)
func worker(ctx context.Context, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
	defer wg.Done()
	for {
		select {
		case j, ok := <-jobs:
			if !ok {
				return
			}
			// 실제 처리
			select {
			case results <- j * 2:
			case <-ctx.Done():
				return
			}
		case <-ctx.Done():
			return
		}
	}
}

func runPool(ctx context.Context, items []int, n int) ([]int, error) {
	jobs := make(chan int)
	results := make(chan int)

	var wg sync.WaitGroup
	wg.Add(n)
	for i := 0; i < n; i++ {
		go worker(ctx, jobs, results, &wg)
	}

	go func() {
		defer close(jobs)
		for _, it := range items {
			select {
			case jobs <- it:
			case <-ctx.Done():
				return
			}
		}
	}()

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

	var out []int
	for {
		select {
		case v, ok := <-results:
			if !ok {
				return out, ctx.Err()
			}
			out = append(out, v)
		case <-ctx.Done():
			return out, ctx.Err()
		}
	}
}

주의할 점:

  • results를 워커가 닫으면 경쟁 조건이 생깁니다. 닫는 책임은 단 한 곳이어야 합니다.
  • ctx.Done()으로 중단되면 생산자/워커/수집자 모두 빠져나올 수 있어야 합니다.

패턴 5: “드레인(drain)”이 필요한 경우를 구분하기

취소 시 즉시 반환하면, 백그라운드 고루틴이 results <- ... 같은 송신에서 막힐 수 있습니다. 이때 선택지는 두 가지입니다.

  1. 결과 채널에 버퍼를 두고, 취소 시에도 일정량을 흡수 가능하게 만들기
  2. 취소 직후에도 잠깐 드레인해서 송신자를 풀어주기

간단한 드레인 예시는 다음과 같습니다.

func drain[T any](ch <-chan T) {
	for {
		select {
		case <-ch:
			// 버림
		default:
			return
		}
	}
}

제네릭 표기 T any 같은 코드는 반드시 백틱으로 감싸지 않으면 안 되지만, 여기서는 코드 블록이므로 안전합니다.

드레인은 만능이 아닙니다. “취소 후에도 일부 고루틴이 안전하게 빠져나오도록” 설계가 안 되어 있을 때 임시방편이 되기 쉽습니다. 가능하면 앞선 패턴처럼 송신 자체가 ctx.Done()을 보게 만드는 편이 더 견고합니다.

흔한 실수: 채널을 닫아야 할 때와 닫지 말아야 할 때

  • 닫아야 함: “더 이상 이 채널로 값이 오지 않는다”를 소비자에게 알려야 할 때
  • 닫지 말아야 함: 단순 신호용 done 채널을 여러 곳에서 닫을 위험이 있을 때(중복 close 패닉)

신호용이라면 context가 대체재가 됩니다. doneCh를 직접 만들기보다 ctx를 전달하는 쪽이 안전합니다.

실전 체크리스트

아래 항목 중 하나라도 “아니다”가 나오면 누수 가능성이 큽니다.

  • 모든 고루틴은 종료 조건이 명확한가
  • 블로킹 가능한 채널 송수신에 ctx.Done() 케이스가 있는가
  • 생산자는 출력 채널을 close 하는가
  • 채널을 닫는 주체가 단 하나로 고정되어 있는가
  • 워커 풀에서 jobs는 반드시 close 되는가
  • time.NewTicker를 쓰고 Stop()을 호출하는가
  • 에러가 나면 관련 고루틴이 함께 정리되는가(필요 시 errgroup)

마무리

Go에서 고루틴 누수는 대개 “작은 부주의”가 아니라 종료 프로토콜이 없는 동시성 설계에서 발생합니다. context는 취소를 전파하고, 채널 close는 생산 종료를 알립니다. 이 둘의 역할을 구분해 설계하면, 고루틴은 자연스럽게 끝나고 서비스는 오래 실행되어도 안정적으로 유지됩니다.

네트워크 호출과 함께 동시성을 구성할 때는 타임아웃/취소가 특히 중요합니다. 요청 체인에서 context가 어디서 끊기는지 점검하는 관점으로는 Go gRPC context deadline exceeded 원인 7가지도 같이 읽어보면 누수 방지에 도움이 됩니다.