매번 DB
…
- 지금 보이는 판
- …
- 주방(DB) 간 횟수
- 0번
6장
손님 1000명이 같은 걸 물어볼 때마다 데이터베이스에 다녀와야 할까요? 미리 적어 둔 답을 보여주면 무엇이 빨라지고, 무엇이 늦어질까요?
1손님마다 주방에 "오늘 메뉴 뭐예요?" 하고 물어보면, 주방이 대답하느라 요리를 못 해요.
2그래서 대기 줄 앞에 오늘 메뉴를 적은 판(메뉴판)을 걸어요. 손님은 판만 보고, 직원이 5초마다 주방에 다시 물어 적어요.
3메뉴가 바뀌어도, 메뉴판을 다시 확인하기 전까지는 옛 메뉴가 보여요.
4메뉴판이 비는 순간 다 같이 주방으로 몰리지 않게, 열쇠를 쥔 한 명만 확인하러 가요.
매번 주방(DB)에 물어보기 · DB 1000번 · 0.06초
점 하나 = 손님 약 25명
메뉴판(Redis) 먼저 보기 · DB 1번 · 0.02초
점 하나 = 손님 약 25명
지난 실측 기록 (2026-10-20, 로컬)
1000번 → 1번
손님 1000명에 데이터베이스를 읽은 횟수
실제 서버에서 "오늘 메뉴" 읽기 1000번을 동시에 해요. 왼쪽은 매번 주방(MySQL)에 묻고, 오른쪽은 5초마다 새로 적는 메뉴판(Redis)을 먼저 봐요. 메뉴판이 이미 채워져 있으면 주방에는 한 번도 안 가요.
같은 1000명인데 주방에 간 횟수가 1000번 → 1번. 걸린 시간은 0.06초 → 0.02초예요. 대신 메뉴가 바뀌면 메뉴판에는 최대 5초 늦게 반영돼요. 아래에서 직접 확인해 보세요.
주방에서 메뉴를 바꾼 뒤 7초 동안 0.5초마다 양쪽을 읽어 봐요. 왼쪽은 바로 바뀌지만 매번 주방에 가고, 오른쪽은 메뉴판을 다시 확인할 때(최대 5초)까지 옛 메뉴가 보여요.
…
…
메뉴판은 5초마다 비워져요. 하필 그 순간 손님 1000명이 한꺼번에 오면, 다들 "비었네?" 하고 주방으로 달려가요(캐시 스탬피드). 서버 3대가 손님을 나눠 받는 상황을 실제 Redis와 DB로 재현해요.
메뉴판이 빈 걸 본 손님이 전부 주방으로 달려가요.
같은 서버에 온 손님끼리는 한 명만 보내고 나머지는 기다려요. 하지만 서버가 3대면 3명이 가요.
Redis에 열쇠(SET NX)를 하나만 두고, 열쇠를 잡은 한 명만 주방에 가요. 나머지는 잠깐 기다렸다 메뉴판을 다시 봐요.
지난 실측 기록 (2026-10-20, 로컬). 눌러서 직접 재 보세요.
서버마다 한 명씩 묶는 건 서버 안에서만 통해요. 서버가 늘면 그만큼 주방에 가죠. 모든 서버가 같이 보는 Redis에 열쇠를 두면, 서버가 몇 대든 주방에 가는 건 한 명이에요.
MenuBoard 행bv:menu:boardSET key value EX 5GET → miss → SELECT → SET EXSET bv:menu:board:lock <token> NX PX 2000DEL)readMenu('cache')가 GET /cache-demo/menu?via=cache를 보내요.readThroughCache()가 GET bv:menu:board를 해요.{ board, source: "cache", hops, ttlLeftMs } (남은 시간은 PTTL)MenuBoard를 읽고 → SET … EX 5 → 200 source: "db"via=db면 readDirect()가 Redis 없이 매번 SELECT해요.POST /cache-demo/menu { invalidate } → UPDATE MenuBoard(version + 1).DEL bv:menu:board → 다음 읽기부터 새 메뉴POST /cache-demo/stampede { visitors: 1000, instances: 3 }. runSimulation 관문(409/429)을 지나요.DEL해서 "막 만료된 순간"을 만들고, 손님 1000명을 서버 3대(한 프로세스 안에서 흉내)에 나눠 동시에 읽혀요.SET NX PX 2000을 잡은 한 명만 SELECT → SET EX → 내 토큰일 때만 DEL하는 Lua. 나머지는 20ms마다 GET, 1초가 넘으면 DB에서 직접 읽어요 (db-fallback){ results: [...] }. 대책마다 DB 읽기 수, Redis 명령 수(GET, SET EX, SET NX, DEL), 손님별 경로가 들어 있어요.| key | type | value | PTTL | 언제 |
|---|---|---|---|---|
| bv:menu:board | string | {"content":"오늘의 메뉴: 김치찌개 (25번째 판)","version":25} | 4970 | miss 뒤 SET EX 5 직후 |
| bv:menu:board:lock | string | d8766401… (요청마다 새 UUID) | 1996 | 몰림 실험(Redis 잠금) 도중 |
| bv:menu:board:lock | none | -2 | 잠금 주인이 Lua로 DEL한 뒤 | |
| bv:menu:board | none | -2 | 5초 뒤 TTL 만료 |
bv-redis에서 직접 읽었어요. PTTL은 남은 밀리초, -2는 키가 없다는 뜻이에요.
| id | name | content | version | updatedAt |
|---|---|---|---|---|
| 2816 | board | 오늘의 메뉴: 김치찌개 (25번째 판) | 25 | 2026-10-20 04:35:15.916 |
캐시에 들어간 값의 원본이에요. updatedAt은 UTC예요.
GET → hitGET → miss → SELECT → 행 → SET EX 5SELECT → 행UPDATE (무효화를 켜면 + DEL). 몰림 실험의 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 실측
| 대책 | GET | SET NX | SET EX | DEL | DB SELECT |
|---|---|---|---|---|---|
none | 1000 | 0 | 1000 | 0 | 1000 |
single-flight | 1000 | 0 | 3 | 0 | 3 |
redis-lock | 1999 | 1000 | 1 | 1 | 1 |