Published on

Go 고루틴 누수 잡기 - context·select로 종료

Authors

서버를 오래 돌리다 보면 메모리는 천천히 늘고, CPU는 간헐적으로 치솟고, 종료 시에는 어딘가에서 영원히 대기하는 고루틴 때문에 graceful shutdown 이 깔끔히 끝나지 않는 경우가 있습니다. 이런 증상은 대부분 고루틴 누수(goroutine leak) 로 이어집니다.

고루틴 누수는 “고루틴을 만들었는데, 종료 조건이 없거나 도달하지 못해 계속 살아있는 상태”를 의미합니다. Go 런타임은 고루틴을 자동으로 회수하지 않습니다. 즉, 개발자가 종료 경로를 설계해야 합니다.

이 글에서는 context 취소 전파와 select 를 이용해 고루틴을 안전하게 종료하는 패턴을 중심으로, 누수가 생기는 대표 원인과 점검 체크리스트까지 다룹니다. 운영 관점에서 “조용히 쌓이다가 장애로 터지는” 문제라는 점에서, 로그/리소스 폭주를 빠르게 다루는 습관도 중요합니다. 관련해서는 journalctl 로그 폭주로 디스크 찰 때 10분 해결 같은 글도 함께 참고하면 좋습니다.

고루틴 누수의 전형적인 패턴

1) 채널 send/recv가 영원히 블로킹

고루틴이 ch <- x 를 했는데 받는 쪽이 사라졌거나, 반대로 <-ch 로 기다리는데 보내는 쪽이 더 이상 값을 보내지 않으면 고루틴은 끝나지 않습니다.

2) for {} 루프에 종료 조건이 없음

특히 “폴링 루프”나 “워커 루프”에서 return 경로가 없으면 누수로 이어집니다.

3) 타임아웃/취소 없는 I/O 대기

네트워크 호출이 영원히 대기하거나, 내부 큐가 막혀서 진행이 안 되는 경우입니다. context 가 연결되지 않은 라이브러리 호출이 원인이 되기도 합니다.

4) time.Tick 사용

time.Tick 은 내부적으로 고루틴/타이머를 생성하며, 명시적으로 Stop 할 수 없습니다. 반복 작업은 time.NewTicker 로 만들고 defer ticker.Stop() 을 해야 합니다.

핵심 원칙: “고루틴은 반드시 끝나야 한다”를 코드로 강제하기

고루틴을 만들 때마다 다음 질문을 코드 리뷰 기준으로 삼는 것이 좋습니다.

  • 이 고루틴은 어떤 조건에서 종료되는가?
  • 그 조건이 외부에서 제어 가능한가? (취소, 타임아웃, 종료 신호)
  • 블로킹 가능 지점(send/recv/lock/I-O)에 탈출구(ctx.Done() 등)가 있는가?

정답은 대부분 contextselect 로 귀결됩니다.

context 취소 전파: 고루틴 생명주기의 표준 인터페이스

Go에서 고루틴 종료 신호를 전달하는 가장 표준적인 방법은 context.Context 입니다.

  • 상위 요청/프로세스 종료 시 cancel() 호출
  • 하위 함수/고루틴은 ctx.Done() 을 감시
  • 블로킹 작업은 가능하면 ctx 를 인자로 받아 취소 가능하게 구성

기본 패턴: 부모가 cancel, 자식은 Done을 select로 감시

package main

import (
	"context"
	"fmt"
	"time"
)

func worker(ctx context.Context, out chan<- int) {
	defer close(out)

	for i := 0; ; i++ {
		select {
		case <-ctx.Done():
			// 종료 신호를 받으면 즉시 반환
			return
		case out <- i:
			// 정상 처리
		}
	}
}

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

	out := make(chan int)
	go worker(ctx, out)

	for v := range out {
		fmt.Println(v)
		if v == 3 {
			cancel()
		}
		// cancel 이후에도 range는 close(out)로 정상 종료
	}

	time.Sleep(100 * time.Millisecond)
}

포인트는 두 가지입니다.

  1. 고루틴 내부 루프는 selectctx.Done() 을 항상 함께 감시합니다.
  2. 송신 채널을 닫아 소비자가 자연스럽게 종료되도록 합니다(가능한 경우).

select로 “블로킹 지점”에 탈출구 만들기

고루틴 누수는 대개 블로킹 호출 때문에 발생합니다. send/recv, 락, 타이머, 외부 I/O 등에서 멈춥니다. 따라서 “블로킹할 수 있는 모든 지점”을 select 로 감싸는 습관이 중요합니다.

채널 send가 막히는 누수 예시와 해결

누수 코드

func leak(ch chan<- int) {
	for i := 0; ; i++ {
		ch <- i // 받는 쪽이 없으면 영원히 블로킹
	}
}

해결: ctx.Done 또는 타임아웃을 함께 select

func safeSend(ctx context.Context, ch chan<- int, v int) error {
	select {
	case <-ctx.Done():
		return ctx.Err()
	case ch <- v:
		return nil
	}
}

func producer(ctx context.Context, ch chan<- int) {
	for i := 0; ; i++ {
		if err := safeSend(ctx, ch, i); err != nil {
			return
		}
	}
}

이 패턴을 적용하면 “소비자가 사라져서 send가 막히는” 상황에서도 상위 취소로 빠져나올 수 있습니다.

채널 recv가 막히는 경우

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

ok 체크는 “생산자가 종료하면서 채널을 닫는” 정상적인 종료 경로를 만들어 줍니다.

ticker/타이머: time.Tick 대신 NewTicker + Stop

주기 작업을 고루틴으로 돌릴 때 흔한 누수 포인트가 타이머입니다.

나쁜 예: time.Tick

func bad(ctx context.Context) {
	for range time.Tick(1 * time.Second) {
		select {
		case <-ctx.Done():
			return
		default:
			// 작업
		}
	}
}

time.Tick 은 중단할 수 없어, 고루틴이 끝나도 내부 리소스가 남는 형태로 문제가 커질 수 있습니다.

좋은 예: NewTickerStop

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

	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			// 주기 작업
		}
	}
}

fan-out/fan-in에서의 누수: 워커 풀 종료 설계

병렬 처리에서 가장 많이 터지는 누수는 “입력 채널은 닫혔는데 워커가 끝나지 않음” 또는 “워커는 끝났는데 집계 고루틴이 끝나지 않음” 같은 형태입니다.

아래 예시는 다음을 만족하는 구조입니다.

  • 상위 ctx 취소로 전체 종료 가능
  • 입력 채널이 닫히면 워커 종료
  • 워커가 모두 종료되면 결과 채널 닫힘
package main

import (
	"context"
	"sync"
)

func startWorkers(ctx context.Context, n int, jobs <-chan int) <-chan int {
	results := make(chan int)

	var wg sync.WaitGroup
	wg.Add(n)

	worker := func() {
		defer wg.Done()
		for {
			select {
			case <-ctx.Done():
				return
			case j, ok := <-jobs:
				if !ok {
					return
				}
				// 작업 결과를 보내는 것도 블로킹 가능하므로 ctx와 함께
				select {
				case <-ctx.Done():
					return
				case results <- (j * 2):
				}
			}
		}
	}

	for i := 0; i < n; i++ {
		go worker()
	}

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

	return results
}

여기서 중요한 디테일은 “워커가 결과를 results <- ... 로 보낼 때도 ctx.Done() 을 함께 select 한다”는 점입니다. 워커 풀에서 흔한 누수는 결과 채널이 막혀 워커가 종료하지 못하는 상황에서 발생합니다.

HTTP 서버/요청 처리에서의 누수: r.Context() 를 생명주기로 사용

Go의 net/http 는 요청마다 r.Context() 를 제공합니다. 클라이언트가 연결을 끊거나 서버가 종료되면 이 컨텍스트는 취소됩니다. 요청 처리 중에 만든 고루틴은 가능하면 이 컨텍스트를 그대로 물려받아야 합니다.

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

	// 요청과 생명주기를 같이하는 고루틴
	go func() {
		select {
		case <-ctx.Done():
			return
		default:
			// 백그라운드 작업
		}
	}()

	w.WriteHeader(200)
}

주의할 점은 “요청 컨텍스트를 백그라운드 작업에 그대로 쓰면, 응답이 끝나면서 작업이 취소될 수 있다”는 것입니다. 의도한 동작이 아니라면 context.Background() 기반으로 별도 생명주기를 설계해야 합니다. 즉, 어떤 생명주기에 묶일지를 명확히 해야 합니다.

종료를 보장하는 실전 체크리스트

1) 모든 고루틴에 종료 신호를 제공했는가

  • ctx 를 인자로 받도록 통일
  • 또는 quit := make(chan struct{}) 같은 종료 채널을 두되, 가능하면 context 로 표준화

2) 블로킹 가능 지점마다 selectDone 을 감시했는가

  • ch <- v, <-ch, ticker.C, time.After, select {}
  • 락(mutex.Lock) 자체는 select 로 감쌀 수 없으니, 락 경합이 심하면 구조를 바꾸거나 채널 기반으로 설계를 재검토

3) 채널 close 책임이 명확한가

  • “누가 닫는가”가 불명확하면 대부분 데드락 또는 패닉으로 이어집니다
  • 원칙적으로 생산자가 닫는다

4) 타임아웃이 필요한 경계가 있는가

외부 의존성(HTTP, DB, 큐) 호출은 무한 대기하지 않도록 context.WithTimeout 을 적극적으로 사용합니다. 운영에서 이런 무한 대기는 스레드/고루틴/커넥션 풀을 잠식해 장애로 커집니다. 장애 진단 관점은 TorchServe 500/타임아웃 진단·튜닝 8선 같은 “타임아웃을 시스템적으로 다루는” 글과도 결이 같습니다.

ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()

req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)

5) 반복 작업은 NewTicker 를 쓰고 Stop 했는가

  • defer ticker.Stop()

누수 의심 시 빠르게 확인하는 방법

1) goroutine 덤프 확인

운영 중 고루틴 수가 비정상적으로 늘면 원인을 찾아야 합니다.

  • net/http/pprof 를 붙였다면 /debug/pprof/goroutine?debug=2
  • 또는 runtime/pprof.Lookup("goroutine").WriteTo(...) 로 덤프

덤프를 보면 많은 고루틴이 chan send 혹은 chan receive 에서 멈춰 있는 패턴이 자주 보입니다. 그 지점이 바로 selectctx.Done() 을 끼워 넣어야 할 곳입니다.

2) 고루틴 수/메모리 추세를 지표로 보기

  • runtime.NumGoroutine() 를 주기적으로 로깅/메트릭화
  • 메모리/FD 증가와 함께 보면 누수를 더 빨리 감지할 수 있습니다

리소스가 서서히 증가하다가 OOM으로 이어지는 패턴은 Go 서비스뿐 아니라 다양한 런타임에서 반복됩니다. 예를 들어 Ray Serve 배포 OOMKilled·콜드스타트 5분 줄이기 같은 사례처럼, “누적 리소스”는 결국 장애로 연결됩니다.

결론: context와 select는 ‘종료 설계’의 기본기

고루틴 누수는 문법 실수가 아니라 생명주기 설계 실패에서 발생합니다. 다음 3가지만 습관화해도 대부분의 누수를 예방할 수 있습니다.

  1. 고루틴을 만들면 ctx 를 같이 넘긴다.
  2. 블로킹 가능 지점은 selectctx.Done() 을 함께 기다린다.
  3. 채널 close 책임과 종료 순서를 코드로 명확히 만든다(wg.Wait 후 close 등).

이 원칙을 코드베이스 전반에 적용하면, “언젠가 터질” 누수가 아니라 “항상 끝나는” 고루틴을 만들 수 있고, 서비스의 장기 안정성이 눈에 띄게 좋아집니다.