Published on

Go select 기본값이 CPU 100%? 공정 대기 패턴

Authors

서버나 워커를 Go로 작성하다 보면 select 문에 default를 넣는 순간 CPU 사용률이 급상승하는 현상을 종종 만납니다. 로그도 조용하고, 락 경합도 없고, GC도 특별히 튀지 않는데 프로세스가 코어를 꽉 채우는 케이스죠.

이 글은 왜 이런 일이 생기는지(언어 레벨 동작), 그리고 바쁜 대기 없이도 응답성을 유지하는 공정 대기(fair wait) 패턴을 코드로 정리합니다.

참고로 이런 류의 문제는 런타임이나 이벤트 루프를 다루는 다른 생태계에서도 자주 등장합니다. 예를 들어 Rust 비동기 런타임에서도 비슷한 형태의 “돌아가는 루프”가 패닉이나 과부하의 원인이 되곤 하는데, 원인 진단 관점은 아래 글도 함께 보면 도움이 됩니다.

select + default가 CPU를 태우는 이유

Go의 select는 준비된(case가 즉시 진행 가능한) 통신이 없으면 블로킹합니다. 그런데 default가 있으면 상황이 바뀝니다.

  • 준비된 case가 있으면 그 중 하나를 실행(여러 개면 의사난수로 선택)
  • 준비된 case가 없으면 default즉시 실행

즉, 아래 코드는 “이벤트가 없으면 잠깐 쉬기”가 아니라 “이벤트가 없으면 즉시 다음 루프로”를 의미합니다.

for {
	select {
	case msg := <-ch:
		_ = msg
	default:
		// 아무 것도 안 함
	}
}

default는 블로킹을 제거하는 장치라서, 루프가 가능한 한 빨리 반복됩니다. 결과적으로:

  • 고루틴은 계속 runnable 상태
  • 스케줄러는 계속 실행 기회를 줌
  • 시스템 콜로 내려가 잠드는 지점이 없음
  • 결국 코어 하나를 100%에 가깝게 사용

이 현상은 “Go select의 기본값이 CPU 100%를 만든다”기보다, 바쁜 대기(busy wait) 를 만들어서 생깁니다.

가장 나쁜 패턴: 폴링 루프 + 빈 default

아래와 같은 폴링은 특히 위험합니다.

  • 소켓/채널/상태를 감시
  • 없으면 default로 빠져서 다음 반복
  • 반복마다 로그나 메트릭까지 찍으면 더 악화
for {
	select {
	case <-stop:
		return
	case ev := <-events:
		handle(ev)
	default:
		// 여기서 상태를 계속 확인하거나
		// 공유 변수를 읽고 조건을 검사하는 코드를 넣으면
		// 코어를 태우는 폴링이 된다.
		if ready() {
			doWork()
		}
	}
}

이 코드는 “이벤트가 없을 때도 일을 할 수 있다”라는 의도로 작성되는 경우가 많지만, 실제로는 대기 비용이 0이라 무한히 회전합니다.

해결 1: default를 제거하고 블로킹으로 설계하기

가장 좋은 해결은 default를 없애고, 필요한 이벤트를 모두 채널로 모델링하는 것입니다.

for {
	select {
	case <-ctx.Done():
		return
	case ev := <-events:
		handle(ev)
	case job := <-jobs:
		do(job)
	}
}

이렇게 하면 이벤트가 없을 때 고루틴은 sleep 상태로 들어가고 CPU를 사용하지 않습니다. “일이 있을 때만 깨어나는” 구조가 됩니다.

하지만 현실적으로는 다음 같은 이유로 default를 넣고 싶어질 때가 있습니다.

  • 주기적으로 housekeeping을 해야 함
  • 큐가 비었을 때도 상태를 점검해야 함
  • 외부 라이브러리가 채널 이벤트를 제공하지 않음

이때는 default 대신 의도적으로 잠드는 지점을 넣어야 합니다.

해결 2: time.Sleep로 숨을 쉬게 하기(가장 단순)

가장 단순한 응급 처치입니다. 폴링이 필요하다면 최소한의 슬립을 둡니다.

for {
	select {
	case <-ctx.Done():
		return
	case ev := <-events:
		handle(ev)
	default:
		// 폴링 간격을 둬서 busy wait 방지
		time.Sleep(5 * time.Millisecond)
	}
}

장점은 쉽다는 것, 단점은:

  • 응답성이 슬립 시간만큼 늦어질 수 있음
  • 부하가 높을 때 불필요한 슬립이 들어갈 수 있음
  • 공정성 측면에서 이벤트 처리 우선순위가 애매해질 수 있음

그래서 보통은 Sleep보다 TickerTimer를 권장합니다.

해결 3: Ticker로 주기 작업을 분리하기

주기 작업이 목적이라면 default가 아니라 Ticker 채널을 case로 추가합니다.

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

for {
	select {
	case <-ctx.Done():
		return
	case ev := <-events:
		handle(ev)
	case <-ticker.C:
		housekeeping()
	}
}

이 구조는 다음을 만족합니다.

  • 이벤트가 없으면 블로킹
  • 주기 작업이 있을 때만 깨어남
  • default가 없어 CPU를 태우지 않음

또한 select는 준비된 case가 여러 개면 랜덤 선택을 하므로, 특정 case만 계속 굶는(starvation) 상황이 줄어듭니다.

해결 4: 공정 대기 패턴 1 - 타이머 기반 유휴 대기(idle wait)

“이벤트가 없으면 잠깐 기다리되, 이벤트가 오면 즉시 반응”을 만들고 싶다면 유휴 타이머를 둡니다.

핵심은 default로 즉시 루프를 돌지 말고, 유휴 상태에서만 타이머를 걸어 블로킹하게 만드는 것입니다.

idle := 10 * time.Millisecond

timer := time.NewTimer(idle)
// 처음에는 바로 대기하지 않도록 멈춰둔다
if !timer.Stop() {
	select {
	case <-timer.C:
	default:
	}
}

for {
	select {
	case <-ctx.Done():
		return

	case ev := <-events:
		handle(ev)
		// 이벤트를 처리했으니 유휴 타이머는 필요 없음
		if !timer.Stop() {
			select {
			case <-timer.C:
			default:
			}
		}

	case <-timer.C:
		// 유휴 시간이 지나면 할 작업
		pollOrCheck()
		// 다음 유휴 대기를 다시 설정
		timer.Reset(idle)

	default:
		// default를 쓰고 싶다면, 여기서는 즉시 루프를 돌지 말고
		// 유휴 타이머를 "켜서" 블로킹 상태로 유도한다.
		timer.Reset(idle)
		select {
		case <-ctx.Done():
			return
		case ev := <-events:
			handle(ev)
		case <-timer.C:
			pollOrCheck()
		}
	}
}

위 코드는 다소 복잡해 보이지만 의도는 명확합니다.

  • 이벤트가 있으면 즉시 처리
  • 이벤트가 없으면 유휴 타이머로 잠듦
  • 유휴 시간이 지나면 점검 작업 수행

실무에서는 이 패턴을 더 단순화해서, 아예 default를 제거하고 timer.C를 case로 넣는 쪽으로 정리하는 편이 안전합니다.

해결 5: 공정 대기 패턴 2 - 지수 백오프(backoff)로 폴링 비용 제어

외부 시스템 상태를 폴링해야 하는 경우(예: 파일 존재, 외부 API 준비, 락 획득 시도 등)에는 고정 슬립보다 백오프가 더 안정적입니다.

  • 처음엔 짧게 기다려 빠른 성공을 노림
  • 계속 실패하면 점점 간격을 늘려 CPU와 외부 부하를 줄임
  • 성공하면 다시 초기화
min := 5 * time.Millisecond
max := 500 * time.Millisecond
backoff := min

for {
	select {
	case <-ctx.Done():
		return
	default:
		ok := tryAcquire()
		if ok {
			backoff = min
			doCritical()
			continue
		}

		time.Sleep(backoff)
		backoff *= 2
		if backoff > max {
			backoff = max
		}
	}
}

이 패턴은 CPU 100%를 막는 것뿐 아니라, 외부 리소스에 대한 “두드리기”를 완화합니다. 특히 컨테이너 환경에서 폴링 루프가 여러 인스턴스로 복제되면 비용이 기하급수로 늘어나는데, 백오프는 이를 크게 줄입니다.

select 공정성(fairness)과 “공정 대기”의 의미

Go의 select는 여러 case가 동시에 준비되면 의사난수로 하나를 선택합니다. 이 덕분에 단순한 형태의 편향은 줄어들지만, 다음은 여전히 주의해야 합니다.

  • 특정 case가 항상 준비되어 있으면 다른 case가 상대적으로 덜 선택될 수 있음
  • default는 준비 여부와 무관하게 즉시 실행되므로, 사실상 다른 case를 “굶길” 수 있음

여기서 말하는 공정 대기 패턴은 다음 목표를 갖습니다.

  • 이벤트가 없을 때는 블로킹해서 CPU를 쉬게 함
  • 이벤트가 생기면 즉시 깨어나 처리
  • 주기 작업이나 폴링이 필요하면 타이머 채널로 모델링
  • 실패 반복은 백오프로 비용을 제어

즉, 공정성은 단지 select의 랜덤 선택에 기대는 것이 아니라, 대기 자체를 채널 기반으로 만들고, 즉시 실행되는 분기를 최소화하는 설계에서 나옵니다.

디버깅 체크리스트: CPU 100%일 때 무엇을 볼까

  1. default가 있는 select 루프가 있는지 검색
    • 특히 for { select { ... default: } } 형태
  2. 루프 안에서 time.Sleep이나 블로킹 I/O가 전혀 없는지 확인
  3. pprof로 hot path 확인
    • go tool pprof에서 상위 함수가 특정 루프에 몰리면 거의 확정
  4. 로그/메트릭이 루프마다 발생하는지 확인
    • 출력 자체가 병목이 되거나, 더 많은 CPU를 사용

비슷한 맥락으로 런타임/이벤트 루프 문제를 다루는 글로는 아래도 참고할 만합니다.

컨테이너에서 CPU 스로틀링과 결합되면, 바쁜 대기는 지연과 오류로 체감될 수 있습니다.

실전 권장 템플릿: 이벤트 루프에 타이머를 섞는 안전한 형태

마지막으로 실무에서 재사용하기 쉬운 형태를 정리합니다.

  • 이벤트 처리 채널
  • 종료 컨텍스트
  • 주기 작업 티커
  • 필요 시 백오프 폴링
type Worker struct {
	events <-chan Event
}

func (w *Worker) Run(ctx context.Context) {
	ticker := time.NewTicker(1 * time.Second)
	defer ticker.Stop()

	min := 10 * time.Millisecond
	max := 500 * time.Millisecond
	backoff := min

	for {
		select {
		case <-ctx.Done():
			return

		case ev := <-w.events:
			handle(ev)
			backoff = min // 이벤트가 오면 폴링 백오프 초기화

		case <-ticker.C:
			housekeeping()

		case <-time.After(backoff):
			// 폴링이 꼭 필요할 때만 사용
			if tryPoll() {
				backoff = min
			} else {
				backoff *= 2
				if backoff > max {
					backoff = max
				}
			}
		}
	}
}

여기서 time.After는 호출마다 타이머를 생성하므로, 매우 고빈도 루프에서는 time.NewTimer를 재사용하는 편이 더 좋습니다. 다만 핵심 메시지는 같습니다.

  • default로 즉시 회전하지 말 것
  • “기다림”을 타이머 채널로 모델링할 것

정리

  • selectdefault를 넣으면 준비된 case가 없을 때도 즉시 실행되어 바쁜 대기가 됩니다.
  • CPU 100%는 Go 런타임 문제가 아니라, 대부분 default가 들어간 폴링 루프 설계 문제입니다.
  • 해결은 default 제거, TickerTimer 도입, 필요 시 백오프 적용으로 요약됩니다.
  • 공정 대기 패턴의 핵심은 “이벤트가 없을 때는 반드시 블로킹 지점을 갖게 하라”입니다.