본문으로 건너뛰기
버튼 하나 뒤에서

6장

맛집 메뉴판

손님 1000명이 같은 걸 물어볼 때마다 데이터베이스에 다녀와야 할까요? 미리 적어 둔 답을 보여주면 무엇이 빨라지고, 무엇이 늦어질까요?

  1. 1손님마다 주방에 "오늘 메뉴 뭐예요?" 하고 물어보면, 주방이 대답하느라 요리를 못 해요.

  2. 2그래서 대기 줄 앞에 오늘 메뉴를 적은 판(메뉴판)을 걸어요. 손님은 판만 보고, 직원이 5초마다 주방에 다시 물어 적어요.

  3. 3메뉴가 바뀌어도, 메뉴판을 다시 확인하기 전까지는 옛 메뉴가 보여요.

  4. 4메뉴판이 비는 순간 다 같이 주방으로 몰리지 않게, 열쇠를 쥔 한 명만 확인하러 가요.

매번 주방(DB)에 물어보기 · DB 1000번 · 0.06초

손님 1000명서버RedisDB1000번

점 하나 = 손님 약 25명

메뉴판(Redis) 먼저 보기 · DB 1번 · 0.02초

손님 1000명서버RedisDB1번

점 하나 = 손님 약 25명

지난 실측 기록 (2026-10-20, 로컬)

1000번 → 1번

손님 1000명에 데이터베이스를 읽은 횟수

실제 서버에서 "오늘 메뉴" 읽기 1000번을 동시에 해요. 왼쪽은 매번 주방(MySQL)에 묻고, 오른쪽은 5초마다 새로 적는 메뉴판(Redis)을 먼저 봐요. 메뉴판이 이미 채워져 있으면 주방에는 한 번도 안 가요.

같은 1000명인데 주방에 간 횟수가 1000번 → 1번. 걸린 시간은 0.06초 → 0.02초예요. 대신 메뉴가 바뀌면 메뉴판에는 최대 5초 늦게 반영돼요. 아래에서 직접 확인해 보세요.

메뉴를 바꾸면 언제 보일까요?

주방에서 메뉴를 바꾼 뒤 7초 동안 0.5초마다 양쪽을 읽어 봐요. 왼쪽은 바로 바뀌지만 매번 주방에 가고, 오른쪽은 메뉴판을 다시 확인할 때(최대 5초)까지 옛 메뉴가 보여요.

매번 DB

…

지금 보이는 판
…
주방(DB) 간 횟수
0번

Redis 메뉴판 (5초)

…

지금 보이는 판
…
주방(DB) 간 횟수
0번

메뉴판이 비는 순간 1000명이 몰리면

메뉴판은 5초마다 비워져요. 하필 그 순간 손님 1000명이 한꺼번에 오면, 다들 "비었네?" 하고 주방으로 달려가요(캐시 스탬피드). 서버 3대가 손님을 나눠 받는 상황을 실제 Redis와 DB로 재현해요.

아무 대책 없이

서버1서버2서버3RedisDB1000번파란 점: 주방에 안 간 손님 · 빨간 점: 주방으로 간 손님
주방(DB) 간 횟수
1000번
걸린 시간
0.10초

메뉴판이 빈 걸 본 손님이 전부 주방으로 달려가요.

서버마다 한 명만 (single-flight)

서버1서버2서버3RedisDB3번파란 점: 주방에 안 간 손님 · 빨간 점: 주방으로 간 손님
주방(DB) 간 횟수
3번
걸린 시간
0.01초 미만

같은 서버에 온 손님끼리는 한 명만 보내고 나머지는 기다려요. 하지만 서버가 3대면 3명이 가요.

전체에서 한 명만 (Redis 잠금)

서버1서버2서버3RedisDB1번파란 점: 주방에 안 간 손님 · 빨간 점: 주방으로 간 손님
주방(DB) 간 횟수
1번
걸린 시간
0.04초

Redis에 열쇠(SET NX)를 하나만 두고, 열쇠를 잡은 한 명만 주방에 가요. 나머지는 잠깐 기다렸다 메뉴판을 다시 봐요.

지난 실측 기록 (2026-10-20, 로컬). 눌러서 직접 재 보세요.

서버마다 한 명씩 묶는 건 서버 안에서만 통해요. 서버가 늘면 그만큼 주방에 가죠. 모든 서버가 같이 보는 Redis에 열쇠를 두면, 서버가 몇 대든 주방에 가는 건 한 명이에요.

숫자로 본 결과

1000번 → 1번
손님 1000명에 데이터베이스를 읽은 횟수
매번 DB로 vs Redis 메뉴판(5초)으로. 메뉴판을 처음 채우는 한 번만 DB에 가요.
1000 → 3 → 1
메뉴판이 비는 순간 1000명이 몰렸을 때 DB 읽기
대책 없음 → 서버(3대)마다 한 명 → Redis 잠금으로 전체에서 한 명
5.2초 → 0초
메뉴를 고친 뒤 새 메뉴가 보이기까지
메뉴판을 그대로 두면 유효 시간(5초)이 끝날 때까지 옛 메뉴. 고칠 때 메뉴판도 지우면(DEL) 다음 읽기부터 새 메뉴 (0.5초 간격으로 확인).
2026-10-20 로컬 실측 — NestJS + Redis 7.4(Docker) + MySQL 8.4, 맥북 한 대. 서버 3대는 API 한 프로세스 안에서 흉내 낸 거예요. 위 버튼들로 직접 다시 잴 수 있어요.
개발자용 자세히cache-aside, TTL(SET EX), cache stampede, single-flight vs 분산 잠금(SET NX PX), 무효화(DEL), Redis 명령 수

흐름 따라가기

비유 ↔ 용어

주방
MySQL. 원본 메뉴가 든 MenuBoard 행
메뉴판
Redis 키 bv:menu:board
5초마다 새로 확인
TTL: SET key value EX 5
메뉴판 먼저, 없으면 주방
cache-aside: GET → miss → SELECT → SET EX
비는 순간 몰림
cache stampede (thundering herd)
서버마다 한 명
프로세스 안의 single-flight. 2장 토큰 갱신과 같은 코드예요 (API용으로 복사해 둔 파일)
열쇠
분산 잠금 SET bv:menu:board:lock <token> NX PX 2000
메뉴판 지우기
무효화 (DEL)

요청 흐름

  1. 브라우저 → API readMenu('cache')가 GET /cache-demo/menu?via=cache를 보내요.
  2. API → Redis readThroughCache()가 GET bv:menu:board를 해요.
    • hit: 200 { board, source: "cache", hops, ttlLeftMs } (남은 시간은 PTTL)
    • miss: MySQL에서 MenuBoard를 읽고 → SET … EX 5 → 200 source: "db"
  3. 브라우저 → API via=db면 readDirect()가 Redis 없이 매번 SELECT해요.
  4. 브라우저 → API → MySQL·Redis 메뉴 바꾸기: POST /cache-demo/menu { invalidate } → UPDATE MenuBoard(version + 1).
    • invalidate가 true면 바로 DEL bv:menu:board → 다음 읽기부터 새 메뉴
    • false면 TTL(최대 5초)이 끝날 때까지 옛 메뉴. 화면은 0.5초마다 읽어서 확인해요
  5. 브라우저 → API 몰림 실험: POST /cache-demo/stampede { visitors: 1000, instances: 3 }. runSimulation 관문(409/429)을 지나요.
  6. API → Redis·MySQL 대책마다 캐시와 잠금 키를 DEL해서 "막 만료된 순간"을 만들고, 손님 1000명을 서버 3대(한 프로세스 안에서 흉내)에 나눠 동시에 읽혀요.
    • 대책 없음: 모두 miss → 모두 SELECT + SET EX
    • single-flight: 서버마다 한 명만 SELECT, 나머지는 그 Promise를 같이 기다려요
    • Redis 잠금: SET NX PX 2000을 잡은 한 명만 SELECT → SET EX → 내 토큰일 때만 DEL하는 Lua. 나머지는 20ms마다 GET, 1초가 넘으면 DB에서 직접 읽어요 (db-fallback)
  7. API → 브라우저 200 { results: [...] }. 대책마다 DB 읽기 수, Redis 명령 수(GET, SET EX, SET NX, DEL), 손님별 경로가 들어 있어요.

Redis·DB에는 이렇게 남아요

Redis 키 (한 번 실행한 예)
keytypevaluePTTL언제
bv:menu:boardstring{"content":"오늘의 메뉴: 김치찌개 (25번째 판)","version":25}4970miss 뒤 SET EX 5 직후
bv:menu:board:lockstringd8766401… (요청마다 새 UUID)1996몰림 실험(Redis 잠금) 도중
bv:menu:board:locknone-2잠금 주인이 Lua로 DEL한 뒤
bv:menu:boardnone-25초 뒤 TTL 만료

bv-redis에서 직접 읽었어요. PTTL은 남은 밀리초, -2는 키가 없다는 뜻이에요.

MenuBoard 행 (같은 때)
idnamecontentversionupdatedAt
2816board오늘의 메뉴: 김치찌개 (25번째 판)252026-10-20 04:35:15.916

캐시에 들어간 값의 원본이에요. updatedAt은 UTC예요.

그림의 말 ↔ 실제 명령

  • 메뉴판에 있을 때: GET → hit
  • 없을 때: GET → miss → SELECT → 행 → SET EX 5
  • 매번 주방에 갈 때: SELECT → 행
  • 메뉴 바꾸기: UPDATE (무효화를 켜면 + DEL). 몰림 실험의 Redis 명령 수는 아래 표에 있어요.

왜 Redis인가

  • single-flight는 한 프로세스의 메모리 안에서만 묶여요. 서버를 늘리면 대수만큼 DB에 가요 (위 3).
  • 잠금과 메뉴판을 모든 서버가 같이 보는 곳(Redis)에 두면 서버 수와 상관없이 한 번이에요.

한계·운영에선

  • 잠금을 기다리는 손님이 20ms마다 다시 GET해서 GET 수가 늘어요 (위 1999).
  • 잠금 주인이 1초 넘게 늦으면 기다리던 손님은 DB에서 직접 읽어요 (db-fallback, 캐시는 안 채워요).
  • 무효화는 "DB 수정 후 DEL" 순서라, 그 사이 아주 짧게 옛 값이 다시 채워질 수 있어요. 지연 이중 삭제나 버전 비교로 줄여요.
  • Redis가 꺼져 있어도 API는 떠요. 6장 요청만 실패해요 (첫 명령 때 연결).

로컬·운영 Redis

  • 로컬: docker compose up -d redis → 컨테이너 bv-redis, 포트 6380(다른 Redis와 안 겹치게), 디스크 저장 끔.
  • 운영: docker-compose.prod.yml의 redis 컨테이너, REDIS_URL.

개발자 도구로 보면

  • GET /cache-demo/menu?via=cache 응답의 source가 cache인지 db인지, ttlLeftMs가 얼마 남았는지 보여요.
  • 몰림 실험은 POST /cache-demo/stampede 한 건이에요. 1000명은 서버 안에서 돌아요.

코드·문서

스탬피드 1000명 (서버 3대) — Redis 명령 수와 DB 읽기, 2026-10-20 실측

대책GETSET NXSET EXDELDB SELECT
none10000100001000
single-flight10000303
redis-lock19991000111