- Published on
Go select 무한루프 CPU 100% 버그 1분 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
운영 중인 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는 블로킹이 아니라 바로 리턴하며, ok는 false가 됩니다. 이 상태를 처리하지 않으면 루프가 쉬지 않고 돕니다.
재현 코드(문제)
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 케이스가 바쁜 루프를 만든다
select에 default가 있으면, 준비된 케이스가 없을 때 즉시 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분 해결” 체크리스트
장애 상황에서 아래 순서로 보면 빠릅니다.
select에서 수신하는 채널이 닫혔을 가능성- 수신부를
v, ok := <-ch로 바꾸고!ok처리 - 여러 채널이면 닫힌 채널을
nil로 바꿔서 제거
- 수신부를
select에default가 있는지- 있으면 제거하거나
Ticker로 주기 제한 - “안 막히게 하려고 default를 넣었다”는 의도가 많지만, 그 순간 폴링이 됩니다
- 있으면 제거하거나
- 루프에서
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분 안에 진압 가능합니다.