Published on

Go goroutine 누수 잡기 - context·채널 종료 패턴

Authors

서버를 운영하다 보면 메모리 그래프는 완만한데도 요청 지연이 늘고, 배포를 반복할수록 프로세스가 무거워지는 증상을 만납니다. 원인이 goroutine 누수인 경우가 많습니다. Go에서 누수는 흔히 “메모리를 계속 잡고 있음”이 아니라 영원히 깨어나지 못하는 대기 상태(채널 수신 대기, 락 대기, I/O 대기)로 나타납니다.

이 글은 다음을 목표로 합니다.

  • 누수의 대표 패턴을 “왜 멈추는지” 관점에서 이해
  • context 취소 전파를 기본값으로 만들기
  • 채널 종료 규칙을 팀 규약처럼 고정하기
  • fan-out/fan-in, 워커 풀, 파이프라인에서 종료를 설계하기
  • 디버깅(스택 덤프, pprof)으로 재현·확인하기

운영 진단 관점은 K8s CrashLoopBackOff 8가지 원인, 로그로 끝내기 글의 “증상에서 원인으로 좁히기” 접근과도 잘 맞습니다.

goroutine 누수의 본질: “종료 조건이 없는 대기”

goroutine은 GC가 “알아서” 정리해주지 않습니다. goroutine이 종료되려면 함수가 return 해야 하고, return 하려면 블로킹 지점(채널, 락, WaitGroup, 네트워크 I/O 등)에서 빠져나올 수 있어야 합니다.

대표적인 누수 신호는 다음과 같습니다.

  • runtime.NumGoroutine() 값이 시간에 따라 계속 증가
  • 요청이 끝났는데도 백그라운드 goroutine이 계속 살아 있음
  • pprof goroutine dump에서 특정 스택이 다수 반복
  • 채널 수신/송신에서 영구 대기(chan receive, chan send)

핵심은 “어떤 조건에서 빠져나오게 할 것인가”입니다. 그 조건을 Go에서는 보통 context 취소, 채널 close, 타임아웃/데드라인으로 설계합니다.

가장 흔한 누수 패턴 1: 수신만 하는 goroutine + close 없는 채널

다음 코드는 전형적인 누수입니다. 생산자가 더 이상 값을 보내지 않는데도 소비자는 range ch에서 영원히 기다립니다.

package main

import (
	"fmt"
	"time"
)

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

	go func() {
		// 소비자는 close가 오지 않으면 끝나지 않는다.
		for v := range ch {
			fmt.Println(v)
		}
	}()

	// 값을 한 번 보내고 끝. 하지만 close를 하지 않는다.
	ch <- 1
	// close(ch) 누락
}

func main() {
	leak()
	time.Sleep(500 * time.Millisecond)
}

해결 규칙: “송신자가 채널을 닫는다”

  • 채널을 닫을 책임은 송신자(생산자) 에게만 둡니다.
  • 소비자는 range ch 또는 v, ok :=로 종료를 감지합니다.
func noLeak() {
	ch := make(chan int)

	go func() {
		for v := range ch {
			fmt.Println(v)
		}
	}()

	ch <- 1
	close(ch) // 송신자가 닫는다
}

하지만 현실에서는 송신자가 여러 goroutine일 수 있고, “언제 닫아야 하는지”가 애매해집니다. 그때는 context 취소 전파와 WaitGroup으로 “모든 송신자가 끝난 뒤 close”를 만들면 됩니다.

가장 흔한 누수 패턴 2: 채널 송신이 막혀서 종료 못함

goroutine이 ch <- x에서 막히면, 그 goroutine은 종료하지 못합니다. 특히 버퍼가 작은 채널, 소비자가 느린 fan-in에서 자주 발생합니다.

func producer(ch chan int) {
	for i := 0; i < 10; i++ {
		ch <- i // 소비자가 없거나 느리면 영구 블로킹 가능
	}
}

해결 패턴: select로 취소 가능하게 만들기

context를 받아서 송신/수신 블로킹을 select로 감쌉니다.

package main

import (
	"context"
	"time"
)

func producer(ctx context.Context, ch chan<- int) {
	defer close(ch)
	for i := 0; i < 10; i++ {
		select {
		case ch <- i:
			// sent
		case <-ctx.Done():
			return
		}
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
	defer cancel()

	ch := make(chan int)
	go producer(ctx, ch)

	// 소비자가 없더라도 ctx 타임아웃으로 producer가 빠져나온다.
	<-ctx.Done()
}

이 패턴은 “송신이 막힐 수 있는 모든 지점”에 적용할 수 있습니다. 수신도 동일합니다.

context 취소 전파를 ‘기본값’으로 만드는 법

goroutine을 띄울 때마다 스스로에게 질문해야 합니다.

  • 이 goroutine은 누가 멈추게 하나?
  • 멈출 때까지 최대 시간은?
  • 멈출 때 정리해야 할 자원(커넥션, 파일, 타이머)은?

이를 코드 레벨 규칙으로 바꾸면:

  1. 백그라운드 goroutine 함수는 가능하면 func(ctx context.Context, ...) 형태로 만든다.
  2. 루프가 있다면 루프마다 select { case <-ctx.Done(): return }을 둔다.
  3. 채널 송수신은 가능하면 select로 감싼다.
  4. 상위 레벨(HTTP 핸들러, 작업 실행기)이 cancel()을 호출할 수 있게 한다.

예시: HTTP 요청 스코프에서 안전한 백그라운드 작업

요청이 끊겼는데도 작업이 계속 돌면 누수가 됩니다. r.Context()를 그대로 넘기면, 클라이언트 연결 종료/서버 타임아웃에서 취소가 전파됩니다.

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

	resultCh := make(chan string, 1)
	go func() {
		res, err := doWork(ctx)
		if err != nil {
			return
		}
		select {
		case resultCh <- res:
		case <-ctx.Done():
			return
		}
	}()

	select {
	case res := <-resultCh:
		w.Write([]byte(res))
	case <-ctx.Done():
		http.Error(w, "canceled", http.StatusRequestTimeout)
	}
}

포인트는 resultCh를 버퍼 1로 두어 “결과를 보내다 막혀서” goroutine이 누수되지 않게 하는 것입니다. 버퍼가 없다면, 핸들러가 먼저 리턴한 순간 송신이 막힐 수 있습니다.

채널 종료 설계: close는 신호, 값은 데이터

채널을 종료 신호로도 쓰고 데이터도 흘리면 설계가 꼬이기 쉽습니다. 실무에서 안정적인 규칙은 다음입니다.

  • 데이터 채널은 데이터만 흘린다.
  • 종료 신호는 context 또는 별도 done 채널로 한다.
  • 꼭 채널 종료가 필요하면 “누가 닫는지”를 단일 책임으로 만든다.

done 채널 패턴(레거시/간단한 경우)

func worker(done <-chan struct{}, jobs <-chan int) {
	for {
		select {
		case <-done:
			return
		case j, ok := <-jobs:
			if !ok {
				return
			}
			_ = j
		}
	}
}

다만 현대 Go에서는 context가 표준이므로, 라이브러리 경계나 간단한 유틸이 아니면 context를 권장합니다.

fan-out/fan-in에서 누수 없애기: WaitGroup + “단 한 번 close”

여러 worker가 하나의 결과 채널로 보내는 구조에서 흔한 실수는 다음 둘 중 하나입니다.

  • 결과 채널을 아무도 닫지 않아서 소비자가 영원히 기다림
  • 여러 worker가 경쟁적으로 close(results)를 호출해서 패닉

정석은 “worker는 보내기만 하고, 별도 goroutine이 WaitGroup을 기다린 뒤 단 한 번 닫기”입니다.

package main

import (
	"context"
	"sync"
)

type Result struct {
	ID  int
	Val int
	Err error
}

func worker(ctx context.Context, id int, jobs <-chan int, results chan<- Result, wg *sync.WaitGroup) {
	defer wg.Done()
	for {
		select {
		case <-ctx.Done():
			return
		case j, ok := <-jobs:
			if !ok {
				return
			}
			// 작업 수행
			select {
			case results <- Result{ID: id, Val: j * 2}:
			case <-ctx.Done():
				return
			}
		}
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	jobs := make(chan int)
	results := make(chan Result)

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

	// jobs 생산
	go func() {
		defer close(jobs)
		for i := 0; i < 10; i++ {
			jobs <- i
		}
	}()

	// results close 담당: 단 한 곳
	go func() {
		wg.Wait()
		close(results)
	}()

	for r := range results {
		_ = r
		// 필요 시 에러 누적 후 cancel()로 전체 중단 가능
	}
}

여기서 중요한 디테일:

  • jobs는 생산자가 닫음
  • results는 “모든 worker 종료 후” 닫음
  • worker 내부 송신도 ctx.Done()으로 탈출 가능

이렇게 하면 소비자 range results는 확실히 종료합니다.

타이머/티커 누수: time.Ticker는 반드시 Stop()

goroutine 누수처럼 보이지만 실제로는 타이머 리소스가 누적되는 케이스도 많습니다. 특히 time.NewTicker를 만들고 Stop()을 안 하면, 내부 리소스가 계속 남습니다.

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

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

time.After를 루프에서 반복 생성하는 것도 주의가 필요합니다. 루프에서는 Ticker가 더 예측 가능하고, 종료 시 Stop()을 호출할 수 있습니다.

“에러가 나면 전체 중단”을 구조화하기: errgroup

표준 라이브러리는 아니지만 golang.org/x/sync/errgroup은 goroutine 누수 방지에 매우 유용합니다. 여러 goroutine 중 하나라도 실패하면 context를 취소해 나머지를 정리합니다.

package main

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

func main() {
	g, ctx := errgroup.WithContext(context.Background())

	g.Go(func() error {
		select {
		case <-time.After(30 * time.Millisecond):
			return fmt.Errorf("worker failed")
		case <-ctx.Done():
			return ctx.Err()
		}
	})

	g.Go(func() error {
		// 첫 번째 goroutine이 실패하면 ctx가 취소되어 여기서 빠져나온다.
		for {
			select {
			case <-ctx.Done():
				return ctx.Err()
			default:
				// do something
				time.Sleep(10 * time.Millisecond)
			}
		}
	})

	_ = g.Wait()
}

errgroup을 쓰면 “실패 시 cancel 호출”을 실수로 빼먹는 일이 줄어듭니다.

누수 디버깅: goroutine dump와 pprof

1) 운영 중 빠르게 확인: goroutine 수

import "runtime"

func logG() {
	// 주기적으로 찍거나, 메트릭으로 내보내면 추세를 볼 수 있다.
	println(runtime.NumGoroutine())
}

이 값이 배포/트래픽에 따라 계속 우상향하면 의심해야 합니다.

2) 스택 덤프: 어디에서 막혔는지 찾기

net/http/pprof를 붙이면 /debug/pprof/goroutine?debug=2에서 goroutine 스택을 볼 수 있습니다.

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

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

덤프에서 chan receive 또는 chan send가 반복되는 스택을 찾고, 해당 함수에 ctx.Done() 탈출 경로가 있는지 확인합니다.

이런 류의 “증상 기반으로 원인을 좁히는” 운영 디버깅 흐름은 systemd 서비스 무한 재시작 10분 진단 체크리스트 같은 체크리스트 접근과도 통합니다.

실무 체크리스트: 누수 없는 goroutine을 위한 규약

아래 항목을 코드 리뷰 체크리스트로 박아두면 재발이 크게 줄어듭니다.

  1. goroutine을 띄우는 모든 함수는 “종료 트리거”가 코드로 보이는가 (context, done, 채널 close)
  2. 무한 루프에는 selectctx.Done() 탈출이 있는가
  3. 채널 송신/수신은 막힐 수 있는가? 막힌다면 select로 취소 가능하게 했는가
  4. range ch를 쓰는 곳에서 채널이 반드시 닫힌다는 보장이 있는가
  5. WaitGroupAddDone이 항상 짝을 이루는가 (에러/리턴 경로 포함)
  6. TickerStop() 하는가
  7. fan-in/out에서 close는 단 한 곳에서만 호출되는가
  8. 결과 채널 송신이 핸들러 종료로 막히지 않게 버퍼 또는 취소 분기를 갖는가

마무리: “종료를 설계”하면 누수는 줄어든다

goroutine 누수는 특정 API 하나를 외워서 해결되는 문제가 아니라, 동시성 구조를 닫히는 형태로 설계해야 해결됩니다.

  • context는 취소 전파의 표준 도구
  • 채널 close는 “데이터 스트림 종료”를 표현하는 신호
  • fan-out/fan-in은 WaitGroup으로 “모두 끝난 뒤 단 한 번 close”
  • 송수신 블로킹은 select로 취소 가능하게

이 원칙을 지키면, 트래픽이 늘어도 goroutine 수가 안정적으로 수렴하고, 배포를 반복해도 프로세스가 무거워지지 않습니다.