Published on

Go 고루틴 누수 원인과 해법 - 채널 close·컨텍스트 취소

Authors

서버를 운영하다 보면 CPU는 낮은데 RSS 메모리가 서서히 증가하거나, 요청이 끝났는데도 고루틴 수가 줄지 않는 현상을 종종 만납니다. Go의 고루틴은 가볍지만 공짜는 아닙니다. 블로킹된 채널 연산, 취소되지 않은 작업, 종료 신호가 없는 루프가 쌓이면 결국 고루틴 누수로 이어지고, GC 부담과 스케줄링 오버헤드가 누적되어 지연이 커집니다.

이 글은 고루틴 누수의 대표 원인인 채널 close 처리 실수와 context 취소 미전파를 중심으로, 재현 가능한 코드와 함께 “누수가 생기는 모양”과 “없애는 규칙”을 정리합니다.

관련해서 장애를 빨리 줄이는 체크리스트 스타일 글이 도움이 된다면, 트러블슈팅 관점은 Kubernetes ImagePullBackOff·ErrImagePull 해결 체크리스트도 참고할 만합니다.

고루틴 누수란 무엇인가

고루틴 누수는 보통 아래 중 하나로 정의됩니다.

  • 더 이상 결과가 필요 없는데도 고루틴이 종료되지 않고 남아 있음
  • 종료되어야 하는데 블로킹되어 영원히 깨어나지 못함
  • 상위 요청이 끝났는데 하위 작업이 계속 실행됨

특히 HTTP 핸들러나 워커 풀, 스트리밍 처리에서 자주 발생합니다. 요청 단위로 고루틴을 만들고, 그 고루틴이 채널 수신 또는 송신에서 멈춰버리면 요청이 끝나도 고루틴은 계속 남습니다.

누수의 1번 원인: 채널 close 규칙 위반

채널 누수는 대개 “누가 채널을 닫아야 하는가” 규칙이 흐려질 때 발생합니다.

규칙 1: 채널은 sender가 닫는다

채널은 값을 더 이상 보내지 않을 쪽, 즉 생산자 측에서 닫는 것이 정석입니다. 소비자가 닫으면 생산자가 이후에 send 하다가 패닉이 나거나, 닫는 타이밍을 두고 경쟁이 생깁니다.

규칙 2: range chclose 없이는 끝나지 않는다

다음 코드는 가장 흔한 누수 패턴입니다. 소비자는 range로 기다리는데, 생산자가 어떤 경로로든 채널을 닫지 않으면 소비자는 영원히 블로킹됩니다.

package main

import (
	"fmt"
	"time"
)

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

	go func() {
		// 값을 조금 보내고 끝내지만, close가 없다.
		for i := 0; i < 3; i++ {
			ch <- i
		}
		// return
	}()

	go func() {
		// close가 없으니 여기서 영원히 대기할 수 있다.
		for v := range ch {
			fmt.Println("recv", v)
		}
		fmt.Println("done")
	}()

	time.Sleep(2 * time.Second)
	fmt.Println("main exit")
}

위 예시는 main이 종료되어 프로세스가 끝나므로 티가 덜 나지만, 서버에서는 이런 고루틴이 계속 쌓입니다.

해결: 생산자에서 defer close(ch)

go func() {
	defer close(ch)
	for i := 0; i < 3; i++ {
		ch <- i
	}
}()

규칙 3: fan-out 구조에서는 close 책임을 한 곳으로 모아라

여러 생산자가 하나의 채널에 send 하는 구조에서 각 생산자가 close를 호출하면 경쟁 조건이 생깁니다. 이때는 sync.WaitGroup으로 생산자 종료를 모은 뒤, 별도 고루틴 하나가 채널을 닫는 패턴이 안전합니다.

package main

import (
	"sync"
)

func merge(cs ...<-chan int) <-chan int {
	out := make(chan int)
	var wg sync.WaitGroup
	wg.Add(len(cs))

	for _, c := range cs {
		c := c
		go func() {
			defer wg.Done()
			for v := range c {
				out <- v
			}
		}()
	}

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

	return out
}

이 패턴을 쓰면 소비자는 range out로 안전하게 종료할 수 있고, 생산자 종료 타이밍에 따라 누수가 생기는 일을 줄일 수 있습니다.

누수의 2번 원인: 컨텍스트 취소가 아래로 전파되지 않음

Go 서버에서 고루틴 누수의 또 다른 큰 축은 context입니다. 상위 요청이 취소되었는데 하위 고루틴이 이를 모르면, 하위 작업은 계속 실행됩니다.

흔한 실수: context.Background()로 갈아끼우기

핸들러에서 받은 r.Context()를 무시하고 context.Background()를 사용하면, 클라이언트가 연결을 끊거나 타임아웃이 나도 작업이 계속됩니다.

func handler(w http.ResponseWriter, r *http.Request) {
	ctx := context.Background() // 잘못된 패턴
	go doWork(ctx)
	w.WriteHeader(200)
}

해결: 상위 컨텍스트를 그대로 전달

func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	go doWork(ctx)
	w.WriteHeader(200)
}

WithCanceldefer cancel()은 세트로

context.WithCancel 또는 context.WithTimeout을 만들었다면, 함수가 끝날 때 반드시 cancel()을 호출해 타이머/리소스를 정리해야 합니다.

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

	return callDownstream(ctx)
}

defer cancel()은 “취소 전파”뿐 아니라 내부 타이머 해제에도 중요합니다. 고QPS 환경에서 이 차이가 누적되면 메모리와 고루틴 수가 눈에 띄게 흔들립니다.

채널과 컨텍스트가 만나는 지점: select로 종료 조건을 강제하기

고루틴이 채널 수신을 기다리는 동안 컨텍스트가 취소되면 즉시 빠져나오게 해야 합니다. 즉, 블로킹 연산은 select로 감싸고 ctx.Done() 케이스를 항상 둡니다.

누수 패턴: 채널 수신만 기다림

func worker(ctx context.Context, jobs <-chan Job) {
	for {
		job := <-jobs // jobs가 닫히지 않으면 영원히 대기 가능
		process(job)
	}
}

해결 패턴: ctx.Done()과 채널 close를 함께 처리

func worker(ctx context.Context, jobs <-chan Job) {
	for {
		select {
		case <-ctx.Done():
			return
		case job, ok := <-jobs:
			if !ok {
				return
			}
			process(job)
		}
	}
}

이 구조를 표준으로 잡으면,

  • 생산자가 close(jobs)를 호출해도 종료
  • 상위에서 cancel()을 호출해도 종료

두 경로 모두에서 고루틴이 빠져나옵니다.

송신 쪽 누수: 받는 쪽이 사라졌는데 send로 멈춤

수신만큼이나 위험한 것이 송신 블로킹입니다. 특히 “결과 채널”로 값을 보내는 고루틴이, 수신자가 이미 리턴해버린 경우입니다.

누수 패턴: 결과를 보내다 멈춤

func fetch(ctx context.Context, out chan<- Result) {
	res := heavy()
	out <- res // 수신자가 없으면 여기서 영원히 블로킹 가능
}

해결 1: send도 select로 보호

func fetch(ctx context.Context, out chan<- Result) {
	res := heavy()
	select {
	case <-ctx.Done():
		return
	case out <- res:
		return
	}
}

해결 2: 버퍼 채널은 "완화"일 뿐, 해결책이 아니다

버퍼를 키우면 일시적으로 블로킹이 줄어들어 누수가 덜 보일 수 있습니다. 하지만 소비자가 영구적으로 사라지는 구조라면, 버퍼는 결국 다시 차고 send는 다시 막힙니다. 즉 버퍼는 성능 튜닝 도구이지, 종료 시그널을 대체하지 못합니다.

errgroup으로 취소 전파를 자동화하기

여러 고루틴을 함께 실행하고, 하나라도 실패하면 나머지를 멈추고 싶다면 golang.org/x/sync/errgroup이 안전합니다. 핵심은 WithContext로 파생 컨텍스트를 만들고, 어떤 고루틴이 에러를 반환하면 그룹 컨텍스트가 취소된다는 점입니다.

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

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

	g.Go(func() error {
		return producer(ctx)
	})
	g.Go(func() error {
		return consumer(ctx)
	})

	return g.Wait()
}

이 패턴은 “실패했는데도 다른 고루틴이 계속 돈다” 유형의 누수를 줄이는 데 효과적입니다.

종료 신호 설계: done 채널 vs context

  • 라이브러리나 패키지 경계 밖으로 취소를 전달해야 한다면 context가 표준입니다.
  • 내부 파이프라인에서 단순 종료 신호만 필요하다면 done := make(chan struct{})도 쓸 수 있습니다.

다만 둘을 섞을 때는 “단일 소스 오브 트루스”를 정하세요. 예를 들어 상위는 context를 쓰고, 내부에서는 ctx.Done()을 그대로 종료 신호로 사용하면 불필요한 변환이 줄어듭니다.

실전 점검 방법: 고루틴 덤프와 pprof

운영 중 누수가 의심되면 먼저 “어디서 블로킹되었는지”를 봐야 합니다.

  • runtime/pprof의 goroutine 프로파일
  • net/http/pprof를 붙여 /debug/pprof/goroutine 확인

고루틴 스택에서 chan receive 또는 chan send로 오래 멈춰 있는 지점을 찾으면, 보통 이 글에서 다룬 패턴 중 하나로 귀결됩니다.

트러블슈팅을 체계화해 재발을 줄이는 접근은 다른 영역에서도 동일합니다. 예를 들어 재시도와 백오프를 잘못 설계하면 요청이 쌓이며 리소스가 새는 것처럼 보이는데, 이런 관점은 Claude 429 과금폭탄 막는 재시도·백오프 전략도 같이 읽으면 도움이 됩니다.

자주 터지는 케이스별 처방전

1) range ch가 끝나지 않는다

  • 생산자에서 close(ch)가 항상 호출되는지 확인
  • 에러 경로, early return 경로에서 close가 빠지지 않게 defer close(ch) 사용
  • 다중 생산자면 WaitGroup으로 close 책임을 단일화

2) 요청은 끝났는데 작업이 계속 돈다

  • context.Background() 사용 흔적 제거
  • WithTimeout 또는 WithCancel을 만들었으면 defer cancel()
  • 블로킹 지점(채널, I/O, 락)에 ctx.Done()을 엮기

3) 결과 채널 send에서 멈춘다

  • send를 select로 감싸고 ctx.Done() 케이스 추가
  • 소비자가 반드시 drain 해야 하는 구조라면, 소비자 종료 전에 생산자 취소를 먼저 전파

정리: 누수를 막는 최소 규칙 6가지

  1. 채널은 sender가 닫고, 소비자는 range로 종료를 기다린다.
  2. 다중 생산자면 WaitGroup으로 생산자 종료를 모은 뒤 한 곳에서만 close한다.
  3. range ch를 쓰면 close가 누락되지 않도록 defer close(ch)를 우선 고려한다.
  4. 상위 요청 컨텍스트를 버리지 말고 그대로 전달한다. context.Background()로 갈아끼우지 않는다.
  5. WithCancel·WithTimeout을 만들면 항상 defer cancel()로 정리한다.
  6. 채널 send/receive 같은 블로킹 연산은 select로 감싸고 ctx.Done() 또는 close를 종료 조건으로 둔다.

이 규칙만 팀 컨벤션으로 고정해도, 운영에서 겪는 고루틴 누수의 상당수를 예방할 수 있습니다. 특히 “채널 close 책임”과 “컨텍스트 취소 전파”를 설계 단계에서 명확히 하면, 나중에 pprof로 쫓아다니는 시간을 크게 줄일 수 있습니다.