들어가며
지난 5월 신규 트렌드 정리에서 다섯 가지 흐름 중 "가장 먼저 도입해야 할 안전망"으로 비용 자가 관리를 꼽았습니다. 다른 트렌드(장기 자율 실행·영구 메모리·A2A·Computer Use)를 도입할 때마다 비용은 기하급수적으로 늡니다. 자가 관리가 안 깔려 있으면 Uber $3.4B 사고 같은 패턴이 자기 회사에서 그대로 재현됩니다.
오늘은 이 추상적인 "자가 관리"를 실제 코드로 풀어봅니다. Spring Boot 4 + Resilience4j 조합으로 다음 세 가지 패턴을 구현합니다.
- 모델 캐스케이드 - 작업 난이도 판정 후 Haiku → Sonnet → Opus 단계별 호출
- 예산 가드 - 일/시간 단위 비용 한도 + 한도 초과 시 자동 다운그레이드/차단
- 캐시 우선 - 동일 질의 재호출 방지로 토큰 비용 절감
이 3가지가 결합되면 동일한 워크로드 비용이 평균 40~70% 줄어드는 게 일반적입니다. 단, 정확도가 떨어지지 않게 "언제 다운그레이드해도 안전한가"의 판단이 핵심이고, 이 부분의 코드 패턴을 집중적으로 다룹니다.
1. 전체 아키텍처
비용 자가 관리 에이전트의 요청 처리 흐름은 다음과 같습니다.
클라이언트 요청
│
▼
[1] 캐시 조회 ─── 히트 ──► 응답 반환 (LLM 호출 X)
│
└── 미스
▼
[2] 작업 복잡도 판정 (간단한 분류기)
│
▼
[3] 예산 게이트 체크
│ - 잔여 예산 충분? → 복잡도 기반 모델 선택
│ - 80%+ 소진? → 강제 Haiku
│ - 100% 소진? → 거부 또는 큐잉
▼
[4] LLM 호출 (Resilience4j: 회로차단 + 재시도 + bulkhead)
│
▼
[5] 비용 누적 + 캐시 저장
▼
응답 반환
요구 의존성
// build.gradle.kts
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.ai:spring-ai-anthropic-spring-boot-starter:1.0.0")
// Resilience4j
implementation("io.github.resilience4j:resilience4j-spring-boot3:2.2.0")
implementation("io.github.resilience4j:resilience4j-circuitbreaker")
implementation("io.github.resilience4j:resilience4j-ratelimiter")
implementation("io.github.resilience4j:resilience4j-bulkhead")
// Micrometer (관측)
implementation("io.micrometer:micrometer-registry-prometheus")
}
2. 가격 모델 정의
비용 계산을 하려면 모델별 가격을 코드 안에 들고 있어야 합니다. Anthropic 가격은 자주 바뀌지 않지만, 운영용으로는 application.yml에 외부화하는 게 정석입니다.
# application.yml
llm:
models:
haiku-4.5:
tier: 1
input-per-mtok: 0.80 # USD per 1M input tokens
output-per-mtok: 4.00
max-tokens: 200000
sonnet-4.6:
tier: 2
input-per-mtok: 3.00
output-per-mtok: 15.00
max-tokens: 200000
opus-4.7:
tier: 3
input-per-mtok: 15.00
output-per-mtok: 75.00
max-tokens: 200000
budget:
daily-usd: 50
hourly-usd: 5
warn-threshold: 0.5 # 50% 소진 시 다운그레이드 시작
hard-threshold: 0.8 # 80% 소진 시 강제 최저티어
block-threshold: 1.0 # 100% 소진 시 차단
@ConfigurationProperties(prefix = "llm")
public record LlmProperties(
Map<String, ModelPrice> models,
Budget budget
) {
public record ModelPrice(
int tier,
BigDecimal inputPerMtok,
BigDecimal outputPerMtok,
int maxTokens
) {
public BigDecimal estimateCost(int inputTokens, int outputTokens) {
BigDecimal in = inputPerMtok
.multiply(BigDecimal.valueOf(inputTokens))
.divide(BigDecimal.valueOf(1_000_000), 6, RoundingMode.HALF_UP);
BigDecimal out = outputPerMtok
.multiply(BigDecimal.valueOf(outputTokens))
.divide(BigDecimal.valueOf(1_000_000), 6, RoundingMode.HALF_UP);
return in.add(out);
}
}
public record Budget(
BigDecimal dailyUsd,
BigDecimal hourlyUsd,
double warnThreshold,
double hardThreshold,
double blockThreshold
) {}
}
3. 작업 복잡도 판정기
모델 캐스케이드의 핵심은 "이 요청에 어느 모델이면 충분한가"의 판정입니다. 가장 단순하면서 실무에서 잘 통하는 방식 두 가지를 결합합니다.
방법 1: 휴리스틱 (빠르고 비용 0)
@Component
public class HeuristicComplexityClassifier {
public ComplexityScore classify(String prompt, Map<String, Object> context) {
int score = 0;
// 길이 기반
int len = prompt.length();
if (len > 5000) score += 2;
else if (len > 1500) score += 1;
// 키워드 기반
if (containsAny(prompt, "왜", "분석", "비교", "설계", "리팩토링", "why", "analyze", "design")) score += 2;
if (containsAny(prompt, "요약", "분류", "추출", "번역", "summarize", "classify")) score += 0;
if (containsAny(prompt, "증명", "증명해", "수학", "alg", "proof")) score += 3;
// 컨텍스트 기반
Object files = context.get("attached_files");
if (files instanceof List<?> fl && fl.size() > 5) score += 1;
// 점수 → 등급
if (score <= 1) return ComplexityScore.LOW; // Haiku
if (score <= 3) return ComplexityScore.MEDIUM; // Sonnet
return ComplexityScore.HIGH; // Opus
}
private boolean containsAny(String text, String... keywords) {
String lower = text.toLowerCase();
for (String k : keywords) {
if (lower.contains(k.toLowerCase())) return true;
}
return false;
}
}
public enum ComplexityScore { LOW, MEDIUM, HIGH }
방법 2: 가벼운 LLM 분류 (정확하지만 비용 발생)
@Component
@RequiredArgsConstructor
public class LlmComplexityClassifier {
private final AnthropicChatClient haikuClient; // 항상 Haiku 사용
public ComplexityScore classify(String prompt) {
String systemPrompt = """
너는 사용자 요청의 난이도를 판정하는 분류기다.
출력은 다음 셋 중 하나의 단어만:
- LOW: 단순 분류, 짧은 요약, 키워드 추출, 간단한 번역
- MEDIUM: 코드 리팩토링, 비교 분석, 일반적 글쓰기
- HIGH: 복잡한 추론, 다단계 설계, 수학 증명, 긴 문서 종합
추가 설명 절대 금지.
""";
String result = haikuClient.prompt()
.system(systemPrompt)
.user(prompt)
.options(AnthropicChatOptions.builder().maxTokens(8).build())
.call().content().trim();
return switch (result) {
case "LOW" -> ComplexityScore.LOW;
case "HIGH" -> ComplexityScore.HIGH;
default -> ComplexityScore.MEDIUM;
};
}
}
실무 권장: 두 방법 결합
휴리스틱이 LOW로 명확히 판정한 건 그대로 신뢰. MEDIUM/HIGH 경계는 LLM 분류기로 확정. 이러면 분류 비용이 전체의 5% 이내로 유지되면서도 정확도는 높아집니다.
4. 예산 추적 (Redis 기반)
비용 누적은 원자적으로 갱신해야 합니다. Redis의 INCRBYFLOAT가 가장 안전합니다.
@Component
@RequiredArgsConstructor
public class BudgetTracker {
private final StringRedisTemplate redis;
private final LlmProperties props;
public BudgetState currentState() {
BigDecimal dailySpent = readSpent("daily", today());
BigDecimal hourlySpent = readSpent("hourly", currentHour());
double dailyRatio = dailySpent.divide(
props.budget().dailyUsd(), 4, RoundingMode.HALF_UP
).doubleValue();
double hourlyRatio = hourlySpent.divide(
props.budget().hourlyUsd(), 4, RoundingMode.HALF_UP
).doubleValue();
return new BudgetState(dailySpent, hourlySpent, dailyRatio, hourlyRatio);
}
public void record(BigDecimal cost) {
// 원자적 증가 + TTL 보장
String dKey = "llm:cost:daily:" + today();
String hKey = "llm:cost:hourly:" + currentHour();
redis.opsForValue().increment(dKey, cost.doubleValue());
redis.expire(dKey, Duration.ofHours(48));
redis.opsForValue().increment(hKey, cost.doubleValue());
redis.expire(hKey, Duration.ofHours(2));
}
private BigDecimal readSpent(String scope, String key) {
String val = redis.opsForValue().get("llm:cost:" + scope + ":" + key);
return val != null ? new BigDecimal(val) : BigDecimal.ZERO;
}
private String today() {
return LocalDate.now(ZoneId.of("Asia/Seoul")).toString();
}
private String currentHour() {
return LocalDateTime.now(ZoneId.of("Asia/Seoul"))
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd-HH"));
}
public record BudgetState(
BigDecimal dailySpent,
BigDecimal hourlySpent,
double dailyRatio,
double hourlyRatio
) {
public double maxRatio() {
return Math.max(dailyRatio, hourlyRatio);
}
}
}
왜 Redis인가
- 멀티 인스턴스 동기화: Spring Boot 앱이 3대 떠 있어도 같은 카운터 공유
- 원자성: INCRBYFLOAT는 race condition 없이 누적
- TTL: 일자/시간 단위 키에 자동 만료 → 별도 정리 불필요
5. 모델 라우터 - 핵심 결정 로직
복잡도와 예산 상태를 받아 "어느 모델로 호출할지"를 결정합니다. 이 한 클래스가 비용 자가 관리의 두뇌입니다.
@Component
@RequiredArgsConstructor
public class CostAwareModelRouter {
private final LlmProperties props;
private final BudgetTracker budgetTracker;
public RouteDecision route(ComplexityScore complexity) {
BudgetTracker.BudgetState budget = budgetTracker.currentState();
double maxRatio = budget.maxRatio();
// 1) 100% 소진 - 차단
if (maxRatio >= props.budget().blockThreshold()) {
return RouteDecision.blocked(
"Budget exhausted: daily=%.1f%% hourly=%.1f%%".formatted(
budget.dailyRatio() * 100, budget.hourlyRatio() * 100));
}
// 2) 80% 이상 - 강제 최저티어
if (maxRatio >= props.budget().hardThreshold()) {
return RouteDecision.allowed("haiku-4.5",
"hard-throttle (budget %.0f%%)".formatted(maxRatio * 100));
}
// 3) 50% 이상 - 한 단계 다운그레이드
if (maxRatio >= props.budget().warnThreshold()) {
String model = switch (complexity) {
case LOW -> "haiku-4.5";
case MEDIUM -> "haiku-4.5"; // sonnet → haiku 다운그레이드
case HIGH -> "sonnet-4.6"; // opus → sonnet 다운그레이드
};
return RouteDecision.allowed(model,
"warn-throttle (budget %.0f%%)".formatted(maxRatio * 100));
}
// 4) 정상 - 복잡도 기반 선택
String model = switch (complexity) {
case LOW -> "haiku-4.5";
case MEDIUM -> "sonnet-4.6";
case HIGH -> "opus-4.7";
};
return RouteDecision.allowed(model,
"normal (complexity=%s)".formatted(complexity));
}
public sealed interface RouteDecision {
record Allowed(String model, String reason) implements RouteDecision {}
record Blocked(String reason) implements RouteDecision {}
static RouteDecision allowed(String m, String r) { return new Allowed(m, r); }
static RouteDecision blocked(String r) { return new Blocked(r); }
}
}
다운그레이드 위험 - 언제 안전한가
다운그레이드는 "이 작업이 더 작은 모델로도 충분한가"의 판단입니다. 안전한 영역:
- 요약·분류·번역·키워드 추출 (LOW)
- 코드 포맷팅·간단한 리팩토링·단위 테스트 작성 (MEDIUM)
위험한 영역(다운그레이드 금지):
- 의료·법률·금융 자문
- 최종 사용자 응답 (잘못된 답이 외부 노출되는 케이스)
- 코드 보안 리뷰 - HIGH 작업으로 잠금
이런 작업은 별도 @CostBypass 같은 어노테이션을 만들어 라우터를 우회시키는 게 정석입니다.
6. 캐시 - 동일 질의 재호출 차단
같은 질의를 여러 번 호출하지 않게 막는 게 가장 확실한 비용 절감입니다. Spring의 @Cacheable로 충분합니다.
@Component
public class LlmCacheKeyGen {
public String generate(String prompt, String model) {
// SHA-256 첫 16바이트 → 32자 hex
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hash = md.digest((model + ":" + prompt).getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash, 0, 16);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
@Service
@RequiredArgsConstructor
public class LlmCache {
private final RedisTemplate<String, String> redis;
private final LlmCacheKeyGen keyGen;
public Optional<String> get(String prompt, String model) {
String key = "llm:cache:" + keyGen.generate(prompt, model);
return Optional.ofNullable(redis.opsForValue().get(key));
}
public void put(String prompt, String model, String response, Duration ttl) {
String key = "llm:cache:" + keyGen.generate(prompt, model);
redis.opsForValue().set(key, response, ttl);
}
}
TTL은 작업 종류별로 다르게
| 작업 유형 | 권장 TTL | 이유 |
|---|---|---|
| 법령·표준 같은 정적 정보 조회 | 7일~30일 | 거의 안 변함 |
| 코드 리팩토링 제안 | 1일 | 코드베이스 변경 시 무효화 필요 |
| 날씨·뉴스 등 시계열 | 10분 | 금방 낡음 |
| 개인화된 추천 | 캐시 X | 사용자별로 다름 |
7. 통합 - CostAwareLlmService
위 컴포넌트들을 하나의 서비스로 묶고, Resilience4j로 감쌉니다.
@Service
@RequiredArgsConstructor
public class CostAwareLlmService {
private final HeuristicComplexityClassifier heuristic;
private final CostAwareModelRouter router;
private final BudgetTracker budgetTracker;
private final LlmCache cache;
private final LlmProperties props;
private final Map<String, AnthropicChatClient> clientsByModel; // 모델별 빈
@CircuitBreaker(name = "llm", fallbackMethod = "fallback")
@RateLimiter(name = "llm")
@Bulkhead(name = "llm", type = Bulkhead.Type.SEMAPHORE)
public LlmResponse complete(LlmRequest req) {
// 1) 캐시 우선
var cached = cache.get(req.prompt(), "any");
if (cached.isPresent()) {
return new LlmResponse(cached.get(), "cache-hit", BigDecimal.ZERO);
}
// 2) 복잡도 + 라우팅
var complexity = heuristic.classify(req.prompt(), req.context());
var decision = router.route(complexity);
if (decision instanceof RouteDecision.Blocked b) {
throw new BudgetExceededException(b.reason());
}
var allowed = (RouteDecision.Allowed) decision;
var client = clientsByModel.get(allowed.model());
// 3) 호출
long start = System.nanoTime();
var response = client.prompt(req.prompt()).call();
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
// 4) 비용 계산 + 누적
var meta = response.metadata().usage();
var price = props.models().get(allowed.model());
BigDecimal cost = price.estimateCost(meta.promptTokens(), meta.completionTokens());
budgetTracker.record(cost);
// 5) 캐시 저장 (TTL은 정책에 따라)
cache.put(req.prompt(), allowed.model(), response.content(), Duration.ofHours(1));
return new LlmResponse(
response.content(),
allowed.model() + " (" + allowed.reason() + ", " + elapsedMs + "ms)",
cost
);
}
private LlmResponse fallback(LlmRequest req, Throwable ex) {
if (ex instanceof BudgetExceededException) {
return new LlmResponse("",
"BLOCKED: " + ex.getMessage(),
BigDecimal.ZERO);
}
// 회로 차단 시 캐시만 응답
return cache.get(req.prompt(), "any")
.map(c -> new LlmResponse(c, "circuit-open-cache-fallback", BigDecimal.ZERO))
.orElse(new LlmResponse("일시적으로 응답할 수 없습니다.",
"circuit-open-no-cache", BigDecimal.ZERO));
}
}
Resilience4j 설정
# application.yml
resilience4j:
circuitbreaker:
instances:
llm:
sliding-window-size: 50
failure-rate-threshold: 30
wait-duration-in-open-state: 30s
slow-call-duration-threshold: 15s
slow-call-rate-threshold: 50
ratelimiter:
instances:
llm:
limit-for-period: 60 # 분당 60회
limit-refresh-period: 60s
timeout-duration: 200ms
bulkhead:
instances:
llm:
max-concurrent-calls: 20
max-wait-duration: 100ms
8. 관측 - 비용·라우팅 메트릭
자가 관리는 측정 가능해야 의미 있습니다. Micrometer + Prometheus로 핵심 지표를 노출합니다. Prometheus + Grafana 구축이 깔려 있다면 그대로 연결됩니다.
@Component
@RequiredArgsConstructor
public class LlmMetrics {
private final MeterRegistry registry;
public void recordCall(String model, String reason, BigDecimal cost, long elapsedMs) {
registry.counter("llm.calls",
"model", model,
"reason", reason
).increment();
registry.counter("llm.cost.usd.total",
"model", model
).increment(cost.doubleValue());
registry.timer("llm.latency",
"model", model
).record(Duration.ofMillis(elapsedMs));
}
public void recordCacheHit() {
registry.counter("llm.cache.hits").increment();
}
public void recordCacheMiss() {
registry.counter("llm.cache.misses").increment();
}
public void recordRouting(String complexity, String chosen, String reason) {
registry.counter("llm.routing",
"complexity", complexity,
"chosen", chosen,
"reason", reason
).increment();
}
}
대시보드에 띄울 핵심 패널
- 일/시간 누적 비용 + 예산 한도 라인
- 모델별 호출 분포 - haiku/sonnet/opus 비율
- 다운그레이드 횟수 - throttle 발동 빈도
- 캐시 적중률 - 30% 이상이 건강한 수치
- 차단 발생 시점 - block 카운터의 알림 기준
9. 알림 - 임계 도달 즉시 알리기
대시보드만으로는 부족합니다. 임계치 도달 시 Slack/PagerDuty 즉시 알림이 필요합니다.
@Component
@RequiredArgsConstructor
public class BudgetAlertJob {
private final BudgetTracker tracker;
private final LlmProperties props;
private final SlackClient slack;
private final StringRedisTemplate redis;
@Scheduled(fixedDelay = 60_000) // 1분마다
public void check() {
var state = tracker.currentState();
double max = state.maxRatio();
if (max >= props.budget().blockThreshold()) {
sendOnce("BLOCK", "\uD83D\uDEA8 LLM 예산 100% 도달, 신규 호출 차단됨 (일=%.0f%% / 시간=%.0f%%)"
.formatted(state.dailyRatio() * 100, state.hourlyRatio() * 100));
} else if (max >= props.budget().hardThreshold()) {
sendOnce("HARD", "\u26A0\uFE0F LLM 예산 80% 도달, Haiku로 강제 다운그레이드 중");
} else if (max >= props.budget().warnThreshold()) {
sendOnce("WARN", "\uD83D\uDD14 LLM 예산 50% 도달, 다운그레이드 시작");
}
}
private void sendOnce(String level, String msg) {
// 1시간에 같은 레벨 알림 1회만 (스팸 방지)
String key = "llm:alert:sent:" + level + ":" + LocalDateTime.now().getHour();
Boolean firstTime = redis.opsForValue().setIfAbsent(key, "1", Duration.ofHours(1));
if (Boolean.TRUE.equals(firstTime)) {
slack.post("#backend-alert", msg);
}
}
}
10. 검증 - 도입 효과 측정 방법
도입했다고 끝이 아닙니다. 도입 전/후 비교를 명확히 해야 "실제로 줄었는지" 압니다.
측정 단위
| 지표 | 의미 | 기대 변화 |
|---|---|---|
| 요청당 평균 비용 (USD) | 비용 효율 | 40~70% 감소 |
| 모델 분포 (haiku 비중) | 다운그레이드 효과 | haiku 50%+ |
| 캐시 적중률 | 중복 호출 절감 | 30%+ |
| P95 응답 시간 | 품질 저하 지표 | 5% 이내 변화 |
| 사용자 만족도 / 재시도율 | 품질 저하 검증 | 3% 이내 변화 |
A/B 테스트 권장
일부 트래픽(예: 10%)만 새로운 라우터로 보내고 나머지는 기존 단일 모델 호출. 1~2주 후 위 지표 비교. 품질 지표가 5% 이상 나빠졌다면 그 영역은 다운그레이드 금지 카테고리에 추가.
11. 안티패턴
도입할 때 자주 보는 실수들.
| 안티패턴 | 왜 문제 | 올바른 접근 |
|---|---|---|
| 모든 요청에 LLM 분류기 호출 | 분류 비용이 절감 효과 잠식 | 휴리스틱 우선, 모호할 때만 LLM |
| 예산 카운터를 메모리에만 저장 | 인스턴스 재시작 시 리셋, 멀티 인스턴스 동기화 X | Redis 등 공유 저장소 |
| 차단 시 무한 재시도 | 예산 회복 후 폭주, 비용 또 폭주 | 지수 백오프 + 사용자 안내 |
| 캐시 키에 사용자 ID 누락 | 다른 사용자의 응답이 노출 (보안 사고) | 키 구성에 사용자 ID 포함 |
| 다운그레이드 무차별 적용 | 품질 사고 → 비즈니스 영향 | 금지 카테고리 명확화 |
마치며
비용 자가 관리 패턴의 핵심을 정리합니다.
- 3축 결합이 정석: 캐시(중복 차단) + 복잡도 라우팅(과잉 모델 차단) + 예산 게이트(절대량 통제). 하나만 깔면 새는 곳이 생깁니다. 셋이 같이 있어야 "평균 50% 절감"이 안정적으로 나옵니다.
- Redis 기반 카운터가 안전. 메모리 카운터는 인스턴스 재시작·멀티 인스턴스에서 사고를 부릅니다. INCRBYFLOAT + TTL이 가장 단순하고 사고 없는 패턴.
- 다운그레이드 금지 영역을 먼저 정의. 도입 전 "이 작업은 절대 모델을 낮추지 않는다"의 화이트리스트를 명문화. 어노테이션이나 헤더로 구분해 라우터를 우회시킵니다. 이게 없으면 비용은 줄었는데 품질 사고가 터지는 함정에 빠집니다.
- 측정 없으면 효과도 없음. 도입 전후 요청당 비용·모델 분포·캐시 적중률·품질 지표를 동시에 봐야 함. 비용만 보면 품질이 죽었는지 모르고, 품질만 보면 비용 절감 의미를 못 느낍니다.
- 알림은 임계 도달 즉시 1회. 분당 알림 폭주는 무시하게 만듭니다. 50%·80%·100% 세 단계에서 1시간당 1회 알림이 운영 피로도와 안전 사이의 균형점.
이 패턴이 깔리면 5월 트렌드의 나머지 4가지(장기 자율 실행·영구 메모리·A2A·Computer Use)를 도입할 때 "비용이 어디로 새는지" 가시성이 확보됩니다. 다음 글에서는 두 번째 안전망인 영구 메모리 레이어를 Mem0 + Spring Boot로 구축하는 실전 패턴을 다뤄볼 예정입니다. PII 자동 만료, 잘못된 기억 검증, 멀티테넌트 격리까지 포함해서요.