Published on

Go 고루틴 누수 5분 진단 - pprof·채널닫기

Authors

서버가 점점 느려지고 메모리가 꾸준히 늘어날 때, 원인이 “진짜 메모리 누수”가 아니라 고루틴 누수인 경우가 많습니다. 고루틴은 가볍지만, 종료되지 않고 쌓이면 스케줄러 부하, 힙 증가, GC 압박, 커넥션/파일 디스크립터 고갈 같은 2차 장애로 이어집니다.

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

  • 5분 안에 pprof로 “고루틴이 새는지” 확인
  • goroutine dump에서 흔한 누수 패턴을 분류
  • 채널 닫기context 취소로 종료 경로를 보장하는 코드 패턴 정리

진단 접근은 다른 장애 분석과 동일하게 “관측 지표를 먼저 확보하고, 재현 가능한 최소 케이스로 좁히는” 방식이 가장 빠릅니다. (네트워크 계층에서 원인 추적이 필요할 때 AWS VPC Reachability Analyzer로 502 추적하기처럼 관측 도구부터 여는 것과 같은 맥락입니다.)

1) 5분 진단 플로우(요약)

  1. 서비스에 pprof 엔드포인트를 켠다
  2. goroutine 프로파일을 두 번 뜬다(현재/몇 분 후)
  3. goroutine 수가 단조 증가하는지 확인한다
  4. 증가하는 스택 트레이스가 어디서 막히는지(채널 수신/전송, select{}, 락, I/O, time.Ticker)로 분류한다
  5. 종료 신호(context.Done(), close(ch))가 실제로 도달하는지 코드로 보강한다

이 글의 나머지는 위 플로우를 그대로 따라갑니다.

2) pprof 엔드포인트 붙이기(운영 안전 버전)

가장 흔한 실수는 pprof를 0.0.0.0로 열어두는 것입니다. 내부망에서만 접근하거나, 별도 포트로 띄우고, 가능하면 방화벽/인증을 붙이세요.

2.1 기본 설정

package main

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

func main() {
	go func() {
		// 운영에서는 localhost 바인딩 권장
		addr := "127.0.0.1:6060"
		log.Printf("pprof listening on %s", addr)
		_ = http.ListenAndServe(addr, nil)
	}()

	// ... your server
	select {}
}
  • import _ "net/http/pprof"만으로 /debug/pprof/ 경로가 등록됩니다.
  • 이미 메인 서버 mux를 쓰는 경우에는 별도 ServeMux를 만들어 붙이는 편이 충돌이 적습니다.

2.2 쿠버네티스에서 접근 팁

  • Pod 내부에서만 열고 kubectl port-forward로 접근하면 노출 위험이 낮습니다.
  • 예: kubectl port-forward pod/your-pod 6060:6060

3) goroutine 수가 새는지 30초 만에 확인

pprof는 웹 UI도 좋지만, “증가 여부”는 커맨드 한두 번이 가장 빠릅니다.

3.1 goroutine 수 단순 확인

curl -s http://127.0.0.1:6060/debug/pprof/goroutine?debug=1 | head

출력 상단에 다음처럼 보입니다.

  • goroutine profile: total 1234

이 숫자를 1~2분 간격으로 두 번 확인했을 때 트래픽이 비슷한데도 계속 증가하면 누수를 강하게 의심할 수 있습니다.

3.2 스택 트레이스 전체 덤프(분류용)

curl -s http://127.0.0.1:6060/debug/pprof/goroutine?debug=2 > goroutines.txt
  • debug=1은 요약 중심
  • debug=2는 각 고루틴의 스택이 더 자세히 나와 “어디서 막혔는지” 분류에 유리

3.3 pprof로 “어떤 스택이 많이 쌓였는지” 보기

go tool pprof -top http://127.0.0.1:6060/debug/pprof/goroutine

또는 웹으로:

go tool pprof -http=:0 http://127.0.0.1:6060/debug/pprof/goroutine

여기서 중요한 건 CPU가 아니라 goroutine 개수의 분포입니다. 특정 함수(예: (*Client).readLoop)가 상위에 계속 보이면 그 루프가 종료되지 않는다는 뜻입니다.

4) goroutine dump에서 자주 보이는 누수 패턴 6가지

고루틴 누수는 결국 “종료 조건이 영원히 충족되지 않음”입니다. dump에서 보이는 블로킹 지점을 기준으로 빠르게 분류할 수 있습니다.

4.1 채널 수신에서 영원히 대기(chan receive)

스택에 chan receive가 보이고, 상위 프레임이 워커/컨슈머 루프라면 다음을 의심합니다.

  • 생산자가 더 이상 값을 보내지 않는데 채널을 닫지 않음
  • 종료 신호(done)를 select로 받지 않음

4.2 채널 전송에서 영원히 대기(chan send)

  • 버퍼가 꽉 찼는데 소비자가 죽었거나 느림
  • fan-in 구조에서 downstream이 막혀 upstream이 줄줄이 정지

이 경우는 “누수”라기보다 “정체(backpressure) 미설계”인 경우도 많지만, 결과적으로 goroutine이 쌓이면 누수처럼 보입니다.

4.3 select {} 또는 for { select { ... } }에서 종료 분기 없음

  • default가 있어 바쁘게 도는 경우는 CPU도 함께 튑니다.
  • 종료 분기(case <-ctx.Done())가 없다면 거의 100% 언젠가 문제가 됩니다.

4.4 sync.Mutex/RWMutex/WaitGroup에서 대기

  • 락 순서 역전, WaitGroup.Add/Done 불일치
  • 특히 WaitGroup은 “누락된 Done” 하나로 영원히 대기합니다.

4.5 time.Ticker/time.After 오용

  • Ticker는 반드시 Stop해야 합니다.
  • time.After를 루프에서 계속 만들면 타이머 객체가 쌓이며 GC 부담이 커집니다(완전한 고루틴 누수는 아니어도 메모리/타이머 큐 압박).

4.6 네트워크 I/O 블로킹

  • Read/Write가 영원히 반환되지 않으면 고루틴이 붙잡힙니다.
  • 타임아웃 설정(SetDeadline) 또는 컨텍스트 기반 취소가 필요합니다.

5) “채널 닫기”를 안전하게 설계하는 규칙

채널을 닫는 방식은 고루틴 종료를 가장 단순하게 만들지만, 규칙을 어기면 패닉(close of closed channel, send on closed channel)이 즉시 터집니다.

5.1 규칙: 채널은 “보내는 쪽”이 닫는다

  • 수신자가 닫으면, 송신자가 아직 보내는 중일 때 패닉이 납니다.
  • 송신자가 여러 명이면 “누가 닫을지”가 애매해집니다. 이때는 보통 context나 별도의 done 채널을 씁니다.

5.2 컨슈머는 range ch로 종료를 자연스럽게 처리

func consumer(ch <-chan int) {
	for v := range ch {
		_ = v
	}
	// 채널이 닫히면 루프 종료
}

이 패턴의 장점은 “종료 조건을 코드가 자동으로 강제”한다는 점입니다.

5.3 생산자 종료 시 defer close(ch)

func producer(out chan<- int, n int) {
	defer close(out)
	for i := 0; i < n; i++ {
		out <- i
	}
}
  • 생산자가 에러로 중간 종료해도 채널은 닫혀 컨슈머가 빠져나옵니다.

6) context로 고루틴 종료를 “전파”하기

채널 닫기는 파이프라인에는 좋지만, RPC 핸들러/백그라운드 루프/여러 송신자가 있는 구조에서는 context가 더 안전합니다.

6.1 누수 나는 전형적인 코드

func startWorker(jobs <-chan Job) {
	go func() {
		for {
			job := <-jobs // jobs가 더 이상 오지 않으면 영원히 대기
			handle(job)
		}
	}()
}

문제:

  • jobs가 닫혀도 job := <-jobs는 제로값을 반환하며 계속 돌 수 있음(타입에 따라 더 위험)
  • 종료 신호가 없음

6.2 컨텍스트 취소를 포함한 안전 버전

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

핵심은 두 가지입니다.

  • ctx.Done()을 항상 select에 포함
  • 채널 수신은 반드시 ok를 확인

7) fan-out 워커풀에서 누수 방지 체크리스트

워커풀은 고루틴 누수의 “진원지”가 되기 쉽습니다. 아래 3가지를 지키면 대부분 예방됩니다.

  1. 입력 채널은 생산자가 닫는다
  2. 워커는 range jobs 또는 ok 체크로 종료한다
  3. 결과 채널은 “모든 워커 종료 후” 닫는다

7.1 안전한 워커풀 예제

type Job struct{ ID int }
type Result struct{ ID int }

func worker(ctx context.Context, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
	defer wg.Done()
	for {
		select {
		case <-ctx.Done():
			return
		case job, ok := <-jobs:
			if !ok {
				return
			}
			// 작업 처리
			results <- Result{ID: job.ID}
		}
	}
}

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

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

	// 생산자: jobs를 닫는 책임
	go func() {
		defer close(jobs)
		for _, j := range in {
			select {
			case <-ctx.Done():
				return
			case jobs <- j:
			}
		}
	}()

	// results는 워커들이 모두 끝난 뒤 닫는다
	go func() {
		wg.Wait()
		close(results)
	}()

	var out []Result
	for r := range results {
		out = append(out, r)
	}
	return out
}

여기서 results를 워커가 닫지 않는 이유는 “여러 송신자(워커)가 존재하는 채널은 닫는 주체를 한 명으로 고정해야” 하기 때문입니다. 그래서 wg.Wait() 뒤에 단일 고루틴이 닫습니다.

8) pprof로 “고쳤는지” 검증하는 방법

수정 후에는 반드시 같은 방식으로 재측정해야 합니다.

8.1 고루틴 수가 안정화되는지 보기

  • 트래픽을 일정하게 걸어두고(hey, wrk 등)
  • goroutine profile: total N이 일정 범위에서 출렁이는지 확인

“완전히 고정”될 필요는 없지만, 시간에 따라 단조 증가만 멈추면 1차 목표는 달성입니다.

8.2 같은 스택이 상위에서 사라졌는지 확인

go tool pprof -top에서 상위에 있던 함수가 내려가거나 사라지면 개선 신호입니다. 여전히 상위라면 “종료 신호가 실제로 전달되지 않는 경로”가 남아 있는 겁니다.

9) 운영에서 자주 놓치는 디테일

9.1 HTTP 핸들러에서 r.Context()를 버리지 말 것

요청이 끊기면 서버가 컨텍스트를 취소합니다. 그런데 핸들러 내부에서 새로운 context.Background()를 만들어 고루틴에 넘기면, 요청 취소가 전파되지 않아 누수가 됩니다.

func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context() // 이 컨텍스트를 전파
	go func() {
		select {
		case <-ctx.Done():
			return
		case <-time.After(2 * time.Second):
			// do something
		}
	}()
}

9.2 TickerStop이 기본

ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()

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

9.3 “채널 닫기”는 종료 신호이지 브로드캐스트 데이터가 아니다

종료 신호는 보통 close(done)처럼 “닫기 이벤트” 자체를 이용하는 게 안전합니다.

done := make(chan struct{})

go func() {
	<-time.After(3 * time.Second)
	close(done) // 브로드캐스트: 모든 수신자가 깨어남
}()

select {
case <-done:
	// stop
}

이때 done에 값을 send하지 않는 편이 단순합니다(닫기만 하면 되므로).

10) 마무리: 5분 안에 결론 내리는 관측 습관

고루틴 누수는 “코드 리뷰로만” 잡기 어렵고, “관측으로 빠르게 의심하고, 스택으로 분류한 뒤, 종료 경로를 강제하는 설계로 고친다”가 가장 효율적입니다.

  • pprof를 열고 goroutine profile: total N의 단조 증가를 본다
  • dump에서 블로킹 지점(채널/락/I-O/타이머)을 기준으로 원인을 좁힌다
  • range ch, ok 체크, ctx.Done()을 통해 종료를 설계한다

빌드/배포 파이프라인에서도 원인 규명이 늦어지면 비용이 커지듯, 런타임 누수도 “관측 도구를 먼저 여는 습관”이 시간을 절약합니다. CI가 갑자기 느려졌을 때 레이어 캐시부터 확인하는 접근은 Jenkins 빌드가 갑자기 느릴 때 Docker 레이어 캐시 복구와도 닮아 있습니다.