"최적 알고리즘은 하나가 아니다"는 걸 데이터로 확인했다. 그럼 상황에 따라 자동으로 바꾸면 되지 않을까?
지난 편에서 6가지 알고리즘을 실측 벤치마크했을 때 얻은 결론은 이거였다.
트래픽 패턴이 다르면 최적 알고리즘도 다르다. 근데 실제 서비스는 배포 시점에 알고리즘 하나를 고정한다. 이 한계를 해결하고 싶었다.
"런타임에 트래픽 상태를 감지하고, 그 상황에 맞는 알고리즘으로 자동 전환한다."
여기에 AI를 붙였다. 단, AI를 런타임 요청 경로에 직접 넣지 않았다.
[K6 벤치마크]
↓
[AI Agent (GPT-4o)] ← 오프라인 분석, 요청 경로 밖
↓
[decision-rules.yml 생성]
↓
[Runtime이 YAML 읽어서 deterministic하게 전환]
Runtime에는 절대 LLM을 호출하지 않는다. AI는 전략을 만드는 역할이고, 실행은 rule-based 엔진이 담당한다. 이유는 명확하다 — LLM 추론 지연은 수백ms~수초인데, 그게 요청 처리 경로에 끼어들면 안 된다.
[Client Requests]
↓
[Adaptive Load Balancer] ← [Rule Config (YAML)]
├─ MetricsCollector (슬라이딩 윈도우 10s)
├─ PatternAnalyzer (TrafficState 분류)
├─ DecisionEngine (state → algorithm 매핑)
└─ Algorithm Executor (실제 분배)
↓
[Backend Servers × 4, port 5001~5004]
--- Offline Feedback Loop ---
[K6 Benchmark] → [Metrics Log (JSON)]
↓
[AI Agent (Python + GPT-4o)]
↓
[새 Rule 생성 → POST /alb/rules/reload]
기술 스택은 Java 21 + Spring Boot 3.4, 벤치마크는 K6, AI Agent는 Python + OpenAI GPT-4o다.

"RPS > 500이면 SPIKE"
이 규칙은 평소 RPS가 50인 소규모 서비스에는 너무 높고, 평소 RPS가 5000인 대규모 서비스에는 너무 낮다. threshold는 스케일에 의존한다.
변화율은 스케일에 독립적이다:
double rpsChangeRate = (currentRps - previousRps) / previousRps;
// +1.0 = 100% 증가 → SPIKE
// +0.3 = 30% 증가 → GRADUAL_INCREASE
서비스 규모가 어떻든 "이전 대비 100% 이상 급증"은 동일한 기준으로 감지된다.
latencySlope는 더 나아가서 최근 N개 윈도우에 선형 회귀를 돌린다. 단순히 "마지막 값이 올랐는지"만 보면 노이즈에 취약한데, 기울기(slope)를 구하면 일시적인 스파이크를 무시하고 진짜 추세를 잡을 수 있다.
// 최소자승법으로 latency 추세의 기울기 계산 (ms/window)
double slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
에어컨의 온도 제어를 생각해보자.
26°C 설정
온도 > 27°C → 냉방 ON (상향 임계값)
온도 < 25°C → 냉방 OFF (하향 임계값)
→ 25~27°C 구간에서는 현재 상태 유지
임계값을 딱 하나만 쓰면 26°C 주변에서 ON/OFF를 끊임없이 반복한다(채터링). Hysteresis는 이걸 막는 제어 이론의 개념이다.
이 프로젝트에서는 "새 TrafficState가 N개 연속 윈도우 동안 유지돼야 전환"으로 구현했다.
// 연속 상태 카운터
if (newState.equals(pendingState)) {
consecutiveCount++;
} else {
pendingState = newState;
consecutiveCount = 1; // 다른 state 오면 리셋
}
// SPIKE: 2창 연속, 나머지: 3창 연속
int required = (newState == SPIKE) ? spikeSustainedWindows : sustainedWindows;
return consecutiveCount >= required;
수치 예시 (sustained_windows=3):
윈도우1: SPIKE 감지 → count=1 → 전환 안 함
윈도우2: HIGH_STABLE → count 리셋
윈도우3: SPIKE 감지 → count=1 → 전환 안 함 (노이즈)
윈도우4: SPIKE 감지 → count=2 → 전환 안 함
윈도우5: SPIKE 감지 → count=3 → 전환! ✅
SPIKE만 2창(20초)으로 예외 처리한 이유: 3창(30초)을 기다리면 이미 서비스 장애가 발생했을 수 있다.
Hysteresis 외에도 전환을 막는 게이트가 4개 더 있다.
public synchronized boolean shouldSwitch(
TrafficState newState, TrafficState currentState, double confidence) {
if (newState == currentState) return false; // 1. 동일 상태
if (confidence < minConfidence) return false; // 2. 신뢰도 부족
if (isInCooldown()) return false; // 3. 쿨다운 중
if (recentSwitchCount() >= maxSwitchesPerMinute) return false; // 4. 분당 횟수 초과
if (consecutiveCount < required) return false; // 5. Hysteresis
return true;
}
각 게이트의 역할:
| State | 판단 기준 |
|---|---|
LOW_TRAFFIC | RPS < 5 |
HIGH_STABLE | RPS 높고 변화율 < 30%, latency 안정 |
SPIKE | RPS 변화율 > 100% |
OVERLOADED_NODE | 특정 서버 error rate > 10% or avg latency > 500ms |
GRADUAL_INCREASE | RPS 변화율 30~100% 구간 지속 |
PatternAnalyzer는 이 5가지를 우선순위 순서로 검사한다.
// 1순위: 특정 서버 이상 (가장 긴급)
if (findOverloadedServer(latest) != null) return OVERLOADED_NODE;
// 2순위: 저트래픽 (알고리즘 선택 의미 없음)
if (currentRps < lowRpsThreshold) return LOW_TRAFFIC;
// 3순위: 급증
if (rpsDerivative >= spikeThreshold) return SPIKE;
// 4순위: 점진적 증가
if (rpsDerivative >= gradualThreshold) return GRADUAL_INCREASE;
// 5순위: 안정 (기본값)
return HIGH_STABLE;
SPIKE가 오면서 특정 서버도 과부하가 걸리는 경우, 더 긴급한 OVERLOADED_NODE를 먼저 처리하는 방식이다.
confidence 계산은 임계값에서 얼마나 벗어났는지로 결정한다:
// SPIKE: rpsDerivative가 클수록 확신도 높음
double confidence = Math.min(0.99, 0.7 + rpsDerivative * 0.15);
// derivative=1.0 → confidence=0.85
// derivative=2.0 → confidence=0.99
알고리즘 전환이 런타임 요청 처리와 동시에 일어나는데, Lock을 최소화하면서 안전성을 보장하는 게 핵심이었다.
| 구조 | 적용 위치 | 이유 |
|---|---|---|
AtomicInteger CAS | RR 인덱스, LC 연결 수 | 단일 카운터 원자적 연산 |
volatile | 알고리즘 참조, 서버 건강 상태 | 즉시 가시성 보장 |
ConcurrentHashMap | IP Hash 캐시 | 키별 독립 락으로 경합 최소화 |
CopyOnWriteArrayList | 서버 목록 | 읽기 빈도 >> 쓰기 빈도 |
ConcurrentLinkedQueue | 메트릭 이벤트 버퍼 | Non-blocking 요청 기록 |
volatile이 충분한 경우와 부족한 경우를 구분하는 게 중요하다.
// ServerPool: 알고리즘 전환
private volatile LoadBalancer currentBalancer;
// 전환: 단일 참조 교체 (write once) → volatile로 충분
public void switchAlgorithm(AlgorithmType type) {
currentBalancer = balancers.get(type); // volatile write
}
// 요청: 기존 또는 새 인스턴스 중 하나만 보면 됨 → volatile read
public BackendServer selectServer(String clientInfo) {
return currentBalancer.select(servers, clientInfo);
}
volatile이 부족한 경우: count++처럼 read-modify-write 복합 연산. 이건 AtomicInteger.compareAndSet()으로 처리해야 한다.
public record DecisionResult(
TrafficState state,
AlgorithmType selectedAlgorithm,
double confidence,
String reason, // 핵심
Instant timestamp,
MetricsSnapshot snapshot
) {}
실제로 기록되는 reason 예시:
[Decision] state=SPIKE algorithm=LEAST_CONNECTIONS confidence=0.87
reason="RPS derivative=1.52 (threshold=1.0) → SPIKE detected.
LC selected: benchmark showed 35% lower p95 latency vs RR in SPIKE pattern.
Switch approved: cooldown=OK, sustained=2/2 windows."
이 로그가 쌓이면 AI Agent의 post_analyzer.py 입력으로 활용된다. "이 전환이 적절했는지" 사후 평가하는 데 쓰인다.
다음 편에서는 AI Feedback Loop 구체적인 구현, GPT-4o 프롬프트 설계, 자동 롤백 로직, 벤치마크 결과를 정리한다.