Published on

Go select 무한루프 CPU 100% 버그 1분 해결

Authors

운영 중인 Go 서비스가 갑자기 CPU 100%를 찍고, pprof를 떠보면 핫스팟이 runtime.selectgo 혹은 select 주변 루프로 몰리는 경우가 있습니다. 대부분은 select 자체가 느린 게 아니라, select가 즉시 반환되는 조건을 만들어 놓고 무한 루프를 돌린 것입니다.

이 글은 “1분 해결” 관점에서, 현장에서 가장 자주 터지는 패턴 3가지를 빠르게 진단하고 바로 고칠 수 있는 코드 템플릿을 제공합니다.

참고로 비슷한 류의 장애 대응 글로는 OpenAI Responses API 스트리밍 끊김·재시도 설계도 같이 보면 좋습니다. 스트리밍/채널 기반 설계에서 재시도 루프가 바쁜 루프로 변질되는 포인트가 닮아 있습니다.

증상 체크: “바쁜 루프(busy loop)”인지 확인

다음 중 하나면 거의 확정입니다.

  • 코어 1개가 100%에 가깝게 치솟고, GC나 sys는 낮음
  • 로그는 안 쌓이는데 CPU만 뜀
  • pprof에서 runtime.selectgo, runtime.chanrecv, runtime.chansend가 상위에 있음

가장 빠른 확인은 프로파일입니다.

go tool pprof -http=:0 http://127.0.0.1:6060/debug/pprof/profile?seconds=10

콜그래프에서 for { select { ... } } 형태가 보이고, 내부 케이스가 “즉시 준비되는 상태”라면 아래 원인 중 하나입니다.

원인 1) 닫힌 채널을 계속 select로 읽는다

Go에서 닫힌 채널을 수신하면 즉시 반환됩니다. v := <-ch는 블로킹이 아니라 바로 리턴하며, okfalse가 됩니다. 이 상태를 처리하지 않으면 루프가 쉬지 않고 돕니다.

재현 코드(문제)

package main

import "fmt"

func main() {
	ch := make(chan int)
	close(ch)

	for {
		select {
		case v := <-ch:
			// 닫힌 채널이면 v는 zero value(0)로 즉시 반환
			fmt.Println(v)
		}
	}
}

1분 해결(정답 패턴): ok 체크 후 종료 또는 채널 nil 처리

for {
	select {
	case v, ok := <-ch:
		if !ok {
			// 1) 루프를 종료하거나
			return
		}
		handle(v)
	}
}

혹은 여러 채널을 select로 묶어 운영 중이고, 특정 채널만 닫히더라도 다른 채널 처리를 계속해야 한다면 닫힌 채널을 nil로 바꿔서 select 대상에서 제거하는 패턴이 효과적입니다.

for {
	select {
	case v, ok := <-ch:
		if !ok {
			ch = nil // nil 채널은 영원히 블로킹되므로 select에서 사실상 제거됨
			continue
		}
		handle(v)
	case msg := <-other:
		handleOther(msg)
	}
}

이 패턴 하나만 알아도 “채널 닫힘 이후 CPU 100%” 유형은 대부분 끝납니다.

원인 2) default 케이스가 바쁜 루프를 만든다

selectdefault가 있으면, 준비된 케이스가 없을 때 즉시 default가 실행됩니다. 이를 for로 감싸면 sleep 없는 폴링이 되어 CPU를 태웁니다.

재현 코드(문제)

for {
	select {
	case msg := <-ch:
		process(msg)
	default:
		// 할 일 없으면 그냥 다음 루프로
		// => 초당 수백만 번 루프, CPU 100%
	}
}

1분 해결 A: default 제거하고 채널/컨텍스트로 블로킹

정말로 “할 일이 없으면 기다려야” 하는 구조라면 default는 빼는 게 정답입니다.

for {
	select {
	case msg := <-ch:
		process(msg)
	case <-ctx.Done():
		return
	}
}

1분 해결 B: 폴링이 필요하면 time.Ticker로 속도 제한

외부 조건 때문에 폴링이 불가피하면, default로 무한 루프를 돌리지 말고 티커로 주기를 강제하세요.

ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()

for {
	select {
	case msg := <-ch:
		process(msg)
	case <-ticker.C:
		pollOnce()
	case <-ctx.Done():
		return
	}
}

핵심은 “일 없을 때도 즉시 다음 루프로 가지 않게 만들기”입니다.

원인 3) time.After를 루프에서 계속 만들어 타이머가 폭증한다

CPU 100%의 직접 원인이 select 즉시 반환이 아닐 때도 있습니다. time.After를 루프에서 매번 만들면 타이머 객체가 계속 생성되고, 루프가 빠르면 런타임/GC 압박으로 CPU가 상승합니다.

흔한 코드(주의)

for {
	select {
	case msg := <-ch:
		process(msg)
	case <-time.After(1 * time.Second):
		// timeout
	}
}

이 코드는 기능적으로는 맞지만, 고빈도 루프에서 비용이 커질 수 있습니다.

1분 해결: time.NewTimer 재사용 또는 Ticker 사용

timer := time.NewTimer(1 * time.Second)
defer timer.Stop()

for {
	// 재사용 타이머는 Reset 전에 채널 비우기 패턴이 중요
	if !timer.Stop() {
		select {
		case <-timer.C:
		default:
		}
	}
	timer.Reset(1 * time.Second)

	select {
	case msg := <-ch:
		process(msg)
	case <-timer.C:
		// timeout
	case <-ctx.Done():
		return
	}
}

주기적 작업이면 Timer보다 Ticker가 더 단순합니다.

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

for {
	select {
	case <-ticker.C:
		doPeriodic()
	case msg := <-ch:
		process(msg)
	case <-ctx.Done():
		return
	}
}

실전 “1분 해결” 체크리스트

장애 상황에서 아래 순서로 보면 빠릅니다.

  1. select에서 수신하는 채널이 닫혔을 가능성
    • 수신부를 v, ok := <-ch로 바꾸고 !ok 처리
    • 여러 채널이면 닫힌 채널을 nil로 바꿔서 제거
  2. selectdefault가 있는지
    • 있으면 제거하거나 Ticker로 주기 제한
    • “안 막히게 하려고 default를 넣었다”는 의도가 많지만, 그 순간 폴링이 됩니다
  3. 루프에서 time.After를 계속 만들고 있는지
    • NewTimer 재사용 또는 Ticker로 전환

이 3가지만 잡아도 select 기반 무한루프 CPU 100% 이슈의 대부분이 해결됩니다.

운영 관점: 재발 방지 패턴

1) 종료 신호는 context로 통일

채널 닫힘/고루틴 종료를 제각각 처리하면 “닫힌 채널 즉시 반환”을 놓치기 쉽습니다.

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

2) select 루프에 관측 지표를 심어 “초당 루프 횟수”를 본다

바쁜 루프는 로그가 없을 수 있습니다. 루프가 초당 몇 번 도는지 카운팅만 해도 조기 탐지가 됩니다(프로메테우스 카운터 등).

3) 비동기/스트리밍은 재시도 루프 설계가 중요

스트리밍 끊김 이후 재시도 로직이 default 폴링으로 변하는 경우가 흔합니다. 재시도/백오프 설계는 별도로 정리한 OpenAI Responses API 스트리밍 끊김·재시도 설계처럼 “대기”가 있는 구조로 만들면 CPU 폭주를 같이 막을 수 있습니다.

마무리

Go의 select는 강력하지만, 닫힌 채널default 폴링, 타이머 생성 남발이 결합되면 아주 쉽게 CPU 100% 무한루프가 됩니다.

가장 빠른 해결은 다음 한 줄에 가깝습니다.

  • 수신은 항상 v, ok := <-ch로 받고 !ok를 처리한다

여기에 default 제거(또는 Ticker로 제한)까지 적용하면, “select 무한루프” 류 장애는 대부분 1분 안에 진압 가능합니다.