Published on

Go 고루틴 누수 원인 - 채널 블로킹 5가지

Authors

서버나 워커 시스템을 Go로 운영하다 보면, CPU는 한가한데 메모리와 고루틴 수가 서서히 증가하는 현상을 만나곤 합니다. 이때 가장 흔한 원인은 고루틴이 종료되지 못한 채 어딘가에서 영원히 대기하는 상황이고, 그 대기의 중심에는 채널(channel) 블로킹이 있는 경우가 많습니다.

이 글에서는 고루틴 누수를 만드는 채널 블로킹 패턴 5가지를 골라, 재현 코드와 함께 왜 누수로 이어지는지, 그리고 실무에서 어떤 형태로 고쳐야 하는지 정리합니다.

참고로 네트워크 호출이 섞이면 채널 블로킹과 context 타임아웃이 함께 엮여 문제를 키우기도 합니다. gRPC 환경이라면 이 글도 같이 보면 원인 파악이 빨라집니다: Go gRPC context deadline exceeded 9가지 원인

고루틴 누수와 채널 블로킹의 관계

고루틴 누수는 GC가 메모리를 회수 못 하는 “메모리 누수”와 결이 조금 다릅니다. 고루틴은 스택, 스케줄링 메타데이터, 참조하는 힙 객체 등을 계속 붙들고 있을 수 있고, 특히 다음 조건이면 장기적으로 장애로 이어집니다.

  • 고루틴이 chan 송신 또는 수신에서 블로킹된 채 깨어나지 않는다
  • 블로킹된 고루틴이 참조하는 객체(버퍼, 캐시, 커넥션 등)도 같이 살아남는다
  • 요청 단위로 고루틴을 생성하는 구조에서 누적된다

채널은 “동기화”를 제공하는 대신, 상대가 없으면 멈춘다는 특성이 있습니다. 이 특성을 통제하지 못하면 누수가 됩니다.

1) 수신자가 사라진 채널로 송신해서 영원히 대기

재현 코드

아래 코드는 워커가 결과를 results로 보내는데, 메인 루틴이 중간에 리턴해 버려 더 이상 수신하지 않습니다. 그러면 워커는 results <- v에서 영원히 멈출 수 있습니다.

package main

import (
	"fmt"
	"time"
)

func main() {
	results := make(chan int) // unbuffered

	go func() {
		for i := 0; i < 3; i++ {
			fmt.Println("worker sending", i)
			results <- i // receiver가 없으면 여기서 블로킹
		}
		fmt.Println("worker done")
	}()

	// 첫 값만 받고 종료 (나머지 수신자가 사라짐)
	fmt.Println("main got", <-results)
	return

	// time.Sleep로 기다려도 worker는 깨어나지 못함
	_ = time.Second
}

왜 누수인가

  • results가 unbuffered이므로 송신은 반드시 수신자와 만나야 완료됩니다.
  • 메인이 리턴하면 수신자가 사라지고, 워커는 해당 라인에서 영구 블로킹됩니다.
  • 요청마다 이런 구조가 반복되면 고루틴 수가 누적됩니다.

해결 패턴

  1. 취소 신호를 함께 설계합니다. context 또는 done 채널을 두고 송신을 select로 감쌉니다.
func sendWithCancel(done <-chan struct{}, ch chan<- int, v int) bool {
	select {
	case ch <- v:
		return true
	case <-done:
		return false
	}
}
  1. 결과 채널을 버퍼링하는 것도 한 방법이지만, 버퍼는 “임시 완충”일 뿐 영구 해결은 아닙니다. 수신이 아예 사라지면 결국 버퍼도 찹니다.

2) 송신자가 사라진 채널에서 수신이 영원히 대기

재현 코드

아래는 소비자가 jobs에서 읽는데, 생산자가 어떤 이유로 종료되어 jobs를 닫지 않습니다. 소비자는 for range에서 영원히 기다립니다.

package main

import "fmt"

func main() {
	jobs := make(chan int)

	go func() {
		// 생산자가 조기 종료하지만 close(jobs)를 호출하지 않음
		return
	}()

	go func() {
		for j := range jobs { // close되지 않으면 여기서 영원히 블로킹
			fmt.Println("job", j)
		}
		fmt.Println("consumer done")
	}()

	select {} // 프로그램 유지
}

해결 패턴

  • 생산자가 소유한 채널은 생산자가 닫는다는 원칙을 지킵니다.
  • 생산자의 종료 경로가 여러 개라면 defer close(ch)를 고려합니다.
func producer(jobs chan<- int) {
	defer close(jobs)
	for i := 0; i < 10; i++ {
		jobs <- i
	}
}
  • 소비자 쪽에서는 “채널이 영원히 안 닫힐 수 있다”는 전제라면, context 기반 타임아웃이나 done 채널을 함께 둡니다.
func consumer(done <-chan struct{}, jobs <-chan int) {
	for {
		select {
		case j, ok := <-jobs:
			if !ok {
				return
			}
			_ = j
		case <-done:
			return
		}
	}
}

3) 버퍼 채널이 가득 차서 송신이 멈추는 “느린 소비자” 문제

버퍼 채널은 unbuffered보다 안전해 보이지만, 소비가 생산을 따라가지 못하면 결국 막힙니다. 특히 로깅, 메트릭, 이벤트 발행에서 흔합니다.

재현 코드

package main

import (
	"time"
)

func main() {
	events := make(chan int, 2)

	go func() {
		for e := range events {
			_ = e
			time.Sleep(500 * time.Millisecond) // 느린 소비자
		}
	}()

	go func() {
		for i := 0; i < 1000000; i++ {
			events <- i // 버퍼가 차면 여기서 블로킹
		}
	}()

	select {}
}

해결 패턴 3가지

(1) 드롭 정책을 명시하고 non-blocking send

로그나 트레이스처럼 “최선 노력”이면 드롭이 더 낫습니다.

func trySend(ch chan<- int, v int) {
	select {
	case ch <- v:
		// sent
	default:
		// drop
	}
}

(2) 백프레셔를 설계에 반영

드롭이 안 된다면 생산 속도를 제어해야 합니다. 예를 들어 작업 큐 앞단에 rate limit 또는 worker pool로 병목을 의도적으로 둡니다.

(3) 소비자 확장 또는 병렬 소비

소비가 병렬화 가능한 작업이면 소비자 고루틴을 늘리고, 작업이 순서 민감하면 별도 구조(예: 단일 소비 + 내부 배치 처리)를 고민해야 합니다.

4) select에서 default를 잘못 써서 종료 신호를 놓치고 영구 대기

selectdefault는 “블로킹을 피한다”는 장점이 있지만, 잘못 조합하면 종료 신호를 무시하거나 바쁜 루프를 만들 수 있습니다.

흔한 실수: 종료를 기다려야 하는데 default로 빠져나감

func worker(done <-chan struct{}, jobs <-chan int) {
	for {
		select {
		case <-done:
			return
		default:
			// 여기서 jobs를 블로킹 수신하면 done을 즉시 못 받는 구조가 되기 쉬움
			j := <-jobs
			_ = j
		}
	}
}

위 코드는 겉보기엔 done을 보지만, 실제로는 default로 들어간 뒤 j := <-jobs에서 블로킹되면 done을 즉시 처리하지 못합니다. jobs가 멈춰 있고 done만 닫히는 상황이면 누수로 이어집니다.

올바른 형태: 블로킹 연산도 select 안으로

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

핵심은 “블로킹 가능성이 있는 채널 연산을 select 바깥에서 하지 않는다”입니다.

5) fan-in, fan-out에서 “누가 언제 채널을 닫는가”가 불명확해 생기는 교착

여러 생산자에서 하나로 모으는 fan-in, 또는 하나에서 여러 소비자로 뿌리는 fan-out은 채널 누수의 온상입니다. 특히 다음이 자주 터집니다.

  • 여러 생산자가 있는데 아무도 출력 채널을 닫지 못함
  • 하나라도 멈추면 전체 파이프라인이 막힘
  • 소비자가 중간에 반환하면 상류가 전부 송신에서 블로킹

재현: fan-in에서 close를 잘못 처리

아래 코드는 여러 입력을 합치는데, out을 닫는 시점이 없어 소비자가 range out에서 끝나지 않습니다.

func merge(a, b <-chan int) <-chan int {
	out := make(chan int)

	go func() {
		for v := range a {
			out <- v
		}
	}()
	go func() {
		for v := range b {
			out <- v
		}
	}()

	// out을 닫는 고루틴이 없음
	return out
}

해결: sync.WaitGroup으로 생산자 종료를 합의하고 한 곳에서 close

package main

import "sync"

func merge(done <-chan struct{}, chans ...<-chan int) <-chan int {
	out := make(chan int)
	var wg sync.WaitGroup
	wg.Add(len(chans))

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

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

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

	return out
}

여기서 중요한 점은 2가지입니다.

  • out단 하나의 고루틴만 닫습니다(경합 방지)
  • done으로 상류와 하류 모두가 빠져나갈 출구를 가집니다

누수 진단 체크리스트

채널 블로킹 누수를 빠르게 좁히려면, 아래 질문을 코드 리뷰 체크리스트로 쓰는 게 효과적입니다.

  1. 이 고루틴은 어떤 조건에서 종료되는가? 종료 경로가 실제로 호출되는가?
  2. 채널 송신과 수신은 취소 가능한가? done 또는 context를 함께 보고 있는가?
  3. for range ch를 쓰는 곳에서 close(ch)의 책임자가 명확한가?
  4. 버퍼 채널 크기는 근거가 있는가? 가득 찼을 때 정책(드롭, 백프레셔, 확장)이 있는가?
  5. 파이프라인(fan-in, fan-out)에서 close는 한 곳에서만 수행되는가?

실무 권장 패턴 요약

  • 요청 단위 고루틴에는 되도록 context를 전달하고, 채널 연산은 select로 취소 가능하게 만든다
  • “생산자가 채널을 닫는다” 원칙을 지키고, 여러 생산자라면 WaitGroup으로 합의 후 단일 close
  • 드롭 가능한 이벤트는 non-blocking send로 시스템 안정성을 우선한다
  • 블로킹 가능한 채널 수신을 select 바깥에서 하지 않는다

비동기 흐름에서 타임아웃과 재시도, 취소를 어떻게 구조화할지에 대한 감각은 언어가 달라도 도움 됩니다. 비슷한 문제의 구조를 Python 관점에서 정리한 글도 참고할 만합니다: Python 데코레이터로 async 타임아웃·재시도 패턴


채널은 Go 동시성의 핵심 도구지만, “상대가 없으면 멈춘다”는 성질을 설계로 흡수하지 않으면 고루틴 누수로 되돌아옵니다. 위 5가지 패턴을 코드베이스에서 검색해 보고, done 또는 context 기반 종료 경로가 보장되는지부터 점검하면 대부분의 누수는 빠르게 잡힙니다.