
지난 글에서 같은 prefix를 60번 호출해 캐싱의 비용 곡선을 그렸다.
그 글 마지막에 이렇게 약속했다.
“다음 글에서는 같은 캐싱 설계를 실제 워크로드에 옮겨본다.
이번 글이 ‘캐싱은 왜 켜는가’였다면
다음 글은 ‘캐싱을 어떻게 붙이는가’다.”
이번 글이 그 약속이다.
먼저 솔직하게 밝힌다.
운영 코드를 그대로 옮길 수는 없어서 핀테크에서 흔한 거래 메시지 분류기를 대표 시나리오로 재구성했다.
수치는 앞 글의 실측을 외삽한 것이고 설계 판단은 실제로 캐시를 붙이며 부딪힌 것들이다.
“왜 켜는가”는 앞의 세 글에서 끝냈으니 여기서는 마커를 어디에 박고
TTL을 어떤 코드 경로에서 가를지를 일지처럼 쓴다.
워크로드부터 정의한다
캐싱 설계는 워크로드 정의에서 시작한다.
여기서 잡은 시나리오는 이렇다.
거래 메시지 한 건을 받아 사전 정의된 라벨로 분류하는 작업이다.
시스템 프롬프트는 세 덩어리로 구성된다.
- 분류 라벨 정의: 약 800 토큰. 거의 안 바뀐다.
- few-shot 예시 20개: 약 2,400 토큰. 분기마다 손보지만 평소엔 고정이다.
- 회사 약관·규정 발췌: 약 1,600 토큰. 규정 개정 때만 바뀐다.
여기에 거래 메시지 한 건(약 50 토큰)이 사용자 입력으로 들어온다.
합치면 호출당 입력이 5천 토큰에 육박하는데 그중 4,800 토큰이 매 호출 똑같다.
캐싱 교과서에 나오는 가장 이상적인 모양이다.
길고, 변하지 않고, 매 호출 재사용되는 prefix.

prefix를 캐시 친화적으로 배치한다
캐시가 먹으려면 prefix가 정확히 일치해야 한다.
그래서 메시지 레이아웃을 “안 변하는 것 → 가끔 변하는 것 → 매번 변하는 것” 순으로 고정한다.
[1] 시스템 프롬프트 (라벨 정의) ← 거의 불변
[2] 도구 정의 ← 거의 불변
[3] 약관·규정 발췌 ← 규정 개정 시에만
[4] few-shot 예시 20개 ← 분기마다
---- 여기까지 캐시 ----
[5] 거래 메시지 한 건 ← 매 호출 변함
원칙은 단순하다.
자주 바뀌는 걸 뒤로 보낼수록 앞쪽 캐시가 오래 산다.
여기서 핀테크 특유의 함정이 하나 있다.
거래 시각, 계좌번호, 요청 ID 같은 동적 값을 무심코 시스템 프롬프트 앞쪽에 끼워넣기 쉽다.
“오늘 날짜: 2026-06-03” 한 줄이 prefix에 들어가는 순간 자정마다 캐시가 통째로 깨진다.
동적 값은 반드시 [5]번, 사용자 입력 블록으로 내려야 한다.
여담이지만, 이 함정은 분류기가 아니라 챗봇을 만들며 실제로 밟았다.
시스템 프롬프트 맨 앞에 “현재 시각” 한 줄을 넣어두고 멀티턴을 돌렸더니 턴마다 cache_read가 0으로 찍혔다.
prefix 길이만 한참 의심하다 매 턴 바뀌는 게 그 시각 한 줄뿐이라는 걸 뒤늦게 봤다.
그 줄을 사용자 입력 쪽으로 내리자 두 번째 턴부터 캐시가 붙기 시작했다.
캐싱은 켜는 일보다 무엇이 prefix를 더럽히는지 찾아내는 게 일의 대부분이었다.
cache_control 마커를 어디에 박는가
앞 글에서 캐시 포인트는 최대 4개까지 둘 수 있다고 했다.
이 워크로드는 그 4개를 알뜰하게 다 쓰기 좋은 구조다.
system = [
{"type": "text", "text": LABEL_DEFINITIONS,
"cache_control": {"type": "ephemeral"}}, # 캐시 포인트 1
]
tools = [...] # 도구 정의에도 cache_control 부착 가능
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": POLICY_EXCERPT,
"cache_control": {"type": "ephemeral"}}, # 캐시 포인트 2
{"type": "text", "text": FEW_SHOT_BLOCK,
"cache_control": {"type": "ephemeral"}}, # 캐시 포인트 3
{"type": "text", "text": transaction_msg}, # 매번 변하는 입력 (마커 없음)
],
}
]
포인트를 단계별로 쪼개두면 일부만 바뀌어도 앞쪽은 살아남는다.
few-shot을 한 개 추가하면 캐시 포인트 3 이후만 다시 써지고
라벨 정의와 약관 캐시(포인트 1·2)는 그대로 재사용된다.
마커를 맨 끝 한 곳에만 몰아 박으면 이 부분적 생존이 안 된다.
few-shot 한 줄 고친 게 전체 캐시를 날리는 일이 생긴다.

TTL을 코드 경로에서 가른다
앞 글의 실측이 남긴 결론은 한 줄이었다.
“TTL 선택은 비용이 아니라 호출 간격으로 결정해야 한다.”
그래서 TTL을 상수로 박지 않고 워크플로우 성격에 따라 함수로 고른다.
def pick_ttl(workflow: str) -> str:
# 실시간 분류: 거래가 초 단위로 들어와 호출 간격이 5분 안쪽
if workflow == "realtime_classify":
return "5m"
# 야간 배치·정산: 호출이 띄엄띄엄, 간격이 5분을 자주 넘김
if workflow == "nightly_batch":
return "1h"
return "5m"
실시간 분류는 거래가 쉴 새 없이 들어와 캐시가 5분 안에 계속 갱신된다.
쓰기 단가가 싼 5분 TTL이 맞다.
반대로 야간 배치는 호출이 드물어 5분 캐시가 식어버린다.
캐시 미스로 매번 처음부터 다시 쓰느니 2배 쓰기 비용을 한 번 무는 1시간 TTL이 싸다.
앞 글에서 본 5분 캐시에 1시간 호출이 free-ride하던 함정도 여기서 조심해야 한다.
두 워크플로우가 같은 prefix를 공유하면 TTL이 섞인다.
그래서 실시간과 배치는 prefix 맨 앞에 워크플로우 태그를 한 줄 달아 캐시 키를 분리했다.
캐시 미스를 로깅한다
캐싱은 켜두는 게 아니라 보고 있는 거다.
매 호출의 usage 네 가지를 구조화해 남긴다.
u = response.usage
log.info("cache", extra={
"workflow": workflow,
"input": u.input_tokens,
"cwrite": u.cache_creation_input_tokens,
"cread": u.cache_read_input_tokens,
"output": u.output_tokens,
})
이 로그 하나로 운영 중 이상을 바로 잡아낸다.
cread가 계속 0이다 → prefix가 매 호출 깨지고 있다. 동적 값이 앞쪽에 샜는지 본다.cwrite가 매 호출 크게 잡힌다 → TTL이 짧거나 호출 간격이 길어 캐시가 자꾸 새로 써진다.- 실시간 워크플로우에서
cwrite에 작은 값(20~40)이 꾸준히 붙는다
→ 앞 글에서 본 5분 TTL의 user message 확장 비용이다. 정상이다.
마지막 항목을 모르면 “왜 cache_creation이 0이 아니지?”에서 반나절을 태운다.
앞 글에서 실측으로 한 번 짚었던 패턴이 운영 알람의 임계값을 잡는 데 그대로 쓰인다.

가정한 절감액과 그 한계
앞 글의 호출당 수치를 이 워크로드에 그대로 얹으면 그림은 분명하다.
하루 1만 건, 실시간 5분 TTL 기준으로 캐싱 OFF 대비 월 1만 달러대 절감이 나온다.
다만 이건 가정이다.
실제 운영에서는 세 가지가 그림을 흐린다.
- 캐시 미스율: few-shot이나 약관을 손대는 빈도가 높으면 절감이 줄어든다.
- 호출 간격 분포: 거래가 한산한 시간대엔 5분 캐시가 식어 미스가 늘어난다.
- prefix 오염: 동적 값이 한 군데라도 새면 절감이 통째로 사라진다.
그래서 가정 숫자는 “상한선”으로만 본다.
진짜 숫자는 위 로그를 한 주 쌓아 미스율을 곱해봐야 나온다.
캐싱 4부작을 닫으며
세 글에 걸쳐 캐싱을 한 바퀴 돌았다.
- 개념 — 캐싱은 KV 행렬을 재사용하는 표시다. 쓰기는 비싸고 읽기는 거의 공짜.
- 실측 — 60번 호출로 손익분기(2~3회)와 16% 비용을 눈으로 확인했다.
- 설계 — prefix를 캐시 친화적으로 배치하고, 마커를 단계별로 박고, TTL을 호출 간격으로 가르고, 미스를 로깅한다.
캐싱은 단가를 안 바꾼다.
같은 토큰을 비싼 길로 보낼지 싼 길로 보낼지, 그 흐름만 바꾼다.
그 흐름을 설계하는 감각이 결국 LLM 비용을 다루는 일의 핵심이었다.