Published on

Go select 공정성·기아 해결 - 랜덤화 패턴

Authors

서버나 워커 풀을 Go로 만들다 보면 select 를 이용해 여러 채널을 동시에 기다리는 구간이 자주 등장합니다. 문제는 select 가 “언제나 공정하게(fair) 분배된다”는 보장을 제공하지 않는다는 점입니다. 특정 케이스가 준비 상태(ready)로 자주 유지되면, 다른 케이스가 장시간 선택되지 않는 기아(starvation)가 실제로 발생할 수 있습니다.

이 글은 select 공정성 이슈가 왜 생기는지, 어떤 상황에서 기아가 체감되는지, 그리고 랜덤화(randomization) 를 중심으로 공정성을 개선하는 실무 패턴을 정리합니다. 동시성 제어가 결국 “경합과 레이스를 다루는 기술”이라는 관점에서, 비슷한 문제를 다루는 글로는 GitHub Actions 동시 실행 경합으로 캐시 깨질 때도 함께 보면 사고방식에 도움이 됩니다.

Go select 의 공정성: 무엇이 보장되고 무엇이 아닌가

Go 스펙 관점에서 select 는 다음 성질을 가집니다.

  • 준비된 케이스가 하나면 그 케이스가 실행됩니다.
  • 준비된 케이스가 여러 개면, 그중 하나가 선택됩니다.
  • 하지만 “모든 케이스가 장기적으로 동일 확률로 선택된다” 같은 강한 공정성 보장은 계약에 포함돼 있지 않습니다.

런타임 구현은 여러 준비 케이스 중 하나를 고르기 위해 내부적으로 섞는(shuffle) 과정을 거치지만, 이것만으로는 실무에서 기대하는 공정성을 항상 만족하지 못합니다. 특히 아래 조건이 겹치면 기아가 눈에 띄게 발생합니다.

  • 어떤 채널은 항상 준비 상태에 가깝다(버퍼가 크고 생산자가 빠르다, 혹은 닫힌 채널을 계속 읽는다 등).
  • 다른 채널은 준비 상태가 되더라도 짧은 시간만 ready 이고 다시 블록된다.
  • 루프가 매우 빠르게 돈다(바쁜 루프) 혹은 다른 고루틴이 스케줄링에서 불리하다.

기아가 실제로 생기는 대표 시나리오

1) “항상 준비된 채널”이 있는 경우

버퍼가 큰 채널에 이벤트가 계속 쌓이거나, 닫힌 채널을 읽는 케이스가 존재하면 해당 케이스는 사실상 항상 ready 입니다.

package main

import (
	"fmt"
	"time"
)

func main() {
	always := make(chan struct{})
	close(always) // 닫힌 채널은 수신이 항상 즉시 성공

	rare := make(chan struct{})
	go func() {
		ticker := time.NewTicker(10 * time.Millisecond)
		defer ticker.Stop()
		for range ticker.C {
			rare <- struct{}{}
		}
	}()

	var a, r int
	deadline := time.After(200 * time.Millisecond)
	for {
		select {
		case <-always:
			a++
		case <-rare:
			r++
		case <-deadline:
			fmt.Println("always:", a, "rare:", r)
			return
		}
	}
}

이 코드는 극단적이지만, 실무에서는 “항상 ready 에 가까운 입력”이 존재하는 순간 공정성 문제가 비슷한 형태로 나타납니다. 예를 들어 로그 채널, 메트릭 채널, 내부 하트비트 채널이 높은 빈도로 들어오면, 상대적으로 낮은 빈도의 제어(control) 이벤트가 밀릴 수 있습니다.

2) default 를 둔 바쁜 루프

default 는 블로킹을 피하는 대신 CPU를 태우는 바쁜 폴링을 만들기 쉽고, 다른 고루틴이 실행될 기회를 줄여 간접적으로 기아를 악화시킬 수 있습니다.

for {
	select {
	case msg := <-ch:
		_ = msg
	default:
		// do nothing
	}
}

이 패턴은 “논블로킹 수신”이 필요한 특별한 경우가 아니라면 피하는 게 좋습니다.

해결 전략 개요: 랜덤화는 ‘도구’이고, 구조가 ‘해결’이다

공정성 문제는 단순히 난수를 넣는다고 항상 해결되지 않습니다. 하지만 랜덤화는 다음 상황에서 매우 유용합니다.

  • 여러 입력을 “대충 공정하게” 섞고 싶다
  • 완전한 라운드로빈 상태를 유지하기 어렵다
  • 특정 입력이 계속 우선되는 편향을 깨고 싶다

다만 공정성을 강하게 만들려면 구조적 개선이 필요합니다.

  • 랜덤화된 선택 순서로 편향 줄이기
  • 라운드로빈 인덱스로 결정적 공정성 강화
  • 큐(버퍼)로 입력을 평탄화하고 소비 정책을 분리
  • 우선순위 + 에이징(aging) 으로 제어 이벤트 굶김 방지

아래는 실무에서 바로 가져다 쓸 수 있는 패턴들입니다.

패턴 1: 케이스 순서를 랜덤하게 섞는 reflect.Select

Go의 select 문 자체는 케이스를 동적으로 만들 수 없습니다. 채널 개수가 가변이거나, “매 루프마다 케이스 순서를 섞고 싶다”면 reflect.Select 가 현실적인 선택지입니다.

핵심 아이디어는 다음입니다.

  • reflect.SelectCase 슬라이스를 만든다
  • 매 루프마다 슬라이스를 rand.Shuffle 로 섞는다
  • reflect.Select 로 하나를 선택한다
package main

import (
	"fmt"
	"math/rand"
	"reflect"
	"time"
)

func main() {
	rand.New(rand.NewSource(time.Now().UnixNano()))

	chs := []chan int{make(chan int, 1), make(chan int, 1), make(chan int, 1)}
	for i := range chs {
		chs[i] <- i
	}

	cases := make([]reflect.SelectCase, 0, len(chs))
	for _, ch := range chs {
		cases = append(cases, reflect.SelectCase{
			Dir:  reflect.SelectRecv,
			Chan: reflect.ValueOf(ch),
		})
	}

	// 여러 번 뽑아보며 편향이 줄어드는지 확인
	count := make([]int, len(chs))
	for iter := 0; iter < 10000; iter++ {
		rand.Shuffle(len(cases), func(i, j int) { cases[i], cases[j] = cases[j], cases[i] })
		i, v, ok := reflect.Select(cases)
		if !ok {
			continue
		}
		count[v.Int()]++
		// 다시 채워 넣어 항상 ready 상태를 유지
		chs[v.Int()] <- int(v.Int())
		_ = i
	}

	fmt.Println("counts:", count)
}

장점

  • 채널 개수가 동적이어도 적용 가능
  • “항상 0번 채널이 먼저 검사된다” 같은 구조적 편향을 줄임

단점

  • reflect 기반이라 비용이 있고, 타입 안정성이 떨어짐
  • 매우 핫한 경로에서는 성능 부담이 될 수 있음

성능이 민감하다면 아래의 라운드로빈/큐잉 패턴이 더 적합합니다.

패턴 2: 고정 개수 채널에서 “랜덤 우선순위 토글”로 편향 깨기

채널 개수가 2개나 3개처럼 고정이라면, reflect 없이도 간단히 랜덤화를 넣을 수 있습니다.

예를 들어 채널 2개를 번갈아 우선 검사하도록 “동전 던지기”로 순서를 바꿉니다.

package main

import (
	"math/rand"
	"time"
)

func loop(a, b <-chan int) int {
	rand.New(rand.NewSource(time.Now().UnixNano()))

	for {
		if rand.Intn(2) == 0 {
			select {
			case v := <-a:
				return v
			case v := <-b:
				return v
			}
		} else {
			select {
			case v := <-b:
				return v
			case v := <-a:
				return v
			}
		}
	}
}

이 방식은 “완전한 공정성”을 보장하진 않지만, 특정 케이스가 지속적으로 먼저 평가되는 편향을 줄여서 기아 확률을 낮춥니다.

주의할 점은 난수 생성기를 루프 안에서 매번 새로 만들지 말고(위 코드는 예시), 보통은 패키지 레벨 rand 를 쓰거나 rand.New 를 한 번만 만들어 재사용하는 게 낫습니다.

패턴 3: 라운드로빈 셀렉터(결정적 공정성)

랜덤화보다 더 강한 공정성이 필요하면 라운드로빈이 정답인 경우가 많습니다. 핵심은 “매번 시작 채널을 한 칸씩 밀어가며 검사”하는 것입니다.

Go의 select 는 동적 케이스가 안 되므로, 보통은 다음 중 하나로 구현합니다.

  • 입력 고루틴을 채널별로 두고 중앙 큐로 합친다(추천)
  • 채널 개수가 작고 고정이면 switch 로 케이스를 회전시킨다

여기서는 실무에서 자주 쓰는 “중앙 큐로 합치기”를 보여줍니다.

package main

import (
	"context"
	"sync"
)

type FanIn[T any] struct {
	out chan T
	wg  sync.WaitGroup
}

func NewFanIn[T any](buf int) *FanIn[T] {
	return &FanIn[T]{out: make(chan T, buf)}
}

func (f *FanIn[T]) Add(ctx context.Context, in <-chan T) {
	f.wg.Add(1)
	go func() {
		defer f.wg.Done()
		for {
			select {
			case <-ctx.Done():
				return
			case v, ok := <-in:
				if !ok {
					return
				}
				select {
				case <-ctx.Done():
					return
				case f.out <- v:
				}
			}
		}
	}()
}

func (f *FanIn[T]) Out() <-chan T { return f.out }

func (f *FanIn[T]) CloseWhenDone() {
	go func() {
		f.wg.Wait()
		close(f.out)
	}()
}

이제 소비자는 select 로 여러 채널을 직접 고르지 않고, 하나의 큐에서만 읽습니다. 공정성은 각 입력 고루틴의 스케줄링과 out 채널 버퍼 정책으로 이동하며, “특정 케이스가 계속 select 에서 이긴다” 같은 형태의 기아는 크게 줄어듭니다.

이 패턴은 레이트리밋/백오프처럼 “입력 폭주를 평탄화”해야 하는 문제에서도 자주 등장합니다. 관련 사고방식은 OpenAI API 429 RateLimit 재시도·백오프 실무와도 통합니다.

패턴 4: 우선순위 채널과 에이징(aging)으로 기아 방지

실무에서는 공정성보다 “제어 이벤트는 반드시 빨리 처리” 같은 우선순위 요구가 더 흔합니다. 예를 들어:

  • controlCh 는 취소/리로드/드레인 같은 운영 명령
  • dataCh 는 대량의 데이터 이벤트

이때 흔한 실수는 우선순위를 주려고 아래처럼 2단 select 를 쓰는 것입니다.

select {
case c := <-controlCh:
	_ = c
default:
	select {
	case d := <-dataCh:
		_ = d
	case c := <-controlCh:
		_ = c
	}
}

이 방식은 control 우선 처리에는 도움이 되지만, 반대로 특정 조건에서 data 가 굶을 수도 있습니다.

대안으로 “에이징”을 넣을 수 있습니다.

  • 평소에는 control 우선
  • 하지만 data 가 오래 대기했으면 확률적으로 혹은 카운터 기반으로 data 를 우대
type Scheduler struct {
	dataBudget int
}

func (s *Scheduler) Loop(controlCh <-chan func(), dataCh <-chan int) {
	for {
		// control을 자주 처리하되, data도 주기적으로 허용
		if s.dataBudget <= 0 {
			select {
			case f := <-controlCh:
				f()
				s.dataBudget = 10 // control 1번 처리 후 data 10개 허용
			case v := <-dataCh:
				_ = v
			}
			continue
		}

		select {
		case f := <-controlCh:
			f()
			s.dataBudget = 10
		case v := <-dataCh:
			_ = v
			s.dataBudget--
		}
	}
}

이건 랜덤화가 아니라 “예산(budget) 기반 공정성”이지만, 결과적으로 기아를 강하게 막아줍니다. 랜덤화를 섞고 싶다면 dataBudget 을 고정 값 대신 범위 랜덤으로 주는 것도 가능합니다.

랜덤화 패턴을 적용할 때의 체크리스트

1) 공정성의 정의를 먼저 정하기

  • 장기적으로 처리 비율이 비슷하면 되는가
  • 최대 지연시간(예: 제어 이벤트 p99 지연)을 제한해야 하는가
  • 특정 입력은 절대 굶으면 안 되는가

정의에 따라 랜덤화만으로 충분할 수도, 라운드로빈/큐잉이 필요할 수도 있습니다.

2) “항상 ready” 케이스를 제거하거나 격리하기

  • 닫힌 채널을 select 에 계속 두지 말기
  • 버퍼가 무한정 쌓이는 구조라면 생산자 측에서 드롭/샘플링/배압(backpressure) 고려

3) default 는 신중히

default 는 공정성 문제 이전에 CPU 사용률과 스케줄링에 악영향을 줍니다. 꼭 필요하면 time.Sleep 같은 완충을 넣거나, 애초에 블로킹 구조로 바꾸는 게 좋습니다.

4) 관측 가능성(메트릭)으로 기아를 확인하기

기아는 “안 일어나면 모르는” 종류의 버그입니다. 다음을 계측하면 조기 발견이 쉽습니다.

  • 채널별 처리 카운트
  • 채널별 큐 길이(버퍼 사용량)
  • 이벤트 대기 시간 히스토그램

이런 디버깅/관측은 분산 워크플로우 문제를 추적할 때와 비슷한 접근이 필요합니다. 더 넓은 맥락의 디버깅 관점은 Kotlin+Temporal로 분산 트랜잭션 워크플로우 디버깅도 참고할 만합니다.

언제 어떤 패턴을 쓰면 좋은가

  • 채널 개수가 동적이고 공정성 편향을 완화하고 싶다: reflect.Select + rand.Shuffle
  • 채널 개수가 작고 고정이며 간단히 편향만 깨고 싶다: “케이스 순서 토글” 랜덤화
  • 공정성을 결정적으로 보장하고 싶다: 팬인(fan-in)으로 단일 큐화, 또는 라운드로빈 스케줄러
  • 우선순위가 필요하지만 기아는 막아야 한다: 우선순위 + 에이징(예산/확률 혼합)

마무리

Go의 select 는 강력하지만, 공정성과 기아 문제는 개발자가 직접 정책을 설계해야 하는 영역입니다. 랜덤화 패턴은 구현 난이도가 낮고 편향을 줄이는 데 효과적이지만, 요구사항이 강해질수록 라운드로빈·큐잉·에이징 같은 구조적 접근이 더 안전합니다.

실무에서는 “정확히 공정해야 한다”보다 “어떤 이벤트도 굶지 않게 하면서, 운영 명령은 빠르게 처리한다” 같은 목표가 많습니다. 그 목표를 수치로 정의하고(지연시간, 처리비율), 계측으로 확인하면서 위 패턴들을 조합하는 것이 가장 현실적인 해법입니다.