
LLM API Token 비용 절감: 게임 서버 텍스트 캐싱·압축 아키텍처
멀티플레이 게임 서버에서 반복되는 NPC 대화, 퀘스트 로그, 운영 메시지로 늘어나는 LLM API Token 비용을 줄이는 방법을 다룬다. 의미 기반 캐시, 프롬프트 압축, Redis TTL, 관측 지표를 구현 순서와 함께 정리한다.
TL;DR
게임 서버의 LLM API 비용은 같은 의도의 요청을 매번 새 프롬프트로 전송할 때 빠르게 증가한다. 요청을 정규화한 뒤 prompt_version을 포함한 캐시 키로 결과를 재사용하고 장기 대화는 요약문과 최근 발화만 보내면 입력 Token과 응답 지연을 함께 줄일 수 있다.
핵심은 캐시는 결정 가능한 요청에만 적용하고 압축 전후의 품질·캐시 적중률·Token 사용량을 같은 지표로 관측하는 것이다.
게임 서버의 LLM API Token 비용은 왜 증가하는가?
NPC 대사 생성, 파티 번역, 퀘스트 힌트, 신고 내용 분류처럼 LLM을 호출하는 기능은 요청 수보다 입력 문맥 길이에서 비용이 커지는 경우가 많다. 특히 대화 이력과 월드 상태를 매 요청마다 그대로 붙이면 플레이어 한 명의 세션이 길어질수록 같은 정보가 반복 전송된다.
LLM 호출의 월간 비용은 다음처럼 근사할 수 있다.
- : 월간 호출 수
- , : 요청당 평균 입력·출력 Token 수
- , : 모델의 100만 Token당 입력·출력 가격
예를 들어 동일한 NPC에게 반복해서 “다음 퀘스트가 뭐야?”를 묻는 요청이 캐시되지 않으면 모델은 거의 같은 답을 만들기 위해 매번 월드 설정과 퀘스트 상태를 다시 읽는다. 이 문제는 API 재시도, 클라이언트 중복 전송, 여러 서버 인스턴스에서의 동시 요청으로 더 커진다.
| 비용 증가 원인 | 게임 서버에서의 예 | 우선 대응 |
|---|---|---|
| 중복 요청 | 같은 퀘스트 힌트를 반복 요청 | 결과 캐시 |
| 긴 문맥 | 대화 전체 이력과 인벤토리 전문 전송 | 요약 + 최근 N개 발화 |
| 표현만 다른 동일 요청 | 공백·대소문자만 다른 채팅 명령 | 입력 정규화 |
| 동시 미스 | 여러 클라이언트가 같은 공용 안내 요청 | 요청 병합(single-flight) |
| 재시도 중복 | 타임아웃 후 같은 요청 재전송 | idempotency key |
LLM 텍스트 캐싱 아키텍처는 어떻게 구성할까?
가장 단순한 구조는 게임 서버 앞에 Redis를 두고 LLM 호출 전 캐시를 조회하는 방식이다. 캐시 키에는 사용자 입력뿐 아니라 결과에 영향을 주는 버전과 게임 상태를 포함해야 한다.
flowchart LR
C[Game Client] --> S[Game Server]
S --> N[Request Normalizer]
N --> K[Cache Key Builder]
K --> R[(Redis)]
R -->|Hit| S
R -->|Miss| L[LLM API]
L --> R
L --> S
캐시에는 개인 상태가 섞이지 않는 공용 응답과, 플레이어별 상태를 명시적으로 포함한 개인 응답을 구분해 저장한다. 예를 들어 서버 공지 요약은 공용 캐시가 적합하지만 인벤토리를 반영한 장비 추천은 player_id 또는 권한 범위를 키에 넣어야 한다.

캐시 키에 반드시 포함할 값
import { createHash } from "node:crypto";
type LlmRequest = {
feature: "npc_hint" | "party_translate";
promptVersion: "2026-08-01";
locale: "ko-KR" | "en-US";
worldStateVersion: number;
normalizedInput: string;
};
export function buildCacheKey(request: LlmRequest) {
const source = [
request.feature,
request.promptVersion,
request.locale,
request.worldStateVersion,
request.normalizedInput,
].join(":");
const digest = createHash("sha256").update(source).digest("hex");
return `llm:${request.feature}:${digest}`;
}
promptVersion을 키에 넣지 않으면 프롬프트를 수정한 뒤에도 이전 응답이 반환된다. 반대로 변하지 않는 모든 월드 데이터를 키에 넣으면 적중률이 지나치게 낮아진다. 응답에 실제로 영향을 주는 상태의 버전만 넣는 것이 좋다.
캐시 키 정규화는 왜 필요한가?
텍스트는 의미가 같아도 표기가 다르다. 공백, 줄바꿈, 대소문자, Unicode 정규화 차이 때문에 캐시 키가 달라지면 Redis는 서로 다른 요청으로 처리한다.
다만 정규화가 의미를 바꾸면 안 된다. 플레이어 이름, 아이템 ID, 좌표, 코드 블록처럼 정확한 원문이 중요한 필드는 무리하게 소문자화하거나 제거하지 않는다.
export function normalizeCacheInput(input: string) {
return input.normalize("NFC").trim().replace(/\s+/g, " ");
}
이 함수는 일반 자연어 질의에만 적용한다. 명령어와 구조화된 데이터는 JSON을 파싱한 뒤 키를 정렬해 직렬화하는 편이 안전하다.
프롬프트 압축은 어떻게 구현할까?
압축의 목표는 문장을 기계적으로 짧게 만드는 것이 아니라 다음 응답에 필요한 정보만 남기는 것이다. 게임 대화에는 보통 최근 발화, 확정된 사실, 현재 퀘스트 목표만 필요하다.
Step 1. 고정 프롬프트와 동적 문맥을 분리한다
시스템 지침, 금칙어 정책, 출력 JSON 스키마는 버전 관리되는 고정 프롬프트로 유지한다. 플레이어의 현재 지역, 활성 퀘스트, 최근 대화처럼 매 요청 달라지는 정보만 동적 문맥으로 보낸다.
Step 2. 오래된 대화는 상태 요약으로 교체한다
최근 4~8개 발화는 원문으로 두고 그 이전 발화는 서버가 유지하는 짧은 사실 요약으로 치환한다. 요약에는 추측이 아니라 확정된 게임 상태를 기록한다.
{
"conversation_summary": [
"플레이어는 수정 동굴 입구에 있다.",
"수정 열쇠 1개를 보유했다.",
"경비병 NPC에게 북문 위치를 들었다."
],
"recent_messages": [
{ "role": "user", "content": "문은 어떻게 열어?" },
{ "role": "assistant", "content": "수정 열쇠를 제단에 사용하세요." }
],
"active_quest": "수정 동굴 탈출"
}
Step 3. 모델 출력 길이를 제한하고 구조화한다
NPC 힌트가 한 문장이면 충분하다면 max_output_tokens를 작게 설정하고 answer와 quest_id 같은 필요한 필드만 받는다. 출력 Token은 보통 캐시하기 쉬운 대신 단가가 높을 수 있으므로 불필요하게 긴 서사형 응답을 허용하지 않는다.
| 데이터 종류 | 권장 처리 | 이유 |
|---|---|---|
| 최근 대화 4~8개 | 원문 유지 | 현재 질문의 맥락 보존 |
| 오래된 대화 | 사실 요약 | 중복 Token 제거 |
| 플레이어 인벤토리 전체 | 관련 아이템만 추출 | 현재 답변과 무관한 상태 제거 |
| 월드 설정 전문 | 지역·퀘스트별 조각 조회 | RAG 문서 전체 전송 방지 |
| 안전 정책 | 고정 프롬프트 버전 관리 | 일관된 동작과 캐시 무효화 |
Redis TTL과 무효화 정책은 무엇이 적절한가?
TTL은 응답의 최신성 요구에 맞춰 정한다. 모든 항목에 같은 TTL을 쓰면 캐시가 오래되어 잘못된 게임 정보를 주거나 반대로 너무 빨리 만료되어 비용 절감 효과가 사라진다.
| 기능 | 예시 TTL | 무효화 기준 |
|---|---|---|
| 공용 NPC 힌트 | 10~60분 | 퀘스트 데이터·프롬프트 버전 변경 |
| 서버 공지 요약 | 1~5분 | 공지 게시·수정 |
| 파티 채팅 번역 | 1~24시간 | 원문·대상 언어 변경 |
| 개인 장비 추천 | 짧은 TTL 또는 미캐시 | 인벤토리·전투력 변경 |
| 신고 분류 | 장기 보관 가능 | 분류 모델·정책 변경 |
개인화 결과를 공용 키로 저장하는 것은 비용 문제보다 먼저 보안 문제다. 캐시 키와 값에 다른 플레이어의 닉네임, 채팅, 장비 정보가 섞이지 않도록 테넌트와 플레이어 범위를 분리해야 한다.
동시 캐시 미스는 어떻게 막을까?
인기 보스 공략이나 공용 이벤트 안내처럼 많은 사용자가 동시에 같은 질문을 하면 Redis 미스 직후 LLM API 요청이 한꺼번에 발생한다. 이를 cache stampede라고 한다.
가장 작은 해결책은 서버 프로세스 내에서 동일 키의 진행 중인 Promise를 공유하는 것이다. 멀티 인스턴스 환경에서 비용이 문제가 되면 Redis 분산 락이나 큐를 추가한다.
const pending = new Map<string, Promise<string>>();
export async function getOrGenerate(
key: string,
generate: () => Promise<string>,
) {
const existing = pending.get(key);
if (existing) return existing;
const task = generate().finally(() => pending.delete(key));
pending.set(key, task);
return task;
}
이 코드는 단일 게임 서버 인스턴스 안에서만 요청을 병합한다. 여러 인스턴스가 같은 공용 캐시 미스를 자주 만든다면 짧은 TTL의 Redis 락으로 확장하되 락 소유자가 실패했을 때의 만료 시간을 반드시 둔다.
비용 절감 효과는 어떤 지표로 검증해야 할까?
캐시를 넣은 뒤 비용이 줄었는지는 호출 수만으로 판단할 수 없다. 입력·출력 Token을 기능별로 분리해 기록하고 품질 저하와 지연 시간을 함께 확인해야 한다.
- : 캐시 히트 수
- : 캐시 미스 수
권장 대시보드 항목은 다음과 같다.
- 기능별 입력·출력 Token 총량과 추정 비용을 기록한다.
- 캐시 적중률, 미스 원인,
prompt_version별 응답 비율을 기록한다. - p50·p95 응답 시간과 사용자 피드백을 비교해 압축으로 인한 품질 저하를 확인한다.
- 캐시된 응답과 새 응답을 표본 추출해 오래된 퀘스트 정보나 개인 정보 노출이 없는지 점검한다.
자주 묻는 질문 (FAQ)
LLM 응답을 무조건 캐시해도 될까?
아니다. 실시간 전투 판단, 개인 인벤토리, 민감한 채팅처럼 최신성·개인화 요구가 큰 응답은 짧은 TTL을 쓰거나 캐시하지 않는 편이 안전하다.
프롬프트를 줄이면 답변 품질이 반드시 떨어질까?
그렇지 않다. 현재 질문과 무관한 이력을 제거하고 확정된 사실 요약을 남기면 비용을 줄이면서도 모델이 참고할 핵심 상태를 더 명확하게 전달할 수 있다.
Redis 캐시만으로 충분한가?
반복 요청이 주된 문제라면 충분히 효과적이다. 의미가 비슷하지만 문장이 다른 질의까지 재사용하려면 임베딩 기반 의미 캐시를 추가할 수 있지만 오답 재사용 위험과 임베딩 비용을 먼저 측정해야 한다.
정리
게임 서버의 LLM 비용 최적화는 먼저 정확한 캐시 키와 짧은 프롬프트에서 시작한다. 요청 정규화, prompt_version 기반 무효화, 대화 요약, 기능별 Token 관측만 적용해도 대부분의 반복 호출을 통제할 수 있다.
의미 기반 캐시는 캐시 적중률이 낮다는 측정 결과가 나온 뒤에 추가해도 늦지 않다. 처음부터 복잡한 계층을 만들기보다 공용 NPC 힌트나 번역처럼 결정적인 기능 하나에서 비용과 품질 지표를 확인하며 확장하자.


