이번 시간에는 Redis로 Cache Aside를 직접 만들어 놓고, 정상 흐름과 그 흐름이 깨지는 경우를 Docker의 Redis, PostgreSQL, Python으로 재현하며 배워가는 시간입니다.
예를 들어서 우리가 상품 조회 API 앞에 Redis를 둔다고 해볼게요. 캐시가 비어 있으면 누가 DB를 읽을까요? 가격을 바꾸면 캐시의 옛 가격은 누가 언제 지울까요?
흔히 쓰는 Cache Aside 방식에서는 이 질문의 답이 모두 애플리케이션이에요.
숫자는 모두 노트북의 Docker 환경에서 잰 값이에요. 그러니 절댓값보다는 방식끼리의 차이를 봐 주세요.
실습 준비
이 글의 명령은 모두 실습 코드의 redis-cache-aside/ 디렉터리에서 실행합니다.
compose.yaml: Redis 8.10, PostgreSQL 17, Python 컨테이너 정의init.sql: 상품 1000개,app계정, 조회 함수get_product를 만드는 SQLcache.py: Cache Aside 읽기·쓰기 함수와 실습 보조 함수 모듈hitmiss.py: 같은 상품을 1000번 조회하는 실습 스크립트write.py: 쓰기 뒤 캐시 삭제 실패를 재현하는 실습 스크립트race.py: 늦은 읽기가 옛값을 다시 넣는 경쟁 조건을 재현하는 실습 스크립트
compose.yaml은 Redis, PostgreSQL, Python 컨테이너를 띄워요. PostgreSQL은 DB 조회 횟수를 세려고 pg_stat_statements를 켰습니다. 함께 적힌 max_connections=30은 이 글에서는 쓰지 않아요.
init.sql은 상품 1000개를 만들고, 애플리케이션이 쓸 app 계정과 조회 함수를 만들어요.
실제 서비스의 상품 조회는 조인이나 집계가 붙어 무거운 경우가 많죠. 그래서 get_product 함수가 0.02초 쉬었다가 한 건을 돌려주게 했습니다.
compose 프로젝트 이름을 rca로 두고, 자주 쓰는 명령을 셸 함수로 만들어 놓을게요. 아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.
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에 모아 두었어요.
- 읽기
get_product(conn, pid, ttl=60): Redis에서 먼저 찾고, 없으면 DB를 조회해 TTL을 붙여SET한 뒤HIT나MISS를 함께 돌려줍니다. - 쓰기
update_price: 가격과version을 바꿔 커밋한 다음 캐시 키를DEL합니다.
cache.py에는 실습용 보조 함수도 있어요.
db_queries():pg_stat_statements에서get_product조회가 몇 번 실행됐는지 읽어요. 애플리케이션이 센 숫자가 아니라 DB가 기록한 숫자입니다.db_price(pid): DB에 들어 있는 지금 가격을 읽어요.reset(): 가격을 10000으로 되돌리고, 조회 통계와 Redis 데이터, Redis 통계를 비웁니다.
실습 1: 캐시는 애플리케이션이 채웁니다
먼저 같은 상품을 1000번 조회해 볼게요. hitmiss.py는 처음 세 요청의 결과와 시간을 찍고, 끝에 HIT·MISS별 횟수와 평균 응답 시간, DB 조회 횟수를 출력해요.
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 자체는 정상 경로예요. 다만 요청이 몰리는 키가 만료되면, 그 순간 들어온 요청이 모두 miss가 되어 각자 DB를 조회해요. 이걸 cache stampede라고 합니다.
캐시를 지우고 Redis에 직접 물어보면 어떻게 될까요? Redis는 DB를 읽지 않고 빈 값을 돌려줘요.
rcli TTL product:42
rcli DEL product:42
rcli GET product:42
(integer) 57
(integer) 1
(nil)
TTL은 키가 사라지기까지 남은 초예요. 60초로 넣은 키가 57초 남아 있었습니다.
실습 2: 커밋 뒤 DEL이 실패하면
가격을 바꿀 때는 DB에 먼저 커밋하고, 그다음 캐시 키를 지워요. 새 값은 다음 읽기가 miss를 거치며 DB에서 읽어 채웁니다.
순서를 바꿔 캐시를 먼저 지운다고 해볼게요. 그러면 지운 직후 커밋 전에 들어온 읽기가 DB의 옛값을 읽어 다시 캐시에 넣을 수 있어요. 커밋은 그 뒤에 끝나니 캐시에는 옛값이 남습니다.
그런데 DB와 Redis는 한 트랜잭션으로 묶이지 않아요. DB 커밋은 성공했는데 그다음 Redis 작업이 실패할 수 있습니다.
write.py로 이 부분 실패를 재현해 볼게요. 이 스크립트의 Redis 클라이언트는 socket_timeout=1로 만들었어요.
prepare: 캐시에 10000원을 TTL 30초로 채웁니다.update-del: DB를 커밋한 뒤 캐시를DEL합니다.watch: 5초마다get_product로 읽어 캐시 값과 DB 값을 비교하고, 같아질 때까지 커밋 후 몇 초가 지났는지 출력합니다.
커밋 직전에 docker compose pause로 Redis 프로세스를 멈춰서, 커밋 뒤의 DEL이 타임아웃 나게 해 볼게요.
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 끝
출력을 보면 DB는 12000원인데 캐시는 30초 동안 10000원을 돌려줬어요. DEL도 커밋 뒤에 실패하면 옛값이 TTL까지 남습니다.
이때 stale의 상한을 정하는 건 TTL뿐이에요. 그래서 캐시에 넣는 모든 값에 TTL을 붙여 두어야 옛값이 남는 시간에 상한이 생깁니다.
실패한 DEL은 나중에 몇 번을 다시 보내도 결과가 같아요. 그러니 실패한 키는 재시도 큐나 outbox 테이블에 남겨 다시 지워 주세요. DB 커밋과 함께 후속 작업을 남기는 방법이 더 궁금하시면 outbox 패턴을 다룬 글을 참고하시면 됩니다.
실습 3: DEL이 성공해도 옛값이 돌아올 수 있습니다
DB COMMIT → DEL 순서를 지키고 DEL이 성공해도 stale은 생길 수 있어요. 읽기 요청이 DB를 읽은 뒤 캐시에 넣기까지의 사이에 쓰기가 끼어드는 경우입니다.
race.py reader는 이 순서를 그대로 만들어요. Reader A는 DB에서 10000원을 읽은 뒤, writer가 커밋하고 DEL할 때까지 기다렸다가 SET합니다.
실제 서비스에서는 GC 멈춤, 네트워크 지연, 스레드 스케줄링 때문에 이런 지연이 생겨요.
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의 버전을 담고, 캐시에 더 새로운 버전이 기록돼 있으면 옛 버전을 넣지 않아요.
결제나 재고처럼 틀리면 안 되는 조회는 캐시를 거치지 않고 DB에서 읽는 것도 방법입니다.
정리
범위: Redis 8.10(redis:8.10.2), PostgreSQL 17, Python redis 6.4.0과 psycopg 3.2.10, 노트북 Docker, DB 조회는 0.02초 걸리게 만든 함수.
- 같은 상품 1000번 조회에서 첫 miss는 22.65ms, hit 999번은 평균 0.07ms였고 DB 조회는 1번이었어요. 캐시를 채우는 건 Redis가 아니라 miss를 낸 애플리케이션입니다.
- DB 커밋 뒤 Redis가 멈춰 캐시
DEL이 타임아웃 나자 TTL 30초가 끝날 때까지 옛 가격 10000원이 나갔습니다. 이 글에 싣지 않은 추가 측정에서는 실패한 키를 재시도 목록에 적어 다시DEL하자 5.4초 만에 12000원이 나갔습니다. 모든 캐시 값에 TTL을 붙이고, 실패한DEL은 재시도 큐에 남기세요. - 커밋과
DEL이 성공해도 늦게 끝난 reader가 10000원을 다시 넣어 TTL 30초 동안 옛값이 나갔습니다. 이 글에 싣지 않은 추가 측정에서는 버전 비교 Lua 스크립트를 쓰자 그SET이 거부됐습니다. 옛값이 잠깐도 나가면 안 되는 키는 버전 비교 후SET하거나 캐시를 거치지 않게 하세요.