Published on

Go 고루틴 누수 7가지 원인과 채널 종료 패턴

Authors

고루틴은 가볍지만 공짜는 아닙니다. 요청마다 고루틴을 띄우는 서버, fan-out/fan-in 파이프라인, 스트리밍 소비자 같은 구조에서 고루틴이 “끝나지 못하고” 남아 있으면 메모리 사용량이 서서히 증가하고, 스케줄러 부하가 커지며, 파일 디스크립터나 네트워크 커넥션이 함께 고갈될 수도 있습니다. 특히 쿠버네티스 환경에서는 이런 누수가 결국 OOMKilled로 나타나기도 합니다. 운영에서 증상이 보인다면 메모리·GC 관점의 진단은 K8s OOMKilled 반복? 메모리 리밋·GC 진단법도 같이 참고하면 좋습니다.

이 글에서는 고루틴 누수가 발생하는 대표적인 7가지 원인을 “왜 종료되지 않는가” 관점으로 정리하고, 채널을 안전하게 닫는 종료 패턴을 코드로 제시합니다.

고루틴 누수란 무엇인가

고루틴 누수는 엄밀히 말해 “메모리가 해제되지 않는다”라기보다, 더 이상 유효한 일을 하지 않는데도 고루틴이 블로킹 상태로 남아 스케줄러와 힙, 각종 리소스를 계속 점유하는 상태를 의미합니다.

대표적인 징후는 다음과 같습니다.

  • runtime.NumGoroutine() 값이 트래픽이 줄어도 내려오지 않음
  • pprof에서 특정 스택의 고루틴이 계속 누적됨
  • 채널 send/recv 혹은 select에서 장시간 멈춘 스택이 다수
  • 메모리 사용량이 완만하게 우상향, GC 빈도 증가

운영에서는 “원인”보다 “종료 신호가 전달되지 않는다 / 블로킹이 풀리지 않는다”가 핵심입니다.

원인 1) 수신자가 사라진 채널 send 블로킹

가장 흔한 누수입니다. 생산자는 계속 ch <- v를 시도하지만, 소비자가 에러로 리턴했거나 컨텍스트 취소로 종료되면 send가 영원히 블로킹됩니다.

잘못된 예

func producer(ch chan<- int) {
	for i := 0; ; i++ {
		ch <- i // 소비자가 없으면 여기서 영원히 대기
	}
}

func consumer(ch <-chan int) error {
	for v := range ch {
		if v > 10 {
			return fmt.Errorf("stop") // 생산자는 이 사실을 모름
		}
	}
	return nil
}

개선 포인트

  • 생산자 루프에 context를 포함하고, send도 select로 취소 가능하게 만들기
  • 버퍼로 임시 완화는 가능하지만 근본 해결은 아님
func producer(ctx context.Context, ch chan<- int) {
	defer close(ch)
	for i := 0; ; i++ {
		select {
		case <-ctx.Done():
			return
		case ch <- i:
		}
	}
}

원인 2) 닫히지 않는 채널 range로 영원히 대기

for v := range ch는 채널이 닫힐 때까지 종료되지 않습니다. 생산자가 어떤 이유로든 close(ch)를 호출하지 않으면 소비자는 영원히 기다립니다.

잘못된 예

func consumer(ch <-chan int) {
	for v := range ch { // close가 없으면 종료 불가
		_ = v
	}
}

개선 포인트

  • 생산자가 종료 시점을 책임지고 채널을 닫기
  • 또는 소비자가 context로 탈출할 수 있게 select 사용
func consumer(ctx context.Context, ch <-chan int) {
	for {
		select {
		case <-ctx.Done():
			return
		case v, ok := <-ch:
			if !ok {
				return
			}
			_ = v
		}
	}
}

원인 3) select에서 취소 케이스가 없거나, 디폴트로 바쁜 루프

취소 신호를 받지 못하면 종료되지 않습니다. 반대로 default를 넣어 비블로킹으로 만들면, 조건이 충족되지 않을 때 CPU를 태우는 바쁜 루프가 되어 시스템 전체에 악영향을 줍니다.

잘못된 예: 취소 없음

for {
	select {
	case v := <-ch:
		_ = v
	}
}

잘못된 예: default 바쁜 루프

for {
	select {
	case v := <-ch:
		_ = v
	default:
		// 계속 반복하며 CPU 사용
	}
}

개선 포인트

  • ctx.Done()을 항상 고려
  • 폴링이 필요하면 time.Ticker로 주기 제한
ticker := time.NewTicker(200 * time.Millisecond)

defer ticker.Stop()
for {
	select {
	case <-ctx.Done():
		return
	case v := <-ch:
		_ = v
	case <-ticker.C:
		// 주기 작업
	}
}

원인 4) 타임아웃 없는 I/O 또는 외부 호출에 갇힘

고루틴 자체는 종료 의지가 있어도, 내부에서 네트워크 read, DB query, 스트리밍 수신 등 블로킹 호출이 타임아웃 없이 걸리면 빠져나오지 못합니다.

개선 포인트

  • context를 실제 I/O 라이브러리에 전달
  • HTTP 클라이언트는 Timeout 또는 request context 사용
  • 커넥션 read/write deadline 설정
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)

스트리밍/장시간 연결에서 타임아웃과 재시도 설계는 별개의 큰 주제인데, 네트워크 단절로 “끝나지 않는 대기”가 생긴다는 점에서 유사합니다. 스트리밍이 끊기며 복구가 필요한 케이스는 OpenAI Responses API 스트리밍 끊김 타임아웃 완전 복구 가이드도 사고방식 참고가 됩니다.

원인 5) WaitGroup 카운트 불일치로 영원히 대기

wg.Add(1) 했는데 어떤 경로에서 wg.Done()이 호출되지 않으면 wg.Wait()는 영원히 블로킹됩니다. 이때 대기 중인 고루틴(또는 메인 고루틴)이 종료되지 못해 “프로세스가 안 죽는” 형태로 나타납니다.

잘못된 예

wg.Add(1)
go func() {
	if err := do(); err != nil {
		return // Done 호출 누락
	}
	wg.Done()
}()

wg.Wait()

개선 포인트

  • 고루틴 시작 직후 defer wg.Done()을 습관화
wg.Add(1)
go func() {
	defer wg.Done()
	_ = do()
}()

wg.Wait()

원인 6) 팬아웃 작업이 결과 채널에 막혀 종료 못함

여러 작업자가 결과를 results 채널로 보내는데, 수집기가 일찍 종료하거나 느리면 작업자가 send에서 막힙니다. 특히 에러가 하나라도 나면 “빠르게 실패”하고 싶어 수집기가 리턴해버리는 패턴에서 자주 터집니다.

잘못된 예

func worker(jobs <-chan Job, results chan<- Result) {
	for j := range jobs {
		r := handle(j)
		results <- r // 수집기가 없으면 블로킹
	}
}

개선 포인트

  • context 취소로 작업자도 빠르게 종료
  • 결과 채널에 send할 때도 select로 취소 가능하게
func worker(ctx context.Context, jobs <-chan Job, results chan<- Result) {
	for {
		select {
		case <-ctx.Done():
			return
		case j, ok := <-jobs:
			if !ok {
				return
			}
			r := handle(j)
			select {
			case <-ctx.Done():
				return
			case results <- r:
			}
		}
	}
}

원인 7) time.Tick 사용으로 타이머 리소스 누수

time.Tick은 내부적으로 Ticker를 만들고, 멈출 방법이 없습니다. 함수가 종료되어도 tick 채널을 참조하는 고루틴이 남거나, 타이머 리소스가 회수되지 않는 형태로 문제가 커질 수 있습니다.

잘못된 예

for range time.Tick(1 * time.Second) {
	doSomething()
}

개선 포인트

  • time.NewTicker를 사용하고 defer ticker.Stop()
ticker := time.NewTicker(1 * time.Second)

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

채널 종료 패턴: 누수 없이 끝내는 5가지 규칙

채널 종료는 “누가 close를 호출할 자격이 있는가”를 명확히 하면 대부분 해결됩니다. 아래 규칙을 팀 컨벤션으로 정해두면 사고가 크게 줄어듭니다.

1) close는 송신자만 한다

수신자가 close(ch)를 호출하면, 다른 송신자가 send하는 순간 패닉이 발생할 수 있습니다. 따라서 원칙은 단순합니다.

  • 단일 송신자: 그 송신자가 종료 시 close
  • 다중 송신자: 송신자들이 직접 닫지 말고, 별도의 조정자(aggregator)가 WaitGroup으로 송신자 종료를 확인한 뒤 닫기

2) “완료 신호”와 “데이터” 채널을 분리한다

데이터 채널을 종료 신호로도 쓰면 복잡해집니다. 취소는 context 또는 done 채널로, 데이터는 데이터대로.

  • 취소: ctx.Done() 또는 done := make(chan struct{})
  • 데이터: chan T

3) fan-in에서는 WaitGroup으로 close 타이밍을 만든다

여러 생산자가 하나의 결과 채널로 모일 때는 다음 패턴이 안전합니다.

func fanIn(ctx context.Context, inputs ...<-chan int) <-chan int {
	out := make(chan int)
	var wg sync.WaitGroup

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

	wg.Add(len(inputs))
	for _, ch := range inputs {
		go forward(ch)
	}

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

	return out
}

핵심은 out을 닫는 주체가 “단 하나”라는 점입니다.

4) 에러로 조기 종료할 때는 반드시 취소를 전파한다

수집기가 에러를 만나면 빨리 리턴하고 싶습니다. 하지만 작업자들이 결과 전송에서 막혀 누수될 수 있으니, 조기 종료 시 cancel()을 호출하고 작업자 쪽 send도 select로 취소를 수용해야 합니다.

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

// 에러 발생 시 cancel 호출
if err != nil {
	cancel()
	return err
}

5) 버퍼 채널은 “완화”일 뿐 “해결”이 아니다

버퍼는 순간적인 생산-소비 속도 차이를 흡수합니다. 하지만 소비자가 사라진 문제를 근본적으로 해결하지 못합니다. 버퍼가 다 차면 결국 동일하게 블로킹되고, 그 시점이 늦춰졌을 뿐이라 디버깅이 더 어려워질 수도 있습니다.

실전 예시: 작업 풀 + 안전한 종료 + 결과 수집

아래는 누수 방지 요소를 한 번에 담은 작업 풀 예시입니다.

  • context로 전체 취소
  • 작업자 defer wg.Done()
  • 결과 send는 취소 가능
  • 결과 채널 close는 단일 고루틴이 담당
type Job struct{ ID int }

type Result struct {
	JobID int
	Err   error
}

func RunPool(ctx context.Context, jobs []Job, workers int) ([]Result, error) {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()

	jobCh := make(chan Job)
	resCh := make(chan Result)

	var wg sync.WaitGroup
	workerFn := func() {
		defer wg.Done()
		for {
			select {
			case <-ctx.Done():
				return
			case j, ok := <-jobCh:
				if !ok {
					return
				}
				err := doJob(ctx, j)
				r := Result{JobID: j.ID, Err: err}
				select {
				case <-ctx.Done():
					return
				case resCh <- r:
				}
			}
		}
	}

	wg.Add(workers)
	for i := 0; i < workers; i++ {
		go workerFn()
	}

	go func() {
		defer close(jobCh)
		for _, j := range jobs {
			select {
			case <-ctx.Done():
				return
			case jobCh <- j:
			}
		}
	}()

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

	var out []Result
	for r := range resCh {
		out = append(out, r)
		if r.Err != nil {
			// 조기 종료: 반드시 cancel로 전파
			cancel()
			// 여기서 바로 return하지 말고,
			// resCh가 닫힐 때까지 drain할지 정책을 정해야 함
		}
	}

	// 에러 정책: 첫 에러를 반환하는 등 팀 규칙에 맞게 처리
	for _, r := range out {
		if r.Err != nil {
			return out, r.Err
		}
	}
	return out, nil
}

func doJob(ctx context.Context, j Job) error {
	// 실제로는 DB/HTTP 호출 등 ctx를 전달
	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-time.After(50 * time.Millisecond):
		return nil
	}
}

위 코드에서 중요한 설계 포인트는 “누가 무엇을 닫는지”가 명확하다는 점입니다.

  • jobCh는 job 생산 고루틴이 닫음
  • resCh는 작업자 WaitGroup을 기다리는 조정자 고루틴이 닫음
  • 취소는 cancel() 하나로 전파

디버깅 체크리스트: 누수 의심 시 빠르게 보는 곳

  1. runtime.NumGoroutine()를 주기적으로 로깅해 추세 확인
  2. net/http/pprof로 goroutine 프로파일 확인
  3. 블로킹 지점이 chan send, chan receive, select인지 분류
  4. 해당 스택에 ctx.Done() 케이스가 있는지 확인
  5. 채널 close 책임자가 단일한지 확인
  6. WaitGroupAddDone이 모든 경로에서 균형인지 확인

운영 장애에서 “원인 7가지”처럼 반복되는 패턴을 체크리스트로 만들면, CrashLoopBackOff나 OOMKilled 같은 형태로 증상이 번지기 전에 잡을 확률이 올라갑니다. 장애 트러블슈팅 관점은 K8s Pod CrashLoopBackOff 원인 7가지와 해결도 함께 보면 좋습니다.

마무리

고루틴 누수는 대개 “종료 신호 부재”와 “블로킹 해제 불가”에서 시작합니다. 따라서 해결도 두 축으로 정리됩니다.

  • 모든 장기 루프와 send/recv 경로에 context 취소를 연결한다
  • 채널 close 책임을 단일화하고, fan-in/fan-out에서는 WaitGroup으로 close 타이밍을 만든다

이 두 가지만 팀 규칙으로 강제해도, 고루틴 누수의 상당수를 예방할 수 있습니다.