최신 트렌드

AI 에이전트 비용 자가 관리 실전 - Spring Boot + Resilience4j로 모델 캐스케이드·예산 가드·캐시 우선 구현하기

백엔드 개발자 김승원 2026. 5. 3. 20:18

들어가며

지난 5월 신규 트렌드 정리에서 다섯 가지 흐름 중 "가장 먼저 도입해야 할 안전망"으로 비용 자가 관리를 꼽았습니다. 다른 트렌드(장기 자율 실행·영구 메모리·A2A·Computer Use)를 도입할 때마다 비용은 기하급수적으로 늡니다. 자가 관리가 안 깔려 있으면 Uber $3.4B 사고 같은 패턴이 자기 회사에서 그대로 재현됩니다.

오늘은 이 추상적인 "자가 관리"를 실제 코드로 풀어봅니다. Spring Boot 4 + Resilience4j 조합으로 다음 세 가지 패턴을 구현합니다.

  1. 모델 캐스케이드 - 작업 난이도 판정 후 Haiku → Sonnet → Opus 단계별 호출
  2. 예산 가드 - 일/시간 단위 비용 한도 + 한도 초과 시 자동 다운그레이드/차단
  3. 캐시 우선 - 동일 질의 재호출 방지로 토큰 비용 절감

이 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 자동 만료, 잘못된 기억 검증, 멀티테넌트 격리까지 포함해서요.