Cache Strategies
캐시 전략은 서비스의 일관성, 지연을 가르는 중요한 결정이다. 읽기 패턴, 쓰기 패턴, 무효화 전략을 어떻게 조합하느냐에 따라 결과가 갈린다.
세 축
캐시 설계는 항상 두 시스템 사이의 동기화 문제다. Source of Truth(보통 DB)와 Cache다. 둘 사이의 동기화를 어떻게 처리하느냐가 곧 캐시 전략이다.
세 가지 축으로 나눠 본다.
- 읽기 패턴: Cache-Aside, Read-Through, Refresh-Ahead. 누가 캐시를 채우는가
- 쓰기 패턴: Write-Through, Write-Behind, Write-Around. DB와 캐시를 어떤 순서로 갱신하는가
- 무효화 전략: TTL, 명시적 삭제, 이벤트 기반, 키 버저닝. 오래된 데이터를 어떻게 걸러내는가
이 세 축을 따로 떼어보면, 흔히 부르는 “캐시 전략” 이름들이 사실은 세 축에서 한두 개를 뽑아 묶은 라벨이라는 게 보인다.
캐시가 어려운 이유는 단순하다. DB와 캐시는 두 개의 분리된 시스템이고, 두 시스템에 대한 쓰기는 원자적이지 않다. 한쪽에 쓰고 다른 쪽에 쓰기 직전에 장애가 나면 두 저장소가 갈라진다. 모든 패턴은 이 갈라짐을 어떻게 다루느냐에 대한 답이다.
읽기 패턴
Cache-Aside
Lazy Loading이라고도 부른다. 가장 널리 쓰이는 형태로, 애플리케이션이 캐시와 DB를 모두 직접 다룬다.
sequenceDiagram
participant App
participant Cache
participant DB
App->>Cache: GET key
alt cache hit
Cache-->>App: value
else cache miss
Cache-->>App: nil
App->>DB: SELECT
DB-->>App: row
App->>Cache: SET key (TTL)
end
핵심 특성은 세 가지다.
- 캐시에 없는 데이터는 미리 채워두지 않는다. 읽기 시점에 lazy하게 채워진다
- 캐시 장애가 나도 애플리케이션은 DB로 직접 fallback할 수 있다. 캐시는 advisory에 가깝다
- 모든 읽기가 잠재적으로 캐시 → DB 두 hop을 가진다
가장 큰 단점은 첫 요청은 항상 느리다는 것이다. Cold cache 상태에서 트래픽이 몰리면 모든 요청이 DB로 떨어진다. 부팅 직후, 캐시 노드 재시작 직후가 특히 위험하다. 이 때문에 워밍업(미리 인기 키를 채워두는 것)을 별도로 운영하는 시스템이 많다.
Read-Through
캐시 라이브러리가 DB까지 알고 있는 형태다. 애플리케이션은 캐시만 호출하고, miss 시 캐시가 DB를 읽어 자신을 채운다.
sequenceDiagram
participant App
participant Cache
participant DB
App->>Cache: GET key
alt cache hit
Cache-->>App: value
else cache miss
Cache->>DB: load
DB-->>Cache: row
Cache-->>App: value
end
Cache-Aside와 결과는 같지만 책임이 다르다. 애플리케이션 코드는 단순해지지만, 캐시 라이브러리에 DB 접근 로직(쿼리, 직렬화 포맷)이 묶인다.
이 결합 때문에 마이크로서비스 환경에서는 잘 안 쓰이고, 단일 서비스 안에서 ORM 2차 캐시(Hibernate, JPA L2 cache) 같은 형태로 자주 보인다. 외부 캐시(Redis, Memcached)를 다양한 서비스가 공유한다면 Cache-Aside가 자연스럽다. 캐시가 한 서비스에 종속적이고 데이터 모델도 단순하다면 Read-Through로 코드를 줄일 수 있다.
Refresh-Ahead
만료 직전에 미리 갱신하는 패턴이다. TTL 기반 캐시의 stale window를 줄이는 게 목적이다.
sequenceDiagram
participant App
participant Cache
participant DB
App->>Cache: GET key (TTL 80% 경과)
Cache-->>App: value (현재 값 즉시 반환)
Cache-)DB: async refresh
DB-->>Cache: new value
TTL이 만료되기 전에 백그라운드로 미리 갱신한다는 점이 다른 패턴과 갈리는 지점이다. 클라이언트는 항상 hit를 보고, DB 부하는 시간 축으로 분산된다. 만료 직후 동시 요청이 몰리는 stampede도 자연스럽게 줄어든다.
조건이 까다롭다. 데이터가 예측 가능한 패턴으로 자주 읽히는 경우에만 효과가 있다. 잘 안 읽히는 키를 미리 갱신해 봐야 의미가 없고, 오히려 DB 부하만 늘린다. 보통은 인기 키 상위 몇 퍼센트에만 적용한다.
또 하나의 결정 포인트는 refresh 시점이다. 보통 TTL의 75~80% 지점을 잡는다. 너무 빠르면 갱신 의미가 없고, 너무 늦으면 만료가 먼저 닥친다. refresh가 실패할 때 어떻게 할지도 미리 정해두어야 한다. 옛 값을 그대로 두고 다음 주기에 다시 시도할지, 아니면 캐시를 비워서 lazy하게 다시 채우게 할지.
쓰기 패턴
캐시 쓰기 패턴을 결정하는 핵심 질문은 단순하다. DB와 캐시 중 어느 쪽에, 언제, 어떤 순서로 쓰는가.
Write-Through
쓰기 시 DB와 캐시를 동기적으로 모두 업데이트한다.
sequenceDiagram
participant App
participant Cache
participant DB
App->>Cache: SET key (sync)
Cache->>DB: write
DB-->>Cache: ok
Cache-->>App: ok
장점은 분명하다. 캐시는 항상 DB와 같다. stale window가 거의 없다.
대신 모든 쓰기 지연이 DB 쓰기 + 캐시 쓰기 합산이 된다. 한쪽이 실패했을 때 어떻게 할지도 명확한 답이 없다. DB 성공 + 캐시 실패면 일관성은 일시적으로 깨지지만 데이터는 안전하다. DB 실패 + 캐시 성공이면 캐시에 ghost data가 생긴다.
이 때문에 실무에서는 보통 DB를 먼저 쓰고 캐시 쓰기는 invalidate(삭제)로 처리한다. SET이 아니라 DEL로 가면, 다음 읽기에서 누군가가 새 값을 캐시에 채우게 된다. 이러면 양방향 race를 줄일 수 있다.
또 하나 함정은 워크로드 적합성이다. 거의 안 읽히는 데이터를 매번 캐시에 써두면 캐시 공간만 낭비된다. write-through는 한 번 쓰고 자주 읽는 데이터에만 의미가 있다.
Write-Behind
Write-Back이라고도 한다. 쓰기를 큐에 쌓아 비동기로 DB에 반영하고, 캐시가 일시적으로 source of truth 역할을 한다.
sequenceDiagram
participant App
participant Cache
participant Queue
participant DB
App->>Cache: SET key
Cache->>Queue: enqueue write
Cache-->>App: ok (즉시 응답)
Queue->>DB: batch flush
쓰기 지연이 캐시 쓰기 시간으로 줄어든다. 같은 키에 대한 연속 쓰기를 합칠 수 있어 DB 부하도 줄어든다. 동일 키에 초당 수천 건의 카운터 증가가 들어와도 DB에는 한두 번의 합산된 업데이트만 닿게 만들 수 있다.
대가는 데이터 유실 위험이다. 캐시 노드가 큐 flush 전에 다운되면 그 쓰기는 영원히 사라진다. 금융, 결제 같은 영역에서는 거의 못 쓰고, 카운터, 메트릭, 게임 점수처럼 일부 유실을 감내할 수 있는 도메인에서 쓰인다.
또 다른 함정은 읽기 일관성의 비대칭이다. 같은 캐시 노드를 거쳐 읽는 클라이언트는 최신 값을 보지만, DB를 직접 읽는 다른 시스템은 옛 값을 본다. 분석 파이프라인이 DB를 폴링하면 데이터 누락처럼 보일 수 있다. 읽기 경로를 캐시로 일원화하지 않는 한 이 비대칭은 사라지지 않는다.
Write-Around
sequenceDiagram
participant App
participant Cache
participant DB
App->>DB: write
DB-->>App: ok
Note over Cache: invalidate or skip
자주 쓰지 않을 데이터를 캐시에 미리 올려두지 않는다. 이미 캐시에 있다면 invalidate한다.
쓰기 후 한참 동안 안 읽힐 데이터, 또는 쓰기는 많지만 읽기는 일부에만 몰리는 워크로드에 적합하다. 로그성 데이터를 가끔 다시 읽는 모니터링 화면, 감사 추적용 이벤트 적재 등이 전형적이다.
갱신 순서
쓰기 패턴 중 가장 흔한 실수는 캐시를 먼저 무효화하는 것이다. 다음 시나리오를 보자.
sequenceDiagram
participant W as Writer
participant R as Reader
participant Cache
participant DB
W->>Cache: DEL key
R->>Cache: GET key (miss)
R->>DB: SELECT (옛 값)
R->>Cache: SET key (옛 값)
W->>DB: UPDATE (새 값)
Note over Cache: 캐시에 옛 값이 새 값 자리에 잔존
Writer가 캐시를 먼저 지우고 DB를 업데이트하는 사이에 Reader가 동시에 진입하면, Reader가 옛 값을 캐시에 다시 적재한다. 이후 Writer가 DB를 갱신해도 캐시는 옛 값을 들고 있다. 다음 무효화나 TTL 만료까지 옛 값이 그대로 남는다.
순서를 뒤집으면 같은 race가 발생해도 결과가 다르다. DB를 먼저 쓰고 캐시를 그 다음에 invalidate하면, Reader가 미세한 윈도우 동안 옛 값을 보지만, 그 직후 Writer의 invalidate가 캐시를 비우고, 다음 Reader는 새 값을 채운다.
기본 규칙은 DB 먼저, Cache invalidate 나중이다. 단, write-through에서 sync로 둘 다 갱신하면 race window 자체가 줄어든다. 대신 양쪽 실패 케이스를 어떻게 다룰지 미리 정해두어야 한다.
무효화 전략
캐시는 어떻게 채울지보다 어떻게 비울지가 더 어렵다. 비우지 못한 캐시에는 stale 데이터가 그대로 쌓인다.
TTL 기반
가장 단순한 형태다. 모든 키에 만료 시간을 두고, 만료 전엔 신선하다고 가정한다.
SET key value EX 300
장점은 명확하다. 기억할 게 없다. 단점도 명확하다. TTL 동안의 stale은 그냥 받아들여야 한다.
TTL은 데이터의 변경 빈도와 stale 허용 한계로 정한다. 자주 변하는 데이터에 긴 TTL을 걸면 stale이 누적되고, 잘 안 변하는 데이터에 짧은 TTL을 걸면 캐시 효과가 사라진다.
흔한 함정 하나는 모든 키에 같은 TTL을 거는 것이다. 기본값으로 5분 같은 단일 값을 쓰면, 캐시가 한꺼번에 만료되며 DB가 한 번에 폭주하는 상황이 생긴다. cache stampede의 한 형태다. TTL에 약간의 jitter를 더해 만료 시점을 분산시켜야 한다.
# Bad: every key expires at the same instant
ttl = 300
# Good: spread between 270~330s
ttl = 300 + random.randint(-30, 30)
Write-Through Invalidation
쓰기 트랜잭션의 마지막에 DEL을 명시적으로 보낸다.
def update_user(user_id, data):
db.update(user_id, data)
cache.delete(f"user:{user_id}")
문제는 삭제는 멱등이지만 누락은 멱등이 아니다라는 것이다. 트랜잭션이 DB 커밋 후 DEL 직전에 죽으면 stale이 살아남는다. 보완 방법은 outbox pattern으로 invalidate를 보장하는 것, 또는 별도 모니터링으로 dual-write 실패를 감지하는 것이다. 실질적으로는 TTL을 함께 깔아 “최악의 경우 TTL이 자정한다” 정도의 안전망을 둔다.
또 한 가지, 분산 환경에서 여러 캐시 노드에 같은 키가 복제되어 있다면, DEL 한 번이 모든 복제본에 전파되었다는 보장이 필요하다. Redis Cluster 같은 환경에서는 키의 슬롯 해시가 한 노드를 가리키므로 자연스럽지만, 애플리케이션 레벨 다중 캐시(예: 로컬 in-process 캐시 + Redis)를 함께 쓰면 모든 레이어를 비워야 한다.
CDC
Change Data Capture. DB의 변경 이벤트(MySQL binlog, PostgreSQL logical replication, MongoDB change stream)를 구독해서 캐시를 갱신한다.
flowchart LR
DB[(Database)] -->|binlog| CDC[CDC Connector]
CDC --> MQ[Message Queue]
MQ --> W1[Cache Updater 1]
MQ --> W2[Cache Updater 2]
W1 --> Cache
W2 --> Cache
장점은 강력하다. 애플리케이션 코드에서 캐시 무효화를 분리한다. 어떤 경로로 DB가 바뀌어도 (다른 서비스, 직접 SQL, 백오피스 툴 등) 캐시가 따라간다. 다양한 쓰기 경로가 한 캐시를 공유하는 시스템에서 일관성 파편화를 막는다.
단점은 인프라 복잡도다. CDC 커넥터, 메시지 큐, 워커 운영, 순서 보장이 모두 필요하다. 단순한 캐시 한두 개에는 과한 투자다. 보통 키 수가 많고 다양한 경로로 DB가 변경되는 시스템에서 정당화된다.
또 한 가지 주의할 점은 순서 보장이다. 같은 키에 대한 연속된 변경 이벤트가 다른 워커로 흘러가서 처리 순서가 뒤바뀌면, 캐시에 옛 값이 마지막으로 박힌다. 키별 파티셔닝(같은 키는 항상 같은 워커로)으로 막는다.
Key Versioning
무효화 자체를 회피하는 트릭이다. 데이터가 바뀔 때마다 키를 바꾼다.
# Bad: same key, requires invalidation
user:42 -> {"name": "old"}
user:42 -> {"name": "new"} # DEL then SET
# Good: bake version into the key
user:42:v1 -> {"name": "old"}
user:42:v2 -> {"name": "new"} # just a different key
버전 번호는 보통 DB 행의 updated_at 타임스탬프나 version 컬럼에서 가져온다. 클라이언트는 먼저 가벼운 메타데이터(현재 버전 번호)를 조회하고, 그 버전으로 캐시 키를 만든다.
def get_user(user_id):
version = db.query(
"SELECT version FROM users WHERE id = ?", user_id
)
key = f"user:{user_id}:v{version}"
return cache.get(key) or load_and_cache(user_id, version)
장점은 race condition이 원천적으로 사라진다는 것이다. 옛 버전 키와 새 버전 키는 다른 키이므로, 동시에 둘이 존재해도 충돌하지 않는다. 무효화 누락도 없다.
단점은 두 가지다. 매 요청마다 메타 조회가 한 번 더 들어간다. 또 옛 버전 키들이 캐시에 누적되어 LRU에 의존해 청소된다.
전형적인 사용처는 큰 객체(사용자 프로필, 상품 정보)를 다수 인스턴스가 공유하면서 강한 일관성이 필요한 경우다. 메타 조회를 같은 라운드트립에 묶을 수 있는 환경(예: GraphQL persisted query)에서 특히 잘 맞는다.
흔한 함정
Cache Stampede
캐시 만료 직후 동일 키에 동시 요청이 몰려 DB로 폭주하는 현상이다.
sequenceDiagram
participant C1 as Client 1
participant C2 as Client 2
participant CN as Client N
participant Cache
participant DB
Note over Cache: key 만료 시점
C1->>Cache: GET (miss)
C2->>Cache: GET (miss)
CN->>Cache: GET (miss)
C1->>DB: SELECT
C2->>DB: SELECT
CN->>DB: SELECT
Note over DB: 같은 쿼리 N번 실행
방지법으로 세 가지가 자주 쓰인다.
Single-flight
첫 요청만 DB로 가고, 나머지는 결과를 기다린다.
def get_with_lock(key):
value = cache.get(key)
if value:
return value
if cache.set(f"lock:{key}", "1", nx=True, ex=10):
try:
value = db.load(key)
cache.set(key, value, ex=300)
return value
finally:
cache.delete(f"lock:{key}")
else:
time.sleep(0.05)
return get_with_lock(key)
락 자체가 또 다른 race를 만들 수 있다. 락 보유자가 다운되거나, 락 TTL이 작업 시간보다 짧으면 두 클라이언트가 동시에 DB로 갈 수 있다. 이 때문에 락 TTL은 작업 최대 시간보다 넉넉하게 잡고, 작업 완료 후 명시적 삭제로 즉시 풀어주는 게 안전하다.
Probabilistic Early Expiration
TTL이 가까워질수록 일부 클라이언트가 확률적으로 미리 갱신한다. 모두가 동시에 만료를 보지 않게 하는 통계적 분산이다.
여기서 delta는 갱신에 걸리는 예상 시간, beta는 공격성을 제어하는 상수다. 식을 음의 로그로 짠 이유는 rand가 0에 가까울수록 확률 가중이 폭발적으로 커져서, TTL 막바지에 갈수록 갱신 확률이 기하급수적으로 늘어나기 때문이다. TTL 한참 전에는 거의 발동 안 하고, 만료 직전에 누군가 한 명이 자연스럽게 갱신을 떠맡는다.
Stale-While-Revalidate
TTL 이후에도 stale 값을 잠깐 반환하면서 백그라운드에서 갱신한다. HTTP의 stale-while-revalidate 디렉티브와 같은 개념이다. 짧은 stale을 감수하는 대신, 만료 시점에 모든 요청이 hit를 본다.
세 처방은 배타적이지 않고 같이 쓸 수 있다. Refresh-Ahead가 인기 키를 미리 갱신하고, Single-flight가 만료 직후의 폭주를 막고, Probabilistic Early Expiration이 인기 키 전반의 만료 시점을 분산시키는 식이다.
Negative Caching
존재하지 않는 키 조회도 캐시한다. 존재하지 않는 ID를 반복 조회하는 패턴(스크래퍼, 손상된 외부 링크, 부정확한 클라이언트 캐시)을 막는다.
NOT_FOUND = "__nf__"
def get_user(user_id):
cached = cache.get(f"user:{user_id}")
if cached == NOT_FOUND:
return None
if cached is not None:
return deserialize(cached)
row = db.load(user_id)
if row is None:
# short TTL so newly-created users do not stay hidden
cache.set(f"user:{user_id}", NOT_FOUND, ex=60)
return None
cache.set(f"user:{user_id}", serialize(row), ex=300)
return row
음수 TTL은 양수 TTL보다 짧게 잡는다. 이제 막 만들어진 데이터가 NOT_FOUND TTL 동안 조회되지 않는 시간을 줄이기 위해서다. 사용자가 가입 직후 자기 프로필을 못 보는 상황이 흔한 사고다.
또 다른 처방은 Bloom filter다. “존재 가능 여부”만 빠르게 판별해서, 명백히 없는 키는 DB까지 안 가게 한다. 데이터가 추가될 때마다 필터에 키를 넣고, 조회 시 필터를 먼저 본다. False positive는 있어도 false negative는 없으므로 안전하다.
Hot Key
특정 키 하나에 트래픽이 집중되는 현상이다. 분산 캐시(Redis Cluster) 환경에서 한 샤드의 한 노드만 부하를 받게 된다. 노드 한 대의 처리량 한계가 곧 시스템 한계가 된다.
해결법은 키 공간 분할이다. 같은 데이터를 여러 키에 복제해 두고, 클라이언트는 무작위로 하나를 고른다.
N_SHARDS = 8
def get_trending():
shard = random.randint(0, N_SHARDS - 1)
return cache.get(f"trending:articles:shard{shard}")
8개 샤드면 단일 노드 부하의 1/8로 분산된다. 대신 invalidate할 때 8개를 모두 지워야 한다. 글로벌 인기 콘텐츠(뉴스 메인, 메인 페이지 캐러셀, 라이브 스트리밍 시청자 수)에서 자주 쓰는 패턴이다.
읽기 전용 hot key라면 클라이언트 사이드 캐시 한 층을 더 두는 것도 효과적이다. 같은 인스턴스 안의 인메모리 캐시에 짧은 TTL(수 초)을 걸면, Redis까지 안 가는 비율이 크게 늘어난다. 데이터 일관성 요구가 약한 hot key에서만 가능하다.
Dual-Write 일관성
캐시와 DB에 모두 쓸 때 한쪽만 성공한 상태에서 장애가 나면 어떻게 할 것인가의 문제다. 캐시 쓰기 패턴은 결국 모두 같은 갈라짐 위험과 마주친다.
근본적인 해법은 두 가지 중 하나다.
- 하나만 source of truth로 정한다. 캐시는 항상 파생 데이터로 보고, 쓰기 실패 시 그냥 비운다. 다음 읽기에서 자연스럽게 다시 채워진다. 이 모델은 캐시가 죽거나 비어 있어도 시스템이 정상 동작한다는 보장이 핵심이다
- 이벤트 기반으로 단방향 흐름을 만든다. DB만 직접 쓰고, 캐시는 CDC로 따라가게 한다. 두 번 쓰는 일이 없으니 갈라질 일도 없다
대부분의 시스템은 1번을 택한다. 2번은 인프라 비용이 정당화되는 큰 시스템에서만 들어온다. 1번도 결국 “캐시는 advisory”라는 운영 원칙이 코드 전반에 일관되게 박혀 있어야 한다. 한 군데서라도 캐시를 strict source처럼 다루면, 그 자리에서 dual-write 갈라짐이 사고로 이어진다.
Semantic Cache
위 모든 패턴은 정확 키 매칭을 전제로 한다. user:42로 저장한 값은 정확히 같은 키로만 가져올 수 있다. LLM 호출이 보편화되면서 다른 패턴이 등장했다. 의미 기반 매칭이다.
LLM 호출은 비싸고 느리다. 한 호출에 수백 ms에서 수 초의 지연과 토큰당 비용이 든다. 그런데 사용자가 보내는 프롬프트는 표현만 다르고 의미는 같은 경우가 많다.
- “오늘 날씨 어때?”
- “오늘 날씨 알려줘”
- “지금 날씨가 어떤가요?”
이 셋을 정확 키 매칭으로 캐시하면 매번 miss다. Semantic cache는 프롬프트를 임베딩 벡터로 바꾸고, 벡터 공간에서 유사도가 임계값 이상인 항목을 hit으로 본다.
flowchart LR
Req[사용자 프롬프트] --> Embed[임베딩 모델]
Embed --> Vec[벡터]
Vec --> Search[Vector Store 유사도 검색]
Search -->|sim ≥ τ| Hit[캐시 응답 반환]
Search -->|sim < τ| Miss[LLM 호출]
Miss --> Store[(Vector Store에 저장)]
어디서 동작하는가
Semantic cache는 백엔드에서 동작한다. 정확하게는 LLM gateway 또는 proxy 레이어다. 클라이언트가 보낸 프롬프트를 받은 서버가 다음 흐름을 거친다.
- 임베딩 모델을 호출해 프롬프트를 벡터로 만든다
- 벡터 스토어(pgvector, Redis Vector, Pinecone 등)에서 유사도 검색을 한다
- 임계값을 넘는 hit이 있으면 저장된 응답을 반환하고, 없으면 LLM에 요청을 전달한다
- LLM 응답이 돌아오면 (벡터, 응답) 쌍을 벡터 스토어에 적재한다
클라이언트 입장에서는 그냥 LLM API 응답이 오는 것처럼 보인다. 캐시가 작동했는지는 보통 응답 헤더로만 노출된다. 이 위치 결정이 중요한 이유는, 클라이언트마다 임베딩 모델을 따로 호출하는 비효율을 피하고, 캐시 결과를 모든 클라이언트가 공유할 수 있게 만들기 때문이다.
K-V 캐시와 차이
- 인프라: K-V 저장소 + 임베딩 모델 + 벡터 인덱스가 모두 필요하다. Redis 한 대로는 부족하다
- 임계값: hit/miss 경계가 확률적이다. 0.95로 잡으면 너무 보수적이라 hit율이 낮고, 0.85로 잡으면 의미가 다른 응답이 섞일 위험이 커진다. 도메인마다 튜닝이 필요하다
- 개인화 데이터: “내 잔액이 얼마인가요?”와 “내 잔액을 알려줘”는 의미는 같지만 사용자가 다르면 답도 달라야 한다. 사용자 ID를 키 namespace로 함께 묶어야 한다
- 무효화: LLM 응답은 항상 응답한 시점에 종속된다. 답이 바뀌어야 할 때(정책 변경, 새 데이터, 사실 갱신) 무효화 기준이 모호하다. TTL이 사실상 유일한 처방이다
- 저장량: 임베딩 벡터는 보통 수백~수천 차원의 float 배열이다. K-V 캐시 키 하나의 수십~수백 배 크기다. 캐시 사이즈 산정이 다른 차원에서 이뤄져야 한다
적합한 워크로드
semantic cache가 잘 맞는 곳.
- FAQ형 챗봇. 유사 질문 반복이 많고 답이 안정적이다
- 코드 어시스턴트의 일반 가이드. “Python에서 파일 읽는 법” 같은 보편 질문은 표현만 달라도 같은 답이면 충분하다
- 요약, 번역. 같은 입력에 대한 결정론적 출력에 가까운 작업이다
semantic cache가 안 맞는 곳.
- 사용자 컨텍스트가 강하게 결합된 대화. “방금 보낸 코드를 고쳐줘”는 매번 다른 의미다
- 시점이 중요한 정보. 주가, 날씨, 뉴스, 환율
- 보안과 개인정보가 들어간 응답. 키 namespace 설계를 한 번 잘못하면 다른 사용자의 응답이 새어 나가는 사고로 이어질 수 있다
키 namespace 설계
semantic cache의 누설 사고는 거의 항상 키 설계에서 발생한다. 안전한 패턴은 다음과 같다.
def cache_key_for(user_id, tenant_id, prompt_vec):
# vector lookup is scoped strictly within (tenant, user)
return {
"namespace": f"tenant:{tenant_id}:user:{user_id}",
"vector": prompt_vec,
}
벡터 검색을 글로벌하게 돌리지 않고, namespace로 한정한다. 동일 사용자 안에서만 의미 매칭을 허용한다. 테넌트 간 데이터 분리가 강하게 요구되는 환경에서는 테넌트 단위 namespace를 한 층 더 둔다.
namespace를 너무 세분화하면 hit율이 떨어진다. FAQ형 응답처럼 사용자별 맥락이 없는 데이터는 글로벌 namespace를 따로 만들어 분리한다. 어떤 데이터가 어느 namespace에 들어가는지가 곧 보안 설계와 직결된다.