Published on

Go 고루틴 누수 잡기 - channel close·context 실수

Authors

고루틴 누수는 흔히 “메모리가 조금 늘었다”로 시작하지만, 실제 장애는 그보다 먼저 옵니다. 대기 중인 고루틴이 소켓을 붙잡거나(커넥션 풀 고갈), 타이머/티커가 계속 깨어나 CPU를 쓰거나, 채널 send/recv에서 영원히 블록되면서 처리량이 떨어집니다. 특히 HTTP 핸들러나 워커 풀처럼 요청 수에 비례해 고루틴이 생성되는 구조에서는, 누수가 곧 CrashLoopBackOff 같은 운영 장애로 이어지기 쉽습니다(원인별 진단 흐름은 Kubernetes CrashLoopBackOff 원인별 로그·해결 9가지도 함께 참고할 만합니다).

이 글은 Go에서 누수의 80%를 만드는 두 축인 channel close 규칙 위반과 context 취소 전파 실수를, “어떻게 재현되는지”와 “어떻게 고치는지” 중심으로 정리합니다.

고루틴 누수의 전형적 증상과 빠른 확인법

증상

  • 시간이 지날수록 runtime.NumGoroutine() 값이 계속 증가
  • pprof에서 goroutine 프로파일에 chan send, chan receive, select 대기 스택이 누적
  • 요청이 끝났는데도 핸들러 내부 고루틴이 살아 있음(로그가 계속 찍힘)
  • 커넥션 풀/FD 고갈: DB, Redis, HTTP client에서 타임아웃이 늘어남

가장 빠른 확인 코드

운영 코드에 상시 넣기보다는, 재현/스테이징에서 누수 의심 시점에 임시로 넣어 추세를 확인하는 용도로 좋습니다.

package main

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

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

	select {}
}

time.Tick 자체도 내부적으로 티커를 계속 유지하므로, 라이브러리/장기 실행 루프에서는 time.NewTickerStop()를 쓰는 습관이 더 안전합니다(뒤에서 다룹니다).

1) channel close 실수로 생기는 누수

채널 관련 누수는 대부분 “누가 언제 닫는가” 규칙이 불명확해서 생깁니다. 핵심 원칙은 단순합니다.

  • 닫는 주체는 send를 끝내는 생산자(producer)
  • receiver는 채널을 닫지 않는다
  • 여러 producer가 하나의 채널로 send한다면, producer가 직접 close하면 거의 항상 위험

실수 A: receiver가 채널을 닫아 panic 또는 교착으로 이어짐

receiver가 “이제 그만 받고 싶다”는 이유로 채널을 닫으면, 다른 producer가 send하는 순간 panic: send on closed channel이 납니다. 운영에서는 panic을 recover하더라도, 그 과정에서 다른 고루틴이 빠져나오지 못해 누수/정지로 이어질 수 있습니다.

// 잘못된 예: receiver가 close를 호출
func consumer(ch chan int) {
	for v := range ch {
		_ = v
		// "이제 충분" 같은 조건이 생기면...
		close(ch) // 위험: 다른 producer가 send 중이면 panic
		return
	}
}

해결 패턴: receiver는 close 대신 “중단 신호”를 별도 채널이나 context로 전달합니다.

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

여기서 중요한 건 채널 방향입니다. ch <-chan int로 선언하면 consumer가 실수로 send/close를 시도하는 것을 컴파일 타임에 막을 수 있습니다.

실수 B: producer가 끝났는데 close를 안 해서 receiver가 영원히 대기

for v := range ch는 채널이 닫힐 때까지 끝나지 않습니다. producer가 더 이상 값을 보내지 않는데도 close를 하지 않으면 receiver는 영원히 대기합니다.

func producer(ch chan<- int) {
	for i := 0; i < 3; i++ {
		ch <- i
	}
	// close(ch) 를 빼먹으면 consumer는 range에서 영원히 대기
}

func consumer(ch <-chan int) {
	for v := range ch {
		_ = v
	}
}

해결: producer가 “send 종료”를 보장할 수 있는 지점에서 defer close(ch)를 사용합니다.

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

실수 C: fan-in 구조에서 여러 producer가 close 경쟁

여러 producer가 하나의 out 채널로 보낼 때, 각 producer가 out을 닫으면 경쟁 조건으로 panic이 나거나, 누군가 close를 포기하면서 receiver가 range에서 빠져나오지 못합니다.

정석 해결: sync.WaitGroup으로 모든 producer 종료를 기다린 뒤, 단 하나의 고루틴close(out)을 수행합니다.

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

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

	for _, ch := range inputs {
		go forward(ch)
	}

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

	return out
}

여기서도 누수 방지 포인트는 두 가지입니다.

  • 입력 채널이 멈췄을 때 ok 체크로 빠져나오기
  • out <- v가 막힐 수 있으니, send에도 ctx.Done()을 걸어 탈출 경로를 마련하기

2) context 실수로 생기는 누수

context는 “취소 신호의 전파” 도구입니다. 그런데 실수 패턴은 대부분 다음 중 하나입니다.

  • context.Background()를 무심코 사용해 요청 취소가 전파되지 않음
  • WithCancel/WithTimeout을 만들고 cancel()을 호출하지 않음
  • select에서 ctx.Done()을 빼먹어 블로킹 호출에서 영원히 대기

실수 D: 요청 스코프에서 Background를 써서 고루틴이 요청 밖으로 새어 나감

HTTP 핸들러에서 흔한 실수입니다.

func handler(w http.ResponseWriter, r *http.Request) {
	go func() {
		// 잘못된 예: 요청이 끊겨도 이 고루틴은 계속 돈다
		ctx := context.Background()
		_ = ctx
		doWorkForever()
	}()

	w.WriteHeader(http.StatusAccepted)
}

해결: 반드시 r.Context()를 기반으로 파생시키고, 내부 루프에서 ctx.Done()을 관찰합니다.

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

	go func() {
		for {
			select {
			case <-ctx.Done():
				return
			default:
				doOneStep()
			}
		}
	}()

	w.WriteHeader(http.StatusAccepted)
}

default를 쓰면 바쁜 루프가 될 수 있으니, 실제로는 time.Ticker나 작업 채널 기반으로 블로킹하면서 select로 취소를 함께 받는 형태가 더 좋습니다.

실수 E: WithTimeout을 만들고 cancel을 호출하지 않아 타이머 리소스가 누적

context.WithTimeout은 내부적으로 타이머를 잡습니다. 타임아웃 전에 작업이 끝났다면 cancel()로 타이머를 해제해야 합니다.

func fetch(ctx context.Context) error {
	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel() // 매우 중요

	req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.com", nil)
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	return nil
}

defer cancel()은 “취소를 발생시키기 위해서”가 아니라 “리소스를 회수하기 위해서”라는 점이 핵심입니다.

실수 F: send/recv 한쪽에만 ctx를 걸어 반쪽짜리 취소가 됨

아래 코드는 in에서 받는 쪽은 취소를 보지만, out으로 보내는 send가 막히면 영원히 대기할 수 있습니다.

func transform(ctx context.Context, in <-chan int, out chan<- int) {
	for {
		select {
		case <-ctx.Done():
			return
		case v, ok := <-in:
			if !ok {
				return
			}
			out <- (v * 2) // 여기서 블록되면 ctx 취소돼도 못 나감
		}
	}
}

해결: send에도 select를 한 번 더 둡니다.

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

이 패턴은 파이프라인/워커에서 누수를 막는 가장 실전적인 형태입니다.

3) 채널/컨텍스트 누수를 줄이는 설계 체크리스트

채널 설계

  • 채널은 가능하면 단방향 타입으로 노출: chan<- T, <-chan T
  • range ch를 쓰면 close 책임자가 누구인지 반드시 문서화
  • fan-in/out에서 close는 딱 한 곳에서만
  • 버퍼 채널은 “성능 최적화”가 아니라 “역압 완화” 도구로 쓰고, 버퍼가 있다고 누수가 사라진다고 믿지 않기

context 설계

  • 요청/작업의 루트는 context.Background()가 아니라, 가능한 한 상위 ctx를 인자로 받는 함수 시그니처로 시작
  • WithCancel, WithTimeout, WithDeadline을 만들면 즉시 defer cancel()
  • 블로킹 가능 지점(채널 send/recv, 네트워크 I/O, 락 대기)에 ctx.Done() 탈출구를 마련

4) pprof로 “어디서 새는지” 찾는 실전 루틴

누수는 코드 리뷰만으로 잡기 어렵고, 결국 “대기 스택”을 봐야 빨리 끝납니다. Go에서는 pprof가 가장 확실합니다.

net/http/pprof 활성화

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

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

	// ... your server
}

이후 로컬/스테이징에서 다음을 확인합니다.

  • goroutine 덤프: http://127.0.0.1:6060/debug/pprof/goroutine?debug=2
  • 누적 비교: 일정 시간 간격으로 덤프를 떠서 같은 스택이 계속 쌓이는지 확인

pprof에서 chan receiveselect로 대기 중인 스택이 특정 함수에 몰리면, 그 함수가 close/취소 전파를 제대로 하지 않는 지점일 확률이 큽니다. 비동기 런타임이 “멈춘 것처럼 보일 때 블로킹을 추적한다”는 관점은 Rust Tokio에서도 유사합니다. 진단 사고방식은 Rust Tokio runtime 멈춤? 블로킹 I/O 진단법과도 맞닿아 있습니다.

5) 자주 놓치는 주변 누수: ticker, timer, errgroup

time.Tick 대신 time.NewTicker + Stop

time.Tick은 채널만 반환하고 stop할 방법이 없습니다. 짧게 쓰고 버리는 코드가 아니라면 다음처럼 씁니다.

ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()

for {
	select {
	case <-ctx.Done():
		return
	case <-ticker.C:
		doPeriodic()
	}
}

errgroup.WithContext로 취소 전파를 표준화

여러 고루틴을 묶어 “하나 실패하면 전체 취소”를 만들 때 직접 WaitGroupcancel을 엮다 보면 실수가 나기 쉽습니다.

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

	g.Go(func() error {
		return workerA(ctx)
	})
	g.Go(func() error {
		return workerB(ctx)
	})

	return g.Wait()
}

errgroup을 쓰면 취소 전파가 강제되고, 각 worker는 ctx.Done()만 잘 보면 됩니다.

결론: 누수는 “close와 cancel의 소유권” 문제다

Go 고루틴 누수의 본질은 대개 복잡한 메모리 문제가 아니라, 종료 신호의 소유권과 전파 경로가 불명확한 설계입니다.

  • 채널은 “누가 닫는가”를 코드로 강제(단방향 채널, 단일 closer)
  • 컨텍스트는 “누가 취소하는가”와 “어디까지 전파되는가”를 코드로 강제(defer cancel(), send/recv 양쪽 select)
  • 누수 의심 시에는 pprof로 대기 스택을 보고, 가장 많이 쌓이는 chan send/receive 지점을 먼저 고친다

이 원칙만 지켜도, 운영에서 고루틴 수가 서서히 증가하다가 결국 리소스 고갈로 터지는 유형의 장애는 대부분 예방할 수 있습니다.