Published on

Go 고루틴 누수 진단 - context·채널 close 7패턴

Authors

서버가 “느려졌다”는 제보를 받고 CPU나 메모리를 먼저 보지만, Go에서는 고루틴 누수가 더 교묘하게 성능을 갉아먹습니다. 고루틴은 가볍지만 공짜는 아니고, 누수는 보통 select 블로킹, 채널 미종료, 컨텍스트 미전파 같은 “종료 신호의 부재”로 발생합니다.

이 글은 고루틴 누수를 진단하는 절차와, 특히 context 와 채널 close 를 둘러싼 실전 7패턴을 코드로 정리합니다. 운영 환경에서 재시작 루프를 타는 경우라면 증상 관찰 관점에서 systemd 서비스가 반복 재시작될 때 원인 추적법도 함께 참고하면 좋습니다.

고루틴 누수의 “정의”를 먼저 맞추기

고루틴 누수는 크게 두 종류가 있습니다.

  • 영구 블로킹: 더 이상 진행할 수 없는 상태로 chan recvchan send 에 걸려 영원히 살아있음
  • 의도치 않은 장기 생존: 요청 단위로 끝나야 할 작업이 타임아웃/취소를 못 받아 오래 살아있음

전자는 대개 채널 프로토콜 문제, 후자는 대개 context 전파/취소 문제입니다.

0단계: 누수 진단 툴체인 (pprof, goroutine dump)

pprof로 고루틴 수와 스택을 본다

HTTP 서버가 있다면 net/http/pprof 를 붙여 고루틴 스택을 확인하는 게 가장 빠릅니다.

package main

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

func main() {
	go func() {
		log.Println(http.ListenAndServe("127.0.0.1:6060", nil))
	}()

	// 실제 서버 로직...
	select {}
}
  • 고루틴 덤프: curl http://127.0.0.1:6060/debug/pprof/goroutine?debug=2
  • pprof: go tool pprof http://127.0.0.1:6060/debug/pprof/goroutine

스택에서 chan receive , select , sync.Cond.Wait 같은 패턴이 반복되면 누수 후보입니다.

운영에서 “재시작만 반복”될 때

고루틴 누수는 단독으로 크래시를 만들기보다, 메모리/FD 고갈로 이어져 재시작 루프를 유발하기도 합니다. K8s라면 CrashLoopBackOff 분석이 필요할 수 있는데, 이때는 Kubernetes CrashLoopBackOff 원인별 로그·Probe·리소스 디버깅처럼 로그와 리소스 한도를 함께 봐야 원인이 좁혀집니다.

패턴 1: context.Background() 를 요청 경로에 박아버림

증상

  • 요청이 끊겨도 DB/외부 API 호출이 계속 진행
  • 고루틴이 “언젠간 끝나겠지” 하며 쌓임

나쁜 예

func handler(w http.ResponseWriter, r *http.Request) {
	ctx := context.Background() // 요청 취소 신호가 사라짐
	go doWork(ctx)
	w.WriteHeader(http.StatusAccepted)
}

고치는 법

  • 요청 스코프는 r.Context() 를 시작점으로 삼고
  • 내부 호출로 ctx 를 끝까지 전달
func handler(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
	defer cancel()

	go doWork(ctx)
	w.WriteHeader(http.StatusAccepted)
}

핵심은 “고루틴이 종료될 수 있는 신호선”을 반드시 갖게 하는 것입니다.

패턴 2: time.After 를 루프에서 남발해 타이머 누수

time.After 는 내부적으로 타이머를 만들고, 채널에서 값을 읽지 못하면 타이머 객체가 GC까지 살아있을 수 있습니다. 특히 select 루프에서 매번 만들면 누수처럼 보이는 메모리 증가가 생깁니다.

나쁜 예

for {
	select {
	case msg := <-in:
		handle(msg)
	case <-time.After(200 * time.Millisecond):
		// tick
	}
}

고치는 법: time.NewTimer 재사용

timer := time.NewTimer(200 * time.Millisecond)
defer timer.Stop()

for {
	timer.Reset(200 * time.Millisecond)
	select {
	case msg := <-in:
		if !timer.Stop() {
			select {
			case <-timer.C:
			default:
			}
		}
		handle(msg)
	case <-timer.C:
		// tick
	}
}

타이머는 “만들고 버리는” 방식보다 “재사용”이 안전합니다.

패턴 3: select 에 취소 케이스가 없어 영구 블로킹

고루틴은 대부분 select 로 대기합니다. 그런데 취소 케이스가 없으면 입력이 오지 않는 순간 영원히 잠듭니다.

나쁜 예

func worker(ctx context.Context, in <-chan Job) {
	for {
		select {
		case job := <-in:
			process(job)
		}
	}
}

고치는 법

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

여기서도 핵심은 “종료 신호를 항상 select에 포함”입니다.

패턴 4: 채널을 닫지 않아 수신자가 끝나지 않음

채널을 닫는 주체는 원칙적으로 송신자(생산자) 입니다. 생산자가 종료되었는데 채널을 닫지 않으면 소비자는 range ch 에서 영원히 대기합니다.

나쁜 예

func fanIn(inputs []<-chan int) <-chan int {
	out := make(chan int)
	for _, ch := range inputs {
		go func(c <-chan int) {
			for v := range c {
				out <- v
			}
		}(ch)
	}
	return out // out을 닫지 않음
}

고치는 법: WaitGroup 으로 마지막에 close(out)

func fanIn(ctx context.Context, inputs []<-chan int) <-chan int {
	out := make(chan int)
	var wg sync.WaitGroup
	wg.Add(len(inputs))

	for _, ch := range inputs {
		go func(c <-chan int) {
			defer wg.Done()
			for {
				select {
				case <-ctx.Done():
					return
				case v, ok := <-c:
					if !ok {
						return
					}
					select {
					case <-ctx.Done():
						return
					case out <- v:
					}
				}
			}
		}(ch)
	}

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

	return out
}
  • close(out) 는 단 한 번, “모든 생산 고루틴이 끝났을 때”만 수행
  • ctx.Done() 으로 강제 종료도 가능하게 설계

패턴 5: 닫힌 채널로 보내서 패닉, 회복 로직이 누수로 번짐

send on closed channel 패닉을 recover 로 덮는 코드를 종종 보는데, 이 경우 “누가 닫는가”에 대한 프로토콜이 무너져 고루틴이 어정쩡하게 살아남거나 재시도 루프를 돌며 누수로 이어질 수 있습니다.

나쁜 예

func producer(out chan<- int, stop <-chan struct{}) {
	defer func() { _ = recover() }() // 문제를 숨김
	for i := 0; ; i++ {
		select {
		case <-stop:
			return
		case out <- i:
		}
	}
}

고치는 법: close 책임을 명확히

  • 생산자가 close(out) 를 소유
  • 소비자는 절대 닫지 않음
  • 종료는 context 로 통일
func producer(ctx context.Context) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for i := 0; ; i++ {
			select {
			case <-ctx.Done():
				return
			case out <- i:
			}
		}
	}()
	return out
}

패턴 6: 버퍼 없는 채널로 “상호 대기” 데드락성 누수

고루틴 누수는 데드락처럼 프로그램 전체가 멈추지 않아도, 일부 고루틴만 서로 기다리며 영원히 살아있는 형태로 나타납니다.

전형적인 상황

  • A는 out <- x 를 기다림
  • B는 A가 보내기 전에 다른 조건을 기다림
  • 둘 다 취소 신호가 없으면 영구 대기

고치는 법: 전송에도 취소를 건다

select {
case <-ctx.Done():
	return
case out <- x:
}

또는 설계적으로

  • 버퍼 채널로 완충을 두거나
  • 전송 전용 고루틴을 두고 큐잉하거나
  • 작업 큐를 errgroup 과 함께 묶어 생명주기를 통제

같은 방식으로 “막히더라도 빠져나갈 구멍”을 만듭니다.

패턴 7: errgroup 를 쓰면서도 컨텍스트 취소를 무시

errgroup.WithContext 는 첫 에러에서 컨텍스트를 취소해 나머지 고루틴을 정리하기 쉽게 해줍니다. 그런데 내부 루프가 ctx.Done() 을 확인하지 않으면 효과가 없습니다.

나쁜 예

func run(ctx context.Context) error {
	g, _ := errgroup.WithContext(ctx)
	g.Go(func() error {
		for msg := range stream() { // ctx 취소와 무관
			_ = msg
		}
		return nil
	})
	return g.Wait()
}

고치는 법: 스트림 소비에 취소를 섞는다

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

	msgs := stream() // 예: 외부에서 오는 채널
	g.Go(func() error {
		for {
			select {
			case <-ctx.Done():
				return ctx.Err()
			case msg, ok := <-msgs:
				if !ok {
					return nil
				}
				_ = msg
			}
		}
	})

	return g.Wait()
}

errgroup 는 “취소를 전파하는 도구”일 뿐, 루프가 그 신호를 수신하도록 작성해야 누수가 사라집니다.

실전 점검 체크리스트

아래 질문에 가 많을수록 누수 가능성이 큽니다.

  1. 요청 처리 코드에서 context.Background() 를 쓰고 있나
  2. 모든 고루틴 루프에 case <-ctx.Done() 이 있나
  3. 채널을 range 로 읽는 곳에서 채널이 닫힌다는 보장이 있나
  4. 채널 close 책임자가 문서/코드로 명확한가
  5. 전송(ch <- x)이 막힐 때 빠져나갈 수 있나
  6. time.After 를 루프에서 매번 만들고 있나
  7. 에러가 발생했을 때 나머지 고루틴이 종료되는 구조인가

스택 덤프로 “어디서 새는지” 빠르게 찾는 요령

pprof 고루틴 덤프에서 자주 보이는 누수 스택 키워드:

  • chan receive 가 특정 함수에서 반복
  • select 한 케이스만 있는 형태
  • net.(*pollDesc).wait 와 함께 컨텍스트 없는 네트워크 대기
  • time.Sleep 로 폴링하는 고루틴이 요청 수만큼 증가

이런 스택이 보이면, 위 7패턴 중 하나로 거의 귀결됩니다.

마무리: “종료 프로토콜”이 설계의 일부다

Go에서 고루틴 누수는 버그라기보다 종료 프로토콜 부재에서 시작합니다. context 는 “취소 신호선”, 채널 close 는 “데이터 스트림의 끝”입니다. 둘을 섞어 쓸 때는 다음 원칙만 지켜도 대부분의 누수를 예방할 수 있습니다.

  • 고루틴을 만들 때는 항상 “어떻게 끝나는가”를 같이 만든다 (ctx.Done())
  • 채널은 생산자가 닫고, 소비자는 닫지 않는다
  • 송신/수신 모두 블로킹 지점에 취소 케이스를 둔다
  • 타이머는 루프에서 재사용하고, 필요 시 drain 한다

운영에서 증상이 재시작 루프나 리소스 고갈로 나타난다면, 서비스 매니저나 오케스트레이터 관점의 디버깅도 병행해야 합니다. 그런 경우 위에서 언급한 systemd 및 K8s 디버깅 글을 함께 보면 “누수의 결과”까지 한 번에 추적할 수 있습니다.