LLM 응답을 다국어로 캐시할 때 키 설계와 교차 오염 막기

LLM 응답을 다국어로

캐시 키에서 언어 코드 하나를 빼먹으면 무슨 일이 벌어지는지 직접 코드로 재현해봤더니, 한국어 요청과 프랑스어 요청이 정확히 같은 해시값을 만들어냈습니다. 소스 문장이 같고 모델 파라미터가 같으면 대상 언어가 달라도 캐시 키가 동일해지는 구조였기 때문입니다. LLM 응답을 다국어로 캐시할 때 이 문제를 그냥 넘기면, 캐시 히트율은 멀쩡한데 사용자에게는 엉뚱한 언어의 답이 나가는 사고로 이어집니다.

이 글에서는 언어별 캐시 키를 어떻게 설계해야 이런 교차 오염을 막을 수 있는지, 그리고 그 설계를 선택했을 때 감수해야 할 히트율 손실은 어느 정도인지 코드와 함께 정리해봤습니다.

언어 코드가 빠진 캐시 키가 만드는 교차 오염 사고

번역이나 다국어 응답 API를 캐싱할 때 가장 흔한 실수는 원문 텍스트만 해시하고 대상 언어(target_lang)는 별도 파라미터로 취급하는 구조입니다. 요청 본문에서는 target_lang="fr"target_lang="ko"가 분명히 다르지만, 캐시 키를 만드는 함수가 user_prompt만 넣고 target_lang을 빼먹으면 두 요청이 같은 키를 가리키게 됩니다.

아래 코드로 이 현상을 그대로 재현할 수 있습니다.

import hashlib, json

def bad_cache_key(model, user_prompt, params):
    payload = {"model": model, "user": user_prompt, "params": params}
    return hashlib.sha256(
        json.dumps(payload, sort_keys=True).encode("utf-8")
    ).hexdigest()

req_ko = bad_cache_key("gpt-4o-mini", "오늘 날씨 어때?", {"target_lang": "ko"})
req_fr = bad_cache_key("gpt-4o-mini", "오늘 날씨 어때?", {"target_lang": "fr"})
# params 딕셔너리 안에 target_lang이 있는데도
# 실제 프로덕션 코드에서는 params를 로깅용으로만 넘기고
# 해시 payload에는 포함시키지 않는 경우가 많습니다
print(req_ko == req_fr)  # 설계에 따라 True가 나올 수 있음

params를 해시 대상에서 빼먹거나, 캐시 미들웨어가 user_prompt만 정규화 후 해싱하도록 짜여 있으면 위 두 요청은 같은 캐시 엔트리를 공유합니다. 결과적으로 먼저 들어온 언어의 응답이 뒤에 들어온 다른 언어 요청자에게도 그대로 나갑니다. 다국어 SaaS나 고객지원 챗봇처럼 언어별 응답이 반드시 갈려야 하는 서비스에서는 치명적인 결함입니다.

캐시 키에 언어 태그를 어디에, 어떻게 넣어야 할까요?

여러 언어 코드가 화면에 표시된 개발자 작업 환경

가장 안전한 방식은 언어 코드를 해시 payload 안에도 넣고, 동시에 키 앞부분의 네임스페이스로도 노출시키는 이중 구조입니다. 해시 안에만 넣으면 충돌은 막을 수 있어도 Redis에서 언어별로 키를 스캔하거나 일괄 삭제하기가 어렵습니다.

import hashlib, json

def build_cache_key(model, system_prompt, user_prompt,
                     source_lang, target_lang, params):
    payload = {
        "model": model,
        "system": system_prompt,
        "user": user_prompt,
        "source_lang": source_lang,
        "target_lang": target_lang,
        "params": params,
    }
    body_hash = hashlib.sha256(
        json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")
    ).hexdigest()
    return f"llmcache:{source_lang}-{target_lang}:{model}:{body_hash}"

print(build_cache_key("gpt-4o-mini", "You are a translator.",
                       "오늘 날씨 어때?", "ko", "fr", {"temperature": 0}))
# llmcache:ko-fr:gpt-4o-mini:9f2a1c...

이렇게 하면 ko-frko-ko는 네임스페이스 단계에서부터 완전히 갈라지고, 해시 충돌이 우연히 일어나도 언어 조합이 다르면 절대 같은 문자열이 나오지 않습니다. Redis라면 SCAN llmcache:ko-fr:* 같은 패턴 매칭으로 특정 언어쌍만 골라 만료시키는 것도 가능합니다. Redis의 키 스캔·만료 옵션은 공식 커맨드 문서에 정리되어 있으니 TTL 전략을 짤 때 참고할 만합니다.

시맨틱 캐시는 다국어에서 오히려 더 위험합니다

단순 텍스트 해시가 아니라 임베딩 유사도로 캐시를 조회하는 시맨틱 캐시(GPTCache 같은 도구)를 쓰는 경우는 문제가 더 미묘해집니다. 다국어 임베딩 모델은 “오늘 날씨 어때?”와 “How’s the weather today?”를 벡터 공간에서 아주 가깝게 배치하도록 설계되어 있습니다. 검색 품질 관점에서는 장점이지만, 캐시 관점에서는 한국어 질문에 영어 캐시 응답이 그대로 튀어나올 수 있다는 뜻입니다.

GPTCache는 유사도 임계값(similarity_threshold)을 조절하는 기능을 제공하지만, 임계값만으로 언어 경계를 완전히 막기는 어렵습니다. 실무에서는 벡터 유사도 검색 전에 언어 메타데이터로 하드 필터를 거는 방식이 더 안정적입니다. 즉 “언어가 같은 후보 중에서만 유사도를 비교”하도록 검색 범위 자체를 좁히는 것이 핵심이고, 유사도 점수를 아무리 엄격하게 잡아도 언어 필터가 빠지면 오염 가능성은 남습니다.

언어 정규화 규칙부터 통일해 보세요

언어 코드를 키에 넣기 시작하면 곧바로 마주치는 문제가 표기 불일치입니다. 같은 한국어를 어떤 요청은 ko, 어떤 요청은 ko-KR, 어떤 클라이언트는 kr로 보냅니다. 이걸 그대로 키에 박으면 사실상 같은 캐시여야 할 항목이 세 갈래로 쪼개지면서 히트율이 눈에 띄게 떨어집니다.

서버와 데이터베이스 캐시 구조를 나타내는 다이어그램

이런 언어 코드 표기 문제는 웹 국제화·현지화 작업 전반에서 반복적으로 나오는 이슈라, 브라우저와 서버가 언어를 주고받을 때는 표준화된 언어 태그 체계를 따르는 것이 일반적입니다. 이 표준을 정의한 규격이 BCP47이며, 서비스에 들어오는 언어 코드를 캐시 키에 넣기 전에 이 규격 기준으로 정규화하는 함수를 하나 통과시키는 것만으로 불필요한 캐시 분산을 상당 부분 줄일 수 있습니다.

def normalize_lang(code: str) -> str:
    code = code.strip().lower().replace("_", "-")
    aliases = {"kr": "ko", "jp": "ja", "en-us": "en", "en-gb": "en"}
    return aliases.get(code, code.split("-")[0])

for c in ["ko", "KO-KR", "kr", "en_US"]:
    print(c, "->", normalize_lang(c))
# ko -> ko
# KO-KR -> ko
# kr -> ko
# en_US -> en

네임스페이스 분리와 히트율 사이의 트레이드오프

언어별로 캐시를 완전히 갈라놓으면 안전하지만 공짜는 아닙니다. 숫자·코드 스니펫·고유명사처럼 언어와 무관하게 답이 똑같은 요청까지 언어별로 중복 저장하게 되어 전체 캐시 크기와 미스율이 함께 늘어납니다. 아래 표는 이 글에서 다룬 세 가지 설계를 정리한 것입니다.

설계 방식 교차 오염 위험 히트율 특성 적합한 상황
언어별 완전 분리 네임스페이스 매우 낮음 언어 무관 콘텐츠에서 중복 저장 발생 번역·고객지원처럼 언어 오류가 치명적인 서비스
단일 캐시 + 해시에 언어 필드 포함 낮음 (해시 충돌 확률만 존재) 네임스페이스 분리보다 약간 높음 언어 종류가 많고 관리 편의가 중요한 경우
임베딩 시맨틱 캐시 + 언어 하드 필터 필터 누락 시 높음 표현이 달라도 히트 가능해 가장 높음 자유 형식 질의응답, FAQ 챗봇

정답은 하나로 고정되지 않고, 응답 오류의 비용이 큰 서비스일수록 첫 번째나 두 번째 방식으로 보수적으로 가는 편이 안전합니다. Anthropic이 제공하는 프롬프트 캐싱 문서에서도 캐시는 프리픽스 콘텐츠가 정확히 일치할 때만 재사용되도록 설계되어 있다고 명시하는데, 이는 결국 “내용이 다르면 반드시 다른 키를 써야 한다”는 이번 글의 원칙과 같은 방향입니다.

지금 코드에 바로 적용해볼 캐시 키 점검 순서

지금 캐시 코드를 열어서 세 가지만 먼저 확인해보시는 걸 추천합니다. 첫 번째는 캐시 키 payload에 target_lang(혹은 source_lang)이 실제로 해시 대상 문자열 안에 들어가는지이고, 두 번째는 들어오는 언어 코드가 정규화 없이 그대로 키에 박히고 있지는 않은지입니다. 세 번째로 시맨틱 캐시를 쓰고 있다면 유사도 검색 전에 언어 필터가 걸려 있는지를 로그로 한 번 찍어보시면 됩니다.

이 세 가지만 손봐도 LLM 응답을 다국어로 캐시할 때 생기는 교차 오염의 대부분은 사전에 막을 수 있습니다. 다음 단계로는 언어쌍별 캐시 히트율을 모니터링 지표에 따로 추가해서, 특정 언어 조합만 유독 미스율이 높은지 확인해보시는 것이 실질적인 개선 지점을 찾는 데 도움이 됩니다.

LLM 임베딩을 캐시할 때, 키를 뭘로 잡아야 할까요

참고: LLM 응답을 다국어로 — 위키백과

Leave a Comment