문서 읽는 데 62분 · day20

Day 20: Cost Guardrail — 비용 폭주를 막는 세 겹의 방어선

전체 26강 중 22강 · 스프링 AI
난이도 · 심화선수지식자바 기초스프링 부트

ℹ️스프링 부트로 만든 ai-friends 프로젝트 위에 얹어 진행해요. 자바·스프링이 처음이라면 먼저 “서버 만들기” 트랙부터 권해요.

안녕하세요, 여러분의 AI 튜터 홍순구입니다.

지난 시간에 Harness 6구성요소를 코드로 채웠어요. 설정을 외부화하고, advisor chain을 조립하고, 도구 필터와 평가기, 사용량 추적기까지 — 에이전트를 감싸는 껍데기가 완성됐죠. 그런데 마지막에 이런 표를 봤어요.

구성요소 구현 상태
1. Input Validation (과제로 남김)
2. Tool Permission
3. Execution Loop Control
4. Output Validation
5. Cost Guardrail ❌ 빈칸
6. Observability Hook (Day 22에서 확장)

5번, Cost Guardrail이 비어 있어요. Harness가 에이전트의 안전띠라면, Cost Guardrail은 지갑의 안전띠예요. 아무리 에이전트가 정확하게 동작해도, 호출 한도 없이 방치하면 월말에 청구서가 폭탄으로 돌아옵니다.

오늘은 이 빈칸을 세 겹의 방어선으로 채울 거예요.

  1. 호출 횟수 제한 — Bucket4j + Redis로 클라이언트별 "분당 N회"를 걸어요
  2. 응답 캐싱 — 같은 질문이 반복되면 LLM을 호출하지 않고 캐시에서 꺼내요
  3. 컨텍스트 캐싱 — 긴 시스템 프롬프트를 프로바이더 측에서 캐싱해 토큰을 절감해요

세 겹이 겹쳐야 비로소 "프로덕션에 올려도 되는 비용 구조"가 됩니다. 거기에 Resilience4j로 타임아웃과 재시도까지 감싸면, 장애 상황에서도 비용이 통제됩니다.

🎯 학습 목표

  • Bucket4j 토큰 버킷 알고리즘으로 클라이언트별 API 호출 횟수를 제한한다
  • Spring Cache + Redis로 동일 프롬프트의 응답을 캐싱하여 중복 호출을 절감한다
  • Gemini Context Caching / Anthropic Prompt Caching의 원리와 비용 절감 효과를 이해한다
  • Resilience4j로 타임아웃·재시도 시 발생하는 추가 비용을 관리한다
  • 세 겹의 Cost Guardrail을 하나의 advisor chain으로 통합하고 트레이드오프를 판단한다

이미 배운 Redis를 "Rate Limit 버킷 + 캐시 저장소"로 재활용하는 거라, 새 인프라 부담은 가벼워요. 그럼 비용의 수도꼭지를 조여 봅시다.


Step 1. Cost Guardrail 개념 & 비용 폭주의 3가지 층

LLM API 비용은 호출 횟수 × 토큰 수 × 단가로 결정돼요. 이 세 변수 중 하나라도 통제를 놓치면 비용이 기하급수적으로 올라갑니다. 오늘 배울 세 겹의 방어선은 각각 이 세 변수를 하나씩 잡아요.

LLM 비용의 구조

카페를 생각해 볼게요. 커피 한 잔의 비용은 "주문 횟수 × 잔당 재료비 × 원두 단가"로 결정되죠.

LLM도 동일해요.

카페 비유 LLM 비용 방어선
주문 횟수 제한 (1인 3잔까지) API 호출 횟수 Rate Limiting
같은 주문이면 미리 만들어둔 거 꺼냄 동일 프롬프트 응답 재사용 응답 캐싱
원두를 한 번에 갈아 며칠 쓰기 긴 시스템 프롬프트를 프로바이더가 캐싱 컨텍스트 캐싱

Day 19 Harness와의 연결

지난 시간에 만든 advisor chain에 오늘의 Cost Guardrail advisor들을 끼워 넣으면 전체 그림이 완성돼요.

계층 advisor 역할
가장 바깥 RateLimitAdvisor 호출 자체를 차단
두 번째 ResponseCacheAdvisor 캐시 히트면 LLM 호출 생략
세 번째 RetryableAdvisor 실패 시 재시도 (비용 관리)
안쪽 Day 19 가드 4종 MaxIterations, Timeout, UsageBudget, ToolCounter
가장 안쪽 UsageTrackingAdvisor 사용량 기록

바깥에서 안쪽으로 갈수록 "비용이 비싼 동작"에 가까워져요. Rate Limit에서 걸리면 LLM 호출 자체가 일어나지 않으니 비용 0원이에요. 캐시에서 걸리면 역시 LLM 호출 없이 응답이 나가요. 이 순서가 비용 효율의 핵심이에요.

🙋 "Rate Limit과 Day 14의 MaxIterationsAdvisor는 뭐가 다른가요?"

좋은 질문이에요. MaxIterationsAdvisor는 "한 비즈니스 사이클 안에서 에이전트가 LLM을 몇 번 호출하는지"를 제한해요. 에이전트가 무한 루프를 도는 걸 막는 거죠.

RateLimitAdvisor는 "한 클라이언트(사용자)가 시간당 몇 번이나 API를 호출하는지"를 제한해요. 여러 사용자가 동시에 쓸 때 특정 사용자가 전체 예산을 독차지하는 걸 막는 거예요.

하나는 "에이전트 자율성의 브레이크", 다른 하나는 "사용자별 공정 배분"이에요.

💡 핵심 감각

세 겹의 방어선은 겹쳐야 의미가 있어요. Rate Limit만 걸면 같은 질문을 매번 LLM에 보내는 낭비를 못 잡고, 캐싱만 하면 새로운 질문의 폭주를 못 잡아요. "호출 차단 → 중복 절감 → 토큰 절감" 세 축이 동시에 움직여야 비용이 통제됩니다.


Step 2. Rate Limiting — Bucket4j + Redis

첫 번째 방어선이에요. 클라이언트별로 "분당 N회"를 넘기면 LLM 호출 자체를 차단합니다. Bucket4j 라이브러리의 토큰 버킷 알고리즘으로 구현해요.

토큰 버킷 알고리즘

양동이에 토큰이 N개 들어 있다고 생각해 보세요. API 호출이 들어올 때마다 토큰을 하나 꺼내요. 양동이가 비면 "잠시 후 다시 시도해 주세요"라고 거절합니다. 시간이 지나면 토큰이 자동으로 채워져요.

이게 토큰 버킷(Token Bucket) 알고리즘이에요. Nginx나 AWS API Gateway에서도 동일한 원리를 사용합니다.

RateLimitAdvisor 구현

// kr.spartaclub.aifriends.harness.cost.RateLimitAdvisor
public class RateLimitAdvisor implements BaseAdvisor {

    private final long capacity;
    private final Duration refillDuration;
    private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();

    public RateLimitAdvisor(long capacity, Duration refillDuration) {
        this.capacity = capacity;
        this.refillDuration = refillDuration;
    }

    @Override
    public ChatClientRequest before(ChatClientRequest request, AdvisorChain advisorChain) {
        String clientKey = resolveClientKey(request);
        Bucket bucket = buckets.computeIfAbsent(clientKey, k -> createBucket());
        ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);

        if (!probe.isConsumed()) {
            long retryAfterMillis = probe.getNanosToWaitForRefill() / 1_000_000;
            throw new RateLimitExceededException(clientKey, retryAfterMillis);
        }
        return request;
    }
}

핵심 동작을 하나씩 보면요.

ConcurrentHashMap<String, Bucket> — 클라이언트별로 독립된 양동이를 관리해요. user-1의 양동이가 비어도 user-2는 영향 없이 호출할 수 있어요.

resolveClientKey() — 요청의 context에서 conversationId를 꺼내 클라이언트 키로 사용해요. 없으면 "global"로 폴백합니다.

private String resolveClientKey(ChatClientRequest request) {
    Map<String, Object> context = request.context();
    if (context != null && context.containsKey("conversationId")) {
        return String.valueOf(context.get("conversationId"));
    }
    return "global";
}

tryConsumeAndReturnRemaining(1) — 토큰 1개를 소비하려고 시도해요. 성공하면 남은 토큰 수가, 실패하면 "다음 토큰이 채워지기까지 남은 시간"이 반환됩니다.

createBucket() — Bucket4j API로 양동이를 생성해요.

private Bucket createBucket() {
    return Bucket.builder()
            .addLimit(Bandwidth.simple(capacity, refillDuration))
            .build();
}

Bandwidth.simple(30, Duration.ofMinutes(1))이면 "1분에 30개 토큰이 채워지는 양동이"가 만들어져요.

RateLimitExceededException

한도를 넘기면 던지는 예외예요. retryAfterMillis를 포함해서, 클라이언트에게 "몇 밀리초 후에 다시 시도하면 되는지" 알려줄 수 있어요.

// kr.spartaclub.aifriends.harness.exception.RateLimitExceededException
public class RateLimitExceededException extends RuntimeException {

    private final String clientKey;
    private final long retryAfterMillis;

    public RateLimitExceededException(String clientKey, long retryAfterMillis) {
        super("호출 한도를 초과했습니다. clientKey=%s, retryAfter=%dms"
                .formatted(clientKey, retryAfterMillis));
        this.clientKey = clientKey;
        this.retryAfterMillis = retryAfterMillis;
    }
}

GlobalExceptionHandler에서 이 예외를 잡아 HTTP 429 응답으로 변환하면, 프론트엔드가 "잠시 후 다시 시도해 주세요" 메시지를 보여줄 수 있어요.

order가 HIGHEST_PRECEDENCE + 1인 이유

Rate Limit은 모든 advisor 중 가장 바깥에 있어야 해요. 한도를 넘은 요청이 캐시 조회나 LLM 호출까지 도달하면 안 되니까요. "문 앞에서 먼저 걸러내는" 역할이에요.

🙋 "인메모리 ConcurrentHashMap이면 서버 재시작 시 양동이가 초기화되지 않나요?"

맞아요. 지금 구현은 학습용 인메모리 버전이에요. 프로덕션에서는 Bucket4j의 Redis ProxyManager를 사용하면 양동이 상태가 Redis에 저장돼서, 서버를 재시작해도 유지되고 여러 서버 인스턴스가 같은 양동이를 공유할 수 있어요.

Bucket4jLettuceBasedProxyManager를 사용하면 코드 구조는 거의 동일하고, Bucket.builder() 대신 proxyManager.builder() 를 호출하는 차이만 있어요. 도전 과제에서 직접 전환해 볼 수 있습니다.


Step 3. 응답 캐싱 — Spring Cache + Redis

두 번째 방어선이에요. 같은 프롬프트가 반복되면 LLM을 호출하지 않고 캐시에서 꺼내요. Rate Limit을 통과한 요청 중에서도 중복을 한 번 더 걸러내는 역할이에요.

왜 LLM 응답을 캐싱하나

ai-friends를 생각해 보세요. 플레이어가 채팅방에 들어갈 때마다 "이 캐릭터는 어떤 성격이야?"라는 시스템 프롬프트가 매번 날아가요. 캐릭터 설정이 바뀌지 않았다면, 이 응답은 캐싱해도 문제없어요.

검색 엔진의 캐시를 떠올리면 쉬워요. 같은 검색어를 1분 안에 두 번 입력하면, 두 번째는 DB를 다시 조회하지 않고 캐시에서 꺼내죠. LLM 응답도 동일한 원리예요.

ResponseCacheAdvisor 핵심 구조

// kr.spartaclub.aifriends.harness.cost.ResponseCacheAdvisor
public class ResponseCacheAdvisor implements BaseAdvisor {

    static final String CACHE_KEY_CONTEXT = "responseCacheKey";
    private final Map<String, ChatClientResponse> cache = new ConcurrentHashMap<>();

    @Override
    public ChatClientRequest before(ChatClientRequest request, AdvisorChain advisorChain) {
        String cacheKey = generateCacheKey(request);

        ChatClientResponse cached = cache.get(cacheKey);
        if (cached != null) {
            throw new CacheHitException(cached);
        }

        return request.mutate()
                .context(CACHE_KEY_CONTEXT, cacheKey)
                .build();
    }

    @Override
    public ChatClientResponse after(ChatClientResponse response, AdvisorChain advisorChain) {
        Map<String, Object> context = response.context();
        if (context != null && context.containsKey(CACHE_KEY_CONTEXT)) {
            String cacheKey = String.valueOf(context.get(CACHE_KEY_CONTEXT));
            cache.put(cacheKey, response);
        }
        return response;
    }
}

동작을 따라가 볼게요.

before 훅 (캐시 조회) — 요청의 프롬프트를 SHA-256 해시로 변환해 캐시 키를 만들어요. 캐시에 있으면 CacheHitException을 던져서 advisor chain을 더 타지 않고 즉시 캐시된 응답을 반환해요. 캐시에 없으면 context에 캐시 키를 심어서 다음 단계로 넘겨요.

after 훅 (캐시 저장) — LLM 호출이 끝나고 돌아온 응답을 context에서 꺼낸 캐시 키로 저장해요. 다음에 같은 프롬프트가 오면 before에서 바로 히트됩니다.

캐시 키 설계

String generateCacheKey(ChatClientRequest request) {
    StringBuilder sb = new StringBuilder();
    if (request.prompt() != null && request.prompt().getInstructions() != null) {
        for (Message msg : request.prompt().getInstructions()) {
            sb.append(msg.getText());
        }
    }
    return sha256(sb.toString());
}

프롬프트의 모든 메시지 텍스트를 이어붙인 뒤 SHA-256 해시의 앞 16자를 키로 사용해요. 동일한 텍스트면 항상 같은 키가 나오고, 한 글자만 달라도 완전히 다른 키가 됩니다.

💡 프로덕션에서는 Spring Cache + Redis로

지금은 학습용 ConcurrentHashMap이에요. 프로덕션에서는 @Cacheable + RedisCacheManager로 교체하면 서버 간 캐시 공유 + TTL 자동 만료가 됩니다. 도전 과제에서 직접 전환해 볼 수 있어요.

🙋 "temperature가 0이 아니면 같은 프롬프트라도 다른 응답이 나오지 않나요?"

정확해요. temperature > 0이면 LLM 응답이 매번 달라질 수 있어요. 캐싱이 효과적인 시나리오는 temperature가 0이거나 낮은 경우, 또는 정확히 같은 답이 와도 괜찮은 경우(예: 캐릭터 설정 조회, 지식 검색 결과)예요.

프로덕션에서는 캐시 키에 temperature를 포함시켜서, temperature가 다르면 별도 캐시 엔트리로 관리하는 전략도 있어요. Step 7에서 ai-friends에 적용할 때 이 기준을 더 구체적으로 다뤄볼게요.


Step 4. 컨텍스트 캐싱 — Gemini / Anthropic

세 번째 방어선이에요. 앞의 두 방어선이 "호출 자체를 줄이는" 전략이었다면, 컨텍스트 캐싱은 호출은 하되 토큰 비용을 줄이는 전략이에요.

왜 컨텍스트 캐싱이 필요한가

ai-friends의 채팅을 생각해 보세요. 매 호출마다 시스템 프롬프트가 함께 전송돼요. 캐릭터 설정, 호감도 규칙, 응답 형식 가이드 — 이게 수백에서 수천 토큰이에요. 사용자 메시지는 "오늘 뭐 했어?" 한 줄인데, 시스템 프롬프트는 매번 동일한 수천 토큰이 반복 전송됩니다.

컨텍스트 캐싱은 이 반복 전송되는 시스템 프롬프트를 프로바이더 서버 측에서 캐싱해서, 두 번째 호출부터는 "이미 보낸 프롬프트입니다"라고 ID만 보내는 거예요. 읽기 비용이 쓰기 비용보다 훨씬 싸서, 호출이 반복될수록 절감 효과가 커져요.

Gemini Context Caching

Gemini 2.5 이상 모델은 암시적 캐싱(implicit caching)이 기본 활성화돼 있어요. 별도 코드 없이도 프로바이더가 자동으로 반복 컨텍스트를 인식해서 캐싱합니다.

# application.yml — Gemini 프로파일
spring:
  ai:
    openai:
      chat:
        options:
          model: ${GEMINI_MODEL:gemini-2.5-flash-lite}

Gemini의 암시적 캐싱은 Spring AI에서 특별한 설정 없이 동작해요. Usage 메타데이터의 cachedContentTokenCount 필드를 확인하면 "이번 호출에서 캐싱된 토큰이 몇 개인지" 볼 수 있습니다.

Anthropic Prompt Caching

Anthropic은 명시적 캐싱을 지원해요. 캐시 breakpoint를 지정해서 "여기까지는 캐싱해 주세요"라고 요청합니다. Spring AI 1.1에서 네이티브로 지원해요.

캐시 breakpoint는 최대 4개까지 설정할 수 있고, 기본 TTL은 5분이에요. 장기 캐싱이 필요하면 1시간까지 늘릴 수 있어요.

전략 선택이 중요해요.

전략 캐싱 대상 효과
system-only 시스템 프롬프트만 안정적, 가장 높은 히트율
system-and-tools 시스템 + 도구 정의 Tool Calling 앱에 적합
conversation-history 대화 히스토리 포함 멀티턴에서 효과적, 히트율 낮을 수 있음

ai-friends처럼 시스템 프롬프트가 고정이고 대화가 길어지는 앱에서는 system-only 전략이 가장 효과적이에요.

🙋 "Gemini 암시적 캐싱과 Anthropic 명시적 캐싱, 어떤 게 더 좋은가요?"

"좋고 나쁨"보다 "자동 vs 수동"의 차이예요. Gemini는 프로바이더가 알아서 캐싱 대상을 판단하니까 개발자가 신경 쓸 게 없어요. 대신 "이 부분은 반드시 캐싱해 주세요"라고 제어할 수 없어요.

Anthropic은 개발자가 breakpoint를 직접 지정하니까 정밀하게 제어할 수 있어요. 대신 breakpoint를 잘못 배치하면 캐시 히트율이 떨어져요.

무료 Gemini 티어를 쓰는 우리 실습에서는 암시적 캐싱이 자동으로 동작하니 별도 설정 없이 효과를 누릴 수 있어요.

💡 비용 감각

시스템 프롬프트 2,000토큰 × 일 1,000회 호출 = 일 200만 토큰. 컨텍스트 캐싱으로 읽기 비용이 75% 절감되면 일 150만 토큰을 아낄 수 있어요. 월로 환산하면 상당한 금액이 됩니다.


Step 5. Resilience4j — 타임아웃·재시도 비용 관리

LLM API 호출이 실패했을 때 재시도하면 비용이 2배가 돼요. 3번 재시도하면 3배. Resilience4j의 지수 백오프로 재시도 횟수와 간격을 제어해서, 장애 상황에서도 비용 폭주를 막아요.

재시도의 비용 함정

네트워크 순간 장애로 LLM 호출이 실패했어요. 바로 재시도하면 높은 확률로 또 실패해요. 서버가 과부하 상태라면 재시도 요청이 오히려 부하를 가중시키고, 결국 모든 요청이 실패하는 썬더링 허드(Thundering Herd) 현상이 벌어져요.

지수 백오프(Exponential Backoff)는 재시도 간격을 점점 늘려서 서버가 회복할 시간을 확보해요.

RetryableAdvisor

// kr.spartaclub.aifriends.harness.cost.RetryableAdvisor
public class RetryableAdvisor implements BaseAdvisor {

    private final int maxRetries;
    private final Duration initialBackoff;
    private final double backoffMultiplier;

    public Duration calculateBackoff(int attempt) {
        if (attempt <= 0) {
            return initialBackoff;
        }
        long millis = (long) (initialBackoff.toMillis() * Math.pow(backoffMultiplier, attempt));
        long capped = Math.min(millis, Duration.ofMinutes(1).toMillis());
        return Duration.ofMillis(capped);
    }
}

calculateBackoff()가 핵심이에요. initialBackoff=500ms, backoffMultiplier=2.0이면 이런 계산이 돼요.

시도 대기 시간 비용 상태
0 (첫 시도) 1회 과금
1 (재시도 1) 500ms 2회 과금
2 (재시도 2) 1,000ms 3회 과금
3 (재시도 3) 2,000ms 4회 과금 — 여기서 포기

maxRetries=3이면 최대 4회 호출(첫 시도 + 재시도 3)로 비용이 제한돼요. 상한은 1분으로 cap 해서 과도하게 긴 대기를 방지합니다.

Resilience4j application.yml 설정

Resilience4j는 어노테이션 기반으로 서비스 메서드에 적용할 수도 있어요.

# application.yml
resilience4j:
  retry:
    instances:
      llm-call:
        max-attempts: 4
        wait-duration: 500ms
        enable-exponential-backoff: true
        exponential-backoff-multiplier: 2.0
  circuitbreaker:
    instances:
      llm-call:
        failure-rate-threshold: 50
        slow-call-duration-threshold: 10s
        sliding-window-size: 10

Circuit Breaker는 연속 실패율이 50%를 넘으면 일정 시간 동안 요청 자체를 차단해요. "서버가 죽어 있는데 계속 요청을 보내는" 무의미한 과금을 막는 거예요.

💡 재시도 = 비용 × 횟수

재시도 정책을 설계할 때 "최악의 경우 비용이 몇 배가 되는가"를 항상 계산해야 해요. maxRetries=3이면 최악 4배, maxRetries=5면 6배예요. Rate Limit과 조합하면 "분당 30회 × 최대 4배 = 분당 최대 120회 LLM 호출"이 상한이 됩니다.


Step 6. Cost Guardrail Advisor Chain 통합

세 겹의 방어선(Rate Limit + 캐싱 + 재시도)을 하나의 설정 클래스로 조립해요. Day 19의 HarnessAdvisorChainConfig와 같은 패턴으로, application.yml 한 장에 비용 한도를 모아서 관리합니다.

CostGuardrailProperties — 설정 외부화

// kr.spartaclub.aifriends.harness.cost.CostGuardrailProperties
@ConfigurationProperties(prefix = "ai-friends.cost-guardrail")
public class CostGuardrailProperties {

    private long rateLimitCapacity = 30;
    private Duration rateLimitDuration = Duration.ofMinutes(1);
    private int maxRetries = 3;
    private Duration retryInitialBackoff = Duration.ofMillis(500);
    private double retryBackoffMultiplier = 2.0;
    private boolean cacheEnabled = true;
}

Day 19에서 HarnessProperties를 만들 때와 동일한 패턴이에요. application.yml에서 이렇게 설정합니다.

ai-friends:
  cost-guardrail:
    rate-limit-capacity: 30
    rate-limit-duration: 1m
    max-retries: 3
    retry-initial-backoff: 500ms
    retry-backoff-multiplier: 2.0
    cache-enabled: true

운영 환경에서는 application-prod.yml에 더 엄격한 값을, application-dev.yml에 느슨한 값을 넣으면 프로파일별로 비용 정책을 분리할 수 있어요.

CostGuardrailConfig — 조립 팩토리

// kr.spartaclub.aifriends.harness.cost.CostGuardrailConfig
@Configuration
@EnableConfigurationProperties(CostGuardrailProperties.class)
public class CostGuardrailConfig {

    public List<Advisor> costGuardrailAdvisors(CostGuardrailProperties props) {
        props.validate();

        List<Advisor> advisors = new ArrayList<>();
        advisors.add(new RateLimitAdvisor(
                props.getRateLimitCapacity(),
                props.getRateLimitDuration()
        ));
        if (props.isCacheEnabled()) {
            advisors.add(new ResponseCacheAdvisor());
        }
        advisors.add(new RetryableAdvisor(
                props.getMaxRetries(),
                props.getRetryInitialBackoff(),
                props.getRetryBackoffMultiplier()
        ));
        return List.copyOf(advisors);
    }
}

세 가지 포인트가 있어요.

props.validate() — advisor를 생성하기 전에 설정값이 올바른지 검증해요. rateLimitCapacity가 0이면 IllegalArgumentException을 던져서 서버 기동 시점에 빠르게 실패합니다.

cacheEnabled 토글 — 캐싱을 끄고 싶을 때 yml 한 줄만 바꾸면 돼요. 코드 수정 없이 동작을 제어할 수 있습니다.

List.copyOf() — 불변 리스트를 반환해서 외부에서 advisor 목록을 조작할 수 없게 해요.

전체 advisor chain 조립

Day 19의 4가드와 오늘의 Cost Guardrail을 합치면 이런 코드가 됩니다.

List<Advisor> guards = harnessConfig.guardAdvisors(harnessProps);
List<Advisor> costGuards = costConfig.costGuardrailAdvisors(costProps);

ChatClient client = builder
        .defaultAdvisors(costGuards.toArray(Advisor[]::new))
        .defaultAdvisors(guards.toArray(Advisor[]::new))
        .defaultAdvisors(filter, tracker)
        .build();

defaultAdvisors를 세 번 호출하고 있어요. Spring AI의 ChatClient.Builder는 호출할 때마다 advisor가 누적되고, order 값 순서대로 chain이 형성됩니다.

🙋 "advisor가 10개가 넘으면 성능에 문제가 없나요?"

Day 19 Step 8에서 다뤘던 트레이드오프와 같은 이야기예요. 각 advisor의 before/after 훅은 AtomicLong 덧셈이나 Map.get() 수준의 가벼운 연산이에요. LLM 호출이 보통 1~5초 걸리는 것에 비하면, advisor chain 통과 시간은 마이크로초 단위라 무시할 수 있어요.

다만 advisor가 20개, 30개로 늘어나면 디버깅이 복잡해져요. "이 요청이 어느 advisor에서 걸렸는지"를 추적하려면 로깅을 잘 남겨야 해요. Day 22(Observability)에서 이걸 체계적으로 다룹니다.


Step 7. ai-friends에 적용 — 대화 vs 지식 검색 차별

세 겹의 방어선을 ai-friends에 실제로 적용해요. 핵심은 캐릭터 대화와 지식 검색(RAG)의 비용 정책이 달라야 한다는 점이에요.

왜 차별 정책이 필요한가

시나리오 캐싱 효과 Rate Limit
캐릭터 대화 낮음 (매번 다른 대화) 느슨하게 (사용자 경험 우선)
지식 검색 (RAG) 높음 (같은 질문 반복 가능) 엄격하게 (비용 우선)

캐릭터 대화는 temperature가 높고 매번 새로운 맥락이 추가되니까 캐시 히트율이 낮아요. 대신 사용자가 자연스럽게 대화하려면 Rate Limit을 너무 엄격하게 걸면 안 돼요.

반면 지식 검색은 "이 캐릭터의 생일이 언제야?"처럼 정해진 답이 있는 질문이 많아서 캐시 히트율이 높아요. 이쪽에 캐싱을 집중하면 비용 절감 효과가 커요.

프로파일별 설정 분리

# application-chat.yml — 캐릭터 대화 프로파일
ai-friends:
  cost-guardrail:
    rate-limit-capacity: 60
    rate-limit-duration: 1m
    cache-enabled: false
    max-retries: 2

# application-rag.yml — 지식 검색 프로파일
ai-friends:
  cost-guardrail:
    rate-limit-capacity: 20
    rate-limit-duration: 1m
    cache-enabled: true
    max-retries: 3

대화용은 분당 60회, 캐싱 꺼짐, 재시도 2회. 지식 검색용은 분당 20회, 캐싱 켜짐, 재시도 3회. 같은 CostGuardrailConfig 코드로 두 가지 정책을 만들 수 있어요.

Before/After — Day 19 vs Day 20

Day 19 (Harness 껍데기) Day 20 (Cost Guardrail 추가)
에이전트 폭주 MaxIterations + Timeout으로 차단 그대로 유지
비용 제어 UsageBudgetAdvisor (토큰 예산만) + Rate Limit + 캐싱 + 재시도 제어
설정 위치 ai-friends.harness.* + ai-friends.cost-guardrail.*
Redis 의존 없음 (인메모리) Redis 추가 (Rate Limit + 캐시)

Day 19에서는 "에이전트가 한 사이클에서 토큰을 얼마나 쓰는지"만 관리했어요. Day 20을 거치면 "클라이언트별 호출 횟수, 중복 요청, 재시도 비용"까지 통제 범위가 확장됩니다.

💡 인프라 추가는 최소한으로

Redis 하나를 추가했을 뿐인데, Rate Limit과 응답 캐싱 두 가지 역할을 동시에 수행해요. 새 인프라를 도입할 때는 "이미 있는 걸 재활용할 수 없나?"를 먼저 따져보는 습관이 중요합니다.


Step 8. 트레이드오프 5종

오늘 만든 세 겹의 방어선에는 각각 트레이드오프가 있어요. 면접에서 "Cost Guardrail을 어떻게 설계하셨나요?"라는 질문에 이 5가지를 언급할 수 있으면, 프로덕션 경험이 드러납니다.

트레이드오프 5종

1. Rate Limit 엄격도 vs 사용자 경험

분당 10회로 걸면 비용은 확실히 통제되지만, 사용자가 조금만 활발하게 대화하면 금방 한도에 걸려요. 분당 100회로 풀면 사용자 경험은 좋지만, 한 사용자가 예산을 독차지할 수 있어요. Step 7에서 봤듯이 "대화는 느슨, 검색은 엄격"처럼 시나리오별 차등 적용이 현실적이에요.

2. 캐시 히트율 vs 응답 신선도

TTL을 길게 잡으면 히트율이 올라가지만, 캐릭터 설정이 바뀌었을 때 오래된 응답이 나갈 수 있어요. TTL을 짧게 잡으면 신선도는 보장되지만 히트율이 떨어져요. "설정이 자주 바뀌는 데이터는 짧게, 고정 지식은 길게" — 이 기준으로 TTL을 분리하세요.

3. 컨텍스트 캐싱 절감 vs 캐시 비용

Gemini의 컨텍스트 캐싱도 무료가 아니에요. 쓰기 비용($0.50/M 토큰)이 있고, 읽기 비용($0.20/M 토큰)이 별도예요. 호출 횟수가 적으면 캐시 쓰기 비용이 오히려 비용을 늘릴 수 있어요. "시스템 프롬프트가 길고, 호출이 빈번한" 시나리오에서만 효과적이에요.

4. 재시도 횟수 vs 장애 복구 시간

maxRetries=3이면 장애 시 최대 약 7초(500 + 1000 + 2000 + 실제 호출 시간)가 걸려요. 사용자는 7초를 기다린 끝에 실패 응답을 받을 수 있어요. 재시도를 1회로 줄이면 빠르게 실패하지만, 순간 장애를 넘길 기회를 잃어요.

5. 인메모리 vs 분산 저장소

지금은 ConcurrentHashMap으로 인메모리에 관리하고 있어요. 서버 1대로 충분한 학습 환경에서는 이게 가장 간단해요. 서버가 2대 이상이 되면 Redis ProxyManager로 전환해야 양동이 상태가 공유돼요. "지금 서버가 몇 대인가"가 결정 기준이에요.

Day 21로 이어지는 다리

오늘 비용을 제어하는 방어선을 완성했어요. Rate Limit으로 호출을 잡고, 캐싱으로 중복을 잡고, 재시도 제어로 장애 비용을 잡았어요.

다음 시간은 Agent Client + Agent Bench예요. Day 14에서 MaxIterationsAdvisor, DurationTimeoutAdvisor, UsageBudgetAdvisor, ToolInvocationCounterAdvisor를 손으로 구현했던 거 기억나시죠? Day 21에서는 Spring AI Agent Client가 이 4가드를 선언 한 줄로 처리하는 모습을 체감하게 돼요.

그리고 Agent Bench로 "캐릭터가 설정을 위반하지 않는지, 호감도 규칙이 정확한지"를 배포 전에 자동으로 검증하는 회귀 테스트 시나리오를 작성해요.


마무리

안녕하세요, 홍순구 튜터입니다.

오늘 Day 20에서 한 일을 정리해 볼게요.

Step 한 줄 요약
1 Cost Guardrail 개념 — 호출 제한 → 중복 절감 → 토큰 절감, 세 겹의 방어선
2 RateLimitAdvisor — Bucket4j 토큰 버킷으로 클라이언트별 "분당 N회" 제한
3 ResponseCacheAdvisor — SHA-256 캐시 키로 동일 프롬프트 응답 재사용
4 컨텍스트 캐싱 — Gemini 암시적 캐싱 + Anthropic 명시적 캐싱의 원리와 절감 효과
5 RetryableAdvisor — 지수 백오프로 재시도 비용 관리 + Circuit Breaker
6 CostGuardrailConfig — 세 advisor를 yml 한 장으로 조립
7 ai-friends 적용 — 캐릭터 대화(느슨) vs 지식 검색(엄격) 차별 정책
8 트레이드오프 5종 + Day 21(Agent Client + Agent Bench) 예고

Day 19에서 Harness 6구성요소의 껍데기를 만들었고, 오늘 5번 Cost Guardrail을 실체로 채웠어요. Harness 6구성요소 중 5.5개가 코드로 존재하는 상태예요. (0.5는 Observability Hook — Day 19에서 인메모리 추적까지 만들었고, Day 22에서 Micrometer 영속 메트릭으로 확장할 예정이에요.)


도전 과제
과제 1. RateLimitAdvisor를 Redis 분산 버킷으로 전환

지금 RateLimitAdvisorConcurrentHashMap으로 인메모리 버킷을 관리해요. 서버가 2대 이상이 되면 각 서버가 독립된 양동이를 갖게 되어 실질적으로 한도가 2배가 돼요.

Bucket4j의 LettuceBasedProxyManager를 사용해서 버킷 상태를 Redis에 저장하도록 전환해 보세요.

구현 힌트:

  • build.gradle에는 이미 bucket4j_jdk17-lettuce 의존성이 추가되어 있어요
  • RedisClient를 주입받아 LettuceBasedProxyManager를 생성하면 돼요
  • Bucket.builder() 대신 proxyManager.builder()를 사용하는 것이 핵심 차이예요
  • 클라이언트 키를 Redis 키로 매핑하는 전략도 설계해야 해요

본 강의의 표준 응답 패턴(ApiResponse<T>)을 잊지 마세요. RateLimitExceededExceptionGlobalExceptionHandler에서 HTTP 429로 변환되는지 확인하세요.

과제 2. ResponseCacheAdvisor를 Spring Cache + Redis로 전환

지금 ResponseCacheAdvisorConcurrentHashMap으로 인메모리 캐시를 관리해요. 이걸 Spring Cache(@Cacheable) + RedisCacheManager로 전환해서 TTL 자동 만료와 서버 간 캐시 공유를 구현해 보세요.

요구사항:

  • RedisCacheConfiguration에서 기본 TTL을 5분으로 설정
  • 캐시 이름은 "llm-responses"
  • 캐시 키는 기존 SHA-256 해시 로직을 재활용
  • docker-compose.yml의 Redis 서비스를 활용

추가 도전: 캐릭터 대화용 캐시("chat-responses", TTL 1분)와 지식 검색용 캐시("rag-responses", TTL 30분)를 분리해서 TTL 차별화를 구현해 보세요.

과제 3. Usage 대시보드 API에 Cost Guardrail 메트릭 추가

Day 19 과제 3에서 만든 GET /api/admin/usage API를 확장해서, Cost Guardrail 메트릭을 함께 보여주세요.

추가 응답 필드:

  • rateLimitBlockedCount: Rate Limit에 걸린 총 횟수
  • cacheHitCount: 캐시 히트 횟수
  • cacheMissCount: 캐시 미스 횟수
  • cacheHitRate: 캐시 히트율 (%)
  • retryCount: 재시도 총 횟수

구현 힌트:

  • RateLimitAdvisorResponseCacheAdvisor에 카운터 필드를 추가하고 AtomicLong으로 관리
  • 기존 UsageSummary record를 확장하거나 별도 CostGuardrailSummary record를 만들어 반환

생각해볼 주제
1. LLM 비용 최적화의 우선순위

Rate Limiting, 응답 캐싱, 컨텍스트 캐싱, 모델 다운그레이드(비싼 모델 → 싼 모델), 프롬프트 압축 — 비용을 줄이는 방법은 여러 가지예요. 여러분의 서비스에서 "가장 먼저" 적용해야 할 전략은 무엇이고, 그 이유는 뭘까요? "모든 걸 다 하면 되지 않나요?"라는 생각에 함정이 있어요.

2. 캐싱과 개인화의 충돌

응답 캐싱은 "같은 질문에 같은 답"을 전제로 해요. 하지만 ai-friends의 캐릭터는 사용자마다 호감도가 다르고, 대화 맥락이 달라요. 캐싱을 적용하면 "A 사용자에게 한 답변이 B 사용자에게도 나가는" 사고가 발생할 수 있어요. 개인화 서비스에서 캐싱을 안전하게 적용하려면 캐시 키를 어떻게 설계해야 할까요?

3. 무료 티어와 유료 티어의 Rate Limit 설계

SaaS 서비스에서는 무료 사용자와 유료 사용자의 API 한도를 다르게 설정해요. "무료는 분당 10회, 프로는 분당 100회, 엔터프라이즈는 무제한"처럼요. 이 정책을 오늘 배운 RateLimitAdvisor로 구현한다면, resolveClientKey() 메서드를 어떻게 확장해야 할까요? 클라이언트 키에 "사용자 ID + 구독 등급"을 조합하는 전략을 설계해 보세요.

✅ 예시 답안정답 보기

도전 과제 예시답안


과제 1. RateLimitAdvisor를 Redis 분산 버킷으로 전환

채점 포인트

항목 배점
LettuceBasedProxyManager 인스턴스 생성 30%
proxyManager.builder()로 버킷 생성 전환 30%
Redis 키 전략 (클라이언트별 독립 키) 20%
GlobalExceptionHandler에서 HTTP 429 반환 20%

튜터의 가이드

기존 RateLimitAdvisorConcurrentHashMap<String, Bucket>을 Bucket4j의 Redis ProxyManager로 교체하는 과제예요.

핵심 변경은 두 군데예요.

1. ProxyManager 생성

StatefulRedisConnection<String, byte[]> connection =
        redisClient.connect(RedisCodec.of(StringCodec.UTF8, ByteArrayCodec.INSTANCE));

LettuceBasedProxyManager<String> proxyManager =
        LettuceBasedProxyManager.builderFor(connection)
                .build();

RedisClient를 Spring Data Redis의 LettuceConnectionFactory에서 꺼내거나, 직접 생성할 수 있어요. ByteArrayCodec이 필요한 이유는 Bucket4j가 버킷 상태를 바이트 배열로 직렬화해서 Redis에 저장하기 때문이에요.

2. 버킷 생성 전환

// Before — 인메모리
private Bucket createBucket() {
    return Bucket.builder()
            .addLimit(Bandwidth.simple(capacity, refillDuration))
            .build();
}

// After — Redis 분산
private Bucket resolveBucket(String clientKey) {
    BucketConfiguration config = BucketConfiguration.builder()
            .addLimit(Bandwidth.simple(capacity, refillDuration))
            .build();
    return proxyManager.builder()
            .build(clientKey, () -> config);
}

proxyManager.builder().build(key, configSupplier) 패턴이 핵심이에요. 같은 키로 호출하면 Redis에 저장된 기존 버킷이 반환되고, 새 키면 configSupplier로 버킷을 생성해요.

3. Redis 키 전략

클라이언트 키 앞에 네임스페이스를 붙이면 Redis에서 관리가 편해져요.

private String redisKey(String clientKey) {
    return "rate-limit:" + clientKey;
}

redis-cli KEYS "rate-limit:*"로 전체 버킷 상태를 한눈에 확인할 수 있어요.

실무 개선 포인트

  • 서버 2대 이상일 때 인메모리 버킷은 각 서버가 독립된 한도를 관리해서 실질 한도가 N배가 돼요
  • Redis 분산 버킷으로 전환하면 모든 서버가 같은 양동이를 공유해요
  • Redis 장애 시 폴백 전략(인메모리 fallback)도 고려해야 해요

🎯 면접관을 홀리는 핵심 멘트

"Rate Limit을 인메모리로 시작했다가, 서버 스케일아웃 시점에 Redis 분산 버킷으로 전환했습니다. Bucket4j의 ProxyManager 덕분에 비즈니스 로직은 그대로 두고 저장소만 교체할 수 있었어요. 단, Redis 장애 시에는 인메모리 폴백으로 graceful degradation 하도록 Circuit Breaker를 걸어뒀습니다."


과제 2. ResponseCacheAdvisor를 Spring Cache + Redis로 전환

채점 포인트

항목 배점
RedisCacheConfiguration + TTL 설정 25%
@Cacheable 적용 또는 CacheManager 직접 사용 25%
캐시 키 설계 (SHA-256 해시 재활용) 25%
TTL 차별화 (대화 vs 지식 검색) 25%

튜터의 가이드

1. Redis Cache 설정

@Configuration
@EnableCaching
public class RedisCacheConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
        RedisCacheConfiguration chatConfig = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(1))
                .serializeValuesWith(
                    RedisSerializationContext.SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer()));

        RedisCacheConfiguration ragConfig = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(30))
                .serializeValuesWith(
                    RedisSerializationContext.SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer()));

        return RedisCacheManager.builder(factory)
                .withCacheConfiguration("chat-responses", chatConfig)
                .withCacheConfiguration("rag-responses", ragConfig)
                .build();
    }
}

2. Advisor에서 CacheManager 사용

@Cacheable 어노테이션은 서비스 메서드에 붙이는 방식이라, advisor 내부에서 직접 쓰기엔 구조가 어색해요. 대신 CacheManager를 주입받아 직접 get/put 하는 방식이 advisor 패턴에 더 적합해요.

public class ResponseCacheAdvisor implements BaseAdvisor {

    private final CacheManager cacheManager;
    private final String cacheName;

    @Override
    public ChatClientRequest before(ChatClientRequest request, AdvisorChain chain) {
        String cacheKey = generateCacheKey(request);
        Cache cache = cacheManager.getCache(cacheName);
        ChatClientResponse cached = cache.get(cacheKey, ChatClientResponse.class);
        if (cached != null) {
            throw new CacheHitException(cached);
        }
        return request.mutate().context(CACHE_KEY_CONTEXT, cacheKey).build();
    }
}

3. TTL 차별화

cacheName을 생성자로 주입받으면 같은 advisor 코드로 TTL이 다른 캐시를 만들 수 있어요.

new ResponseCacheAdvisor(cacheManager, "chat-responses")   // TTL 1분
new ResponseCacheAdvisor(cacheManager, "rag-responses")     // TTL 30분

🎯 면접관을 홀리는 핵심 멘트

"LLM 응답 캐시를 Spring Cache + Redis로 구현할 때, 시나리오별 TTL 차별화가 핵심이었습니다. 캐릭터 대화는 1분, 지식 검색은 30분으로 분리해서, 개인화 응답의 신선도와 반복 검색의 비용 절감을 동시에 달성했어요."


과제 3. Usage 대시보드 API에 Cost Guardrail 메트릭 추가

채점 포인트

항목 배점
RateLimitAdvisor에 blockedCount 카운터 추가 20%
ResponseCacheAdvisor에 hitCount/missCount 카운터 추가 30%
CostGuardrailSummary record 설계 20%
API 응답에 ApiResponse<T> 래핑 15%
cacheHitRate 계산 로직 15%

튜터의 가이드

1. Advisor에 카운터 추가

AtomicLong으로 Day 19 UsageTrackingAdvisor와 동일한 패턴이에요.

public class RateLimitAdvisor implements BaseAdvisor {
    private final AtomicLong blockedCount = new AtomicLong(0);

    @Override
    public ChatClientRequest before(ChatClientRequest request, AdvisorChain chain) {
        // ... 기존 로직 ...
        if (!probe.isConsumed()) {
            blockedCount.incrementAndGet();
            throw new RateLimitExceededException(clientKey, retryAfterMillis);
        }
        return request;
    }

    public long getBlockedCount() { return blockedCount.get(); }
}

2. CostGuardrailSummary record

public record CostGuardrailSummary(
    long rateLimitBlockedCount,
    long cacheHitCount,
    long cacheMissCount,
    double cacheHitRate,
    long retryCount
) {}

3. 컨트롤러 확장

@GetMapping("/api/admin/cost-guardrail")
public ResponseEntity<ApiResponse<CostGuardrailSummary>> getCostGuardrail() {
    long hits = cacheAdvisor.getHitCount();
    long misses = cacheAdvisor.getMissCount();
    double hitRate = (hits + misses) > 0
            ? Math.round(hits * 10000.0 / (hits + misses)) / 100.0
            : 0.0;

    CostGuardrailSummary summary = new CostGuardrailSummary(
            rateLimitAdvisor.getBlockedCount(),
            hits, misses, hitRate,
            retryAdvisor.getRetryCount()
    );
    return ResponseEntity.ok(ApiResponse.success(summary));
}

🎯 면접관을 홀리는 핵심 멘트

"Cost Guardrail의 효과를 수치로 증명할 수 있어야 의사결정자를 설득할 수 있습니다. Rate Limit 차단 횟수, 캐시 히트율, 재시도 횟수를 한 API로 보여주면 '이 가드를 걸어서 월 비용이 얼마나 줄었는지'를 바로 보여줄 수 있어요."


생각해볼 주제 예시답안


1. LLM 비용 최적화의 우선순위

문제 상황 요약

Rate Limiting, 응답 캐싱, 컨텍스트 캐싱, 모델 다운그레이드, 프롬프트 압축 — 비용을 줄이는 방법이 여럿 있는데, 어떤 순서로 적용해야 할까?

튜터의 가이드 및 해설

"모든 걸 다 하면 되지 않나요?"의 함정복잡성 비용이에요. 최적화 기법을 하나 추가할 때마다 코드 복잡도, 디버깅 난이도, 운영 부담이 올라가요. 최적화 자체가 비용이 드는 거죠.

우선순위 원칙: "가장 큰 낭비부터 잡아라"

  1. 모니터링 먼저 — 비용 구조를 모르는 상태에서 최적화하면 엉뚱한 곳을 잡게 돼요. Day 19의 UsageTrackingAdvisor로 먼저 "어디서 토큰이 많이 나가는지"를 파악하세요.

  2. 프롬프트 압축 — 가장 비용 대비 효과가 높아요. 시스템 프롬프트에서 불필요한 반복을 제거하는 것만으로 20~30% 토큰을 아낄 수 있어요. 코드 변경 없이 프롬프트 텍스트만 다듬으면 돼요.

  3. 응답 캐싱 — 반복 질문이 많은 서비스라면 효과가 크고, 적은 서비스라면 건너뛰어도 돼요. "반복률"을 모니터링으로 먼저 확인하세요.

  4. Rate Limiting — 프로덕션에 올리는 순간 필수예요. 최적화라기보다 안전장치에 가까워요.

  5. 모델 다운그레이드 — 모든 요청에 최고급 모델을 쓸 필요는 없어요. "간단한 분류는 경량 모델, 복잡한 생성은 고급 모델"로 라우팅하면 비용이 크게 줄어요. 단, 품질 저하를 감수해야 해요.

🎯 면접관을 홀리는 핵심 멘트

"LLM 비용 최적화는 '모니터링 → 프롬프트 정리 → 캐싱 → Rate Limit → 모델 라우팅' 순서로 접근했습니다. 가장 큰 깨달음은 프롬프트 압축이 코드 변경 0줄로 가장 높은 ROI를 준다는 거였어요. 최적화 기법 자체가 복잡성 비용을 발생시키니까, 모니터링 없이 최적화부터 하면 오히려 전체 비용이 올라갑니다."


2. 캐싱과 개인화의 충돌

문제 상황 요약

응답 캐싱은 "같은 질문에 같은 답"을 전제로 하는데, ai-friends의 캐릭터는 사용자마다 호감도가 다르고 대화 맥락이 달라요. 캐시 키를 어떻게 설계해야 할까?

튜터의 가이드 및 해설

핵심은 "무엇이 같으면 같은 응답인가"를 정의하는 거예요.

프롬프트 텍스트만으로 캐시 키를 만들면 위험해요. 같은 "오늘 기분 어때?"라는 질문이라도, 호감도가 80인 사용자와 -20인 사용자에게 가는 시스템 프롬프트가 다르니까요. 다행히 우리 generateCacheKey()시스템 프롬프트 + 사용자 메시지 전체를 해시하기 때문에, 호감도가 다르면 시스템 프롬프트가 달라지고, 자연스럽게 다른 캐시 키가 생성돼요.

안전한 캐시 키 설계 원칙:

포함해야 하는 것 이유
전체 프롬프트 (시스템 + 사용자) 개인화 컨텍스트 반영
모델 이름 같은 질문이라도 모델이 다르면 응답이 다름
temperature 0이면 결정론적, 0 초과면 매번 다를 수 있음

캐싱이 안전한 시나리오:

  • 지식 검색 (RAG): "이 캐릭터의 생일은?" → 정답이 하나
  • 설정 조회: "캐릭터 프로필 요약" → 설정이 바뀌지 않으면 동일

캐싱이 위험한 시나리오:

  • 자유 대화 (temperature > 0): 매번 다른 답이 와야 자연스러움
  • 감정 표현: 같은 질문이라도 대화 흐름에 따라 감정이 달라져야 함

🎯 면접관을 홀리는 핵심 멘트

"개인화 서비스에서 LLM 캐싱의 핵심은 '캐시 키에 개인화 컨텍스트를 포함시키는 것'이었습니다. 시스템 프롬프트에 사용자별 호감도·대화 이력이 녹아 있으니, 프롬프트 전체를 해시하면 자연스럽게 사용자별 캐시가 분리됩니다. 다만 temperature > 0인 자유 대화는 캐싱 대상에서 제외해야 사용자 경험이 유지돼요."


3. 무료 티어와 유료 티어의 Rate Limit 설계

문제 상황 요약

SaaS에서 무료/프로/엔터프라이즈 등급별로 API 한도를 다르게 설정해야 해요. resolveClientKey()를 어떻게 확장할까?

튜터의 가이드 및 해설

1단계: 클라이언트 키에 등급 정보를 조합

private String resolveClientKey(ChatClientRequest request) {
    Map<String, Object> context = request.context();
    String userId = context.getOrDefault("userId", "anonymous").toString();
    String tier = context.getOrDefault("subscriptionTier", "free").toString();
    return tier + ":" + userId;
}

"free:user-123", "pro:user-456", "enterprise:user-789" — 이렇게 키가 만들어지면, 같은 user-123이라도 등급이 올라가면 새 양동이가 생성돼요.

2단계: 등급별 양동이 크기 분기

private Bucket createBucket(String tier) {
    long capacity = switch (tier) {
        case "pro" -> 100;
        case "enterprise" -> Long.MAX_VALUE;
        default -> 10;
    };
    return Bucket.builder()
            .addLimit(Bandwidth.simple(capacity, Duration.ofMinutes(1)))
            .build();
}

3단계: 엔터프라이즈 "무제한"의 현실

Long.MAX_VALUE는 사실상 무제한이지만, 진짜 무제한은 위험해요. 버그나 공격으로 초당 만 건이 날아오면 LLM 프로바이더 계정이 정지될 수 있어요. 엔터프라이즈도 "분당 1,000회"처럼 현실적인 상한을 두되, 무료(10)보다 100배 높게 잡는 게 안전합니다.

설계 원칙:

  • 무료: 맛보기 수준 (분당 10회)
  • 프로: 실무 사용 가능 (분당 100회)
  • 엔터프라이즈: 대량 처리 가능하되 상한 존재 (분당 1,000회)
  • 모든 등급에 burst 허용 (순간적으로 한도의 2배까지 허용 후 점진 회복)

🎯 면접관을 홀리는 핵심 멘트

"Rate Limit의 등급별 설계에서 가장 중요한 건 '엔터프라이즈도 무제한이 아니다'는 원칙입니다. 진짜 무제한은 프로바이더 계정 정지 리스크가 있어서, 현실적 상한을 두되 무료 대비 100배 수준으로 잡았어요. Bucket4j의 Bandwidth.simple() 한 줄로 등급별 양동이 크기를 다르게 설정하면 됩니다."

더 배우려면

실무 프로젝트까지 가고 싶다면

팀스파르타 백엔드 부트캠프에서 인스타그램 클론을 풀스택으로 완성합니다.