
===== 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로 맞혔습니다.

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

이 글은 같은 20단계 대화를 이력 관리 전략 네 가지로 돌려 보고, 단계마다 Ollama가 알려 주는 프롬프트 토큰 수, 캐시에서 재사용한 토큰 수, 처리 시간을 기록합니다. 끝의 일곱 단계는 앞에서 얻은 값을 다시 묻는 확인 질문이라, 각 전략이 무엇을 잃었는지 정답 수로 볼 수 있습니다. 마지막으로 이력이 `num_ctx`를 넘을 때 Ollama가 무엇을 버리는지 확인합니다. 검색 결과를 몇 개 넣을지 같은 RAG 쪽 문제는 다루지 않고 에이전트의 대화 이력만 다룹니다.

## 비교할 네 가지 전략

네 전략 모두 시스템 메시지(모델의 역할과 규칙을 적은 첫 메시지)는 항상 남깁니다. 다른 점은 그 뒤의 이력에서 무엇을 모델에 보내느냐입니다.

- `full`: 모든 메시지를 보냅니다. 아무것도 하지 않는 기준입니다.
- `last_n`: 최근 N개 사용자 턴과 그 턴에 딸린 모델 응답, 도구 결과만 보냅니다. 이 글에서 N은 8입니다.
- `clear_tool`: 최근 K개 도구 결과는 그대로 두고, 그보다 오래된 도구 결과의 내용만 `[이전 도구 결과 생략]`으로 바꿉니다. 메시지 자리는 남기므로 모델은 "여기서 도구를 불렀다"는 사실은 봅니다. K는 3입니다.
- `summarize`: 직전 호출의 프롬프트가 3,000토큰을 넘으면, 최근 두 턴을 뺀 앞부분을 같은 모델로 요약해 메시지 하나로 바꿉니다. 요약 호출에 든 토큰과 시간도 따로 셉니다.

<figure>
<svg viewBox="0 -19 480 479" role="img" aria-label="네 전략이 모델에 보내는 메시지. full은 모두 보내고, last_n은 앞쪽 턴을 빼고, clear_tool은 오래된 도구 결과만 짧은 문구로 바꾸고, summarize는 앞쪽 턴을 요약 메시지 하나로 바꾼다. 시스템 메시지는 모두 남는다" className="diagram h-auto w-full">
  <text x="22" y="18" textAnchor="start" fontSize="17" fontWeight="700" className="fill-foreground">full: 모두 보냄</text>
  <rect x="22" y="30" width="40" height="44" rx="6" className="fill-card stroke-border" strokeWidth="2" />
  <text x="42" y="58" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-foreground">시</text>
  <rect x="66" y="30" width="40" height="44" rx="6" className="fill-card stroke-border" strokeWidth="2" />
  <text x="86" y="58" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-foreground">사</text>
  <rect x="110" y="30" width="40" height="44" rx="6" className="fill-card stroke-border" strokeWidth="2" />
  <text x="130" y="58" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-foreground">도</text>
  <rect x="154" y="30" width="40" height="44" rx="6" className="fill-card stroke-border" strokeWidth="2" />
  <text x="174" y="58" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-foreground">답</text>
  <rect x="198" y="30" width="40" height="44" rx="6" className="fill-card stroke-border" strokeWidth="2" />
  <text x="218" y="58" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-foreground">사</text>
  <rect x="242" y="30" width="40" height="44" rx="6" className="fill-card stroke-border" strokeWidth="2" />
  <text x="262" y="58" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-foreground">도</text>
  <rect x="286" y="30" width="40" height="44" rx="6" className="fill-card stroke-border" strokeWidth="2" />
  <text x="306" y="58" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-foreground">답</text>
  <rect x="330" y="30" width="40" height="44" rx="6" className="fill-card stroke-border" strokeWidth="2" />
  <text x="350" y="58" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-foreground">사</text>
  <rect x="374" y="30" width="40" height="44" rx="6" className="fill-card stroke-border" strokeWidth="2" />
  <text x="394" y="58" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-foreground">도</text>
  <rect x="418" y="30" width="40" height="44" rx="6" className="fill-card stroke-border" strokeWidth="2" />
  <text x="438" y="58" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-foreground">답</text>
  <text x="22" y="126" textAnchor="start" fontSize="17" fontWeight="700" className="fill-foreground">last_n: 최근 N턴만</text>
  <rect x="22" y="138" width="40" height="44" rx="6" className="fill-card stroke-border" strokeWidth="2" />
  <text x="42" y="166" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-foreground">시</text>
  <rect x="66" y="138" width="40" height="44" rx="6" className="fill-background stroke-border" strokeWidth="2" strokeDasharray="5 4" />
  <text x="86" y="166" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-muted-foreground">사</text>
  <rect x="110" y="138" width="40" height="44" rx="6" className="fill-background stroke-border" strokeWidth="2" strokeDasharray="5 4" />
  <text x="130" y="166" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-muted-foreground">도</text>
  <rect x="154" y="138" width="40" height="44" rx="6" className="fill-background stroke-border" strokeWidth="2" strokeDasharray="5 4" />
  <text x="174" y="166" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-muted-foreground">답</text>
  <rect x="198" y="138" width="40" height="44" rx="6" className="fill-background stroke-border" strokeWidth="2" strokeDasharray="5 4" />
  <text x="218" y="166" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-muted-foreground">사</text>
  <rect x="242" y="138" width="40" height="44" rx="6" className="fill-background stroke-border" strokeWidth="2" strokeDasharray="5 4" />
  <text x="262" y="166" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-muted-foreground">도</text>
  <rect x="286" y="138" width="40" height="44" rx="6" className="fill-background stroke-border" strokeWidth="2" strokeDasharray="5 4" />
  <text x="306" y="166" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-muted-foreground">답</text>
  <rect x="330" y="138" width="40" height="44" rx="6" className="fill-card stroke-border" strokeWidth="2" />
  <text x="350" y="166" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-foreground">사</text>
  <rect x="374" y="138" width="40" height="44" rx="6" className="fill-card stroke-border" strokeWidth="2" />
  <text x="394" y="166" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-foreground">도</text>
  <rect x="418" y="138" width="40" height="44" rx="6" className="fill-card stroke-border" strokeWidth="2" />
  <text x="438" y="166" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-foreground">답</text>
  <text x="22" y="234" textAnchor="start" fontSize="17" fontWeight="700" className="fill-foreground">clear_tool: 오래된 도구 결과만 지움</text>
  <rect x="22" y="246" width="40" height="44" rx="6" className="fill-card stroke-border" strokeWidth="2" />
  <text x="42" y="274" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-foreground">시</text>
  <rect x="66" y="246" width="40" height="44" rx="6" className="fill-card stroke-border" strokeWidth="2" />
  <text x="86" y="274" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-foreground">사</text>
  <rect x="110" y="260" width="40" height="16" rx="6" className="fill-muted stroke-border" strokeWidth="2" strokeDasharray="5 4" />
  <rect x="154" y="246" width="40" height="44" rx="6" className="fill-card stroke-border" strokeWidth="2" />
  <text x="174" y="274" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-foreground">답</text>
  <rect x="198" y="246" width="40" height="44" rx="6" className="fill-card stroke-border" strokeWidth="2" />
  <text x="218" y="274" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-foreground">사</text>
  <rect x="242" y="260" width="40" height="16" rx="6" className="fill-muted stroke-border" strokeWidth="2" strokeDasharray="5 4" />
  <rect x="286" y="246" width="40" height="44" rx="6" className="fill-card stroke-border" strokeWidth="2" />
  <text x="306" y="274" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-foreground">답</text>
  <rect x="330" y="246" width="40" height="44" rx="6" className="fill-card stroke-border" strokeWidth="2" />
  <text x="350" y="274" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-foreground">사</text>
  <rect x="374" y="246" width="40" height="44" rx="6" className="fill-card stroke-border" strokeWidth="2" />
  <text x="394" y="274" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-foreground">도</text>
  <rect x="418" y="246" width="40" height="44" rx="6" className="fill-card stroke-border" strokeWidth="2" />
  <text x="438" y="274" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-foreground">답</text>
  <text x="22" y="342" textAnchor="start" fontSize="17" fontWeight="700" className="fill-foreground">summarize: 앞쪽을 요약 하나로</text>
  <rect x="22" y="354" width="40" height="44" rx="6" className="fill-card stroke-border" strokeWidth="2" />
  <text x="42" y="382" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-foreground">시</text>
  <rect x="66" y="354" width="260" height="44" rx="6" className="fill-accent stroke-primary" strokeWidth="2" />
  <text x="196" y="382" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-accent-foreground">요약 메시지 1개</text>
  <rect x="330" y="354" width="40" height="44" rx="6" className="fill-card stroke-border" strokeWidth="2" />
  <text x="350" y="382" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-foreground">사</text>
  <rect x="374" y="354" width="40" height="44" rx="6" className="fill-card stroke-border" strokeWidth="2" />
  <text x="394" y="382" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-foreground">도</text>
  <rect x="418" y="354" width="40" height="44" rx="6" className="fill-card stroke-border" strokeWidth="2" />
  <text x="438" y="382" textAnchor="middle" fontSize="17" fontWeight="700" className="fill-foreground">답</text>
  <text x="22" y="436" textAnchor="start" fontSize="16" className="fill-muted-foreground">시: 시스템 · 사: 사용자 · 도: 도구 결과 · 답: 모델 응답</text>
</svg>
<figcaption>네 전략은 같은 이력에서 모델에 보내는 부분만 다릅니다. 흐린 칸은 보내지 않는 메시지입니다.</figcaption>
</figure>

전략은 이력을 고치지 않습니다. 대화 이력 전체는 그대로 쌓고, 모델을 부르기 직전에 "이번에 무엇을 보낼지"만 정하는 함수로 만들었습니다.

```python
CLEARED = "[이전 도구 결과 생략]"


def user_turn_starts(msgs):
    return [i for i, m in enumerate(msgs) if m["role"] == "user" and not m.get("_summary")]


def full(history, **_):
    return list(history)


def last_n(history, n=8, **_):
    starts = user_turn_starts(history)
    if len(starts) <= n:
        return list(history)
    return [history[0]] + history[starts[-n]:]


def clear_tool(history, k=3, **_):
    tool_idx = [i for i, m in enumerate(history) if m["role"] == "tool"]
    old = set(tool_idx[:-k]) if k else set(tool_idx)
    return [dict(m, content=CLEARED) if i in old else m for i, m in enumerate(history)]
```

`summarize`는 요약문을 상태로 들고 있어야 해서 세션 실행기 안에 있습니다. 아래 요약 절에서 따로 봅니다.

## 실습 준비

실습 환경은 macOS(Apple Silicon)의 Docker Desktop입니다. 다른 실습과 Docker를 함께 쓰고 있어서 이 실습의 컨테이너 두 개는 합쳐서 메모리 5GB, CPU 3개를 넘지 않게 상한을 걸었습니다. Docker Desktop 안에서는 Mac의 GPU를 쓰지 못하므로 모든 추론은 CPU로 합니다.

```yaml
name: actx
services:
  ollama:
    image: ollama/ollama:0.34.4
    environment:
      OLLAMA_KEEP_ALIVE: "-1"
      OLLAMA_NUM_PARALLEL: "1"
      LLAMA_ARG_CACHE_RAM: "256"
      OLLAMA_DEBUG: "${OLLAMA_DEBUG:-0}"
    volumes:
      - ollama:/root/.ollama
    ports: ["127.0.0.1:7570:11434"]
    mem_limit: 4500m
    cpus: 2.75
  client:
    image: python:3.12-slim
    command: ["sh", "-c", "pip install -q --disable-pip-version-check --root-user-action=ignore -r requirements.txt && exec sleep infinity"]
    environment:
      OLLAMA_URL: http://ollama:11434
      PIP_CACHE_DIR: /pip-cache
      PYTHONUNBUFFERED: "1"
    volumes:
      - .:/app
      - pip:/pip-cache
    working_dir: /app
    mem_limit: 500m
    cpus: 0.25
volumes:
  ollama: {}
  pip: {}
```

- `OLLAMA_NUM_PARALLEL=1`: 요청을 한 번에 하나만 처리합니다. 처리 중인 대화를 담는 자리(슬롯)가 하나뿐이라, 캐시 재사용을 해석하기 쉽습니다.
- `OLLAMA_KEEP_ALIVE=-1`: 모델을 메모리에서 내리지 않습니다.
- `LLAMA_ARG_CACHE_RAM=256`: 이 버전은 CPU에서 모델을 `llama-server` 프로세스로 띄우는데, 처리한 프롬프트를 쌓아 두는 캐시의 기본 상한이 8192MiB라서 컨테이너 메모리 상한을 넘길 수 있습니다. 256MiB로 낮췄습니다.
- `OLLAMA_DEBUG`: 평소에는 끄고, 마지막 실습에서 이력을 잘라 내는 로그를 볼 때만 켭니다.
- `client`: 세션을 돌리고 채점하는 Python 컨테이너입니다. 패키지는 `httpx==0.28.1`, `jsonschema==4.26.0`만 씁니다. `ollama` 파이썬 패키지 0.6.3의 응답 타입에는 `prompt_eval_cached_count` 필드가 없고 `chat()`에 `truncate` 인자도 없어서, `/api/chat`에 JSON을 직접 보내고 응답 JSON을 통째로 저장했습니다.

명령은 모두 실습 디렉터리에서 실행합니다. compose 파일에 `name: actx`가 있어 프로젝트 이름은 따로 주지 않고, 클라이언트 컨테이너에서 Python을 실행하는 함수 하나를 정의해 둡니다.

```bash
docker compose up -d
docker compose exec ollama ollama pull qwen3:1.7b
lab() { docker compose exec -T client python "$@"; }
docker compose exec ollama ollama show qwen3:1.7b
```

```
  Model
    architecture        qwen3     
    parameters          2.0B      
    context length      40960     
    embedding length    2048      
    quantization        Q4_K_M    

  Capabilities
    completion     
    tools          
    thinking       
        levels     false, true    
        default    true           
```

모델은 `qwen3:1.7b`(Q4_K_M)이고, 모든 호출에 `think: false`를 줘서 생각 과정을 쓰지 않게 했습니다. 이 시리즈의 [도구 호출 정확도를 잰 글](/posts/agent-tool-calling)에서는 메모리가 작은 `qwen2.5:1.5b`를 기본 모델로 정했습니다. 그런데 [에이전트 루프의 멈춤 조건을 다룬 글](/posts/agent-loop-stop)의 과제에서 `qwen2.5:1.5b`는 45개 중 하나도 풀지 못했고 `qwen3:1.7b`는 40% 가까이 풀었습니다. 이 글은 토큰과 시간뿐 아니라 "무엇을 잊었는지"를 정답 수로 재야 하므로, 여러 턴을 따라가는 능력이 나은 `qwen3:1.7b`를 골랐습니다.

호출은 모두 같은 옵션으로 했습니다.

- `temperature: 0`, `seed: 42`: 같은 입력에 같은 출력이 나오게 하려는 설정입니다.
- `num_ctx: 8192`: 컨텍스트 길이입니다. 마지막 실습만 2048로 줄입니다.
- `num_predict: 256`: 한 응답의 최대 생성 토큰 수입니다. 요약 호출만 512입니다.
- `num_thread: 3`: 컨테이너 CPU 상한(2.75)에 맞췄습니다. 이유는 아래 속도 절에 있습니다.

```bash
docker compose exec ollama ollama ps
```

```
NAME          ID              SIZE      PROCESSOR    CONTEXT    UNTIL   
qwen3:1.7b    8f68893c685c    2.4 GB    100% CPU     8192       Forever    
```

`CONTEXT`가 요청에 준 8192로 잡혀 있습니다. 이 상태에서 Ollama 컨테이너의 메모리 사용량은 2.4GB 안팎이었습니다.

### 도구 네 개와 20단계 세션

모델이 사전 지식으로 답하지 못하게 이 사이트처럼 보이는 가짜 데이터(글 60개, 콘텐츠 12개)를 SQLite 파일 하나에 만들고, 도구 네 개를 붙였습니다. 데이터와 앞의 두 도구는 에이전트 루프 글의 실습 모듈을 그대로 가져왔고, 뒤의 두 도구를 이 글에서 추가했습니다.

- `search_articles(query)`: 제목에 검색어가 들어간 글의 slug와 제목 목록
- `get_like_count(slug)`: 글 하나의 좋아요 수
- `get_article_meta(slug)`: 글 하나의 제목, 태그, 작성일, 글자 수, 연결된 콘텐츠(`course_slug`)와 본문 앞부분. 오래된 도구 결과를 지우는 효과가 보이도록 일부러 길게 만들었고, 이 도구를 부른 한 턴이 이력에 420~470토큰을 더합니다.
- `list_courses(tag)`: 태그 하나에 연결된 콘텐츠 목록(제목, 가격, 수강생 수). 메시징 태그는 8개 콘텐츠를 돌려주고, 이 턴이 약 440토큰을 더합니다.

좋아요 수(100~999), 수강생 수(1000~9999), 글자 수(10000~29999), 가격(31000 이상)은 모두 서로 겹치지 않는 정수라서, 엉뚱한 값이 우연히 정답과 같아지는 일이 없습니다. 세션은 `session.jsonl`에 한 줄에 한 단계씩 적었습니다.

```bash
lab make_session.py
```

```
424e6f3264e4eedf0a2bd28f47f72f5c74c6d394686077afa26d333f38aa7963 site.db (sha256 of the SQL dump)
80ce8cbfd9ad8f98aced636a4a2646b5f60d2e19dad0c54a9d2a131f2f627b51 session.jsonl
steps 20, recall 7, tool 13
  step 14 <- step 13 (distance  1) answer 14646
  step 15 <- step 12 (distance  3) answer 821
  step 16 <- step  6 (distance 10) answer 908
  step 17 <- step  7 (distance 10) answer 24843
  step 18 <- step  4 (distance 14) answer 94000
  step 19 <- step  2 (distance 17) answer crs-02
  step 20 <- step  2 (distance 18) answer 20331
```

1~13단계는 도구를 불러야 하는 요청이고("get_like_count 도구로 mysql-06 글의 좋아요 수를 조회해 줘."), 14~20단계는 앞에서 본 값을 다시 묻는 확인 질문입니다("앞에서 확인한 mysql-06 글의 좋아요 수는 몇이었어? 도구를 다시 부르지 말고 앞에서 확인한 값으로 답해 줘."). `distance`는 원래 값을 얻은 단계와의 거리이고, 가까운 것(1, 3), 중간(10, 10, 14), 먼 것(17, 18)을 섞었습니다. 19단계의 `course_slug`, 17단계와 14단계의 글자 수처럼 원래 요청이 묻지 않아 모델의 답에는 나오지 않고 도구 결과 안에만 있는 값도 일부러 넣었습니다.

확인 질문 단계에서 모델이 도구를 부르면 실행하지 않고 "이 질문은 도구를 다시 부르지 않고 앞에서 확인한 값으로 답해야 합니다."를 도구 결과로 돌려줍니다. 채점은 정규식으로 합니다. 확인 질문은 답의 마지막 `답: <값>` 줄(없으면 답 전체)에서 마지막 숫자나 slug를 뽑아 정답과 같은지, 도구 단계는 첫 도구 호출의 이름과 핵심 인자가 맞는지만 봅니다. LLM으로 채점하지 않습니다.

세션을 도는 함수의 뼈대는 이렇습니다. 사용자 요청 하나를 이력에 넣고, 도구 호출이 없는 답이 나올 때까지(최대 네 번) 모델을 부릅니다. 모델을 부르기 직전마다 `view()`가 전략에 따라 보낼 메시지를 고릅니다.

```python
    for step in steps:
        n = step["step"]
        # summarize: compress before the new user turn when the last prompt was over the threshold
        ...
        history.append({"role": "user", "content": step["question"] + user_suffix})
        final, first_calls, attempts, err, sent = None, None, 0, None, []
        for call_no in range(1, max_calls + 1):
            sent = view()
            raw = client.chat(sent, tools)
            line = record(n, call_no, "agent", sent, raw)
            ...
            tcs = line["tool_calls"]
            ...
            if not tcs:
                final = line["content"]
                history.append({"role": "assistant", "content": final})
                break
            history.append({"role": "assistant", "content": line["content"], "tool_calls": tcs})
            for tc in tcs:
                fn = tc.get("function", {})
                if step["kind"] == "recall":
                    attempts += 1
                    content = BLOCKED
                else:
                    content = call_tool(fn.get("name", ""), fn.get("arguments", {}), ctx).content
                tm = {"role": "tool", "tool_name": fn.get("name", ""), "content": content}
                if tc.get("id"):
                    tm["tool_call_id"] = tc["id"]
                history.append(tm)
```

`record()`는 호출마다 `results.jsonl`에 한 줄을 씁니다. 전략, 반복 번호, 단계, 보낸 메시지 수, 그리고 응답의 지표 필드와 응답 JSON 전체가 들어갑니다. 이 글의 숫자는 모두 이 파일에서 다시 계산할 수 있습니다.

## 응답에서 읽는 세 숫자

Ollama `/api/chat` 응답에는 프롬프트에 관한 숫자가 세 개 있습니다.

- `prompt_eval_count`: 프롬프트의 입력 토큰 수입니다. Ollama 0.34.4에서는 캐시에서 꺼낸 토큰도 포함합니다. 소스의 CLI 출력 코드가 다시 계산한 토큰을 `PromptEvalCount - PromptEvalCachedCount`로 구합니다.
- `prompt_eval_cached_count`: 그중 캐시에서 읽은 토큰 수입니다. 앞선 요청과 앞부분(접두부)이 같으면 그만큼은 다시 계산하지 않습니다.
- `prompt_eval_duration`: 캐시에 없던 토큰을 처리하는 데 걸린 시간(나노초)입니다.

그래서 이 글에서 "다시 계산한 토큰"은 `prompt_eval_count - prompt_eval_cached_count`입니다. 문서에는 캐시 필드가 있다고 나오지만 실제 응답에 오는지는 버전마다 다를 수 있어 먼저 확인했습니다. 첫 실행부터 모든 응답에 `prompt_eval_cached_count`가 있었고, 캐시를 쓰지 않은 호출에서는 0으로 왔습니다.

## 먼저 속도부터: 실행 시간을 어림하고 범위 줄이기

처음 계획은 30단계 세션을 전략마다 세 번씩 돌리는 것이었습니다. CPU 추론이라 이 계획이 시간 안에 끝나는지부터 쟀습니다. 먼저 스레드 수입니다. 캐시가 없는 약 1,200토큰 프롬프트를 `num_thread` 2와 3으로 번갈아 두 번씩 보냈습니다.

```bash
lab speed.py 2,3,2,3
```

```
num_thread 2: prompt 1176 tokens (cached 0) 31.2s = 38 tok/s | gen 64 tokens 7.1s = 9.0 tok/s | load 21.3s
num_thread 3: prompt 1176 tokens (cached 0) 26.3s = 45 tok/s | gen 64 tokens 7.6s = 8.4 tok/s | load 6.2s
num_thread 2: prompt 1176 tokens (cached 0) 30.6s = 38 tok/s | gen 64 tokens 8.5s = 7.5 tok/s | load 5.9s
num_thread 3: prompt 1176 tokens (cached 0) 27.0s = 43 tok/s | gen 64 tokens 8.5s = 7.5 tok/s | load 7.6s
```

프롬프트 처리는 3스레드가 15% 남짓 빨랐고 생성 속도는 비슷했습니다. 이후 모든 호출은 3스레드로 했습니다. 초당 40토큰 남짓이면 4,000토큰짜리 프롬프트를 처음부터 계산하는 데 1분 반이 넘게 걸립니다.

다음은 대화가 길어질 때의 크기와 속도입니다. 처음 짠 30단계 세션(`session30.jsonl`)의 정답 경로(맞는 도구 호출과 짧은 고정 답)를 이력으로 만들고, 한 단계씩 늘려 가며 `num_predict: 1`로 보냈습니다. 뒤 호출은 앞 호출의 캐시를 쓰므로 단계마다 늘어난 토큰만 계산합니다.

```bash
lab gold.py session30.jsonl out/gold_sizes30.json | tee out/lab0_gold30.txt
```

```
step  1 tool   search_articles  prompt   772 (+ 772) cached 1  16.9s
step  2 tool   get_like_count   prompt   865 (+  93) cached 758   3.3s
step  3 tool   get_article_meta prompt  1334 (+ 469) cached 851  13.2s
...
step 10 tool   get_article_meta prompt  3017 (+ 453) cached 2550  19.3s
...
step 27 tool   get_article_meta prompt  6821 (+ 456) cached 6351  33.4s
step 28 tool   get_like_count   prompt  6911 (+  90) cached 6807  10.6s
step 29 recall                  prompt  6976 (+  65) cached 6897   8.4s
step 30 recall                  prompt  7044 (+  68) cached 6962   8.4s
```

두 가지가 보입니다.

- 프롬프트는 단계마다 거의 일정하게 늘었습니다. 도구 정의와 시스템 메시지가 약 670토큰이고, 좋아요 수 조회 한 턴이 약 90토큰, 메타 정보 조회 한 턴이 약 450토큰을 더합니다. 이력을 모두 유지하면 프롬프트는 단계 수에 비례해 커지고, 한 턴의 크기는 도구 결과 길이가 정합니다.
- 같은 크기를 더해도 뒤로 갈수록 오래 걸렸습니다. 약 90토큰을 더하는 데 2단계(865토큰)에서는 3.3초, 28단계(6,911토큰)에서는 10.6초가 걸렸습니다. 새 토큰 하나를 처리할 때 앞의 모든 토큰을 함께 봐야 하므로, 캐시를 써도 긴 대화의 단계는 비쌉니다.

이 크기와 30단계 세션의 앞 5단계를 실제로 세 번 돌린 속도로 `plan.py`가 전체 시간을 어림했습니다.

```bash
lab lab.py session full --session session30.jsonl --runs 1,2,3 --steps 5 --label speed5 --out results_lab0.jsonl
lab plan.py
```

```
prompt eval: 42 tok/s at 1k context, 11 tok/s at 6k
generation : 9.9 tok/s at 1k context, 4.0 tok/s at 6k
full       one session  26.4 min x 3 =   79.3 min
last_n     one session  33.9 min x 3 =  101.7 min
clear_tool one session  24.4 min x 3 =   73.3 min
summarize  one session  25.5 min x 3 =   76.5 min
total 331 min (5.5 h) for 30 steps
```

5시간 반은 이 실습에 잡은 3시간을 넘습니다. 실습 전에 정해 둔 순서대로 줄였습니다. 실습 3, 4의 반복을 두 번으로 줄여도 4.1시간이라, 세션을 20단계로 줄이고 확인 질문의 거리 구성(가까움, 중간, 멂)은 유지했습니다. 20단계 기준 어림은 2.5시간이었습니다. 실제로는 난도를 맞추면서(다음 절) 모델의 답이 훨씬 짧아져 한 세션이 3~8분에 끝났고, 그래서 반복은 다시 전략마다 세 번으로 돌렸습니다. 어림이 크게 빗나간 것은 앞 5단계에서 모델이 목록 조회 결과를 JSON 그대로 256토큰 가득 옮겨 적었기 때문입니다.

이 측정은 다음 절에서 시스템 메시지와 요청 문장을 고치기 전에 했습니다. 앞 5단계 세 번 반복은 응답 문자열과 토큰 수가 모두 같았습니다. 다른 것은 도구 호출마다 붙는 임의의 `id`뿐이었습니다.

## 세션 난도 맞추기

처음 짠 20단계 세션은 확인 질문이 중간중간 섞여 있었습니다(5, 8, 12, 16, 18, 19, 20단계). `full`로 한 번 돌려 보니 5단계 확인 질문 뒤로 모델이 도구를 거의 부르지 않았습니다.

```
step  5.1 sent  18 msgs prompt=2094 cached=1378 eval=4 18.3s
  recall step 5 expected 794 got 784 -> WRONG (value in prompt: True)
step  6.1 sent  20 msgs prompt=2126 cached=2081 eval=17 3.5s
step  7.1 sent  22 msgs prompt=2166 cached=2113 eval=10 3.2s
...
== full_r1: tool steps 4/13, recall 4/7, 238.8s
```

6단계("mysql-05 글의 메타 정보를 조회해서 작성일을 알려 줘")의 답은 도구 호출 없이 `{"published_at": "2026-03-06"}`이었습니다. 도구 결과를 흉내 낸 글자를 직접 쓴 것이고, 날짜는 3단계에서 본 redis-03 글의 작성일을 그대로 가져온 값입니다(mysql-05의 실제 작성일은 2026-07-05). 도구 단계 13개 중 4개만 맞게 불렀으니, 이대로는 확인 질문이 틀려도 이력 관리 때문인지 애초에 값을 얻지 못해서인지 가를 수 없습니다.

세 가지를 차례로 바꿔 봤습니다.

- 시스템 메시지에 "새 값을 물으면 반드시 도구를 부른다", "도구 결과를 JSON 그대로 옮겨 적지 말라", "도구를 부르지 말라고 한 질문에만 도구 없이 답하고 그다음 요청부터는 다시 도구를 부른다"를 넣었습니다.
- 도구 단계 요청에 도구 이름을 넣고("get_like_count 도구로 ...를 조회해 줘"), 목록 조회는 목록 대신 개수만 묻게 했습니다.
- 같은 세션을 `qwen2.5:1.5b`로도 돌려 봤습니다.

앞의 두 가지로 목록을 JSON으로 길게 옮겨 적는 일은 사라졌습니다(4단계 답이 256토큰에서 5토큰으로). 하지만 확인 질문에 도구 없이 답한 바로 다음 단계부터 도구를 부르지 않는 현상은 그대로였고, 6단계와 9단계의 작성일은 둘 다 `답: 2026-03-06`이었습니다. `qwen2.5:1.5b`도 6단계부터 똑같았습니다. 그래서 세션 순서를 바꿨습니다. 도구 단계 13개를 먼저 두고 확인 질문 7개를 끝에 모았습니다. 거리 구성은 위의 출력처럼 다시 맞췄습니다. 실제 대화에서는 확인 질문이 중간에 섞이겠지만, 이 글이 재려는 것은 "앞에서 얻은 값이 이력에 남아 있는가"이므로 값을 얻는 단계가 망가지지 않는 쪽을 택했습니다. 시스템 메시지와 요청 문장은 이 절에서 바꾼 것을 끝까지 그대로 썼습니다.

## 기준선: 이력 없이 물으면 맞히는가

확인 질문 일곱 개를 이력 없이, 그 값을 얻은 원래 요청과 도구 결과 하나만 넣어 물었습니다. 모델이 계산이나 값 찾기 자체를 못 하는 질문을 먼저 걸러 내기 위해서입니다.

```bash
lab lab.py baseline --runs 1,2,3
```

```
run 1 step 14 (from step 13) expected  14646 got  14646 OK
run 1 step 15 (from step 12) expected    821 got    821 OK
run 1 step 16 (from step  6) expected    908 got    908 OK
run 1 step 17 (from step  7) expected  24843 got  24843 OK
run 1 step 18 (from step  4) expected  94000 got  94000 OK
run 1 step 19 (from step  2) expected crs-02 got crs-02 OK
run 1 step 20 (from step  2) expected  20331 got  20331 OK
run 1: 7/7
...
run 3: 7/7
```

세 번 모두 7개를 맞혔습니다. 이 모델은 필요한 값이 눈앞에 있으면 일곱 질문을 모두 풉니다. 아래에서 틀리는 질문은 모델이 계산을 못 해서가 아니라 이력 안에서 값을 찾지 못했거나 값이 이력에 없어서 틀린 것입니다.

## 전체 이력 유지: 토큰은 쌓이고 정답은 절반

`full`로 20단계 세션을 세 번 돌렸습니다. 나머지 전략도 같은 명령에 전략 이름만 바꿔 돌립니다.

```bash
lab lab.py session full --runs 1,2,3
lab show.py steps full_r1
```

```
[full_r1] step: calls | prompt at first call (cached) | recomputed in step | model time
step  1: 2 |   706 (  669) |   133 |   3.5s
step  2: 2 |   838 (  693) |   575 |  10.1s
step  3: 2 |  1303 (  825) |   537 |  10.2s
step  4: 2 |  1417 ( 1290) |   525 |  11.2s
step  5: 2 |  1850 ( 1404) |   828 |  17.4s
step  6: 2 |  2272 ( 1837) |   492 |  13.4s
step  7: 2 |  2366 ( 2259) |   522 |  14.0s
step  8: 2 |  2843 ( 2353) |   884 |  21.3s
step  9: 2 |  3292 ( 2830) |   684 |  18.7s
step 10: 2 |  3552 ( 3279) |   681 |  21.2s
step 11: 2 |  3998 ( 3539) |   558 |  18.0s
step 12: 2 |  4128 ( 3985) |   200 |  10.5s
step 13: 1 |  4222 ( 4115) |   107 |   5.4s
step 14: 1 |  4288 ( 4209) |    79 |   3.8s
step 15: 1 |  4346 ( 4275) |    71 |   4.6s
step 16: 1 |  4408 ( 4333) |    75 |   3.4s
step 17: 1 |  4464 ( 4395) |    69 |   3.6s
step 18: 1 |  4530 ( 4451) |    79 |   4.1s
step 19: 1 |  4594 ( 4517) |    77 |   4.0s
step 20: 1 |  4649 ( 4581) |    68 |   4.1s
```

열은 왼쪽부터 그 단계의 모델 호출 수, 첫 호출의 프롬프트 토큰(괄호 안은 그중 캐시에서 읽은 토큰), 그 단계의 호출들이 다시 계산한 토큰의 합, 모델 처리 시간입니다. 도구 단계는 보통 도구를 부르는 호출과 결과를 읽고 답하는 호출, 두 번입니다.

<figure>
<svg viewBox="0 -19 480 540" role="img" aria-label="full 전략 1회차에서 단계마다 모델에 보낸 프롬프트 토큰. 재사용한 토큰과 다시 계산한 토큰으로 나눈 막대. 프롬프트는 단계마다 늘어 20단계에서 4천6백 토큰을 넘고, 다시 계산한 토큰은 단계마다 수십에서 수백 토큰이다" className="diagram h-auto w-full">
  <text x="64" y="16" textAnchor="middle" fontSize="15" className="fill-muted-foreground">0</text>
  <text x="254" y="16" textAnchor="middle" fontSize="15" className="fill-muted-foreground">2,500</text>
  <text x="444" y="16" textAnchor="end" fontSize="15" className="fill-muted-foreground">5,000</text>
  <text x="54" y="43" textAnchor="end" fontSize="15" className="fill-muted-foreground">1</text>
  <rect x="64" y="30" width="51" height="16" className="fill-card stroke-border" strokeWidth="1" />
  <rect x="115" y="30" width="10" height="16" className="fill-primary" />
  <text x="54" y="65" textAnchor="end" fontSize="15" className="fill-muted-foreground">2</text>
  <rect x="64" y="52" width="52" height="16" className="fill-card stroke-border" strokeWidth="1" />
  <rect x="116" y="52" width="44" height="16" className="fill-primary" />
  <text x="54" y="87" textAnchor="end" fontSize="15" className="fill-muted-foreground">3</text>
  <rect x="64" y="74" width="62" height="16" className="fill-card stroke-border" strokeWidth="1" />
  <rect x="126" y="74" width="41" height="16" className="fill-primary" />
  <text x="54" y="109" textAnchor="end" fontSize="15" className="fill-muted-foreground">4</text>
  <rect x="64" y="96" width="98" height="16" className="fill-card stroke-border" strokeWidth="1" />
  <rect x="162" y="96" width="40" height="16" className="fill-primary" />
  <text x="54" y="131" textAnchor="end" fontSize="15" className="fill-muted-foreground">5</text>
  <rect x="64" y="118" width="106" height="16" className="fill-card stroke-border" strokeWidth="1" />
  <rect x="170" y="118" width="63" height="16" className="fill-primary" />
  <text x="54" y="153" textAnchor="end" fontSize="15" className="fill-muted-foreground">6</text>
  <rect x="64" y="140" width="139" height="16" className="fill-card stroke-border" strokeWidth="1" />
  <rect x="203" y="140" width="37" height="16" className="fill-primary" />
  <text x="54" y="175" textAnchor="end" fontSize="15" className="fill-muted-foreground">7</text>
  <rect x="64" y="162" width="171" height="16" className="fill-card stroke-border" strokeWidth="1" />
  <rect x="235" y="162" width="40" height="16" className="fill-primary" />
  <text x="54" y="197" textAnchor="end" fontSize="15" className="fill-muted-foreground">8</text>
  <rect x="64" y="184" width="179" height="16" className="fill-card stroke-border" strokeWidth="1" />
  <rect x="243" y="184" width="67" height="16" className="fill-primary" />
  <text x="54" y="219" textAnchor="end" fontSize="15" className="fill-muted-foreground">9</text>
  <rect x="64" y="206" width="215" height="16" className="fill-card stroke-border" strokeWidth="1" />
  <rect x="279" y="206" width="52" height="16" className="fill-primary" />
  <text x="54" y="241" textAnchor="end" fontSize="15" className="fill-muted-foreground">10</text>
  <rect x="64" y="228" width="249" height="16" className="fill-card stroke-border" strokeWidth="1" />
  <rect x="313" y="228" width="52" height="16" className="fill-primary" />
  <text x="54" y="263" textAnchor="end" fontSize="15" className="fill-muted-foreground">11</text>
  <rect x="64" y="250" width="269" height="16" className="fill-card stroke-border" strokeWidth="1" />
  <rect x="333" y="250" width="42" height="16" className="fill-primary" />
  <text x="54" y="285" textAnchor="end" fontSize="15" className="fill-muted-foreground">12</text>
  <rect x="64" y="272" width="303" height="16" className="fill-card stroke-border" strokeWidth="1" />
  <rect x="367" y="272" width="15" height="16" className="fill-primary" />
  <text x="54" y="307" textAnchor="end" fontSize="15" className="fill-muted-foreground">13</text>
  <rect x="64" y="294" width="313" height="16" className="fill-card stroke-border" strokeWidth="1" />
  <rect x="377" y="294" width="8" height="16" className="fill-primary" />
  <text x="54" y="329" textAnchor="end" fontSize="15" className="fill-muted-foreground">14</text>
  <rect x="64" y="316" width="320" height="16" className="fill-card stroke-border" strokeWidth="1" />
  <rect x="384" y="316" width="6" height="16" className="fill-primary" />
  <text x="54" y="351" textAnchor="end" fontSize="15" className="fill-muted-foreground">15</text>
  <rect x="64" y="338" width="325" height="16" className="fill-card stroke-border" strokeWidth="1" />
  <rect x="389" y="338" width="5" height="16" className="fill-primary" />
  <text x="54" y="373" textAnchor="end" fontSize="15" className="fill-muted-foreground">16</text>
  <rect x="64" y="360" width="329" height="16" className="fill-card stroke-border" strokeWidth="1" />
  <rect x="393" y="360" width="6" height="16" className="fill-primary" />
  <text x="54" y="395" textAnchor="end" fontSize="15" className="fill-muted-foreground">17</text>
  <rect x="64" y="382" width="334" height="16" className="fill-card stroke-border" strokeWidth="1" />
  <rect x="398" y="382" width="5" height="16" className="fill-primary" />
  <text x="54" y="417" textAnchor="end" fontSize="15" className="fill-muted-foreground">18</text>
  <rect x="64" y="404" width="338" height="16" className="fill-card stroke-border" strokeWidth="1" />
  <rect x="402" y="404" width="6" height="16" className="fill-primary" />
  <text x="54" y="439" textAnchor="end" fontSize="15" className="fill-muted-foreground">19</text>
  <rect x="64" y="426" width="343" height="16" className="fill-card stroke-border" strokeWidth="1" />
  <rect x="407" y="426" width="6" height="16" className="fill-primary" />
  <text x="54" y="461" textAnchor="end" fontSize="15" className="fill-muted-foreground">20</text>
  <rect x="64" y="448" width="348" height="16" className="fill-card stroke-border" strokeWidth="1" />
  <rect x="412" y="448" width="5" height="16" className="fill-primary" />
  <rect x="64" y="484" width="18" height="14" className="fill-card stroke-border" strokeWidth="1" />
  <text x="90" y="497" textAnchor="start" fontSize="15" className="fill-foreground">재사용한 토큰</text>
  <rect x="234" y="484" width="18" height="14" className="fill-primary" />
  <text x="260" y="497" textAnchor="start" fontSize="15" className="fill-foreground">그 단계에서 다시 계산한 토큰</text>
  <text x="54" y="497" textAnchor="middle" fontSize="15" className="fill-muted-foreground"></text>
</svg>
<figcaption>full 전략 1회차. 막대 길이는 단계의 마지막 호출 프롬프트, 진한 부분은 그 단계의 호출들이 다시 계산한 토큰의 합입니다.</figcaption>
</figure>

프롬프트는 706토큰에서 4,649토큰까지 늘었습니다. 그래도 각 단계가 다시 계산한 토큰은 수십에서 수백 토큰이고, 처리 시간도 단계당 4~21초에 머물렀습니다. 앞부분이 그대로인 동안은 그만큼을 캐시에서 읽기 때문입니다. 세 번 반복은 단계별 토큰 수와 답이 모두 같았고, 확인 질문은 세 번 모두 7개 중 3개를 맞혔습니다.

```bash
lab show.py recall full_r1
```

```
[full_r1] recall 3/7
  step 14 distance  1: expected  14646 got    17927 WRONG (value in prompt no)
  step 15 distance  3: expected    821 got      821 OK    (value in prompt yes)
  step 16 distance 10: expected    908 got      811 WRONG (value in prompt yes)
  step 17 distance 10: expected  24843 got    14843 WRONG (value in prompt yes)
  step 18 distance 14: expected  94000 got    94000 OK    (value in prompt yes)
  step 19 distance 17: expected crs-02 got   crs-02 OK    (value in prompt yes)
  step 20 distance 18: expected  20331 got    17927 WRONG (value in prompt yes)
```

`value in prompt`는 정답 값이 그 호출에 보낸 메시지 안에 문자열로 있었는지입니다. 틀린 네 개를 나눠 보면 이렇습니다.

- 14단계는 값이 이력에 없었습니다. 13단계("es-15 글의 메타 정보를 조회해서 작성일을 알려 줘")에서 모델이 도구를 부르지 않고 `답: 2026-03-13`이라고 지어냈기 때문입니다. 13단계는 프롬프트가 4,200토큰을 넘은 시점이었고, 세 번 모두 같았습니다.
- 16, 17, 20단계는 값이 이력 안에 있었는데 틀렸습니다. 16단계의 811은 docker-11의 좋아요 수, 14단계와 20단계의 17927은 10단계에서 본 k8s-13의 글자 수입니다. 17단계의 14843은 정답 24843과 한 자리만 다릅니다. 모르는 값을 지어낸 것이 아니라 이력에 있는 비슷한 종류의 값 가운데 엉뚱한 것을 집었습니다.

이력이 길어질수록 그 안의 정보를 정확히 꺼내는 능력이 떨어진다는 현상을 Anthropic의 컨텍스트 엔지니어링 글은 context rot이라고 부릅니다. 이 실습의 4,600토큰은 모델이 지원하는 길이(40,960)의 9분의 1 정도인데도, 1.7B 모델에서는 같은 종류의 숫자가 여러 개 쌓인 것만으로 이런 혼동이 나왔습니다.

## 다시 계산하는 토큰은 어디서 생기나

위 표를 다시 보면 캐시가 완벽하지 않습니다. 2단계 첫 호출의 프롬프트는 838토큰인데 캐시에서 읽은 것은 693토큰입니다. 1단계 마지막 호출의 프롬프트가 798토큰이었으니, 그대로 이어졌다면 790토큰 남짓을 재사용했어야 합니다. 새 사용자 턴이 시작될 때마다 직전 턴 전체를 다시 계산하고 있었습니다.

원인은 모델의 대화 템플릿(메시지 목록을 모델 입력 문자열로 바꾸는 규칙)에 있었습니다.

```bash
docker compose exec ollama ollama show qwen3:1.7b --template
```

```
{{- range $i, $_ := .Messages }}
{{- $last := eq (len (slice $.Messages $i)) 1 -}}
{{- if eq .Role "user" }}<|im_start|>user
{{ .Content }}
{{- if and $.IsThinkSet (eq $i $lastUserIdx) }}
   {{- if $.Think -}}
      {{- " "}}/think
   {{- else -}}
      {{- " "}}/no_think
   {{- end -}}
{{- end }}<|im_end|>
...
{{- if and (ne .Role "assistant") $last }}<|im_start|>assistant
{{ if and $.IsThinkSet (not $.Think) -}}
<think>

</think>

{{ end -}}
```

요청에 `think` 값을 주면(`IsThinkSet`) 템플릿은 마지막 사용자 메시지 끝에만 ` /no_think`를 붙입니다. 다음 턴이 오면 마지막 사용자 메시지가 바뀌므로, 직전 사용자 메시지의 ` /no_think`는 사라지고 새 메시지에 붙습니다. 문자열로 보면 직전 사용자 메시지부터 달라진 것이라, 그 지점부터 끝까지 다시 계산합니다. 같은 템플릿은 응답을 만들 자리에 빈 `<think></think>` 블록을 넣는데, 응답을 이력에 넣고 다시 그리면 이 블록이 빠집니다. 그래서 한 턴 안에서도 직전 응답 앞의 몇 토큰부터 다시 계산합니다. 1단계 두 번째 호출이 캐시에서 읽은 토큰이 첫 호출의 프롬프트(706토큰)보다 4토큰 적은 702토큰이었던 것이 그 흔적입니다.

`full`에서 이 비용은 직전 턴 하나만큼이라 크지 않았습니다. 도구 결과가 긴 턴 다음에 커지고(8단계 884토큰), 짧은 확인 질문만 이어지는 14단계 이후에는 70~80토큰입니다. 생각 모드를 지원하지 않는 `qwen2.5:1.5b`로 난도 맞추기 때 세션 앞부분을 돌렸을 때는 새 턴에서도 직전 프롬프트를 거의 다 재사용했습니다(2단계 첫 호출 792토큰 중 761토큰). 캐시가 어디서 끊기는지는 이력 관리 전략만이 아니라 모델 템플릿에도 달려 있으므로, 쓰는 모델에서 `prompt_eval_cached_count`를 직접 보는 것이 확실합니다.

## 최근 N턴만 남기기와 오래된 도구 결과만 지우기

`last_n`과 `clear_tool`을 두 번씩 돌렸습니다. 두 번의 결과는 단계별 토큰 수와 답이 모두 같았고, 다시 계산한 토큰의 합만 수십 토큰 차이가 났습니다.

```bash
lab lab.py session last_n --n 8 --runs 1,2
lab lab.py session clear_tool --k 3 --runs 1,2
lab show.py steps last_n_r1
```

```
[last_n_r1] step: calls | prompt at first call (cached) | recomputed in step | model time
step  1: 2 |   706 (  693) |   109 |   2.7s
...
step  8: 2 |  2843 ( 2353) |   884 |  21.8s
step  9: 2 |  3162 (  669) |  2715 |  51.3s
step 10: 1 |  2952 (  670) |  2282 |  40.6s
step 11: 3 |  2916 (  669) |  2421 |  47.7s
step 12: 2 |  2666 (  669) |  2054 |  36.9s
step 13: 2 |  2334 (  670) |  2069 |  37.6s
step 14: 1 |  2711 (  670) |  2041 |  37.6s
step 15: 1 |  2313 (  669) |  1644 |  26.4s
step 16: 1 |  1921 (  674) |  1247 |  20.1s
step 17: 1 |  1704 (  669) |  1035 |  15.3s
step 18: 1 |  1713 (  669) |  1044 |  15.6s
step 19: 1 |  1577 (  669) |   908 |  13.3s
step 20: 1 |  1544 (  670) |   874 |  12.8s
```

8단계까지는 `full`과 같습니다. 9단계에서 사용자 턴이 9개가 되어 맨 앞 턴을 빼기 시작하자, 캐시에서 읽은 토큰이 669~674로 떨어졌습니다. 시스템 메시지와 도구 정의까지만 같고 그 뒤는 매 단계 처음부터 다시 계산한다는 뜻입니다. 앞 턴 하나를 빼면 그 뒤의 모든 메시지가 앞으로 당겨지므로, 시스템 메시지 뒤의 문자열이 직전 요청과 처음부터 달라집니다.

그 결과 프롬프트는 `full`보다 작은데 처리 시간은 더 길었습니다. 20단계에서 `full`의 프롬프트는 4,649토큰, `last_n`은 1,544토큰입니다. 그런데 9~14단계에서 `last_n`은 단계마다 2,000~2,700토큰을 다시 계산했고 단계당 37~51초가 걸렸습니다. 같은 구간에서 `full`은 200~880토큰, 10~21초였습니다. 세션 전체의 모델 처리 시간은 `full` 3.4분, `last_n` 7.6분입니다. 이력을 줄이면 보내는 토큰은 줄지만, 접두부가 매번 바뀌면 캐시를 잃어 오히려 느려질 수 있습니다.

`clear_tool`은 그 중간이었습니다.

```bash
lab show.py steps clear_tool_r1
```

```
[clear_tool_r1] step: calls | prompt at first call (cached) | recomputed in step | model time
step  1: 2 |   706 (  669) |   133 |   3.1s
step  2: 2 |   838 (  693) |   575 |   9.6s
step  3: 2 |  1303 (  825) |   537 |   9.7s
step  4: 2 |  1417 ( 1290) |  1163 |  18.8s
step  5: 2 |  1799 ( 1353) |  1433 |  26.6s
step  6: 2 |  1839 ( 1404) |  1425 |  23.8s
step  7: 2 |  1923 ( 1816) |  1096 |  20.1s
step  8: 2 |  2048 ( 1558) |  1518 |  26.4s
step  9: 2 |  2162 ( 1701) |  1668 |  30.1s
step 10: 2 |  2413 ( 2140) |  1474 |  28.0s
step 11: 2 |  2492 ( 2033) |  1341 |  25.0s
step 12: 2 |  2274 ( 2131) |   837 |  17.0s
step 13: 1 |  2194 ( 2087) |   107 |   4.3s
step 14: 1 |  2272 ( 2181) |    91 |   2.8s
...
step 20: 1 |  2656 ( 2587) |    69 |   2.8s
```

도구 결과가 네 번째로 쌓이는 4단계부터 12단계까지, 새 도구 결과가 들어올 때마다 가장 오래된 원본 결과 하나가 `[이전 도구 결과 생략]`으로 바뀝니다. 바뀐 메시지가 이력 중간에 있으므로 그 지점부터 끝까지 다시 계산했고, 이 구간에서 단계마다 1,100~1,700토큰을 다시 계산했습니다. 도구를 더 부르지 않는 13단계부터는 지울 결과가 없어 다시 `full`처럼 캐시를 탔습니다. 세션 전체 모델 처리 시간은 4.4분이었습니다.

Anthropic의 컨텍스트 엔지니어링 글은 오래된 도구 결과를 지우는 것을 가장 안전하고 가벼운 압축 방식(compaction) 가운데 하나로 소개합니다. 이 실습에서도 지운 결과는 대부분 다시 볼 일이 없는 본문 앞부분이었습니다. 다만 "지우는 시점"이 매 단계면 캐시를 매 단계 깨뜨립니다. 지울 결과를 몇 단계씩 모았다가 한 번에 지우면 캐시가 깨지는 횟수를 줄일 수 있습니다(이 실습에서는 재지 않았습니다).

### 무엇을 잊었나

```bash
lab show.py recall last_n_r1 clear_tool_r1
```

```
[last_n_r1] recall 2/7
  step 14 distance  1: expected  14646 got    14646 OK    (value in prompt yes)
  step 15 distance  3: expected    821 got      821 OK    (value in prompt yes)
  step 16 distance 10: expected    908 got      124 WRONG (value in prompt no)
  step 17 distance 10: expected  24843 got    14246 WRONG (value in prompt yes)
  step 18 distance 14: expected  94000 got   150000 WRONG (value in prompt no)
  step 19 distance 17: expected crs-02 got   crs-08 WRONG (value in prompt no)
  step 20 distance 18: expected  20331 got    14246 WRONG (value in prompt no)
[clear_tool_r1] recall 2/7
  step 14 distance  1: expected  14646 got    17927 WRONG (value in prompt no)
  step 15 distance  3: expected    821 got      821 OK    (value in prompt yes)
  step 16 distance 10: expected    908 got      908 OK    (value in prompt yes)
  step 17 distance 10: expected  24843 got       07 WRONG (value in prompt no)
  step 18 distance 14: expected  94000 got  150,000 WRONG (value in prompt no)
  step 19 distance 17: expected crs-02 got   crs-10 WRONG (value in prompt no)
  step 20 distance 18: expected  20331 got    17927 WRONG (value in prompt yes)
```

두 전략 모두 7개 중 2개였고, 두 번째 실행도 같았습니다. 잃는 방식은 달랐습니다.

- `last_n`은 거리로 잘랐습니다. 최근 8턴 안에 있는 14, 15단계만 맞히고, 거리 10 이상은 모두 틀렸습니다. 틀린 답은 남아 있는 이력에 없는 숫자(124, 150000)나 다른 콘텐츠의 slug였습니다. 값이 없으니 모델은 모른다고 하지 않고 그럴듯한 값을 만들었습니다. 17단계에 `value in prompt yes`가 찍힌 것은 정답 24843이 남은 이력에 있기는 했기 때문인데, 10단계에서 모델이 도구를 부르지 않고 k8s-13의 글자 수로 24843을 지어낸 답이었습니다. 정답 문자열이 남아 있어도 엉뚱한 글에 붙어 있었습니다.
- `clear_tool`은 값의 위치로 잘랐습니다. 16단계의 908은 6단계 도구 결과가 지워졌는데도 맞혔습니다. 6단계에서 모델이 `답: 908`이라고 쓴 답이 이력에 남아 있었기 때문입니다. 반대로 도구 결과 안에만 있던 값(17단계 글자 수, 18단계 가격, 19단계 `course_slug`)은 모두 틀렸습니다. 17단계에서는 남아 있는 같은 글의 작성일 `2026-02-07`을 답으로 냈습니다. 지운 도구 결과에서 살아남는 것은 모델이 답에 옮겨 적은 값뿐입니다.
- 14단계는 전략이 아니라 13단계 도구 호출에서 갈렸습니다. `clear_tool`도 `full`처럼 13단계에서 도구를 부르지 않고 작성일을 지어냈고(이번에는 `2026-04-15`), `last_n`은 13단계에서 도구를 제대로 불렀습니다. `last_n`이 도구를 부르지 않은 곳은 10단계였습니다. 프롬프트가 짧다고 도구를 더 잘 부른다고 말하기에는 사례가 적습니다.

## 요약으로 압축하기

`summarize`는 새 사용자 턴을 받기 직전에 직전 호출의 `prompt_eval_count`를 봅니다. 3,000을 넘었으면 최근 두 턴을 뺀 앞부분(앞선 요약이 있으면 그것까지)을 대화 기록 글로 바꿔 같은 모델에 요약을 시키고, 그 요약을 시스템 메시지 바로 뒤의 메시지 하나로 넣습니다.

```python
        if strategy == "summarize" and last_prompt > params.get("threshold", 4096):
            starts = [i for i in policies.user_turn_starts(history) if i >= cut]
            keep = params.get("keep_turns", 2)
            if len(starts) > keep:
                upto = starts[-keep]
                part = ([policies.summary_message(summary)] if summary else []) + history[cut:upto]
                sm = policies.summary_request(part, params.get("summary_version", 1))
                raw = client.chat(sm, None, {"num_predict": params.get("summary_predict", 512)})
                line = record(n, 0, "summary", sm, raw)
                summary = raw.get("message", {}).get("content", "").strip()
                ...
                cut = upto
```

요약 지시문은 실험 전에 정해 둔 것을 썼습니다. 지시문 다음에 대화 기록을 붙여 보냅니다.

```
아래는 사용자와 운영 도우미의 대화 기록이다. 나중에 사용자가 앞에서 본 값을 다시 물을 수 있으니, 사용자가 요청한 것과 도구가 돌려준 값을 요약하라. slug, course_slug, 좋아요 수, 글자 수, 작성일, 콘텐츠 제목과 가격, 수강생 수 같은 값은 빠짐없이 원래 값 그대로 적어라. 본문 내용은 적지 않는다. 글머리표 목록으로만 쓴다.
```

```bash
lab lab.py session summarize --threshold 3000 --runs 1,2
lab show.py summaries summarize_r1
```

```
--- summary before step 9 (covers 6 turns): prompt 1734, output 7 tokens, 37.5s
답: 908
--- summary before step 14 (covers 5 turns): prompt 1925, output 5 tokens, 44.3s
답: 2
```

요약이 두 번 일어났고, 결과는 `답: 908`과 `답: 2`였습니다. 대화 기록 속의 모든 답이 `답: <값>` 형식이다 보니, 모델은 기록을 요약하지 않고 마지막 요청에 답하는 쪽을 따라갔습니다. 6턴 분량의 값이 `908` 하나로 줄었고, 두 번째 요약은 그것마저 덮어썼습니다. 두 번 돌려 둘 다 같았고 확인 질문은 7개 중 2개(14, 15단계)만 맞혔습니다. 요약 뒤에 원래 메시지로 남은 최근 턴의 질문들입니다.

결과를 본 뒤 지시문을 한 번 고쳤습니다. 대화 기록을 앞에 두고 지시문을 뒤로 옮겼으며, "질문에 답하지 말고 `답:` 줄도 쓰지 않는다"를 넣었습니다. 아래부터는 이 두 번째 지시문의 결과입니다.

```
<대화 기록>
(대화 기록)
</대화 기록>

위는 사용자와 운영 도우미의 대화 기록이다. 이 기록을 요약하라. 질문에 답하지 말고 `답:` 줄도 쓰지 않는다. 도구 결과에 나온 slug, course_slug, 좋아요 수, 글자 수, 작성일, 콘텐츠 제목과 가격, 수강생 수는 빠짐없이 원래 값 그대로 적는다. 본문 내용은 적지 않는다. 글머리표 목록으로만 쓴다.
```

```bash
lab lab.py session summarize --threshold 3000 --summary-version 2 --label summarize2 --runs 1,2
lab show.py summaries summarize2_r1
```

```
--- summary before step 9 (covers 6 turns): prompt 1743, output 147 tokens, 54.5s
답:  
- kafka-01: 784  
- redis-03: 20331  
- mysql-06: 908  
- cours-01: 180000  
- cours-02: 147000  
- cours-03: 123000  
- cours-05: 94000  
- cours-06: 77000  
- cours-08: 51000  
- cours-09: 178000  
- cours-12: 118000
--- summary before step 14 (covers 5 turns): prompt 2074, output 198 tokens, 76.7s
- kafka-01: 784  
- redis-03: 20331  
- mysql-06: 908  
- cours-01: 180000  
- cours-02: 147000  
- cours-03: 123000  
- cours-05: 94000  
- cours-06: 77000  
- cours-08: 51000  
- cours-09: 178000  
- cours-12: 118000  
답:  
- grpc-09: 2026-02-07  
- 인프라: 10  
- 데이터베이스: 4  
- k8s-13: 17927
```

이번에는 값이 목록으로 남았습니다. 첫 요약은 여전히 빈 `답:` 줄로 시작했고, 두 번째 요약은 첫 요약을 그대로 옮긴 뒤 새 값을 덧붙였습니다. 남은 것과 빠진 것을 나누면 이렇습니다.

- 남은 것: 좋아요 수, 사용자가 물어서 모델이 답한 글자 수(redis-03의 20331, k8s-13의 17927), 콘텐츠 가격.
- 빠진 것: 각 값이 무엇이었는지를 알려 주는 이름입니다. `kafka-01: 784`는 좋아요 수인지 글자 수인지 적혀 있지 않습니다. 콘텐츠는 제목 없이 slug만 남았고, 그 slug도 `crs-05`가 `cours-05`로 바뀌었습니다.
- 아예 없는 것: 도구 결과 안에만 있던 값입니다. redis-03에 연결된 `course_slug`(crs-02), grpc-09의 글자 수(24843)는 요약에 없습니다. grpc-09는 사용자가 물은 작성일만 남았습니다.

```bash
lab show.py recall summarize2_r1
```

```
[summarize2_r1] recall 4/7
  step 14 distance  1: expected  14646 got    14646 OK    (value in prompt yes, in summary no)
  step 15 distance  3: expected    821 got      821 OK    (value in prompt yes, in summary no)
  step 16 distance 10: expected    908 got      908 OK    (value in prompt yes, in summary yes)
  step 17 distance 10: expected  24843 got       07 WRONG (value in prompt no, in summary no)
  step 18 distance 14: expected  94000 got   147000 WRONG (value in prompt yes, in summary yes)
  step 19 distance 17: expected crs-02 got cours-03 WRONG (value in prompt no, in summary no)
  step 20 distance 18: expected  20331 got    20331 OK    (value in prompt yes, in summary yes)
```

두 번째 실행도 요약문 두 개와 일곱 개의 답이 모두 같았습니다. 7개 중 4개로, 첫 지시문(2개)과 `full`(3개)보다 많이 맞혔습니다.

틀린 세 개는 두 갈래로 나뉩니다.

- 요약에서 빠져서 틀림: 17단계(grpc-09 글자 수)와 19단계(`course_slug`)입니다. 17단계는 `clear_tool`과 똑같이 남아 있던 작성일 `2026-02-07`을 답으로 냈고, 19단계는 요약에 있던 `cours-03`을 골랐습니다.
- 요약에는 있었지만 못 씀: 18단계입니다. 정답 94000은 요약에 `cours-05: 94000`으로 있었지만, 질문은 「메시지 큐 실무」라는 제목으로 물었고 요약에는 제목이 없습니다. 모델은 다른 콘텐츠의 가격 147000을 답했습니다. 값이 요약에 남아 있어도 질문이 가리키는 이름(제목)이 빠지면 모델은 그 값을 찾지 못했습니다.

비용도 따로 봐야 합니다. 요약 호출은 한 번에 1,700~2,100토큰을 읽고 150~200토큰을 생성해 55~88초가 걸렸습니다. 게다가 요약 직후의 첫 호출은 캐시에서 5토큰만 읽었습니다. 슬롯이 하나뿐이라 시스템 메시지가 다른 요약 요청이 끼어들면 대화의 캐시가 밀려나고, 요약을 넣은 새 프롬프트는 시스템 메시지와 도구 정의까지 처음부터 다시 계산합니다.

```bash
lab show.py steps summarize2_r1
```

```
[summarize2_r1] step: calls | prompt at first call (cached) | recomputed in step | model time
...
step  8: 2 |  2843 ( 2353) |   884 |  38.7s
step  9: 2 |  1788 (    5) |  3743 |  47.4s + summary 54.5s
step 10: 2 |  2048 ( 1775) |   681 |  25.1s
...
step 13: 2 |  2789 ( 2682) |   512 |  26.3s
step 14: 1 |  1470 (    5) |  3534 |  34.3s + summary 76.6s
step 15: 1 |  1531 ( 1457) |    74 |   3.3s
```

요약이 일어난 9단계와 14단계는 요약 호출과 그 뒤의 재계산을 합쳐 각각 100초, 110초 남짓이 걸렸습니다. 요약 직후 프롬프트는 1,470~1,790토큰으로 줄고, 그 뒤로는 다시 캐시를 탑니다.

## num_ctx를 넘으면 Ollama는 무엇을 버리나

지금까지는 `num_ctx`가 8192라서 `full`의 가장 긴 프롬프트(4,649토큰)도 넉넉히 들어갔습니다. 이번에는 `full`을 `num_ctx: 2048`로 돌립니다. 한 번은 요청에 `"truncate": false`를 넣고, 한 번은 아무것도 넣지 않습니다. 잘라 내는 과정을 로그로 보려고 Ollama를 `OLLAMA_DEBUG=1`로 다시 띄웠습니다.

```bash
OLLAMA_DEBUG=1 docker compose up -d ollama
lab lab.py session full --num-ctx 2048 --truncate false --label ctx2048_notrunc --out results_lab5.jsonl
```

```
== ctx2048_notrunc_r1
step  1.1 sent   2 msgs prompt=706 cached=0 eval=21 27.6s call search_articles({"query": "Kafka"})
...
step  5.1 sent  18 msgs prompt=1851 cached=1404 eval=23 20.5s call get_article_meta({"slug": "mysql-05"})
step  5.2 ERROR 400 {"error":"{\"error\":{\"code\":400,\"message\":\"request (2229 tokens) exceeds the available context size (2048 tokens), try increasing it\",\"type\":\"exceed_context_size_error\",\"n_prompt_tokens\":2229,\"n_ctx\":2048}}"}
```

`truncate: false`에서는 메타 정보 조회 결과가 들어가 프롬프트가 2,229토큰이 되는 순간 HTTP 400과 함께 `exceeds the available context size` 오류가 돌아왔습니다. 클라이언트는 이력이 넘쳤다는 사실을 바로 압니다.

`truncate`를 빼고 보내면 다릅니다.

```bash
lab lab.py session full --num-ctx 2048 --label ctx2048 --out results_lab5.jsonl
docker compose logs ollama > out/lab5_ollama_debug.log
grep -E "truncating input messages|context shift" out/lab5_ollama_debug.log
```

```
step  5.1 sent  18 msgs prompt=1851 cached=1404 eval=23 14.2s call get_article_meta({"slug": "mysql-05"})
step  5.2 sent  20 msgs prompt=2041 cached=669 eval=14 35.7s
step  6.1 sent  22 msgs prompt=1686 cached=4 eval=23 29.3s call get_like_count({"slug": "mysql-06"})
...
step 19.1 sent  60 msgs prompt=2031 cached=669 eval=8 24.1s
step 20.1 sent  62 msgs prompt=1698 cached=669 eval=9 20.0s
== ctx2048_r1: tool steps 10/13, recall 2/7, 465.4s
```

```
level=DEBUG source=prompt.go:77 msg="truncating input messages which exceed context length" truncated=13
slot   operator(): id  0 | task 304 | slot context shift, n_keep = 4, n_left = 2043, n_discard = 1021
level=DEBUG source=prompt.go:77 msg="truncating input messages which exceed context length" truncated=14
...
```

오류는 한 번도 없었고 세션은 20단계까지 갔습니다. 클라이언트는 매번 이력 전체(마지막에는 62개 메시지)를 보냈지만, `prompt_eval_count`는 5단계부터 1,665~2,041 사이를 오갔습니다. 2048을 넘은 적이 없습니다. 기본값에서는 Ollama가 요청 안의 메시지를 버려 `num_ctx`에 맞춥니다. 응답 JSON에는 잘랐다는 표시가 없고, 서버 디버그 로그에만 남습니다.

Ollama 0.34.4 소스의 `chatPrompt`가 이 일을 합니다. 요청의 `truncate`가 없거나 `true`면, 메시지 목록의 앞에서부터 하나씩 빼 보면서 렌더링한 프롬프트의 토큰 수가 `num_ctx` 이하가 되는 첫 지점을 찾습니다. 이때 앞쪽에서 빼는 메시지 가운데 시스템 메시지는 따로 모아 다시 붙이고, 마지막 메시지는 무슨 일이 있어도 남깁니다. 로그의 `truncated=13`은 이름과 달리 "끝에서부터 남긴 메시지 수"입니다(`len(msgs[currMsgIdx:])`).

<figure>
<svg viewBox="0 -20 480 416" role="img" aria-label="num_ctx를 넘으면 Ollama는 시스템 메시지와 마지막 메시지는 남기고, 오래된 메시지부터 통째로 버려 프롬프트를 num_ctx 안에 맞춘다" className="diagram h-auto w-full">
  <rect x="90" y="0" width="300" 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">도구 정의 포함, 항상 남음</text>
  <rect x="90" y="98" width="300" height="82" rx="10" className="fill-background stroke-border" strokeWidth="2" />
  <text x="240" y="133" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-muted-foreground">오래된 메시지들</text>
  <text x="240" y="159" textAnchor="middle" fontSize="17" className="fill-muted-foreground">앞에서부터 통째로 버림</text>
  <rect x="90" y="196" width="300" height="82" rx="10" className="fill-card stroke-border" strokeWidth="2" />
  <text x="240" y="231" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-foreground">최근 메시지들</text>
  <text x="240" y="257" textAnchor="middle" fontSize="17" className="fill-muted-foreground">num_ctx 안에 들어가는 만큼</text>
  <rect x="90" y="294" width="300" height="82" rx="10" className="fill-accent stroke-primary" strokeWidth="2" />
  <text x="240" y="329" textAnchor="middle" fontSize="19" fontWeight="700" className="fill-accent-foreground">마지막 메시지</text>
  <text x="240" y="355" textAnchor="middle" fontSize="17" className="fill-accent-foreground">항상 남음</text>
</svg>
<figcaption>잘라 내는 단위는 메시지입니다. 이 과정은 응답에 따로 표시되지 않습니다.</figcaption>
</figure>

로그를 호출 순서와 짝지어 보면, 남은 메시지가 어디서부터였는지와 확인 질문의 원래 값이 남아 있었는지를 알 수 있습니다.

```bash
lab lab5_kept.py
```

```
step  5.2 sent 20 msgs, kept system + last 13 (from turn  3) prompt=2041
step  6.1 sent 22 msgs, kept system + last 14 (from turn  3) prompt=1686
...
step 14.1 sent 50 msgs, kept system + last 22 (from turn  8) prompt=1784 | src step 13 value kept: no  -> WRONG (24843)
step 15.1 sent 52 msgs, kept system + last 24 (from turn  8) prompt=1845 | src step 12 value kept: yes -> OK (821)
step 16.1 sent 54 msgs, kept system + last 26 (from turn  8) prompt=1901 | src step  6 value kept: no  -> WRONG (811)
step 17.1 sent 56 msgs, kept system + last 28 (from turn  8) prompt=1960 | src step  7 value kept: yes -> OK (24843)
step 18.1 sent 58 msgs, kept system + last 30 (from turn  9) prompt=2029 | src step  4 value kept: no  -> WRONG (118000)
step 19.1 sent 60 msgs, kept system + last 30 (from turn  9) prompt=2031 | src step  2 value kept: no  -> WRONG (crs-07)
step 20.1 sent 62 msgs, kept system + last 30 (from turn  9) prompt=1698 | src step  2 value kept: no  -> WRONG (24843)
```

`from turn`은 남은 메시지 중 가장 앞의 것이 속한 턴입니다. 확인 질문 단계에서 모델이 본 이력은 8~9턴부터였습니다. 사실상 `last_n`과 같은 일을 Ollama가 대신 한 셈이고, 결과도 7개 중 2개로 같았습니다. 그중 17단계는 운으로 맞혔습니다. 남은 이력 안의 24843은 grpc-09의 원래 도구 결과가 아니라, 10단계에서 모델이 도구를 부르지 않고 k8s-13의 글자 수라며 지어낸 값이었습니다. 모델은 14, 17, 20단계에 모두 24843을 답했습니다.

자를 때 생기는 일이 두 가지 더 있습니다.

- 잘라 낸 뒤의 첫 호출은 캐시를 거의 못 씁니다. 6.1단계는 캐시에서 4토큰만 읽었습니다. 앞쪽 메시지가 빠지면 `last_n`과 마찬가지로 시스템 메시지 뒤가 모두 새 문자열이 되기 때문입니다. 2048로 줄였는데도 세션 동안 다시 계산한 토큰은 20,052개로, `num_ctx` 8192의 `full`(7,244개)보다 2.8배 많았습니다.
- 메시지를 빼서 맞춘 프롬프트가 2,041토큰처럼 상한에 바짝 붙으면, 답을 생성하는 도중에 2048에 닿습니다. 이때 `llama-server`가 앞의 4토큰만 남기고 1,021토큰을 한 번 더 밀어 냈습니다(로그의 `slot context shift`, `n_discard = 1021`). 이 세션에서 세 번 일어났습니다. 그 호출의 답은 프롬프트의 절반을 잃은 상태에서 끝까지 생성된 것입니다.

그래서 `num_ctx`를 넘길 수 있는 에이전트라면 요청에 `"truncate": false`를 넣어 넘침을 오류로 받고, 무엇을 줄일지는 애플리케이션이 정하는 편이 낫습니다. 기본값에 맡기면 "전체 이력 유지"로 짠 코드가 실제로는 Ollama가 정한 최근 몇 턴만 보내는 코드가 됩니다. `prompt_eval_count`가 `num_ctx` 바로 아래에 계속 붙어 있으면 잘리고 있다고 의심할 만합니다. 검색 문단을 넣는 [RAG 프롬프트의 길이를 다룬 글](/posts/rag-context-size)에서는 메시지 하나짜리 프롬프트를 이 버전이 토큰 단위로 절반 남짓 잘라 낸 것을 봤는데, 메시지가 여러 개인 대화에서는 그보다 먼저 메시지 단위로 잘라 냈습니다.

## 네 전략을 한자리에서

```bash
lab show.py compare full_r1 full_r2 full_r3 last_n_r1 last_n_r2 clear_tool_r1 clear_tool_r2 \
  summarize_r1 summarize_r2 summarize2_r1 summarize2_r2
```

```
full_r1        last prompt 4649 | sent 95600 | recomputed  7244 | calls 32+0 | model 3.4 min (steps 1-8 101s) | tool 12/13 | recall 3/7
full_r2        last prompt 4649 | sent 95600 | recomputed  7220 | calls 32+0 | model 3.5 min (steps 1-8 102s) | tool 12/13 | recall 3/7
full_r3        last prompt 4649 | sent 95600 | recomputed  7220 | calls 32+0 | model 3.3 min (steps 1-8  95s) | tool 12/13 | recall 3/7
last_n_r1      last prompt 1544 | sent 71805 | recomputed 24806 | calls 33+0 | model 7.6 min (steps 1-8 100s) | tool 12/13 | recall 2/7
last_n_r2      last prompt 1544 | sent 71805 | recomputed 24824 | calls 33+0 | model 7.5 min (steps 1-8  96s) | tool 12/13 | recall 2/7
clear_tool_r1  last prompt 2656 | sent 62811 | recomputed 13860 | calls 32+0 | model 4.4 min (steps 1-8 138s) | tool 12/13 | recall 2/7
clear_tool_r2  last prompt 2656 | sent 62811 | recomputed 13836 | calls 32+0 | model 4.6 min (steps 1-8 140s) | tool 12/13 | recall 2/7
summarize_r1   last prompt 1649 | sent 69195 | recomputed 13805 | calls 34+2 | model 7.2 min (steps 1-8 164s) | tool 13/13 | recall 2/7
summarize_r2   last prompt 1649 | sent 69195 | recomputed 13829 | calls 34+2 | model 7.1 min (steps 1-8 160s) | tool 13/13 | recall 2/7
summarize2_r1  last prompt 1844 | sent 72227 | recomputed 14322 | calls 34+2 | model 8.3 min (steps 1-8 166s) | tool 13/13 | recall 4/7
summarize2_r2  last prompt 1844 | sent 72227 | recomputed 14322 | calls 34+2 | model 8.4 min (steps 1-8 160s) | tool 13/13 | recall 4/7
```

열은 마지막 단계의 프롬프트 토큰, 세션 동안 보낸 프롬프트 토큰의 합(`prompt_eval_count` 합), 실제로 다시 계산한 토큰의 합, 모델 호출 수(대화 호출 + 요약 호출), 모델 처리 시간, 도구 단계 정답, 확인 질문 정답입니다. `summarize`가 `summarize_r*`(첫 지시문), `summarize2_r*`(고친 지시문)입니다.

시간은 조심해서 읽어야 합니다. 1~8단계는 `clear_tool`을 뺀 모든 전략이 똑같은 메시지를 보내는데, `summarize` 실행 때만 이 구간이 160초 남짓으로 다른 실행(95~102초)보다 1.6배 걸렸습니다. 같은 Docker에서 다른 실습이 돌고 있었기 때문으로 보입니다. 그래서 비교는 다시 계산한 토큰을 기준으로 하고, 시간은 같은 조건에서 돈 `full`과 `last_n` 사이에서만 비교합니다.

- `full`: 보낸 토큰은 가장 많지만(95,600) 다시 계산한 토큰은 가장 적습니다(7,220~7,244). 확인 질문 3/7.
