Published on

Go 고루틴 누수 진단 - context 취소·채널 close

Authors

서버가 느려지거나 메모리가 조금씩 증가하는데 GC 로그는 정상, CPU도 애매하게 올라가는 상황이라면 고루틴 누수를 의심해야 합니다. 고루틴 누수는 흔히 “고루틴이 끝나지 못하고 영원히 기다리는 상태”로 발생하며, 대개 두 축에서 시작합니다.

  • context 취소가 끝까지 전파되지 않음
  • 채널을 닫지 않거나, 닫는 주체가 불명확하여 수신/송신이 영원히 블록됨

이 글은 pproftrace 같은 도구 진단 전에, 코드 구조만으로 누수를 줄이는 핵심 패턴인 context 취소와 채널 close 규칙을 중심으로 정리합니다. 고루틴 스택을 실제로 추적하는 방법은 별도 글인 Go 고루틴 leak 추적 - pprof·trace로 10분 진단도 함께 보시면 연결이 잘 됩니다.

고루틴 누수의 전형적 증상과 “대기 지점”

고루틴은 보통 아래 지점에서 멈춥니다.

  • 채널 수신: v := <-ch
  • 채널 송신: ch <- v
  • select 에서 어떤 케이스도 준비되지 않음
  • sync.Cond, WaitGroup, Mutex 등 동기화 프리미티브 대기
  • 네트워크/IO 블록 (특히 타임아웃 없는 read)

이 중 실무에서 가장 흔한 조합은 “채널 수신 대기 + 취소 미전파”입니다. 즉, 요청은 끝났는데 워커 고루틴은 for { select { case x := <-ch: ... } } 에서 계속 살아있는 형태입니다.

핵심 원칙 1: 모든 고루틴은 종료 조건을 가져야 한다

고루틴을 만들 때마다 스스로에게 질문해야 합니다.

  • 이 고루틴은 언제 끝나는가
  • 끝나게 하는 신호는 누가 보내는가
  • 그 신호가 전달되지 않으면 어디에서 영원히 대기하는가

종료 조건은 보통 두 가지 중 하나로 구현됩니다.

  • context.Done() 을 통한 취소
  • 채널 close 를 통한 종료

중요한 점은 “둘 중 하나만” 고집하기보다, 데이터 스트림에는 채널 close, 요청 생명주기에는 context 취소를 섞어 쓰는 것이 자연스럽다는 것입니다.

핵심 원칙 2: context 는 “요청 생명주기”를 끝까지 전파한다

흔한 실수: context.Background() 로 끊어버리기

아래 코드는 누수의 씨앗입니다. 상위 요청이 취소되어도 내부 작업은 계속 진행될 수 있습니다.

func handler(w http.ResponseWriter, r *http.Request) {
	// 나쁜 예: 요청 컨텍스트를 버리고 Background를 사용
	go doWork(context.Background())
	w.WriteHeader(202)
}

func doWork(ctx context.Context) {
	// ctx가 절대 취소되지 않으면 내부 대기/재시도/IO가 영원히 지속될 수 있음
	for {
		select {
		case <-ctx.Done():
			return
		default:
			// ...
		}
	}
}

개선: 상위 ctx 를 전달하고, 필요하면 WithCancel 로 하위 생명주기 관리

func handler(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithCancel(r.Context())
	defer cancel() // handler 종료 시점에 하위 작업도 정리

	go doWork(ctx)
	w.WriteHeader(202)
}

다만 handler 가 바로 끝나는데 백그라운드 작업은 계속해야 하는 요구(예: 비동기 작업 큐)가 있다면, 그 작업은 요청 컨텍스트와 분리하되 “영원히” 돌지 않도록 별도 타임아웃/종료 정책이 있어야 합니다.

  • 작업 큐에 넣고 워커가 처리한다
  • 워커는 프로세스 종료 시점에 정리된다
  • 각 작업은 context.WithTimeout 같은 상한을 가진다

WithTimeout 은 누수 방지의 안전벨트

네트워크 호출, 외부 API, DB 쿼리 등은 타임아웃이 없으면 고루틴이 IO에서 묶일 수 있습니다.

func fetchSomething(parent context.Context) error {
	ctx, cancel := context.WithTimeout(parent, 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
}

이 패턴은 “정상 종료”뿐 아니라 “비정상 상황에서도 결국 끝난다”는 보장을 줍니다.

핵심 원칙 3: 채널 close 는 “송신자”가 책임진다

채널 종료 규칙은 단순하지만 어기기 쉽습니다.

  • 채널은 송신자(sender) 가 닫는다
  • 수신자(receiver) 가 닫지 않는다
  • 여러 송신자가 있는 채널은 “누가 닫는가”를 별도로 설계해야 한다

흔한 실수: 수신자가 close 하거나, 닫힌 채널에 송신

닫힌 채널에 송신하면 패닉이 발생합니다. 그래서 “아무나 닫지 말자”로 흐르면, 이번엔 수신자가 영원히 대기하며 누수가 납니다.

종료 신호로서의 채널 close: for range ch

채널 기반 파이프라인에서는 for range 가 가장 깔끔한 종료 형태입니다.

func producer(ctx context.Context, out chan<- int) {
	defer close(out) // 송신자가 닫는다
	for i := 0; i < 10; i++ {
		select {
		case <-ctx.Done():
			return
		case out <- i:
		}
	}
}

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

여기서 포인트는 2가지입니다.

  • producer 는 반드시 defer close(out) 로 종료를 보장
  • consumerok 체크 또는 for range 로 종료를 감지

context 취소 vs 채널 close: 무엇을 언제 쓰나

둘을 역할로 나누면 설계가 쉬워집니다.

  • context 는 “중단/타임아웃/요청 스코프”에 적합
  • 채널 close 는 “데이터 스트림이 끝남”을 알리는 데 적합

문제는 둘을 섞지 않고 한 쪽만으로 모든 것을 해결하려 할 때 생깁니다.

  • context 만으로 스트림 종료를 표현하면, 수신 루프가 Done 을 놓치거나 데이터 처리가 꼬일 수 있음
  • 채널 close 만으로 요청 취소를 표현하면, 타임아웃/데드라인/원인 전달이 어려움

따라서 실무적으로는 아래 조합이 안정적입니다.

  • 파이프라인의 데이터 채널은 생산자가 close
  • 모든 단계는 selectctx.Done() 을 함께 감시

채널 대기에서 빠져나오지 못해 데드락처럼 보이는 케이스는 Go 고루틴 채널 데드락 5분 재현·해결도 같이 보면 원인 분류가 더 빨라집니다.

누수를 만드는 대표 패턴 5가지와 처방

1) select 에서 취소 케이스가 없음

func worker(ch <-chan int) {
	for v := range ch { // ch가 닫히지 않으면 영원히 대기
		_ = v
	}
}

처방: ctx.Done() 을 함께 보거나, 반드시 닫히는 채널만 range 하도록 계약을 명확히 합니다.

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

2) 무한 재시도 루프에 취소/상한이 없음

for {
	err := call()
	if err == nil {
		break
	}
	time.Sleep(100 * time.Millisecond)
}

처방: ctx 와 최대 시도 횟수 또는 데드라인을 둡니다.

for attempt := 0; attempt < 5; attempt++ {
	select {
	case <-ctx.Done():
		return ctx.Err()
	default:
	}
	if err := call(); err == nil {
		return nil
	}
	time.Sleep(100 * time.Millisecond)
}
return errors.New("retry exhausted")

3) fan-out 후 fan-in 에서 일부 결과를 버려 고루틴이 막힘

아래는 결과를 다 읽지 않으면 송신자가 블록되어 누수가 되는 전형적인 형태입니다.

results := make(chan int)
for i := 0; i < 10; i++ {
	go func() {
		results <- 1 // 수신자가 다 읽지 않으면 여기서 블록
	}()
}

// 일부만 읽고 return 하면 나머지 고루틴이 송신에서 멈춤
<-results
return

처방은 3가지 중 하나입니다.

  • results 를 충분히 버퍼링
  • 컨텍스트 취소로 송신을 포기하게 만들기
  • WaitGroup 으로 생산자 종료를 기다리고 수신 루프를 끝까지 소비

아래는 ctx + WaitGroup + close(results) 조합 예시입니다.

func fanIn(ctx context.Context) (int, error) {
	results := make(chan int)
	var wg sync.WaitGroup

	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			select {
			case <-ctx.Done():
				return
			case results <- 1:
			}
		}()
	}

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

	sum := 0
	for {
		select {
		case <-ctx.Done():
			return 0, ctx.Err()
		case v, ok := <-results:
			if !ok {
				return sum, nil
			}
			sum += v
		}
	}
}

4) time.Tick 사용으로 타이머 리소스가 누적

time.Tick 은 멈출 수 없는 ticker를 만들기 때문에, 고루틴이 종료돼도 내부 타이머가 남아 누수처럼 작동할 수 있습니다.

처방: time.NewTicker 를 쓰고 Stop() 합니다.

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

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

5) 채널을 닫지 않는 생산자

생산자가 에러로 조기 리턴하면서 close(out) 를 빼먹는 케이스가 많습니다.

처방: 생산자 함수 시작 직후 defer close(out) 를 습관화하고, “정상 종료/에러/취소” 모든 경로에서 닫히게 만듭니다.

실전 진단 체크리스트: 코드 리뷰에서 바로 잡는 포인트

서비스에서 누수가 의심될 때, 도구를 켜기 전에 코드만으로도 빠르게 확률을 줄일 수 있습니다.

  1. go func 를 검색하고, 모든 고루틴이 ctx.Done() 또는 채널 종료를 감시하는지 확인
  2. context.Background() 또는 context.TODO() 가 요청 경로에 섞여 컨텍스트 전파를 끊는지 확인
  3. for range ch 를 쓰는 곳에서 채널이 “반드시 닫히는 계약”인지 확인
  4. 여러 송신자가 있는 채널이라면 close 책임자가 별도로 존재하는지 확인
  5. time.Tick 사용 여부 확인 후 NewTicker 로 교체
  6. 결과 채널에 송신하는 고루틴이 있는데 수신 측이 조기 리턴하는지 확인

이 체크리스트로도 해결이 안 되면, 고루틴 스택에서 “어디에서 대기 중인지”를 확인해야 합니다. 이때는 Go 고루틴 leak 추적 - pprof·trace로 10분 진단처럼 pprof 의 goroutine dump, block profile, trace 로 병목 지점을 찾는 단계로 넘어가면 됩니다.

결론: 누수는 버그가 아니라 “종료 설계 부재”에서 온다

Go의 고루틴 누수는 대개 특정 API의 함정이라기보다, 종료 신호가 명확히 설계되지 않은 상태에서 동시성을 얹을 때 발생합니다.

  • 요청 단위 작업은 context 로 생명주기를 끝까지 전파하고, IO에는 WithTimeout 을 기본값으로 둡니다.
  • 스트림 처리 파이프라인은 “송신자가 닫는다” 규칙을 지키고, 수신자는 ok 또는 range 로 종료를 감지합니다.
  • fan-out/fan-in 구조에서는 “결과를 끝까지 소비하거나, 취소로 송신을 포기하게 하거나, 버퍼/정리 고루틴으로 배수” 중 하나를 반드시 선택합니다.

이 원칙만 지켜도, 운영에서 마주치는 고루틴 누수의 상당수를 예방하고 진단 시간을 크게 줄일 수 있습니다.