
===== redis-cache-aside =====
상품 조회 API 앞에 Redis를 두면 응답이 빨라지고 DB 부하가 줄어듭니다. 그런데 캐시를 붙이고 나면 새로운 질문이 생깁니다. 캐시가 비어 있으면 누가 DB를 읽을까요? 가격을 바꾸면 캐시의 옛 가격은 누가 언제 지울까요? 인기 상품의 캐시가 만료되는 순간이나 Redis가 멈춘 순간에는 DB에 무슨 일이 생길까요?

흔히 쓰는 Cache Aside 방식에서는 이 질문의 답이 모두 애플리케이션입니다. 이 글에서는 Redis 8.10(`redis:8.10.2` 이미지), PostgreSQL 17, Python `redis` 6.4.0과 `psycopg` 3.2.10으로 Cache Aside를 직접 만들고, 정상 흐름과 함께 그 흐름이 깨지는 경우를 재현합니다. 쓰기 뒤 캐시 갱신이 실패하는 경우, 늦게 끝난 읽기가 옛값을 다시 넣는 경쟁 조건, TTL 길이에 따른 DB 부하와 stale 시간, 인기 키가 만료될 때 몰리는 요청, Redis 장애 때 DB로 쏟아지는 요청을 차례로 봅니다. 숫자는 모두 노트북의 Docker 환경에서 잰 값이므로, 절대값보다 방식끼리의 차이를 봐 주세요.

## 캐시를 채우고 지우는 것은 애플리케이션입니다

Cache Aside에서 DB는 원본이고 Redis는 언제든 다시 만들 수 있는 복사본입니다. Redis와 DB는 서로 연결되어 있지 않습니다. Redis는 키가 없으면 없다고 답할 뿐 DB를 대신 읽어 오지 않고, DB의 값이 바뀌어도 Redis는 그 사실을 모릅니다. 읽기 흐름은 이렇습니다.

1. 애플리케이션이 Redis에 `GET`을 보냅니다.
2. 값이 있으면(cache hit) 그대로 응답합니다.
3. 없으면(cache miss) 애플리케이션이 DB를 조회하고, 결과에 TTL을 붙여 Redis에 `SET`한 뒤 응답합니다.

<figure>
<svg viewBox="0 -20 480 530" role="img" aria-label="애플리케이션이 Redis에 GET을 보내 값이 있으면 바로 응답하고, 없으면 DB를 조회한 뒤 TTL을 붙여 Redis에 SET하고 응답한다" className="diagram h-auto w-full">
  <rect x="130" y="0" width="220" height="82" rx="10" className="fill-card stroke-border" strokeWidth="2" />
  <text x="240" y="35" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">애플리케이션</text>
  <text x="240" y="61" textAnchor="middle" fontSize="17" className="fill-muted-foreground">GET product:42</text>
  <rect x="25" y="152" width="190" height="82" rx="10" className="fill-accent stroke-primary" strokeWidth="2" />
  <text x="120" y="187" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-accent-foreground">HIT</text>
  <text x="120" y="213" textAnchor="middle" fontSize="17" className="fill-accent-foreground">값을 바로 응답</text>
  <rect x="245" y="152" width="210" height="82" rx="10" className="fill-card stroke-destructive" strokeWidth="2" />
  <text x="350" y="187" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-destructive">MISS</text>
  <text x="350" y="213" textAnchor="middle" fontSize="17" className="fill-destructive">Redis는 nil만 돌려줌</text>
  <line x1="240" y1="88" x2="130" y2="141" className="stroke-primary" strokeWidth="2" />
  <polygon points="120,146 127,136 133,147" className="fill-primary" />
  <line x1="240" y1="88" x2="340" y2="141" className="stroke-muted-foreground" strokeWidth="2" />
  <polygon points="350,146 337,146 343,136" className="fill-muted-foreground" />
  <rect x="245" y="280" width="210" height="82" rx="10" className="fill-card stroke-border" strokeWidth="2" />
  <text x="350" y="315" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">DB 조회</text>
  <text x="350" y="341" textAnchor="middle" fontSize="17" className="fill-muted-foreground">애플리케이션이 직접</text>
  <line x1="350" y1="240" x2="350" y2="263" className="stroke-muted-foreground" strokeWidth="2" />
  <polygon points="350,274 344,263 356,263" className="fill-muted-foreground" />
  <rect x="245" y="408" width="210" height="82" rx="10" className="fill-card stroke-border" strokeWidth="2" />
  <text x="350" y="443" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">SET + TTL</text>
  <text x="350" y="469" textAnchor="middle" fontSize="17" className="fill-muted-foreground">다음 요청을 위해 채움</text>
  <line x1="350" y1="368" x2="350" y2="391" className="stroke-muted-foreground" strokeWidth="2" />
  <polygon points="350,402 344,391 356,391" className="fill-muted-foreground" />
</svg>
<figcaption>Redis는 값이 없다고 알려 줄 뿐이고, DB를 읽어 캐시를 채우는 것은 애플리케이션입니다.</figcaption>
</figure>

## 실습 준비

Redis, PostgreSQL, Python 컨테이너를 띄웁니다. PostgreSQL은 DB 조회 횟수를 세려고 `pg_stat_statements`를 켜고, 커넥션 고갈을 재현하려고 `max_connections`를 30으로 낮췄습니다.

```yaml
services:
  redis:
    image: redis:8.10.2
    command: redis-server --save "" --appendonly no
    ports:
      - "6401:6379"
  db:
    image: postgres:17-alpine
    command: postgres -c shared_preload_libraries=pg_stat_statements -c max_connections=30
    environment:
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: shop
    volumes:
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    ports:
      - "5401:5432"
  app:
    image: python:3.12-slim
    command: sh -c "pip install -q --root-user-action=ignore redis==6.4.0 'psycopg[binary,pool]==3.2.10' && sleep infinity"
    volumes:
      - .:/app
    working_dir: /app
```

`init.sql`은 상품 1000개를 만들고, 애플리케이션이 쓸 `app` 계정과 조회 함수를 만듭니다. 실제 서비스의 상품 조회는 조인이나 집계가 붙어 무거운 경우가 많아서, `get_product` 함수가 0.02초 쉬었다가 한 건을 돌려주게 했습니다. 이 20ms는 일부러 넣은 값입니다.

```sql
CREATE EXTENSION pg_stat_statements;
CREATE ROLE app LOGIN PASSWORD 'app';
CREATE TABLE products (
  id      int PRIMARY KEY,
  name    text NOT NULL,
  price   int NOT NULL,
  version int NOT NULL DEFAULT 1
);
INSERT INTO products SELECT g, 'product-' || g, 10000, 1 FROM generate_series(1, 1000) g;
GRANT SELECT, UPDATE ON products TO app;

-- 무거운 조회를 흉내 낸다: p_delay초 쉰 뒤 상품 한 건을 돌려준다
CREATE FUNCTION get_product(p_id int, p_delay float8 DEFAULT 0.02)
RETURNS TABLE (name text, price int, version int) LANGUAGE sql AS $$
  SELECT pg_sleep(p_delay);
  SELECT name, price, version FROM products WHERE id = p_id;
$$;
```

compose 프로젝트 이름을 `rca`로 두고, 자주 쓰는 명령을 셸 함수로 만듭니다.

```bash
export COMPOSE_PROJECT_NAME=rca
docker compose up -d
rcli() { docker compose exec -T redis redis-cli --no-raw "$@"; }
py() { docker compose exec -T app python "$@"; }
```

Cache Aside의 핵심 코드는 `cache.py`에 모았습니다. 캐시 값에는 DB에서 읽은 시간(`loaded_at`)을 함께 넣어 두었는데, 마지막 절에서 데이터 age를 잴 때 씁니다.

```python
r = redis.Redis(host="redis", decode_responses=True)


def connect():
    return psycopg.connect(APP_DSN, autocommit=True)


def load_from_db(conn, pid, delay=0.02):
    name, price, version = conn.execute(
        "SELECT name, price, version FROM get_product(%s, %s)", (pid, delay)
    ).fetchone()
    return {"id": pid, "name": name, "price": price, "version": version, "loaded_at": time.time()}


def get_product(conn, pid, ttl=60):
    key = f"product:{pid}"
    cached = r.get(key)                      # 1. Redis에서 먼저 찾는다
    if cached is not None:
        return json.loads(cached), "HIT"
    value = load_from_db(conn, pid)          # 2. 없으면 애플리케이션이 DB를 조회하고
    r.set(key, json.dumps(value), ex=ttl)    # 3. TTL을 붙여 Redis에 채운다
    return value, "MISS"


def update_price(conn, pid, price):
    with conn.transaction():                 # 블록을 나가면 COMMIT
        conn.execute(
            "UPDATE products SET price = %s, version = version + 1 WHERE id = %s", (price, pid)
        )
    r.delete(f"product:{pid}")               # COMMIT 뒤에 캐시를 지운다
```

`cache.py`에는 실습용 보조 함수도 있습니다.

- `db_queries()`: `pg_stat_statements`에서 `get_product` 조회가 몇 번 실행됐는지 읽습니다. 애플리케이션이 센 숫자가 아니라 DB가 기록한 숫자입니다.
- `db_price(pid)`: DB에 들어 있는 지금 가격을 읽습니다.
- `reset()`: 가격을 10000으로 되돌리고, 조회 통계와 Redis 데이터, Redis 통계를 비웁니다.

## hit와 miss는 걸리는 시간이 다릅니다

같은 상품을 1000번 조회합니다.

```python
reset()
conn = connect()
timings = {"HIT": [], "MISS": []}
for i in range(1, 1001):
    t0 = time.perf_counter()
    value, result = get_product(conn, 42)
    ms = (time.perf_counter() - t0) * 1000
    timings[result].append(ms)
    if i <= 3:
        print(f"요청 {i}: {result:4} {ms:6.2f} ms, price={value['price']}")

for k, v in timings.items():
    print(f"{k:4} {len(v):4}회, 평균 {sum(v) / len(v):6.2f} ms")
print("DB 조회 횟수:", db_queries())
```

```bash
py hitmiss.py
```

```
요청 1: MISS  22.65 ms, price=10000
요청 2: HIT    0.12 ms, price=10000
요청 3: HIT    0.10 ms, price=10000
HIT   999회, 평균   0.07 ms
MISS    1회, 평균  22.65 ms
DB 조회 횟수: 1
```

첫 요청만 miss라서 DB를 읽고 캐시를 채웠고, 나머지 999번은 Redis에서 끝났습니다. DB 조회는 1번입니다. miss 한 번에는 Redis 왕복, DB 조회, `SET`이 모두 들어가므로 hit보다 훨씬 깁니다. miss 자체는 정상 경로입니다. 다만 한꺼번에 몰리면 그 비용도 한꺼번에 DB로 갑니다.

캐시를 지우고 Redis에 직접 물어보면, Redis는 DB를 읽지 않고 빈 값을 돌려줍니다.

```bash
rcli TTL product:42
rcli DEL product:42
rcli GET product:42
```

```
(integer) 57
(integer) 1
(nil)
```

`TTL`은 키가 사라지기까지 남은 초입니다. 60초로 넣은 키가 57초 남아 있었습니다.

## 쓰기는 DB COMMIT 다음에 DEL입니다

가격을 바꿀 때는 DB에 먼저 커밋하고, 그다음 캐시 키를 지웁니다. 새 값은 다음 읽기가 miss를 거치며 DB에서 읽어 채웁니다.

순서를 바꿔 캐시를 먼저 지우면, 지운 직후 커밋 전에 들어온 읽기가 DB의 옛값을 읽어 다시 캐시에 넣을 수 있습니다. 커밋은 그 뒤에 끝나므로 캐시에는 옛값이 남습니다.

그런데 DB와 Redis는 한 트랜잭션으로 묶이지 않습니다. DB 커밋은 성공했는데 그다음 Redis 작업이 실패할 수 있습니다. `write.py`로 이 부분 실패를 재현해 보겠습니다. 이 스크립트의 Redis 클라이언트는 `socket_timeout=1`로 만들었습니다.

```python
elif mode in ("update-set", "update-del"):
    price = int(sys.argv[2])
    with conn.transaction():
        conn.execute("UPDATE products SET price = %s, version = version + 1 WHERE id = 42", (price,))
    open(".commit_at", "w").write(str(time.time()))
    print("DB COMMIT 완료:", price)
    try:
        if mode == "update-set":
            value = {"id": 42, "name": "product-42", "price": price}
            rw.set(KEY, json.dumps(value), ex=30)
            print("캐시 SET 완료")
        else:
            rw.delete(KEY)
            print("캐시 DEL 완료")
    except redis.exceptions.RedisError as e:
        print("캐시 갱신 실패:", type(e).__name__)
        if "--retry" in sys.argv:
            open(".retry", "a").write(KEY + "\n")
            print("재시도 목록에 기록:", KEY)
```

- `prepare`: 캐시에 10000원을 TTL 30초로 채웁니다.
- `update-set`: DB를 커밋한 뒤 캐시를 새 값으로 `SET`합니다.
- `update-del`: DB를 커밋한 뒤 캐시를 `DEL`합니다. `--retry`를 붙이면 실패한 키를 재시도 목록에 적습니다.
- `watch`: 5초마다 `get_product`로 읽어 캐시 값과 DB 값을 비교하고, 같아질 때까지 커밋 후 몇 초가 지났는지 출력합니다.

### 캐시를 새 값으로 갱신하다 실패하면

커밋 직전에 `docker compose pause`로 Redis 프로세스를 멈춰서, 커밋 뒤의 Redis 작업이 타임아웃 나게 합니다.

```bash
py write.py prepare
docker compose pause redis
py write.py update-set 12000
docker compose unpause redis
py write.py watch
```

```
캐시: 10000 TTL 30 초
DB COMMIT 완료: 12000
캐시 갱신 실패: TimeoutError
COMMIT 후  4.9초: HIT, 캐시 10000 != DB 12000, 남은 TTL 21초
COMMIT 후  9.9초: HIT, 캐시 10000 != DB 12000, 남은 TTL 16초
COMMIT 후 14.9초: HIT, 캐시 10000 != DB 12000, 남은 TTL 11초
COMMIT 후 19.9초: HIT, 캐시 10000 != DB 12000, 남은 TTL 6초
COMMIT 후 25.0초: HIT, 캐시 10000 != DB 12000, 남은 TTL 1초
COMMIT 후 30.0초: MISS, 캐시 12000 = DB, stale 끝
```

DB는 12000원인데 캐시는 30초 동안 10000원을 돌려줬습니다. stale은 옛 캐시 값의 TTL이 끝나고서야 풀렸습니다. TTL이 1시간이었다면 1시간 동안 틀린 가격이 나갔을 것입니다.

타임아웃이 났을 때 애플리케이션이 아는 것은 응답을 받지 못했다는 사실뿐입니다. 명령이 Redis에 도착해 실행됐고 응답만 늦었을 수도 있습니다. 이번 실습에서는 Redis가 다시 움직인 뒤에도 캐시에 10000원이 남아 있었으니 `SET`은 실행되지 않았습니다.

### DEL도 실패하면 stale이 남습니다

같은 상황에서 `SET` 대신 `DEL`을 보냅니다.

```bash
py write.py prepare
docker compose pause redis
py write.py update-del 12000
docker compose unpause redis
py write.py watch
```

```
캐시: 10000 TTL 30 초
DB COMMIT 완료: 12000
캐시 갱신 실패: TimeoutError
COMMIT 후  5.1초: HIT, 캐시 10000 != DB 12000, 남은 TTL 21초
COMMIT 후 10.2초: HIT, 캐시 10000 != DB 12000, 남은 TTL 16초
COMMIT 후 15.2초: HIT, 캐시 10000 != DB 12000, 남은 TTL 11초
COMMIT 후 20.2초: HIT, 캐시 10000 != DB 12000, 남은 TTL 6초
COMMIT 후 25.2초: HIT, 캐시 10000 != DB 12000, 남은 TTL 1초
COMMIT 후 30.3초: MISS, 캐시 12000 = DB, stale 끝
```

결과는 같습니다. `DEL`도 커밋 뒤에 실패하면 옛값이 TTL까지 남습니다. 이때 stale의 상한을 정하는 것은 TTL뿐이므로, 캐시에 넣는 모든 값에 TTL을 붙여 두는 것이 마지막 안전망입니다.

`DEL`이 나은 점은 다시 시도하기가 안전하다는 데 있습니다. 실패한 `DEL`은 나중에 몇 번을 다시 보내도 결과가 같습니다. 실패한 `SET`을 나중에 다시 보내면, 그사이 더 새로운 값이 들어왔을 때 그 값을 옛값으로 덮을 수 있습니다. 실패한 키를 재시도 목록에 적고, Redis가 돌아온 뒤 다시 지워 봅니다.

```bash
py write.py prepare
docker compose pause redis
py write.py update-del 12000 --retry
docker compose unpause redis
py write.py retry
py write.py watch
```

```
캐시: 10000 TTL 30 초
DB COMMIT 완료: 12000
캐시 갱신 실패: TimeoutError
재시도 목록에 기록: product:42
재시도 DEL product:42 -> 1
COMMIT 후  5.4초: MISS, 캐시 12000 = DB, stale 끝
```

재시도 `DEL`이 1을 돌려줬으니, 처음 보낸 `DEL`은 실행되지 않았고 키가 남아 있었다는 뜻입니다. 재시도로 키를 지우자 다음 읽기가 바로 12000원을 가져왔습니다. 실습에서는 파일에 적었지만, 실제로는 재시도 큐나 outbox 테이블에 남깁니다. DB 커밋과 함께 후속 작업을 남기는 방법은 [outbox 패턴을 다룬 글](/posts/kafka-outbox-pattern)에 있습니다. 재시도까지 실패한 횟수는 따로 세어 알림을 걸어 둡니다.

### 쓰기가 둘이면 SET은 순서가 뒤집힙니다

`DEL`을 쓰는 이유가 하나 더 있습니다. 두 writer가 DB에는 11000원, 12000원 순서로 커밋했는데, 캐시 작업은 거꾸로 도착하는 경우입니다. `race.py`는 `threading.Event`로 이 순서를 고정합니다.

```bash
py race.py writers-set
py race.py writers-del
```

```
   51 ms  writer1  DB COMMIT 11000
   53 ms  writer2  DB COMMIT 12000
   53 ms  writer2  캐시 SET 12000
   53 ms  writer1  캐시 SET 11000
   64 ms  reader   HIT 11000, DB 12000, 남은 TTL 60초
```

```
   51 ms  writer1  DB COMMIT 11000
   52 ms  writer2  DB COMMIT 12000
   52 ms  writer2  캐시 DEL
   53 ms  writer1  캐시 DEL
   87 ms  reader   MISS 12000, DB 12000, 남은 TTL 60초
```

`SET`으로 갱신하면 늦게 도착한 writer1의 11000원이 남아 TTL 60초 동안 틀린 값을 돌려줍니다. `DEL`은 어떤 순서로 도착해도 결과가 "키 없음"으로 같아서, 다음 읽기가 DB의 최신값을 가져옵니다.

## DEL이 성공해도 옛값이 돌아올 수 있습니다

`DB COMMIT → DEL` 순서를 지키고 `DEL`이 성공해도 stale은 생길 수 있습니다. 읽기 요청이 DB를 읽은 뒤 캐시에 넣기까지의 사이에 쓰기가 끼어드는 경우입니다.

<figure>
<svg viewBox="0 -39 480 583" role="img" aria-label="Reader A가 캐시 miss 후 DB에서 10000을 읽고, 그사이 writer가 12000을 커밋하고 캐시를 지운 뒤, Reader A가 늦게 10000을 SET해 캐시에 옛값이 남는다" className="diagram h-auto w-full">
  <text x="125" y="0" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">Reader A</text>
  <text x="355" y="0" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">Writer</text>
  <rect x="25" y="26" width="200" height="58" rx="10" className="fill-card stroke-border" strokeWidth="2" />
  <text x="125" y="62" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">① GET → MISS</text>
  <rect x="25" y="118" width="200" height="82" rx="10" className="fill-card stroke-border" strokeWidth="2" />
  <text x="125" y="153" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">② DB 조회</text>
  <text x="125" y="179" textAnchor="middle" fontSize="17" className="fill-muted-foreground">price 10000</text>
  <line x1="125" y1="90" x2="125" y2="101" className="stroke-muted-foreground" strokeWidth="2" />
  <polygon points="125,112 119,101 131,101" className="fill-muted-foreground" />
  <rect x="255" y="234" width="200" height="58" rx="10" className="fill-card stroke-border" strokeWidth="2" />
  <text x="355" y="270" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">③ COMMIT 12000</text>
  <line x1="125" y1="206" x2="344" y2="227" className="stroke-muted-foreground" strokeWidth="2" />
  <polygon points="355,228 343,233 345,221" className="fill-muted-foreground" />
  <rect x="255" y="326" width="200" height="82" rx="10" className="fill-card stroke-border" strokeWidth="2" />
  <text x="355" y="361" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">④ DEL</text>
  <text x="355" y="387" textAnchor="middle" fontSize="17" className="fill-muted-foreground">지울 값이 없음</text>
  <line x1="355" y1="298" x2="355" y2="309" className="stroke-muted-foreground" strokeWidth="2" />
  <polygon points="355,320 349,309 361,309" className="fill-muted-foreground" />
  <rect x="25" y="442" width="200" height="82" rx="10" className="fill-card stroke-destructive" strokeWidth="2" />
  <text x="125" y="477" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-destructive">⑤ SET 10000</text>
  <text x="125" y="503" textAnchor="middle" fontSize="17" className="fill-destructive">TTL 동안 옛값</text>
  <line x1="355" y1="414" x2="136" y2="435" className="stroke-primary" strokeWidth="2" />
  <polygon points="125,436 135,429 137,441" className="fill-primary" />
</svg>
<figcaption>DEL이 먼저 끝나고 SET이 나중에 도착하면, 지운 자리에 옛값이 다시 들어갑니다.</figcaption>
</figure>

`race.py reader`는 이 순서를 그대로 만듭니다. Reader A는 DB에서 10000원을 읽은 뒤, writer가 커밋하고 `DEL`할 때까지 기다렸다가 `SET`합니다. 실제 서비스에서는 GC 멈춤, 네트워크 지연, 스레드 스케줄링 때문에 이런 지연이 생깁니다.

```python
    def reader():
        conn = connect()
        log("readerA", f"GET -> {r.get(KEY)} (MISS)")
        value = load_from_db(conn, 42)
        log("readerA", f"DB 조회 price={value['price']} version={value['version']}")
        read_done.set(); writer_done.wait()       # 여기서 늦어진다(GC, 네트워크 지연 등)
        if fenced:
            ok = SET_IF_NOT_OLDER(keys=[KEY, KEY + ":ver"], args=[json.dumps(value), value["version"], 30])
            log("readerA", f"버전 비교 SET {value['price']} -> {'저장' if ok else '거부'}")
        else:
            r.set(KEY, json.dumps(value), ex=30)
            log("readerA", f"SET {value['price']} EX 30")

    def writer():
        conn = connect(); read_done.wait()
        version = commit(conn, 12000); log("writer", f"DB COMMIT 12000 (version {version})")
        if fenced:
            INVALIDATE(keys=[KEY, KEY + ":ver"], args=[version, 60]); log("writer", "캐시 DEL + 버전 표시")
        else:
            r.delete(KEY); log("writer", "캐시 DEL")
        writer_done.set()
```

```bash
py race.py reader
```

```
   21 ms  readerA  GET -> None (MISS)
   44 ms  readerA  DB 조회 price=10000 version=1
   45 ms  writer   DB COMMIT 12000 (version 2)
   45 ms  writer   캐시 DEL
   45 ms  readerA  SET 10000 EX 30
   57 ms  reader   HIT 10000, DB 12000, 남은 TTL 30초
 1066 ms  reader   HIT 10000, DB 12000, 남은 TTL 29초
 2082 ms  reader   HIT 10000, DB 12000, 남은 TTL 28초
31124 ms  reader   MISS 12000, DB 12000
```

writer의 `DEL`은 지울 키가 없을 때 실행됐고, 바로 뒤에 Reader A가 10000원을 넣었습니다. 그 뒤 모든 읽기가 TTL 30초가 끝날 때까지 10000원을 받았습니다. 순서를 지키고 `DEL`이 성공해도 Cache Aside는 강한 일관성을 보장하지 못합니다. 이 경쟁 조건은 다음 방법으로 줄입니다.

- 짧은 TTL: stale이 남아 있는 최대 시간을 줄입니다. 경쟁 조건 자체는 그대로입니다.
- 지연 이중 삭제: writer가 `DEL`하고 잠시 뒤(예: 1초) 한 번 더 `DEL`합니다. 그 사이에 들어온 옛값을 지우지만, reader가 그 대기 시간보다 더 늦으면 막지 못합니다.
- 버전 비교 후 SET: 값에 DB의 버전을 담고, 캐시에 더 새로운 버전이 기록돼 있으면 옛 버전을 넣지 않습니다.

마지막 방법을 Lua 스크립트로 만들어 보겠습니다. writer는 키를 지우면서 `product:42:ver`에 커밋한 버전을 남기고, reader는 자기가 읽은 버전이 그보다 작으면 `SET`하지 않습니다. Lua 스크립트는 Redis 안에서 한 번에 실행되므로, 비교와 `SET` 사이에 다른 명령이 끼어들지 않습니다.

```python
# 쓰기 후 캐시를 지우고, 그 버전보다 오래된 값은 SET하지 못하게 표시를 남긴다
INVALIDATE = r.register_script("""
redis.call('DEL', KEYS[1])
redis.call('SET', KEYS[2], ARGV[1], 'EX', ARGV[2])
return 1
""")
# 표시된 버전보다 오래된 값이면 SET하지 않는다
SET_IF_NOT_OLDER = r.register_script("""
local fence = tonumber(redis.call('GET', KEYS[2]) or '0')
if tonumber(ARGV[2]) < fence then return 0 end
redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[3])
return 1
""")
```

```bash
py race.py reader-fenced
```

```
   21 ms  readerA  GET -> None (MISS)
   43 ms  readerA  DB 조회 price=10000 version=1
   45 ms  writer   DB COMMIT 12000 (version 2)
   46 ms  writer   캐시 DEL + 버전 표시
   46 ms  readerA  버전 비교 SET 10000 -> 거부
   82 ms  reader   MISS 12000, DB 12000, 남은 TTL 60초
 1098 ms  reader   HIT 12000, DB 12000, 남은 TTL 59초
 2110 ms  reader   HIT 12000, DB 12000, 남은 TTL 58초
```

버전 1을 들고 온 Reader A의 `SET`이 거부됐고, 다음 읽기가 12000원을 채웠습니다. 다만 버전 표시 키도 TTL(여기서는 60초)이 지나면 사라지므로, 그보다 오래 멈춘 reader는 막지 못합니다. Redis Cluster에서는 Lua 스크립트가 다루는 키가 같은 슬롯에 있어야 하므로 `{product:42}`, `{product:42}:ver`처럼 해시 태그를 붙입니다. 결제나 재고처럼 틀리면 안 되는 조회는 캐시를 거치지 않고 DB에서 읽는 것도 방법입니다.

## TTL은 DB 부하와 stale 시간을 맞바꿉니다

TTL을 얼마로 둘지는 DB 부하와 stale 시간 사이에서 고르는 문제입니다. `ttl.py`로 이 차이를 재 봅니다.

- 상품 10개를 돌아가며 30초 동안 조회합니다(요청 사이 5ms 대기).
- 별도 스레드가 1초마다 상품 하나의 가격을 DB에서만 바꿉니다. 배치 작업이나 다른 서비스가 캐시를 지우지 않고 DB만 바꾼 상황이라, stale을 끝내는 것은 TTL뿐입니다.
- 응답한 가격이 지금 DB 가격과 다르면 stale로 세고, 그 가격이 DB에서 바뀐 뒤 몇 초가 지났는지 기록합니다.

```bash
py ttl.py 1
py ttl.py 5
py ttl.py 20
```

```
TTL  1초: 요청 3466, DB 조회 286, stale 응답 167 (4.8%), 최대 stale 0.8초
TTL  5초: 요청 4263, DB 조회 60, stale 응답 964 (22.6%), 최대 stale 4.3초
TTL 20초: 요청 4405, DB 조회 20, stale 응답 2829 (64.2%), 최대 stale 19.0초
```

TTL을 1초에서 20초로 늘리자 DB 조회는 286번에서 20번으로 줄었지만, 틀린 가격을 돌려준 응답은 4.8%에서 64.2%로 늘었고 가장 오래된 stale은 19초였습니다. 최대 stale이 TTL 근처에서 멈추는 것도 보입니다. TTL은 최신성을 보장하지 않고, 무효화가 빠졌을 때 틀린 값이 남는 시간의 상한을 정해 줄 뿐입니다. 그래서 TTL은 데이터마다 "얼마 동안 틀려도 되는가"를 먼저 정하고, 그 시간에 맞춰 고릅니다.

## 인기 키가 만료되면 miss가 한꺼번에 옵니다

요청이 몰리는 키가 만료되면, 그 순간 들어온 요청이 모두 miss가 되어 각자 DB를 조회합니다. 이것을 cache stampede라고 합니다. `stampede.py`는 TTL 1초로 넣은 상품이 만료되기를 기다린 뒤, 20개 스레드가 `threading.Barrier`로 같은 순간에 조회하게 합니다.

```bash
py stampede.py naive
```

```
동시 요청 20개, DB 조회 20회
  MISS         20개, 최대    31 ms
```

요청 20개가 DB 조회 20번이 됐습니다. 키가 만료되는 순간마다 동시 요청 수만큼 같은 조회가 DB로 갑니다.

### single-flight: 같은 키는 한 요청만 DB로 보냅니다

miss가 나면 `SET NX`로 락을 잡은 요청 하나만 DB를 읽고, 나머지는 캐시가 채워지기를 기다립니다.

```python
def get_product_single_flight(conn, pid, ttl=60, load_delay=0.02, wait_timeout=1.0, on_timeout="db"):
    key, lock = f"product:{pid}", f"lock:product:{pid}"
    cached = r.get(key)
    if cached is not None:
        return json.loads(cached), "HIT"
    token = uuid.uuid4().hex
    if r.set(lock, token, nx=True, px=3000):          # 락을 잡은 요청 하나만 DB로 간다
        try:
            value = load_from_db(conn, pid, load_delay)
            r.set(key, json.dumps(value), ex=ttl)
            return value, "LOADER"
        finally:
            RELEASE(keys=[lock], args=[token])        # 내 토큰일 때만 지운다
    deadline = time.time() + wait_timeout              # 나머지는 캐시가 채워지기를 기다린다
    while time.time() < deadline:
        time.sleep(0.02)
        cached = r.get(key)
        if cached is not None:
            return json.loads(cached), "WAITED"
    if on_timeout == "db":                             # 기다리다 지치면 DB로 간다
        return load_from_db(conn, pid, load_delay), "TIMEOUT->DB"
    raise TimeoutError("cache fill timeout")
```

락에는 세 가지가 필요합니다.

- 소유 토큰: 요청마다 다른 값을 락에 넣고, 해제할 때 Lua 스크립트로 내 토큰일 때만 지웁니다. 내 락이 만료된 뒤 다른 요청이 잡은 락을 지우지 않기 위해서입니다.
- 락 TTL(`px=3000`): 락을 잡은 요청이 죽어도 3초 뒤에는 락이 풀립니다.
- 대기 제한(`wait_timeout`): 기다리는 요청이 언제까지 기다릴지, 그 뒤에 무엇을 할지 정합니다.

```bash
py stampede.py single-flight
```

```
동시 요청 20개, DB 조회 1회
  LOADER        1개, 최대    26 ms
  WAITED       19개, 최대    46 ms
```

DB 조회가 20번에서 1번으로 줄었고, 기다린 19개 요청도 46ms 안에 캐시에서 값을 받았습니다.

DB 조회가 대기 제한보다 오래 걸리는 경우도 봅니다. 조회에 2초가 걸리게 하고 대기 제한을 0.5초로 둡니다. 대기가 끝난 요청이 DB로 가게 한 경우(`slow-db`)와 오류를 돌려주게 한 경우(`slow-db-fail`)입니다.

```bash
py stampede.py slow-db
py stampede.py slow-db-fail
```

```
동시 요청 20개, DB 조회 20회
  LOADER        1개, 최대  2013 ms
  TIMEOUT->DB  19개, 최대  2532 ms
```

```
동시 요청 20개, DB 조회 1회
  ERROR        19개, 최대   518 ms
  LOADER        1개, 최대  2012 ms
```

대기가 끝난 요청을 DB로 보내면 락이 있어도 DB 조회는 다시 20번이 됩니다. 오류를 돌려주면 DB는 지킬 수 있지만 19개 요청이 실패합니다. `SET NX` 하나로 stampede가 끝나지는 않습니다. 대기 제한, 그 뒤의 처리(오류, 옛값 응답, DB 조회), 락 TTL을 DB 조회 시간에 맞춰 함께 정해야 합니다. 또 Redis의 락은 같은 Redis를 보는 요청끼리만 막습니다. 앱 서버 안의 메모리 락으로 single-flight를 만들면 서버 수만큼 DB 조회가 갑니다.

### TTL jitter: 여러 키가 같은 시간에 만료되지 않게 합니다

single-flight는 같은 키에 몰린 요청만 묶습니다. 배포 직후나 배치로 수많은 키를 같은 TTL로 한꺼번에 채우면, 서로 다른 키들이 같은 시간에 만료되면서 DB에 조회가 몰립니다. TTL에 무작위 값을 조금 더해 만료 시간을 흩뜨립니다. 키 1000개를 TTL 600초로 넣은 경우와, 600초에 0~120초를 더한 경우를 비교합니다.

```bash
py stampede.py jitter
```

```
고정 TTL: 키 1000개가 만료되는 초 1개, 한 초에 최대 1000개
jitter: 키 1000개가 만료되는 초 121개, 한 초에 최대 17개
```

고정 TTL에서는 1000개가 같은 1초에 만료되고, jitter를 주면 121초에 걸쳐 한 초에 최대 17개씩 만료됩니다. jitter는 키 하나에 몰리는 요청에는 효과가 없습니다.

### early refresh: 만료되기 전에 미리 갱신합니다

인기 키 하나의 만료 순간 자체를 없애려면 만료 전에 값을 갱신합니다. 조회할 때 `PTTL`로 남은 시간을 함께 보고, 1초 미만이면 한 요청만 백그라운드에서 DB를 읽어 캐시를 다시 채웁니다. 나머지 요청은 그동안 기존 값을 그대로 받습니다.

```python
    def refresh_in_background(pid):
        if r.set(f"refresh:product:{pid}", 1, nx=True, ex=2):   # 갱신은 한 요청만
            def job():
                value = load_from_db(connect(), pid)
                r.set(f"product:{pid}", json.dumps(value), ex=3)
            threading.Thread(target=job).start()
            return True
        return False
```

TTL 3초인 키 하나를 10개 스레드가 10초 동안 계속 조회합니다.

```bash
py stampede.py early
```

```
그냥 만료: 10초 동안 {'MISS': 30, 'HIT': 8253, 'REFRESH': 0}, DB 조회 30회
early refresh: 10초 동안 {'MISS': 0, 'HIT': 8386, 'REFRESH': 4}, DB 조회 4회
```

그냥 두면 만료될 때마다 10개 스레드가 함께 miss를 내서 DB 조회가 30번이었습니다. early refresh에서는 miss가 없고, 만료 전에 4번 갱신한 것이 DB 조회의 전부입니다. 이 방법은 자주 조회되는 키에만 맞습니다. 거의 조회되지 않는 키는 갱신할 기회가 오기 전에 만료되고, 모든 키를 미리 갱신하면 쓰지 않을 값까지 DB에서 읽게 됩니다.

<figure>
<svg viewBox="0 -20 480 338" role="img" aria-label="같은 키에 동시에 몰린 miss는 single-flight로, 여러 키가 같은 시간에 만료되는 것은 TTL jitter로, 인기 키의 만료 순간 자체는 early refresh로 다룬다" className="diagram h-auto w-full">
  <rect x="12" y="0" width="200" height="82" rx="10" className="fill-card stroke-border" strokeWidth="2" />
  <text x="112" y="48" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">같은 키 동시 MISS</text>
  <rect x="238" y="0" width="230" height="82" rx="10" className="fill-accent stroke-primary" strokeWidth="2" />
  <text x="353" y="35" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-accent-foreground">single-flight</text>
  <text x="353" y="61" textAnchor="middle" fontSize="17" className="fill-accent-foreground">SET NX 락, 1개만 DB로</text>
  <line x1="218" y1="41" x2="221" y2="41" className="stroke-primary" strokeWidth="2" />
  <polygon points="232,41 221,47 221,35" className="fill-primary" />
  <rect x="12" y="108" width="200" height="82" rx="10" className="fill-card stroke-border" strokeWidth="2" />
  <text x="112" y="156" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">여러 키 동시 만료</text>
  <rect x="238" y="108" width="230" height="82" rx="10" className="fill-accent stroke-primary" strokeWidth="2" />
  <text x="353" y="143" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-accent-foreground">TTL jitter</text>
  <text x="353" y="169" textAnchor="middle" fontSize="17" className="fill-accent-foreground">만료 시간을 흩뜨림</text>
  <line x1="218" y1="149" x2="221" y2="149" className="stroke-primary" strokeWidth="2" />
  <polygon points="232,149 221,155 221,143" className="fill-primary" />
  <rect x="12" y="216" width="200" height="82" rx="10" className="fill-card stroke-border" strokeWidth="2" />
  <text x="112" y="264" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">인기 키 만료 순간</text>
  <rect x="238" y="216" width="230" height="82" rx="10" className="fill-accent stroke-primary" strokeWidth="2" />
  <text x="353" y="251" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-accent-foreground">early refresh</text>
  <text x="353" y="277" textAnchor="middle" fontSize="17" className="fill-accent-foreground">만료 전에 미리 갱신</text>
  <line x1="218" y1="257" x2="221" y2="257" className="stroke-primary" strokeWidth="2" />
  <polygon points="232,257 221,263 221,251" className="fill-primary" />
</svg>
<figcaption>세 기법은 막는 상황이 서로 다릅니다. 하나로 나머지를 대신할 수 없습니다.</figcaption>
</figure>

## Redis가 멈추면 요청은 DB로 갑니다

Redis 조회가 실패하면 DB에서 바로 읽게(fallback) 만드는 경우가 많습니다. 요청이 적을 때는 괜찮지만, 평소 hit로 끝나던 요청이 전부 DB로 가면 DB가 버티지 못합니다.

먼저 타임아웃입니다. redis-py는 `socket_timeout`의 기본값이 `None`이라서, Redis 프로세스가 멈춰 응답하지 않으면 한없이 기다립니다. Redis를 `pause`한 상태에서 타임아웃 설정별로 `GET` 한 번을 보내 봅니다.

```bash
docker compose pause redis
py fallback.py timeout
docker compose unpause redis
```

```
timeout 없음(기본값): 5초가 지나도 응답 없음
socket_timeout=1: TimeoutError, 1003 ms
socket_timeout=0.05: TimeoutError, 55 ms
```

타임아웃이 없으면 요청 스레드가 묶여 서버 전체가 멈추고, 1초면 모든 요청이 1초씩 늦어진 뒤 DB로 갑니다. Redis 조회의 타임아웃은 평소 응답 시간에 맞춰 짧게 둡니다.

이번에는 Redis를 아예 내리고(`stop`), 60개 스레드가 5초 동안 요청을 보냅니다. 스레드마다 요청 사이에 50ms를 쉽니다. 무제한 fallback은 Redis가 실패하면 요청마다 DB 커넥션을 열어 조회합니다. 앱 서버 여러 대가 각자 커넥션 풀을 가지고 있어서, 풀 크기를 모두 더하면 DB 한도를 넘는 상황을 단순하게 흉내 낸 것입니다. DB의 `max_connections`는 30이고 그중 3개는 슈퍼유저 몫이라, `app` 계정이 쓸 수 있는 커넥션은 27개입니다.

```bash
docker compose stop redis
py fallback.py unlimited
```

```
무제한 fallback: 60개 스레드, 5초, app 커넥션 최대 24개
  Redis 오류: 1867
  DB 조회 성공: 1236
  DB 오류: remaining connection slots are reserved for roles with the SUPERUSER attribute: 366
  DB 오류: sorry, too many clients already: 265
  성공 응답 시간 p50 115 ms, p99 265 ms
```

요청 1867개가 모두 DB로 갔고, 그중 631개는 커넥션을 얻지 못해 실패했습니다. 이 DB를 함께 쓰는 다른 기능도 같은 시간에 커넥션을 얻지 못하므로, 캐시 장애가 DB 장애로 번집니다. app 커넥션 최대값은 50ms 간격으로 잰 값이라 실제 최대값보다 작게 나올 수 있습니다.

보호 장치를 세 가지 넣습니다.

- 짧은 타임아웃: Redis 연결과 응답 타임아웃을 0.05초로 둡니다.
- 서킷 브레이커: Redis가 5번 연달아 실패하면 2초 동안 Redis를 부르지 않고 바로 다음 단계로 넘어갑니다. 2초가 지나면 다시 한 번 시도해 봅니다.
- 동시성 제한: DB 접근은 최대 10개짜리 커넥션 풀로만 합니다. 0.1초 안에 커넥션을 받지 못한 요청은 기다리지 않고 바로 거절(HTTP라면 503)합니다.

```python
class CircuitBreaker:
    """연속 실패가 threshold번이면 open_seconds 동안 Redis를 아예 부르지 않는다"""
    def __init__(self, threshold=5, open_seconds=2.0):
        self.threshold, self.open_seconds = threshold, open_seconds
        self.failures, self.opened_at, self.lock = 0, 0.0, threading.Lock()

    def allow(self):
        with self.lock:
            return self.failures < self.threshold or time.time() - self.opened_at > self.open_seconds

    def record(self, ok):
        with self.lock:
            if ok:
                self.failures = 0
            else:
                self.failures += 1
                if self.failures >= self.threshold:
                    self.opened_at = time.time()
```

```python
    if protected:
        rc = redis.Redis(host="redis", socket_connect_timeout=0.05, socket_timeout=0.05)
        breaker = CircuitBreaker()
        pool = ConnectionPool(APP_DSN, min_size=0, max_size=10, timeout=0.1,
                              kwargs={"autocommit": True}, open=True)
```

```python
        if protected:
            try:
                with pool.connection() as conn:          # 동시에 DB로 가는 요청은 최대 10개
                    load_from_db(conn, 1)
                return "DB 조회 성공"
            except PoolTimeout:
                return "거절(503)"                      # 0.1초 안에 커넥션을 못 받으면 바로 거절
```

```bash
py fallback.py protected
docker compose start redis
```

```
보호 장치 있음: 60개 스레드, 5초, app 커넥션 최대 10개
  DB 조회 성공: 2298
  breaker로 Redis 건너뜀: 2230
  Redis 오류: 80
  거절(503): 12
  성공 응답 시간 p50 80 ms, p99 125 ms
```

DB 커넥션은 10개를 넘지 않았고 DB 오류는 없었습니다. 브레이커가 열린 동안 2230번은 Redis를 아예 부르지 않았고, 커넥션을 기다리다 0.1초를 넘긴 12개는 바로 거절됐습니다. 성공 건수가 무제한 쪽보다 많은 것은, 커넥션 풀이 커넥션을 다시 쓰고 실패한 연결 시도에 시간을 쓰지 않았기 때문입니다. 이번 부하는 커넥션 10개로 감당할 수 있는 정도라 거절이 적었습니다. 부하가 더 크면 거절이 늘지만, 그 대신 DB와 DB를 함께 쓰는 다른 기능은 살아 있습니다.

Redis가 내려가 있던 동안에는 캐시를 채우지도 못했습니다. 이 실습의 Redis는 영속화를 꺼 두었으므로 다시 올라오면 키가 하나도 없는 상태에서 시작하고, 첫 요청들이 한꺼번에 miss를 냅니다. 복구 직후에도 앞의 stampede 대책과 동시성 제한이 필요합니다.

<figure>
<svg viewBox="0 -20 480 396" role="img" aria-label="Redis 장애 때 요청을 무제한으로 DB에 보내면 커넥션이 고갈되고, 짧은 타임아웃과 서킷 브레이커, 동시성 제한을 거치면 DB로 가는 양이 제한된다" className="diagram h-auto w-full">
  <rect x="125" y="0" width="230" height="82" rx="10" className="fill-card stroke-destructive" strokeWidth="2" />
  <text x="240" y="35" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-destructive">Redis 장애</text>
  <text x="240" y="61" textAnchor="middle" fontSize="17" className="fill-destructive">timeout 또는 연결 실패</text>
  <rect x="15" y="146" width="200" height="82" rx="10" className="fill-card stroke-destructive" strokeWidth="2" />
  <text x="115" y="181" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-destructive">무제한 fallback</text>
  <text x="115" y="207" textAnchor="middle" fontSize="17" className="fill-destructive">모든 요청이 DB로</text>
  <rect x="239" y="146" width="225" height="82" rx="10" className="fill-accent stroke-primary" strokeWidth="2" />
  <text x="352" y="181" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-accent-foreground">보호 장치</text>
  <text x="352" y="207" textAnchor="middle" fontSize="17" className="fill-accent-foreground">타임아웃·브레이커·제한</text>
  <line x1="240" y1="88" x2="126" y2="136" className="stroke-muted-foreground" strokeWidth="2" />
  <polygon points="115,140 123,130 128,141" className="fill-muted-foreground" />
  <line x1="240" y1="88" x2="342" y2="135" className="stroke-primary" strokeWidth="2" />
  <polygon points="352,140 339,141 345,130" className="fill-primary" />
  <rect x="15" y="274" width="200" height="82" rx="10" className="fill-card stroke-destructive" strokeWidth="2" />
  <text x="115" y="322" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-destructive">DB 커넥션 고갈</text>
  <rect x="239" y="274" width="220" height="82" rx="10" className="fill-card stroke-border" strokeWidth="2" />
  <text x="349" y="309" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">DB로는 최대 N개</text>
  <text x="349" y="335" textAnchor="middle" fontSize="17" className="fill-muted-foreground">넘치면 빨리 거절</text>
  <line x1="115" y1="234" x2="115" y2="257" className="stroke-muted-foreground" strokeWidth="2" />
  <polygon points="115,268 109,257 121,257" className="fill-muted-foreground" />
  <line x1="352" y1="234" x2="350" y2="257" className="stroke-primary" strokeWidth="2" />
  <polygon points="349,268 344,257 356,257" className="fill-primary" />
</svg>
<figcaption>캐시 장애 대응에는 DB를 지키는 장치까지 들어가야 합니다.</figcaption>
</figure>

## 무엇을 지켜봐야 할까요

### hit ratio

Redis의 `INFO stats`에는 키를 찾았을 때와 못 찾았을 때의 누적 횟수가 있습니다. 상품 200개를 앞쪽 상품일수록 자주 조회되게 하고, TTL 5초로 10초 동안 조회한 뒤 확인합니다.

```bash
py metrics.py hit-ratio
rcli INFO stats | grep -E "^keyspace_(hits|misses)|^evicted_keys"
```

```
애플리케이션 집계: HIT 910, MISS 238, DB 조회 238
INFO stats: keyspace_hits 910, keyspace_misses 238, hit ratio 79.3%
HIT 데이터 age: p50 2.5초, 최대 5.0초
```

```
evicted_keys:0
keyspace_hits:910
keyspace_misses:238
```

- `keyspace_hits`, `keyspace_misses`: 키를 찾은 횟수와 못 찾은 횟수입니다. hit ratio는 `hits / (hits + misses)`로 계산합니다.
- 이 값은 Redis 전체의 누적값이고 명령 종류를 가리지 않습니다. 락 키나 다른 용도의 키 조회도 함께 섞이므로, 기능별 hit ratio는 애플리케이션에서 따로 셉니다. `CONFIG RESETSTAT`으로 0부터 다시 셀 수 있습니다.

hit ratio가 높아도 틀린 값을 많이 돌려줄 수 있습니다. 앞의 TTL 실습에서 TTL 20초는 DB 조회가 가장 적었지만 응답의 64.2%가 틀린 가격이었습니다. 그래서 hit ratio와 함께 다음 값을 봅니다.

- 데이터 age: 캐시 값에 DB에서 읽은 시간을 넣어 두고, 응답할 때 지금과의 차이를 기록합니다. 위 결과의 최대 age 5.0초는 TTL 5초와 같습니다. 허용한 stale 시간보다 큰 age가 보이면 TTL이 지켜지지 않는 키가 있다는 뜻입니다.
- DEL 실패 수: 쓰기 뒤 캐시 삭제가 실패한 횟수와 재시도까지 실패한 횟수입니다. 이 값이 0이 아니면 그 키는 TTL까지 stale일 수 있습니다.
- miss와 fallback으로 생긴 DB 조회 수(QPS), 캐시와 DB 각각의 p95·p99 응답 시간.

### evicted_keys

Redis 메모리가 `maxmemory`에 닿으면 `maxmemory-policy`에 따라 동작합니다. Redis 8.10의 기본 정책은 `noeviction`입니다. `maxmemory`를 2MB로 낮추고 1KB 값 5000개를 넣어 봅니다.

```bash
py metrics.py eviction
```

```
noeviction: SET 실패 4424, 남은 키 576, evicted_keys 0, used_memory 2.00M
   command not allowed when used memory > 'maxmemory'.
allkeys-lru: SET 실패 0, 남은 키 548, evicted_keys 4429, used_memory 2.00M
```

- `noeviction`: 키를 지우지 않고 메모리를 늘리는 쓰기 명령을 `OOM` 오류로 거부합니다. 캐시를 채우는 `SET`이 4424번 실패했습니다. Cache Aside에서는 응답은 DB 값으로 나가지만 캐시가 채워지지 않아 miss가 계속됩니다.
- `allkeys-lru`: 최근에 덜 쓴 키부터 지우고 새 키를 넣습니다. 4429개가 밀려났습니다.

캐시 용도라면 `allkeys-lru`나 `allkeys-lfu`처럼 키를 지우는 정책을 고르고, `evicted_keys`가 꾸준히 늘면 메모리가 작업 세트보다 작다는 뜻이므로 hit ratio와 함께 봅니다. TTL이 있는 키만 지우는 `volatile-*` 정책은 TTL 없는 키가 메모리를 차지하고 있으면 지울 키를 찾지 못합니다.

## 정리

1. Cache Aside에서 Redis는 DB를 대신 읽지 않습니다. miss가 나면 애플리케이션이 DB를 읽고 TTL을 붙여 채웁니다.
2. 쓰기는 `DB COMMIT → DEL` 순서로 합니다. `DEL`은 여러 writer의 순서가 뒤집혀도 결과가 같고, 실패했을 때 다시 보내도 안전합니다.
3. DB 커밋 뒤의 `SET`이나 `DEL`은 실패할 수 있고, 그러면 TTL까지 stale이 남습니다. 실패한 키는 재시도 큐에 남기고 실패 수를 셉니다.
4. `DEL`이 성공해도 늦게 끝난 reader가 옛값을 다시 넣을 수 있습니다. 짧은 TTL, 지연 이중 삭제, 버전 비교 후 SET으로 줄이고, 틀리면 안 되는 조회는 캐시를 거치지 않습니다.
5. TTL은 무효화가 빠졌을 때 stale이 남는 시간의 상한입니다. 데이터별로 허용할 stale 시간을 먼저 정합니다.
6. stampede 대책은 적용 범위가 다릅니다. 같은 키의 동시 miss는 single-flight, 여러 키의 동시 만료는 TTL jitter, 인기 키의 만료 순간은 early refresh로 다루고, 락에는 토큰, TTL, 대기 제한을 함께 둡니다.
7. Redis 장애 때 요청을 무제한으로 DB에 보내지 않습니다. 짧은 타임아웃, 서킷 브레이커, DB 동시성 제한으로 DB를 지킵니다.
8. hit ratio만 보지 말고 데이터 age, DEL 실패 수, DB QPS, `evicted_keys`를 함께 봅니다.

===== redis-vs-valkey =====
Redis를 쓰는 서비스에서 Valkey로 옮기자는 이야기가 나오면 "Redis에서 갈라져 나온 프로젝트니까 이미지 이름만 바꾸면 된다"는 말이 함께 나오곤 합니다. 같은 클라이언트 라이브러리로 접속해 같은 명령을 보낼 수 있으니 틀린 말은 아닙니다. 그런데 두 프로젝트가 갈라진 지 2년 반이 지나는 동안 각자 추가한 명령이 생겼고, 디스크에 남기는 RDB 파일의 형식도 달라졌습니다.

배경이 된 사실부터 날짜순으로 적습니다.

- 2024년 3월 20일: Redis는 7.4부터 라이선스를 BSD 3-Clause에서 RSALv2와 SSPLv1 중 하나를 고르는 이중 라이선스로 바꾼다고 발표했습니다(Redis 공식 블로그 "Redis Adopts Dual Source-Available Licensing"). 7.2 이전 릴리스는 BSD 3-Clause 그대로입니다.
- 2024년 3월 28일: Linux Foundation이 Redis 7.2.4에서 포크한 Valkey 프로젝트를 발표했습니다. 라이선스는 BSD 3-Clause입니다(Linux Foundation 보도자료 "Linux Foundation Launches Open Source Valkey Community").
- 2025년 5월 1일: Redis는 Redis 8부터 AGPLv3를 세 번째 선택지로 추가했다고 발표했습니다(Redis 공식 블로그 "Redis is now available under the AGPLv3 open source license").

라이선스가 내 서비스에 어떤 조건을 거는지는 각 원문을 직접 확인해야 합니다. 이 글은 기술적인 차이만 다룹니다.

이 글에서는 `redis:8.10.2`와 `valkey/valkey:9.1.2` 이미지를 나란히 띄우고 Python `redis` 6.4.0 클라이언트 하나로 네 가지를 확인합니다. 같은 명령에 같은 응답이 오는지, 한쪽에만 있는 명령은 무엇인지, RDB 파일을 서로 넘기면 읽히는지, 같은 조건의 `redis-benchmark` 결과가 어떻게 나오는지입니다. 측정은 2026년 10월 2일에 했고, 두 프로젝트 모두 릴리스가 잦으니 다른 버전에서는 결과가 달라질 수 있습니다.

## 실습 준비

Redis, Valkey, Python 클라이언트 컨테이너를 같은 Docker 네트워크에 띄웁니다. 두 서버는 영속성을 끄고(`--save "" --appendonly no`) CPU 2개와 메모리 1GB로 똑같이 제한합니다. `VALKEY_ARGS`는 뒤에서 Valkey에 옵션을 하나 더 줄 때 씁니다.

```yaml
services:
  redis:
    image: redis:8.10.2
    command: redis-server --save "" --appendonly no
    ports:
      - "7290:6379"
    cpus: 2
    mem_limit: 1g
    volumes:
      - rdata:/data
  valkey:
    image: valkey/valkey:9.1.2
    command: valkey-server --save "" --appendonly no ${VALKEY_ARGS:-}
    ports:
      - "7291:6379"
    cpus: 2
    mem_limit: 1g
    volumes:
      - vdata:/data
  app:
    image: python:3.12-slim
    command: sh -c "pip install -q --root-user-action=ignore redis==6.4.0 && sleep infinity"
    volumes:
      - .:/app
    working_dir: /app
volumes:
  rdata:
  vdata:
```

compose 프로젝트 이름은 `valk`로 두고 셸 함수를 만듭니다. `rcli`와 `vcli`는 둘 다 Redis 컨테이너 안의 같은 `redis-cli`를 실행하고, `vcli`만 `-h valkey`로 Valkey에 접속합니다. 명령행 클라이언트도 이렇게 하나만 씁니다.

```bash
export COMPOSE_PROJECT_NAME=valk
docker compose up -d
rcli() { docker compose exec -T redis redis-cli --no-raw "$@"; }
vcli() { docker compose exec -T redis redis-cli --no-raw -h valkey "$@"; }
py() { docker compose exec -T app python "$@"; }
```

두 서버의 버전을 확인합니다.

```bash
rcli INFO server | grep -E "^(redis_version|valkey_version|server_name)"
vcli INFO server | grep -E "^(redis_version|valkey_version|server_name)"
```

```
redis_version:8.10.2
redis_version:7.2.4
server_name:valkey
valkey_version:9.1.2
```

Valkey 9.1.2는 `redis_version` 필드에 7.2.4를 보고합니다. 갈라져 나온 시점의 Redis 버전을 호환용으로 남겨 둔 값이고, 실제 버전은 `valkey_version`에 있습니다. 모니터링 도구나 클라이언트가 `redis_version`을 보고 기능을 켜고 끈다면 Valkey 9.1을 Redis 7.2.4로 판단하게 되므로, 쓰는 도구가 이 값을 읽는지 미리 확인해 둡니다.

<figure>
<svg viewBox="0 -20 480 414" role="img" aria-label="Python redis 6.4.0 클라이언트 하나가 같은 명령 79개를 Redis 8.10.2와 Valkey 9.1.2에 보내고 응답을 비교해 77개가 일치했다" className="diagram h-auto w-full">
  <rect x="90" y="0" width="300" height="82" rx="10" className="fill-accent stroke-primary" strokeWidth="2" />
  <text x="240" y="35" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-accent-foreground">app 컨테이너</text>
  <text x="240" y="61" textAnchor="middle" fontSize="17" className="fill-accent-foreground">redis-py 6.4.0, 명령 79개</text>
  <rect x="18" y="146" width="214" height="82" rx="10" className="fill-card stroke-border" strokeWidth="2" />
  <text x="125" y="181" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">Redis 8.10.2</text>
  <text x="125" y="207" textAnchor="middle" fontSize="17" className="fill-muted-foreground">호스트 7290</text>
  <rect x="248" y="146" width="214" height="82" rx="10" className="fill-card stroke-border" strokeWidth="2" />
  <text x="355" y="181" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">Valkey 9.1.2</text>
  <text x="355" y="207" textAnchor="middle" fontSize="17" className="fill-muted-foreground">호스트 7291</text>
  <line x1="240" y1="88" x2="135" y2="135" className="stroke-muted-foreground" strokeWidth="2" />
  <polygon points="125,140 133,130 137,141" className="fill-muted-foreground" />
  <line x1="240" y1="88" x2="345" y2="135" className="stroke-muted-foreground" strokeWidth="2" />
  <polygon points="355,140 343,141 347,130" className="fill-muted-foreground" />
  <rect x="90" y="292" width="300" height="82" rx="10" className="fill-card stroke-border" strokeWidth="2" />
  <text x="240" y="327" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">응답 비교</text>
  <text x="240" y="353" textAnchor="middle" fontSize="17" className="fill-muted-foreground">79개 중 77개 일치</text>
  <line x1="125" y1="234" x2="172" y2="278" className="stroke-muted-foreground" strokeWidth="2" />
  <polygon points="180,286 168,283 176,274" className="fill-muted-foreground" />
  <line x1="355" y1="234" x2="308" y2="278" className="stroke-muted-foreground" strokeWidth="2" />
  <polygon points="300,286 304,274 312,283" className="fill-muted-foreground" />
</svg>
<figcaption>클라이언트, 명령 순서, 비교 코드를 모두 같게 두고 서버만 바꿉니다.</figcaption>
</figure>

## 같은 클라이언트, 같은 명령

`compat.py`는 같은 명령 목록을 두 서버에 차례로 보내고 응답을 비교해 다른 것만 출력합니다. 명령은 서비스에서 자주 쓰는 것을 자료구조별로 골랐습니다. 자료구조마다 어떤 연산이 빠른지는 [Redis 자료구조를 다룬 글](/posts/redis-data-structures)에서 따로 다뤘습니다. 문자열, Hash(필드 TTL을 거는 `HEXPIRE` 포함), List, Set, Sorted Set, Stream과 소비자 그룹, Bitmap, HyperLogLog, Geo에 더해 Lua 스크립트, 함수(`FUNCTION LOAD`, `FCALL`), 오류 응답, 트랜잭션, Pub/Sub까지 79개입니다.

```python
import redis
from redis.exceptions import ResponseError

SERVERS = {"redis": "redis", "valkey": "valkey"}

LIB = """#!lua name=mylib
redis.register_function('myincr', function(keys, args) return redis.call('INCR', keys[1]) end)"""

CMDS = [
    # String
    ["SET", "s:1", "hello"], ["GET", "s:1"], ["SET", "s:1", "x", "NX"], ["APPEND", "s:1", "!"],
    ["GETRANGE", "s:1", "0", "2"], ["STRLEN", "s:1"], ["SET", "n:1", "10"], ["INCR", "n:1"],
    ["INCRBYFLOAT", "n:1", "0.5"], ["MSET", "s:2", "a", "s:3", "b"], ["MGET", "s:2", "s:3", "s:none"],
    ["SET", "s:4", "v", "EX", "100"], ["TTL", "s:4"], ["GETDEL", "s:4"],
    # Hash
    ["HSET", "h:1", "name", "kim", "age", "30"], ["HGET", "h:1", "name"], ["HINCRBY", "h:1", "age", "1"],
    ["HGETALL", "h:1"], ["HLEN", "h:1"], ["OBJECT", "ENCODING", "h:1"], ["HEXPIRE", "h:1", "100", "FIELDS", "1", "name"],
    ["HTTL", "h:1", "FIELDS", "2", "name", "age"], ["HPERSIST", "h:1", "FIELDS", "1", "name"],
    ["HGETDEL", "h:1", "FIELDS", "1", "age"],
    # List
    ["RPUSH", "l:1", "a", "b", "c"], ["LPUSH", "l:1", "z"], ["LRANGE", "l:1", "0", "-1"],
    ["LPOS", "l:1", "b"], ["LMOVE", "l:1", "l:2", "LEFT", "RIGHT"], ["LPOP", "l:1", "2"],
    # Set
    ["SADD", "set:1", "a", "b", "c"], ["SADD", "set:2", "b", "c", "d"], ["SISMEMBER", "set:1", "a"],
    ["SMISMEMBER", "set:1", "a", "z"], ["SINTERCARD", "2", "set:1", "set:2"],
    # Sorted Set
    ["ZADD", "z:1", "10", "a", "20", "b", "30", "c"], ["ZINCRBY", "z:1", "5", "a"],
    ["ZRANGE", "z:1", "0", "-1", "WITHSCORES"], ["ZRANGE", "z:1", "(15", "+inf", "BYSCORE"],
    ["ZRANK", "z:1", "b", "WITHSCORE"], ["ZMPOP", "1", "z:1", "MIN"],
    # Stream
    ["XADD", "x:1", "1-1", "f", "v"], ["XADD", "x:1", "1-2", "f", "w"], ["XLEN", "x:1"],
    ["XRANGE", "x:1", "-", "+"], ["XGROUP", "CREATE", "x:1", "g1", "0"],
    ["XREADGROUP", "GROUP", "g1", "c1", "COUNT", "1", "STREAMS", "x:1", ">"],
    ["XPENDING", "x:1", "g1"], ["XACK", "x:1", "g1", "1-1"],
    # Bitmap, HyperLogLog, Geo
    ["SETBIT", "b:1", "7", "1"], ["BITCOUNT", "b:1"], ["BITPOS", "b:1", "1"],
    ["PFADD", "hll:1", "a", "b", "c", "a"], ["PFCOUNT", "hll:1"],
    ["GEOADD", "g:1", "126.9780", "37.5665", "seoul", "129.0756", "35.1796", "busan"],
    ["GEODIST", "g:1", "seoul", "busan", "km"],
    ["GEOSEARCH", "g:1", "FROMLONLAT", "127", "37.5", "BYRADIUS", "50", "km", "ASC"],
    # Keys
    ["TYPE", "z:1"], ["EXISTS", "s:1", "h:1", "nope"], ["EXPIRE", "s:1", "100"], ["PERSIST", "s:1"],
    ["COPY", "s:1", "s:copy"], ["RENAME", "s:copy", "s:copy2"], ["OBJECT", "ENCODING", "h:1"],
    ["OBJECT", "ENCODING", "l:1"], ["OBJECT", "ENCODING", "set:1"], ["OBJECT", "ENCODING", "n:1"],
    # Lua, Functions
    ["EVAL", "return redis.call('INCR', KEYS[1])", "1", "n:lua"],
    ["FUNCTION", "LOAD", LIB], ["FCALL", "myincr", "1", "n:fn"],
    # Errors
    ["GET", "h:1"], ["NOSUCHCMD"], ["GET"],
    # Connection
    ["CLIENT", "SETNAME", "compat"], ["CLIENT", "GETNAME"], ["PUBLISH", "ch:1", "hi"], ["DBSIZE"],
]


def norm(v):
    if isinstance(v, bytes):
        return v.decode()
    if isinstance(v, (list, tuple)):
        return [norm(x) for x in v]
    if isinstance(v, dict):
        return {norm(k): norm(x) for k, x in v.items()}
    return v


def run(host):
    r = redis.Redis(host=host, single_connection_client=True)
    r.flushall()
    r.function_flush()
    out = []
    for cmd in CMDS:
        try:
            out.append(norm(r.execute_command(*cmd)))
        except ResponseError as e:
            out.append(f"(error) {e}")
    # 트랜잭션
    p = r.pipeline(transaction=True)
    p.incr("n:tx"); p.incr("n:tx"); p.get("n:tx")
    out.append(norm(p.execute()))
    # Pub/Sub: 다른 연결에서 구독한 뒤 발행
    sub = redis.Redis(host=host).pubsub()
    sub.subscribe("ch:2")
    sub.get_message(timeout=1)
    n = r.publish("ch:2", "hello")
    msg = sub.get_message(timeout=1)
    out.append([n, norm(msg["type"]), norm(msg["data"])])
    return out


res = {name: run(host) for name, host in SERVERS.items()}
labels = [" ".join(c if len(c) < 40 else c[:30] + "..." for c in cmd) for cmd in CMDS]
labels += ["MULTI; INCR; INCR; GET; EXEC", "SUBSCRIBE + PUBLISH"]
same = 0
for i, label in enumerate(labels):
    a, b = res["redis"][i], res["valkey"][i]
    if a == b:
        same += 1
        continue
    print(f"[다름] {label}")
    print(f"  redis : {a}")
    print(f"  valkey: {b}")
print(f"명령 {len(labels)}개 중 {same}개 응답 일치")
```

```bash
py compat.py
```

```
[다름] OBJECT ENCODING h:1
  redis : listpackex
  valkey: hashtable
[다름] NOSUCHCMD
  redis : (error) unknown command 'NOSUCHCMD'
  valkey: (error) unknown command 'NOSUCHCMD', with args beginning with: 
명령 79개 중 77개 응답 일치
```

79개 중 77개가 같았습니다. 다른 두 개는 이렇습니다.

- `OBJECT ENCODING h:1`: 필드에 TTL을 한 번 건 Hash의 내부 인코딩입니다. Redis는 `listpackex`, Valkey는 `hashtable`을 씁니다. 같은 스크립트에서 TTL을 걸기 전에는 둘 다 `listpack`으로 같았습니다. 저장된 값과 다른 명령의 응답은 같았습니다.
- `NOSUCHCMD`: 인자 없이 없는 명령을 보냈을 때 Redis 8.10은 `with args beginning with:` 부분을 붙이지 않습니다. 인자를 붙여 보내면 두 서버의 오류 문자열이 똑같았습니다.

트랜잭션, Lua, 함수, Pub/Sub, 소비자 그룹, 오류 응답(`WRONGTYPE`, 인자 수 오류)은 모두 같았습니다. 이 79개 명령만 쓰고 오류 문자열을 그대로 비교하는 코드가 없다면, 클라이언트 코드는 고치지 않아도 됩니다.

RESP3로 접속할 때 서버가 돌려주는 `HELLO` 응답은 서버 이름과 버전이 다릅니다.

```bash
rcli HELLO 3 | head -7
vcli HELLO 3 | head -7
```

```
1# "server" => "redis"
2# "version" => "8.10.2"
3# "proto" => (integer) 3
4# "id" => (integer) 24
5# "mode" => "standalone"
6# "role" => "master"
7# "modules" => 
1# "server" => "valkey"
2# "version" => "9.1.2"
3# "proto" => (integer) 3
4# "id" => (integer) 19
5# "mode" => "standalone"
6# "role" => "master"
7# "modules" => 
```

`valkey.conf`에는 Valkey가 자신을 Redis라고 소개하게 하는 `extended-redis-compatibility` 설정이 있습니다. 도구나 클라이언트가 서버 이름 때문에 문제를 일으킬 때만 쓰라는 임시 설정이고, 나중 버전에서 없어질 예정이라고 적혀 있습니다. 켜 보면 `HELLO` 응답이 이렇게 바뀝니다.

```bash
vcli CONFIG SET extended-redis-compatibility yes; vcli HELLO 3 | head -2
vcli CONFIG SET extended-redis-compatibility no
```

```
OK
1# "server" => "redis"
2# "version" => "7.2.4"
OK
```

## 한쪽에만 있는 명령

이번에는 두 서버가 아는 명령 이름을 모두 받아 비교합니다. `COMMAND LIST`는 하위 명령(`CLIENT|CAPA` 같은 것)도 따로 셉니다. Redis 쪽에서는 모듈이 제공하는 명령을 `COMMAND LIST FILTERBY MODULE`로 따로 빼냅니다.

```python
import redis

r = {n: redis.Redis(host=n, decode_responses=True) for n in ("redis", "valkey")}
names = {n: set(c.command_list()) for n, c in r.items()}
mods = [m["name"] for m in r["redis"].module_list()]
modcmds = set()
for m in mods:
    modcmds |= set(r["redis"].command_list(module=m))
a, b = names["redis"], names["valkey"]
print(f"redis {len(a)}개, valkey {len(b)}개, 공통 {len(a & b)}개")
print(f"redis 모듈({', '.join(mods)}) 명령 {len(modcmds)}개")
only_r = sorted(a - b - modcmds)
only_v = sorted(b - a)
print(f"\nredis에만 있는 명령(모듈 제외) {len(only_r)}개")
print(" ".join(only_r))
print(f"\nvalkey에만 있는 명령 {len(only_v)}개")
print(" ".join(only_v))
```

```bash
py cmddiff.py
rcli MODULE LIST | grep -A1 name
```

```
redis 669개, valkey 399개, 공통 385개
redis 모듈(bf, timeseries, ReJSON, search, vectorset) 명령 233개

redis에만 있는 명령(모듈 제외) 51개
arcount ardel ardelrange arget argetrange argrep arinfo arinsert arlastitems arlen armget armset arnext arop arring arscan arseek arset backup backup|abort backup|cleanup backup|help backup|list backup|seal backup|start backup|status blmovem cluster|migration delex digest himport himport|discard himport|discardall himport|prepare himport|set hotkeys hotkeys|get hotkeys|help hotkeys|reset hotkeys|start hotkeys|stop increx lmovem sdiffcard sunioncard trimslots xackdel xcfgset xdelex xidmprecord xnack

valkey에만 있는 명령 14개
client|capa client|import-source clusterscan cluster|cancelslotmigrations cluster|flushslot cluster|getslotmigrations cluster|migrateslots commandlog commandlog|get commandlog|help commandlog|len commandlog|reset delifeq script|show
1) 1) "name"
   2) "bf"
2) 1) "name"
   2) "timeseries"
3) 1) "name"
   2) "ReJSON"
4) 1) "name"
   2) "search"
5) 1) "name"
   2) "vectorset"
```

Redis 8.10.2 이미지는 모듈 다섯 개를 불러온 상태로 뜹니다. Redis 8.0 릴리스 노트에 따르면 Redis 8부터 검색, JSON, 시계열, 확률 자료구조(Bloom 필터 등)가 기본 배포에 들어갔고, 이 명령이 233개입니다. `valkey/valkey:9.1.2` 이미지에는 이런 모듈이 없어서 `JSON.SET`이나 `FT.SEARCH`를 쓰는 서비스는 이 이미지로 바로 옮길 수 없습니다.

모듈을 빼고도 Redis에만 있는 명령이 51개, Valkey에만 있는 명령이 14개입니다. 릴리스 노트에서 추가 버전을 확인한 것만 적으면 다음과 같습니다.

- Redis 8.2: `XDELEX`, `XACKDEL`
- Redis 8.4: `DELEX`, `DIGEST`
- Redis 8.6: `HOTKEYS`
- Redis 8.8: `INCREX`, `XNACK`, Array 자료구조(`AR`로 시작하는 명령)
- Redis 8.10: `HIMPORT`, `LMOVEM`, `BLMOVEM`, `SDIFFCARD`, `BACKUP`
- Valkey 8.1: `COMMANDLOG`
- Valkey 9.0: `DELIFEQ`
- Valkey 9.1: `CLUSTERSCAN`

같은 일을 하는 명령이 이름만 다른 경우도 있습니다. 값이 같을 때만 키를 지우는 동작은 Redis에서는 `DELEX key IFEQ value`, Valkey에서는 `DELIFEQ key value`입니다. 분산 락을 풀 때 쓰는 동작인데, 상대 서버에 보내면 없는 명령이라는 오류가 납니다.

```bash
rcli SET lock:1 token-a; rcli DELIFEQ lock:1 token-a
vcli SET lock:1 token-a; vcli DELEX lock:1 IFEQ token-a
vcli DELIFEQ lock:1 token-a; rcli DELEX lock:1 IFEQ token-a
```

```
OK
(error) ERR unknown command 'DELIFEQ', with args beginning with: 'lock:1' 'token-a' 
OK
(error) ERR unknown command 'DELEX', with args beginning with: 'lock:1' 'IFEQ' 'token-a' 
(integer) 1
(integer) 1
```

락 해제를 Lua 스크립트(`GET`으로 비교한 뒤 `DEL`)로 구현했다면 앞의 호환 스크립트에서 본 것처럼 두 서버에서 똑같이 동작합니다. 새 전용 명령으로 바꿨다면 그 코드는 서버를 바꿀 때 함께 고쳐야 합니다.

<figure>
<svg viewBox="0 -20 480 716" role="img" aria-label="Redis 7.2.4에서 갈라진 뒤 Redis는 8.2부터 8.10까지 XDELEX, DELEX, HOTKEYS, XNACK, HIMPORT 같은 명령을, Valkey는 8.1부터 9.1까지 COMMANDLOG, DELIFEQ, CLUSTERSCAN 같은 명령을 각자 추가했다" className="diagram h-auto w-full">
  <rect x="90" y="0" width="300" height="82" rx="10" className="fill-accent stroke-primary" strokeWidth="2" />
  <text x="240" y="35" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-accent-foreground">Redis 7.2.4</text>
  <text x="240" y="61" textAnchor="middle" fontSize="17" className="fill-accent-foreground">공통 출발점</text>
  <rect x="18" y="146" width="214" height="82" rx="10" className="fill-card stroke-border" strokeWidth="2" />
  <text x="125" y="181" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">Redis 8.2</text>
  <text x="125" y="207" textAnchor="middle" fontSize="17" className="fill-muted-foreground">XDELEX, XACKDEL</text>
  <rect x="18" y="258" width="214" height="82" rx="10" className="fill-card stroke-border" strokeWidth="2" />
  <text x="125" y="293" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">Redis 8.4</text>
  <text x="125" y="319" textAnchor="middle" fontSize="17" className="fill-muted-foreground">DELEX, DIGEST</text>
  <rect x="18" y="370" width="214" height="82" rx="10" className="fill-card stroke-border" strokeWidth="2" />
  <text x="125" y="405" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">Redis 8.6</text>
  <text x="125" y="431" textAnchor="middle" fontSize="17" className="fill-muted-foreground">HOTKEYS</text>
  <rect x="18" y="482" width="214" height="82" rx="10" className="fill-card stroke-border" strokeWidth="2" />
  <text x="125" y="517" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">Redis 8.8</text>
  <text x="125" y="543" textAnchor="middle" fontSize="17" className="fill-muted-foreground">INCREX, XNACK</text>
  <rect x="18" y="594" width="214" height="82" rx="10" className="fill-card stroke-border" strokeWidth="2" />
  <text x="125" y="629" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">Redis 8.10</text>
  <text x="125" y="655" textAnchor="middle" fontSize="17" className="fill-muted-foreground">HIMPORT, LMOVEM</text>
  <rect x="248" y="146" width="214" height="82" rx="10" className="fill-muted stroke-border" strokeWidth="2" />
  <text x="355" y="181" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">Valkey 7.2.5</text>
  <text x="355" y="207" textAnchor="middle" fontSize="17" className="fill-muted-foreground">첫 릴리스</text>
  <rect x="248" y="258" width="214" height="82" rx="10" className="fill-card stroke-border" strokeWidth="2" />
  <text x="355" y="293" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">Valkey 8.1</text>
  <text x="355" y="319" textAnchor="middle" fontSize="17" className="fill-muted-foreground">COMMANDLOG</text>
  <rect x="248" y="370" width="214" height="82" rx="10" className="fill-card stroke-border" strokeWidth="2" />
  <text x="355" y="405" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">Valkey 9.0</text>
  <text x="355" y="431" textAnchor="middle" fontSize="17" className="fill-muted-foreground">DELIFEQ</text>
  <rect x="248" y="482" width="214" height="82" rx="10" className="fill-card stroke-border" strokeWidth="2" />
  <text x="355" y="517" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">Valkey 9.1</text>
  <text x="355" y="543" textAnchor="middle" fontSize="17" className="fill-muted-foreground">CLUSTERSCAN</text>
  <line x1="240" y1="88" x2="135" y2="135" className="stroke-muted-foreground" strokeWidth="2" />
  <polygon points="125,140 133,130 137,141" className="fill-muted-foreground" />
  <line x1="240" y1="88" x2="345" y2="135" className="stroke-muted-foreground" strokeWidth="2" />
  <polygon points="355,140 343,141 347,130" className="fill-muted-foreground" />
  <line x1="125" y1="234" x2="125" y2="241" className="stroke-muted-foreground" strokeWidth="2" />
  <polygon points="125,252 119,241 131,241" className="fill-muted-foreground" />
  <line x1="125" y1="346" x2="125" y2="353" className="stroke-muted-foreground" strokeWidth="2" />
  <polygon points="125,364 119,353 131,353" className="fill-muted-foreground" />
  <line x1="125" y1="458" x2="125" y2="465" className="stroke-muted-foreground" strokeWidth="2" />
  <polygon points="125,476 119,465 131,465" className="fill-muted-foreground" />
  <line x1="125" y1="570" x2="125" y2="577" className="stroke-muted-foreground" strokeWidth="2" />
  <polygon points="125,588 119,577 131,577" className="fill-muted-foreground" />
  <line x1="355" y1="234" x2="355" y2="241" className="stroke-muted-foreground" strokeWidth="2" />
  <polygon points="355,252 349,241 361,241" className="fill-muted-foreground" />
  <line x1="355" y1="346" x2="355" y2="353" className="stroke-muted-foreground" strokeWidth="2" />
  <polygon points="355,364 349,353 361,353" className="fill-muted-foreground" />
  <line x1="355" y1="458" x2="355" y2="465" className="stroke-muted-foreground" strokeWidth="2" />
  <polygon points="355,476 349,465 361,465" className="fill-muted-foreground" />
</svg>
<figcaption>각 갈래 옆의 명령은 실습에서 한쪽에만 있다고 나온 것 가운데 릴리스 노트로 추가 버전을 확인한 예입니다.</figcaption>
</figure>

## RDB 파일을 넘기면 읽힐까

RDB 파일을 상대 서버로 옮겨 그대로 읽히는지 확인합니다. 영속성 자체(RDB와 AOF가 언제 무엇을 남기는지)는 [Redis 영속성을 다룬 글](/posts/redis-persistence)에서 다뤘으니, 여기서는 파일 호환만 봅니다.

시험 데이터는 `seed.py`로 만듭니다. 자료구조마다 키 10개씩 90개를 넣고, `--bulk`를 주면 문자열 키를 더 넣습니다. `--hfe`를 주면 필드 하나에 TTL을 건 Hash를 3개 추가합니다.

```python
import sys
import redis

host = sys.argv[1]
r = redis.Redis(host=host)
p = r.pipeline(transaction=False)
for i in range(10):
    p.set(f"str:{i}", f"value-{i}")
    p.hset(f"hash:{i}", mapping={"name": f"user{i}", "age": 20 + i})
    p.rpush(f"list:{i}", *[f"item{j}" for j in range(5)])
    p.sadd(f"set:{i}", *[f"m{j}" for j in range(5)])
    p.zadd(f"zset:{i}", {f"m{j}": j * 1.5 for j in range(5)})
    p.xadd(f"stream:{i}", {"event": "login", "n": i}, id=f"{i + 1}-0")
    p.setbit(f"bitmap:{i}", i * 8, 1)
    p.pfadd(f"hll:{i}", *[f"u{j}" for j in range(100)])
    p.geoadd(f"geo:{i}", (126.978, 37.5665, "seoul"))
if "--hfe" in sys.argv:
    for i in range(3):
        p.hset(f"hfe:{i}", mapping={"token": f"t{i}", "user": f"u{i}"})
        p.hexpire(f"hfe:{i}", 3600, "token")
p.execute()
if "--bulk" in sys.argv:
    n = int(sys.argv[sys.argv.index("--bulk") + 1])
    for start in range(0, n, 10000):
        p = r.pipeline(transaction=False)
        for i in range(start, min(start + 10000, n)):
            p.set(f"key:{i}", f"{i:016d}")
        p.execute()
print(f"{host}: DBSIZE {r.dbsize()}")
```

`check.py`는 키 수와 함께 대량으로 넣은 `key:*`를 뺀 자료구조 키를 모두 읽어 내용 해시를 만듭니다. 두 서버에서 해시가 같으면 내용이 같다고 봅니다.

```python
import hashlib, sys
import redis

r = redis.Redis(host=sys.argv[1], decode_responses=True)

def read(k):
    t = r.type(k)
    if t == "string":
        if k.startswith("bitmap:"):
            return r.bitpos(k, 1)
        if k.startswith("hll:"):
            return r.pfcount(k)
        return r.get(k)
    if t == "hash":
        return sorted(r.hgetall(k).items())
    if t == "list":
        return r.lrange(k, 0, -1)
    if t == "set":
        return sorted(r.smembers(k))
    if t == "zset":
        return r.zrange(k, 0, -1, withscores=True)
    if t == "stream":
        return r.xrange(k)
    return t

keys = sorted(k for k in r.scan_iter(count=1000) if not k.startswith("key:"))
h = hashlib.sha1()
for k in keys:
    h.update(repr((k, read(k))).encode())
print(f"DBSIZE {r.dbsize()}, 자료구조 키 {len(keys)}개, 내용 해시 {h.hexdigest()[:12]}")
print("hash:3 ->", read("hash:3"))
print("zset:3 ->", read("zset:3"))
print("hll:3  ->", r.pfcount("hll:3"))
if r.exists("hfe:0"):
    print("hfe:0 token TTL ->", r.httl("hfe:0", "token"))
```

파일을 옮기는 함수도 만듭니다. Redis의 `dump.rdb`를 호스트로 꺼낸 뒤, 멈춘 Valkey 컨테이너의 데이터 디렉터리에 넣고 다시 띄웁니다. `to_redis`는 방향만 반대입니다.

```bash
to_valkey() {
  docker compose cp redis:/data/dump.rdb ./dump.rdb
  docker compose stop valkey
  docker compose cp ./dump.rdb valkey:/data/dump.rdb
  docker compose up -d valkey
}
to_redis() {
  docker compose cp valkey:/data/dump.rdb ./dump.rdb
  docker compose stop redis
  docker compose cp ./dump.rdb redis:/data/dump.rdb
  docker compose up -d redis
}
```

### Redis 8.10에서 Valkey 9.1로

Redis에 데이터를 넣고 `SAVE`로 파일을 만든 뒤 파일 앞 9바이트를 봅니다.

```bash
py seed.py redis --bulk 100000
rcli SAVE
docker compose exec redis sh -c 'head -c 9 /data/dump.rdb; echo'
py check.py redis
```

```
redis: DBSIZE 100090
OK
REDIS0015
DBSIZE 100090, 자료구조 키 90개, 내용 해시 427025fb948b
hash:3 -> [('age', '23'), ('name', 'user3')]
zset:3 -> [('m0', 0.0), ('m1', 1.5), ('m2', 3.0), ('m3', 4.5), ('m4', 6.0)]
hll:3  -> 99
```

RDB 파일은 `REDIS` 다섯 글자 뒤에 네 자리 RDB 버전이 붙습니다. Redis 8.10.2는 RDB 버전 15로 저장했습니다. 이 파일을 Valkey로 옮깁니다.

```bash
to_valkey
docker compose logs --no-log-prefix valkey | tail -2
```

```
7:M 02 Oct 2026 00:35:48.447 # Can't handle RDB format version 15
7:M 02 Oct 2026 00:35:48.447 # Fatal error loading the DB, check server logs. Exiting.
```

Valkey는 기동하자마자 RDB 버전 15를 처리할 수 없다며 종료했습니다. Valkey 9.1.2 소스(`src/rdb.h`)를 보면 Valkey 자신의 RDB 버전은 80이고, 12부터 79까지는 다른 구현이 쓰는 "foreign" 버전으로 비워 두었습니다. 이 실습의 Redis 8.10.2가 만든 버전 15도 이 범위에 들어갑니다. 기본 설정(`rdb-version-check strict`)에서는 이 범위의 파일을 읽지 않습니다.

`valkey.conf`에는 이 검사를 느슨하게 하는 설정이 있습니다.

- `rdb-version-check strict`: 기본값입니다. 자기가 모르는 RDB 버전이면 읽지 않습니다.
- `rdb-version-check relaxed`: 일단 읽어 보고, 모르는 정보가 나오면 그 자리에서 중단합니다. `RESTORE`와 복제에도 똑같이 적용됩니다.

`relaxed`로 다시 띄웁니다.

```bash
export VALKEY_ARGS="--rdb-version-check relaxed"
docker compose up -d valkey
docker compose logs --no-log-prefix valkey | tail -6
```

```
7:M 02 Oct 2026 00:35:59.512 * Cleaning slot migration log in anticipation of a load operation.
7:M 02 Oct 2026 00:35:59.512 * Loading RDB produced by Redis version 8.10.2
7:M 02 Oct 2026 00:35:59.512 * RDB age 33 seconds
7:M 02 Oct 2026 00:35:59.512 * RDB memory usage when created 7.74 Mb
7:M 02 Oct 2026 00:35:59.515 # Can't handle foreign type or opcode 27 in RDB with version 15
7:M 02 Oct 2026 00:35:59.515 # Fatal error loading the DB, check server logs. Exiting.
```

이번에는 Redis 8.10.2가 만든 파일이라는 것까지 읽었지만, 타입 27에서 멈췄습니다. Redis 8.10.2 소스의 `src/rdb.h`에서 27은 `RDB_TYPE_STREAM_LISTPACKS_5`, 즉 XNACK을 지원하는 새 Stream 형식입니다. 이 실습의 Stream은 `XNACK`을 쓴 적이 없는데도 이 형식으로 저장됐습니다. Stream 키를 지우고 다시 저장해 옮겨 봅니다.

```bash
for i in 0 1 2 3 4 5 6 7 8 9; do rcli DEL stream:$i; done; rcli SAVE
to_valkey
docker compose logs --no-log-prefix valkey | tail -3
py check.py redis; py check.py valkey
```

```
OK
6:M 02 Oct 2026 00:36:57.239 * Done loading RDB, keys loaded: 100080, keys expired: 0, all fields expired hashes: 0.
6:M 02 Oct 2026 00:36:57.239 * DB loaded from disk: 0.148 seconds
6:M 02 Oct 2026 00:36:57.239 * Ready to accept connections tcp
DBSIZE 100080, 자료구조 키 80개, 내용 해시 47f8d4e83668
hash:3 -> [('age', '23'), ('name', 'user3')]
zset:3 -> [('m0', 0.0), ('m1', 1.5), ('m2', 3.0), ('m3', 4.5), ('m4', 6.0)]
hll:3  -> 99
DBSIZE 100080, 자료구조 키 80개, 내용 해시 47f8d4e83668
hash:3 -> [('age', '23'), ('name', 'user3')]
zset:3 -> [('m0', 0.0), ('m1', 1.5), ('m2', 3.0), ('m3', 4.5), ('m4', 6.0)]
hll:3  -> 99
```

Stream이 없는 파일은 키 100,080개가 모두 로드됐고 내용 해시도 같았습니다. 이제 필드 TTL을 건 Hash를 하나 추가해 봅니다.

```bash
rcli HSET hfe:0 token t0 user u0; rcli HEXPIRE hfe:0 3600 FIELDS 1 token; rcli SAVE
to_valkey
docker compose logs --no-log-prefix valkey | tail -2
```

```
(integer) 2
1) (integer) 1
OK
7:M 02 Oct 2026 00:37:37.374 # Can't handle foreign type or opcode 25 in RDB with version 15
7:M 02 Oct 2026 00:37:37.374 # Fatal error loading the DB, check server logs. Exiting.
```

타입 25는 Redis의 `RDB_TYPE_HASH_LISTPACK_EX`, 필드 TTL이 있는 Hash 형식입니다. Valkey 9.0도 Hash 필드 만료를 지원하고 명령 응답도 같았지만, 디스크에 쓰는 형식이 달라서 읽지 못했습니다. 앞의 호환 스크립트에서 `OBJECT ENCODING`이 달랐던 키와 같은 종류입니다.

### Valkey 9.1에서 Redis 8.10으로

반대 방향입니다. 새 환경에서 Valkey에 같은 데이터를 넣고 저장합니다.

```bash
py seed.py valkey --bulk 100000
vcli SAVE
docker compose exec valkey sh -c 'head -c 9 /data/dump.rdb; echo'
to_redis
docker compose logs --no-log-prefix redis | grep -E 'signature|Fatal'
```

```
valkey: DBSIZE 100090
OK
VALKEY080
1:M 02 Oct 2026 00:38:49.771 # Wrong signature trying to load DB from file
1:M 02 Oct 2026 00:38:49.771 # Fatal error loading the DB, check server logs. Exiting.
```

Valkey 9.1.2는 파일 앞을 `VALKEY`로 시작하고 RDB 버전 80을 붙였습니다. Redis는 파일 앞부분부터 자기 형식이 아니라고 보고 종료했습니다. Redis에는 이 검사를 느슨하게 하는 설정이 없습니다.

<figure>
<svg viewBox="0 -54 480 652" role="img" aria-label="Redis 8.10.2의 RDB 15 파일은 Valkey 9.1.2 기본 설정에서 거부되고, relaxed로 바꾸면 Stream이나 Hash 필드 TTL이 없을 때만 로드된다. Valkey의 VALKEY080 파일은 Redis가 시그니처 오류로 거부한다" className="diagram h-auto w-full">
  <rect x="57" y="444" width="366" height="134" rx="10" className="fill-background stroke-border" strokeWidth="2" strokeDasharray="6 6" />
  <text x="73" y="468" fontSize="17" fontWeight="700" className="fill-muted-foreground">Valkey 9.1.2 → Redis 8.10.2 (VALKEY080)</text>
  <rect x="57" y="-34" width="366" height="440" rx="10" className="fill-background stroke-border" strokeWidth="2" strokeDasharray="6 6" />
  <text x="73" y="-10" fontSize="17" fontWeight="700" className="fill-muted-foreground">Redis 8.10.2 → Valkey 9.1.2 (REDIS0015)</text>
  <rect x="75" y="0" width="330" height="82" rx="10" className="fill-card stroke-destructive" strokeWidth="2" />
  <text x="240" y="35" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-destructive">strict(기본값)</text>
  <text x="240" y="61" textAnchor="middle" fontSize="17" className="fill-destructive">RDB 버전 15 거부</text>
  <rect x="75" y="102" width="330" height="82" rx="10" className="fill-card stroke-destructive" strokeWidth="2" />
  <text x="240" y="137" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-destructive">relaxed, Stream 있음</text>
  <text x="240" y="163" textAnchor="middle" fontSize="17" className="fill-destructive">타입 27 거부</text>
  <rect x="75" y="204" width="330" height="82" rx="10" className="fill-accent stroke-primary" strokeWidth="2" />
  <text x="240" y="239" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-accent-foreground">relaxed, Stream 지운 뒤</text>
  <text x="240" y="265" textAnchor="middle" fontSize="17" className="fill-accent-foreground">100,080개 로드, 내용 해시 일치</text>
  <rect x="75" y="306" width="330" height="82" rx="10" className="fill-card stroke-destructive" strokeWidth="2" />
  <text x="240" y="341" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-destructive">relaxed, 필드 TTL 키 추가</text>
  <text x="240" y="367" textAnchor="middle" fontSize="17" className="fill-destructive">타입 25 거부</text>
  <rect x="75" y="478" width="330" height="82" rx="10" className="fill-card stroke-destructive" strokeWidth="2" />
  <text x="240" y="513" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-destructive">Redis 기본 설정</text>
  <text x="240" y="539" textAnchor="middle" fontSize="17" className="fill-destructive">Wrong signature</text>
</svg>
<figcaption>dump.rdb를 상대 서버의 데이터 디렉터리에 넣고 기동했을 때의 결과입니다.</figcaption>
</figure>

### 파일이 안 읽힐 때 옮기는 방법

RDB 파일을 그대로 옮길 수 없다면 다른 경로를 찾아야 합니다. 새 환경에서 Stream과 필드 TTL Hash가 모두 있는 데이터(93개 키)를 Redis에 넣고 세 가지를 시험했습니다.

```bash
py seed.py redis --hfe
vcli REPLICAOF redis 6379
docker compose logs --no-log-prefix valkey | grep -E 'RDB format|Failed to load' | head -2
vcli REPLICAOF NO ONE
```

```
redis: DBSIZE 93
OK
7:S 02 Oct 2026 01:22:22.260 # Can't handle RDB format version 15
7:S 02 Oct 2026 01:22:22.260 # Failed to load RDB
OK
```

복제는 처음 동기화할 때 Redis가 RDB를 만들어 보내므로, 파일을 옮길 때와 같은 이유로 실패했습니다. 키를 옮기는 `MIGRATE` 명령도 시험했습니다.

```bash
rcli MIGRATE valkey 6379 "" 0 5000 COPY KEYS str:1
```

```
(error) ERR Target instance replied with error: ERR DUMP payload version or checksum are wrong
```

`MIGRATE`는 공식 문서대로 내부에서 `DUMP`와 `RESTORE`를 쓰기 때문에, Valkey가 `RESTORE` 단계에서 거부했습니다. 그래서 다음은 키마다 `DUMP`로 직렬화한 값을 Valkey에 `RESTORE`하는 `restore.py`입니다.

```python
# Redis에서 DUMP한 값을 Valkey에 RESTORE해 본다
from collections import Counter
import redis
from redis.exceptions import ResponseError

src, dst = redis.Redis(host="redis"), redis.Redis(host="valkey")
dst.flushall()
ok, errors = Counter(), Counter()
for key in src.scan_iter(count=1000):
    kind = key.decode().split(":")[0]
    try:
        dst.restore(key, 0, src.dump(key), replace=True)
        ok[kind] += 1
    except ResponseError as e:
        errors[(kind, str(e))] += 1
print("성공:", dict(sorted(ok.items())))
for (kind, msg), n in sorted(errors.items()):
    print(f"실패: {kind} {n}개 - {msg}")
```

```bash
py restore.py
vcli CONFIG SET rdb-version-check relaxed; py restore.py
```

```
성공: {}
실패: bitmap 10개 - DUMP payload version or checksum are wrong
실패: geo 10개 - DUMP payload version or checksum are wrong
실패: hash 10개 - DUMP payload version or checksum are wrong
실패: hfe 3개 - DUMP payload version or checksum are wrong
실패: hll 10개 - DUMP payload version or checksum are wrong
실패: list 10개 - DUMP payload version or checksum are wrong
실패: set 10개 - DUMP payload version or checksum are wrong
실패: str 10개 - DUMP payload version or checksum are wrong
실패: stream 10개 - DUMP payload version or checksum are wrong
실패: zset 10개 - DUMP payload version or checksum are wrong
OK
성공: {'bitmap': 10, 'geo': 10, 'hash': 10, 'hll': 10, 'list': 10, 'set': 10, 'str': 10, 'zset': 10}
실패: hfe 3개 - Bad data format
실패: stream 10개 - Bad data format
```

`DUMP` 값에도 RDB 버전이 들어 있어서 기본 설정에서는 모두 거부됐고, `relaxed`에서는 파일을 옮길 때처럼 Stream과 필드 TTL Hash만 실패했습니다.

마지막은 자료구조마다 읽기 명령으로 값을 읽어 같은 자료구조의 쓰기 명령으로 다시 쓰는 `migrate.py`입니다. 키 TTL과 Hash 필드 TTL도 남은 시간만큼 다시 겁니다.

```python
# 자료구조별 읽기 명령으로 읽어 같은 자료구조의 쓰기 명령으로 옮긴다
import redis

src = redis.Redis(host="redis")
dst = redis.Redis(host="valkey")
dst.flushall()
copied = 0
for key in src.scan_iter(count=1000):
    t = src.type(key).decode()
    p = dst.pipeline(transaction=True)
    p.delete(key)
    if t == "string":
        p.set(key, src.get(key))
    elif t == "hash":
        h = src.hgetall(key)
        p.hset(key, mapping=h)
        for field, ttl in zip(h, src.hpttl(key, *h)):
            if ttl > 0:
                p.hpexpire(key, ttl, field)
    elif t == "list":
        p.rpush(key, *src.lrange(key, 0, -1))
    elif t == "set":
        p.sadd(key, *src.smembers(key))
    elif t == "zset":
        p.zadd(key, dict(src.zrange(key, 0, -1, withscores=True)))
    elif t == "stream":
        for entry_id, fields in src.xrange(key):
            p.xadd(key, fields, id=entry_id)
    else:
        print("건너뜀:", key, t)
        continue
    ttl = src.pttl(key)
    if ttl > 0:
        p.pexpire(key, ttl)
    p.execute()
    copied += 1
print(f"옮긴 키 {copied}개")
```

```bash
py migrate.py
py check.py redis; py check.py valkey
```

```
옮긴 키 93개
DBSIZE 93, 자료구조 키 93개, 내용 해시 69a049c590fd
hash:3 -> [('age', '23'), ('name', 'user3')]
zset:3 -> [('m0', 0.0), ('m1', 1.5), ('m2', 3.0), ('m3', 4.5), ('m4', 6.0)]
hll:3  -> 99
hfe:0 token TTL -> [3574]
DBSIZE 93, 자료구조 키 93개, 내용 해시 69a049c590fd
hash:3 -> [('age', '23'), ('name', 'user3')]
zset:3 -> [('m0', 0.0), ('m1', 1.5), ('m2', 3.0), ('m3', 4.5), ('m4', 6.0)]
hll:3  -> 99
hfe:0 token TTL -> [3573]
```

93개 키가 모두 옮겨졌고 내용 해시도 같았습니다. 필드 TTL은 옮기는 데 걸린 시간만큼 줄어 있습니다. 이 방법으로 옮길 때 주의할 점은 다음과 같습니다.

- Stream의 소비자 그룹과 처리 대기 목록(PEL)은 옮기지 않습니다. 필요하면 `XINFO GROUPS`로 읽어 따로 만들어야 합니다.
- 키를 하나씩 읽으므로, 옮기는 동안 원본에 쓰기가 계속 들어오면 한 시점의 스냅숏이 되지 않습니다. 쓰기를 멈추거나, 이후 변경분을 따로 맞추는 절차가 필요합니다.
- 원소가 아주 많은 키는 `HSCAN`, `SSCAN`처럼 나눠 읽어야 합니다. 한 번에 읽으면 원본 서버의 다른 요청이 그동안 기다립니다.

## 설정 이름과 INFO 필드

애플리케이션 코드가 같아도 설정 파일과 모니터링은 따로 확인해야 합니다. `settings.py`는 주요 설정값과 설정 이름, `INFO` 필드 이름을 비교합니다.

```python
import redis

r = {n: redis.Redis(host=n, decode_responses=True) for n in ("redis", "valkey")}
MODULE_PREFIX = ("bf-", "cf-", "search-", "ts-", "vset-")

print("# INFO server")
for n, c in r.items():
    info = c.info("server")
    keys = ["redis_version", "server_name", "valkey_version", "redis_mode", "server_mode"]
    print(n, {k: info[k] for k in keys if k in info})

print("\n# CONFIG GET")
want = ["io-threads", "maxmemory", "maxmemory-policy", "save", "appendonly",
        "lazyfree-lazy-user-del", "lazyfree-lazy-expire", "lazyfree-lazy-eviction",
        "slowlog-log-slower-than"]
for k in want:
    a, b = r["redis"].config_get(k).get(k), r["valkey"].config_get(k).get(k)
    mark = "" if a == b else "   <- 다름"
    print(f"{k:24} redis={a!r:12} valkey={b!r}{mark}")

print("\n# CONFIG GET * 이름 비교")
ca, cb = set(r["redis"].config_get("*")), set(r["valkey"].config_get("*"))
mod = {k for k in ca if k.startswith(MODULE_PREFIX)}
print(f"redis {len(ca)}개(모듈 설정 {len(mod)}개 포함), valkey {len(cb)}개, 공통 {len(ca & cb)}개")
print("valkey에만:", " ".join(sorted(cb - ca)))
print("redis에만(모듈 설정 제외):", " ".join(sorted(ca - cb - mod)))

print("\n# INFO 필드 이름 비교 (INFO everything)")
ia, ib = set(r["redis"].info("everything")), set(r["valkey"].info("everything"))
print(f"redis {len(ia)}개, valkey {len(ib)}개, 공통 {len(ia & ib)}개")
```

```bash
py settings.py
```

```
# INFO server
redis {'redis_version': '8.10.2', 'redis_mode': 'standalone'}
valkey {'redis_version': '7.2.4', 'server_name': 'valkey', 'valkey_version': '9.1.2', 'server_mode': 'standalone'}

# CONFIG GET
io-threads               redis='1'          valkey='1'
maxmemory                redis='0'          valkey='0'
maxmemory-policy         redis='noeviction' valkey='noeviction'
save                     redis=''           valkey=''
appendonly               redis='no'         valkey='no'
lazyfree-lazy-user-del   redis='no'         valkey='yes'   <- 다름
lazyfree-lazy-expire     redis='no'         valkey='yes'   <- 다름
lazyfree-lazy-eviction   redis='no'         valkey='yes'   <- 다름
slowlog-log-slower-than  redis='10000'      valkey='10000'

# CONFIG GET * 이름 비교
redis 303개(모듈 설정 76개 포함), valkey 237개, 공통 201개
valkey에만: active-defrag-cycle-us availability-zone cluster-announce-client-ipv4 cluster-announce-client-ipv6 cluster-announce-client-port cluster-announce-client-tls-port cluster-blacklist-ttl cluster-config-save-behavior cluster-databases cluster-manual-failover-timeout cluster-slot-migration-log-max-len commandlog-execution-slower-than commandlog-large-reply-max-len commandlog-large-request-max-len commandlog-reply-larger-than commandlog-request-larger-than commandlog-slow-execution-max-len dual-channel-replication-enabled extended-redis-compatibility hash-seed import-mode log-format log-timestamp-format mptcp prefetch-batch-max-size primaryauth primaryuser rdb-version-check rdma-bind rdma-completion-vector rdma-port rdma-rx-size repl-mptcp slot-migration-max-failover-repl-bytes tls-auto-reload-interval unixsocketgroup
redis에만(모듈 설정 제외): aof-load-corrupt-tail-max-size array-slice-size array-sparse-kmax array-sparse-kmin backup-sealed-ttl backupdirname cluster-bus-port-protected-mode cluster-compatibility-sample-ratio cluster-slot-migration-handoff-max-lag-bytes cluster-slot-migration-write-pause-timeout dynamic-hz hash-max-template-entries hash-min-template-entries hash-rdb-load-max-template-entries hash-rdb-load-min-template-entries hash-rdb-load-template-disassembly-threshold key-memory-histograms lookahead preload-file replica-full-sync-buffer-limit sanitize-dump-payload slowlog-entry-max-argc slowlog-entry-max-string-len stream-idmp-duration stream-idmp-maxsize tls-expected-peer-name

# INFO 필드 이름 비교 (INFO everything)
redis 282개, valkey 255개, 공통 222개
```

이 출력에서 볼 것은 세 가지입니다.

- `io-threads`, `maxmemory`, 영속성 설정은 두 서버가 같았습니다. 뒤의 벤치마크는 이 값으로 잽니다.
- `lazyfree-lazy-user-del`, `lazyfree-lazy-expire`, `lazyfree-lazy-eviction`의 기본값이 다릅니다. Redis 8.10.2는 `no`, Valkey 9.1.2는 `yes`입니다. Valkey에서는 `DEL`이 `UNLINK`처럼 메모리 해제를 백그라운드 스레드로 넘깁니다. 큰 키를 `DEL`로 지울 때의 차이는 [Redis 성능을 다룬 글](/posts/redis-why-fast)에서 쟀습니다. 같은 동작을 원하면 설정 파일에 명시해 두는 편이 안전합니다.
- 설정 이름은 201개가 같고, 나머지는 한쪽에만 있습니다. Valkey는 느린 명령 기록 설정을 `commandlog-execution-slower-than`으로 옮겼지만, `valkey.conf`에 적힌 대로 예전 이름 `slowlog-log-slower-than`도 아직 받습니다. Redis의 `hash-*-template-*`, `backupdirname`처럼 새 기능에 딸린 설정은 Valkey에 없습니다.

상대에게 없는 설정 이름을 주면 서버가 아예 뜨지 않습니다. 명령행 인자로 한 줄씩 넣어 확인했습니다.

```bash
docker run --rm valkey/valkey:9.1.2 valkey-server --backupdirname x 2>&1 | tail -3
docker run --rm redis:8.10.2 redis-server --rdb-version-check relaxed 2>&1 | tail -3
```

```
>>> 'backupdirname "x"'
Bad directive or wrong number of arguments
1:M 02 Oct 2026 00:45:21.432 # >>> 'rdb-version-check relaxed'
1:M 02 Oct 2026 00:45:21.432 # Module Configuration detected without loadmodule directive or no ApplyConfig call: aborting
```

Valkey는 모르는 설정이라며 바로 종료했고, Redis는 모듈 설정인지 확인한 뒤 종료했습니다. 기존 설정 파일은 대상 서버로 먼저 띄워 보고 옮깁니다.

`INFO` 필드 이름은 222개가 같고 Redis에만 60개, Valkey에만 33개가 있었습니다. `cmdstat_*`처럼 실행한 명령에 따라 늘어나는 필드도 있어서 숫자는 서버 상태마다 조금씩 다릅니다. 대시보드나 알림이 읽는 필드가 대상 서버에도 있는지 확인합니다.

## 같은 조건에서 redis-benchmark

마지막으로 처리량과 지연을 잽니다. 여기 나오는 숫자는 이 노트북에서 아래 조건으로 잰 참고치이고, 두 서버의 우열을 가리는 데 쓸 수 없습니다. 이벤트 루프, `io-threads`, 파이프라이닝이 숫자를 어떻게 바꾸는지는 [Redis 성능을 다룬 글](/posts/redis-why-fast)에서 따로 쟀습니다.

측정 조건은 다음과 같습니다.

- 서버: `redis:8.10.2`(`redis_version:8.10.2`), `valkey/valkey:9.1.2`(`valkey_version:9.1.2`)
- 호스트: Apple M3 노트북, Docker Desktop 4.38.0, Docker VM에 CPU 8개와 메모리 9.7GiB 할당
- 컨테이너 제한: 두 서버 모두 `cpus: 2`, `mem_limit: 1g`. 벤치마크 클라이언트 컨테이너는 제한 없음
- 네트워크: 클라이언트와 서버가 같은 Docker 브리지 네트워크(`valk_default`)
- 서버 설정: `io-threads 1`(두 서버 기본값), `--save "" --appendonly no`, `maxmemory 0`
- 클라이언트: 두 서버 모두 `redis:8.10.2` 이미지의 `redis-benchmark` 하나로 측정
- 명령: `redis-benchmark -h <서버> -t set,get,incr,lpush,hset -n 1000000 -r 100000 -d 64 -c 50 -P <1 또는 16> --csv`
- 반복: 파이프라인 깊이마다 서버별 5회. 순서는 redis, valkey, valkey, redis, redis, valkey처럼 번갈아 돌리고, 매 회차 전에 `FLUSHALL`
- 함께 돈 작업: 같은 Docker VM에서 다른 실습 컨테이너 여러 개가 동시에 돌고 있었습니다. 30초마다 `docker stats`로 확인했을 때 그중 하나가 측정 내내 CPU를 약 280~1000% 쓰고 있었습니다. 회차 사이의 흔들림에는 이 영향이 섞여 있습니다.
- 측정하지 않은 것: 클러스터 모드, 복제, TLS, 큰 값, `io-threads`를 켠 경우, 실제 서비스 트래픽

벤치마크를 돌리는 `bench.sh`입니다.

```bash
#!/usr/bin/env bash
# 같은 redis-benchmark로 두 서버를 번갈아 5회씩 잰다. 결과는 out/bench.csv
set -u
cd "$(dirname "$0")"
export COMPOSE_PROJECT_NAME=valk
bench() { docker run --rm --network valk_default redis:8.10.2 redis-benchmark "$@"; }
flush() { docker compose exec -T redis redis-cli -h "$1" FLUSHALL > /dev/null; }
mkdir -p out
echo "pipeline,round,server,test,rps,avg_ms,min_ms,p50_ms,p95_ms,p99_ms,max_ms" > out/bench.csv
for P in 1 16; do
  for i in 1 2 3 4 5; do
    if [ $((i % 2)) = 1 ]; then order="redis valkey"; else order="valkey redis"; fi
    for s in $order; do
      flush "$s"
      bench -h "$s" -t set,get,incr,lpush,hset -n 1000000 -r 100000 -d 64 -c 50 -P "$P" --csv \
        | grep -v '^"test"' | tr -d '"' | sed "s/^/$P,$i,$s,/" >> out/bench.csv
      echo "P=$P round=$i $s done $(date +%T)"
    done
  done
done
# 교차 확인: valkey-benchmark로 각 서버 1회씩
for P in 1 16; do
  for s in redis valkey; do
    docker compose exec -T redis redis-cli -h "$s" FLUSHALL > /dev/null
    docker run --rm --network valk_default valkey/valkey:9.1.2 valkey-benchmark -h "$s" \
      -t set,get,incr,lpush,hset -n 1000000 -r 100000 -d 64 -c 50 -P "$P" --csv \
      | grep -v '^"test"' | tr -d '"' | sed "s/^/$P,vb,$s,/" >> out/bench.csv
    echo "valkey-benchmark P=$P $s done $(date +%T)"
  done
done
```

```bash
./bench.sh
```

회차별 원본은 `out/bench.csv`에 남기고, `summarize.py`로 조건·명령·서버별 최솟값과 최댓값만 모았습니다. rps는 초당 요청 수(천 단위), p50과 p99는 ms입니다.

```python
# out/bench.csv를 조건·명령·서버별 최소~최대 범위로 요약한다
import csv, sys
from collections import defaultdict

rows = [r for r in csv.DictReader(open(sys.argv[1] if len(sys.argv) > 1 else "out/bench.csv")) if r["round"] != "vb"]
g = defaultdict(list)
for r in rows:
    g[(r["pipeline"], r["test"], r["server"])].append(r)
for P in ("1", "16"):
    print(f"# -c 50 -P {P}, 회차 {len(g[(P, 'SET', 'redis')])}번")
    for t in ("SET", "GET", "INCR", "LPUSH", "HSET"):
        parts = []
        for s in ("redis", "valkey"):
            rs = g[(P, t, s)]
            if not rs:
                continue
            rps = [float(r["rps"]) for r in rs]
            p50 = [float(r["p50_ms"]) for r in rs]
            p99 = [float(r["p99_ms"]) for r in rs]
            parts.append(f"{s} {min(rps)/1000:.0f}~{max(rps)/1000:.0f}k rps, p50 {min(p50):.3f}~{max(p50):.3f}, p99 {min(p99):.3f}~{max(p99):.3f}")
        if len(parts) == 2:
            a, b = g[(P, t, "redis")], g[(P, t, "valkey")]
            ra = (min(float(r["rps"]) for r in a), max(float(r["rps"]) for r in a))
            rb = (min(float(r["rps"]) for r in b), max(float(r["rps"]) for r in b))
            overlap = "겹침" if ra[0] <= rb[1] and rb[0] <= ra[1] else "안 겹침"
        else:
            overlap = ""
        print(f"{t:5} | " + " | ".join(parts) + f" | rps 범위 {overlap}")
```

```bash
py summarize.py
```

```
# -c 50 -P 1, 회차 5번
SET   | redis 12~63k rps, p50 0.471~1.015, p99 2.335~34.911 | valkey 17~57k rps, p50 0.447~0.839, p99 3.855~21.391 | rps 범위 겹침
GET   | redis 16~55k rps, p50 0.487~1.047, p99 3.471~25.071 | valkey 22~53k rps, p50 0.455~0.711, p99 4.535~15.383 | rps 범위 겹침
INCR  | redis 18~68k rps, p50 0.415~0.831, p99 2.279~20.623 | valkey 24~68k rps, p50 0.399~0.711, p99 2.495~11.487 | rps 범위 겹침
LPUSH | redis 19~47k rps, p50 0.503~0.927, p99 5.087~15.063 | valkey 20~61k rps, p50 0.439~0.807, p99 2.911~17.679 | rps 범위 겹침
HSET  | redis 22~27k rps, p50 0.607~0.815, p99 11.103~18.063 | valkey 21~30k rps, p50 0.591~0.879, p99 10.119~14.351 | rps 범위 겹침
# -c 50 -P 16, 회차 5번
SET   | redis 85~894k rps, p50 0.631~3.879, p99 2.399~57.343 | valkey 191~1042k rps, p50 0.623~2.351, p99 1.815~22.719 | rps 범위 겹침
GET   | redis 100~990k rps, p50 0.591~3.615, p99 2.415~54.047 | valkey 167~1208k rps, p50 0.519~1.983, p99 1.775~35.391 | rps 범위 겹침
INCR  | redis 99~1088k rps, p50 0.511~2.695, p99 2.551~74.623 | valkey 277~1372k rps, p50 0.487~1.431, p99 1.399~15.455 | rps 범위 겹침
LPUSH | redis 101~762k rps, p50 0.775~2.951, p99 3.351~81.151 | valkey 169~907k rps, p50 0.727~2.511, p99 2.127~28.671 | rps 범위 겹침
HSET  | redis 133~868k rps, p50 0.751~3.367, p99 2.183~46.815 | valkey 93~715k rps, p50 0.879~4.039, p99 2.975~54.367 | rps 범위 겹침
```

모든 조건, 모든 명령에서 두 서버의 rps 범위가 겹쳤습니다. 같은 서버, 같은 명령인데도 회차에 따라 몇 배씩 차이가 났습니다. 예를 들어 `-P 16`의 SET은 Redis가 85k~894k, Valkey가 191k~1042k rps였습니다. 두 서버 사이의 차이보다 회차 사이의 차이가 훨씬 컸습니다. 이 환경에서는 두 서버의 처리량 차이를 구분할 수 없었습니다. 측정 직후 `valkey-benchmark`로 각 조건을 서버마다 한 번씩 더 쟀을 때도 SET은 `-P 1`에서 Redis 103k, Valkey 110k rps, `-P 16`에서 Redis 870k, Valkey 1083k rps였고, 다른 명령에서는 순서가 뒤바뀌기도 했습니다. 이 값들도 앞의 범위 안팎에서 움직였습니다.

첫 회차만 봤다면 `-P 1`의 SET에서 Redis 63k, Valkey 17k rps라는 숫자로 결론을 냈을 것입니다. 처리량을 비교하려면 다른 작업이 없는 장비에서 서버 순서를 번갈아 가며 여러 번 재고 범위를 봐야 합니다.

## 전환 전에 확인할 것

1. 서비스가 실제로 보내는 명령 목록을 모읍니다. 애플리케이션 코드와 함께 `INFO commandstats`에 나오는 명령 이름을 보면 빠뜨린 것을 찾기 쉽습니다. 이 목록을 대상 버전에 보내 응답을 비교합니다.
2. 모듈 명령(`JSON.*`, `FT.*`, `TS.*`, `BF.*`)과 한쪽에만 있는 새 명령(`DELEX`, `DELIFEQ` 같은 것)을 쓰는지 확인합니다.
3. `redis_version`, `HELLO`의 `server` 값으로 분기하는 클라이언트나 도구가 있는지 봅니다.
4. 지금 Redis 버전으로 만든 RDB 파일을 대상 Valkey 버전에 실제로 올려 봅니다. 실패하면 로그의 RDB 버전과 타입 번호로 원인을 찾고, 복제나 `DUMP`/`RESTORE`, 키 단위 복사 가운데 되는 방법을 정합니다.
5. 설정 파일을 대상 서버로 띄워 모르는 설정 이름이 없는지 보고, `lazyfree-*`처럼 기본값이 다른 설정은 명시합니다.
6. 대시보드와 알림이 읽는 `INFO` 필드가 대상 서버에도 있는지 확인합니다.
7. 성능은 자기 서비스와 비슷한 명령, 값 크기, 연결 수로 같은 장비에서 번갈아 여러 번 잽니다. 버전, 설정, CPU 제한, 클라이언트 위치, 반복 횟수를 결과와 함께 적습니다.

## 정리

- Valkey는 Redis 7.2.4에서 갈라져 나왔습니다. 이 글의 범위(명령 79개)에서는 같은 Python 클라이언트로 77개가 같은 응답을 받았고, 다른 두 개는 내부 인코딩 이름과 오류 문자열이었습니다.
- 갈라진 뒤 각자 추가한 명령이 있습니다. 모듈을 빼고 Redis 8.10.2에만 51개, Valkey 9.1.2에만 14개였고, Redis 8.10.2 이미지에는 모듈 명령 233개가 더 있습니다.
- Redis 8.10.2의 RDB 파일(버전 15)은 Valkey 9.1.2 기본 설정에서 읽히지 않았습니다. `relaxed`로 바꾸면 Stream과 Hash 필드 TTL이 없는 파일만 읽혔고, 반대 방향은 읽히지 않았습니다. 옮기기 전에 실제 파일로 시험합니다.
- `lazyfree-*` 기본값처럼 이름은 같아도 기본값이 다른 설정이 있고, 한쪽에만 있는 설정 이름은 서버 기동을 막습니다.
- 벤치마크 숫자는 버전, 설정, CPU 제한, 클라이언트 위치, 반복 횟수와 함께 읽어야 합니다. 이 글의 결과는 다른 작업이 함께 돌던 노트북 한 대의 참고치입니다.

===== semantic-cache =====
LLM 앞에 질문 캐시를 붙이면 같은 질문이 다시 들어왔을 때 모델을 부르지 않고 저장해 둔 답을 바로 돌려줄 수 있습니다. 그런데 사용자는 같은 질문을 같은 글자로 묻지 않습니다. 그래서 질문을 임베딩해 "충분히 비슷하면 같은 질문"으로 보는 시맨틱 캐시를 씁니다. 문제는 이 "충분히 비슷하면"이 어디까지냐는 것입니다.

이 글의 실습에서 나온 장면 하나를 먼저 보겠습니다. 캐시에는 "NGINX 설정에 keepalive를 쓰지 않았는데도 upstream 연결이 재사용되나요?"라는 질문과 그 답이 들어 있습니다. 여기에 다음 질문이 들어옵니다.

```
질문: NGINX 설정에 keepalive를 썼는데도 upstream 연결이 재사용되지 않는 이유는?
가장 가까운 캐시 질문: NGINX 설정에 keepalive를 쓰지 않았는데도 upstream 연결이 재사용되나요?
코사인 유사도: 0.994
돌려준 답: 설정 파일에 `keepalive`가 한 줄도 없는데 연결을 재사용한 것입니다.
```

질문은 정반대인데 유사도는 0.994입니다. 이 실습의 같은 뜻 질문 60개 가운데 이보다 높은 것은 하나도 없었습니다. 이 글에서는 같은 뜻 질문과 말만 비슷한 다른 뜻 질문을 직접 만들어 기준값마다 적중과 잘못된 적중을 세고, Redis와 Qdrant로 같은 조회를 구현하고, 임베딩 모델을 바꾸면 기준값이 어떻게 움직이는지 잽니다. 숫자는 모두 이 실습에서 나온 것입니다.

## 시맨틱 캐시가 하는 일

시맨틱 캐시가 하는 일은 두 가지입니다.

- 조회: 새 질문을 벡터로 바꾸고, 캐시에 저장된 질문 벡터 가운데 가장 가까운 하나를 찾습니다. 그 유사도가 기준값 이상이면 그 질문에 저장된 답을 돌려줍니다(적중). 아니면 LLM을 부릅니다(미스).
- 저장: 미스였다면 LLM이 만든 답을 질문 벡터와 함께 저장해 다음 질문에 씁니다.

<figure>
<svg viewBox="0 -20 480 714" role="img" aria-label="질문을 임베딩해 가장 가까운 이전 질문 하나를 찾고, 유사도가 기준값 이상이면 저장된 답을 돌려주고, 아니면 LLM을 호출한 뒤 답과 질문 벡터를 저장한다" className="diagram h-auto w-full">
  <rect x="110" y="0" width="259" height="58" rx="10" className="fill-card stroke-border" strokeWidth="2" />
  <text x="240" y="36" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">새 질문</text>
  <rect x="110" y="98" width="259" height="82" rx="10" className="fill-card stroke-border" strokeWidth="2" />
  <text x="240" y="133" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">임베딩</text>
  <text x="240" y="159" textAnchor="middle" fontSize="17" className="fill-muted-foreground">질문 → 벡터</text>
  <rect x="110" y="220" width="259" height="82" rx="10" className="fill-card stroke-border" strokeWidth="2" />
  <text x="240" y="255" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">가장 가까운 이전 질문 1개</text>
  <text x="240" y="281" textAnchor="middle" fontSize="17" className="fill-muted-foreground">벡터 검색, 유사도 계산</text>
  <rect x="110" y="342" width="259" height="58" rx="10" className="fill-accent stroke-primary" strokeWidth="2" />
  <text x="240" y="378" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-accent-foreground">유사도 ≥ 기준값?</text>
  <line x1="240" y1="64" x2="240" y2="81" className="stroke-muted-foreground" strokeWidth="2" />
  <polygon points="240,92 234,81 246,81" className="fill-muted-foreground" />
  <line x1="240" y1="186" x2="240" y2="203" className="stroke-muted-foreground" strokeWidth="2" />
  <polygon points="240,214 234,203 246,203" className="fill-muted-foreground" />
  <line x1="240" y1="308" x2="240" y2="325" className="stroke-muted-foreground" strokeWidth="2" />
  <polygon points="240,336 234,325 246,325" className="fill-muted-foreground" />
  <rect x="30" y="470" width="196" height="82" rx="10" className="fill-card stroke-border" strokeWidth="2" />
  <text x="128" y="505" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">저장된 답 반환</text>
  <text x="128" y="531" textAnchor="middle" fontSize="17" className="fill-muted-foreground">적중</text>
  <rect x="254" y="470" width="196" height="82" rx="10" className="fill-muted stroke-border" strokeWidth="2" />
  <text x="352" y="505" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">LLM 호출</text>
  <text x="352" y="531" textAnchor="middle" fontSize="17" className="fill-muted-foreground">미스</text>
  <line x1="240" y1="406" x2="138" y2="459" className="stroke-primary" strokeWidth="2" />
  <polygon points="128,464 135,454 141,464" className="fill-primary" />
  <text x="94" y="444" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-primary">예</text>
  <line x1="240" y1="406" x2="342" y2="459" className="stroke-muted-foreground" strokeWidth="2" />
  <polygon points="352,464 339,464 345,454" className="fill-muted-foreground" />
  <text x="386" y="444" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-muted-foreground">아니오</text>
  <rect x="250" y="592" width="204" height="82" rx="10" className="fill-muted stroke-border" strokeWidth="2" />
  <text x="352" y="627" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">답과 질문 벡터 저장</text>
  <text x="352" y="653" textAnchor="middle" fontSize="17" className="fill-muted-foreground">다음 질문을 위해</text>
  <line x1="352" y1="558" x2="352" y2="575" className="stroke-muted-foreground" strokeWidth="2" />
  <polygon points="352,586 346,575 358,575" className="fill-muted-foreground" />
</svg>
<figcaption>조회는 가장 가까운 질문 하나와의 유사도를 기준값과 비교하는 것이 전부입니다.</figcaption>
</figure>

규칙은 "가장 가까운 질문과의 유사도가 기준값 이상인가" 하나뿐입니다. 기준값을 낮추면 적중이 늘어 LLM 호출이 줄지만, 다른 질문에 다른 질문의 답이 나가는 경우도 함께 늘어납니다. 이 글에서는 이것을 잘못된 적중이라고 부릅니다. 기준값을 고르려면 적중과 잘못된 적중을 함께 재야 합니다.

일반 캐시처럼 시맨틱 캐시에도 TTL을 둡니다. 다만 원문이 바뀌었을 때 어느 항목을 지울지는 일반 캐시보다 정하기 어렵습니다. [Redis Cache-Aside를 다룬 글](/posts/redis-cache-aside)에서는 키가 정해져 있어 쓰기 뒤에 그 키를 지우면 됐지만, 시맨틱 캐시의 키는 질문 벡터라서 원문 한 문단이 바뀌었을 때 그 문단을 근거로 한 답이 어느 질문 아래에 저장돼 있는지 키만 보고는 알 수 없습니다. 이 글에서는 조회 기준값만 다룹니다.

## 실습 준비

Redis, Qdrant, Python 클라이언트 컨테이너를 띄웁니다. Redis는 `redis:8.10.2` 이미지를 씁니다. Redis 8부터는 공식 이미지에 검색과 벡터 인덱스 기능(Redis Query Engine)이 들어 있어 따로 모듈을 붙이지 않아도 `FT.CREATE`로 벡터 인덱스를 만들 수 있습니다.

```yaml
name: scache
services:
  redis:
    image: redis:8.10.2
    ports:
      - "7492:6379"
    mem_limit: 384m
    cpus: 0.5
  qdrant:
    image: qdrant/qdrant:v1.19.1
    ports:
      - "7490:6333"
      - "7491:6334"
    mem_limit: 512m
    cpus: 0.5
  client:
    image: python:3.12-slim
    working_dir: /lab
    command: sleep infinity
    environment:
      HF_HOME: /hf
      OMP_NUM_THREADS: "2"
      REDIS_URL: redis://redis:6379
      QDRANT_URL: http://qdrant:6333
    volumes:
      - .:/lab
      - hf:/hf
    mem_limit: 3072m
    cpus: 2
    depends_on:
      - redis
      - qdrant
volumes:
  hf:
```

자원은 세 컨테이너를 합쳐 메모리 4GB 안쪽, CPU 3개로 잡았습니다.

- 메모리: 클라이언트 3GB, Qdrant 512MB, Redis 384MB입니다. 뒤에서 쓰는 1024차원 모델 두 개는 가중치만 2GB가 넘어서 클라이언트에 대부분을 줬습니다. 캐시에 넣는 질문은 30개뿐이라 Redis와 Qdrant는 작게 둬도 됩니다.
- CPU: 클라이언트 2개, Redis와 Qdrant는 0.5개씩입니다. 뒤의 조회 지연은 이 제한 아래에서 잰 값입니다.
- 스레드: `OMP_NUM_THREADS`로 PyTorch 스레드를 2개로 고정했습니다.

패키지 버전은 `requirements.txt`에 고정했습니다. 임베딩 관련 버전은 [임베딩 모델을 비교한 글](/posts/embedding-model-compare)과 같고, Redis 클라이언트 `redis`와 Redis 공식 벡터 라이브러리 `redisvl`을 더했습니다.

```
--extra-index-url https://download.pytorch.org/whl/cpu
torch==2.14.1+cpu
sentence-transformers==6.1.0
transformers==5.18.0
qdrant-client==1.19.1
numpy==2.5.3
redis==8.1.0
redisvl==0.27.2
```

실습 코드는 `sc.py` 하나에 모았고, `python sc.py <명령>` 형태로 실행합니다. 명령을 짧게 쓰려고 셸 함수 두 개를 만듭니다.

```bash
dc() { docker compose -p scache "$@"; }
py() { dc exec -T client python sc.py "$@"; }
dc up -d
dc exec -T client pip install --no-cache-dir -q -r requirements.txt
py info
```

```
redis_version 8.10.2
module vectorset 1
module search 81001
module timeseries 81001
module bf 81001
module ReJSON 81000
FT._LIST []
```

`info`는 Redis의 `INFO server`, `MODULE LIST`, `FT._LIST`를 출력합니다. 8.10.2에 `search` 모듈이 올라와 있고, 아직 만든 인덱스는 없습니다.

## 질문 쌍 만들기

캐시에 미리 넣어 둘 질문(이하 캐시 질문)은 [청크 크기를 비교한 글](/posts/rag-chunk-size)의 평가셋 질문 30개입니다. 질문마다 정답 문장(`gold_passage`)이 붙어 있어서, 이 글에서는 LLM이 만든 답 대신 이 문장을 캐시에 저장할 답으로 씁니다. 이 글은 어느 질문의 답이 나갔는지만 세므로 답 문장이 무엇이든 결과는 같습니다.

여기에 들어올 질문 158개(`pairs.jsonl`)를 직접 썼습니다. 종류와 작성 규칙은 이렇습니다.

- 같은 뜻 60개: 캐시 질문마다 2개씩 썼습니다. 하나는 어순, 존댓말, 동의어를 바꾼 것이고, 다른 하나는 "producer retry로 같은 batch가 두 번 오면"처럼 영어 단어를 섞은 것입니다. 정답은 원래 캐시 질문입니다.
- 다른 뜻 58개: 캐시 질문마다 2개씩, 말은 거의 같지만 캐시에 있는 답을 보여 주면 틀린 답이 되는 질문입니다. 네 유형을 고르게 섞었습니다.
  - 반대 방향(14개): 켜기와 끄기, 먼저와 나중, 커지는 이유와 줄이는 방법. 예: "Kafka로 이벤트를 먼저 보낸 다음 주문을 DB에 커밋하면 어떤 문제가 생기나요?"
  - 숫자나 이름만 다름(16개): 오류 코드, 설정 이름, 설정 값. 예: "binlog_format을 STATEMENT로 두면 Binlog에 무엇이 기록되나요?"
  - 대상만 다름(14개): Pub/Sub와 Streams, CPU와 메모리, 메타데이터 락과 행 잠금. 예: "Redis Streams로 채팅 서버를 연결했을 때 구독이 잠깐 끊긴 동안 보낸 메시지는 어떻게 되나요?"
  - 묻는 범위가 다름(14개): "왜 생기나요"와 "어떻게 고치나요"처럼 같은 주제의 다른 질문. 예: "Kafka lag 숫자가 계속 커지면 어떻게 줄여야 하나요?"
- 관계없는 질문 30개: CSS, iOS, Git 같은 이 사이트 밖의 개발 질문 15개와 "김치찌개를 맛있게 끓이는 순서는?" 같은 일상 질문 15개입니다.
- 짧은 후속 질문 10개: "그럼 기본값은요?", "그거 끄면 어떻게 돼요?"처럼 앞 대화 없이는 무엇을 묻는지 정해지지 않는 질문입니다.

정답은 이렇게 정했습니다.

- 다른 뜻 질문은 원칙적으로 "미스여야 함"입니다. 다만 다른 캐시 질문의 답이 맞는 경우는 그 질문을 정답으로 적었습니다. 예를 들어 "프로듀서가 같은 결제 메시지를 두 번 보내도 브로커에 한 번만 저장되게 하려면?"은 결제 캐시 질문에서 만들었지만, 답은 멱등 프로듀서를 묻는 다른 캐시 질문의 답입니다. 이런 질문이 3개입니다.
- 관계없는 질문과 후속 질문은 모두 "미스여야 함"입니다.
- 실행 전에 다른 뜻 질문 60개를 "이 질문에 캐시된 답을 보여 주면 틀린 답인가"로 한 번 더 읽고, 답이 일부 맞는 2개를 뺐습니다. 그래서 다른 뜻 질문은 58개입니다.
- 쓰는 단계에서 아예 뺀 후보도 있습니다. "Valkey에서 maxmemory-samples 값은 무엇을 정하나요?"는 대상만 바꾼 질문이지만 Valkey는 Redis에서 갈라져 나온 서버라 답이 같습니다. 대상이 달라도 답이 같으면 다른 뜻 질문이 아닙니다.

LLM을 쓰지 않으므로 "답이 틀렸다"는 판단은 이 라벨로만 합니다. 또 결과는 질문을 어떻게 썼는지에 크게 좌우됩니다. 다른 뜻 질문을 일부러 어렵게 쓰면 잘못된 적중이 늘고, 쉽게 쓰면 줄어듭니다. 그래서 질문 파일을 실행 전에 확정하고 해시를 남겼고, 결과를 본 뒤에는 고치지 않았습니다.

```bash
py prep
```

```
seeds.jsonl 704ff492dc82cb17
pairs.jsonl 148d44a3c7a455e0
seed 30 개  probe 158 개
  diff       number      16
  diff       opposite    14
  diff       scope       14
  diff       target      14
  followup   followup    10
  same       codeswitch  30
  same       rephrase    30
  unrelated  daily       15
  unrelated  dev         15
다른 뜻인데 다른 seed의 답이 맞는 질문 3 개: [(103, 27), (106, 26), (113, 30)]
```

`prep`은 두 파일의 해시와 종류별 개수를 출력합니다. 마지막 줄의 세 질문이 다른 캐시 질문을 정답으로 둔 질문입니다. 실습 출력에서 seed는 캐시 질문, probe는 들어온 질문을 가리킵니다.

## 유사도 분포 보기

먼저 기본 모델 `intfloat/multilingual-e5-small`로 봅니다. 모델은 처음 불러올 때 Hugging Face에서 `hf` 볼륨으로 받습니다. 질문끼리 비교하므로 캐시 질문과 들어온 질문 양쪽 모두 `query: ` 접두어를 붙였습니다. [임베딩 거리를 다룬 글](/posts/embedding-similarity)에서 질문끼리 비교할 때 쓴 방식과 같습니다.

```python
MODELS = {
    "e5s": {"name": "intfloat/multilingual-e5-small", "prefix": "query: "},
    "e5l": {"name": "intfloat/multilingual-e5-large", "prefix": "query: "},
    "m3": {"name": "BAAI/bge-m3", "prefix": ""},
}


def nearest(key):
    sv, pv = vecs(key)  # 정규화된 캐시 질문 벡터, 들어온 질문 벡터
    sims = pv @ sv.T
    idx = sims.argmax(axis=1)
    return [SEED_IDS[i] for i in idx], sims.max(axis=1), sims
```

`embed`는 질문 188개(캐시 질문 30개, 들어온 질문 158개)를 임베딩해 파일로 저장하고, 질문을 하나씩 임베딩하는 시간도 잽니다. `dist`는 들어온 질문마다 가장 가까운 캐시 질문과의 코사인 유사도를 구해 종류별 분포를 출력합니다. 벡터가 정규화돼 있으니 내적이 곧 코사인입니다. 저장소 없이 numpy로 전부 비교한 값이고, 뒤에서 Redis와 Qdrant 결과를 이 값과 맞춰 봅니다.

```bash
py embed e5s
py dist e5s
```

```
e5s intfloat/multilingual-e5-small  384차원  질문 188개 일괄 3.5초  하나씩 중앙값 22.7ms p99 42.9ms
[e5s] 가장 가까운 seed와의 코사인 유사도
  same             60개  최소 0.879  10% 0.920  중앙값 0.958  90% 0.988  최대 0.993
  diff             58개  최소 0.840  10% 0.899  중앙값 0.943  90% 0.979  최대 0.994
  diff/opposite    14개  최소 0.931  10% 0.934  중앙값 0.965  90% 0.990  최대 0.994
  diff/number      16개  최소 0.861  10% 0.896  중앙값 0.938  90% 0.958  최대 0.970
  diff/target      14개  최소 0.840  10% 0.889  중앙값 0.932  90% 0.965  최대 0.980
  diff/scope       14개  최소 0.924  10% 0.939  중앙값 0.951  90% 0.969  최대 0.987
  unrelated        30개  최소 0.793  10% 0.797  중앙값 0.820  90% 0.845  최대 0.867
  followup         10개  최소 0.816  10% 0.817  중앙값 0.832  90% 0.849  최대 0.862
  same 중 가장 가까운 seed가 원래 질문인 것 59/60
    0.879 producer retry로 같은 batch가 두 번 오면 broker가 duplicate를 어떻게 막나요? -> seed 4 (원래 seed 27, 유사도 0.867)
  same 중앙값(0.958) 이상인 diff 17개
    0.994 [opposite] NGINX 설정에 keepalive를 썼는데도 upstream 연결이 재사용되지 않는 이유는?  -> seed 13 NGINX 설정에 keepalive를 쓰지 않았는데도 upstream 연결이 재사용되나요?
    0.992 [opposite] MySQL에서 잠금 대기 시간 초과 오류가 나면 트랜잭션 전체를 취소하게 하려면 어떻게 하나요?  -> seed 30 MySQL에서 잠금 대기 시간 초과 오류가 나면 트랜잭션 전체가 취소되나요?
    0.987 [scope] Redis에서 오래 걸리는 명령이 있어도 다른 클라이언트 요청이 느려지지 않게 하려면?  -> seed 2 Redis에서 오래 걸리는 명령 하나가 다른 클라이언트 요청까지 느리게 만드는 이유는?
    0.987 [opposite] Kafka로 이벤트를 먼저 보낸 다음 주문을 DB에 커밋하면 어떤 문제가 생기나요?  -> seed 10 주문을 DB에 커밋한 다음 Kafka로 이벤트를 보내면 어떤 문제가 생기나요?
    0.981 [opposite] slow query log에서 Rows_sent가 Rows_examined보다 크면 어떻게 된 건가요?  -> seed 20 slow query log에서 Rows_examined가 Rows_sent보다 훨씬 크면 무엇을 의심해야 하나요?
    0.980 [target] Redis Streams로 채팅 서버를 연결했을 때 구독이 잠깐 끊긴 동안 보낸 메시지는 어떻게 되나요?  -> seed 11 Redis Pub/Sub로 채팅 서버를 연결했을 때 구독이 잠깐 끊긴 동안 보낸 메시지는 어떻게 되나요?
    0.978 [opposite] 컨테이너에 CPU limit을 걸지 않으면 CPU 시간을 어떻게 나눠 쓰나요?  -> seed 6 컨테이너에 CPU limit을 걸면 CPU 시간을 어떤 단위로 나눠서 제한하나요?
    0.974 [opposite] 여러 서비스에 걸친 주문 처리에서 중간 단계가 실패하면 그 단계부터 다시 시도하려면 어떻게 하나요?  -> seed 12 여러 서비스에 걸친 주문 처리에서 중간 단계가 실패하면 이미 끝난 단계는 어떻게 되돌리나요?
```

종류별로 읽으면 이렇습니다.

- 관계없는 질문: 0.793~0.867입니다. 앞의 글에서 이 모델은 관계없는 문장끼리도 0.75~0.84가 나왔는데, 여기서도 "김치찌개를 맛있게 끓이는 순서는?" 같은 질문이 기술 질문과 0.8을 넘깁니다. 같은 뜻 질문의 최솟값(0.879)보다는 낮습니다.
- 같은 뜻 질문: 중앙값 0.958, 10% 백분위 0.920입니다. 60개 중 59개는 가장 가까운 캐시 질문이 원래 질문이었습니다. 영어를 섞은 "producer retry로..." 하나는 원래 질문(0.867)보다 PostgreSQL VACUUM 질문(0.879)에 더 가까웠습니다.
- 다른 뜻 질문: 중앙값 0.943으로 같은 뜻 질문과 0.015밖에 차이가 나지 않습니다. 최댓값 0.994는 같은 뜻 질문의 최댓값 0.993보다 높습니다. 58개 중 17개가 같은 뜻 질문의 중앙값보다 높습니다.
- 유형별: 반대 방향 유형이 중앙값 0.965로 가장 높고 최솟값도 0.931입니다. 단어 한두 개만 바꾸거나 순서만 바꾼 질문이라 문장 대부분이 겹칩니다. 숫자나 이름만 다른 유형(중앙값 0.938)과 대상만 다른 유형(0.932)이 그다음입니다.

## 기준값별 적중과 잘못된 적중

이제 기준값을 정해 분류합니다. 들어온 질문 하나는 기준값 t에서 네 가지 중 하나가 됩니다.

- 올바른 적중: 가장 가까운 캐시 질문이 정답이고 유사도가 t 이상
- 잘못된 적중: 유사도가 t 이상인데, 정답이 "미스여야 함"이거나 다른 캐시 질문
- 놓친 적중: 정답 캐시 질문이 있는데 적중하지 못함
- 올바른 미스: 정답이 "미스여야 함"이고 유사도가 t 미만

```python
def classify(near, sim, t):
    """기준값 t에서 probe별 결과: ok(올바른 적중) wrong(잘못된 적중) miss(놓친 적중) skip(올바른 미스)"""
    res = []
    for i, p in enumerate(PROBES):
        hit = sim[i] >= t
        e = p["expect"]
        if hit and near[i] == e:
            res.append("ok")
        elif hit:
            res.append("wrong")
        elif e is not None:
            res.append("miss")
        else:
            res.append("skip")
    return res
```

`sweep`은 0.80부터 0.99까지 0.01 간격으로 분류해 전체를 `out/sweep-e5s.csv`로 남기고, 화면에는 고른 기준값만 출력합니다. 적중률은 같은 뜻 질문 60개 가운데 올바르게 적중한 비율입니다.

```bash
py sweep e5s 0.80 0.99 0.85,0.88,0.90,0.92,0.95,0.98
```

```
[e5s] 기준값별 결과 (적중률은 같은 뜻 60개 기준)
  0.85  적중 59/60 (98.3%)  잘못된 적중  62 (같은 뜻이 다른 seed로 1, 다른 뜻 57, 관계없음 3, 후속 1)
  0.88  적중 59/60 (98.3%)  잘못된 적중  56 (같은 뜻이 다른 seed로 0, 다른 뜻 56, 관계없음 0, 후속 0)
  0.90  적중 58/60 (96.7%)  잘못된 적중  51 (같은 뜻이 다른 seed로 0, 다른 뜻 51, 관계없음 0, 후속 0)
  0.92  적중 53/60 (88.3%)  잘못된 적중  47 (같은 뜻이 다른 seed로 0, 다른 뜻 47, 관계없음 0, 후속 0)
  0.95  적중 41/60 (68.3%)  잘못된 적중  26 (같은 뜻이 다른 seed로 0, 다른 뜻 26, 관계없음 0, 후속 0)
  0.98  적중 15/60 (25.0%)  잘못된 적중   5 (같은 뜻이 다른 seed로 0, 다른 뜻 5, 관계없음 0, 후속 0)
  훑은 범위에서 잘못된 적중이 0인 기준값 없음
  잘못 붙는 probe의 최고 유사도 0.9944, 이보다 높은 같은 뜻 적중 0/60
  diff/opposite 최고 0.994  NGINX 설정에 keepalive를 썼는데도 upstream 연결이 재사용되지 않는 이유는?  -> seed 13
  diff/number   최고 0.970  binlog_format을 STATEMENT로 두면 Binlog에 무엇이 기록되나요?  -> seed 24
  diff/target   최고 0.980  Redis Streams로 채팅 서버를 연결했을 때 구독이 잠깐 끊긴 동안 보낸 메시지는 어떻게 되나요?  -> seed 11
  diff/scope    최고 0.987  Redis에서 오래 걸리는 명령이 있어도 다른 클라이언트 요청이 느려지지 않게 하려면?  -> seed 2
```

기준값을 내리면 적중률과 잘못된 적중이 함께 오릅니다. 출력을 보면 이렇습니다.

- 관계없는 질문과 후속 질문은 기준값으로 걸러집니다. 0.87부터 둘 다 잘못된 적중이 0입니다. 같은 뜻 질문이 다른 캐시 질문에 붙는 경우(앞의 "producer retry..." 하나)도 0.88부터 사라집니다.
- 다른 뜻 질문은 걸러지지 않습니다. 같은 뜻 질문 58개(96.7%)가 적중하는 0.90에서 다른 뜻 질문도 58개 중 51개가 적중합니다. 0.95로 올리면 적중률이 68.3%로 떨어지는데 잘못된 적중은 26개가 남고, 0.98에서도 5개가 남습니다.
- 0.80~0.99 어디에도 잘못된 적중이 0인 기준값은 없습니다. 잘못 붙는 질문의 최고 유사도가 0.9944이고, 이보다 높은 같은 뜻 질문은 0개입니다. 이 질문셋과 이 모델에서는 잘못된 적중을 0으로 만드는 기준값이 곧 적중률 0인 기준값입니다.

유형별 최고 유사도를 보면 네 유형 모두 0.97 이상입니다. 반대 방향(0.994)과 범위가 다른 질문(0.987)이 가장 높은 기준값까지 살아남았습니다. "유사도 0.9 이상이면 같은 질문으로 봐도 된다"는 이 모델에서는 맞지 않고, "기준값을 높이면 잘못된 적중이 사라진다"도 맞지 않습니다. 기준값을 올리면 잘못된 적중보다 적중률이 먼저 무너집니다.

<figure>
<svg viewBox="0 -187 480 485" role="img" aria-label="e5-small에서 같은 뜻 질문 60개와 다른 뜻 질문 58개가 가장 가까운 캐시 질문과 이루는 유사도를 점으로 찍었다. 기준값 0.90 오른쪽에 같은 뜻 58개와 다른 뜻 51개가 함께 있다" className="diagram h-auto w-full">
  <text x="30" y="-150" textAnchor="start" fontSize="17" fontWeight="700" className="fill-foreground">같은 뜻 질문 60개</text>
  <circle cx="147" cy="-4" r="4.2" className="fill-muted-foreground" />
  <circle cx="197" cy="-4" r="4.2" className="fill-muted-foreground" />
  <circle cx="221" cy="-4" r="4.2" className="fill-muted-foreground" />
  <circle cx="234" cy="-4" r="4.2" className="fill-muted-foreground" />
  <circle cx="234" cy="-14" r="4.2" className="fill-muted-foreground" />
  <circle cx="246" cy="-4" r="4.2" className="fill-muted-foreground" />
  <circle cx="246" cy="-14" r="4.2" className="fill-muted-foreground" />
  <circle cx="259" cy="-4" r="4.2" className="fill-muted-foreground" />
  <circle cx="283" cy="-4" r="4.2" className="fill-muted-foreground" />
  <circle cx="296" cy="-4" r="4.2" className="fill-muted-foreground" />
  <circle cx="296" cy="-14" r="4.2" className="fill-muted-foreground" />
  <circle cx="308" cy="-4" r="4.2" className="fill-muted-foreground" />
  <circle cx="308" cy="-14" r="4.2" className="fill-muted-foreground" />
  <circle cx="308" cy="-23" r="4.2" className="fill-muted-foreground" />
  <circle cx="308" cy="-32" r="4.2" className="fill-muted-foreground" />
  <circle cx="320" cy="-4" r="4.2" className="fill-muted-foreground" />
  <circle cx="320" cy="-14" r="4.2" className="fill-muted-foreground" />
  <circle cx="320" cy="-23" r="4.2" className="fill-muted-foreground" />
  <circle cx="320" cy="-32" r="4.2" className="fill-muted-foreground" />
  <circle cx="333" cy="-4" r="4.2" className="fill-muted-foreground" />
  <circle cx="333" cy="-14" r="4.2" className="fill-muted-foreground" />
  <circle cx="333" cy="-23" r="4.2" className="fill-muted-foreground" />
  <circle cx="333" cy="-32" r="4.2" className="fill-muted-foreground" />
  <circle cx="333" cy="-42" r="4.2" className="fill-muted-foreground" />
  <circle cx="333" cy="-51" r="4.2" className="fill-muted-foreground" />
  <circle cx="345" cy="-4" r="4.2" className="fill-muted-foreground" />
  <circle cx="345" cy="-14" r="4.2" className="fill-muted-foreground" />
  <circle cx="345" cy="-23" r="4.2" className="fill-muted-foreground" />
  <circle cx="345" cy="-32" r="4.2" className="fill-muted-foreground" />
  <circle cx="345" cy="-42" r="4.2" className="fill-muted-foreground" />
  <circle cx="345" cy="-51" r="4.2" className="fill-muted-foreground" />
  <circle cx="345" cy="-61" r="4.2" className="fill-muted-foreground" />
  <circle cx="357" cy="-4" r="4.2" className="fill-muted-foreground" />
  <circle cx="357" cy="-14" r="4.2" className="fill-muted-foreground" />
  <circle cx="357" cy="-23" r="4.2" className="fill-muted-foreground" />
  <circle cx="370" cy="-4" r="4.2" className="fill-muted-foreground" />
  <circle cx="370" cy="-14" r="4.2" className="fill-muted-foreground" />
  <circle cx="382" cy="-4" r="4.2" className="fill-muted-foreground" />
  <circle cx="382" cy="-14" r="4.2" className="fill-muted-foreground" />
  <circle cx="382" cy="-23" r="4.2" className="fill-muted-foreground" />
  <circle cx="382" cy="-32" r="4.2" className="fill-muted-foreground" />
  <circle cx="382" cy="-42" r="4.2" className="fill-muted-foreground" />
  <circle cx="394" cy="-4" r="4.2" className="fill-muted-foreground" />
  <circle cx="394" cy="-14" r="4.2" className="fill-muted-foreground" />
  <circle cx="394" cy="-23" r="4.2" className="fill-muted-foreground" />
  <circle cx="407" cy="-4" r="4.2" className="fill-muted-foreground" />
  <circle cx="407" cy="-14" r="4.2" className="fill-muted-foreground" />
  <circle cx="407" cy="-23" r="4.2" className="fill-muted-foreground" />
  <circle cx="407" cy="-32" r="4.2" className="fill-muted-foreground" />
  <circle cx="419" cy="-4" r="4.2" className="fill-muted-foreground" />
  <circle cx="419" cy="-14" r="4.2" className="fill-muted-foreground" />
  <circle cx="419" cy="-23" r="4.2" className="fill-muted-foreground" />
  <circle cx="419" cy="-32" r="4.2" className="fill-muted-foreground" />
  <circle cx="419" cy="-42" r="4.2" className="fill-muted-foreground" />
  <circle cx="419" cy="-51" r="4.2" className="fill-muted-foreground" />
  <circle cx="419" cy="-61" r="4.2" className="fill-muted-foreground" />
  <circle cx="419" cy="-70" r="4.2" className="fill-muted-foreground" />
  <circle cx="419" cy="-79" r="4.2" className="fill-muted-foreground" />
  <circle cx="431" cy="-4" r="4.2" className="fill-muted-foreground" />
  <circle cx="431" cy="-14" r="4.2" className="fill-muted-foreground" />
  <line x1="30" y1="2" x2="450" y2="2" className="stroke-muted-foreground" strokeWidth="2" />
  <text x="30" y="40" textAnchor="start" fontSize="17" fontWeight="700" className="fill-foreground">다른 뜻 질문 58개</text>
  <circle cx="49" cy="186" r="4.2" className="fill-muted-foreground" />
  <circle cx="110" cy="186" r="4.2" className="fill-muted-foreground" />
  <circle cx="172" cy="186" r="4.2" className="fill-muted-foreground" />
  <circle cx="172" cy="176" r="4.2" className="fill-muted-foreground" />
  <circle cx="197" cy="186" r="4.2" className="fill-muted-foreground" />
  <circle cx="197" cy="176" r="4.2" className="fill-muted-foreground" />
  <circle cx="197" cy="167" r="4.2" className="fill-muted-foreground" />
  <circle cx="209" cy="186" r="4.2" className="fill-primary" />
  <circle cx="246" cy="186" r="4.2" className="fill-primary" />
  <circle cx="246" cy="176" r="4.2" className="fill-primary" />
  <circle cx="246" cy="167" r="4.2" className="fill-primary" />
  <circle cx="259" cy="186" r="4.2" className="fill-primary" />
  <circle cx="271" cy="186" r="4.2" className="fill-primary" />
  <circle cx="283" cy="186" r="4.2" className="fill-primary" />
  <circle cx="283" cy="176" r="4.2" className="fill-primary" />
  <circle cx="283" cy="167" r="4.2" className="fill-primary" />
  <circle cx="283" cy="158" r="4.2" className="fill-primary" />
  <circle cx="296" cy="186" r="4.2" className="fill-primary" />
  <circle cx="296" cy="176" r="4.2" className="fill-primary" />
  <circle cx="296" cy="167" r="4.2" className="fill-primary" />
  <circle cx="296" cy="158" r="4.2" className="fill-primary" />
  <circle cx="296" cy="148" r="4.2" className="fill-primary" />
  <circle cx="296" cy="139" r="4.2" className="fill-primary" />
  <circle cx="296" cy="129" r="4.2" className="fill-primary" />
  <circle cx="296" cy="120" r="4.2" className="fill-primary" />
  <circle cx="296" cy="111" r="4.2" className="fill-primary" />
  <circle cx="296" cy="101" r="4.2" className="fill-primary" />
  <circle cx="308" cy="186" r="4.2" className="fill-primary" />
  <circle cx="308" cy="176" r="4.2" className="fill-primary" />
  <circle cx="308" cy="167" r="4.2" className="fill-primary" />
  <circle cx="320" cy="186" r="4.2" className="fill-primary" />
  <circle cx="320" cy="176" r="4.2" className="fill-primary" />
  <circle cx="333" cy="186" r="4.2" className="fill-primary" />
  <circle cx="333" cy="176" r="4.2" className="fill-primary" />
  <circle cx="333" cy="167" r="4.2" className="fill-primary" />
  <circle cx="333" cy="158" r="4.2" className="fill-primary" />
  <circle cx="333" cy="148" r="4.2" className="fill-primary" />
  <circle cx="333" cy="139" r="4.2" className="fill-primary" />
  <circle cx="333" cy="129" r="4.2" className="fill-primary" />
  <circle cx="333" cy="120" r="4.2" className="fill-primary" />
  <circle cx="345" cy="186" r="4.2" className="fill-primary" />
  <circle cx="345" cy="176" r="4.2" className="fill-primary" />
  <circle cx="357" cy="186" r="4.2" className="fill-primary" />
  <circle cx="357" cy="176" r="4.2" className="fill-primary" />
  <circle cx="357" cy="167" r="4.2" className="fill-primary" />
  <circle cx="370" cy="186" r="4.2" className="fill-primary" />
  <circle cx="370" cy="176" r="4.2" className="fill-primary" />
  <circle cx="370" cy="167" r="4.2" className="fill-primary" />
  <circle cx="382" cy="186" r="4.2" className="fill-primary" />
  <circle cx="382" cy="176" r="4.2" className="fill-primary" />
  <circle cx="382" cy="167" r="4.2" className="fill-primary" />
  <circle cx="394" cy="186" r="4.2" className="fill-primary" />
  <circle cx="394" cy="176" r="4.2" className="fill-primary" />
  <circle cx="407" cy="186" r="4.2" className="fill-primary" />
  <circle cx="419" cy="186" r="4.2" className="fill-primary" />
  <circle cx="419" cy="176" r="4.2" className="fill-primary" />
  <circle cx="431" cy="186" r="4.2" className="fill-primary" />
  <circle cx="431" cy="176" r="4.2" className="fill-primary" />
  <line x1="30" y1="192" x2="450" y2="192" className="stroke-muted-foreground" strokeWidth="2" />
  <text x="79" y="216" textAnchor="middle" fontSize="15" className="fill-muted-foreground">0.85</text>
  <text x="203" y="216" textAnchor="middle" fontSize="15" className="fill-muted-foreground">0.90</text>
  <text x="326" y="216" textAnchor="middle" fontSize="15" className="fill-muted-foreground">0.95</text>
  <text x="450" y="216" textAnchor="middle" fontSize="15" className="fill-muted-foreground">1.00</text>
  <line x1="203" y1="-130" x2="203" y2="198" className="stroke-primary" strokeWidth="2" strokeDasharray="6 6" />
  <text x="209" y="246" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-primary">기준값 0.90 오른쪽 = 적중</text>
  <text x="240" y="274" textAnchor="middle" fontSize="16" className="fill-muted-foreground">진한 점: 잘못된 적중 51개</text>
</svg>
<figcaption>e5-small 실측값입니다. 기준값을 어디에 그어도 오른쪽에 다른 뜻 질문이 남습니다.</figcaption>
</figure>

## 라이브러리 기본값 그대로 쓰면

RedisVL의 `SemanticCache`는 Redis로 시맨틱 캐시를 만드는 공식 라이브러리입니다. 기준값을 정하지 않고 쓰면 어떤 값으로 동작하는지 확인합니다. 0.27.2의 생성자 시그니처와 docstring에 적힌 내용은 이렇습니다.

- `distance_threshold`: 기본값 0.1입니다. 단위는 유사도가 아니라 Redis COSINE 거리(0~2)이고, 작을수록 엄격합니다.
- `ttl`: 기본값 `None`으로, 만료 시간 없이 저장합니다.
- `vectorizer`: 넘기지 않으면 Hugging Face의 `redis/langcache-embed-v1` 모델을 받아 씁니다.

기본 vectorizer를 쓰면 모델이 바뀌어 앞의 결과와 비교할 수 없으므로, 앞에서 계산해 둔 e5-small 벡터를 돌려주는 `CustomVectorizer`를 넘겼습니다. `distance_threshold`는 지정하지 않았습니다.

```python
vz = CustomVectorizer(embed=lambda text: table.get(text, sv[0]).tolist())  # 미리 계산한 e5-small 벡터
cache = SemanticCache(name="rvl", vectorizer=vz, redis_url=os.environ["REDIS_URL"], overwrite=True)
for s in SEEDS:
    cache.store(prompt=s["question"], response=s["gold_passage"])
for p in PROBES:
    hits = cache.check(prompt=p["question"])
```

```bash
py rvl e5s
```

출력에서 앞부분과 잘못된 적중 몇 줄만 옮깁니다.

```
distance_threshold 기본값 0.1  ttl 기본값 None
인덱스 vector 필드 [('prompt_vector', <VectorIndexAlgorithm.FLAT: 'FLAT'>, <VectorDistanceMetric.COSINE: 'COSINE'>)]
RedisVL 기본값으로 조회: 적중 58/60  잘못된 적중 51 (같은 뜻이 다른 seed로 0, 다른 뜻 51, 관계없음 0, 후속 0)
numpy 유사도 0.90 분류와 같은 probe 158 / 158
  잘못된 적중 0.924 [diff/scope] Kafka lag 숫자가 계속 커지면 어떻게 줄여야 하나요? -> seed 1 답: lag는 메시지 개수입니다. 몇 초 늦었는지는 메시지가 얼마나 빨리 들어
  잘못된 적중 0.934 [diff/number] 컨테이너가 종료 코드 143으로 죽었을 때 원인을 어떻게 확인하나요? -> seed 5 답: 메모리 때문에 끝났는지는 `OOMKilled` 값과 `oom` 이벤트로 
  잘못된 적중 0.994 [diff/opposite] NGINX 설정에 keepalive를 썼는데도 upstream 연결이 재사용되지 않는 이유는? -> seed 13 답: 설정 파일에 `keepalive`가 한 줄도 없는데 연결을 재사용한 것입
  잘못된 적중 0.970 [diff/number] binlog_format을 STATEMENT로 두면 Binlog에 무엇이 기록되나요? -> seed 24 답: `ROW`(기본값): 바뀐 행 자체를 기록합니다.
```

- 기본값: 출력한 값도 거리 0.1, TTL 없음입니다. 인덱스는 FLAT(전수 비교)에 COSINE 거리입니다.
- 결과: 적중 58/60, 잘못된 적중 51개입니다. 앞의 `sweep`에서 유사도 0.90으로 분류한 결과와 158개 질문 모두 같습니다. 거리 0.1은 유사도 0.9와 같은 기준입니다.
- 잘못 나간 답: Kafka lag를 줄이는 방법을 물었는데 "lag는 메시지 개수입니다"가, 컨테이너 종료 코드 143의 원인을 물었는데 137과 OOM을 가리는 방법이 나갑니다.

기본값 0.1은 어떤 모델을 쓸지 모르는 채로 정해진 값입니다. vectorizer를 넘기지 않을 때 쓰는 모델은 `redis/langcache-embed-v1`이고, 그 모델에 맞춘 값이 e5-small에도 맞는다는 근거는 없습니다. 이 질문셋에서는 같은 뜻 질문 대부분을 적중시키면서 다른 뜻 질문도 대부분 적중시키는 값이었습니다.

## Redis와 Qdrant로 조회하기

같은 캐시를 Redis 벡터 인덱스와 Qdrant로 직접 만듭니다. Redis는 캐시 질문 하나를 Hash 하나(`cache:<id>`)로 저장하고, 그 위에 벡터 인덱스를 만듭니다.

```python
r.execute_command("FT.CREATE", "cache", "ON", "HASH", "PREFIX", 1, "cache:", "SCHEMA",
                  "q", "TEXT", "a", "TEXT",
                  "v", "VECTOR", "FLAT", 6, "TYPE", "FLOAT32", "DIM", dim, "DISTANCE_METRIC", "COSINE")
r.execute_command("FT.CREATE", "cache_hnsw", "ON", "HASH", "PREFIX", 1, "cache:", "SCHEMA",
                  "v", "VECTOR", "HNSW", 6, "TYPE", "FLOAT32", "DIM", dim, "DISTANCE_METRIC", "COSINE")
for s, v in zip(SEEDS, sv):
    r.hset(f"cache:{s['id']}", mapping={"q": s["question"], "a": s["gold_passage"], "v": v.tobytes()})
```

벡터 필드 정의에서 숫자 6은 뒤에 오는 속성 인자의 개수(`TYPE FLOAT32 DIM 384 DISTANCE_METRIC COSINE`)입니다. 같은 Hash 위에 FLAT 인덱스와 HNSW 인덱스를 하나씩 만들었습니다. 정의는 이렇습니다.

- FLAT: 저장된 벡터를 모두 비교합니다. 캐시 질문이 적을 때는 이것으로 충분합니다.
- HNSW: 그래프를 따라가며 근사 검색을 합니다. 속성을 주지 않아 기본값으로 만들어졌고, `FT.INFO cache_hnsw`에 `M 16`, `ef_construction 200`, `ef_runtime 10`으로 나옵니다.
- COSINE: Redis는 이 값을 유사도가 아니라 거리 `1 - cos`로 돌려줍니다. 작을수록 가깝습니다.

조회는 KNN 쿼리입니다. `$vec`에 질문 벡터의 float32 바이트를 넘깁니다.

```python
def redis_knn(r, vec, index="cache"):
    res = r.execute_command("FT.SEARCH", index, "*=>[KNN 1 @v $vec AS dist]",
                            "PARAMS", 2, "vec", vec.astype(np.float32).tobytes(),
                            "SORTBY", "dist", "RETURN", 1, "dist", "DIALECT", 2)
    hit = res[b"results"][0]  # redis-py 8은 RESP3로 접속해 결과가 dict로 온다
    return int(hit[b"id"].decode().split(":")[1]), float(hit[b"extra_attributes"][b"dist"])
```

끝의 `DIALECT 2`는 빼면 안 됩니다. 이 서버의 기본 문법 버전(`search-default-dialect`)은 1이고, 같은 쿼리를 `DIALECT 2` 없이 보내면 `SEARCH_SYNTAX Syntax error at offset 1 near >[` 오류가 납니다. Qdrant는 Cosine 컬렉션에 같은 벡터를 넣고 `query_points`로 1개만 가져옵니다.

```python
def qdrant_top1(q, vec, threshold=None):
    pts = q.query_points("cache", query=vec.tolist(), limit=1, score_threshold=threshold).points
    return (pts[0].id, pts[0].score) if pts else (None, None)
```

`score_threshold`는 이 값보다 점수가 나쁜 결과를 버립니다. Cosine 컬렉션에서는 점수가 유사도라서 이 값 이상만 남습니다. [임베딩 거리를 다룬 글](/posts/embedding-similarity)에서 본 것처럼 Qdrant Cosine 컬렉션은 저장할 때 벡터를 정규화하고 유사도를 점수로 돌려줍니다.

`stores`는 두 저장소에 캐시 질문 30개를 넣고 들어온 질문 158개로 조회해, 가장 가까운 캐시 질문과 점수를 numpy 결과와 맞춰 봅니다. 마지막 세 줄은 Qdrant에 기준값 0.1과 0.9를 넣었을 때 결과가 나온 질문 수와, Redis 거리 0.1 이하인 질문 수입니다.

```bash
py stores e5s
```

```
Redis 인덱스 문서 수 FLAT 30  HNSW 30
probe 158개  Redis FLAT 최근접 = numpy 158/158  HNSW = FLAT 158/158  Qdrant = Redis FLAT 158/158
|(1 - Redis dist) - Qdrant score| 최대 2.43e-07   |(1 - Redis dist) - numpy 코사인| 최대 7.75e-07
  [same] Kafka 컨슈머 lag 값이 몇 초 지연을 뜻하지 않는 이유가 뭔가요?
      Redis seed 1 dist 0.0578   Qdrant seed 1 score 0.9422
  [diff] Kafka lag 숫자가 계속 커지면 어떻게 줄여야 하나요?
      Redis seed 1 dist 0.0759   Qdrant seed 1 score 0.9241
  [same] MySQL binlog retention 기간을 정하는 설정 이름이 뭔가요?
      Redis seed 16 dist 0.0578   Qdrant seed 16 score 0.9422
  Qdrant score_threshold=0.1: 결과가 나온 probe 158/158
  Qdrant score_threshold=0.9: 결과가 나온 probe 109/158
  Redis dist <= 0.1: 109/158
```

- 최근접: Redis FLAT, Redis HNSW, Qdrant, numpy 네 결과가 158개 질문 모두 같은 캐시 질문을 골랐습니다. 캐시 질문이 30개뿐이라 HNSW 근사 검색도 전수 비교와 같았습니다.
- 점수: `1 - Redis 거리`와 Qdrant 점수의 차이는 최대 2.43 x 10⁻⁷, numpy 코사인과의 차이는 최대 7.75 x 10⁻⁷입니다. float32 반올림 오차 수준이고, 같은 값을 한쪽은 거리로, 한쪽은 유사도로 적은 것입니다.
- 기준값 변환: Redis에서 쓰던 0.1을 Qdrant `score_threshold`에 그대로 넣으면 158개 모두 결과가 나옵니다. 관계없는 질문도, 후속 질문도 모두 적중으로 처리됩니다. 0.9로 바꿔 넣어야 Redis의 거리 0.1 이하와 같은 109개가 됩니다.

<figure>
<svg viewBox="0 -20 480 412" role="img" aria-label="같은 질문 쌍의 코사인 유사도가 0.9422일 때 Redis COSINE은 거리 0.0578을, Qdrant Cosine은 점수 0.9422를 돌려준다. 거리 기준은 1에서 유사도 기준을 뺀 값이다" className="diagram h-auto w-full">
  <rect x="90" y="0" width="300" height="82" rx="10" className="fill-accent stroke-primary" strokeWidth="2" />
  <text x="240" y="35" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-accent-foreground">코사인 유사도 0.9422</text>
  <text x="240" y="61" textAnchor="middle" fontSize="17" className="fill-accent-foreground">같은 질문 쌍, 같은 벡터</text>
  <rect x="30" y="142" width="196" height="82" rx="10" className="fill-card stroke-border" strokeWidth="2" />
  <text x="128" y="177" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">Redis COSINE</text>
  <text x="128" y="203" textAnchor="middle" fontSize="17" className="fill-muted-foreground">dist = 0.0578</text>
  <rect x="254" y="142" width="196" height="82" rx="10" className="fill-card stroke-border" strokeWidth="2" />
  <text x="352" y="177" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">Qdrant Cosine</text>
  <text x="352" y="203" textAnchor="middle" fontSize="17" className="fill-muted-foreground">score = 0.9422</text>
  <line x1="240" y1="88" x2="138" y2="132" className="stroke-muted-foreground" strokeWidth="2" />
  <polygon points="128,136 136,126 140,137" className="fill-muted-foreground" />
  <line x1="240" y1="88" x2="342" y2="132" className="stroke-muted-foreground" strokeWidth="2" />
  <polygon points="352,136 340,137 344,126" className="fill-muted-foreground" />
  <text x="128" y="254" textAnchor="middle" fontSize="16" className="fill-muted-foreground">작을수록 가깝다</text>
  <text x="352" y="254" textAnchor="middle" fontSize="16" className="fill-muted-foreground">클수록 가깝다</text>
  <rect x="75" y="290" width="330" height="82" rx="10" className="fill-muted stroke-border" strokeWidth="2" />
  <text x="240" y="325" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">거리 기준 = 1 - 유사도 기준</text>
  <text x="240" y="351" textAnchor="middle" fontSize="17" className="fill-muted-foreground">dist ≤ 0.1 ⇔ score ≥ 0.9</text>
</svg>
<figcaption>같은 이웃, 같은 값이고 표기만 다릅니다. 0.1을 Qdrant에 그대로 넣으면 거의 모든 질문이 적중합니다.</figcaption>
</figure>

조회 시간도 잽니다. `lat`은 클라이언트 컨테이너에서 질문 158개의 벡터를 차례로 1,000번 보내(158,000회) 한 번씩 걸린 시간을 모읍니다. 임베딩 시간은 빼고 조회만 잰 값이고, 저장소마다 세 번 반복했습니다.

```bash
for r in 1 2 3; do py lat redis 1000; py lat qdrant 1000; done
```

```
redis  조회 158,000회  중앙값 0.183ms  p99 0.522ms  전체 32초
qdrant 조회 158,000회  중앙값 1.064ms  p99 2.102ms  전체 179초
redis  조회 158,000회  중앙값 0.185ms  p99 0.447ms  전체 32초
qdrant 조회 158,000회  중앙값 1.062ms  p99 1.616ms  전체 175초
redis  조회 158,000회  중앙값 0.182ms  p99 0.228ms  전체 29초
qdrant 조회 158,000회  중앙값 1.075ms  p99 2.757ms  전체 189초
```

조회 시간은 세 번 모두 비슷했습니다.

- Redis: 중앙값 0.182~0.185ms, p99 0.228~0.522ms입니다.
- Qdrant: 중앙값 1.062~1.075ms, p99 1.616~2.757ms입니다. `qdrant-client`의 기본 설정인 REST(HTTP)로 보낸 값이라 Redis 프로토콜과는 요청 하나에 드는 처리가 다릅니다. gRPC 포트(6334)로 보내는 경우는 재지 않았습니다.
- 질문 임베딩: 앞의 `embed` 출력에서 e5-small로 질문 하나를 임베딩하는 데 중앙값 22.7ms가 걸렸습니다. Redis 조회의 약 120배, Qdrant 조회의 약 20배입니다.

같은 노트북 안의 컨테이너끼리 보낸 값이라 실제 네트워크를 거치면 달라집니다. 그래도 캐시 조회 한 번의 비용은 대부분 질문을 벡터로 바꾸는 데서 나오고, 캐시 질문이 30개인 이 실습에서는 저장소를 무엇으로 고르든 조회 시간이 임베딩 시간보다 한 자릿수 이상 작았습니다.

## 모델을 바꾸면

같은 질문을 `intfloat/multilingual-e5-large`와 `BAAI/bge-m3` 밀집 벡터로 다시 임베딩해 분포와 기준값 훑기를 반복합니다. bge-m3는 모델 카드대로 접두어를 붙이지 않았습니다. 두 모델은 각각 2GB 남짓이라 처음 받을 때 시간이 걸립니다. bge-m3는 유사도가 낮게 퍼지는 모델이라 0.50부터 훑었습니다.

```bash
py embed e5l
py embed m3
py dist e5l
py dist m3
py sweep e5l 0.80 0.99 0.85,0.88,0.90,0.92,0.95,0.98
py sweep m3 0.50 0.99 0.55,0.60,0.65,0.70,0.75,0.80,0.85,0.88,0.90,0.95
```

`dist` 출력에서 분포 줄과, `sweep` 출력을 옮깁니다.

```
e5l intfloat/multilingual-e5-large  1024차원  질문 188개 일괄 39.5초  하나씩 중앙값 264.4ms p99 324.5ms
m3 BAAI/bge-m3  1024차원  질문 188개 일괄 34.0초  하나씩 중앙값 238.7ms p99 304.7ms
[e5l] 가장 가까운 seed와의 코사인 유사도
  same             60개  최소 0.899  10% 0.934  중앙값 0.969  90% 0.992  최대 0.996
  diff             58개  최소 0.842  10% 0.900  중앙값 0.942  90% 0.969  최대 0.989
  diff/opposite    14개  최소 0.916  10% 0.928  중앙값 0.950  90% 0.985  최대 0.989
  diff/number      16개  최소 0.842  10% 0.895  중앙값 0.944  90% 0.955  최대 0.959
  diff/target      14개  최소 0.869  10% 0.894  중앙값 0.931  90% 0.967  최대 0.980
  diff/scope       14개  최소 0.898  10% 0.919  중앙값 0.934  90% 0.961  최대 0.968
  unrelated        30개  최소 0.775  10% 0.789  중앙값 0.810  90% 0.830  최대 0.864
  followup         10개  최소 0.814  10% 0.815  중앙값 0.822  90% 0.841  최대 0.857
[m3] 가장 가까운 seed와의 코사인 유사도
  same             60개  최소 0.735  10% 0.825  중앙값 0.930  90% 0.978  최대 0.990
  diff             58개  최소 0.568  10% 0.711  중앙값 0.825  90% 0.916  최대 0.965
  diff/opposite    14개  최소 0.789  10% 0.815  중앙값 0.842  90% 0.960  최대 0.965
  diff/number      16개  최소 0.568  10% 0.688  중앙값 0.801  90% 0.855  최대 0.861
  diff/target      14개  최소 0.574  10% 0.651  중앙값 0.800  90% 0.902  최대 0.939
  diff/scope       14개  최소 0.760  10% 0.783  중앙값 0.842  90% 0.898  최대 0.912
  unrelated        30개  최소 0.381  10% 0.415  중앙값 0.486  90% 0.530  최대 0.593
  followup         10개  최소 0.446  10% 0.476  중앙값 0.513  90% 0.555  최대 0.559
```

```
[e5l] 기준값별 결과 (적중률은 같은 뜻 60개 기준)
  0.85  적중 60/60 (100.0%)  잘못된 적중  59 (같은 뜻이 다른 seed로 0, 다른 뜻 56, 관계없음 2, 후속 1)
  0.88  적중 60/60 (100.0%)  잘못된 적중  55 (같은 뜻이 다른 seed로 0, 다른 뜻 55, 관계없음 0, 후속 0)
  0.90  적중 59/60 (98.3%)  잘못된 적중  51 (같은 뜻이 다른 seed로 0, 다른 뜻 51, 관계없음 0, 후속 0)
  0.92  적중 57/60 (95.0%)  잘못된 적중  44 (같은 뜻이 다른 seed로 0, 다른 뜻 44, 관계없음 0, 후속 0)
  0.95  적중 48/60 (80.0%)  잘못된 적중  19 (같은 뜻이 다른 seed로 0, 다른 뜻 19, 관계없음 0, 후속 0)
  0.98  적중 21/60 (35.0%)  잘못된 적중   3 (같은 뜻이 다른 seed로 0, 다른 뜻 3, 관계없음 0, 후속 0)
  잘못된 적중이 0이 되는 가장 낮은 기준값 0.99, 그때 적중 12/60 (20.0%)
  잘못 붙는 probe의 최고 유사도 0.9891, 이보다 높은 같은 뜻 적중 13/60
  diff/opposite 최고 0.989  NGINX 설정에 keepalive를 썼는데도 upstream 연결이 재사용되지 않는 이유는?  -> seed 13
  diff/number   최고 0.959  binlog_format을 STATEMENT로 두면 Binlog에 무엇이 기록되나요?  -> seed 24
  diff/target   최고 0.980  Redis Streams로 채팅 서버를 연결했을 때 구독이 잠깐 끊긴 동안 보낸 메시지는 어떻게 되나요?  -> seed 11
  diff/scope    최고 0.968  Redis에서 오래 걸리는 명령이 있어도 다른 클라이언트 요청이 느려지지 않게 하려면?  -> seed 2
[m3] 기준값별 결과 (적중률은 같은 뜻 60개 기준)
  0.55  적중 60/60 (100.0%)  잘못된 적중  63 (같은 뜻이 다른 seed로 0, 다른 뜻 58, 관계없음 3, 후속 2)
  0.60  적중 60/60 (100.0%)  잘못된 적중  56 (같은 뜻이 다른 seed로 0, 다른 뜻 56, 관계없음 0, 후속 0)
  0.65  적중 60/60 (100.0%)  잘못된 적중  55 (같은 뜻이 다른 seed로 0, 다른 뜻 55, 관계없음 0, 후속 0)
  0.70  적중 60/60 (100.0%)  잘못된 적중  53 (같은 뜻이 다른 seed로 0, 다른 뜻 53, 관계없음 0, 후속 0)
  0.75  적중 59/60 (98.3%)  잘못된 적중  48 (같은 뜻이 다른 seed로 0, 다른 뜻 48, 관계없음 0, 후속 0)
  0.80  적중 56/60 (93.3%)  잘못된 적중  39 (같은 뜻이 다른 seed로 0, 다른 뜻 39, 관계없음 0, 후속 0)
  0.85  적중 49/60 (81.7%)  잘못된 적중  20 (같은 뜻이 다른 seed로 0, 다른 뜻 20, 관계없음 0, 후속 0)
  0.88  적중 48/60 (80.0%)  잘못된 적중  14 (같은 뜻이 다른 seed로 0, 다른 뜻 14, 관계없음 0, 후속 0)
  0.90  적중 38/60 (63.3%)  잘못된 적중   9 (같은 뜻이 다른 seed로 0, 다른 뜻 9, 관계없음 0, 후속 0)
  0.95  적중 16/60 (26.7%)  잘못된 적중   3 (같은 뜻이 다른 seed로 0, 다른 뜻 3, 관계없음 0, 후속 0)
  잘못된 적중이 0이 되는 가장 낮은 기준값 0.97, 그때 적중 11/60 (18.3%)
  잘못 붙는 probe의 최고 유사도 0.9645, 이보다 높은 같은 뜻 적중 11/60
  diff/opposite 최고 0.965  slow query log에서 Rows_sent가 Rows_examined보다 크면 어떻게 된 건가요?  -> seed 20
  diff/number   최고 0.861  Redis maxmemory-policy 값은 무엇을 정하나요?  -> seed 19
  diff/target   최고 0.939  Redis Streams로 채팅 서버를 연결했을 때 구독이 잠깐 끊긴 동안 보낸 메시지는 어떻게 되나요?  -> seed 11
  diff/scope    최고 0.912  Redis에서 오래 걸리는 명령이 있어도 다른 클라이언트 요청이 느려지지 않게 하려면?  -> seed 2
```

[임베딩 모델을 비교한 글](/posts/embedding-model-compare)에서 본 것처럼 bge-m3는 관계없는 문장의 유사도가 훨씬 낮습니다. 여기서도 관계없는 질문이 e5-small 0.793~0.867, bge-m3 0.381~0.593이었습니다. 시맨틱 캐시 기준값에서는 이 차이가 이렇게 나타납니다.

- 잘못된 적중이 0이 되는 가장 낮은 기준값: e5-small은 0.99까지 없었고(필요한 값 0.9944 초과, 그때 적중 0/60), e5-large는 0.99(적중 12/60), bge-m3는 0.97(적중 11/60)입니다. 세 모델 모두 다른 뜻 질문까지 0으로 만들면 같은 뜻 질문의 80% 이상을 놓칩니다.
- 같은 기준값을 옮기면: e5-small에서 적중률 96.7%를 주던 0.90을 bge-m3에 그대로 쓰면 적중이 58개에서 38개(63.3%)로 줄고, 잘못된 적중은 51개에서 9개로 줍니다. 반대로 bge-m3에서 같은 뜻 질문을 모두 적중시키는 0.70을 e5-small에 옮기면, e5-small은 관계없는 질문까지 적중시킵니다. e5-small의 0.80에서 이미 관계없는 질문 24개와 후속 질문 10개가 적중했습니다.
- 다른 뜻 질문은 bge-m3에서 같은 뜻 질문과 더 멀리 떨어집니다. 다른 뜻 질문의 중앙값이 0.825로 같은 뜻 질문의 중앙값(0.930)보다 0.105 낮고, e5-small에서는 0.015 차이였습니다. 그래도 반대 방향 유형은 bge-m3에서도 0.965까지 올라왔고, 같은 뜻 질문 중앙값보다 높은 다른 뜻 질문은 e5-large와 bge-m3에서 똑같은 5개(keepalive, 이벤트 먼저 보내기, 잠금 대기 시간 초과, Rows_sent, Redis Streams)였습니다.

기준값은 모델에 딸린 값입니다. 모델을 바꾸면 질문셋으로 기준값을 다시 훑어야 하고, 어느 모델에서도 기준값 하나로 반대 방향 질문을 막을 수는 없었습니다.

## 캐시에 넣지 말아야 할 질문

기준값만으로 막지 못하는 질문은 조회 앞뒤에 규칙을 두어 미스로 돌립니다. 결과를 보기 전에 두 규칙을 정해 두었습니다.

- 짧은 질문 제외: 공백을 뺀 글자 수가 15자 미만이면 캐시를 조회하지 않습니다. "그럼 기본값은요?" 같은 질문은 앞 대화에 따라 뜻이 달라지므로 질문만으로 답을 고를 수 없습니다.
- 숫자와 식별자 일치: 질문에서 숫자와 식별자(밑줄이나 숫자가 섞인 영문 토큰, 점이나 하이픈으로 이어진 영문 토큰, 두 글자 이상 대문자 토큰)를 대소문자 구분 없이 뽑아, 가장 가까운 캐시 질문에서 뽑은 집합과 다르면 미스로 처리합니다.

```python
# 한글 조사가 바로 붙어도(예: ROW로, NGINX에) 잡히도록 영문·숫자 경계를 직접 정한다
IDENT = re.compile(r"(?<![A-Za-z0-9_])(?:[A-Za-z][A-Za-z0-9]*(?:[_.\-][A-Za-z0-9]+)+|[A-Za-z]*\d[A-Za-z0-9]*|[A-Z]{2,})(?![A-Za-z0-9_])")


def idents(text):
    return {m.lower() for m in IDENT.findall(text)} | set(re.findall(r"\d+", text))


def short(text, n=15):
    return len(re.sub(r"\s", "", text)) < n
```

처음에는 정규식에 `\b`를 썼다가 "STATEMENT로", "NGINX에"처럼 한글 조사가 바로 붙은 식별자를 놓쳤습니다. Python 정규식에서는 한글도 단어 문자라서 `STATEMENT`와 `로` 사이에 단어 경계가 생기지 않습니다. 그래서 영문과 숫자의 경계를 직접 지정했습니다.

```bash
py rules e5s 0.85,0.90,0.95
```

```
[e5s] 짧은 후속 질문이 붙는 seed
  0.839 그럼 기본값은요?            -> seed 19 Redis maxmemory-samples 값은 무엇을 정하나요?
  0.832 그건 왜 그래요?            -> seed 7 L4 로드밸런서 뒤에 gRPC 서버를 여러 대 뒀는데 요청이 한 서버로만 가는 이유는?
  0.827 그거 끄면 어떻게 돼요?        -> seed 23 relay_log_space_limit은 무엇을 제한하나요?
  0.847 몇 초로 하면 돼요?          -> seed 1 Kafka lag 숫자가 몇 초 늦었는지를 뜻하지 않는 이유는?
  0.843 다른 방법은 없나요?          -> seed 26 컨슈머가 같은 결제 메시지를 두 번 받아도 결제가 한 번만 되게 하려면?
  0.831 그럼 운영에서는 어떻게 해요?     -> seed 12 여러 서비스에 걸친 주문 처리에서 중간 단계가 실패하면 이미 끝난 단계는 어떻게 되돌리나요?
  0.833 설정 파일 어디에 넣어요?       -> seed 16 MySQL이 Binlog를 며칠 동안 남길지는 어떤 설정이 정하나요?
  0.817 그게 버전마다 달라요?         -> seed 25 binlog와 redo log는 각각 MySQL의 어느 쪽이 남기나요?
  0.816 아까 그거랑 뭐가 달라요?       -> seed 9 DB를 고치고 캐시를 지웠는데도 예전 값이 다시 캐시에 들어가는 경우가 있나요?
  0.862 그럼 재시작해야 해요?         -> seed 27 프로듀서가 재시도해서 같은 배치를 다시 보내면 브로커는 어떻게 중복을 걸러 내나요?
[e5s] 기준값 0.85에서 규칙 적용 전후
  규칙 없음      적중 59/60  잘못된 적중 62 (diff 57: opposite 14 number 16 target 13 scope 14, 관계없음 3, 후속 1, 같은 뜻 1)
  짧은 질문 제외   적중 59/60  잘못된 적중 61 (diff 57: opposite 14 number 16 target 13 scope 14, 관계없음 3, 후속 0, 같은 뜻 1)
  숫자·식별자 일치  적중 52/60  잘못된 적중 38 (diff 35: opposite 13 number 1 target 8 scope 13, 관계없음 2, 후속 1, 같은 뜻 0)
  두 규칙 모두    적중 52/60  잘못된 적중 37 (diff 35: opposite 13 number 1 target 8 scope 13, 관계없음 2, 후속 0, 같은 뜻 0)
[e5s] 기준값 0.90에서 규칙 적용 전후
  규칙 없음      적중 58/60  잘못된 적중 51 (diff 51: opposite 14 number 12 target 11 scope 14, 관계없음 0, 후속 0, 같은 뜻 0)
  짧은 질문 제외   적중 58/60  잘못된 적중 51 (diff 51: opposite 14 number 12 target 11 scope 14, 관계없음 0, 후속 0, 같은 뜻 0)
  숫자·식별자 일치  적중 51/60  잘못된 적중 35 (diff 35: opposite 13 number 1 target 8 scope 13, 관계없음 0, 후속 0, 같은 뜻 0)
  두 규칙 모두    적중 51/60  잘못된 적중 35 (diff 35: opposite 13 number 1 target 8 scope 13, 관계없음 0, 후속 0, 같은 뜻 0)
[e5s] 기준값 0.95에서 규칙 적용 전후
  규칙 없음      적중 41/60  잘못된 적중 26 (diff 26: opposite 9 number 5 target 4 scope 8, 관계없음 0, 후속 0, 같은 뜻 0)
  짧은 질문 제외   적중 41/60  잘못된 적중 26 (diff 26: opposite 9 number 5 target 4 scope 8, 관계없음 0, 후속 0, 같은 뜻 0)
  숫자·식별자 일치  적중 39/60  잘못된 적중 19 (diff 19: opposite 9 number 1 target 2 scope 7, 관계없음 0, 후속 0, 같은 뜻 0)
  두 규칙 모두    적중 39/60  잘못된 적중 19 (diff 19: opposite 9 number 1 target 2 scope 7, 관계없음 0, 후속 0, 같은 뜻 0)
```

후속 질문 10개는 e5-small에서 0.816~0.862로 엉뚱한 캐시 질문에 붙었습니다. "몇 초로 하면 돼요?"는 Kafka lag 질문에, "그럼 재시작해야 해요?"는 멱등 프로듀서 질문에 붙었습니다. 규칙의 효과는 기준값마다 다릅니다.

- 짧은 질문 제외: 0.85에서 후속 질문 잘못된 적중 1개를 막았습니다. 0.90 이상에서는 후속 질문이 원래 기준값에 걸리지 않아 바뀌는 것이 없습니다. 기준값을 낮게 잡거나 유사도가 높게 나오는 모델을 쓸 때를 위한 장치이고, 이 질문셋에서는 같은 뜻 질문을 하나도 놓치게 하지 않았습니다.
- 숫자와 식별자 일치: 0.90에서 숫자나 이름만 다른 유형의 잘못된 적중이 12개에서 1개로 줄었습니다. 남은 하나는 "잠금 대기 시간 초과 오류는 기본 몇 초 뒤에 나나요?"로, 숫자를 묻는 질문이라 질문 안에 숫자가 없습니다. 대상만 다른 유형도 11개에서 8개로 줄었습니다.
- 반대 방향 유형은 규칙으로 줄지 않습니다. 0.90에서 14개가 13개가 됐는데, 줄어든 하나는 "autovacuum"과 "VACUUM"의 표기가 달라 우연히 걸린 것입니다. "썼는데도"와 "쓰지 않았는데도"의 차이는 숫자도 식별자도 아닙니다. 범위가 다른 유형도 14개에서 13개로 거의 그대로입니다.
- 대가: 같은 뜻 적중이 58개에서 51개로 7개 줄었습니다. "PITR 할 때..."처럼 바꿔 쓰면서 약어를 넣거나, "슬로 쿼리 로그에서 검사한 행 수가..."처럼 `Rows_examined`를 우리말로 풀어 쓰거나, "nginx"를 소문자로 쓰면 식별자 집합이 달라져 미스가 됩니다.

규칙은 특정 유형의 잘못된 적중을 확실히 줄이지만 같은 뜻 적중도 함께 줄입니다. 반대 방향과 범위가 다른 질문처럼 규칙으로 잡히지 않는 유형이 남는다면, 이런 질문이 많은 서비스에서는 캐시된 답을 그대로 돌려주는 대신 "이전에 비슷한 질문이 있었다"고 후보로 보여 주거나, 캐시된 답을 LLM에 근거로 넘겨 새로 답하게 하는 방식이 더 안전합니다.

## 정리

- 시맨틱 캐시는 "가장 가까운 이전 질문과의 유사도가 기준값 이상이면 그 답을 돌려준다"는 규칙 하나입니다. e5-small에서 기준값을 0.95에서 0.90으로 내리면 적중이 41개에서 58개로, 잘못된 적중이 26개에서 51개로 함께 늘었습니다. 기준값은 적중과 잘못된 적중을 같은 질문셋으로 함께 재서 고릅니다.
- 말이 거의 같고 뜻이 다른 질문은 같은 뜻으로 바꿔 쓴 질문보다 유사도가 높게 나올 수 있습니다. e5-small에서 "keepalive를 썼는데도"와 "쓰지 않았는데도"는 0.994였고, 같은 뜻 질문 중 이보다 높은 것은 없었습니다. 반대 방향과 범위가 다른 질문은 기준값으로도, 숫자와 식별자 규칙으로도 막지 못했습니다.
- 기준값은 모델에 딸린 값입니다. 잘못된 적중이 0이 되는 최저 기준값이 e5-large 0.99, bge-m3 0.97이었고 e5-small은 0.99로도 부족했습니다. e5-small의 0.90을 bge-m3로 옮기면 적중이 58개에서 38개로 줄었습니다.
- 라이브러리 기본값도 기준값입니다. RedisVL `SemanticCache`의 기본 `distance_threshold` 0.1은 유사도 0.9와 같고, e5-small 벡터로 이 질문셋에서 잘못된 적중 51개를 냈습니다.
- Redis COSINE은 거리 `1 - cos`, Qdrant Cosine은 유사도를 돌려줍니다. 두 저장소는 158개 질문 모두 같은 이웃을 골랐지만, Redis의 0.1을 Qdrant `score_threshold`에 그대로 넣으면 158개가 모두 적중합니다. 저장소를 옮길 때는 기준값을 `1 - 기준값`으로 바꿉니다.
- LLM 없이도 캐시의 위험은 잴 수 있습니다. 같은 뜻 질문과 다른 뜻 질문을 정답 라벨과 함께 만들어 두면 "다른 질문에 다른 질문의 답이 나간 횟수"를 셀 수 있고, 모델이나 기준값을 바꿀 때마다 같은 질문셋으로 다시 재면 됩니다.

===== agent-context-growth =====
로컬 LLM 에이전트와 20단계짜리 대화를 나눈 뒤, 앞에서 조회한 값을 다시 물어봤습니다. 대화 이력은 하나도 버리지 않고 매번 모델에 그대로 보냈습니다.

```
step 16.1 sent  56 msgs prompt=4408 cached=4333 eval=4 3.4s
  recall step 16 expected 908 got 811 -> WRONG (value in prompt: True)
```

6단계에서 `get_like_count`가 돌려준 mysql-06 글의 좋아요 수는 908이었고, 이 값은 16단계에 보낸 56개 메시지 안에 그대로 있었습니다. 모델은 811이라고 답했습니다. 811은 12단계에서 조회한 다른 글(docker-11)의 좋아요 수입니다. 같은 질문을 이력 없이 필요한 도구 결과 하나만 넣어 물으면 세 번 모두 908로 맞혔습니다.

에이전트는 단계를 거듭할수록 대화가 길어집니다. 사용자 요청, 모델의 도구 호출, 도구 결과, 모델의 답이 단계마다 쌓이고, 다음 호출에는 이 전체가 다시 들어갑니다. 모델이 한 번에 읽는 입력 전체를 컨텍스트라고 하고, 그 크기는 토큰(모델이 글자를 잘게 나눈 단위)으로 셉니다. 컨텍스트가 길어지면 무엇이 느려지는지, 그리고 길이를 줄이려고 이력을 덜어 내면 무엇을 잃는지가 이 글의 질문입니다.

