상품 조회 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>

