Database/redis

Redis 실무 가이드 4 — 운영·장애 대응과 대용량 캐시 설계

bluebamus 2026. 9. 8. 09:53

목차

Part 6. [Ops] Redis 운영 및 장애 대응

Part 7. [Appendix] 보너스 트랙 — 대용량 서비스 설계


Part 6. [Ops] Redis 운영 및 장애 대응


6-1. Key 이름 짓기 — 6개월 뒤에도 읽히는 규칙

이 글에서 세울 규칙

  • Redis 운영의 기초인 Key 명명 규칙(Naming Convention) 의 중요성을 이해합니다.
  • 실무에서 표준으로 사용되는 계층형 구조(Colons) 사용법을 익힙니다.
  • 보안(개인정보)과 유지보수, 모니터링을 모두 고려한 Key 설계 능력을 갖춥니다.

1. Key 이름이 운영 난이도를 결정하는 이유

Redis는 기본적으로 모든 데이터가 단순한 Key-Value 형태입니다. 수만, 수백만 개의 Key가 아무런 규칙 없이 쌓여 있다면 다음과 같은 문제가 발생합니다.

  • 데이터 중복: 이미 존재하는 데이터인지 모르고 비슷한 Key를 또 만들게 되어 메모리가 낭비됩니다.
  • 가독성 저하: user123이라는 Key가 유저 프로필인지, 세션 토큰인지 이름만 보고는 알 수 없습니다.
  • 관리 툴 활용 불가: RedisInsight 같은 GUI 툴은 특정 규칙(:)을 기준으로 데이터를 폴더처럼 묶어 보여주는데, 규칙이 없으면 이런 시각화 기능을 사용할 수 없습니다.

2. 실무 표준 — 콜론으로 계층을 만드는 법

가장 널리 쓰이고 권장되는 방식은 콜론(:) 을 구분자로 사용하여 데이터의 범위를 큰 범주에서 작은 범주로 좁혀나가는 방식입니다.

💡 권장 포맷

text
[서비스/프로젝트]:[도메인/엔티티]:[식별자]:[속성]

❌ 나쁜 예시

  • user1 (무슨 정보인지, 어떤 서비스인지 알 수 없음)
  • user_profile_data_id_1 (너무 길고, 언더바(_)는 텍스트 더블클릭 선택 시 불편함)
  • 1 (최악의 사례, 숫자만으로는 아무것도 식별 불가)

✅ 좋은 예시

  • shop:user:1:profile (상점 서비스의 1번 유저 프로필)
  • shop:product:105:stock (상점 서비스의 105번 상품 재고)
  • auth:session:abc123xyz (인증 서비스의 특정 세션 토큰)

🗣 참고

  • 여러 서비스가 동일한 Redis를 사용할 경우 충돌을 방지하기 위해 서비스 prefix를 Key 앞에 두는 것이 일반적입니다.
  • 계층형 Key 구조는 운영 환경에서 SCAN shop:user:*나 SCAN auth:session:*와 같이 특정 도메인 데이터를 쉽게 패턴으로 조회하고 관리할 수 있게 해줍니다.
  • Redis 공식 문서에서도 user:1000, session:abc123처럼 콜론 기반 Key 패턴을 예시로 사용합니다.

3. 이름을 정하기 전에 통과할 여섯 가지 질문

① 가급적 소문자를 사용하세요

Redis의 Key는 대소문자를 엄격하게 구분합니다(User:1과 user:1은 다름). 휴먼 에러 방지와 일관성을 위해 소문자 사용을 권장합니다.

② 너무 길지 않게, 하지만 명확하게

Redis는 In-Memory DB이므로 Key 이름 자체가 차지하는 메모리도 무시할 수 없습니다. 또한 Redis는 보통 네트워크를 통해 호출되기 때문에 Key가 지나치게 길면 메모리뿐만 아니라 네트워크 전송 비용도 증가할 수 있으므로 적절한 길이를 유지하는 것이 좋습니다. 너무 장황한 이름은 피해야 합니다.

  • user:1:img:url (O)
  • user:1:the_path_of_user_profile_image_url (X)

③ 식별자는 구체적으로

단순히 식별자 번호만 쓰기보다는 엔티티 이름을 명시하는 것이 가독성에 좋습니다.

  • user:1 (O) / 1 (X)

④ 날짜/시간이 필요할 땐 고정 포맷을 쓰세요

시간 데이터가 Key에 포함될 경우 정렬과 검색을 위해 자리수를 고정해야 합니다.

  • logs:2024-05-11 (O)
  • logs:240511 (X — 연도 확장이 어렵고 가독성이 떨어짐)

⑤ 비즈니스 로직(상태)을 Key에 녹이지 마세요

Key 이름에 변하는 비즈니스 상태를 넣으면, 상태가 변할 때마다 Key 이름을 통째로 바꿔야(RENAME) 하는 비효율이 발생합니다. 상태 정보는 Value(Hashes의 Field 등)에 넣는 것이 좋습니다.

  • user:1:status:active (X — 휴면 계정이 되면 Key 이름을 바꿔야 함)
  • user:1:profile 안에 {"status": "active"}로 저장 (O)

⑥ [보안] 개인정보(PII)는 반드시 해싱(Hashing)하세요 🛡

전화번호, 이메일, 주민번호 등 민감한 개인정보를 Key 이름에 평문으로 쓰면 안 됩니다. 메모리 덤프나 로그 유출 시 심각한 보안 사고로 이어집니다.

  • auth:code:01012345678 (X — 보안 취약점)
  • auth:code:a8f5f167f4... (O — 전화번호를 SHA-256 등으로 단방향 해싱하여 사용)

4. [실습] RedisInsight에서 계층이 보이는 것 확인하기

계층형 네이밍 규칙을 지켰을 때, 관리 툴에서 얼마나 데이터를 보기 편해지는지 직접 체험해 봅시다.

① 터미널(CLI)에서 데이터 입력

Redis CLI를 열고 아래 명령어들을 차례대로 입력해 보세요.

redis
# User 도메인 데이터
SET shop:user:1:status "active"
HSET shop:user:1:profile name "Kim" age 30
SET shop:user:2:status "inactive"
HSET shop:user:2:profile name "Lee" age 25

# Product 도메인 데이터
SET shop:product:100:stock 50
SET shop:product:101:stock 12

② RedisInsight에서 확인하기

  1. RedisInsight 데스크톱 앱(또는 웹 UI)을 엽니다.
  2. 좌측 데이터 브라우저(Browser) 패널을 새로고침합니다.
  3. 아래와 같이 콜론(:)을 기준으로 완벽한 트리(Tree) 폴더 구조가 자동 생성된 것을 눈으로 확인하세요!
text
📁 shop
 ├── 📁 product
 │    ├── 🔑 100:stock (50)
 │    └── 🔑 101:stock (12)
 └── 📁 user
       ├── 📁 1
       │    ├── 🔑 profile ({name: "Kim", age: 30})
       │    └── 🔑 status ("active")
       └── 📁 2
            ├── 🔑 profile ({name: "Lee", age: 25})
            └── 🔑 status ("inactive")

이처럼 잘 지은 Key 이름은 그 자체로 명세서(Documentation) 가 되어, 새로운 팀원이 합류해도 데이터 구조를 직관적으로 파악할 수 있게 해줍니다.

🚩 정리 — 네이밍이 무너지면 생기는 일

  • Redis에서 Key 설계는 단순한 네이밍 문제가 아니라 데이터 구조와 운영 전략을 결정하는 아키텍처 설계의 일부입니다.
  • 잘 설계된 Key 이름은 운영 중 장애가 발생했을 때 데이터의 정체를 쉽게 파악하게 해줍니다.
  • 프로젝트 시작 전, 팀원들과 네이밍 컨벤션(Naming Convention) 을 반드시 문서화하고 공유하세요.

6-2. KEYS *를 치는 순간 서버가 멈추는 이유와 SCAN

이 글에서 없앨 장애 원인

  • Redis 운영 환경에서 KEYS * 명령어가 왜 '금기어'인지 이해합니다.
  • Redis의 싱글 스레드(Single-threaded) 특성과 블로킹(Blocking) 현상을 학습합니다.
  • 서비스 중단 없이 안전하게 키를 조회하는 SCAN 명령어의 사용법을 익힙니다.

1. 운영 서버에서 절대 치면 안 되는 명령어

초보 개발자가 운영 중인 Redis 서버에 접속해 "지금 무슨 데이터가 들어있지?"라며 KEYS *를 입력하는 순간, 서비스 전체가 마비될 수 있습니다.

❓ 왜 위험한가요?

Redis는 싱글 스레드로 동작합니다. 즉, 한 번에 하나의 명령어만 처리할 수 있습니다.

  • KEYS * 의 동작: Redis에 저장된 수백만, 수천만 개의 키를 처음부터 끝까지 다 뒤져서 목록을 만듭니다.
  • 결과: 이 작업이 끝날 때까지 Redis는 다른 모든 요청(GET, SET, 로그인, 결제 등)을 전혀 처리하지 못하고 멈춰 서게 됩니다. 이를 '블로킹' 현상이라고 합니다.

2. 싱글 스레드에서 O(N)이 갖는 의미

명령어의 시간 복잡도가 O(N)이라는 것은, 데이터 개수(N)가 많아질수록 실행 시간이 정비례해서 늘어난다는 뜻입니다.

  • 데이터 1,000개: 0.01초 (체감 안 됨, 개발 서버에서는 문제없어 보임)
  • 데이터 1,000만 개: 수 초 ~ 수십 초 소요 (실제 운영 서버에서는 대형 장애로 직결)

3. 끊어서 훑는 대안 — SCAN 커서

SCAN은 전체 키를 한꺼번에 가져오는 대신, 커서(Cursor) 를 기반으로 조금씩 끊어서 가져오는 방식입니다.

✅ SCAN의 3대 특징

  1. Non-blocking (논블로킹): 한 번 호출할 때 아주 짧은 시간만 작업하고 결과를 반환하므로, 다른 일반 명령어(GET/SET 등)가 실행될 틈을 줍니다.
  2. Cursor 기반: 0번 커서로 시작해 서버가 '다음 커서 번호'를 알려주면, 클라이언트는 그 번호로 다시 요청하는 과정을 반복합니다. (페이징과 유사)
  3. 상태 미유지 (Stateless): Redis 서버는 커서의 상태를 따로 메모리에 저장하지 않으므로 서버 자원을 소모하지 않습니다.

4. [실습] MATCH·COUNT로 안전하게 키 찾기

① 기본 사용 (커서 0으로 시작)

redis
# SCAN [커서번호] [MATCH 패턴] [COUNT 개수]
127.0.0.1:6379> SCAN 0
1) "17"           # 서버가 알려주는 '다음번에 사용할 커서 번호'
2) 1) "user:1"    # 이번 턴에 찾은 키 목록들...
   2) "item:100"

② 패턴 검색과 개수 조절

이전 강의에서 배운 계층형 구조(Colon)가 여기서 강력한 힘을 발휘합니다. 특정 도메인의 데이터만 안전하게 검색할 수 있습니다.

redis
# 'shop:user:'로 시작하는 키를 약 100개씩 끊어서 찾아줘
127.0.0.1:6379> SCAN 0 MATCH shop:user:* COUNT 100
1) "42"
2) 1) "shop:user:1:profile"
   2) "shop:user:2:status"

🔍 [Tip] COUNT 100의 진짜 의미

COUNT는 정확한 개수를 보장하는 옵션이 아니라 Redis에게 "이 정도 작업량으로 스캔하라"는 힌트입니다. 즉 "정확히 100개를 가져와라"가 아닙니다. "한 번 스캔 작업을 할 때 100개 정도의 바구니(데이터 셋)를 뒤져라"라는 힌트입니다. 따라서 반환되는 결과의 개수가 0개일 수도 있고, 100개보다 약간 많을 수도 있습니다.

🗣 SCAN 참고사항

  • SCAN도 전체 키를 결국 한 번씩 확인해야 하므로 전체적으로는 O(N) 작업입니다. 다만 이 작업을 여러 번에 나누어 수행하여 Redis 서버를 블로킹하지 않는 방식입니다.
  • SCAN은 내부적으로 Redis의 해시 테이블을 순회하는 방식이기 때문에 같은 키가 중복으로 반환될 수도 있고 순서도 보장되지 않습니다.

5. 사고를 예방하는 운영 습관

  1. 설정으로 원천 차단하기: redis.conf 파일에서 아래와 같이 설정하여 KEYS 명령어 자체를 아무도 쓰지 못하게 비활성화하는 것이 실무 표준입니다.
conf
rename-command KEYS ""
  1. 애플리케이션 코드 (Python): 파이썬 코드에서 전체 키 목록을 가져오는 로직은 가급적 지양해야 합니다. 꼭 필요하다면 redis-py 라이브러리에서 제공하는 scan_iter() 함수를 사용하여 내부적으로 커서를 자동 관리하게 만드세요.
  2. 모니터링: 누군가 무거운 명령어를 치고 있지 않은지 SLOWLOG를 주기적으로 확인하여 실행 시간이 오래 걸리는 O(N) 명령어가 있는지 체크해야 합니다.

🚩 정리 — O(N) 명령어를 다루는 원칙

  • Redis는 싱글 스레드이므로 시간이 오래 걸리는 명령어는 서비스 전체의 지연을 유발합니다.
  • Redis는 매우 빠른 데이터베이스지만, O(N) 명령어 하나로 전체 서비스가 멈출 수도 있습니다.
  • KEYS는 절대 금지, 전체 검색이 필요하다면 반드시 SCAN을 사용하세요.
  • 데이터가 수백만 개를 넘어가면 SCAN도 네트워크와 클라이언트에 부담이 될 수 있습니다. 따라서 처음부터 데이터 구조를 잘 설계하여 전체 스캔을 할 일을 만들지 않는 것이 가장 훌륭한 아키텍처입니다.

6-3. 메모리 상한과 꽉 찼을 때의 행동 정하기

이 글에서 정할 설정

  • Redis가 사용할 수 있는 최대 메모리를 제한하는 법을 배웁니다.
  • 메모리가 가득 찼을 때 데이터를 삭제하는 다양한 Eviction 정책을 이해합니다.
  • 서비스 성격에 맞는 최적의 알고리즘(LRU, LFU 등)을 선택하는 능력을 갖춥니다.

1. maxmemory를 안 걸면 벌어지는 일

Redis는 물리적 메모리(RAM)를 사용합니다. 제한 없이 사용하다가 OS 전체의 메모리가 부족해지면 OOM(Out Of Memory) 이 작동하여 Redis 프로세스를 강제로 종료시켜 버립니다.

  • 설정의 필요성: 전체 RAM의 60~80% 정도로 한계치를 설정하여 시스템 안정성을 확보해야 합니다.
redis
# 현재 설정된 최대 메모리 설정 값 확인
CONFIG GET maxmemory

# 2GB로 제한 설정
CONFIG SET maxmemory 2gb

2. 한도에 닿았을 때의 여덟 가지 선택지

maxmemory에 도달했을 때, 새로운 데이터를 넣으려면 기존 데이터 중 일부를 지워야 합니다. 어떤 기준으로 지울 것인가가 바로 Eviction Policy입니다.

💡 주요 정책 5가지

정책 이름 설명 권장 상황
noeviction 기본값. 어떤 데이터도 지우지 않고 쓰기 에러를 반환합니다. 데이터가 절대 삭제되면 안 되는 DB 용도
allkeys-lru 가장 오랫동안 참조되지 않은 키부터 삭제합니다. 일반적인 캐시 서비스 (가장 권장)
volatile-lru TTL이 설정된 키들 중 가장 오래된 것을 삭제합니다. 캐시와 영구 저장을 혼용할 때
allkeys-lfu 가장 적게 사용된(빈도가 낮은) 키부터 삭제합니다. 특정 데이터만 집중적으로 조회되는 경우
allkeys-random 무작위로 키를 삭제합니다. 데이터 중요도가 모두 비슷할 때
  • Redis에는 총 8가지 Eviction 정책이 있지만, 실제 현업에서는 allkeys-lru, volatile-lru, noeviction 이 3가지가 전체 유스케이스에서 가장 많이 차지합니다.
  • allkeys-* 정책은 TTL 여부와 상관없이 모든 키를 삭제 후보로 보고, volatile-* 정책은 TTL이 설정된 키만 삭제 후보로 봅니다.

3. LRU와 LFU, 무엇을 기준으로 버릴까

🕒 LRU (Least Recently Used)

  • 핵심: "가장 최근에 언제 썼나?"
  • 특징: '시간'이 기준입니다. 아무리 많이 썼던 데이터라도 최근에 한 번도 안 썼다면 삭제 대상 1순위가 됩니다.

📊 LFU (Least Frequently Used)

  • 핵심: "얼마나 자주 썼나?"
  • 특징: '횟수'가 기준입니다. 최근에 썼더라도 전체적인 사용 빈도가 낮으면 삭제 대상이 됩니다. (최신 데이터보다 인기/스테디셀러 데이터를 보호할 때 유리함)

4. 현재 설정 확인하고 바꿔보기

redis
# 1. 현재 메모리 정책 확인
127.0.0.1:6379> CONFIG GET maxmemory-policy
1) "maxmemory-policy"
2) "noeviction"  # (기본값인 경우가 많음)

# 2. 정책 변경 (캐시 용도로 최적화)
127.0.0.1:6379> CONFIG SET maxmemory-policy allkeys-lru
OK

# 3. 현재 메모리 사용량 실시간 모니터링
127.0.0.1:6379> INFO memory

🔍 [Tip] used_memory_human을 확인하세요!

INFO memory 결과에서 used_memory_human은 사람이 읽기 편한 단위(예: 500MB, 1.2G)로 현재 사용량을 직관적으로 보여줍니다.

항목 의미 비고
used_memory_human 현재 Redis가 실제로 사용 중인 메모리 사람이 읽기 쉬운 단위(MB, GB)로 표시된 현재 사용량
used_memory_peak_human Redis 실행 이후 기록된 최대 메모리 사용량 과거에 가장 많이 사용했던 메모리
maxmemory_human 설정된 Redis 최대 사용 가능 메모리 maxmemory 설정값을 사람이 읽기 쉬운 단위로 표시
mem_fragmentation_ratio Redis 메모리 단편화 비율 OS 메모리 / Redis 내부 메모리 비율

5. 캐시 용도와 저장소 용도의 설정 차이

  • 캐시 서비스라면? allkeys-lru를 권장합니다. 가장 합리적으로 메모리를 비워줍니다.
  • DB 대용(영구 저장)이라면? noeviction을 유지하고, 메모리가 70%가 넘을 때 경고 알림(Alert)이 오도록 모니터링을 구축해야 합니다.
  • 만료 시간(TTL) 활용: Eviction에만 의존하지 말고, 애초에 모든 데이터에 적절한 EXPIRE를 설정하여 메모리가 차오르는 속도를 늦추는 것이 최고의 전략입니다.

❓ [핵심 주의사항] 만료 시간(TTL)이 없는 데이터는 안전한가요?

많은 분이 "SET 할 때 만료 시간을 지정하지 않으면 영구적으로 보관되겠지?"라고 생각합니다. 하지만 allkeys- 계열의 정책을 사용 중이라면 이야기가 달라집니다.

  1. allkeys-lru / lfu — TTL 설정 여부와 상관없이 메모리가 부족하면 모든 키를 삭제 후보로 봅니다. 즉, 영구 저장용 데이터도 오랫동안 안 썼다면 가차 없이 삭제될 수 있습니다.
  2. volatile-lru / lfu — 이 정책은 오직 TTL이 설정된(만료 시간이 있는) 키만 골라서 삭제합니다. 만료 시간이 없는 소중한 데이터는 보호하고 싶을 때 유용합니다.

🚩 정리 — 정책 선택이 곧 장애 대응

  • Redis 서버 장애를 막기 위해 반드시 Maxmemory를 적절한 용량으로 설정하세요.
  • LRU는 '시간'을, LFU는 '빈도'를 기준으로 데이터를 청소합니다.
  • noeviction 정책을 사용할 때 메모리가 가득 차서 Redis가 쓰기 에러(OOM)를 반환하면, 애플리케이션(FastAPI 등) 서버에서도 연쇄적으로 에러(500 Internal Server Error)가 발생할 수 있으므로 반드시 예외 처리(try-except)가 필요합니다.

6-4. RDB와 AOF — 재기동 후 데이터가 남는 원리와 복구 실습

이 글에서 검증할 복구

  • Redis의 데이터를 디스크에 저장하는 두 가지 방식(RDB, AOF)의 차이점을 이해합니다.
  • 각 방식의 개념과 동작 원리 및 장단점을 파악합니다.
  • 실무에서 사용하는 백업 전략을 확인하고 두 가지 방식의 백업/복구 시연을 진행합니다.

1. 인메모리인데 데이터가 살아남는 방식

Redis는 인메모리(In-Memory) 데이터베이스입니다. 서버가 재시작되거나 프로세스가 종료되면 메모리에 있던 모든 데이터는 사라집니다. 이를 방지하기 위해 Redis는 메모리의 데이터를 SSD/HDD 같은 비휘발성 저장장치에 파일로 기록하여 서버 재시작 시 복구할 수 있도록 하는 기능을 제공하는데 이를 영속성(Persistence) 이라고 합니다. Redis는 데이터의 영속성을 위해 RDB 방식과 AOF 방식을 제공합니다.

text
             Redis Memory
                   │
        ┌──────────┴──────────┐
        │                     │
     RDB Snapshot          AOF Log
        │                     │
     dump.rdb       Redis 6 → appendonly.aof
                    Redis 7 → base.rdb + incr.aof

Redis 7부터는 AOF 파일이 하나가 아니라 base.rdb + incr.aof 구조로 분리되어 복구 속도가 크게 빨라졌습니다.

2. RDB — 특정 시점을 통째로 뜨는 스냅샷

RDB는 특정 시점의 메모리 데이터를 통째로 하나의 .rdb 파일로 덤프(Dump)하는 스냅샷 방식입니다.

💡 동작 과정 (BGSAVE와 fork)

text
[ 메모리 데이터 ]
      │ (특정 조건 만족)
      ▼
[ BGSAVE 실행 ]
      │ ① fork() → 자식 프로세스 생성
      ├────────────────────────────┐
      ▼                            ▼
[ 부모 프로세스 ]            [ 자식 프로세스 ]
 계속해서 클라이언트의          메모리 데이터를 읽어
 새로운 요청 처리               RDB 파일로 저장
                                   │
                                   ▼
                            [ dump.rdb 생성 ]

⚙ RDB 저장 조건 확인 (redis.conf 설정 예시)

redis
# redis.conf 설정 확인
CONFIG GET save

기본값(최신 Redis 기준): save 3600 1 300 100 60 10000

서비스의 트래픽 수준에 따라 다른 기준으로 스냅샷을 만들기 위해 3단계를 둡니다.

구조: save <seconds> <changes> — 시간 + 변경 횟수 구조로 주기적 백업이 아니라 "변경 기반 스냅샷"

조건 의미
save 3600 1 3600초(1시간) 동안 1번 이상 변경 발생 시 저장
save 300 100 300초(5분) 동안 100번 이상 변경 발생 시 저장
save 60 10000 60초(1분) 동안 10,000번 이상 변경 발생 시 저장

🛑 [실무 Tip] RDB 자동 저장 비활성화 (save "")

실무에서는 트래픽이 폭주할 때 저장 조건이 충족되어 예기치 않게 스냅샷 작업(BGSAVE)이 돌면서 서버에 렉(Latency Spike)이 발생하는 것을 막기 위해, 자동 저장을 비활성화 하는 경우가 있습니다. 대신 트래픽이 가장 적은 새벽에 스크립트를 통해 수동으로 백업을 진행합니다.

✅ 장점

  • 복구 속도가 매우 빠름: 메모리 이미지를 그대로 바이너리로 압축해 올리기 때문에 대용량 데이터 복구 시 압도적으로 유리합니다.
  • 백업/복제에 적합: 특정 시점의 '최종 데이터 상태'만 고도로 압축된 바이너리 포맷으로 저장하기 때문에 파일 크기가 작아 S3 같은 외부 저장소로 전송하기 좋습니다.

❌ 단점

  • 데이터 유실 가능성: 마지막 스냅샷 이후의 데이터는 서버가 죽으면 사라집니다. (예: 1시간마다 저장 → 59분 시점에 서버 장애 발생 → 59분 치의 데이터 손실)
  • 메모리 사용량 증가: fork() 시 자식 프로세스가 복제되므로 일시적으로 메모리 사용량이 크게 튈 수 있습니다.

3. AOF — 명령어를 순서대로 기록하는 로그

AOF는 Redis에서 실행된 모든 쓰기 명령(Command)을 로그 파일에 순서대로 기록하는 방식입니다. 메모리 상태가 아닌 "명령어 자체"를 저장합니다.

💡 동작 원리

text
[ 클라이언트 명령 실행 ]
(예: SET user:1 "kim" / INCR view:100 / LPUSH log "login")
        │
        ▼
[ Redis 메모리에 반영 ]
        │
        ▼
[ AOF 파일(appendonly.aof 또는 Redis 7의 incr.aof)에 명령 기록 ]

복구 시: 서버 재시작 시 이 AOF 파일을 읽어 명령어를 처음부터 끝까지 순서대로 재실행(Replay) 하여 메모리 상태를 복구합니다.

⚙ AOF 동기화 정책 (appendfsync)

디스크에 얼마나 자주 쓸 것인지 결정하는 옵션입니다.

redis
CONFIG GET appendonly    # AOF 기능이 켜져 있는지 확인
CONFIG GET appendfsync   # AOF 동기화 정책 확인
옵션 의미 특징
always 매 명령마다 디스크에 기록 가장 안전하지만, 디스크 I/O로 인해 느림
everysec 1초마다 기록 실무 기본값. 성능과 안전성의 최적의 타협점
no OS에 맡김 가장 빠르지만, OS 크래시 시 데이터 유실 위험 큼

no 옵션은 Redis가 AOF 로그를 OS 버퍼까지만 쓰고 디스크 동기화는 운영체제에 맡기는 방식입니다.

🗜 AOF Rewrite (파일 압축)

AOF는 명령어를 계속 파일에 추가하므로 시간이 지나면 파일 크기가 무한정 커집니다. 이를 해결하기 위해 Redis는 현재 메모리 상태를 기준으로 로그를 재작성(압축)합니다.

text
[기존 AOF] (명령어 4줄 차지)
SET count 1  →  SET count 2  →  SET count 3  →  SET count 4

[Rewrite 후] (최종 상태 1줄만 남김)
SET count 4

[Rewrite 조건]

설정 의미
auto-aof-rewrite-percentage AOF 파일이 마지막 rewrite 이후 100% 증가
auto-aof-rewrite-min-size AOF 파일이 최소 64MB 이상

✅ 장점

  • 데이터 유실 최소화: everysec 설정 시 최대 1초 만큼의 데이터만 잃습니다.
  • 정교한 복구: 실수로 FLUSHALL 명령어를 실행했어도, AOF 파일 맨 끝의 해당 줄만 지우고 재시작하면 됩니다.

❌ 단점

  • 파일 크기가 커짐: RDB보다 파일 용량을 많이 차지합니다. (최종 데이터 상태만 압축 저장하는 RDB와 달리, 변경된 모든 이력(명령어)을 RESP 프로토콜 형태(텍스트 형태)로 누적 기록하기 때문입니다.)
  • 복구 속도가 느림: 명령어를 일일이 재실행해야 하므로 부팅 시간이 깁니다.

4. [비교] 유실 범위·속도·파일 크기로 갈라 보기

항목 RDB AOF
저장 방식 특정 시점의 메모리 스냅샷 (사진) 실행된 모든 쓰기 명령 로그 (일기장)
파일 크기 작음 (바이너리 압축) 큼 (텍스트 누적)
복구 속도 매우 빠름 느림 (데이터 양에 비례)
데이터 안정성 일부 유실 가능 (스냅샷 주기 사이) 거의 없음 (최대 1초 유실)
성능 영향 평소엔 낮음 (fork 시 튐) 상대적으로 높음 (지속적인 디스크 I/O)

💡 Redis 재시작 시 데이터 복구 흐름도

서버가 꺼졌다가 다시 켜질 때, Redis는 하드디스크에 저장된 백업 파일을 찾아 메모리로 다시 끌어올립니다. 이때 복구의 우선순위는 다음과 같습니다.

text
[ Redis 서버 재시작 ]
         │
         ▼
[ 1순위: AOF 기능이 켜져 있는가? ]
   ├─ (Yes) ──> AOF 로그를 처음부터 끝까지 재실행 ──> [ 데이터 복구 완료! ]
   │
   └─ (No) ───> [ 2순위: RDB 파일(dump.rdb)이 존재하는가? ]
                  ├─ (Yes) ──> RDB 스냅샷을 메모리에 로드 ──> [ 데이터 복구 완료! ]
                  │
                  └─ (No) ───> 빈 메모리 상태로 신규 시작

🔍 핵심 원리: AOF 옵션이 켜져 있다면, Redis는 RDB 파일이 있어도 무조건 AOF 파일을 우선적으로 읽어 복구합니다. AOF가 데이터 유실이 훨씬 적어 '더 최신 상태'의 데이터를 보장한다고 판단하기 때문입니다.

5. 실무에서 실제로 쓰는 조합

실무에서는 Persistence 자체보다 "Redis의 역할(순수 캐시인가, 데이터 저장소인가)"을 먼저 결정하는 것이 중요합니다.

🥇 전략 1: RDB + AOF 같이 사용 (가장 흔한 실무 표준)

목적의 분리(백업용 vs 복구용): AOF가 켜져 있으면 Redis는 재시작 시 기존 RDB 파일을 무시하고 오직 AOF만 읽어서 복구합니다. 그렇다면 왜 같이 쓸까요? 복구 시 두 개를 섞어 쓰는 것이 아니라, 저장소의 목적 자체를 나누는 것입니다.

  • RDB: 파일 크기가 작고 단일 파일이므로, 원격 저장소(S3 등)에 넘겨 정기적인 장기 보관 스냅샷(Snapshot Backup)을 뜨는 용도로 씁니다. (재해 복구)
  • AOF: 파일 크기가 크지만 데이터 유실이 적으므로, 장애가 발생했을 때 즉각적으로 서버 자체를 살려내는 생명줄(Durability) 용도로 씁니다. (운영 서버 복구)

🚀 [최신 트렌드] Redis 7.0의 Multi-part AOF

과거(Redis 6 이전)에는 AOF 파일이 무식하게 하나로만 커져서 관리가 어려웠습니다. 하지만 최신 Redis 7.0부터는 AOF 구조 자체가 진화했습니다.

  • AOF를 켜면 내부적으로 base.rdb(AOF rewrite 시 생성되는 내부 스냅샷) 파일과 incr.aof(이후 변경 로그) 파일로 나뉘어 저장됩니다.
  • 즉, 최신 버전에서는 서버 재시작 시 내장된 Base RDB를 초고속으로 먼저 읽어 들이고, 이후의 추가 로그(incr.aof)만 짧게 재실행하는 '진정한 의미의 하이브리드 고속 복구'가 Redis 내부적으로 자동 수행됩니다!

🥈 전략 2: AOF Only

목적: 단 1초의 데이터 유실도 치명적인 핵심 데이터(결제, 세션 등)를 다룰 때 사용합니다. 스냅샷 백업은 덜 중요할 때 선택합니다.

🥉 전략 3: RDB Only

목적: 약간의 유실은 감수할 수 있지만, 정기적인 백업과 빠른 장애 복구가 더 중요한 시스템에서 사용합니다.

💨 전략 4: 영속성 끄기 (순수 캐시)

목적: 100% Cache 용도. RDB와 AOF를 모두 끕니다. 데이터가 날아가도 메인 DB에서 다시 가져오면 되기 때문에, Redis의 성능을 극한으로 끌어올릴 때 사용합니다.

6. 🧪 [실습] 강제 종료 후 데이터가 얼마나 남는가

[실습 1] RDB만 켜고 죽여보기 — 유실 확인

먼저 일반적인 Redis(AOF 꺼짐)를 띄우고 RDB 스냅샷을 찍은 뒤, 추가 데이터를 넣고 장애를 재현해 봅니다.

1. Redis 컨테이너 삭제 후 재실행

AOF가 켜져 있거나 잔여 파일이 남아있으면, 재시작 시 RDB가 아닌 AOF를 우선으로 읽어버려 RDB 기반 복구가 이뤄지지 않습니다. 이를 방지하기 위해 컨테이너를 삭제하고 완전히 초기화합니다.

bash
docker rm -f my-redis
docker run -d --name my-redis -p 127.0.0.1:6379:6379 redis

2. 초기 데이터(1~100) 입력 및 수동 RDB 저장(스냅샷 저장)

bash
# [Mac / Linux (Bash)]
for i in {1..100}; do docker exec my-redis redis-cli SET key:$i val:$i; done && docker exec my-redis redis-cli SAVE

# [Mac / Linux (Bash): Redis에 비밀번호가 설정된 경우]
for i in {1..100}; do docker exec my-redis redis-cli -a <비밀번호> SET key:$i val:$i; done && docker exec my-redis redis-cli -a <비밀번호> SAVE
powershell
# [Windows (PowerShell)]
1..100 | ForEach-Object { docker exec my-redis redis-cli SET "key:$_" "val:$_" }; docker exec my-redis redis-cli SAVE

# [Windows (PowerShell): Redis에 비밀번호가 설정된 경우]
1..100 | ForEach-Object { docker exec my-redis redis-cli -a <비밀번호> SET "key:$_" "val:$_" }; docker exec my-redis redis-cli -a <비밀번호> SAVE

💡 [실무 Tip] 실제 운영 환경에서는 SAVE를 절대 쓰지 않습니다!

시연에서는 즉각적인 저장을 위해 SAVE를 사용했지만, 이 명령어는 메인 스레드를 멈추게(Blocking) 합니다. 실무에서는 자동 저장(redis.conf)이나 백그라운드 저장(BGSAVE)을 사용합니다.

3. 추가 데이터(101~120) 입력 (스냅샷 저장 안 함)

사진(스냅샷)을 찍은 이후에 들어온 데이터입니다.

bash
# [Mac / Linux (Bash)]
for i in {101..120}; do docker exec my-redis redis-cli SET key:$i val:$i; done

# [Mac / Linux (Bash): Redis에 비밀번호가 설정된 경우]
for i in {101..120}; do docker exec my-redis redis-cli -a <비밀번호> SET key:$i val:$i; done
powershell
# [Windows (PowerShell)]
101..120 | ForEach-Object { docker exec my-redis redis-cli SET "key:$_" "val:$_" }

# [Windows (PowerShell): Redis에 비밀번호가 설정된 경우]
101..120 | ForEach-Object { docker exec my-redis redis-cli -a <비밀번호> SET "key:$_" "val:$_" }

4. 장애 상황 재현 및 복구 확인

bash
# 서버 전원 강제 차단 (Kill) 후 다시 켜기
docker kill my-redis
docker start my-redis

# 데이터 개수 확인
docker exec -it my-redis redis-cli DBSIZE

# 데이터 개수 확인 (Redis에 비밀번호가 설정된 경우)
docker exec -it my-redis redis-cli -a <비밀번호> DBSIZE

# 결과: (integer) 100

💡 [주의] 왜 restart 대신 kill을 쓰나요?

일반적인 docker restart 명령을 내리면, Redis는 종료 신호(SIGTERM)를 받고 "어? 나 꺼지네? 마지막으로 메모리 저장하고 죽어야지"라며 알아서 RDB 스냅샷을 남기는 안전 종료(Graceful Shutdown)를 수행해 버립니다. 정전이나 서버 크래시 같은 '진짜 장애' 상황을 연출하려면 docker kill 명령어로 프로세스를 즉시 강제 종료(SIGKILL)해야 합니다.

[실습 2] AOF 켜고 죽여보기 — 전량 복구 확인

이번에는 AOF 기능을 켠 상태로 띄워, SAVE를 하지 않아도 모든 데이터가 완벽히 살아나는 것을 확인합니다.

1. AOF 활성화된 Redis로 새로 띄우기

bash
docker rm -f my-redis
docker run --name my-redis -p 127.0.0.1:6379:6379 -d redis redis-server --appendonly yes

💡 왜 CONFIG SET으로 안 바꾸고 새로 띄우나요?

CONFIG SET appendonly yes로 켜면 재시작 시 원래의 설정(AOF 비활성화)으로 되돌아가며 부팅되어 AOF 파일을 무시해버립니다. 따라서 시작 옵션에 --appendonly yes를 고정해야 합니다.

2. 데이터 50개 연속 입력 (SAVE 명령어 없음!)

이번에는 수동으로 저장하는 과정 없이 그냥 데이터를 밀어 넣습니다.

bash
# [Mac / Linux (Bash)]
for i in {1..50}; do docker exec my-redis redis-cli SET key:$i val:$i; done

# [Mac / Linux (Bash): Redis에 비밀번호가 설정된 경우]
for i in {1..50}; do docker exec my-redis redis-cli -a <비밀번호> SET key:$i val:$i; done
powershell
# [Windows (PowerShell)]
1..50 | ForEach-Object { docker exec my-redis redis-cli SET "key:$_" "val:$_" }

# [Windows (PowerShell): Redis에 비밀번호가 설정된 경우]
1..50 | ForEach-Object { docker exec my-redis redis-cli -a <비밀번호> SET "key:$_" "val:$_" }

3. 장애 상황 재현 및 복구 확인

bash
# 서버 전원 강제 차단 (Kill) 후 다시 켜기
docker kill my-redis
docker start my-redis

# 데이터 개수 확인
docker exec -it my-redis redis-cli DBSIZE

# 데이터 개수 확인 (Redis에 비밀번호가 설정된 경우)
docker exec -it my-redis redis-cli -a <비밀번호> DBSIZE

# 결과: (integer) 50

💡 [실무 Tip] 실제 운영 환경에서는 SAVE를 절대 쓰지 않습니다!

시연에서는 즉각적인 저장을 위해 SAVE를 사용했지만, 이 명령어는 데이터 저장이 끝날 때까지 서버의 메인 스레드를 완전히 멈추게(Blocking) 만듭니다. 실무에서는 다음과 같은 방식으로 RDB를 관리합니다.

  1. 자동 저장 (redis.conf): 설정된 조건을 만족하면 백그라운드에서 저장합니다. - [실무]: 실무에서는 트래픽이 폭주할 때 조건이 충족되어 예기치 않게 스냅샷 작업이 돌면서 서버에 렉(Latency Spike)이 걸리는 것을 방지하기 위해 자동 저장을 아예 끄는 경우가 있습니다. save ""(빈 문자열)로 설정하여 자동 저장을 끄고, AOF만 켜두거나 배치 백업을 활용합니다.
  2. 배치 백업 (BGSAVE): 트래픽이 가장 적은 새벽 시간에 자식 프로세스를 생성해 비동기적으로 저장하는 BGSAVE 명령어를 사용합니다.
  3. 클라우드 스냅샷: AWS ElastiCache 등 관리형 서비스를 이용해 클릭 한 번으로 자동 스냅샷 백업을 예약합니다.

🚩 정리 — 한 줄로 남기는 선택 기준

  • RDB: 특정 시점의 메모리 상태를 통째로 저장하는 스냅샷 방식 (빠른 복구)
  • AOF: 실행된 명령을 모두 기록하여 재실행으로 데이터를 복구하는 로그 방식 (데이터 안전)
  • 백업 파일(dump.rdb)은 서버 안에만 두지 말고 주기적으로 별도의 클라우드 저장소(AWS S3 등)에 복사해 두는 것이 안전합니다.

6-5. INFO·MONITOR·SLOWLOG로 서버 상태 읽어내기

이 글에서 익힐 진단법

  • Redis의 전체 상태를 한눈에 파악하는 INFO 명령어의 활용법을 익힙니다.
  • 실시간으로 들어오는 명령어를 추적하는 MONITOR의 장단점을 이해합니다.
  • 성능 병목의 주범을 찾아내는 SLOWLOG 진단법을 배웁니다.

1. INFO — 한 번에 받아보는 종합 지표

INFO는 Redis 내부 상태를 확인하는 가장 기본적인 진단 명령어이며, 운영 환경에서도 안전하게 사용할 수 있습니다.

INFO는 Redis 서버의 거의 모든 정보를 텍스트 형태로 출력합니다. 내용이 너무 많으므로, 실무에서는 필요한 섹션만 골라보는 것이 중요합니다.

💡 주요 섹션별 핵심 확인 지표

CLI에서 INFO memory나 INFO stats처럼 원하는 섹션 이름만 붙여서 조회할 수 있습니다.

명령어 확인해야 할 핵심 지표 의미
INFO memory used_memory_human 현재 Redis가 사용 중인 순수 메모리 용량
mem_fragmentation_ratio 메모리 단편화 비율 (1.0에 가까울수록 좋음)
INFO clients connected_clients 현재 연결된 클라이언트(애플리케이션 서버 등) 수
INFO stats instantaneous_ops_per_sec 초당 처리 중인 명령어 수 (TPS 파악용)
keyspace_hits / misses 캐시에 적중한 횟수 / 실패한 횟수
INFO cpu used_cpu_sys / user Redis 프로세스가 사용하는 누적 CPU 시간

2. MONITOR — 지금 들어오는 명령을 그대로 보기

현재 Redis 서버에 들어오는 모든 명령어를 실시간으로 터미널에 뿌려주는 명령어입니다.

✅ 언제 쓰나요?

"어플리케이션에서 지금 Redis에 데이터를 제대로 쏘고 있나?"를 확인하고 싶을 때, 개발/테스트 환경에서 훌륭한 디버깅 도구가 됩니다.

redis
127.0.0.1:6379> MONITOR
OK
1715432100.123456 [0 127.0.0.1:54321] "SET" "user:1" "Kim"
1715432101.987654 [0 127.0.0.1:54321] "GET" "user:1"
# (Ctrl+C를 눌러서 종료합니다)

🛑 [매우 주의] 운영 환경에서는 절대 사용 금지!

MONITOR는 모든 명령어를 실시간으로 스트리밍하기 때문에 트래픽이 많은 운영 환경에서는 심각한 성능 저하를 유발할 수 있습니다. 따라서 운영 서버에서는 사용을 피해야 합니다.

3. SLOWLOG — 느린 명령어의 범인 찾기

설정한 시간보다 오래 걸린 명령어들을 기록해두는 '블랙박스' 같은 기능입니다. 서버 렉(Latency)의 주범인 O(N) 명령어를 찾아내는 데 최고의 도구입니다.

예를 들어 KEYS * 같은 O(N) 명령어는 데이터가 많을수록 실행 시간이 길어져 SLOWLOG에 기록될 수 있습니다.

설정값 의미 사용 상황
10000 (10ms) 가장 일반적인 설정 대부분 서비스
5000 (5ms) 좀 더 엄격한 모니터링 고성능 서비스
1000 (1ms) 매우 민감한 분석 성능 튜닝
0 모든 명령 기록 테스트 / 디버깅
-1 SLOWLOG 비활성화 거의 사용 안함

⚙ 설정 및 조회 방법

redis
# 1. 기준 시간 설정: 10ms(10,000 microseconds) 이상 걸리는 명령어 기록
127.0.0.1:6379> CONFIG SET slowlog-log-slower-than 10000

# 2. 최근 슬로우 로그 10개 조회
127.0.0.1:6379> SLOWLOG GET 10

🧐 로그 결과 해석하기

text
1) 1) (integer) 14         # 1. 일련번호 (ID)
   2) (integer) 1715432200 # 2. 발생 시각 (Unix Timestamp)
   3) (integer) 15000      # 3. 실행 시간 (Microseconds, 이 경우 15ms 소요)
   4) 1) "KEYS"            # 4. 실행된 명령어와 인자 (범인 검거!)
      2) "*"

SLOWLOG에 기록되는 시간은 네트워크 지연을 제외한 Redis 내부 명령어 실행 시간만 측정한 값입니다.

4. 매일 봐야 할 지표와 경보 기준

장애를 예방하고 건강한 Redis를 유지하기 위해 반드시 체크해야 할 3가지 수치입니다.

🥇 1. 캐시 히트율 (Cache Hit Rate)

  • 확인: INFO stats
  • 계산법: keyspace_hits / (keyspace_hits + keyspace_misses)
  • 진단: 이 비율이 너무 낮다면(예: 50% 미만), 캐시 전략이 잘못되었거나 TTL(만료 시간)이 너무 짧게 설정되어 DB로 트래픽이 새고 있다는 뜻입니다. 애플리케이션 로직을 점검해야 합니다.
히트율 해석
90% 이상 매우 좋은 캐시
80% 이상 일반적으로 정상
70% 이하 캐시 전략 점검 필요

🥈 2. 커넥션 수의 급증 (Connection Leaks)

  • 확인: INFO clients
  • 지표: connected_clients
  • 진단: 이 수치가 우상향 곡선을 그리며 계속 높아진다면, 애플리케이션(Node.js, Spring, FastAPI 등)에서 Redis 커넥션을 맺고 제대로 닫지 않는(Close) 버그가 있을 확률이 매우 높습니다. 즉 계속 증가한다면 커넥션 누수(Connection Leak) 또는 커넥션 풀 설정 문제일 가능성이 있습니다.

🥉 3. 메모리 단편화 (Fragmentation)

  • 확인: INFO memory
  • 지표: mem_fragmentation_ratio (= OS가 할당한 메모리 / Redis 실제 데이터 메모리)
  • 진단: 데이터가 충분히 쌓인 운영 환경에서 이 수치가 1.5 이상으로 높다면, Redis가 저장한 실제 데이터 양보다 OS로부터 쓸데없이 많은 메모리를 할당받아 점유하고 있다는 뜻입니다.
값 의미
1.0 ~ 1.2 정상
1.3 ~ 1.5 약간 단편화
1.5 이상 메모리 낭비 가능성
  • 참고: 데이터가 거의 없는 로컬 환경에서는 ratio가 매우 크게 나오는 것이 정상입니다.
  • 해결: CONFIG SET activedefrag yes 명령어를 통해 조각모음을 실행하거나, 서비스가 한산한 시간에 서버를 재시작해야 합니다.

💡 [실무 Tip] 내 로컬 환경에서는 수치가 너무 높게 나오는데요?

mem_fragmentation_ratio는 Redis에 저장된 데이터가 거의 없을 때(서버 시작 직후, 로컬/테스트 환경) 매우 크게 나올 수 있습니다. Redis가 사용하는 jemalloc 할당자는 성능 최적화를 위해 미리 일정량의 메모리를 확보해두는 특성이 있습니다. 예를 들어 실제 데이터(used_memory)는 1MB인데 OS가 잡아준 메모리(used_memory_rss)가 16MB라면 비율은 16 / 1 = 16이 됩니다.

따라서 실무에서는 Ratio 숫자만 볼 것이 아니라, used_memory와 used_memory_rss의 실제 용량 차이를 함께 확인해야 합니다. 데이터가 적은 로컬 환경에서의 높은 수치는 정상이니 안심하세요!

🚩 정리 — 장애 전에 잡히는 신호들

  • INFO는 전체적인 정기 검진, SLOWLOG는 렉의 원인을 찾는 정밀 검사, MONITOR는 응급 상황 디버깅용입니다.
  • 운영 중인 서버에서 MONITOR와 KEYS *는 절대 금지 명령어라는 것을 다시 한번 명심하세요.
  • CLI 명령어 외에도 RedisInsight, Grafana 같은 시각화 도구를 연동하면 훨씬 직관적이고 효율적인 모니터링 체계를 구축할 수 있습니다.

6-6. [절대 금기] 운영 서버를 무너뜨리는 안티패턴 8가지

"Redis는 정말 빠릅니다. 하지만 잘못 쓰는 순간, 여러분의 서비스는 가장 빠르게 멈출 것입니다."

실무에서 발생하는 Redis 장애의 약 70%는 복잡한 아키텍처 문제가 아니라, 아주 사소한 '안티패턴'에서 시작됩니다. Redis 공식 가이드를 기반으로 개발자가 반드시 피해야 할 대표적인 함정과 그 탈출구를 파헤쳐 봅니다.

안티패턴 1. O(N) 명령어 남용 — 한 줄로 전체 정지

명령어의 실행 시간이 데이터 개수(N)에 비례하는 명령어들입니다. 싱글 스레드인 Redis에서 이 명령어들이 실행되는 동안 다른 모든 요청은 멈춥니다(Blocking).

❌ 주요 범인들 (Why it's a problem)

  • KEYS *
    • 역할: 데이터베이스 내의 모든 키 목록을 가져옵니다.
    • 문제: 수백만 개의 키가 있을 때 이를 다 훑는 동안 Redis가 멈춥니다. 개발 환경에서는 키가 적어 빠르지만, 운영 환경에서는 대형 장애의 1순위 원인입니다.
  • HGETALL [key] / SMEMBERS [key]
    • 역할: 특정 Hash나 Set 안에 있는 모든 필드/멤버를 한 번에 가져옵니다.
    • 문제: 만약 특정 Hash에 필드가 수십만 개 들어있다면, 이 하나의 키를 읽기 위해 네트워크 대역폭이 고갈되고 Redis CPU가 장시간 점유됩니다.
  • FLUSHALL / FLUSHDB
    • 역할: 전체 또는 현재 DB의 모든 데이터를 즉시 삭제합니다.
    • 문제: 데이터가 많을 경우 삭제 연산 자체도 무겁지만, 삭제 후 디스크(AOF/RDB) 동기화 작업이 수반되면서 시스템이 장시간 응답 불능 상태에 빠집니다.

✅ Solution: SCAN 계열 명령어 활용

위험한 O(N) 명령어 대신, 커서(Cursor)를 기반으로 데이터를 조금씩 끊어서 가져오는 SCAN 계열 명령어를 사용해야 합니다. 한 번 호출될 때마다 소량의 데이터만 반환하므로, 중간중간 다른 클라이언트의 요청이 처리될 수 있어 싱글 스레드 블로킹을 방지합니다.

  • SCAN : KEYS *의 안전한 대안입니다. 전체 데이터베이스의 키 목록을 순회하며 검색합니다.
  • HSCAN : HGETALL의 안전한 대안입니다. 거대한 Hash 내부의 필드와 값들을 페이징하듯 끊어서 가져옵니다.
  • SSCAN / ZSCAN : SMEMBERS나 ZRANGE를 대체하여 거대한 Set이나 Sorted Set의 멤버들을 안전하게 순회합니다.

💡 실무 팁: 운영 환경에서는 redis.conf 설정 파일에 rename-command KEYS ""를 추가하여 위험한 명령어 자체를 비활성화하는 것이 정석입니다.

안티패턴 2. TTL 누락 — 조용히 차오르는 메모리

모든 데이터에 수명을 정해주지 않으면 메모리는 언젠가 반드시 가득 차고 서버는 중단(OOM)됩니다.

✅ Solution

  • Set-on-Write: Strings를 쓸 때는 반드시 SET ... EX 옵션을 한 묶음으로 습관화하세요.
  • Default TTL: 비즈니스 로직 상 영구 저장이 필요한 핵심 데이터가 아니라면, 무조건 최소한의 TTL(예: 30일)이라도 설정하는 "방어적 설계"가 필수입니다.

💡 참고: 데이터 타입별 권장 TTL 설정 방법

Redis는 모든 자료구조에 대해 Key 단위로 만료 시간을 설정할 수 있습니다. 각 자료형의 성격에 맞는 최적의 방법을 사용해야 합니다.

데이터 타입 권장 설정 방식 (Best Practice) 예시 명령어
String 저장과 동시에 설정 (Atomic 연산) SET session:1 "data" EX 3600
Hash 키 생성 후 별도 설정 HSET user:1 f v + EXPIRE user:1 3600
List / Set 요소 추가 후 별도 설정 LPUSH log "msg" + EXPIRE log 86400
Common 기존 데이터 수명만 연장 EXPIRE key 600

💡 최신 팁 (Redis 7.4+): 최신 버전에서는 HEXPIRE 명령어를 통해 Hash 내부의 전체가 아닌 '특정 필드'만 골라서 TTL을 설정할 수 있어 메모리 관리가 더욱 정교해졌습니다.

안티패턴 3. Big Key — 키 하나가 서버를 붙잡는다

하나의 키에 너무 많은 데이터(수십 MB 이상)가 담겨 있는 상태입니다. 네트워크 병목의 주범입니다.

❌ Anti-Pattern

  • 하나의 Hash 필드에 거대 JSON 문서나 이미지 바이너리를 통째로 밀어 넣기.
  • 하나의 List에 LTRIM(크기 제한) 없이 무한정 로그성 데이터를 밀어 넣기.

✅ Solution

  • 큰 데이터는 S3 같은 오브젝트 스토리지에 저장하고, Redis에는 그 URL(경로)만 캐싱하세요.
  • 주기적으로 터미널에서 redis-cli --bigkeys 명령어를 실행하여 서버 내의 거대 키를 모니터링하고 쪼개기(Sharding) 작업을 수행하세요.

🛠 [실습] Big Key 탐지 및 테스트 환경 셋업

실제로 어떤 키가 메모리를 많이 잡아먹는지 찾아보고, 실습을 위해 강제로 "Big Key"를 만들어 보는 과정을 진행합니다.

① [준비] 테스트용 거대 데이터 생성 스크립트

파이썬을 이용해 Redis에 의도적으로 큰 데이터를 밀어 넣습니다.

python
import redis

rd = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)


def setup_big_keys():
    print("🚀 테스트용 Big Key 생성 중...")

    # 1. 거대 String (약 10MB 문자열)
    big_str = "A" * (1024 * 1024 * 10)
    rd.set("bigkey:string", big_str)

    # 2. 필드가 너무 많은 Hash (10만 개 필드)
    pipe = rd.pipeline()
    for i in range(100000):
        pipe.hset("bigkey:hash", f"field:{i}", f"value:{i}")
        if i % 10000 == 0:
            pipe.execute()
    pipe.execute()

    # 3. 요소가 너무 많은 List
    for i in range(50000):
        pipe.lpush("bigkey:list", f"item:{i}")
    pipe.execute()

    print("✅ 생성 완료! 이제 'bigkeys' 명령으로 찾아보세요.")


if __name__ == "__main__":
    setup_big_keys()

② 내 서버의 '범인(Big Key)' 찾기

데이터가 생성되었다면 터미널에서 다음 명령어를 실행하여 리포트를 확인합니다.

bash
# 운영 중인 서버에 부하를 최소화하며 타입별로 가장 큰 키를 찾아줍니다.
docker exec my-redis redis-cli --bigkeys

💡 [운영 서버 실행 가이드] --bigkeys는 운영 중에 사용해도 안전할까요?

네, 비교적 안전합니다! 내부적으로 위험한 KEYS *가 아닌 안전한 SCAN 명령어를 사용하여 데이터를 조금씩 끊어서 검사하기 때문에 싱글 스레드인 레디스 서버가 멈추는 대형 장애를 유발하지 않습니다.

하지만 전체 키를 순회하며 크기를 계산하므로 CPU 자원을 꽤 소모합니다. 따라서 트래픽이 폭발하는 피크 타임에는 실행을 피하고, 부하가 1%라도 걱정되는 매우 민감한 서버라면 아래와 같이 -i(interval) 옵션을 주어 스캔 중간중간에 휴식 시간을 부여하는 방식을 권장합니다.

bash
# 0.1초 간격으로 쉬어가며 더욱 안전하게 스캔 (부하 최소화)
docker exec my-redis redis-cli --bigkeys -i 0.1

[결과 분석 및 해석 가이드]

text
Sampled 3 keys in the keyspace!
Total key length in bytes is 35 (avg len 11.67)

Biggest   list found "bigkey:list" has 50000 items
Biggest   hash found "bigkey:hash" has 100000 fields
Biggest string found "bigkey:string" has 10485760 bytes

1 lists with 50000 items (33.33% of keys, avg size 50000.00)
1 hashs with 100000 fields (33.33% of keys, avg size 100000.00)
0 streams with 0 entries (00.00% of keys, avg size 0.00)
1 strings with 10485760 bytes (33.33% of keys, avg size 10485760.00)
0 sets with 0 members (00.00% of keys, avg size 0.00)
0 zsets with 0 members (00.00% of keys, avg size 0.00)

위 리포트는 크게 3가지 파트로 나뉘어 서버의 상태를 알려줍니다.

1. 전체 스캔 요약 (맨 윗부분)

  • Sampled 3 keys in the keyspace! : 현재 데이터베이스에서 총 3개의 키를 검사했다는 뜻입니다.
  • Total key length... : 데이터의 용량이 아니라, 키 '이름' 자체의 텍스트 길이 합산 및 평균입니다. (예: "bigkey:list"라는 이름의 길이)

2. 자료형별 1등 (중간 부분 - 가장 중요!)

각 데이터 타입별로 가장 덩치가 큰 키(Biggest key) 하나씩을 찾아내어 고발합니다. 이 녀석들이 바로 서버를 느려지게 만드는 주범(Hot/Big Key)일 확률이 높습니다.

  • List: bigkey:list라는 키가 가장 크며, 50,000개의 데이터(items)가 들어있습니다.
  • Hash: bigkey:hash 키가 100,000개의 필드(fields)를 가지고 있습니다.
  • String: bigkey:string 키가 10,485,760 bytes(10MB)의 용량을 차지하고 있습니다.
  • 주의: String 타입만 용량(bytes)으로 표시되고, 나머지 자료구조는 요소의 '개수'로 표시됩니다.

3. 전체 통계 요약 (아래 부분)

현재 Redis에 어떤 자료구조가 몇 퍼센트 비율로 존재하는지, 그리고 각 자료구조당 평균 크기가 얼마인지 보여주어 전반적인 데이터 저장 트렌드를 파악할 수 있게 해줍니다.

안티패턴 4. Hot Key — 특정 키에 트래픽이 몰릴 때

❌ Anti-Pattern

수백만 명의 유저가 동시에 하나의 키(예: 이벤트 당첨자 공지글)를 조회합니다. 아무리 Redis가 빨라도 하나의 물리적 서버/스레드에 초당 수십만 건이 꽂히면 장비가 견디지 못합니다.

✅ Solution

  • 로컬 캐시 혼용: 애플리케이션 서버 메모리(로컬)에 1~2초간 1차 캐싱을 두어 Redis로 가는 요청 자체를 차단합니다.
  • 키 복제(Sharding): notice:event:1, notice:event:2처럼 같은 데이터를 여러 키로 복제해두고, 클라이언트가 랜덤하게 읽어가도록 분산시킵니다.

안티패턴 5. 파이프라인 미사용 — 왕복 비용 낭비

Redis 자체는 1초에 10만 건을 처리할 수 있지만, 네트워크 지연(RTT, Round Trip Time) 때문에 실제 앱에서는 1천 건도 처리하지 못하는 경우가 많습니다.

❌ Anti-Pattern

for문 안에서 SET 명령어를 1,000번 반복해서 호출합니다. 이 경우 네트워크 왕복 1,000번의 시간이 낭비됩니다.

✅ Solution

파이프라인(Pipeline) 을 사용하세요. 명령어를 메모리에 모아두었다가 한 번의 네트워크 통신으로 1,000개의 명령을 뭉쳐서 보내고, 결과도 한 번에 받아옵니다. 속도가 수십 배 향상됩니다.

python
import asyncio
import redis.asyncio as redis


async def pipeline_example(rd: redis.Redis):
    # 1. 파이프라인 객체 생성
    # transaction=True (기본값): 내부적으로 MULTI/EXEC를 호출하여 원자성까지 보장합니다.
    # transaction=False: 순수하게 네트워크 왕복(RTT)만 줄입니다. (속도 최적화 시 권장)
    pipe = rd.pipeline(transaction=True)

    # 2. 명령어를 서버로 바로 보내지 않고 파이프라인 메모리에 모아둠
    for i in range(1000):
        pipe.set(f"key:{i}", f"value:{i}")

    # 3. 한 번의 네트워크 통신으로 1000개의 명령을 일괄 전송 및 실행
    await pipe.execute()

안티패턴 6. 요청마다 커넥션 생성 — 연결 고갈

❌ Anti-Pattern

API가 호출될 때마다 파이썬 로직 내부에서 redis.from_url()을 호출하여 연결을 맺고 끊기를 반복합니다. DB 연결 수립(Handshake) 과정은 매우 비싼 작업입니다.

✅ Solution

반드시 연결 풀(Connection Pool) 을 사용하세요. FastAPI의 경우 lifespan 이벤트 안에서 서버 구동 시 딱 한 번 풀을 생성하여 app.state.redis에 담아두고 전역적으로 재사용해야 합니다.

python
from contextlib import asynccontextmanager
from fastapi import FastAPI
import redis.asyncio as redis


@asynccontextmanager
async def lifespan(app: FastAPI):
    # 서버 시작 시: Connection Pool을 한 번만 생성하고 앱 상태에 보관
    app.state.redis = redis.from_url("redis://localhost:6379/0", decode_responses=True)

    yield

    # 서버 종료 시: 안전하게 모든 커넥션을 반환하고 풀을 닫음
    await app.state.redis.aclose()


app = FastAPI(lifespan=lifespan)

안티패턴 7. JSON 통째 저장 — 필드 하나에 전체 전송

객체나 구조화된 JSON 데이터를 직렬화하여 하나의 거대한 String으로 통째로 저장하는 것은 비효율을 초래합니다.

❌ Anti-Pattern

수많은 필드를 가진 JSON 객체를 json.dumps()로 변환하여 SET user:1 '{"name":"Kim", "age":30, "point":100, ...}'와 같이 저장합니다.

  • 부분 수정 불가 (No atomic field updates): point 값 하나만 101로 변경하고 싶어도, 거대한 전체 JSON을 GET으로 읽어오고 파싱하여 수정한 뒤, 다시 전체를 SET으로 덮어써야 합니다. 이 과정에서 동시성 문제(Race Condition)가 발생할 수 있습니다.
  • 비싼 파싱 비용 (Expensive parsing): 불필요하게 큰 데이터를 매번 직렬화/역직렬화해야 하므로 애플리케이션 서버의 CPU와 메모리를 낭비합니다.

✅ Solution

  • Hashes 자료구조 활용: 객체의 각 속성을 개별 필드(Field)로 저장하세요. HINCRBY를 사용하면 전체 데이터를 읽어올 필요 없이 point 필드만 원자적(Atomic)으로 증가시킬 수 있습니다.
  • RedisJSON 모듈 사용: 복잡하게 중첩된 JSON 구조라면 Redis Stack에서 제공하는 RedisJSON을 활용하세요. JSON 구조 원본을 그대로 저장하면서 JSON.SET, JSON.GET 명령어를 통해 내부 특정 경로(Path)의 값만 즉시 읽거나 수정할 수 있습니다.

안티패턴 8. SELECT로 DB 번호 나눠 쓰기

Redis는 기본적으로 0번부터 15번까지 16개의 논리적 데이터베이스를 제공합니다.

❌ Anti-Pattern

"0번 DB는 유저 정보, 1번 DB는 캐시, 2번 DB는 로그..." 이런 식으로 SELECT 1, SELECT 2 명령어를 통해 논리적 DB를 전환해 가며 사용합니다.

✅ Solution

  • 논리적 DB 전환은 과거의 유물입니다. Redis 창시자인 Salvatore Sanfilippo조차 이를 설계상 최악의 실수로 꼽았습니다.
  • SELECT 명령어를 사용하는 것 자체가 오버헤드이며, 대규모 분산 환경인 Redis Cluster에서는 0번 DB 하나만 지원하므로 확장이 불가능해집니다.
  • 해결책: 데이터 구분은 DB 번호가 아니라 Key 네이밍 규칙(예: user:*, cache:*)을 콜론(:)으로 분리하여 해결하는 것이 모던 Redis의 표준입니다.

6-7. Prometheus·Grafana로 Redis 대시보드 세우기

이 글에서 구축할 관측 환경

  • CLI 기반 모니터링(INFO)의 한계를 극복하는 시각화 대시보드의 필요성을 이해합니다.
  • Docker Compose, Redis Exporter, Prometheus, Grafana의 역할을 이해합니다.
  • 실무에서 반드시 관제해야 할 핵심 지표(Key Metrics) 를 해석하는 안목을 기릅니다.

1. 수집과 시각화를 분리해서 얻는 것

이전 시간에 배운 INFO와 같은 Redis 모니터링 명령어는 Redis의 '현재 시점'의 상태만 텍스트 형태로 보여줍니다. 하지만 실무에서는 다음과 같은 기능이 필수적입니다.

  • 과거 데이터 추적: "오늘 새벽 3시에 왜 갑자기 서버가 느려졌지?"를 확인하려면 과거의 트렌드 그래프가 필요합니다.
  • 시각화 (Visualization): 직관적인 Chart와 Gauge 같은 시각화 도구로 봐야 한눈에 파악하고 즉각적인 이상 탐지가 가능합니다.
  • 알람 (Alerting): 메모리 80% 초과나 캐시 히트율 50% 이하와 같은 경고 상황 발생 시, Slack이나 이메일로 즉시 알림을 받아야 합니다.

이와 같은 모니터링 시스템 구축을 위해 현업에서 가장 많이 사용하는 오픈소스 조합이 바로 Prometheus + Grafana입니다.

2. exporter에서 대시보드까지의 데이터 경로

  1. Docker Compose(오케스트레이션): 여러 컨테이너를 한 번에 실행하고 서로 통신할 수 있게 묶어주는 도구입니다.
  2. Redis Exporter (통역사): Redis의 INFO 데이터를 읽어서 → Prometheus가 수집할 수 있는 /metrics 형식으로 변환해 노출합니다.
  3. Prometheus (데이터 수집 + 시계열 DB): Exporter의 /metrics를 주기적으로 수집(Scrape)해서 → 시계열 데이터베이스에 저장합니다.
  4. Grafana (시각화 도구): Prometheus 데이터를 가져와 → Chart, Gauge, Dashboard 형태로 시각화합니다.

"Prometheus가 데이터를 모아서 저장하고, Grafana가 그것을 시각화한다"

3. 🛠 [실습] Docker Compose로 한 번에 띄우기

① 작업 폴더 만들기

원하는 위치에 redis-monitor라는 이름의 새 폴더를 하나 만듭니다. 앞으로 만들 두 개의 설정 파일은 모두 이 폴더 안에 저장합니다.

② docker-compose.yml 파일 작성

새로 만든 폴더 안에 docker-compose.yml 파일을 생성하고 아래 내용을 붙여넣습니다.

❓ 기존에 생성한 Redis를 모니터링하나요?

아래 설정은 아무것도 없는 상태에서 테스트용 새 Redis 컨테이너와 모니터링 도구를 함께 띄우는 기본 구성입니다.

[기본 설정: 새 Redis + 모니터링 도구 함께 실행]

yaml
services:
  # 1. 모니터링 대상인 Redis 서버 (새로 생성됨)
  redis:
    image: redis:latest
    container_name: my-redis-monitor
    ports:
      - "127.0.0.1:6379:6379"  # 로컬 PC에서만 접근 가능하도록 바인딩

  # 2. Redis Exporter(통역사)
  redis-exporter:
    image: oliver006/redis_exporter:latest
    environment:
      - REDIS_ADDR=redis://redis:6379
      # - REDIS_PASSWORD=your_password  # Redis에 비밀번호(requirepass)가 설정된 경우 사용
    ports:
      - "127.0.0.1:9121:9121"
    depends_on:
      - redis

  # 3. Prometheus (데이터 수집 + 시계열 DB)
  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
      - prometheus-data:/prometheus
    ports:
      - "127.0.0.1:9090:9090"

  # 4. Grafana (시각화 대시보드)
  grafana:
    image: grafana/grafana:latest
    ports:
      - "127.0.0.1:3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin
    volumes:
      - grafana-data:/var/lib/grafana

# 도커에서 관리하는 가상 디스크(볼륨) 선언
volumes:
  prometheus-data:
  grafana-data:

🚨 [트러블슈팅] 포트 충돌(port is already allocated) 에러가 발생하나요?

이전에 실습하며 띄워 둔 기존 my-redis 컨테이너가 6379 포트를 사용 중이라서 발생하는 에러입니다. 두 가지 해결책이 있습니다.

  1. 기존 것 끄기: 터미널에서 docker rm -f my-redis 명령어로 기존 컨테이너를 지워버린 후 다시 실행하세요.
  2. 기존 것 그대로 쓰기 (추천): 현재 복사한 내용을 지우고, 바로 아래에 있는 🔥 [기존 Redis 사용] 코드를 복사해서 사용하세요!

🔥 기존 Redis 사용

이전 실습에서 생성한 my-redis를 모니터링하려면 아래 설정을 사용하세요.

yaml
services:
  # 2. Redis Exporter (통역사)
  redis-exporter:
    image: oliver006/redis_exporter:latest
    environment:
      # Docker Desktop 환경(Mac/Windows)에서 호스트 PC의 6379 포트로 접속하는 특수 주소입니다.
      - REDIS_ADDR=redis://host.docker.internal:6379
      # - REDIS_PASSWORD=your_password
    ports:
      - "127.0.0.1:9121:9121"

  # 3. Prometheus (데이터 수집/저장소)
  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
      - prometheus-data:/prometheus
    ports:
      - "127.0.0.1:9090:9090"

  # 4. Grafana (시각화 대시보드)
  grafana:
    image: grafana/grafana:latest
    ports:
      - "127.0.0.1:3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin
    volumes:
      - grafana-data:/var/lib/grafana

volumes:
  prometheus-data:
  grafana-data:

③ prometheus.yml 설정 파일 작성

같은 폴더(redis-monitor)에 prometheus.yml 파일을 만들고, Prometheus가 Exporter를 수집할 수 있도록 아래와 같이 설정합니다.

yaml
global:
  # 실습에서는 그래프 변화를 빨리 보기 위해 5초로 설정
  # (실무에서는 보통 15초를 많이 사용)
  scrape_interval: 5s

scrape_configs:
  - job_name: 'redis_exporter'
    static_configs:
      - targets: ['redis-exporter:9121']

④ 실행 및 접속

터미널을 열고 해당 폴더(redis-monitor)로 이동한 뒤 아래 명령어를 실행합니다. ("docker-compose.yml에 정의된 모든 컨테이너를 백그라운드에서 한 번에 실행하는 명령어")

bash
docker compose up -d

이제 브라우저에서 http://localhost:3000으로 Grafana에 접속합니다. 초기 로그인 계정은 admin/admin입니다.

4. 📊 대시보드에서 실제로 볼 지표 고르기

Grafana에 접속한 후, 먼저 Data Sources에서 Prometheus(http://prometheus:9090)를 연결합니다. 그 다음 좌측 메뉴에서 Dashboards → Import를 클릭하여 대시보드 ID를 입력합니다.

🎨 추천 대시보드 ID

  1. ID 14091 (추천 ⭐): 최신 UI 기반으로 가장 직관적이고 실습에 적합한 템플릿.
  2. ID 763 (클래식 대안): Redis Exporter 공식 대시보드로 꾸준히 사용됨.
  3. ID 11835 (HA 대안): Helm / 클러스터 환경에 적합한 변형 버전.

💡 [설정 경로] http://localhost:3000 ➔ Dashboards ➔ Import ➔ ID 입력 ➔ Prometheus 선택 ➔ Import

🔥 반드시 봐야 할 4대 핵심 지표

14091 대시보드의 많은 패널 중 다음 4가지 지표를 예의주시해야 합니다.

🥇 1. Total Memory Usage (메모리 사용량)

  • 그래프 의미: 사용 메모리 / maxmemory 비율.
  • 위험 신호: 지속적으로 증가하며 80% 이상.
  • 대처법: 키 정리 / 스케일업 / 클러스터 고려.

💡 실무 팁 — 80%는 기준일 뿐이며, eviction 정책에 따라 임계치는 달라질 수 있습니다.

🥈 2. Hit ratio per instance (캐시 적중률)

  • 그래프 의미: Redis 조회 중 캐시 적중 비율 (keyspace_hits / (keyspace_hits + keyspace_misses))
  • 위험 신호: 평소 80~90%를 유지하다가 갑자기 50% 이하로 급락할 때.
  • 대처법: TTL 설정 오류 / 캐시 무효화 버그 점검.

🥉 3. Connected/Blocked Clients (연결된 클라이언트 수)

  • 그래프 의미: 현재 Redis 연결 수.
  • 위험 신호: 트래픽은 정상인데 계속 증가 (Connection Leak).
  • 대처법: Connection Pool 관리 확인.

🏅 4. Key evictions per second (강제 삭제된 키 수)

  • 그래프 의미: 메모리 부족으로 삭제된 키 수.
  • 위험 신호: 지속적으로 증가.
  • 대처법: 메모리 부족의 확실한 증거. 중요한 세션 데이터나 캐시가 쫓겨나고 있을 수 있으므로 즉시 용량을 증설 검토.

💡 [운영 심화 지표]

위 4가지 외에도, 대시보드 상단의 Command latency per second(명령어 처리 지연 시간)가 갑자기 튀어 오르는 현상(Spike)이 보인다면 누군가 무거운 KEYS * 같은 O(N) 명령어를 쳤을 확률이 높습니다.

"화려한 그래프보다 중요한 건, 이상 징후를 읽어내는 능력입니다." "Memory, Hit Rate, Clients, Eviction — 이 4가지만 봐도 대부분의 장애는 미리 보입니다."

5. 🛠 [부하 생성기] 그래프가 움직이는지 확인하기

현재 우리 테스트 환경에서는 트래픽이 거의 없어서, Redis를 모니터링하면 그래프가 일직선으로만 나와서 Redis 상황을 모니터링 할 수 없습니다. 파이썬 스크립트를 통해 강제로 메모리를 채우고 캐시 Hit/Miss를 유발하여 Grafana 대시보드가 실제 운영 서버처럼 움직이도록 만들어 봅시다.

① 트래픽 발생 스크립트 작성 (traffic_generator.py)

아래 코드를 파이썬 파일로 저장하고 실행해 주세요. (실행 전 pip install redis 필요)

💡 [스크립트 동작 요약]

  • TPS 폭주: 5개의 스레드와 파이프라인을 사용해 초당 수만 건의 명령(GET, SET, INCR)을 쏟아냅니다.
  • 메모리 요동: 500KB의 무거운 더미 데이터를 2~5초의 짧은 수명(TTL)으로 반복 주입하여 메모리가 들쑥날쑥 하도록 유도합니다.
  • 캐시 적중률 널뛰기: 넓은 범위의 무작위 키를 조회하여 의도적인 Cache Miss를 유발, 적중률 그래프를 출렁이게 합니다.
python
import redis
import time
import random
import threading

# 연결 풀을 넉넉하게 생성 (멀티스레드 지원)
pool = redis.ConnectionPool(
    host='localhost', port=6379, db=0, decode_responses=True, max_connections=50
)
rd = redis.Redis(connection_pool=pool)

print("🚀 [초강력 부하 테스트] 레디스 멀티스레드 트래픽 발생기를 시작합니다. (종료: Ctrl+C)")
print("💡 팁: Grafana 우측 상단의 자동 새로고침(Auto-refresh) 주기를 '5s'로 설정하세요!")

# 500KB 크기의 무거운 더미 데이터를 만들어 메모리 변동폭을 극대화합니다.
dummy_data = "A" * (1024 * 500)


def worker_thread(thread_id):
    """각 스레드에서 무한히 파이프라인 트래픽을 쏘는 워커 함수"""
    try:
        while True:
            # 파이프라인(Pipeline)을 사용해 한 번에 수백 개의 명령을 쏟아냅니다.
            pipe = rd.pipeline()

            for _ in range(200):
                # 1~10000 사이 랜덤 키 접근 (Cache Miss/Hit 유발)
                key_id = random.randint(1, 10000)
                cache_key = f"fake:data:{key_id}"

                # 1. 키 조회 시도
                pipe.get(cache_key)

                # 2. 10% 확률로 500KB 더미 데이터 쓰기 (Memory Usage 폭등 및 급락 유도)
                if random.random() < 0.1:
                    # 2~5초 사이의 아주 짧은 TTL 부여
                    pipe.set(cache_key, dummy_data, ex=random.randint(2, 5))

                # 3. 처리량 폭증 유발
                pipe.incr("fake:global:hits")

            # 장전된 명령(약 600개) 일괄 발사!
            pipe.execute()

            # 파이썬 CPU 과부하 방지를 위한 미세한 대기
            time.sleep(0.01)
    except Exception as e:
        print(f"Thread {thread_id} stopped: {e}")


# 5개의 스레드를 동시에 돌려 폭발적인 트래픽 융단폭격 생성!
threads = []
for i in range(5):
    t = threading.Thread(target=worker_thread, args=(i,))
    t.daemon = True  # 메인 프로그램 종료 시 스레드도 함께 종료되도록 설정
    t.start()
    threads.append(t)

# 메인 스레드는 종료 대기
try:
    while True:
        time.sleep(1)
except KeyboardInterrupt:
    print("\n🛑 트래픽 발생을 중단합니다.")

② 대시보드 관찰 포인트 (14091 대시보드 기준)

위 스크립트를 실행한 상태에서 대시보드를 보시면, 다음과 같은 변화를 관찰할 수 있습니다.

  • Commands per second: 5개의 스레드가 동시에 파이프라인을 쏘아대기 때문에, 초당 명령어 처리 수(TPS)가 가파르게 치솟습니다.
  • Total Memory Usage: 무거운 500KB 더미 데이터가 빠르게 채워졌다가 만료되면서 그래프가 오르락내리락 합니다.
  • Total Items per DB / Expiring vs Not-Expiring Keys: 스크립트에서 ex 옵션(TTL)을 부여해 데이터를 넣고 있으므로, 'Expiring(만료 예정인 키)' 그래프가 상승하는 것을 볼 수 있습니다.
  • Hit ratio per instance: 임의의 랜덤 키를 계속 GET하므로 Cache Miss가 다수 발생하며 캐시 적중률이 요동치는 모습을 관찰할 수 있습니다.

(참고) Docker 컴포즈 다시 실행

Docker Compose로 실행 중인 모든 컨테이너를 안전하게 중지(down)한 뒤, 변경된 설정을 반영하여 백그라운드에서 다시 실행(up -d)하는 명령입니다.

bash
docker compose down
docker compose up -d

# (참고)
# 볼륨까지 삭제 (데이터 제거)
docker compose down -v

# 이미지까지 다 정리
docker compose down --rmi all -v

🚩 정리 — 대시보드를 세운 다음에 할 일

  • 실무에서 INFO 명령어는 응급 처치용이며, 평상시 관제는 무조건 시각화 대시보드를 구축해서 봅니다.
  • Prometheus + Grafana 조합은 Redis뿐만 아니라 백엔드 서버, DB 등 모든 인프라를 모니터링하는 현대 클라우드 네이티브 생태계의 절대 표준입니다.
  • 화려한 그래프에 속지 마세요. 결국 중요한 것은 메모리(Memory), 적중률(Hit Rate), 연결 수(Clients), 쫓겨난 키(Evicted) 이 4가지 지표가 흔들리는지 파악하는 안목입니다.

6-8. Part 6 마무리 — 운영자로서 갖출 판단 기준

섹션 6. [Ops] Redis 운영 및 장애 대응 · 유닛 44 / 48 · 보충 학습 자료

이 글에서 정리할 관점

  • 지금까지 배운 내용을 하나의 의사결정 흐름으로 재구성합니다.
  • 실무에서 Redis를 다룰 때 지켜야 할 원칙을 체크리스트로 정리합니다.
  • 장애가 발생했을 때 즉시 꺼내 쓸 수 있는 대응 플레이북을 확보합니다.
  • 이 강의 이후 무엇을 더 학습해야 하는지 로드맵을 세웁니다.

1. Part 6에서 다룬 것을 한 장으로

text
                    [Redis = 메모리 위의 자료구조 서버]
                                  |
        +-------------------------+-------------------------+
        |                         |                         |
    [무엇을 담나]              [어떻게 쓰나]              [어떻게 지키나]
        |                         |                         |
  String  카운터/캐시         Cache-Aside 캐싱           TTL + Eviction
  List    큐/최근목록          분산 세션                  RDB / AOF
  Set     중복제거/태그        분산 락                    KEYS 금지, SCAN
  Hash    객체                Rate Limiting              SLOWLOG / INFO
  ZSet    랭킹/스케줄          실시간 랭킹                 Prometheus + Grafana
  Stream  이벤트 로그          Pub/Sub 알림               안티패턴 회피
  GEO     위치 검색            캐시 스탬피드 방어           모니터링과 알림
        |                         |                         |
        +-------------------------+-------------------------+
                                  |
                     [원자성이 필요하면: MULTI / Lua / Functions]

2. 실무에서 지켜야 할 열 가지 원칙

번호 원칙 이유와 실행 방법
1 모든 키에 TTL을 건다 TTL 없는 키는 언젠가 반드시 메모리를 채웁니다. 영구 보관이 필요하다면 그것은 캐시가 아니라 데이터베이스의 역할입니다
2 O(N) 명령을 운영에서 쓰지 않는다 KEYS, FLUSHALL, 큰 컬렉션의 SMEMBERS/HGETALL/LRANGE 0 -1은 싱글 스레드를 정지시킵니다. SCAN 계열로 대체합니다
3 Redis를 원본으로 삼지 않는다 Eviction과 장애로 데이터는 사라질 수 있습니다. 사라져도 서비스가 (느리게라도) 동작해야 합니다
4 키 이름은 규칙으로 통제한다 서비스:도메인:식별자:속성 형태로 통일하면 운영 중 추적과 일괄 무효화가 쉬워집니다
5 하나의 값이 커지지 않게 한다 거대 키(빅키)는 조회 한 번으로 지연을 유발합니다. 값은 수십 KB, 컬렉션 원소는 수천 개 이내를 목표로 합니다
6 원자성은 명령 수준에서 확보한다 읽고 판단해 쓰는 로직은 INCR, SET NX, Lua 스크립트로 한 번에 처리합니다
7 실패를 기본값으로 설계한다 Redis 장애 시 예외를 그대로 전파하지 말고 DB로 우회(degrade)합니다. 캐시는 부가 기능입니다
8 지표 없이 운영하지 않는다 히트율, 메모리 사용률, 초당 명령 수, 느린 명령, 커넥션 수. 최소 이 다섯 가지는 상시 관측합니다
9 변경은 항상 되돌릴 수 있게 한다 새 캐시 도입, TTL 변경, Eviction 정책 변경은 기능 플래그로 즉시 되돌릴 수 있어야 합니다
10 모르면 재현한다 문서와 추측 대신 로컬 컨테이너에서 직접 재현하십시오. Redis는 재현 비용이 가장 낮은 미들웨어입니다

3. 상황이 오면 어떤 순서로 판단할 것인가

새 기능에 Redis를 도입할 때 아래 순서대로 자문하면 대부분의 설계 실수를 예방할 수 있습니다.

text
Q1. 이 데이터가 사라져도 되는가?
      아니오 -> Redis 단독 저장 금지. DB를 원본으로 두고 캐시로만 사용
      예     -> 계속

Q2. 접근 패턴이 무엇인가? (단건 조회 / 범위 조회 / 순위 / 집합 연산 / 시계열)
      -> 패턴에 맞는 자료형 선택 (String / Hash / ZSet / Set / Stream)

Q3. 얼마나 오래 유지되어야 하는가?
      -> TTL 결정 + 지터 적용

Q4. 값이 커질 가능성이 있는가?
      예 -> 분할 저장 또는 페이지네이션 설계

Q5. 동시에 여러 요청이 같은 키를 수정하는가?
      예 -> INCR / SET NX / Lua로 원자성 확보

Q6. 캐시가 만료되는 순간 부하가 몰리는가?
      예 -> 락 또는 논리적 만료로 스탬피드 방어 (7-2, 7-3)

Q7. 이 키가 몇 개까지 늘어나는가? 메모리 총량은?
      -> 키 수 x 평균 크기로 추정하고 maxmemory와 대조

4. 장애가 났을 때 따라갈 순서

실제 장애 상황에서는 순서가 중요합니다. 아래 절차를 그대로 실행하십시오.

4.1 1단계 — 30초 안에 상태 파악하기

bash
# 응답 여부와 지연 확인
redis-cli ping
redis-cli --latency -i 3

# 핵심 지표 한눈에
redis-cli INFO stats     | grep -E "instantaneous_ops_per_sec|keyspace_hits|keyspace_misses|evicted_keys|rejected_connections"
redis-cli INFO memory    | grep -E "used_memory_human|maxmemory_human|mem_fragmentation_ratio"
redis-cli INFO clients   | grep -E "connected_clients|blocked_clients"
redis-cli INFO replication | head -5

4.2 2단계 — 지표를 보고 범위를 좁히기

증상 확인 명령 유력한 원인
전체 응답 지연 SLOWLOG GET 20 O(N) 명령, 큰 값 조회, 무거운 Lua 스크립트
메모리 급증 redis-cli --bigkeys, MEMORY USAGE <key> 빅키, TTL 누락, 컬렉션 무한 증가
키가 사라짐 INFO stats의 evicted_keys maxmemory 도달로 Eviction 발생
DB 부하 급증 히트율 hits/(hits+misses) 캐시 스탬피드, 대량 무효화, 콜드 스타트
커넥션 오류 INFO clients, CLIENT LIST 풀 미사용, 연결 누수, maxclients 초과
모든 명령이 BUSY - Lua 스크립트 폭주. SCRIPT KILL

4.3 3단계 — 이때 절대 하면 안 되는 것

장애 중 금지 행동

  • KEYS *로 상태를 확인하려 한다 — 멀쩡한 서버도 멈춥니다. --scan이나 DBSIZE를 쓰십시오.
  • FLUSHALL로 "일단 비우기" — 콜드 스타트로 DB가 즉시 무너집니다.
  • 원인 파악 없이 재기동 — 증거가 사라지고 캐시도 함께 날아갑니다.
  • MONITOR를 오래 켜 둔다 — 모든 명령을 출력하느라 성능이 크게 떨어집니다. 몇 초만 사용하십시오.
  • maxmemory를 즉흥적으로 크게 올린다 — OS 메모리를 잠식해 OOM Kill로 이어질 수 있습니다.

4.4 4단계 — 재발을 막는 사후 조치

  • 동일 장애의 재발을 막을 알림 조건을 추가합니다(히트율 급락, 메모리 80%, evicted_keys 증가).
  • 원인이 된 명령이나 키 패턴을 코드 리뷰 체크리스트에 반영합니다.
  • 타임라인 · 영향 범위 · 근본 원인 · 재발 방지책을 포스트모템으로 남깁니다. 사람을 탓하지 않고 시스템을 고칩니다.

5. 배포 직전에 훑을 점검 목록

번호 점검 항목 참고
1 새로 추가한 모든 키에 TTL이 설정되어 있는가 3-3
2 TTL에 지터를 적용했는가 7-2
3 키 네이밍이 프로젝트 규칙을 따르는가 6-1
4 O(N) 명령을 사용하지 않았는가 6-2
5 단일 값 또는 컬렉션이 과도하게 커질 수 있는가 6-6
6 동시성이 필요한 로직에 원자적 명령을 사용했는가 4-5, 4-6
7 Redis 장애 시 우회 경로가 있는가 6-6
8 캐시 무효화 시점이 명확히 정의되어 있는가 7-1
9 핫키에 스탬피드 방어가 적용되어 있는가 7-2, 7-3
10 예상 메모리 증가량을 계산했는가 6-3
11 새 지표를 대시보드에 추가했는가 6-7
12 클러스터 환경이라면 키 슬롯을 고려했는가 4-6

6. 여기서 더 나아가려면

주제 무엇을 배우나 언제 필요한가
고가용성 (Sentinel) 마스터 장애 시 자동 페일오버, 복제 구성 단일 Redis 장애가 서비스 중단으로 이어질 때
수평 확장 (Cluster) 해시 슬롯 분할, 리샤딩, 키 슬롯 설계 단일 노드 메모리 또는 처리량 한계에 도달했을 때
Redis Stack 심화 RedisJSON, RediSearch, 벡터 검색 검색·추천·AI 임베딩 저장이 필요할 때
Stream 심화 Consumer Group, ACK, Pending 재처리 메시지 유실이 허용되지 않는 이벤트 처리
대안 검토 Valkey, Dragonfly, KeyDB, 매니지드 서비스 라이선스 · 비용 · 멀티코어 활용을 고민할 때
성능 튜닝 redis-benchmark, 커널 파라미터, THP 비활성화, 파이프라이닝 지연 시간의 마지막 한 자릿수를 줄여야 할 때

추천 순서

실무에서 가장 먼저 마주치는 것은 고가용성입니다. 캐시가 죽어도 서비스가 살아남는 구조(우회 경로)를 먼저 만들고, 그다음 Sentinel 또는 매니지드 서비스로 자동 페일오버를 확보하십시오. 클러스터는 정말로 단일 노드 한계에 부딪혔을 때 도입해도 늦지 않습니다. 복잡도를 미리 사는 것은 대부분 손해입니다.

7. 마무리하며

기억할 세 문장

  • Redis는 빠른 것이 아니라 가까운 것이다. 디스크를 건너뛰기 때문에 빠릅니다. 그 대가로 메모리는 유한하고 휘발적입니다. 이 트레이드오프를 잊는 순간 사고가 시작됩니다.
  • 싱글 스레드는 축복이자 저주다. 원자성을 공짜로 얻지만, 명령 하나가 전체를 멈출 수 있습니다. 모든 명령의 시간 복잡도를 의식하십시오.
  • 캐시는 정답이 아니라 타협이다. 캐시를 넣는 순간 정합성 문제가 따라옵니다. 무엇을 얼마나 포기할지 의도적으로 결정하십시오.

수료를 축하합니다

여기까지 왔다면 이제 Redis는 "빠른 저장소"가 아니라 설계 도구로 보일 것입니다. 남은 것은 실제 서비스에 적용하며 겪는 경험입니다. 작게 시작하고, 지표를 보고, 한 번에 하나씩 바꾸십시오. 그리고 반드시 기억하십시오 — 가장 좋은 캐시 설계는 캐시가 없어도 서비스가 죽지 않는 설계입니다.


Part 7. [Appendix] 보너스 트랙 — 대용량 서비스 설계


7-1. 캐시가 틀려지는 것을 전제로 한 무효화 설계

섹션 7. [Appendix] 보너스 트랙 · 유닛 45 / 48 · 보충 학습 자료

이 글에서 설계할 것

  • 캐시 무효화가 왜 어려운 문제인지를 세 가지 관점(딜레마 · 스테일 데이터 · 캐시 스톰)으로 이해합니다.
  • 무효화 전략을 수동적(Passive) → 능동적(Active) → 구조적(Architecture) 3단계 로드맵으로 배치하고 선택 기준을 세웁니다.
  • TTL · 지터 · 적응형 TTL · Write-Through · Write-Behind · 이벤트 기반 · CDC를 단계별 구현 절차까지 따라 만듭니다.
  • 이벤트 기반 무효화의 3대 난제인 전달 보장 · 멱등성 · 순서를 코드 수준에서 해결합니다.
  • 다계층 캐시와 CDC를 결합한 하이브리드 구조를 설계하고, 캐시 관통 방어와 관측 지표로 마무리합니다.

이 글을 읽는 방법

1장은 문제를 정의하고, 2장은 전체 지도를 그립니다. 3장부터 6장까지가 실제 전략이며 각 전략마다 "언제 쓰는가 → 단계별 구현 → 검증 방법 → 한계" 순서로 반복됩니다. 처음 읽는다면 1 → 2 → 3 → 4장을 먼저 끝내고, 다중 서버나 로컬 캐시를 쓰는 시점에 5 → 6장으로 돌아오십시오. 7장은 운영에 올린 뒤에 반복해서 보는 점검 목록입니다.

1. 왜 중요한가

1-1. 핵심 딜레마 — 속도와 정확성은 같은 방향이 아니다

캐시를 두는 이유는 하나입니다. 원본(DB)에 가지 않고 답하기 위해서입니다. 그런데 원본에 가지 않는다는 말은 원본이 바뀐 것을 모른다는 말과 같습니다. 캐시의 성능 이득과 데이터 정확성은 같은 원인에서 나오는 앞면과 뒷면입니다.

text
캐시 적중률을 높이려면            정확성을 높이려면
  TTL 을 길게                       TTL 을 짧게
  원본 조회를 줄이고                 원본을 자주 확인하고
  사본을 오래 재사용                 사본을 빨리 버린다
        |                                 |
        +------------ 서로 반대 ----------+

  => "둘 다 최대" 는 존재하지 않는다.
     설계란 이 축 위에서 "우리 데이터는 여기" 라고 좌표를 찍는 일이다.

그래서 무효화 설계의 첫 질문은 "어떻게 항상 정확하게 만들까"가 아니라 "이 데이터는 몇 초까지 틀려도 되는가" 입니다. 이 허용치를 스테일 예산(staleness budget) 이라고 부릅니다.

정합성 수준 스테일 예산 데이터 예시 기본 전략
강한 정합성 0초 (불일치 불가) 잔액, 재고 차감, 쿠폰 사용 여부, 좌석 예약 캐시하지 않거나 Redis를 원본으로 사용. 캐시한다면 쓰기 시 즉시 삭제 + 매우 짧은 TTL
준실시간 1 ~ 10초 상품 가격, 재고 표시용 수치, 프로필 쓰기 시 명시적 삭제 + 백스톱 TTL(수 분)
최종적 정합성 1 ~ 10분 상품 목록, 카테고리, 배너, 추천 TTL 만료에 위임 + 관리자 변경 시에만 삭제
느슨함 1시간 이상 인기 검색어, 통계, 집계 리포트 스케줄러가 주기적으로 사전 계산

현업 대화법

기획자에게 "정합성이 필요합니까"라고 물으면 100% "네"가 돌아옵니다. 질문을 바꾸십시오.

  • 나쁜 질문: "이 데이터는 실시간이어야 하나요?"
  • 좋은 질문: "관리자가 가격을 바꾼 뒤 사용자 화면에 30초 뒤에 반영되면 사고입니까, 괜찮습니까?"
  • 더 좋은 질문: "틀린 값이 보였을 때 누가 얼마를 손해 봅니까?"

구체적인 시간과 손해 금액으로 물어야 진짜 요구 수준이 드러납니다. 이 답이 그대로 TTL 값과 전략 선택의 근거가 됩니다.

무효화가 어려운 구조적인 이유 네 가지

  • 영향 범위를 코드가 모른다. 상품 하나를 고치면 상세 캐시, 목록 캐시, 카테고리 집계, 검색 결과, 추천 결과가 모두 낡습니다. 개발자가 이 목록을 머리로 관리하는 순간 누락이 시작됩니다.
  • 무효화 자체가 실패한다. Redis 순단, 애플리케이션 크래시, 예외로 인한 삭제 코드 미실행. 삭제 명령은 "보냈다"와 "적용됐다"가 다릅니다.
  • 동시에 일어나면 순서가 뒤집힌다. 읽기가 캐시를 채우는 시점과 쓰기가 캐시를 지우는 시점이 교차하면, 지운 뒤에 낡은 값이 들어옵니다.
  • 서버가 여러 대다. Redis만 지워서는 각 애플리케이션 서버의 로컬 메모리 캐시가 남습니다. 전파 채널이 따로 필요합니다.

1-2. 스테일 데이터의 위험 — 틀린 값이 만드는 사고

스테일 데이터(Stale Data)는 원본은 바뀌었는데 캐시에 남아 있는 옛날 값입니다. 단순히 "화면이 조금 늦게 갱신된다" 정도로 넘기면 위험합니다. 실제로는 금액과 재고가 걸린 사고가 됩니다.

text
t=0   DB: price = 10000      Redis: price = 10000     일치
t=1   DB: price = 8000       Redis: price = 10000     불일치 발생 (스테일)
                                    ^^^^^^^^^^^^
      이 순간부터 사용자는 잘못된 가격을 본다.
      결제 모듈이 DB 를 본다면 "장바구니 10000원, 결제창 8000원" 불일치가 노출된다.
      반대 방향이면 "할인 끝난 가격으로 결제" 가 되어 고객 컴플레인과 환불로 이어진다.
도메인 스테일이 만드는 사고 체감 심각도
가격 / 프로모션 장바구니와 결제 금액 불일치, 종료된 할인 적용 매출 손실 + CS 폭증
재고 품절 상품 주문 접수 후 취소, 오버셀링 신뢰도 손상, 보상 비용
권한 / 인증 탈퇴하거나 권한을 뺏은 사용자가 계속 접근 보안 사고
피드 / 게시물 삭제한 글이 목록에 계속 노출 법적 리스크(명예훼손, 개인정보)
설정 / 피처 플래그 롤백했는데 일부 서버가 계속 새 동작 수행 장애 복구 지연

가장 위험한 조합 — 권한 캐시

권한과 세션은 "조금 늦어도 되는 데이터"처럼 보이지만 취소(revoke) 방향에서는 절대 늦으면 안 됩니다. 권한을 부여하는 변경이 늦게 반영되는 것은 불편이지만, 권한을 회수하는 변경이 늦게 반영되는 것은 보안 사고입니다. 이런 데이터는 TTL을 짧게 잡는 것으로 끝내지 말고, 회수 시에 차단 목록(deny list)을 즉시 기록하고 조회 경로가 그 목록을 먼저 확인하도록 설계하십시오.

1-3. 캐시 스톰 — 무효화가 만드는 2차 재해

무효화를 열심히 하면 정확성은 올라갑니다. 그런데 무효화 자체가 장애의 원인이 될 수 있습니다. 캐시가 사라진 순간 그 부하는 전부 DB로 갑니다. 이것을 캐시 스톰(Cache Storm) 이라고 부릅니다.

유형 원인 현상
동시 만료 (Cache Avalanche) 같은 TTL로 대량의 키를 한꺼번에 채움. 배포 직후 워밍업이 전형적 정확히 같은 초에 수만 개 키가 동시 만료 → DB QPS가 순간적으로 수십 배
단일 핫키 스탬피드 (Cache Stampede) 인기 키 하나가 만료. 그 사이 들어온 모든 요청이 각자 DB 조회 같은 쿼리가 수천 번 중복 실행 → DB 커넥션 고갈
일괄 무효화 폭탄 운영자가 "캐시 전체 비우기" 버튼을 누름 (FLUSHDB, 네임스페이스 전체 삭제) 전 구간 미스 → DB 과부하 → 타임아웃 → 재시도 → 더 큰 과부하
캐시 관통 (Cache Penetration) 존재하지 않는 키를 계속 조회(스크래핑, 잘못된 ID) 캐시가 영원히 미스 → 모든 요청이 DB 직행
text
[동시 만료가 만드는 그래프]

DB QPS
 ^
 |                    ####
 |                    ####          <- TTL 300초가 한꺼번에 끝나는 순간
 |                    ####
 |____________________####_________________ 평상시
 +------------------------------------> time
                      ^
                 배포 후 정확히 300초

이 글과 7-2의 역할 분담

캐시 스톰의 예방은 이 글(지터, 적응형 TTL, 다계층 캐시, 관통 방어)에서 다룹니다. 이미 스톰이 시작됐을 때의 방어(락, 확률적 조기 갱신, stale-while-revalidate, 요청 병합)는 다음 유닛 7-2와 7-3에서 이어집니다. 두 글은 같은 문제의 앞뒤입니다.

2. 전략 오버뷰 — Passive에서 Active를 거쳐 Architecture로

무효화 전략은 개별 기법을 나열해서 외우는 것이 아니라 "얼마나 적극적으로 개입하는가" 라는 하나의 축 위에 놓고 보아야 이해됩니다. 개입이 커질수록 정확성은 올라가고, 복잡도와 운영 비용도 함께 올라갑니다.

text
      Passive                    Active                     Architecture
   (시간에 맡긴다)            (쓰기가 알린다)             (시스템이 보장한다)
        |                          |                            |
  TTL / Jitter              Write-Invalidate            Event / Stream
  Adaptive TTL              Delayed Double Delete       CDC (binlog/WAL)
                            Write-Through               Multi-Layer + Tracking
                            Write-Behind                Hybrid (CDC + long TTL)
        |                          |                            |
  구현 비용: 거의 0          구현 비용: 중간              구현 비용: 높음
  정확성   : 낮음            정확성   : 높음              정확성   : 가장 높음
  누락 위험: 없음            누락 위험: 큼(사람이 씀)      누락 위험: 작음(자동)

  => 아래에서 위로 쌓는다. 위 단계가 아래 단계를 대체하지 않는다.
     CDC 를 넣어도 TTL 은 그대로 둔다. TTL 은 마지막 안전망이기 때문이다.

2-1. 단계별 채택 로드맵

단계 무엇을 하는가 이 단계로 충분한 경우 다음 단계로 넘어갈 신호
① Passive 모든 키에 TTL과 지터를 건다. 그 외에는 아무것도 하지 않는다. 목록, 배너, 추천처럼 분 단위 지연이 허용되는 데이터 "관리자가 고쳤는데 왜 안 바뀌냐"는 문의가 반복될 때
② Active 쓰기 경로에서 관련 캐시를 삭제한다. 저장소 계층 한 곳으로 모은다. 단일 서비스가 DB를 단독으로 소유하고, 쓰기 경로가 손에 꼽을 만큼일 때 배치 작업, 타 서비스, 수기 SQL 등 애플리케이션을 거치지 않는 변경이 생길 때
③ Architecture 변경 이벤트를 발행하거나 DB 로그(CDC)를 읽어 무효화를 자동화한다. 여러 서비스가 같은 데이터를 캐시하고, 로컬 캐시까지 쓰는 규모 (종착역) 이후로는 TTL을 길게 되돌려 비용을 회수하는 단계로 간다

가장 흔한 실수 — 2단계를 건너뛰거나, 1단계를 버리는 것

  • 1단계를 버리는 실수: "이벤트로 다 지우니까 TTL은 필요 없다" → 이벤트는 유실됩니다. TTL 없는 키는 무효화가 한 번만 실패해도 영원히 틀립니다.
  • 2단계를 건너뛰는 실수: 트래픽이 작은데 처음부터 Kafka와 Debezium을 깝니다. 운영할 사람이 없으면 그 파이프라인이 곧 장애 원인이 됩니다.
  • 판단 기준: "애플리케이션 코드를 거치지 않는 DB 변경이 존재하는가"가 2단계와 3단계를 가르는 선입니다.

2-2. 전략 선택 순서도

text
시작
 |
 +-- 이 데이터가 틀리면 금전/보안 사고인가?
 |      YES -> 캐시하지 않거나, Redis 를 원본으로 쓰고 DB 는 백업용으로 (유닛 6-4 참고)
 |      NO  -> 계속
 |
 +-- 허용 지연이 분 단위인가?
 |      YES -> [3장] TTL + Jitter 만으로 종료
 |      NO  -> 계속
 |
 +-- 변경이 항상 우리 애플리케이션 코드를 거치는가?
 |      YES -> [4장] 쓰기 시 삭제 + 백스톱 TTL (+ 경합 심하면 지연 이중 삭제)
 |      NO  -> [6-3장] CDC 기반 무효화로 간다
 |
 +-- 애플리케이션 서버가 로컬 메모리 캐시를 함께 쓰는가?
 |      YES -> [5장] 이벤트 전파 + [6-1장] 다계층 설계
 |      NO  -> 여기서 종료
 |
 +-- 한 번의 변경이 수백~수천 개 키에 영향을 주는가?
        YES -> [6-2장] 버전(네임스페이스) 무효화 또는 태그 무효화

3. 기본 전략 — 시간에 맡기는 무효화

3-1. TTL과 지터(Jitter)

가장 단순하고 가장 강력한 안전망입니다. 아무 코드를 쓰지 않아도 시간이 지나면 캐시가 사라집니다. 다른 모든 전략은 이 위에 얹는 것이지, 이것을 대체하지 않습니다.

구분 내용
장점 구현 비용 0. 무효화 코드를 빠뜨려도 최대 TTL 시간이면 자동으로 복구된다
단점 TTL 동안은 스테일 데이터가 노출된다. 짧게 잡으면 미스가 늘어 DB 부하가 커진다
필수 조건 모든 캐시 키에 예외 없이 TTL을 건다. TTL 없는 키는 메모리 누수이자 영원한 스테일 데이터다

How to — 단계별 구현

① 스테일 예산에서 기준 TTL을 정한다. 1장의 표에서 이 데이터가 속한 행을 찾고, 그 허용 지연의 절반 정도를 기준 TTL로 잡습니다. 허용 지연이 10분이면 기준 TTL 300초가 출발점입니다.

② TTL을 값과 함께 한 번에 쓴다. SET과 EXPIRE를 나눠서 호출하면 그 사이에 프로세스가 죽었을 때 TTL 없는 키가 영구히 남습니다. SET key value EX seconds 또는 SETEX로 한 명령에 처리합니다.

bash
# 나쁜 방법 - 두 명령 사이에 프로세스가 죽으면 TTL 없는 키가 남는다
127.0.0.1:6379> SET product:1001 '{"price":9900}'
OK
127.0.0.1:6379> EXPIRE product:1001 300
(integer) 1

# 좋은 방법 - 원자적으로 한 번에
127.0.0.1:6379> SET product:1001 '{"price":9900}' EX 300
OK
127.0.0.1:6379> TTL product:1001
(integer) 300

# TTL 이 -1 이면 만료가 없다는 뜻이다. 캐시 키에서 -1 은 곧 버그다.
127.0.0.1:6379> SET some:key:without:ttl value
OK
127.0.0.1:6379> TTL some:key:without:ttl
(integer) -1

# 참고: 키가 아예 없으면 -2 가 나온다. -1 과 혼동하지 않는다.
127.0.0.1:6379> TTL no:such:key
(integer) -2

③ TTL에 무작위 편차(지터)를 넣는다. 동일한 TTL로 대량의 키를 한꺼번에 채우면 같은 초에 모두 만료되어 DB에 폭탄이 떨어집니다(1-3의 동시 만료). 기준값의 ±10~20% 범위로 흩뜨립니다.

python
import random

BASE_TTL = 300          # 5분


def ttl_with_jitter(base: int = BASE_TTL, ratio: float = 0.2) -> int:
    """base 의 +/- 20% 범위로 TTL 을 흩뿌린다.

    ratio 0.2, base 300 이면 240 ~ 360 초 사이의 값이 나온다.
    만료 시점이 120초 폭으로 퍼지므로 동시 만료로 인한 순간 부하가 사라진다.
    """
    delta = int(base * ratio)
    return base + random.randint(-delta, delta)


r.setex(f"product:{pid}", ttl_with_jitter(), payload)

④ 지터가 실제로 퍼졌는지 확인한다. 구현했다고 믿지 말고 만료 분포를 직접 봅니다.

bash
# 같은 네임스페이스의 키 TTL 을 모아 분포를 본다 (KEYS 대신 --scan 사용)
$ redis-cli --scan --pattern "product:*" --count 500 \
    | head -200 \
    | xargs -n1 redis-cli TTL \
    | sort -n | uniq -c

# 지터가 없다면:  200개가 전부 300
# 지터가 있다면:  240 ~ 360 구간에 고르게 흩어진다

지터를 넣어도 남는 문제

지터는 동시 만료는 막지만 인기 키 하나가 만료되는 순간의 스탬피드는 막지 못합니다. 조회가 몰리는 키는 지터와 무관하게 만료 직후 수천 개 요청이 동시에 미스를 냅니다. 이 문제는 락이나 확률적 조기 갱신으로 따로 풀어야 하며 유닛 7-2, 7-3에서 다룹니다.

3-2. 적응형 TTL(Adaptive TTL) — 데이터마다 TTL을 다르게

모든 키에 같은 TTL을 주는 것은 편하지만 낭비입니다. 자주 바뀌는 데이터는 짧게, 거의 안 바뀌는 데이터는 길게 주면 같은 메모리로 더 높은 적중률과 더 낮은 스테일을 동시에 얻습니다. TTL을 고정 상수가 아니라 데이터의 성질에서 계산하는 함수로 바꾸는 것이 핵심입니다.

신호 규칙 근거
최근 변경 시각 방금 바뀐 데이터는 TTL을 짧게, 오래 안 바뀐 데이터는 길게 한 번 바뀐 데이터는 연달아 또 바뀔 확률이 높다(변경의 군집성)
조회 빈도 핫한 키는 TTL을 길게 재사용 횟수가 많을수록 캐싱의 이득이 크다
생성 비용 만드는 데 오래 걸리는 값(복잡한 집계)은 TTL을 길게 미스 1회의 비용이 크므로 미스 빈도를 줄이는 쪽이 이득
데이터 등급 가격과 재고는 짧게, 카테고리와 약관은 길게 스테일 예산이 도메인마다 다르다(1장의 표)
시간대 피크 시간에는 TTL을 늘려 DB를 보호 부하가 높을 때는 정확성보다 가용성이 우선인 경우가 많다

How to — 단계별 구현

① TTL 계산을 한 함수로 모은다. 코드 곳곳에 흩어진 300, 600 같은 숫자를 없애고 규칙을 한 곳에 둡니다.

python
import random
import time

# 도메인 등급별 기준 TTL (초). 1장의 스테일 예산 표를 그대로 옮긴 것이다.
TIER_BASE_TTL = {
    "realtime": 10,      # 재고 표시용 수치
    "near":     300,     # 상품 가격, 프로필
    "eventual": 1800,    # 상품 목록, 카테고리
    "static":   86400,   # 약관, 국가 코드
}

MIN_TTL, MAX_TTL = 5, 86400


def adaptive_ttl(
    tier: str,
    last_modified_at: float | None = None,
    hit_count: int = 0,
    now: float | None = None,
) -> int:
    """등급 + 최근 변경 + 인기도로 TTL 을 계산하고 지터를 얹는다."""
    now = now or time.time()
    ttl = TIER_BASE_TTL.get(tier, 300)

    # (1) 최근에 바뀐 데이터는 또 바뀔 가능성이 높다 -> 짧게
    if last_modified_at is not None:
        age = now - last_modified_at
        if age < 60:
            ttl = int(ttl * 0.25)      # 1분 내 변경: 4분의 1
        elif age < 3600:
            ttl = int(ttl * 0.5)       # 1시간 내 변경: 절반
        elif age > 7 * 86400:
            ttl = int(ttl * 2)         # 일주일째 그대로: 두 배

    # (2) 많이 조회되는 키는 길게 살려 미스 비용을 줄인다
    if hit_count > 1000:
        ttl = int(ttl * 1.5)

    # (3) 지터를 얹는다. 동시 만료를 막기 위해 +/- 15% 로 흩뿌린다.
    delta = int(ttl * 0.15)
    ttl += random.randint(-delta, delta)

    # (4) 마지막에 상한과 하한을 적용한다. 순서가 중요하다 - 지터 뒤에 잘라야
    #     계산식 버그나 지터가 TTL 0 이나 MAX_TTL 초과를 만들지 못한다.
    return max(MIN_TTL, min(MAX_TTL, ttl))

② 조회 횟수를 저렴하게 센다. 매 조회마다 INCR를 하면 쓰기가 조회만큼 늘어납니다. 정확할 필요가 없으므로 표본 추출로 충분합니다.

python
import random

SAMPLE_RATE = 0.01      # 1% 만 센다. 실제 횟수는 카운터 x 100 으로 추정한다.


async def note_hit(key: str) -> None:
    """조회 인기도를 표본으로 기록한다. 실패해도 조회를 막지 않는다."""
    if random.random() >= SAMPLE_RATE:
        return
    try:
        pipe = r.pipeline()
        pipe.incr(f"hits:{key}")
        pipe.expire(f"hits:{key}", 3600)   # 카운터에도 반드시 TTL
        await pipe.execute()
    except Exception:
        pass        # 인기도 집계 실패가 서비스 조회를 막아서는 안 된다

③ 효과를 측정한다. 적응형 TTL은 느낌으로 도입하면 안 됩니다. 도입 전후로 두 지표를 비교합니다.

  • 적중률(hit ratio): keyspace_hits / (keyspace_hits + keyspace_misses) — 올라가야 정상
  • 스테일 관측치: 표본 검사로 캐시 값과 DB 값을 비교한 불일치율 — 떨어지거나 최소한 유지되어야 정상
bash
127.0.0.1:6379> INFO stats
# Stats
keyspace_hits:284913
keyspace_misses:9137
...
# 적중률 = 284913 / (284913 + 9137) = 0.9689  -> 96.9%

적응형 TTL을 넣기 전에 먼저 할 일

적응형 TTL은 복잡도를 늘립니다. 그 전에 등급별 고정 TTL(위 코드의 TIER_BASE_TTL만 쓰는 형태)로 바꾸는 것만으로도 효과의 대부분을 얻는 경우가 많습니다. 등급 분리로 부족하다는 측정 근거가 나온 뒤에 변경 시각과 인기도를 얹으십시오.

적응형 TTL의 함정

  • TTL이 0이나 음수가 되는 버그: 계수를 곱하다 보면 반드시 생깁니다. 위 코드처럼 MIN_TTL과 MAX_TTL 클램프를 항상 두십시오.
  • 인기 키의 TTL이 계속 길어지는 폭주: 조회할 때마다 TTL을 연장하면 그 키는 영원히 만료되지 않습니다. 연장은 하지 말고 재생성 시점에만 TTL을 계산하십시오.
  • 설명 불가능한 캐시: "이 키는 왜 아직 살아 있나"에 답할 수 없으면 장애 대응이 불가능합니다. 계산된 TTL을 로그나 값 안의 메타 필드로 남기십시오.

4. 쓰기 기반 전략 — 데이터를 고치는 쪽이 캐시를 책임진다

4-1. 개요 — 쓰기 패턴 네 가지와 공통 원칙

TTL만으로는 "관리자가 가격을 바꿨는데 5분간 옛날 가격이 보인다"를 해결할 수 없습니다. 그래서 데이터를 변경하는 쪽이 관련 캐시를 직접 처리합니다. 방식은 네 가지입니다.

패턴 동작 장점 단점
Cache-Aside (Lazy Loading) 앱이 미스 시 DB를 조회해 캐시에 저장한다. 쓰기 시에는 캐시를 삭제한다 가장 보편적. Redis가 죽어도 DB로 서비스가 계속된다 첫 조회가 항상 느리다. 무효화 코드를 앱이 직접 관리해 누락이 생긴다
Read-Through 캐시 계층이 미스 시 스스로 DB를 조회해 채운다 애플리케이션 코드가 단순해진다 캐시 라이브러리나 프록시에 의존한다
Write-Through 쓰기를 캐시에 하면 캐시가 DB에도 동기적으로 기록한다 캐시가 항상 최신. 정합성이 가장 좋다 쓰기 지연이 커진다. 안 읽을 데이터도 캐싱한다
Write-Behind (Write-Back) 캐시에만 기록하고 DB에는 비동기로 반영한다 쓰기 성능이 압도적(조회수 카운팅 등) 캐시 유실 시 데이터가 사라진다. 영속성 설계가 필수

실무 기본값

대부분의 웹 서비스는 Cache-Aside + 쓰기 시 삭제 + 백스톱 TTL 조합으로 충분합니다. Write-Through와 Write-Behind는 "전면 도입"하는 것이 아니라 특정 데이터 한두 종류에만 선택적으로 적용하는 도구로 보십시오.

공통 원칙 1 — 갱신(Update)이 아니라 삭제(Delete)

방식 동작 평가
캐시 갱신 (Write-Update) DB를 수정하면서 캐시에도 새 값을 써 넣는다 동시 쓰기 시 순서가 뒤집혀 오래된 값이 최종적으로 남을 수 있다. 아무도 조회하지 않을 데이터를 미리 계산하는 낭비도 발생한다
캐시 삭제 (Write-Invalidate) DB를 수정하고 캐시 키를 지운다. 다음 조회가 DB에서 새로 채운다 권장. 삭제는 멱등(여러 번 해도 결과가 같음)이고, 순서가 뒤집혀도 피해가 훨씬 작다
text
[캐시 갱신의 위험 - 쓰기 순서 역전]
  요청A: DB 에 price=100 기록
  요청B: DB 에 price=200 기록
  요청B: 캐시에 200 기록
  요청A: 캐시에 100 기록      <- 뒤늦게 도착. 캐시에 영구히 100 이 남는다 (DB 는 200)

[캐시 삭제는 이 문제가 없다]
  요청A: DB 에 price=100, 캐시 DEL
  요청B: DB 에 price=200, 캐시 DEL
  다음 조회: DB 에서 200 을 읽어 캐시에 채움

공통 원칙 2 — DB 먼저, 캐시 나중

python
# 권장 순서
db.update(product)                 # 1) 원본을 먼저 확정한다
r.delete(f"product:{pid}")         # 2) 그 다음 캐시를 제거한다

# 위험한 순서
r.delete(f"product:{pid}")         # 1) 캐시를 먼저 지우면
db.update(product)                 #    이 사이에 들어온 조회가 "옛날 DB 값" 을 다시 캐싱한다

캐시를 먼저 지우면, DB 갱신이 끝나기 전의 짧은 구간에 들어온 조회가 아직 바뀌지 않은 DB 값을 읽어 캐시에 다시 채웁니다. 그 값은 TTL이 끝날 때까지 살아남습니다.

공통 원칙 3 — 삭제해도 되살아나는 구간이 있다

"DB 먼저, 캐시 나중"을 지켜도 완전하지 않습니다. 읽기 요청이 캐시를 채우는 시점과 쓰기 요청이 캐시를 지우는 시점이 교차하면 스테일 데이터가 TTL 만료까지 살아남습니다. 이것이 Cache-Aside의 고전적 경쟁 조건입니다.

text
시간   읽기 요청 R                      쓰기 요청 W
 t1   캐시 조회 -> MISS
 t2   DB 조회 -> price=10000
 t3                                    DB 갱신 -> price=8000
 t4                                    캐시 DEL (지울 것이 없다)
 t5   캐시에 10000 기록                <- 지워진 뒤에 옛날 값이 들어온다

 결과: 캐시=10000, DB=8000 이 TTL 이 끝날 때까지 유지된다

How to — 지연 이중 삭제(Delayed Double Delete)

① 쓰기 직후 한 번 지웁니다. 대부분의 스테일은 여기서 제거됩니다. ② 교차 구간이 끝나고도 남을 시간이 지난 뒤 한 번 더 지웁니다. t5에서 뒤늦게 들어온 값을 걷어내는 것이 목적입니다. ③ 지연 시간은 "DB 조회 + 직렬화 + 캐시 쓰기"의 p99보다 크게 잡습니다. 보통 0.5~2초입니다. 이 값은 추측하지 말고 APM에서 실제 p99를 확인해 정하십시오.

python
import asyncio
import json
import redis.asyncio as redis

r = redis.from_url("redis://localhost:6379", decode_responses=True)

DELAY_SEC = 1.0     # 읽기 요청이 캐시를 채우는 데 걸리는 최대 시간보다 크게


async def _delayed_delete(key: str, delay: float = DELAY_SEC):
    await asyncio.sleep(delay)
    await r.delete(key)


async def update_product(pid: int, data: dict):
    key = f"product:{pid}"

    # 1) 원본 갱신 - 반드시 먼저
    await db.update_product(pid, data)

    # 2) 1차 삭제 - 대부분의 스테일은 여기서 제거된다
    await r.delete(key)

    # 3) 2차 삭제 예약 - 교차 구간에 다시 채워진 스테일을 제거한다
    asyncio.create_task(_delayed_delete(key))

    return {"ok": True}

지연 삭제의 한계와 보완

  • asyncio.create_task는 프로세스가 죽으면 함께 사라집니다. 중요한 데이터라면 Celery나 RQ 같은 영속 큐에 지연 작업을 등록하십시오.
  • 지연 시간이 p99보다 짧으면 2차 삭제가 t5보다 먼저 실행되어 아무 효과가 없습니다.
  • 그래도 100% 보장은 아닙니다. TTL은 언제나 최후의 안전망으로 함께 걸어야 합니다.
  • 더 강한 보장이 필요하면 조회 경로에 락을 걸어 채우기 자체를 직렬화합니다(유닛 7-3 참고).

How to — 삭제 누락을 구조적으로 막기

쓰기 기반 전략의 최대 약점은 사람이 코드를 빠뜨린다는 것입니다. 새 캐시 키를 추가하면서 무효화 코드를 안 넣는 일은 반드시 생깁니다. 이것은 규칙이나 코드 리뷰가 아니라 구조로 막아야 합니다.

① 캐시 접근을 저장소 계층 한 곳으로 모읍니다. 서비스 코드 여기저기에서 r.get, r.delete를 직접 부르면 누락은 시간문제입니다. ② TTL을 선택 인자가 아니라 기본 동작으로 만듭니다. TTL 없이 저장하는 경로를 아예 제공하지 않습니다. ③ 무효화 실패를 삼키지 않습니다. 예외를 로그와 메트릭으로 남기고, 재시도 가능한 형태로 기록합니다. ④ "어떤 도메인 변경이 어떤 키에 영향을 주는지"를 표로 관리합니다. 유닛 6-1 키 네이밍 전략과 같은 문서에 두십시오.

python
import json
import logging
from typing import Any, Awaitable, Callable

log = logging.getLogger(__name__)


class CacheRepository:
    """캐시 접근을 한곳으로 모아 무효화 누락과 TTL 누락을 구조적으로 막는다."""

    DEFAULT_TTL = 300

    def __init__(self, client):
        self.r = client

    async def get_or_set(
        self,
        key: str,
        loader: Callable[[], Awaitable[Any]],
        ttl: int | None = None,
    ) -> Any:
        cached = await self.r.get(key)
        if cached is not None:
            return json.loads(cached)

        value = await loader()
        # TTL 을 선택 사항으로 두지 않는다. TTL 없이 저장하는 경로가 아예 없다.
        await self.r.setex(
            key,
            ttl_with_jitter(ttl or self.DEFAULT_TTL),
            json.dumps(value),
        )
        return value

    async def invalidate(self, *keys: str) -> None:
        if not keys:
            return
        try:
            await self.r.delete(*keys)
        except Exception:
            # 실패를 조용히 넘기지 않는다. 알림과 메트릭의 대상이다.
            log.exception("캐시 무효화 실패: %s", keys)
            raise

4-2. Write-Through — 쓰기가 캐시를 거쳐 DB로 간다

애플리케이션은 캐시에만 씁니다. 캐시 계층이 같은 요청 안에서 DB에도 기록합니다. 둘 다 성공해야 쓰기가 성공으로 처리되므로 캐시와 DB가 어긋나는 구간이 사실상 없습니다.

text
[Cache-Aside 의 쓰기]              [Write-Through 의 쓰기]

   App --write--> DB                  App --write--> Cache Layer
   App --DEL----> Cache                                 |
                                                        +--write--> DB
   (캐시는 비어 있다가                                   |
    다음 조회 때 채워진다)                            둘 다 성공해야 200 응답
                                      (캐시는 이미 최신 값을 들고 있다)
구분 내용
언제 쓰는가 읽기가 쓰기보다 압도적으로 많고, 쓴 직후 바로 읽히는 데이터(사용자 프로필, 설정)
얻는 것 쓰기 직후 조회에서 미스가 나지 않는다. 무효화 코드 누락이라는 문제 자체가 없다
잃는 것 쓰기 지연 = DB 쓰기 + 캐시 쓰기. 아무도 안 읽을 데이터까지 메모리를 차지한다
쓰면 안 되는 경우 쓰기가 많고 읽기가 드문 데이터(로그, 이력). 메모리만 잡아먹는다

How to — 단계별 구현

① DB를 먼저, 캐시를 나중에 씁니다. 순서를 바꾸면 DB 쓰기가 실패했을 때 캐시에만 존재하는 유령 데이터가 생깁니다. ② 캐시 쓰기 실패를 요청 실패로 만들지 않습니다. 원본(DB)은 이미 확정됐습니다. 캐시 쓰기가 실패하면 지우고 넘어갑니다. 다음 조회가 DB에서 다시 채우면 되기 때문입니다. ③ 그래도 TTL을 겁니다. Write-Through라도 캐시 쓰기 실패, 프로세스 크래시, 다른 경로의 DB 변경은 여전히 가능합니다.

python
import json
import logging

log = logging.getLogger(__name__)


async def write_through_update(pid: int, data: dict) -> dict:
    """DB 를 원본으로 확정한 뒤 캐시를 같은 요청 안에서 최신화한다."""
    key = f"product:{pid}"

    # 1) 원본 먼저. 실패하면 여기서 예외가 나고 캐시는 건드리지 않는다.
    product = await db.update_product(pid, data)

    # 2) 캐시 최신화. 실패해도 요청은 성공이다 - 대신 반드시 지운다.
    try:
        await r.setex(key, ttl_with_jitter(300), json.dumps(product))
    except Exception:
        log.exception("write-through 캐시 갱신 실패, 캐시를 비웁니다: %s", key)
        try:
            await r.delete(key)     # 낡은 값이 남는 것보다 비어 있는 편이 안전하다
        except Exception:
            log.exception("캐시 삭제도 실패: %s", key)
            # 여기서 TTL 이 유일한 안전망이 된다

    return product

Write-Through의 오해 — "이제 정합성이 보장된다"

보장되지 않습니다. Redis와 외부 DB를 하나의 트랜잭션으로 묶을 방법이 없기 때문입니다. MULTI/EXEC는 Redis 내부 명령만 묶습니다. DB는 커밋됐는데 캐시 쓰기 직전에 프로세스가 죽는 구간은 언제나 존재합니다. Write-Through는 어긋나는 구간을 아주 짧게 만드는 기법이지 없애는 기법이 아닙니다. 그래서 TTL이 여전히 필요합니다.

4-3. Write-Behind(Write-Back) — 캐시에 쓰고 DB는 나중에

캐시에만 쓰고 즉시 응답합니다. DB 반영은 배치 작업이 모아서 처리합니다. 쓰기 처리량이 극적으로 올라가는 대신, 캐시가 유실되면 그 데이터도 사라집니다.

text
[조회수 1000 회가 들어왔을 때]

 Write-Through        : DB UPDATE 1000 번   <- DB 가 먼저 죽는다
 Write-Behind         : Redis INCR 1000 번 + DB UPDATE 1 번 (10초마다 플러시)
                        => DB 쓰기가 1000분의 1 로 줄어든다
구분 내용
언제 쓰는가 쓰기가 매우 잦고, 약간의 유실이 허용되는 값. 조회수, 좋아요, 재생 횟수, 최근 접속 시각
절대 쓰면 안 되는 경우 금액, 재고, 주문, 결제. 유실이 곧 사고인 데이터
전제 조건 Redis 영속성(AOF everysec 이상) 설정, 복제 구성, 그리고 유실 허용 범위를 문서로 합의

How to — 단계별 구현

① 증가분을 원자적으로 누적합니다. GET → 계산 → SET은 원자적이지 않아 동시 요청에서 값이 유실됩니다. INCR나 HINCRBY를 쓰십시오. ② "아직 DB에 안 넘긴 키" 목록을 따로 관리합니다. 플러시할 때 전체를 훑지 않기 위해서입니다. ③ 플러시는 "읽고 지우기"를 원자적으로 합니다. 읽은 뒤에 들어온 증가분이 사라지면 안 되므로, 대기 목록을 통째로 교체(rename)하거나 GETDEL로 값을 꺼내면서 지웁니다. ④ DB 반영 실패 시 값을 되돌려 놓습니다. 그냥 버리면 조용한 데이터 유실입니다.

python
import asyncio
import logging

log = logging.getLogger(__name__)

DIRTY_SET = "wb:dirty:view_count"       # DB 반영 대기 중인 키 목록
FLUSH_INTERVAL = 10                     # 초


async def increase_view(pid: int) -> int:
    """조회수를 Redis 에만 올리고 즉시 응답한다. DB 는 나중에 따라온다."""
    key = f"view_count:{pid}"
    pipe = r.pipeline()
    pipe.incr(key)                      # 원자적 증가 - GET/SET 조합은 유실된다
    pipe.sadd(DIRTY_SET, pid)           # 플러시 대상으로 표시
    result = await pipe.execute()
    return result[0]


async def flush_to_db() -> None:
    """대기 목록을 통째로 가져와 DB 에 한 번에 반영한다."""
    # (1) 대기 목록을 원자적으로 교체한다.
    #     이렇게 해야 플러시 도중에 들어온 신규 증가분이 유실되지 않는다.
    staging = f"{DIRTY_SET}:staging"
    try:
        await r.rename(DIRTY_SET, staging)
    except redis.ResponseError:
        return                          # 대기 중인 키가 없다 (no such key)

    pids = await r.smembers(staging)
    if not pids:
        await r.delete(staging)
        return

    # (2) 값을 꺼내면서 동시에 0 으로 리셋한다 (GETDEL: Redis 6.2+)
    pipe = r.pipeline()
    for pid in pids:
        pipe.getdel(f"view_count:{pid}")
    values = await pipe.execute()

    pending = {int(pid): int(v) for pid, v in zip(pids, values) if v}

    # (3) DB 에 누적분을 더한다. UPDATE ... SET view_count = view_count + :delta
    try:
        await db.bulk_add_view_counts(pending)
        await r.delete(staging)
    except Exception:
        log.exception("write-behind 플러시 실패, 증가분을 Redis 로 되돌립니다")
        # (4) 실패분 복구 - 버리면 조용한 데이터 유실이 된다
        pipe = r.pipeline()
        for pid, delta in pending.items():
            pipe.incrby(f"view_count:{pid}", delta)
            pipe.sadd(DIRTY_SET, pid)
        await pipe.execute()
        await r.delete(staging)


async def flush_loop() -> None:
    while True:
        await asyncio.sleep(FLUSH_INTERVAL)
        try:
            await flush_to_db()
        except Exception:
            log.exception("플러시 루프 예외 - 다음 주기에 재시도합니다")

⑤ 종료 시점에 마지막 플러시를 겁니다. 배포로 프로세스를 내릴 때 대기 중이던 증가분이 통째로 날아가는 것이 가장 흔한 유실 경로입니다.

python
from contextlib import asynccontextmanager
from fastapi import FastAPI


@asynccontextmanager
async def lifespan(app: FastAPI):
    task = asyncio.create_task(flush_loop())
    yield
    task.cancel()
    await flush_to_db()         # 종료 직전 마지막 플러시 - 유실 구간을 좁힌다


app = FastAPI(lifespan=lifespan)

Write-Behind에서 유실이 발생하는 세 구간

  • Redis 프로세스 다운: AOF appendfsync everysec이면 최대 1초분이 사라집니다. always는 안전하지만 느립니다.
  • 플러시 주기: 10초 주기라면 최대 10초분이 Redis에만 존재합니다. 이 구간에 Redis와 복제본이 모두 죽으면 유실입니다.
  • 애플리케이션 강제 종료: SIGKILL은 종료 훅을 실행하지 않습니다. 위 코드의 마지막 플러시도 동작하지 않습니다.

이 세 구간의 합이 이 데이터의 최대 유실 허용치보다 작은지를 도입 전에 계산하고, 그 숫자를 기획자와 문서로 합의하십시오. "조회수는 조금 틀려도 됩니다"라는 구두 합의로는 부족합니다.

5. 이벤트 기반 무효화 — 변경을 방송한다

5-1. 아키텍처

애플리케이션 서버가 여러 대이고 각자 로컬 메모리 캐시를 들고 있으면, Redis만 지워서는 부족합니다. 각 프로세스의 메모리에 남은 사본까지 지워야 합니다. 이때 "이 키가 바뀌었다"는 사실을 모든 서버에 전파하는 채널이 필요합니다.

text
[관리자가 상품 수정]
        |
   DB 갱신 + Redis DEL
        |
   PUBLISH cache:invalidate "product:1001"
        |
   +---------------------+---------------------+
   v                     v                     v
[WAS #1]              [WAS #2]              [WAS #3]
로컬캐시 제거          로컬캐시 제거          로컬캐시 제거

전달 채널 선택

채널 전달 보장 적합한 용도 주의점
Redis Pub/Sub 없음(fire-and-forget). 구독 중이 아니면 영구 유실 로컬 캐시 제거 알림처럼 놓쳐도 TTL로 복구되는 신호 배포 중이거나 네트워크가 순단된 서버는 신호를 못 받는다. 로컬 TTL을 짧게 유지해야 한다
Redis Stream 있음. 소비자 그룹, 재처리, 미확인 메시지 추적 지원 유실이 문제가 되는 무효화. 감사 로그가 필요한 경우 스트림 길이를 MAXLEN으로 제한하지 않으면 메모리가 계속 자란다
Kafka 등 외부 브로커 있음. 파티션 단위 순서 보장, 장기 보존 여러 팀, 여러 서비스가 같은 변경 이벤트를 소비하는 규모 운영 비용이 크다. 팀에 운영 역량이 없으면 이것이 새 장애 원인이 된다
키스페이스 알림 없음. Pub/Sub 위에서 동작 Redis 자체의 키 변경과 만료를 감지할 때 기본 비활성이고 부하가 있다. 전면 활성화는 권장하지 않는다
클라이언트 사이드 캐싱 (RESP3 Tracking) 연결이 살아 있는 동안 보장. 끊기면 재구독 필요 Redis가 2계층 캐시 무효화를 대신 관리해 주기를 원할 때 Redis 6 이상과 지원 클라이언트가 필요. 서버가 추적 테이블 메모리를 쓴다

How to — Pub/Sub 기반 2계층 캐시 무효화

① 조회는 로컬 → Redis → DB 순서로 내려갑니다. 위 계층에서 찾으면 아래는 건드리지 않습니다. ② 쓰기는 DB → Redis → 내 로컬 → 방송 순서로 처리합니다. 내 로컬 캐시를 직접 지우는 3번 단계를 빠뜨리기 쉽습니다. Pub/Sub 발행자는 자기 자신에게 메시지를 받지 못하는 구성일 수 있기 때문입니다. ③ 구독 루프는 애플리케이션 수명주기에 묶습니다. 종료 시 정리하지 않으면 배포할 때마다 유령 구독이 남습니다.

python
import asyncio
import json
from contextlib import asynccontextmanager

import redis.asyncio as redis
from fastapi import FastAPI

INVALIDATE_CHANNEL = "cache:invalidate"
local_cache: dict[str, str] = {}        # 프로세스 로컬 1차 캐시


async def invalidation_listener(app: FastAPI):
    """다른 서버가 보낸 무효화 신호를 받아 로컬 캐시를 비운다."""
    async with app.state.redis.pubsub() as pubsub:
        await pubsub.subscribe(INVALIDATE_CHANNEL)
        while True:
            msg = await pubsub.get_message(
                ignore_subscribe_messages=True, timeout=1.0
            )
            if msg and msg["type"] == "message":
                local_cache.pop(msg["data"], None)
            await asyncio.sleep(0.01)


@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.redis = redis.from_url(
        "redis://localhost:6379", decode_responses=True
    )
    task = asyncio.create_task(invalidation_listener(app))
    yield
    task.cancel()
    await app.state.redis.aclose()


app = FastAPI(lifespan=lifespan)


@app.get("/products/{pid}")
async def get_product(pid: int):
    key = f"product:{pid}"

    # 1차: 로컬 메모리 (가장 빠름, 네트워크 왕복 0회)
    if key in local_cache:
        return {"source": "local", "data": json.loads(local_cache[key])}

    # 2차: Redis (모든 서버가 공유)
    cached = await app.state.redis.get(key)
    if cached:
        local_cache[key] = cached
        return {"source": "redis", "data": json.loads(cached)}

    # 3차: DB
    row = await db.get_product(pid)
    payload = json.dumps(row)
    await app.state.redis.setex(key, 300, payload)
    local_cache[key] = payload
    return {"source": "db", "data": row}


@app.put("/products/{pid}")
async def update_product(pid: int, data: dict):
    key = f"product:{pid}"
    await db.update_product(pid, data)              # 1) 원본 갱신
    await app.state.redis.delete(key)               # 2) 공유 캐시 제거
    local_cache.pop(key, None)                      # 3) 내 로컬 캐시 제거
    await app.state.redis.publish(                  # 4) 다른 서버에 방송
        INVALIDATE_CHANNEL, key
    )
    return {"ok": True}

Pub/Sub의 한계 — 반드시 알고 써야 한다

Redis Pub/Sub은 메시지를 보관하지 않고 즉시 전달합니다(fire-and-forget). 발행 순간 구독 중이 아닌 서버(배포 중, 네트워크 순단, GC 정지)는 그 신호를 영구히 놓칩니다. 따라서 로컬 캐시의 TTL은 반드시 짧게(수 초 ~ 수십 초) 유지해야 합니다. 신호 유실이 치명적이라면 Pub/Sub 대신 Redis Stream이나 Kafka를 쓰십시오.

참고 — 키스페이스 알림 켜보기

bash
# 만료 이벤트만 켜기
#   K: 키스페이스, E: 키이벤트, x: 만료, g: 일반, $: 문자열
127.0.0.1:6379> CONFIG SET notify-keyspace-events Ex
OK
127.0.0.1:6379> SUBSCRIBE __keyevent@0__:expired
# 다른 창에서 키가 만료되면 여기로 키 이름이 도착한다

# 주의: 만료 이벤트는 키가 "실제로 제거되는 순간" 에 발생한다.
#       TTL 이 지났어도 아무도 조회하지 않으면 늦게 제거될 수 있으므로
#       이 이벤트의 도착 시각을 정확한 만료 시각으로 믿으면 안 된다.

5-2. 구현 난제 — 전달 보장, 멱등성, 순서

이벤트 기반 무효화는 그림은 단순하지만 실제로 돌리면 세 가지 문제가 반드시 나옵니다. 이 셋을 해결하지 않은 이벤트 파이프라인은 있으나 마나입니다.

난제 1. 전달 보장(Reliability) — 이벤트가 사라진다

text
유실이 생기는 지점

  DB 커밋 성공 --> [여기서 프로세스 크래시] --> 이벤트 발행 안 됨
                                                (DB 는 바뀌었는데 캐시는 그대로)

  이벤트 발행 성공 --> [구독자가 배포 중] --> 아무도 못 받음
                                              (Pub/Sub 은 재전송이 없다)

  이벤트 수신 성공 --> [삭제 처리 중 크래시] --> 캐시는 안 지워짐

해법은 트랜잭셔널 아웃박스(Transactional Outbox) 입니다.

① 데이터 변경과 이벤트 기록을 같은 DB 트랜잭션에 넣습니다. 둘 다 커밋되거나 둘 다 롤백되므로 "DB는 바뀌었는데 이벤트가 없는" 구간이 사라집니다. ② 별도의 릴레이 프로세스가 아웃박스 테이블을 읽어 이벤트를 발행합니다. ③ 발행에 성공한 행을 발행 후에 표시합니다. 중복 발행은 허용하되 유실은 허용하지 않습니다(at-least-once).

python
import asyncio
import uuid


async def update_product_with_outbox(pid: int, data: dict):
    """데이터 변경과 무효화 이벤트를 하나의 DB 트랜잭션으로 묶는다."""
    async with db.transaction():
        await db.update_product(pid, data)
        await db.insert_outbox(
            event_id=str(uuid.uuid4()),     # 멱등성 판단에 쓸 고유 ID
            topic="cache.invalidate",
            payload={"key": f"product:{pid}", "version": data["version"]},
        )
    # 커밋이 끝난 뒤에야 이벤트가 존재한다.
    # 여기서 프로세스가 죽어도 릴레이가 아웃박스를 읽어 발행한다.


async def outbox_relay():
    """아웃박스를 읽어 Stream 으로 발행한다. 최소 1회 전달을 보장한다."""
    while True:
        rows = await db.fetch_unpublished_outbox(limit=100)
        for row in rows:
            # Stream 은 Pub/Sub 과 달리 구독자가 없어도 메시지가 남는다
            await r.xadd(
                "cache:invalidate",
                {"event_id": row.event_id, "key": row.payload["key"],
                 "version": str(row.payload["version"])},
                maxlen=100_000, approximate=True,   # 무한 증가 방지
            )
            await db.mark_outbox_published(row.id)
        if not rows:
            await asyncio.sleep(0.5)

아웃박스가 과한 경우의 현실적인 대안

아웃박스는 테이블과 릴레이 프로세스를 추가로 운영해야 합니다. 규모가 작다면 "짧은 백스톱 TTL + 무효화 실패 로그 + 수동 퍼지 도구" 만으로도 실질적인 위험은 충분히 낮출 수 있습니다. 유실이 발생해도 TTL이 상한을 만들기 때문입니다. 아웃박스는 TTL을 길게 가져가고 싶을 때 비로소 값을 합니다.

난제 2. 멱등성(Idempotency) — 같은 이벤트가 여러 번 온다

최소 1회 전달(at-least-once)을 택하는 순간 중복은 필연입니다. 릴레이가 발행 직후 죽으면 같은 이벤트를 다시 발행하고, 소비자가 확인(ACK) 직전에 죽으면 같은 메시지를 다시 받습니다.

처리 동작 중복 실행 시 멱등한가
DEL product:1001 이미 없는 키를 또 지운다. 결과 동일 멱등하다. 별도 처리 불필요
INCR ver:category:shoes 버전이 두 번 올라 멀쩡한 캐시까지 버려진다 멱등하지 않다. 방어 필요
캐시에 새 값 쓰기 오래된 이벤트가 늦게 도착하면 낡은 값을 덮어쓴다 멱등하지 않다. 버전 비교 필요

해법 1: 삭제만 하도록 설계를 제한한다. 가장 싸고 확실한 방법입니다. 이벤트 소비자가 하는 일을 DEL로만 제한하면 멱등성 문제 자체가 사라집니다.

해법 2: 처리한 이벤트 ID를 기록한다. 삭제 이외의 동작이 꼭 필요할 때만 씁니다.

python
SEEN_TTL = 3600     # 재전송 가능 시간보다 넉넉하게


async def handle_once(event_id: str, handler) -> bool:
    """같은 event_id 를 두 번 처리하지 않는다.

    SET NX 는 "없을 때만 생성" 을 원자적으로 수행하므로
    여러 소비자가 동시에 같은 이벤트를 받아도 한 명만 True 를 얻는다.
    """
    first = await r.set(f"seen:{event_id}", "1", nx=True, ex=SEEN_TTL)
    if not first:
        return False            # 이미 처리했다. 조용히 넘어간다.
    try:
        await handler()
        return True
    except Exception:
        # 처리에 실패했으면 마커를 지워 재시도가 가능하게 한다
        await r.delete(f"seen:{event_id}")
        raise

멱등성 마커의 주의점

마커 TTL이 재전송 가능 시간보다 짧으면 중복 방지가 뚫립니다. Stream의 미확인 메시지가 한 시간 뒤 재처리되는데 마커 TTL이 10분이면 아무 효과가 없습니다. 또 마커를 먼저 남기고 처리하다 실패하면 영영 처리되지 않는 이벤트가 생기므로, 위 코드처럼 실패 시 마커를 되돌려야 합니다.

난제 3. 순서(Ordering) — 늦게 온 이벤트가 최신 값을 덮는다

이벤트는 발행 순서대로 도착하지 않습니다. 네트워크 재전송, 소비자 여러 대, 파티션이 다르면 순서가 뒤집힙니다. 삭제만 한다면 순서가 뒤집혀도 피해가 작지만(결국 지워진 상태가 되므로), 이벤트로 캐시에 값을 채우는 순간 치명적입니다.

text
발행 순서            도착 순서
  v=17 (price 8000)    v=18 (price 7000)   -> 캐시에 7000 기록
  v=18 (price 7000)    v=17 (price 8000)   -> 캐시에 8000 덮어씀  <- 낡은 값이 최종

  결과: DB 는 7000, 캐시는 8000. TTL 이 끝날 때까지 틀린 값.

해법 1: 버전을 값에 넣고, 더 낮은 버전은 무시한다.

python
import json

# 버전 비교와 조건부 쓰기를 원자적으로 수행한다.
# Lua 스크립트는 Redis 안에서 한 덩어리로 실행되므로 중간에 끼어들 수 없다.
SET_IF_NEWER = """
local raw = redis.call('GET', KEYS[1])
if raw then
  local cur = cjson.decode(raw)
  if cur.version and tonumber(cur.version) >= tonumber(ARGV[2]) then
    return 0                                  -- 이미 같거나 더 새로운 값이 있다
  end
end
redis.call('SET', KEYS[1], ARGV[1], 'EX', tonumber(ARGV[3]))
return 1
"""


async def apply_event(key: str, payload: dict, version: int, ttl: int):
    """더 새로운 버전일 때만 캐시에 반영한다."""
    script = r.register_script(SET_IF_NEWER)
    body = json.dumps({"version": version, **payload})
    applied = await script(keys=[key], args=[body, version, ttl])
    return bool(applied)

Cluster에서 Lua를 쓸 때

Redis Cluster에서 하나의 Lua 스크립트가 건드리는 키는 모두 같은 슬롯에 있어야 합니다. 관련된 키를 같은 슬롯에 모으려면 해시 태그(product:{1001}:detail처럼 중괄호로 묶는 부분)를 씁니다. 다만 해시 태그를 남용하면 특정 슬롯에 데이터가 몰리므로, 함께 원자적으로 다뤄야 하는 키에만 적용하십시오.

해법 2: 순서가 필요 없게 설계한다. 실무에서 더 자주 쓰이는 선택입니다.

  • 이벤트 소비자는 삭제만 한다. 값 채우기는 다음 조회에 맡긴다.
  • 삭제는 순서가 뒤집혀도 결과가 같다. 두 이벤트가 어떤 순서로 와도 캐시는 비어 있게 된다.
  • 다음 조회가 DB에서 최신 값을 읽으므로 DB가 순서의 최종 심판이 된다.

세 난제를 한 문장으로

이벤트 소비자가 하는 일을 "키를 지운다"로만 제한하면, 세 난제 중 둘(멱등성, 순서)이 자동으로 해결됩니다. 남는 것은 전달 보장뿐이고, 그것은 짧은 백스톱 TTL로 상한을 만들 수 있습니다. 값을 채우는 이벤트는 정말 필요할 때만 도입하십시오.

6. 하이브리드 전략과 고급 패턴

6-1. 다계층 캐싱(Multi-Layer Caching)

캐시는 한 층이 아닙니다. 사용자와 DB 사이에는 여러 층이 있고, 층마다 속도와 무효화 난이도가 정반대입니다. 빠른 층일수록 무효화가 어렵습니다. 이 사실을 인정하고 설계하는 것이 다계층 캐싱입니다.

층 위치 응답 속도 무효화 난이도 권장 TTL
L0. 브라우저 사용자 기기 0ms 불가능(한 번 나가면 회수 못 함) 초 단위, 또는 콘텐츠 해시로 URL 분리
L1. CDN 엣지 수 ms 어렵다(퍼지 API 호출, 전파에 수 초~수 분) 분 단위 이상
L2. 로컬 메모리 애플리케이션 프로세스 0.01ms 미만 중간(서버 전체에 전파 필요) 수 초 ~ 수십 초
L3. Redis 공유 캐시 서버 0.5 ~ 2ms 쉽다(한 곳만 지우면 끝) 분 단위
L4. DB 원본 10 ~ 100ms 해당 없음 해당 없음

다계층 설계의 한 가지 규칙

빠른 층일수록 TTL을 짧게. 위 표에서 TTL 열이 아래로 갈수록 길어지는 것을 보십시오. L2 로컬 캐시의 TTL을 Redis와 같게 잡는 것이 가장 흔한 실수입니다. 로컬 캐시는 무효화 신호를 놓칠 수 있으므로, 신호가 없어도 금방 스스로 사라지도록 짧게 잡아야 합니다. "로컬 5초 + Redis 300초"가 실무의 기본형입니다.

How to — 단계별 설계

① 각 층의 스테일 예산을 따로 정합니다. 전체 허용 지연이 30초라면 L2에 5초, L3에 25초처럼 예산을 쪼개 배분합니다. 층별 지연이 더해진다는 점을 기억하십시오.

② L2에는 용량 상한을 둡니다. 로컬 캐시는 애플리케이션 힙을 씁니다. 상한이 없으면 메모리 부족으로 프로세스가 죽습니다. 파이썬이라면 cachetools.TTLCache처럼 크기와 TTL이 모두 있는 구현을 쓰십시오.

python
from cachetools import TTLCache

# maxsize: 힙 보호. ttl: 무효화 신호를 놓쳤을 때의 자동 복구 상한.
# 둘 중 하나라도 빠지면 안 된다.
local_cache = TTLCache(maxsize=10_000, ttl=5)


async def get_product(pid: int) -> dict:
    key = f"product:{pid}"

    # L2: 로컬 (네트워크 왕복 0회)
    if (hit := local_cache.get(key)) is not None:
        return hit

    # L3: Redis (네트워크 왕복 1회)
    if (cached := await r.get(key)) is not None:
        value = json.loads(cached)
        local_cache[key] = value
        return value

    # L4: DB
    value = await db.get_product(pid)
    await r.setex(key, ttl_with_jitter(300), json.dumps(value))
    local_cache[key] = value
    return value

③ 무효화는 가장 느린 층부터 지웁니다. Redis를 먼저 지우고 로컬을 나중에 지우면, 그 사이에 로컬이 만료되어 Redis에서 낡은 값을 다시 가져올 수 있습니다. 순서는 DB → Redis → 로컬 → 방송입니다.

④ 어느 층에서 응답했는지 계측합니다. 층별 적중률을 모르면 어느 층의 TTL을 조정해야 할지 판단할 수 없습니다. 응답에 source 필드를 넣거나 메트릭 레이블로 남기십시오.

CDN(L1)을 쓸 때 반드시 확인할 것

  • 개인화된 응답을 캐시하지 마십시오. 로그인 사용자별로 다른 HTML이 CDN에 캐시되면 다른 사람의 정보가 노출됩니다. Cache-Control: private 또는 no-store를 명확히 지정하십시오.
  • 정적 자산은 무효화하지 말고 URL을 바꾸십시오. 파일명에 콘텐츠 해시를 넣으면(app.9f3a1c.js) 퍼지 자체가 필요 없습니다.
  • 퍼지는 즉시 반영되지 않습니다. 엣지 전파에 수 초에서 수 분이 걸립니다. 이 시간을 스테일 예산에 포함시키십시오.

6-2. 고급 패턴 — 대량 무효화와 클라이언트 사이드 캐싱

패턴 1. 버전(네임스페이스) 무효화 — O(1)로 수천 개를 버린다

"카테고리 전체 캐시를 한 번에 날려야 한다" 같은 상황에서 키를 하나씩 지우는 것은 비현실적입니다. 키 이름에 버전을 포함시키고, 버전 숫자만 올려 기존 키를 통째로 고아로 만듭니다. 기존 키를 지우는 것이 아니라 아무도 찾지 않는 이름으로 만들어 버리는 방식입니다.

python
async def get_version(namespace: str) -> int:
    v = await r.get(f"ver:{namespace}")
    return int(v) if v else 0     # 첫 INCR 결과(1)와 달라야 첫 무효화부터 동작한다


async def bump_version(namespace: str) -> int:
    """이 네임스페이스의 모든 캐시를 한 번에 무효화한다. O(1)."""
    return await r.incr(f"ver:{namespace}")


async def get_product_list(category: str, page: int):
    ver = await get_version(f"category:{category}")
    key = f"list:v{ver}:{category}:p{page}"         # 버전이 키 이름에 포함된다

    if cached := await r.get(key):
        return json.loads(cached)

    rows = await db.fetch_products(category, page)
    await r.setex(key, ttl_with_jitter(600), json.dumps(rows))
    return rows


# 카테고리에 상품이 추가되면
await bump_version("category:shoes")
# -> 이전 버전(list:v0:shoes:*) 키들은 더 이상 아무도 조회하지 않는다.
#    지우지 않아도 TTL 이 지나면 자연히 메모리에서 제거된다.
구분 내용
장점 INCR 한 번(O(1))으로 수천 개 키를 무효화. KEYS나 SCAN이 필요 없다
단점 구버전 키가 TTL까지 메모리에 남는다(메모리 여유 필요). 조회할 때 버전 조회로 왕복이 1회 늘어난다
최적화 버전 값을 애플리케이션 로컬에 짧게(1~3초) 캐싱해 왕복을 줄인다. 이 3초가 곧 추가 스테일 시간이다
주의 버전을 너무 자주 올리면 캐시가 늘 비어 있는 상태가 된다. 변경이 잦은 데이터에는 맞지 않는다

절대 금지 — KEYS 패턴 삭제

수백만 키가 있는 서버에서 KEYS는 수 초간 Redis 전체를 정지시킵니다(유닛 6-2 참고). 부득이하게 패턴 삭제가 필요하면 SCAN을 커서로 나눠 돌리되, 애초에 버전 무효화나 태그 방식으로 설계를 바꾸는 것이 정답입니다.

python
# 운영 서버를 멈추게 하는 코드 - 절대 쓰지 말 것
for k in r.keys("list:shoes:*"):     # O(N) 전수 조사 + 싱글 스레드 블로킹
    r.delete(k)

패턴 2. 태그 기반 무효화 — 하나의 변경이 여러 키에 걸칠 때

"이 상품이 포함된 모든 캐시 키"를 Set으로 관리해 두고, 변경 시 그 Set을 순회해 삭제합니다. 버전 무효화가 네임스페이스 단위라면, 태그 무효화는 임의의 묶음 단위입니다.

python
async def cache_with_tags(key: str, value: str, ttl: int, tags: list[str]):
    """캐시를 저장하면서 어떤 태그에 속하는지 함께 기록한다."""
    pipe = r.pipeline()
    pipe.setex(key, ttl, value)
    for tag in tags:
        pipe.sadd(f"tag:{tag}", key)
        # 태그 집합 자체도 캐시보다 넉넉하게 만료시켜 누수를 막는다
        pipe.expire(f"tag:{tag}", ttl * 2)
    await pipe.execute()


async def invalidate_tag(tag: str):
    """태그에 속한 모든 캐시 키를 삭제한다."""
    tag_key = f"tag:{tag}"

    # 태그 집합이 클 수 있으므로 SSCAN 으로 나눠 처리한다.
    # SMEMBERS 는 O(N) 이고 원소가 수십만이면 서버를 멈춘다.
    cursor = 0
    while True:
        cursor, keys = await r.sscan(tag_key, cursor, count=500)
        if keys:
            await r.delete(*keys)
        if cursor == 0:
            break
    await r.delete(tag_key)


# 사용 예: 상품 상세 캐시는 '상품' 태그와 '카테고리' 태그에 모두 속한다
await cache_with_tags(
    key=f"product:{pid}",
    value=json.dumps(product),
    ttl=600,
    tags=[f"product:{pid}", f"category:{product['category']}"],
)

# 카테고리 개편 시 해당 카테고리의 모든 캐시 제거
await invalidate_tag("category:shoes")

태그 무효화의 비용

  • 태그 Set이 무한히 커지면 순회 비용이 커집니다. 태그당 원소 수에 상한을 두거나, 위 코드처럼 SSCAN으로 나눠 처리하십시오.
  • 태그 Set에 반드시 TTL을 부여하십시오. 캐시 키는 만료됐는데 태그 Set만 남아 계속 자라는 것이 전형적인 메모리 누수입니다.
  • Cluster에서는 태그 Set과 대상 키가 다른 노드에 있을 수 있으므로 DEL을 여러 키에 한 번에 못 씁니다. 노드별로 나누거나 클라이언트가 처리하도록 하십시오.

패턴 3. 클라이언트 사이드 캐싱(RESP3 Tracking)

Redis 6부터는 Redis가 직접 각 클라이언트가 어떤 키를 읽었는지 추적하고, 그 키가 바뀌면 해당 클라이언트에게 무효화 메시지를 보냅니다. 5장에서 직접 만든 2계층 캐시 무효화를 Redis가 대신 관리해 주는 셈입니다.

bash
# RESP3 프로토콜로 연결한 뒤 추적을 켠다
127.0.0.1:6379> HELLO 3
127.0.0.1:6379> CLIENT TRACKING ON
OK

# 이제 이 연결이 읽은 키가 다른 곳에서 바뀌면
# __redis__:invalidate 푸시 메시지로 키 이름이 도착한다.

# 서버가 쓰는 추적 테이블 크기를 확인한다
127.0.0.1:6379> INFO clients
tracking_clients:12
구분 내용
장점 무효화 전파 코드를 직접 짜지 않아도 된다. 읽은 키만 추적하므로 불필요한 방송이 없다
단점 Redis 6 이상과 RESP3 지원 클라이언트가 필요하다. 서버가 추적 테이블 메모리를 쓴다
모드 기본 모드는 키 단위로 정확히 추적한다. 브로드캐스트 모드(BCAST)는 접두사 단위로 전체에 알리므로 서버 메모리는 아끼지만 불필요한 무효화가 늘어난다
주의 연결이 끊기면 그동안의 무효화를 놓친다. 재연결 시 로컬 캐시를 통째로 비우는 처리가 반드시 필요하다

6-3. 고급 아키텍처 — CDC와 하이브리드

지금까지의 전략은 모두 애플리케이션 코드가 변경을 안다는 전제 위에 있습니다. 그런데 현실에는 그 전제가 깨지는 경로가 많습니다.

  • 야간 배치 작업이 DB를 직접 업데이트한다
  • 다른 팀의 서비스가 같은 테이블에 쓴다
  • 운영자가 장애 대응 중 수기 SQL을 실행한다
  • 데이터 마이그레이션 스크립트가 대량으로 값을 바꾼다

이 경우 애플리케이션 기반 무효화는 전부 무력합니다. CDC(Change Data Capture) 는 애플리케이션이 아니라 DB의 변경 로그(MySQL binlog, PostgreSQL WAL)를 직접 읽어 변경을 감지합니다. DB에 기록된 모든 변경이 대상이므로 위의 모든 경로를 빠짐없이 잡아냅니다.

text
[애플리케이션 기반 무효화]            [CDC 기반 무효화]

  App --write--> DB                     App  ----+
   |                                    Batch ---+--write--> DB
   +--DEL--> Cache                      수기SQL --+            |
                                                             binlog / WAL
  (App 을 거치지 않는 변경은                                    |
   영원히 놓친다)                                        Debezium 등 커넥터
                                                             |
                                                        Kafka / Stream
                                                             |
                                                      무효화 소비자 --DEL--> Cache

                                        (변경 경로가 무엇이든 전부 잡힌다)

How to — 단계별 도입

① 대상 테이블과 컬럼을 좁힙니다. 모든 테이블을 CDC로 흘리면 이벤트 양이 감당이 안 됩니다. 캐시에 실제로 영향을 주는 테이블만 선택하고, 가능하면 관심 있는 컬럼만 필터링합니다.

② 변경 행에서 캐시 키를 계산하는 규칙을 한 곳에 정의합니다. 이것이 CDC 도입의 실제 작업량 대부분입니다. "products 테이블의 id=1001 행이 바뀌면 어떤 캐시 키들이 낡는가"를 코드로 표현해야 합니다.

python
# 테이블 변경 -> 무효화할 캐시 키 목록. 이 매핑이 CDC 무효화의 핵심이다.
# 애플리케이션의 캐시 키 규칙과 반드시 같은 곳에서 관리해야 어긋나지 않는다.

def keys_for_change(table: str, before: dict | None, after: dict | None) -> list[str]:
    row = after or before        # DELETE 면 after 가 없다
    if table == "products":
        keys = [f"product:{row['id']}"]
        # 카테고리가 바뀌었다면 옛 카테고리와 새 카테고리 목록이 모두 낡는다
        for r_ in (before, after):
            if r_ and r_.get("category"):
                keys.append(f"ver-bump:category:{r_['category']}")
        return keys
    if table == "product_prices":
        return [f"product:{row['product_id']}"]
    return []


async def consume_cdc_event(event: dict) -> None:
    """CDC 이벤트 하나를 처리한다. 하는 일은 삭제뿐이다(멱등 + 순서 무관)."""
    keys = keys_for_change(event["table"], event.get("before"), event.get("after"))

    plain = [k for k in keys if not k.startswith("ver-bump:")]
    bumps = [k.removeprefix("ver-bump:") for k in keys if k.startswith("ver-bump:")]

    if plain:
        await r.delete(*plain)
    for ns in bumps:
        await bump_version(ns)      # 버전 증가는 멱등하지 않으므로 event_id 로 중복 방지

③ 소비자가 하는 일을 삭제로 제한합니다. 5-2장에서 본 대로, 삭제만 하면 멱등성과 순서 문제가 사라집니다. CDC 이벤트로 캐시에 값을 채우는 것은 훨씬 더 어려운 문제이며, 처음부터 시도하지 마십시오.

④ 지연(lag)을 측정하고 경보를 겁니다. CDC 파이프라인이 멈추면 무효화가 통째로 멈춥니다. 그런데 서비스는 멀쩡해 보입니다. 캐시는 응답하고 있고 단지 틀린 값일 뿐이기 때문입니다. 이것이 CDC 운영의 가장 위험한 지점입니다.

python
# CDC 이벤트에 담긴 DB 커밋 시각과 처리 시각의 차이가 곧 무효화 지연이다.
lag_seconds = time.time() - event["source"]["ts_ms"] / 1000
metrics.gauge("cache.invalidation.lag_seconds", lag_seconds)

# 경보 기준 예: lag 이 백스톱 TTL 의 절반을 5분 이상 넘으면 알린다.
# lag 이 TTL 을 넘어서면 CDC 가 있으나 마나가 된다.

하이브리드 — CDC로 실시간성을, TTL로 안전망을

가장 견고한 조합

CDC로 실시간성을 확보하되, 긴 TTL을 안전망으로 두는 것이 가장 견고합니다. 이 둘은 서로를 대체하는 관계가 아니라 서로의 약점을 덮는 관계입니다.

  • CDC가 덮는 것: 평상시의 정확성. 변경 후 1초 이내 무효화. 애플리케이션을 거치지 않는 변경까지 포착.
  • TTL이 덮는 것: CDC 파이프라인이 멈췄을 때의 최악 스테일 시간 상한. 커넥터 장애, 매핑 규칙 누락, 소비자 배포 실패.
text
[CDC 없이 TTL 만]        TTL 을 짧게 잡아야 한다 (60초)
                          -> 미스가 잦아 DB 부하가 크다

[CDC 만, TTL 없이]       파이프라인이 멈추면 영원히 스테일
                          -> 서비스는 멀쩡해 보이므로 아무도 모른다

[CDC + 긴 TTL]  <- 권장   평상시: CDC 가 1초 내 무효화 -> 정확
                          장애시: TTL 3600초가 상한   -> 최악이 유한

  => CDC 를 넣으면 TTL 을 "짧게" 가 아니라 "길게" 바꿀 수 있다.
     이것이 CDC 도입으로 얻는 실질적인 성능 이득이다.
도입 전 도입 후 얻는 것
TTL 60초, 무효화 코드 산재 TTL 3600초, CDC가 무효화 DB 조회가 60분의 1로 감소. 동시에 스테일 시간은 60초에서 1초 수준으로 단축

CDC를 도입하면 안 되는 경우

  • 운영 인력이 없을 때. 커넥터, 브로커, 소비자가 모두 새로운 장애 지점입니다. 새벽에 이것을 고칠 사람이 없으면 도입하지 마십시오.
  • 매핑을 관리할 주인이 없을 때. "테이블 변경 → 캐시 키" 매핑은 스키마가 바뀔 때마다 같이 바뀌어야 합니다. 이것이 방치되면 CDC는 조용히 무력해집니다.
  • 2단계(쓰기 시 삭제)를 아직 안 했을 때. 순서를 건너뛰지 마십시오. 2단계로 먼저 효과를 확인하고, 그것으로 부족한 이유가 명확할 때 3단계로 갑니다.

7. 최적화 및 모니터링

7-1. 캐시 관통(Cache Penetration) 방어

지금까지는 "캐시에 낡은 값이 있는" 문제를 다뤘습니다. 반대로 캐시에 값이 영원히 생기지 않는 문제도 있습니다. 존재하지 않는 데이터를 계속 조회하면 매번 미스가 나고, 모든 요청이 DB로 직행합니다. 이것이 캐시 관통이며, 스크래핑이나 잘못된 ID 파라미터로 쉽게 재현됩니다.

text
GET /products/999999999   (존재하지 않는 상품)
   -> 캐시 MISS
   -> DB 조회 -> 결과 없음
   -> 캐시에 아무것도 안 넣음        <- 여기가 문제
   -> 다음 요청도 똑같이 MISS

  초당 1만 건이 들어오면 초당 1만 건이 그대로 DB 로 간다.
  캐시가 있으나 마나인 상태가 된다.

How to — 방어 1: 없음(negative)도 캐싱한다

① "결과 없음"을 나타내는 구분 가능한 값(sentinel)을 정합니다. 빈 문자열은 쓰지 마십시오. "캐시에 없음"과 구분되지 않습니다. ② negative 캐시의 TTL은 정상 캐시보다 훨씬 짧게 잡습니다. 나중에 실제로 생성될 데이터를 오래 막아버리면 안 되기 때문입니다.

python
NOT_FOUND = "\x00__notfound__"      # 정상 페이로드와 절대 겹치지 않는 값
NEGATIVE_TTL = 30                   # 정상 TTL(300) 보다 훨씬 짧게


async def get_product(pid: int) -> dict | None:
    key = f"product:{pid}"
    cached = await r.get(key)

    if cached == NOT_FOUND:
        return None                 # 없다는 사실을 캐시가 답한다. DB 에 안 간다.
    if cached is not None:
        return json.loads(cached)

    row = await db.get_product(pid)
    if row is None:
        # 없다는 사실도 캐싱한다. TTL 은 짧게.
        await r.setex(key, NEGATIVE_TTL, NOT_FOUND)
        return None

    await r.setex(key, ttl_with_jitter(300), json.dumps(row))
    return row

negative 캐시가 만드는 새 문제

상품을 새로 생성했는데 직전에 누가 그 ID를 조회해 negative 캐시가 깔려 있으면, 최대 NEGATIVE_TTL초 동안 "없는 상품"으로 보입니다. 그래서 생성 경로에서도 캐시를 삭제해야 합니다. 무효화를 수정과 삭제에만 걸고 생성에는 빠뜨리는 것이 흔한 버그입니다.

How to — 방어 2: 입력을 먼저 거른다

더 싼 방법은 애초에 DB까지 보내지 않는 것입니다. ID 형식 검증(범위, 자릿수, 체크섬)만으로도 무작위 스캔 트래픽의 대부분을 걸러낼 수 있습니다. 존재하는 ID의 집합이 크고 안정적이라면 블룸 필터를 앞에 두는 방법도 있습니다. 블룸 필터는 "확실히 없다"는 판정만 신뢰할 수 있고 "있다"는 오탐이 가능하지만, 관통 방어에는 그 성질로 충분합니다.

7-2. 사전 갱신(Proactive) — 만료되기 전에 미리 채운다

무효화의 반대 방향 최적화입니다. 캐시가 비는 순간을 없애면 미스 자체가 사라집니다.

방법 동작 적합한 대상
스케줄 워밍업 스케줄러가 주기적으로 인기 키를 다시 계산해 채운다 인기 순위, 집계 리포트처럼 대상이 명확하고 개수가 적은 데이터
배포 워밍업 배포 직후 트래픽을 받기 전에 핵심 키를 미리 채운다 로컬 캐시를 쓰는 서비스. 배포마다 콜드 스타트가 반복되는 경우
확률적 조기 갱신 만료가 가까워질수록 높은 확률로 조회 요청 하나가 미리 갱신한다 핫키. 자세한 내용은 유닛 7-2에서 다룬다
예측 기반 갱신 (AI/ML) 접근 패턴을 학습해 "곧 조회될 키"를 미리 채운다 패턴이 뚜렷하고 캐시 비용이 큰 대규모 서비스

예측 기반 갱신을 현실적으로 보는 법

"AI로 캐시를 예측한다"는 말은 거창하지만, 실제로 효과를 내는 형태는 대부분 매우 단순한 규칙입니다.

  • 시간대 패턴: 매일 오전 9시에 조회되는 대시보드 집계를 8시 55분에 미리 계산한다.
  • 이벤트 선행: 타임세일 시작 10분 전에 해당 상품들의 캐시를 채운다.
  • 동반 접근: 상품 상세를 보면 리뷰 목록도 보므로, 상세 조회 시 리뷰를 미리 채운다.

이 세 가지를 먼저 구현해 보십시오. 모델을 학습시키기 전에 얻을 수 있는 이득의 대부분이 여기 있습니다. 예측 갱신은 틀렸을 때 메모리와 DB 부하를 낭비하므로, 적중률을 측정해 "미리 채운 키 중 실제로 조회된 비율"이 의미 있는 수준인지 확인한 뒤에만 유지하십시오.

7-3. 모니터링 — 무엇을 보고 무엇에 경보를 걸 것인가

캐시 무효화의 가장 무서운 실패 방식은 조용하다는 것입니다. 무효화가 멈춰도 에러율은 0이고 응답 속도는 오히려 빨라집니다. 틀린 값을 빠르게 돌려주고 있기 때문입니다. 정확성은 따로 측정하지 않으면 절대 드러나지 않습니다.

지표 구하는 법 경보 기준(예시) 의미
캐시 적중률 INFO stats의 keyspace_hits / (hits + misses) 평소 대비 10%p 이상 하락이 10분 지속 TTL 오설정, 대량 무효화, 메모리 축출 발생
원본 DB QPS DB 모니터링 평소 대비 3배 이상 급증 캐시 스톰 진행 중. 즉시 대응 필요
무효화 실패 건수 애플리케이션 메트릭(invalidate 예외 카운터) 0 초과가 5분 지속 Redis 순단 또는 코드 결함. 스테일이 쌓이고 있다
무효화 지연(lag) 이벤트의 DB 커밋 시각과 처리 시각의 차이 백스톱 TTL의 절반 초과가 5분 지속 CDC나 이벤트 파이프라인이 밀리고 있다
스테일 불일치율 표본 키를 뽑아 캐시 값과 DB 값을 비교 0.1% 초과 정확성을 직접 재는 유일한 지표. 다른 지표로는 절대 안 보인다
축출(eviction) INFO stats의 evicted_keys 0 초과가 지속 메모리 부족. TTL과 무관하게 캐시가 사라지고 있다
TTL 없는 키 주기적 SCAN 후 TTL이 -1인 키 집계 캐시 네임스페이스에서 1개라도 발견 영구 스테일 + 메모리 누수의 씨앗

How to — 스테일 불일치율 측정기

위 표에서 가장 중요한 지표입니다. 그리고 대부분의 팀이 가지고 있지 않은 지표이기도 합니다. 표본을 뽑아 캐시와 DB를 비교하는 작은 배치 하나면 됩니다.

python
import random

SAMPLE_SIZE = 200


async def measure_staleness() -> float:
    """표본을 뽑아 캐시와 DB 의 불일치율을 잰다. 5분마다 실행한다."""
    pids = await db.sample_product_ids(SAMPLE_SIZE)

    mismatched = compared = 0
    for pid in pids:
        cached = await r.get(f"product:{pid}")
        if cached is None:
            continue                    # 캐시에 없으면 스테일이 아니다
        compared += 1
        fresh = await db.get_product(pid)
        if json.loads(cached) != fresh:
            mismatched += 1
            log.warning("스테일 감지: product:%s", pid)

    ratio = mismatched / compared if compared else 0.0
    metrics.gauge("cache.stale_ratio", ratio)
    return ratio

측정기 자체가 부하가 되지 않게

이 배치는 표본마다 DB를 조회합니다. 표본 크기와 주기를 크게 잡으면 그 자체가 부하입니다. 200건을 5분마다면 초당 0.7건 수준이므로 안전합니다. 또한 이 조회는 캐시를 우회해서 DB를 직접 읽어야 의미가 있습니다. 캐시를 거쳐 읽으면 항상 일치한다는 결과만 나옵니다.

7-4. 무효화 실패를 전제로 한 안전장치 목록

현실 인식

무효화는 반드시 어딘가에서 실패합니다. 배포 누락, 예외로 인한 삭제 코드 미실행, Redis 순단, 개발자가 새 캐시 키를 추가하며 무효화 코드를 빠뜨림. 따라서 "무효화가 100% 동작한다"는 가정 위에 시스템을 세우면 안 됩니다.

안전장치 내용
백스톱 TTL 모든 캐시에 TTL을 부여한다. 무효화가 실패해도 손실 시간의 상한이 생긴다
무효화 실패 로깅 삭제 명령의 예외를 삼키지 말고 로그와 메트릭으로 남긴다
단일 진입점 캐시 읽기, 쓰기, 삭제를 저장소 계층 한 곳으로 모은다. 흩어진 호출은 누락의 원인
키 규칙 문서화 어떤 도메인 변경이 어떤 키에 영향을 주는지 표로 관리한다(유닛 6-1 네이밍 전략과 함께)
수동 퍼지 도구 운영자가 특정 네임스페이스를 즉시 비울 수 있는 관리 API를 준비한다. 사고 시 복구 시간을 줄인다
정합성 모니터링 표본으로 캐시 값과 DB 값을 비교해 불일치율을 지표화한다(7-3)
캐시 없이 동작 확인 Redis를 내려도 서비스가 (느리게라도) 동작하는지 정기적으로 확인한다

수동 퍼지 도구를 만들 때

"전체 비우기" 버튼은 그 자체가 장애 원인입니다(1-3의 일괄 무효화 폭탄). 퍼지 도구는 네임스페이스 단위로 좁히고, 가능하면 버전 증가 방식(6-2)으로 만들어 실제 삭제 부하가 없게 하십시오. FLUSHDB와 FLUSHALL은 운영 ACL에서 아예 막는 것이 정답입니다.

8. 안티패턴 정리

안티패턴 무엇이 문제인가 대안
TTL 없는 캐시 키 무효화가 실패하면 영원히 스테일. 메모리 누수 모든 키에 TTL. 축출 정책도 함께 설정(유닛 6-3)
KEYS로 패턴 삭제 O(N) 블로킹으로 전체 장애 유발 버전 무효화 또는 태그 무효화
캐시를 원본처럼 사용 축출로 데이터가 사라진다 원본은 DB. Redis를 원본으로 쓰려면 영속성과 복제 설계 필요(유닛 6-4)
캐시 갱신(Write-Update) 남용 동시 쓰기 시 옛 값이 최종으로 남는다 삭제 후 다음 조회에 채우기
모든 곳에서 직접 r.delete 무효화 누락이 필연적으로 발생 저장소 계층으로 캡슐화
동일 TTL 대량 세팅 동시 만료로 DB 폭탄 지터 적용(그리고 유닛 7-2의 스탬피드 방어)
캐시를 먼저 지우고 DB 갱신 그 사이 조회가 옛 DB 값을 다시 캐싱한다 DB 먼저, 캐시 나중
생성 경로에 무효화 누락 negative 캐시 때문에 새 데이터가 없는 것으로 보인다 생성, 수정, 삭제 세 경로 모두에 무효화
로컬 캐시 TTL을 Redis와 동일하게 전파 신호를 놓치면 복구 수단이 없다 로컬은 수 초 ~ 수십 초로 짧게
이벤트 파이프라인만 믿고 TTL 제거 파이프라인 장애가 조용한 영구 스테일이 된다 CDC를 넣어도 TTL은 유지(유닛 6-3)

9. 🧪 [실습] 스테일 데이터를 직접 만들어보기

4-1장에서 설명한 경쟁 조건을 눈으로 확인합니다. 이 실습의 목적은 "이런 일이 정말 일어난다"를 직접 보는 것입니다.

① 지연을 넣은 캐시 채우기 코드를 준비합니다.

python
# stale_demo.py
import threading
import time

import redis

r = redis.Redis(host="localhost", port=6379, decode_responses=True)

DB = {"price": 10000}


def reader():
    """느린 DB 조회를 흉내 낸다."""
    if r.get("product:1") is None:
        value = DB["price"]                     # t2: 옛 값 10000 을 읽음
        time.sleep(1.0)                         # 느린 직렬화/네트워크 구간
        r.setex("product:1", 60, value)         # t5: 뒤늦게 캐시에 기록
        print(f"[reader] 캐시에 기록: {value}")


def writer():
    time.sleep(0.3)
    DB["price"] = 8000                          # t3: 원본 변경
    r.delete("product:1")                       # t4: 캐시 삭제 (지울 게 없다)
    print("[writer] DB 갱신 + 캐시 삭제")


if __name__ == "__main__":
    r.delete("product:1")
    t1, t2 = threading.Thread(target=reader), threading.Thread(target=writer)
    t1.start(); t2.start(); t1.join(); t2.join()
    time.sleep(2.0)                             # 지연 삭제 타이머(1.5초)가 있다면 끝난 뒤에 확인한다
    print("DB    :", DB["price"])
    print("Redis :", r.get("product:1"))

② 실행해 결과를 확인합니다.

bash
$ python stale_demo.py
[writer] DB 갱신 + 캐시 삭제
[reader] 캐시에 기록: 10000
DB    : 8000
Redis : 10000        <- 스테일 데이터가 60초간 살아남는다

③ 지연 이중 삭제를 추가하고 다시 실행합니다.

python
def writer():
    time.sleep(0.3)
    DB["price"] = 8000
    r.delete("product:1")                                        # 1차 삭제
    threading.Timer(1.5, lambda: r.delete("product:1")).start()  # 2차 삭제
    print("[writer] DB 갱신 + 지연 이중 삭제 예약")

다시 실행하면 결과가 달라집니다.

bash
$ python stale_demo.py
[writer] DB 갱신 + 지연 이중 삭제 예약
[reader] 캐시에 기록: 10000
DB    : 8000
Redis : None         <- 1.5초 뒤 2차 삭제가 옛 값을 걷어냈다

확인 결과

  • 결과: 1차 삭제만 있을 때는 Redis에 10000이 남지만, 지연 이중 삭제를 추가하면 1.5초 뒤 캐시가 비워지고 다음 조회에서 8000이 채워집니다.
  • 의미: 캐시 무효화는 "지웠다"가 아니라 "언제 지웠는가" 의 문제입니다. 읽기와 쓰기가 교차하는 짧은 구간을 어떻게 덮을 것인지가 설계의 핵심이며, 그 위에는 항상 TTL이라는 마지막 안전망이 있어야 합니다.
  • 확장 과제: threading.Timer 대신 프로세스를 중간에 강제 종료해 보십시오. 예약된 2차 삭제가 사라지는 것을 확인할 수 있습니다. 이것이 4-1장에서 "영속 큐를 쓰라"고 한 이유입니다.

🚩 정리 — 전략 선택표

전략 언제 쓰는가 비용과 주의
TTL 만료 모든 캐시의 기본값 비용 0. 반드시 지터와 함께
적응형 TTL 데이터 종류별 변경 빈도 차이가 클 때 상한/하한 클램프 필수. 먼저 등급별 고정 TTL부터
쓰기 시 삭제 준실시간 정합성이 필요한 데이터 DB 먼저, 캐시 나중. 갱신이 아니라 삭제
지연 이중 삭제 읽기와 쓰기의 경합이 잦은 핫 데이터 지연 작업의 영속성 확보 필요. 지연은 p99보다 크게
Write-Through 쓴 직후 바로 읽히는 데이터 쓰기 지연 증가. DB와 캐시의 원자성은 여전히 없음
Write-Behind 쓰기가 매우 잦고 유실이 허용되는 카운터 유실 구간 3곳을 계산하고 문서로 합의
버전 무효화 목록과 집계 등 대량 키 일괄 무효화 구버전 키가 TTL까지 메모리 점유
태그 무효화 하나의 변경이 여러 키에 영향 태그 Set 크기 관리 필요. SSCAN 사용
이벤트 방송(Pub/Sub) 다중 서버 + 로컬 캐시 2계층 유실 가능. 로컬 TTL을 짧게
Stream + 아웃박스 무효화 유실이 문제가 될 때 멱등성 처리 필요. 스트림 길이 상한 설정
CDC 애플리케이션을 거치지 않는 DB 변경이 있을 때 운영 비용 큼. 지연 경보 필수. TTL은 그대로 유지
negative 캐싱 존재하지 않는 키 조회가 반복될 때 TTL을 짧게. 생성 경로에서 반드시 삭제

한 줄 요약

캐시 무효화의 목표는 완벽한 일치가 아니라 허용 가능한 불일치 시간의 관리입니다. 데이터별 스테일 예산을 먼저 정하고, 능동적 삭제로 평균을 낮추고, TTL로 최악을 제한하십시오. 그리고 그 결과를 불일치율이라는 숫자로 측정하십시오. 재지 않으면 틀린 줄도 모릅니다. 다음 유닛(7-2)에서는 무효화 직후에 찾아오는 또 다른 위협, 캐시 스탬피드를 다룹니다.


7-2. Cache Stampede 방어 전략 — 대규모 트래픽에서 Cache Miss 폭발을 막는 아키텍처

섹션 7. [Appendix] 보너스 트랙 · 유닛 46 / 48 · 보충 학습 자료

이 글에서 막을 것

  • Cache Stampede가 무엇이고 왜 스스로 악화되어 전면 장애로 번지는지 설명할 수 있습니다.
  • "만료 후 0.1초" 시나리오를 숫자로 계산해 우리 서비스의 위험도를 직접 추정할 수 있습니다.
  • 네 가지 방어 패턴 Locking · PER · SingleFlight · SWR의 동작을 시간 순서 상태도로 이해합니다.
  • 각 패턴을 단계별 How to에 따라 직접 구현하고, 주의 사항을 코드에 반영합니다.
  • 금융 · 커머스 · 사전예약 · 랭킹 등 상황별로 어떤 패턴을 조합할지 판단할 수 있습니다.

이 글을 읽는 방법

1장과 2장은 문제를 정의하고 숫자로 체감합니다. 3장이 본론이며 네 패턴 모두 "개념과 동작 → 시간 순서 상태도 → 특징 → 주의 사항 → 단계별 How to → 코드" 의 같은 순서로 서술합니다. 4장은 상황별 선택표이고, 5장부터는 마지막 방어선과 실습, 운영 점검입니다. Locking의 완성형 구현(락 연장, Pub/Sub 대기, 데코레이터)은 다음 유닛 7-3에서 이어집니다.

1. Cache Stampede란?

1-1. 정의

정의

Cache Stampede는 캐시가 만료되는 순간, 그 키를 기다리던 수많은 병렬 요청이 동시에 DB(origin)로 몰리는 현상입니다.

소 떼가 한꺼번에 몰려가는 모습에 빗대어 Thundering Herd, 개들이 먹이 하나에 달려드는 모습에 빗대어 Dogpile이라고도 부릅니다. 모두 같은 현상입니다.

text
[평상시 - 캐시 적중률 99%]
  10,000 req/s  ->  Redis (HIT)  ->  응답          DB 부하: 거의 0

[만료 순간 - Stampede]
  10,000 req/s  ->  Redis (MISS) ->  DB 로 같은 쿼리가 동시에 수천 개
                                      |
                                      v
                         커넥션 풀 100개 -> 즉시 고갈
                         대기열 폭증     -> 타임아웃
                         DB CPU 100%     -> 모든 쿼리 지연

핵심은 "같은 결과를 얻기 위한 같은 쿼리" 가 수천 번 실행된다는 점입니다. 첫 번째 쿼리 하나만 있으면 충분했고, 나머지는 전부 낭비입니다. 방어 전략은 모두 이 낭비를 없애는 서로 다른 방법입니다.

1-2. 왜 스스로 악화되는가 — 양의 되먹임

text
  캐시 MISS
     |
     v
  DB 로 요청 폭주 ----> DB 가 느려진다
     ^                      |
     |                      v
  다음 요청도 MISS <---- 캐시를 채우는 시간이 길어진다

  => 원을 돌 때마다 상황이 나빠진다.
     트래픽을 줄이거나 캐시가 채워지지 않는 한 자연 회복되지 않는다.

보통의 과부하는 요청이 줄면 회복됩니다. Stampede는 다릅니다. DB가 느려질수록 캐시를 채우는 첫 번째 쿼리도 느려지고, 그 사이 미스가 더 쌓입니다. 최악의 경우 캐시를 채우는 쿼리 자체가 타임아웃으로 실패해 캐시가 영영 채워지지 않습니다.

역설 — 캐시가 잘 동작할수록 더 위험하다

적중률 99%는 평소에 DB가 전체 트래픽의 1%만 받는다는 뜻입니다. DB 용량도 그 1%에 맞춰 줄여 두기 마련입니다. 그런데 핫키가 만료되는 순간 DB는 평소의 100배를 받습니다. 캐시가 훌륭할수록, 캐시가 사라지는 순간의 낙차는 커집니다.

1-3. 발생하는 세 가지 유형

유형 발생 상황 주 방어 수단
A. 동시 만료 (Mass Expiration) 같은 TTL로 한꺼번에 채운 수천 개 키가 같은 초에 만료. 배포 직후 일괄 적재가 전형적 TTL 지터(유닛 7-1), 사전 갱신
B. 핫키 만료 (Hot Key) 메인 배너, 실시간 랭킹, 인기 상품처럼 키 하나에 트래픽이 집중. 그 키 하나가 만료되면 전체가 DB로 직행 이 글의 네 패턴(Locking, PER, SingleFlight, SWR)
C. 콜드 스타트 (Cold Start) Redis 재기동, FLUSHALL, 장애 복구, 신규 노드 투입으로 캐시 전체가 빈 상태에서 트래픽 유입 워밍업, 점진적 트래픽 투입, 부하 차단(5장)

지터는 유형 A를 거의 해결하지만 유형 B에는 무력합니다. 키가 하나뿐이면 흩뿌릴 대상이 없기 때문입니다. 이 글은 유형 B를 중심으로 네 가지 패턴을 다루고, 유형 C는 5장에서 다룹니다.

2. 시나리오 — 0.1초의 재앙

추상적인 설명보다 숫자 하나가 더 명확합니다. 인기 스마트폰 사전예약 페이지를 예로 들어 봅니다.

조건 값
대상 아이폰 16 사전예약 상품 페이지 (키 product:iphone16:detail)
유입 트래픽 초당 10,000건 (0.1초당 1,000건)
캐시 TTL 60초
DB 원본 쿼리 소요 시간 평소 200ms (가격 · 재고 · 옵션 · 프로모션 조인)
DB 커넥션 풀 애플리케이션 전체 합계 100개
커넥션 대기 타임아웃 3초
text
시간         캐시 상태        DB 상태                         사용자 체감
---------------------------------------------------------------------------------
T=0s~59.9s   HIT              부하 0. 평온함                   응답 5ms
             (모든 요청이 Redis 에서 끝난다)

T=60.0s      만료(삭제됨)     아직 조용함                      -
             (키가 사라진다. 아무도 모른다)

T=60.1s      MISS x 1,000     같은 쿼리 1,000개 동시 도착      응답 대기
             (0.1초 사이 들어온 1,000건이 전부 DB 로 직행)

T=60.1s~     여전히 비어 있음 커넥션 100개 즉시 소진           응답 대기
             (첫 쿼리가 끝나야 채워진다)   900개는 커넥션 대기열

T=60.3s      여전히 비어 있음 DB CPU 급등, 쿼리 200ms -> 2s    응답 대기
             (느려진 탓에 캐시를 채울 쿼리도 늦어진다)
             그 사이 또 2,000건 유입

T=63.1s      여전히 비어 있음 대기열 타임아웃(3s) 발생         5xx 에러
             재시도 요청이 더해져 트래픽은 오히려 증가

T=65s~       비어 있음        DB 과부하, 다른 테이블 쿼리까지  서비스 전면 장애
                              지연 -> 주문 · 로그인까지 실패

숫자로 다시 보기

계산 결과 의미
캐시가 비어 있는 시간 x 초당 요청 0.2초 x 10,000 = 2,000건 첫 쿼리가 끝나기 전에 쌓이는 중복 요청
중복 요청 - 커넥션 풀 2,000 - 100 = 1,900건 대기 대기열이 생기는 순간 DB는 이미 포화
필요한 쿼리 수 1건 2,000건 중 1,999건은 결과가 완전히 같은 낭비

이 시나리오가 알려주는 것

  • 장애의 원인은 트래픽 총량이 아니라 "빈틈"입니다. 59.9초 동안 아무 문제가 없었습니다. 0.2초짜리 빈틈이 전체를 무너뜨렸습니다.
  • 빈틈은 DB가 느릴수록 넓어집니다. 200ms가 2초가 되면 빈틈은 10배가 되고, 그 안에 쌓이는 요청도 10배가 됩니다.
  • 재시도는 불에 기름입니다. 클라이언트와 게이트웨이의 자동 재시도가 트래픽을 두세 배로 불립니다.
  • 따라서 방어의 목표는 명확합니다. 빈틈을 없애거나(PER, SWR), 빈틈 안의 중복을 하나로 줄이는 것(Locking, SingleFlight) 입니다.

우리 서비스의 위험도 계산해 보기

핫키의 초당 요청 수 x 원본 쿼리 p99(초)를 계산하고 이를 DB 커넥션 풀 크기와 비교하십시오. 결과가 풀 크기보다 크면 그 키는 만료 한 번으로 장애가 날 수 있는 키입니다. 핫키 후보는 redis-cli --hotkeys(LFU 축출 정책 필요)나 애플리케이션 접근 로그에서 찾습니다.

3. 방어 전략 오버뷰

네 패턴은 같은 문제를 서로 다른 지점에서 막습니다. "누가 DB에 가는가" 와 "그동안 나머지는 무엇을 하는가" 로 보면 차이가 선명해집니다.

패턴 한 줄 비유 누가 DB에 가는가 나머지는 무엇을 하는가 핵심 가치
3.1 Locking (Mutex) 번호표 뽑기 락을 잡은 한 명(서버 전체 기준) 대기했다가 채워진 캐시를 읽는다 Consistency — 항상 최신 값, DB 쿼리 1개
3.2 PER (Probabilistic Early Recomputation) 우유 유통기한 전에 미리 사 두기 만료 전에 확률적으로 뽑힌 소수 아무 일도 없다. 기존 캐시를 그대로 읽는다 High Traffic — 만료 순간 자체를 없앤다
3.3 SingleFlight (Request Coalescing) 같은 행선지 승객을 한 비행기에 태우기 프로세스 안에서 첫 요청 하나 같은 실행(Flight)의 결과를 함께 받는다 Efficiency — 애플리케이션 레벨, 인프라 불필요
3.4 SWR (Stale-While-Revalidate) 어제 신문을 먼저 건네고 오늘 신문은 뒤에서 배달 백그라운드 갱신 작업 하나 낡은 값을 즉시 받는다 UX — 사용자 대기 0
비교 항목 Locking PER SingleFlight SWR
만료 순간 DB 쿼리 수 1 대부분 0 (이미 갱신됨) 서버 대수만큼 1 (백그라운드)
사용자 대기 시간 있음 (갱신 시간만큼) 뽑힌 소수만 있음 있음 (갱신 시간만큼) 없음
낡은 값 응답 없음 없음 없음 있음 (의도된 동작)
서버 간 중복 제거 된다 확률적으로 줄어든다 안 된다 (프로세스 한정) 락과 함께 쓰면 된다
필요한 인프라 Redis (분산 락) 없음 (값에 메타 추가) 없음 Redis (갱신 락)
콜드 스타트 대응 된다 안 된다 일부 (프로세스 내) 안 된다 (낡은 값조차 없음)
구현 난도 중 ~ 상 중 하 중

네 패턴은 경쟁 관계가 아니다

표의 "안 된다" 칸을 보십시오. 모든 패턴에 구멍이 있고, 그 구멍을 다른 패턴이 메웁니다. 실무에서는 하나를 고르는 것이 아니라 층으로 쌓습니다.

text
요청
 |
 v
[1층] SingleFlight   ---- 같은 서버 안의 중복을 먼저 1개로 합친다 (비용 0)
 |
 v
[2층] SWR 또는 PER   ---- 캐시가 비는 순간 자체를 없앤다 (핫키)
 |     (캐시가 정말 비어 있으면)
 v
[3층] Locking        ---- 서버 간 중복을 1개로 합친다 (콜드 스타트, 첫 적재)
 |
 v
[4층] 부하 차단       ---- 세마포어 / 서킷 브레이커로 DB 동시 접근 상한 (최후 방어선)
 |
 v
 DB

패턴보다 먼저 깔아야 할 기본기

  • TTL 지터: 유형 A(동시 만료)는 지터로 대부분 해결됩니다. 모든 키의 기본값입니다(유닛 7-1).
  • DB 동시 접근 상한: 네 패턴이 모두 뚫려도 DB가 죽지 않게 하는 최후의 선입니다(5장).
  • 워밍업 절차: 콜드 스타트는 패턴이 아니라 운영 절차로 막습니다(5장).

3.1. Locking(Mutex) — 번호표 뽑기

한 줄 요약

한 명만 DB에 보내고 나머지는 대기시킨다. 핵심 가치: Consistency — 모두가 최신 값을 받고 DB 쿼리는 정확히 1개.

개념과 동작

은행 창구의 번호표와 같습니다. 캐시가 비어 있다는 것을 발견한 요청들이 모두 DB로 달려가는 대신, Redis에 락 키를 먼저 만드는 경쟁을 합니다. SET lock:key token NX EX 5는 "키가 없을 때만 만든다"를 원자적으로 수행하므로, 1,000개가 동시에 시도해도 정확히 한 명만 성공합니다. 성공한 요청이 DB를 조회해 캐시를 채우고, 실패한 요청들은 잠깐 기다렸다가 채워진 캐시를 읽습니다.

시간 순서 상태도

text
시간     요청 A (락 획득자)               요청 B~Z (999개)                 Redis 상태
-------------------------------------------------------------------------------------------------
T0       GET product -> MISS              GET product -> MISS              product: 없음
T0+1ms   SET lock NX EX 5 -> OK           SET lock NX EX 5 -> nil          lock: A 의 토큰
T0+2ms   GET product 한 번 더 (재확인)    대기 20ms (+ 무작위 지연)         product: 없음
         -> 여전히 MISS
T0+3ms   DB 조회 시작 (200ms)             GET product -> MISS, 다시 대기    (DB 쿼리 1개 진행 중)
  ...                                     대기 간격 40ms -> 80ms -> ...
T0+203ms DB 조회 완료
T0+204ms SET product value EX 60                                            product: 새 값
T0+205ms Lua: 토큰이 내 것이면 DEL lock                                     lock: 없음
T0+205ms 응답
T0+240ms                                  GET product -> HIT, 응답

결과: DB 쿼리 1,000개 -> 1개.  대가: B~Z 는 약 200ms 를 기다렸다.

특징

구분 내용
장점 효과가 가장 확실하다. 서버가 몇 대든 DB 쿼리가 1개로 줄어든다. 낡은 값을 응답하지 않는다
단점 대기하는 요청의 응답 지연이 늘어난다. 락 경쟁과 폴링이 Redis에 추가 명령을 만든다
강점 영역 콜드 스타트와 첫 적재. 낡은 값조차 없는 상황에서도 동작하는 유일한 패턴
적합한 데이터 재고 표시, 가격, 좌석 현황처럼 낡은 값을 보여주면 안 되는 데이터

주의 사항

락이 새로운 장애 원인이 되는 다섯 가지 경우

  • 락에 TTL이 없다: 락을 잡은 프로세스가 죽으면 락이 영원히 남고, 모든 요청이 영원히 기다립니다. 반드시 SET ... NX EX 한 명령으로 만듭니다.
  • 남의 락을 지운다: DB 조회가 락 TTL보다 오래 걸리면 락이 먼저 만료되고, 다른 요청이 새 락을 잡습니다. 이때 그냥 DEL하면 남의 락이 지워집니다. 토큰을 비교하고 지우는 Lua 스크립트로 해제합니다.
  • 재확인(double-check)을 빠뜨린다: 락을 잡기 직전에 다른 요청이 캐시를 이미 채웠을 수 있습니다. 락을 잡은 직후 캐시를 한 번 더 읽지 않으면 불필요한 DB 쿼리가 생깁니다.
  • 대기에 상한이 없다: 락 소유자가 느리면 대기자 전원이 느려집니다. 대기 시간 상한을 두고, 넘으면 정해 둔 정책(에러 응답 등)으로 빠져나옵니다.
  • 대기 실패 시 DB로 직행한다: 가장 흔한 실수입니다. 999명이 동시에 타임아웃되어 DB로 가면 Stampede가 2초 늦게 다시 일어날 뿐입니다. 폴백은 "빠른 실패" 또는 "DB 동시 접근 상한 아래에서만 조회"여야 합니다.

How to — 단계별 구현

① 락 키 이름을 정한다. 캐시 키에 접두사를 붙여 lock:{캐시키}로 만듭니다. 캐시 키 하나당 락 하나입니다.

② 락 TTL을 정한다. "원본 조회 + 캐시 저장"의 p99보다 넉넉히 크게(보통 3배 이상) 잡습니다. p99가 1초면 락 TTL 3~5초입니다. 너무 짧으면 조회 도중 락이 풀려 중복 조회가 생기고, 너무 길면 소유자가 죽었을 때 복구가 늦습니다.

③ 토큰을 넣어 원자적으로 락을 잡는다. 요청마다 고유한 토큰(UUID)을 값으로 넣습니다. 이 토큰이 "이 락은 내 것"이라는 증명입니다.

bash
127.0.0.1:6379> SET lock:product:iphone16:detail 9f1c2e NX EX 5
OK                        # 락 획득 성공
127.0.0.1:6379> SET lock:product:iphone16:detail 7ab0d4 NX EX 5
(nil)                     # 이미 누군가 보유 중 -> 실패
127.0.0.1:6379> TTL lock:product:iphone16:detail
(integer) 5               # 소유자가 죽어도 최대 5초 뒤에 풀린다

④ 락을 잡으면 캐시를 한 번 더 확인한다. 비어 있을 때만 DB를 조회하고 캐시에 저장합니다.

⑤ 토큰을 비교해서 해제한다. "읽고 비교하고 지우기"를 Lua로 묶어 원자적으로 실행합니다. finally에 두어 예외가 나도 반드시 해제합니다.

⑥ 락을 못 잡은 요청은 짧게 기다리며 캐시를 다시 읽는다. 대기 간격은 조금씩 늘리고(지수 백오프) 무작위 지연을 섞습니다. 모두가 정확히 20ms마다 동시에 두드리면 그 자체가 작은 Stampede가 되기 때문입니다. 대기 중에도 락 획득을 계속 시도해야 소유자가 죽었을 때 다음 사람이 이어받습니다.

⑦ 대기 상한을 넘으면 정책대로 빠져나온다. 기본값은 빠른 실패(HTTP 503 + Retry-After)입니다. 서비스 특성상 반드시 응답해야 한다면 5장의 DB 동시 접근 상한 아래에서만 조회합니다.

python
import asyncio
import json
import random
import uuid

import redis.asyncio as redis

r = redis.from_url("redis://localhost:6379", decode_responses=True)

LOCK_TTL = 5            # 초. 원본 조회 p99 의 3배 이상
MAX_WAIT = 2.0          # 초. 대기 상한
BACKOFF_START = 0.02    # 초
BACKOFF_MAX = 0.2       # 초

# 토큰이 내 것일 때만 지운다. 읽기-비교-삭제를 원자적으로 수행한다.
RELEASE_LUA = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('DEL', KEYS[1])
end
return 0
"""
release_lock = r.register_script(RELEASE_LUA)


class CacheBusyError(Exception):
    """대기 상한 초과. 호출자는 503 + Retry-After 로 응답한다."""


async def get_with_lock(key: str, loader, ttl: int):
    # (0) 대부분의 요청은 여기서 끝난다
    cached = await r.get(key)
    if cached is not None:
        return json.loads(cached)

    lock_key = f"lock:{key}"
    token = uuid.uuid4().hex
    loop = asyncio.get_running_loop()
    deadline = loop.time() + MAX_WAIT
    backoff = BACKOFF_START

    while True:
        # (1) 락 획득 시도 - 원자적, TTL 포함
        if await r.set(lock_key, token, nx=True, ex=LOCK_TTL):
            try:
                # (2) 재확인 - 락을 기다리는 사이 누가 채웠을 수 있다
                cached = await r.get(key)
                if cached is not None:
                    return json.loads(cached)
                # (3) 나만 DB 에 간다
                value = await loader()
                await r.set(key, json.dumps(value), ex=ttl)
                return value
            finally:
                # (4) 내 토큰일 때만 해제
                await release_lock(keys=[lock_key], args=[token])

        # (5) 락 실패 - 백오프 + 무작위 지연 후 캐시 재확인
        await asyncio.sleep(backoff + random.uniform(0, backoff))
        backoff = min(backoff * 2, BACKOFF_MAX)

        cached = await r.get(key)
        if cached is not None:
            return json.loads(cached)

        # (6) 상한 초과 - DB 로 직행하지 않는다
        if loop.time() >= deadline:
            raise CacheBusyError(key)

7-3에서 이어서 완성하는 것

위 코드는 동작하는 최소 구현입니다. 조회가 락 TTL보다 길어질 때 락을 연장하는 워치독, 폴링 대신 Pub/Sub으로 "채워졌다"는 알림을 받는 대기 방식, 데코레이터로 재사용하는 방법, redis-py 내장 Lock과의 비교는 유닛 7-3에서 다룹니다.

3.2. PER(Probabilistic Early Recomputation) — 만료 전에 확률적으로 미리 갱신

한 줄 요약

만료 순간을 기다리지 않고 만료 직전에 확률적으로 뽑힌 요청 하나가 미리 갱신한다. 핵심 가치: High Traffic — 트래픽이 많을수록 더 잘 동작한다.

개념과 동작

우유를 유통기한 당일에 사러 가면 품절일 수 있습니다. 그래서 유통기한이 가까워지면 누군가 미리 사 둡니다. PER은 이 "누군가"를 확률로 정합니다. 만료까지 여유가 많으면 확률이 0에 가깝고, 만료가 가까워질수록 확률이 급격히 올라갑니다. 트래픽이 많으면 만료 1~2초 전에 누군가 반드시 당첨되어 갱신하므로, 실제 만료 시각에는 이미 새 값이 들어 있습니다. 캐시가 비는 순간이 생기지 않으니 락도 대기도 필요 없습니다.

학계에서는 XFetch 알고리즘으로 알려져 있습니다(Vattani, Chierichetti, Lowenstein, "Optimal Probabilistic Cache Stampede Prevention", VLDB 2015). Instagram, Wikipedia 같은 초고트래픽 서비스의 사례와 함께 자주 언급되는 대표적인 기법입니다.

판단 공식

text
조기 갱신 조건:   now  -  delta x beta x ln(rand)   >=   expiry

  now     : 현재 시각
  expiry  : 이 값의 만료 시각
  delta   : 이 값을 원본에서 만드는 데 걸린 시간(초). 값과 함께 저장해 둔다
  beta    : 조기 갱신 강도. 기본 1.0. 클수록 더 일찍 갱신한다
  rand    : 0 초과 1 이하의 난수.  ln(rand) 는 0 이하이므로 왼쪽 항은 now 보다 커진다

  풀어 쓰면:   남은 시간(expiry - now) 을  "delta x beta x 난수 지수분포" 와 비교한다.
  갱신 확률:   P = exp( -남은시간 / (delta x beta) )

공식이 두 가지를 동시에 반영한다는 점이 핵심입니다. 만료가 가까울수록 확률이 오르고, 만드는 데 오래 걸리는 값(delta가 큰 값)일수록 더 일찍 갱신합니다. 200ms짜리 쿼리는 여유 있게 미리 만들어야 하지만, 2ms짜리 쿼리는 거의 만료 직전에 만들어도 되기 때문입니다.

남은 시간 (delta = 0.2초, beta = 1) 요청 1건이 갱신에 당첨될 확률
2.0초 0.005%
1.0초 0.67%
0.4초 13.5%
0.2초 36.8%
0.1초 60.7%
0초 (만료) 100%

아이폰 16 시나리오에 대입하기 (초당 10,000건, delta 0.2초)

  • 남은 시간 T 이후로 기대되는 당첨 횟수는 10,000 x 0.2 x exp(-T/0.2)입니다. 이 값이 1이 되는 T는 약 1.5초입니다. 즉 만료 약 1.5초 전에 첫 갱신이 일어납니다.
  • 첫 갱신이 끝나기까지 0.2초 사이에 추가로 당첨되는 요청은 평균 2건 안팎입니다.
  • 결과: 만료 순간 2,000건이던 중복 쿼리가 한 자릿수로 줄고, 사용자 대부분은 아무것도 기다리지 않습니다.

시간 순서 상태도

text
(TTL 60초, delta 0.2초, 초당 10,000건)

시간            캐시 상태             요청들의 동작                        DB
-----------------------------------------------------------------------------------------
T=0 ~ 57s       HIT (여유 많음)       전원 캐시 사용. 당첨 확률 사실상 0    0
T=58.5s         HIT (남은 1.5초)      요청 X 가 당첨 -> 원본 조회 시작       1
                                      나머지 9,999건은 계속 캐시 사용
T=58.5~58.7s    HIT (옛 값 유지)      당첨자 2명 정도 추가 발생             +2
T=58.7s         HIT (새 값, 만료 리셋) 요청 X 가 새 값 저장. 만료 = 118.7s   -
T=60.0s         HIT                   원래 만료 시각. 아무 일도 없다        0

결과: 만료 순간의 MISS 가 존재하지 않는다. DB 쿼리 약 3개, 사용자 대기 거의 0.

특징

구분 내용
장점 락이 없어 대기와 락 경쟁이 0. 추가 인프라 없음. 만료 순간의 미스 자체를 사전에 제거
단점 완전한 중복 제거가 아니다(수천 개가 한 자릿수로 줄 뿐). 당첨된 요청은 원본 조회 시간만큼 느려진다
강점 영역 트래픽이 꾸준히 많은 핫키. 요청이 많을수록 만료 전에 누군가 반드시 당첨된다
적합한 데이터 랭킹, 인기 상품, 집계, 추천처럼 재계산 비용이 크고 조회가 끊이지 않는 데이터

주의 사항

PER이 동작하지 않는 경우

  • 트래픽이 적은 키: 만료 직전 몇 초 동안 요청이 한 건도 없으면 당첨될 기회가 없어 그냥 만료됩니다. 이 경우는 Stampede 위험 자체가 낮으므로 괜찮지만, "PER을 적용했으니 미스가 없다"고 가정하면 안 됩니다.
  • 콜드 스타트: 값이 아예 없으면 PER이 할 일이 없습니다. 첫 적재는 Locking이나 SingleFlight로 보호해야 합니다.
  • ln(0) 오류: random.random()은 0.0을 반환할 수 있고 log(0)은 예외입니다. 1.0 - random.random()으로 0 초과 1 이하 범위를 만드십시오.
  • 서버 간 시계 차이: 만료 시각을 값에 저장하고 각 서버의 시계로 비교하므로, 서버 시계가 수 초씩 어긋나면 갱신 시점이 틀어집니다. NTP 동기화는 필수입니다.
  • beta를 크게 잡는 실수: beta를 키우면 더 일찍, 더 자주 갱신합니다. DB 부하가 늘어날 뿐 Stampede 방어 효과는 이미 1.0 근처에서 충분합니다. 1.0에서 시작해 측정 후 조정하십시오.

How to — 단계별 구현

① 캐시 값의 형태를 바꾼다. 값만 저장하지 않고 {"value": ..., "delta": 생성시간, "expiry": 만료시각}을 함께 저장합니다. 판단에 필요한 정보를 한 번의 GET으로 얻기 위해서입니다(TTL 명령을 따로 부르면 왕복이 1회 늘어납니다).

② 원본 조회 시간을 잰다. time.monotonic()으로 loader 실행 전후를 측정해 delta로 저장합니다. 벽시계(time.time())는 시각 보정 시 뒤로 갈 수 있으므로 소요 시간 측정에는 쓰지 않습니다.

③ 조회할 때마다 조기 갱신 여부를 판단한다. 공식이 참이면 loader를 실행하고, 거짓이면 캐시 값을 그대로 반환합니다.

④ 갱신 시 만료 시각을 새로 계산해 저장한다. Redis 물리 TTL도 같은 값으로 걸어 누수를 막습니다.

⑤ 미스 경로를 보호한다. 캐시가 아예 없을 때는 PER이 아니라 Locking(3.1) 또는 SingleFlight(3.3)가 처리하도록 연결합니다.

⑥ 조기 갱신 횟수를 계측한다. "조기 갱신 수 / 전체 조회 수"와 "만료 후 미스 수"를 지표로 남겨, beta가 적절한지 판단합니다. 만료 후 미스가 여전히 많다면 beta를 조금 올립니다.

python
import json
import math
import random
import time

BETA = 1.0


async def get_with_per(key: str, loader, ttl: int, beta: float = BETA):
    raw = await r.get(key)
    if raw is not None:
        entry = json.loads(raw)
        # 1.0 - random() 은 (0, 1] 범위다. random() 이 0.0 이면 log(0) 예외가 나기 때문이다.
        gap = -entry["delta"] * beta * math.log(1.0 - random.random())
        if time.time() + gap < entry["expiry"]:
            return entry["value"]          # 아직 여유 있음 - 대부분 여기서 끝난다
        # 조기 갱신에 당첨되었다 - 아래로 내려가 새 값을 만든다

    # 미스이거나 조기 갱신 당첨.
    # 실무에서는 미스(raw is None) 경로를 get_with_lock 등으로 감싸 콜드 스타트를 보호한다.
    started = time.monotonic()
    value = await loader()
    delta = time.monotonic() - started

    entry = {"value": value, "delta": delta, "expiry": time.time() + ttl}
    await r.set(key, json.dumps(entry), ex=ttl)
    return value

당첨자가 동시에 여러 명 나오는 것이 싫다면

조기 갱신에 당첨된 요청이 짧은 락(SET refresh:key 1 NX EX 2)을 잡은 경우에만 갱신하고, 못 잡으면 그냥 캐시 값을 반환하게 하면 중복 갱신이 1개로 줄어듭니다. 당첨자는 락을 못 잡아도 기다리지 않고 옛 값을 쓰면 되므로 PER의 "대기 0" 장점은 유지됩니다.

3.3. SingleFlight(Request Coalescing) — 동일 요청을 하나의 실행으로 합치기

한 줄 요약

같은 프로세스 안에서 같은 키를 동시에 요청하면 하나의 실행 그룹(Flight)으로 합쳐 한 번만 실행하고 결과를 나눠 받는다. 핵심 가치: Efficiency — 인프라 없이 코드만으로 얻는 절감.

개념과 동작

같은 행선지로 가는 승객 1,000명에게 비행기를 1,000대 띄우지 않고 한 대(Flight)에 태우는 것입니다. 어떤 키에 대한 로딩이 이미 진행 중이면, 뒤따라온 요청은 새로 로딩하지 않고 진행 중인 작업의 결과를 기다립니다. 작업이 끝나면 모든 대기자가 같은 결과를 받고, 그 Flight는 사라집니다.

이름은 Go 언어의 표준 확장 패키지 golang.org/x/sync/singleflight에서 왔습니다. 락과 달리 Redis가 필요 없고, 대기자가 폴링하지 않으며, 네트워크 왕복도 없습니다. 대신 같은 프로세스 안에서만 동작합니다.

시간 순서 상태도

text
(WAS 1대, 같은 키 요청 500개가 동시에 도착)

시간      요청 #1                         요청 #2 ~ #500                  inflight 표
-------------------------------------------------------------------------------------------
T0        캐시 MISS                       캐시 MISS                       {}
T0        inflight 에 없음 -> 작업 생성    -                               {key: 작업1}
T0        작업1 await (DB 조회 시작)
T0+1ms                                    inflight 에 작업1 있음          {key: 작업1}
                                          -> 새로 만들지 않고 작업1 await
  ...     (DB 쿼리는 여전히 1개)
T0+200ms  작업1 완료 -> 결과 R            결과 R 을 동시에 받는다          {} (완료 콜백이 제거)
T0+201ms  응답                            응답
T0+300ms  (다음 MISS 가 오면 새 Flight 가 시작된다)

결과: 이 서버의 DB 쿼리 500개 -> 1개. 대기자는 폴링하지 않고 결과를 "통지"받는다.
text
[서버가 여러 대일 때 - SingleFlight 의 한계]

  WAS #1 (요청 500) --Flight 1개--+
  WAS #2 (요청 500) --Flight 1개--+-->  DB 쿼리 10개   (서버 대수만큼)
    ...                           |
  WAS #10(요청 500) --Flight 1개--+

  + Redis 락(3.1)을 Flight 안에서 쓰면  -->  DB 쿼리 1개
  + 락 경쟁에 참여하는 요청도 5,000개가 아니라 10개로 줄어 Redis 부담도 준다

특징

구분 내용
장점 구현이 짧고 추가 인프라가 없다. 대기자가 폴링하지 않아 CPU와 Redis 명령이 낭비되지 않는다
단점 프로세스(워커) 단위로만 합쳐진다. 서버 N대 x 워커 M개면 최대 N x M개 쿼리가 나간다
강점 영역 다른 모든 패턴의 1층. Locking, SWR, PER 앞에 두면 그 패턴에 도달하는 요청 자체가 줄어든다
적합한 데이터 사실상 모든 읽기. 부작용이 없는 조회라면 어디든 적용할 수 있다

주의 사항

SingleFlight에서 사고가 나는 여섯 지점

  • 키에 조회 조건을 빠뜨린다 — 보안 사고: /me/orders를 "orders" 키로 합치면 다른 사용자의 주문이 응답됩니다. 키에는 결과를 바꾸는 모든 입력(사용자 ID, 권한, 언어, 페이지)을 넣어야 합니다.
  • 한 대기자의 취소가 모두를 취소시킨다: 클라이언트 연결이 끊겨 첫 요청이 취소되면, 공유 작업까지 취소되어 나머지 499명이 모두 실패할 수 있습니다. 파이썬에서는 asyncio.shield로 공유 작업을 보호합니다.
  • 에러도 공유된다: 작업이 실패하면 대기자 전원이 같은 에러를 받습니다. 이것은 의도된 동작입니다(실패한 DB에 500번 재시도하지 않음). 다만 Flight가 끝나면 즉시 표에서 지워야 다음 요청이 재시도할 수 있습니다.
  • 느린 리더가 모두를 느리게 만든다: 첫 작업이 30초 걸리면 전원이 30초 기다립니다. 호출자 쪽에 asyncio.wait_for로 대기 상한을 둡니다.
  • 결과 객체를 공유한다: 500명이 같은 dict 객체를 받습니다. 한 요청이 그 객체를 수정하면 다른 요청의 응답이 오염됩니다. 결과는 읽기 전용으로 다루거나 복사해서 씁니다.
  • 쓰기 작업에 쓴다: "결제 요청"을 합치면 두 사람의 결제가 한 번만 실행됩니다. SingleFlight는 부작용 없는 읽기 전용입니다.

How to — 단계별 구현

① 진행 중인 작업 표를 만든다. 키 → 작업(asyncio.Task) 사전입니다. 워커 프로세스마다 하나씩 존재합니다.

② 표에 없으면 작업을 만들어 등록하고, 있으면 그 작업을 기다린다. asyncio는 단일 스레드이므로 "확인 후 등록" 사이에 await가 없으면 경쟁 조건이 생기지 않습니다.

③ 작업이 끝나면 표에서 지운다. 완료 콜백에서 지우되, 표에 있는 작업이 자기 자신일 때만 지웁니다. 그 사이 새 Flight가 등록되었을 수 있기 때문입니다.

④ 대기는 asyncio.shield로 감싼다. 대기자 한 명이 취소되어도 공유 작업은 계속 진행됩니다.

⑤ 캐시 조회 경로에 끼워 넣는다. "캐시 미스 이후의 로딩 전체"를 Flight로 감쌉니다. 캐시 히트는 이미 충분히 빠르므로 감쌀 필요가 없습니다.

⑥ 검증한다. 동시 요청 1,000개에 loader 호출이 1회인지, 에러가 전원에게 전달되는지, 대기자 하나를 취소해도 나머지가 결과를 받는지 테스트로 확인합니다(아래 코드는 이 세 가지를 검증한 구현입니다).

python
import asyncio


class SingleFlight:
    """같은 key 에 대한 동시 호출을 하나의 실행으로 합친다. 프로세스(워커) 단위로 동작한다."""

    def __init__(self):
        self._calls: dict[str, asyncio.Task] = {}

    async def do(self, key: str, fn):
        task = self._calls.get(key)
        if task is None:
            # 첫 요청: Flight 를 만든다. 여기와 등록 사이에 await 가 없어 경쟁이 없다.
            task = asyncio.create_task(fn())
            self._calls[key] = task

            def _cleanup(t: asyncio.Task, key: str = key) -> None:
                # 이미 새 Flight 가 등록되었다면 그것은 지우지 않는다
                if self._calls.get(key) is t:
                    del self._calls[key]

            task.add_done_callback(_cleanup)

        # shield: 이 호출자가 취소되어도 공유 작업은 계속된다
        return await asyncio.shield(task)


sf = SingleFlight()


async def get_product_detail(pid: int):
    key = f"product:{pid}:detail"     # 결과를 바꾸는 모든 입력을 키에 넣는다

    cached = await r.get(key)
    if cached is not None:
        return json.loads(cached)

    # 미스 이후 로딩 전체를 Flight 로 감싼다.
    # 서버 간 중복까지 없애려면 fn 안에서 get_with_lock(3.1)을 호출한다.
    return await asyncio.wait_for(
        sf.do(key, lambda: get_with_lock(key, lambda: db.get_product(pid), ttl=60)),
        timeout=3.0,                  # 느린 리더에게 전원이 묶이지 않도록
    )

서버 대수로 계산해 보는 절감 효과

  • WAS 10대, 대당 워커 4개, 만료 순간 동시 요청 2,000건일 때
  • 방어 없음: DB 쿼리 2,000개
  • SingleFlight만: DB 쿼리 최대 40개(10 x 4) — 98% 감소, 인프라 비용 0
  • SingleFlight + Locking: DB 쿼리 1개, Redis 락 경쟁 참여자도 2,000명이 아니라 40명

3.4. SWR(Stale-While-Revalidate) — 낡은 값을 먼저 주고 뒤에서 갱신

한 줄 요약

만료된 데이터라도 일단 즉시 반환(Stale) 하고, 갱신은 뒤에서 몰래 실행한다. 핵심 가치: UX — 어떤 사용자도 DB를 기다리지 않는다.

개념과 동작

아침에 신문을 달라는 사람에게 "오늘 신문이 인쇄될 때까지 기다리세요"라고 하지 않고, 어제 신문을 먼저 건네면서 오늘 신문 배달을 요청하는 방식입니다. 핵심은 만료를 두 단계로 나누는 것입니다.

  • Soft 만료(신선 기간): 이 시각이 지나면 값이 "낡았다"고 판단합니다. 하지만 값을 지우지는 않습니다.
  • Hard 만료(최대 허용 낡음): Redis의 실제 TTL입니다. 이 시각이 지나면 값이 정말로 사라집니다.
text
값이 저장된 시점부터의 시간 흐름

 0s                     60s (soft)                                 600s (hard, Redis TTL)
 |---------- FRESH ------|------------------- STALE -------------------|---- 없음 ----
 |  그대로 반환          |  즉시 반환 + 백그라운드 갱신 1회 요청          |  동기 로딩
 |  (DB 0)               |  (사용자 대기 0, DB 1)                         |  (Locking 으로 보호)

 이 개념은 HTTP 표준에도 있다:  Cache-Control: max-age=60, stale-while-revalidate=540
 CDN 과 브라우저가 이미 널리 쓰는 검증된 패턴을 애플리케이션 캐시에 옮긴 것이다.

시간 순서 상태도

text
(soft 60초, hard 600초, 갱신 락 10초, 초당 10,000건)

시간        캐시 상태              요청들의 동작                                   DB
------------------------------------------------------------------------------------------------
T=0~59.9s   FRESH                  전원 그대로 반환                                0
T=60.0s     STALE (값은 남아 있음) 요청 A: 낡은 값 즉시 반환                       -
                                   + SET refresh:key NX EX 10 -> OK
                                   + 백그라운드 갱신 작업 시작                     1
T=60.0~0.2s STALE                  나머지 2,000건: 낡은 값 즉시 반환               0
                                   SET refresh:key NX -> nil (갱신 중이므로 생략)
T=60.2s     FRESH (새 값)          백그라운드 작업이 새 값 저장                    -
                                   soft = 120.2s, hard TTL = 600초로 리셋
T=60.2s~    FRESH                  전원 새 값 반환                                 0

[갱신이 실패하면]
T=60.2s     STALE 유지             예외 기록. 갱신 락은 지우지 않는다              -
T=70.0s     STALE                  갱신 락 만료 -> 다음 요청이 다시 갱신 시도      1
                                   (락 TTL 10초가 자연스러운 재시도 간격이 된다)

결과: 사용자 대기 0. DB 쿼리 1개. 대가: 약 0.2초 동안 최대 60초 낡은 값이 응답되었다.

특징

구분 내용
장점 응답 시간이 항상 캐시 수준으로 일정하다. DB가 느려지거나 일시 장애여도 사용자는 낡은 값을 받는다(가용성 향상)
단점 낡은 값이 응답된다. 물리 TTL이 길어 메모리를 더 쓴다
강점 영역 UX가 최우선이고 약간의 지연이 허용되는 데이터. DB 장애 시 완충 역할도 한다
적합한 데이터 랭킹, 피드, 추천, 인기 목록, 카테고리, 배너, 통계
금지 데이터 재고 차감, 잔액, 결제 금액, 좌석 예약, 권한 회수

주의 사항

SWR을 잘못 쓰면 생기는 일

  • Hard 만료를 두지 않는다: 갱신이 계속 실패하면 몇 시간, 며칠 전 값이 조용히 응답됩니다. 반드시 Redis TTL로 "최대 허용 낡음"의 상한을 두십시오.
  • 콜드 스타트는 막지 못한다: 값이 아예 없으면 돌려줄 낡은 값도 없습니다. 이 경로는 동기 로딩이며 Locking(3.1)과 SingleFlight(3.3)로 보호해야 합니다.
  • 백그라운드 작업이 사라진다: 파이썬 asyncio.create_task의 반환값을 어디에도 보관하지 않으면 작업이 실행 도중 가비지 컬렉션될 수 있습니다. 작업을 집합(set)에 보관하고 완료 시 제거합니다.
  • 갱신 락 없이 백그라운드 작업을 띄운다: 낡은 값을 읽은 2,000건이 모두 백그라운드 갱신을 시작하면, 사용자만 안 기다릴 뿐 DB에는 그대로 Stampede입니다. SET refresh:key NX EX로 한 명만 갱신합니다.
  • 갱신 실패를 모른다: 사용자 화면은 멀쩡하므로 아무도 알아채지 못합니다. 갱신 실패 수와 "낡은 값 응답 비율"을 지표로 남기고 경보를 거십시오.

How to — 단계별 구현

① 두 개의 시간을 정한다. Soft TTL은 "이 정도면 신선하다"(스테일 예산), Hard TTL은 "이보다 낡은 값은 절대 보여주지 않는다"(최대 허용 낡음)입니다. 예: soft 60초, hard 600초.

② 값에 soft 만료 시각을 넣어 저장한다. {"value": ..., "soft_expiry": 시각}을 저장하고, Redis TTL은 hard로 겁니다.

③ 조회 시 세 갈래로 나눈다. 값 없음 → 동기 로딩 / soft 이전 → 그대로 반환 / soft 이후 → 즉시 반환 + 갱신 요청.

④ 갱신은 락을 잡은 한 명만 시작한다. SET refresh:{key} 1 NX EX 10이 성공한 요청만 백그라운드 작업을 띄웁니다.

⑤ 갱신 락은 지우지 않고 만료되게 둔다. 성공하면 값이 다시 신선해지므로 락이 남아 있어도 문제없습니다. 실패하면 락 TTL(10초)이 자연스러운 재시도 간격이 되어, 장애 중인 DB를 매 요청마다 두드리지 않습니다. 남의 락을 지울 위험도 사라집니다.

⑥ 백그라운드 작업의 참조를 보관하고, 실패를 기록한다.

⑦ 관측 필드를 남긴다. 응답이 낡은 값이었는지(stale=True)를 메트릭이나 응답 헤더로 남겨 "낡은 값 응답 비율"을 측정합니다.

python
import asyncio
import json
import logging
import time

log = logging.getLogger(__name__)

SOFT_TTL = 60             # 신선 기간(초)
HARD_TTL = 600            # 최대 허용 낡음 = Redis 물리 TTL(초)
REFRESH_LOCK_TTL = 10     # 갱신 락 = 실패 시 재시도 간격(초)

_background: set[asyncio.Task] = set()   # 백그라운드 작업이 GC 되지 않도록 참조 보관


async def get_with_swr(key: str, loader):
    raw = await r.get(key)

    # (1) 값이 아예 없다 - 돌려줄 낡은 값도 없으므로 동기 로딩 (콜드 스타트)
    if raw is None:
        return await load_and_store(key, loader)

    entry = json.loads(raw)

    # (2) 신선하다 - 그대로 반환
    if time.time() < entry["soft_expiry"]:
        return entry["value"]

    # (3) 낡았다 - 즉시 반환하고, 한 명만 백그라운드 갱신을 시작한다
    if await r.set(f"refresh:{key}", "1", nx=True, ex=REFRESH_LOCK_TTL):
        task = asyncio.create_task(_revalidate(key, loader))
        _background.add(task)
        task.add_done_callback(_background.discard)
    return entry["value"]


async def load_and_store(key: str, loader):
    value = await loader()
    entry = {"value": value, "soft_expiry": time.time() + SOFT_TTL}
    await r.set(key, json.dumps(entry), ex=HARD_TTL)
    return value


async def _revalidate(key: str, loader) -> None:
    try:
        await load_and_store(key, loader)
    except Exception:
        # 실패해도 낡은 값은 HARD_TTL 까지 계속 응답된다.
        # 갱신 락은 지우지 않는다 - 락 TTL 이 지나야 다음 재시도가 일어난다.
        log.exception("SWR 갱신 실패: %s", key)

SWR과 PER은 무엇이 다른가

둘 다 "캐시가 비는 순간을 없앤다"는 목표는 같습니다. PER은 만료 전에 요청 스레드가 직접 갱신하므로 낡은 값이 나가지 않지만 당첨자는 기다립니다. SWR은 만료 후에 백그라운드로 갱신하므로 아무도 기다리지 않지만 잠깐 낡은 값이 나갑니다. "낡은 값을 1초라도 보여주면 안 되는가?"라는 질문 하나로 둘 중 무엇을 쓸지 결정됩니다.

4. 상황별 추천 전략

패턴 선택은 기술 취향이 아니라 "틀린 값과 느린 응답 중 무엇이 더 비싼가" 로 결정됩니다. 아래 표는 대표적인 상황에서 그 답과 그에 맞는 조합입니다.

상황 정합성 요구 추천 전략 (층 순서) 핵심 가치 금지
금융 · 결제 · 재고 차감 필수 (틀리면 금전 사고) 캐시하지 않는 것이 1순위. 조회 캐시가 필요하면 SingleFlight + Locking + 짧은 TTL, 쓰기 시 즉시 삭제. 최종 판단(차감 · 결제)은 반드시 DB 트랜잭션에서 Consistency SWR, PER 없이 긴 TTL. 낡은 값 응답 일체
사전예약 · 티켓팅 오픈 (아이폰 16 시나리오) 표시값은 준실시간, 확정은 필수 오픈 전 워밍업 + 상품 정보는 SingleFlight + PER, 잔여 수량 표시는 SingleFlight + Locking(짧은 TTL). 대기열과 DB 동시 접근 상한 필수 Availability + Consistency 오픈 직후 콜드 캐시로 트래픽 수용. 수량 표시에 SWR
커머스 상품 상세 · 게시글 준실시간 (수 초) SingleFlight + Locking + TTL 지터. 핫 상품만 PER 추가 Consistency + Efficiency 가격을 SWR로 수 분간 낡게 응답
랭킹 · 피드 · 추천 · 인기 목록 최종적 정합성 (수십 초 ~ 수 분) SingleFlight + SWR, 콜드 경로만 Locking. 필요 시 주기적 사전 갱신 UX 핫키에 Locking만 적용해 만료마다 전원 대기
고비용 집계 · 대시보드 리포트 느슨함 (분 ~ 시간) PER(delta가 커서 효과 극대화) 또는 스케줄러 사전 계산 + SWR High Traffic + 비용 절감 사용자 요청 경로에서 동기 재계산
설정 · 카테고리 · 코드 테이블 느슨함, 변경 드묾 로컬 캐시 + SingleFlight + 긴 TTL, 변경 시 무효화 방송(유닛 7-1) Efficiency 매 요청마다 Redis 왕복
Redis 재기동 · 장애 복구 - 워밍업 후 트래픽 투입 + 점진적 투입 + Locking + DB 동시 접근 상한 Availability 빈 캐시에 전체 트래픽 즉시 투입

4-1. 선택 순서도

text
시작: 이 키는 만료 한 번으로 장애가 날 수 있는가?  (핫키 QPS x 쿼리 p99  >  커넥션 풀)
 |   NO  -> TTL 지터 + SingleFlight 로 충분. 종료
 |   YES
 v
[모든 경우 1층] SingleFlight 적용
 |
 v
낡은 값을 잠깐이라도 보여주면 안 되는가?
 |   YES (가격, 재고, 좌석) ------------------------------+
 |                                                        v
 |                                         트래픽이 꾸준히 매우 많은가?
 |                                           YES -> PER + (미스 경로) Locking
 |                                           NO  -> Locking
 |   NO  (랭킹, 피드, 추천)
 v
SWR + (콜드 경로) Locking
 |
 v
[모든 경우 마지막 층] DB 동시 접근 상한 + 워밍업 절차

4-2. 조합 구현 예 — SingleFlight + SWR + Locking

랭킹처럼 UX가 중요한 핫키에 세 층을 모두 적용한 형태입니다. 앞에서 만든 부품을 조립만 합니다.

python
async def cold_load(key: str, loader):
    """캐시가 아예 없을 때만 호출된다. 서버 간 중복을 락으로 1개로 줄인다(3.1과 같은 절차)."""
    lock_key, token = f"lock:{key}", uuid.uuid4().hex
    deadline = asyncio.get_running_loop().time() + MAX_WAIT
    while True:
        if await r.set(lock_key, token, nx=True, ex=LOCK_TTL):
            try:
                raw = await r.get(key)                     # 재확인
                if raw is not None:
                    return json.loads(raw)["value"]
                return await load_and_store(key, loader)   # SWR 형식으로 저장
            finally:
                await release_lock(keys=[lock_key], args=[token])
        await asyncio.sleep(0.05 + random.uniform(0, 0.05))
        raw = await r.get(key)
        if raw is not None:
            return json.loads(raw)["value"]
        if asyncio.get_running_loop().time() >= deadline:
            raise CacheBusyError(key)


async def get_ranking(board: str):
    key = f"ranking:{board}"

    async def flight():
        raw = await r.get(key)
        if raw is None:
            return await cold_load(key, lambda: db.load_ranking(board))   # 3층: Locking
        return await get_with_swr(key, lambda: db.load_ranking(board))    # 2층: SWR

    return await asyncio.wait_for(sf.do(key, flight), timeout=3.0)        # 1층: SingleFlight

5. 마지막 방어선 — 부하 차단과 워밍업

네 패턴은 모두 "정상적으로 동작한다면"이라는 전제가 있습니다. 락 서버가 느려지거나, 새 캐시 키에 패턴 적용을 빠뜨리거나, 콜드 스타트가 겹치면 뚫립니다. 그때 DB가 죽지 않게 하는 마지막 선이 필요합니다.

5-1. DB 동시 접근 상한

"일부 요청이 빠르게 실패하는 것"이 "전체 서비스가 느리게 죽는 것"보다 낫습니다. 프로세스마다 DB에 동시에 보낼 수 있는 쿼리 수에 상한을 두고, 자리가 나지 않으면 오래 기다리지 않고 포기합니다.

python
import asyncio

DB_SEMAPHORE = asyncio.Semaphore(20)     # 이 프로세스에서 동시에 20개까지만


async def guarded_query(loader):
    try:
        # 1초 안에 자리가 나지 않으면 포기한다 (전체가 멈추는 것보다 낫다)
        await asyncio.wait_for(DB_SEMAPHORE.acquire(), timeout=1.0)
    except asyncio.TimeoutError:
        raise CacheBusyError("일시적으로 요청이 많습니다")
    try:
        return await loader()
    finally:
        DB_SEMAPHORE.release()
  • 상한 계산: DB 커넥션 풀 전체 / (서버 수 x 워커 수)보다 작게 잡습니다. 풀 100개를 WAS 10대 x 워커 4개가 나눠 쓰면 워커당 2~2.5개입니다.
  • 쿼리 타임아웃: DB 드라이버와 DB 양쪽에 문장 타임아웃을 겁니다. 느린 쿼리가 커넥션을 붙잡고 있지 못하게 합니다.
  • 서킷 브레이커: 실패율이 임계값을 넘으면 일정 시간 DB 호출 자체를 차단하고, SWR의 낡은 값이나 기본값으로 응답합니다.
  • 재시도 제한: 게이트웨이와 클라이언트의 자동 재시도는 지수 백오프와 횟수 상한을 둡니다. 재시도가 Stampede를 두세 배로 키웁니다.

5-2. 콜드 스타트 — 워밍업 후 트래픽 투입

python
import json
import random

from fastapi import FastAPI, Response

app = FastAPI()
# r 은 3.1 절에서 만든 Redis 클라이언트, db 는 이 문서 전체에서 쓰는 원본 저장소 객체다.


def ttl_with_jitter(base: int, ratio: float = 0.2) -> int:
    """TTL 을 base 의 +/- ratio 범위로 흩뿌린다 (유닛 7-1 의 함수와 같다)."""
    delta = int(base * ratio)
    return base + random.randint(-delta, delta)


async def warmup() -> None:
    """트래픽을 받기 전에 핵심 캐시를 채운다. 준비 상태 확인(readiness) 조건에 포함한다."""
    top = await db.get_top_products(limit=500)
    pipe = r.pipeline()
    for row in top:
        pipe.set(f"product:{row['id']}:detail", json.dumps(row), ex=ttl_with_jitter(600))
    await pipe.execute()
    app.state.warmed = True


@app.get("/health/ready")
async def ready():
    # 워밍업이 끝나기 전에는 로드밸런서가 트래픽을 보내지 않는다
    if not getattr(app.state, "warmed", False):
        return Response(status_code=503)
    return {"ok": True}

운영 절차로 고정하십시오

  • Redis를 재기동했거나 캐시를 비웠다면 워밍업 완료 전까지 로드밸런서에 붙이지 않습니다.
  • 워밍업 완료 여부를 /health/ready에 반영해 오케스트레이터가 판단하게 합니다.
  • 콜드 상태로 트래픽을 받아야만 한다면 점진적 투입(10% → 50% → 100%) 으로 완충합니다.
  • 사전예약 · 티켓팅처럼 오픈 시각이 정해진 이벤트는 오픈 수 분 전에 워밍업하고, TTL이 오픈 직후에 만료되지 않도록 계산합니다.

6. 🧪 [실습] Stampede 재현과 방어 효과 측정

6-1. SingleFlight 효과 측정 (Redis 없이 실행 가능)

① 스크립트를 준비합니다.

python
# singleflight_demo.py
import asyncio


class SingleFlight:
    def __init__(self):
        self._calls = {}

    async def do(self, key, fn):
        task = self._calls.get(key)
        if task is None:
            task = asyncio.create_task(fn())
            self._calls[key] = task

            def _cleanup(t, key=key):
                if self._calls.get(key) is t:
                    del self._calls[key]

            task.add_done_callback(_cleanup)
        return await asyncio.shield(task)


db_calls = 0


async def slow_db_query():
    """무거운 조회를 흉내 낸다 (200ms)."""
    global db_calls
    db_calls += 1
    await asyncio.sleep(0.2)
    return {"product": "iphone16", "stock": 120}


async def run(label, get, n=1000):
    global db_calls
    db_calls = 0
    loop = asyncio.get_running_loop()
    started = loop.time()
    await asyncio.gather(*[get() for _ in range(n)])
    print(f"{label:<14} 요청 {n}건 | DB 호출 {db_calls:>4}회 | 소요 {loop.time() - started:.2f}s")


async def main():
    sf = SingleFlight()
    await run("방어 없음", slow_db_query)
    await run("SingleFlight", lambda: sf.do("product:iphone16", slow_db_query))


asyncio.run(main())

② 실행 결과를 확인합니다.

bash
$ python singleflight_demo.py
방어 없음          요청 1000건 | DB 호출 1000회 | 소요 0.22s
SingleFlight   요청 1000건 | DB 호출    1회 | 소요 0.22s

6-2. 분산 락 효과 측정 (로컬 Redis 필요)

WSL2의 Redis에 Windows에서 접속한다면

WSL2를 mirrored 네트워킹으로 쓰면서 Windows 쪽에서 실습 코드를 실행할 때는 호스트를 localhost 대신 127.0.0.1로 지정하십시오. localhost는 IPv6 주소 ::1로 먼저 연결을 시도하는데, 이 경로가 거부되지 않고 응답 없이 매달려 연결 제한 시간을 통째로 소모할 수 있습니다. 또한 WSL 배포판이 꺼져 있으면 연결이 거부되므로 wsl -l -v로 Running 상태인지 먼저 확인합니다.

① 스크립트를 준비합니다. 스레드 500개가 동시에 만료된 캐시를 조회하는 상황을 만듭니다.

python
# stampede_demo.py
import json
import threading
import time
import uuid

import redis

r = redis.Redis(host="localhost", port=6379, decode_responses=True)

db_calls = 0
counter_lock = threading.Lock()
KEY = "product:iphone16:detail"


def slow_db_query():
    """무거운 조회를 흉내 낸다 (200ms)."""
    global db_calls
    with counter_lock:
        db_calls += 1
    time.sleep(0.2)
    return {"product": "iphone16", "at": time.time()}


# --- 방어 없음 ---
def naive_get():
    cached = r.get(KEY)
    if cached:
        return json.loads(cached)
    value = slow_db_query()
    r.set(KEY, json.dumps(value), ex=60)
    return value


# --- 분산 락 ---
release = r.register_script("""
if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end
return 0
""")


def locked_get():
    cached = r.get(KEY)
    if cached:
        return json.loads(cached)
    token = uuid.uuid4().hex
    deadline = time.monotonic() + 2.0
    while time.monotonic() < deadline:
        if r.set(f"lock:{KEY}", token, nx=True, ex=5):
            try:
                cached = r.get(KEY)                    # 재확인
                if cached:
                    return json.loads(cached)
                value = slow_db_query()
                r.set(KEY, json.dumps(value), ex=60)
                return value
            finally:
                release(keys=[f"lock:{KEY}"], args=[token])
        time.sleep(0.05)
        cached = r.get(KEY)
        if cached:
            return json.loads(cached)
    raise TimeoutError("cache busy")                    # DB 로 직행하지 않는다


def run(fn, label, n=500):
    global db_calls
    db_calls = 0
    r.delete(KEY, f"lock:{KEY}")                        # 캐시 만료 상황 재현
    started = time.perf_counter()
    threads = [threading.Thread(target=fn) for _ in range(n)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    print(f"{label:<10} 요청 {n}건 | DB 호출 {db_calls}회 | 소요 {time.perf_counter() - started:.2f}s")


if __name__ == "__main__":
    run(naive_get, "방어 없음")
    run(locked_get, "분산 락")

② 실행 결과를 확인합니다.

bash
$ python stampede_demo.py
방어 없음      요청 500건 | DB 호출 500회 | 소요 0.39s
분산 락       요청 500건 | DB 호출 1회 | 소요 0.34s

확인 결과

  • 결과: 방어가 없으면 요청 대부분이 각자 DB를 호출합니다. 분산 락과 SingleFlight는 호출을 1회로 줄입니다. 방어 없음의 정확한 호출 수는 스레드 스케줄링과 실행 환경에 따라 달라집니다.
  • 의미: 줄어든 호출은 모두 결과가 완전히 같은 낭비였습니다. 실제 서비스에서 이 낭비는 커넥션 풀 고갈과 연쇄 장애로 이어집니다.
  • 확장 과제 1: slow_db_query의 지연을 2초로 늘리고 락 TTL(5초)과 대기 상한(2초)을 바꿔 보십시오. 대기 상한을 넘은 요청이 DB로 가지 않고 실패하는 것을 확인합니다.
  • 확장 과제 2: 3.4의 get_with_swr로 같은 실험을 하되, 미리 값을 넣고 soft 만료만 지나게 만든 뒤 측정하십시오. 응답 시간이 0.2초가 아니라 수 밀리초로 떨어지는 것을 확인합니다.

7. 운영 지표와 점검 목록

지표 구하는 법 의미와 경보 기준(예시)
원본 DB QPS 급증 DB 모니터링 평소 대비 3배 이상 급증 — Stampede 진행 중
캐시 적중률 급락 INFO stats의 keyspace_hits, keyspace_misses 평소 대비 10%p 하락이 수 분 지속 (유닛 6-7)
락 획득 실패율 · 대기 시간 애플리케이션 메트릭 대기 p99가 원본 쿼리 p99를 크게 넘으면 락 소유자가 느리거나 죽고 있다
CacheBusyError 발생 수 애플리케이션 메트릭 0 초과가 지속 — 대기 상한 초과, 사용자에게 503이 나가고 있다
SingleFlight 공유 비율 (대기자 수) / (전체 미스 수) 높을수록 중복 제거 효과가 크다. 갑자기 오르면 미스 폭주 신호
PER 조기 갱신 수 당첨 횟수 카운터 0이면 트래픽이 부족하거나 beta가 너무 작다
SWR 낡은 값 응답 비율 · 갱신 실패 수 stale=True 응답 카운터, 갱신 예외 카운터 갱신 실패가 연속되면 사용자는 모르는 채 낡은 값을 받고 있다
번호 점검 항목
1 핫키 목록을 알고 있는가 (redis-cli --hotkeys, 접근 로그). 각 핫키의 "QPS x 쿼리 p99"를 계산했는가
2 모든 캐시 TTL에 지터가 적용되어 있는가
3 캐시 미스 이후의 로딩이 SingleFlight로 감싸져 있는가. 키에 결과를 바꾸는 모든 입력이 들어 있는가
4 핫키마다 Locking / PER / SWR 중 무엇을 쓸지 정합성 요구에 따라 결정되어 있는가
5 락에 TTL과 토큰 검증 해제, 대기 상한이 있고, 상한 초과 시 DB로 직행하지 않는가
6 SWR에 hard 만료가 있고, 갱신 실패가 지표로 보이는가
7 DB 동시 접근 상한, 쿼리 타임아웃, 재시도 제한이 설정되어 있는가
8 Redis 재기동과 이벤트 오픈에 대한 워밍업 절차가 문서화 · 자동화되어 있는가
9 캐시 계층이 완전히 죽어도 서비스가 (느리게라도) 동작하는가

🚩 정리 — 패턴 선택표

패턴 한 줄 정의 언제 쓰는가 반드시 챙길 것
Locking 한 명만 DB에, 나머지는 대기 낡은 값이 금지된 데이터, 콜드 스타트 락 TTL, 토큰 해제, 재확인, 대기 상한, DB 직행 금지
PER 만료 전에 확률적으로 미리 갱신 트래픽이 꾸준한 고비용 핫키 1 - random(), delta 측정, 미스 경로 보호
SingleFlight 같은 요청을 하나의 실행으로 합침 모든 읽기의 1층 키에 모든 입력 포함, shield, 대기 타임아웃, 읽기 전용
SWR 낡은 값을 즉시 주고 뒤에서 갱신 UX 우선, 약간의 지연 허용 데이터 hard 만료, 갱신 락, 작업 참조 보관, 실패 경보
부하 차단 · 워밍업 모든 방어가 뚫렸을 때의 마지막 선 항상 동시 접근 상한, 준비 상태 확인, 점진적 투입

한 줄 요약

Cache Stampede의 원인은 트래픽의 양이 아니라 캐시가 비는 짧은 빈틈입니다. 방어는 두 방향뿐입니다. 빈틈 안의 중복을 하나로 줄이거나(Locking, SingleFlight), 빈틈 자체를 없애는 것(PER, SWR)입니다. 하나를 고르지 말고 SingleFlight를 1층에 깔고, 정합성 요구에 따라 위층을 고르고, 맨 아래에 부하 차단을 두십시오. 다음 유닛(7-3)에서는 이 중 Locking을 운영 수준으로 완성합니다.


7-3. 스탬피드 방어 #1 — 실전 분산 락 완성하기

섹션 7. [Appendix] 보너스 트랙 · 유닛 47 / 48 · 보충 학습 자료

이 글에서 완성할 락

  • 캐시 미스 한 번에 원본 조회를 한 요청에게만 허용하는 락의 전체 흐름을 순서대로 설명할 수 있습니다.
  • 코드를 쓰기 전에 redis-cli로 락의 동작을 직접 확인하고, 검수 체크리스트로 판정할 수 있습니다.
  • 락의 취약점(데드락, 남의 락 해제, 작업보다 짧은 TTL, 무한 대기 등)과 각각의 방어 대책을 코드로 반영합니다.
  • Double-Check Locking으로 순차적 스탬피드(Sequential Stampede)를 막고, 그 장단점을 판단합니다.
  • 락을 못 잡은 수천 개의 요청(Loser)을 위한 두 가지 생존 전략, Polling & Backoff와 Pub/Sub(Event-Driven)의 이론 · 동작 · Python 구현을 익힙니다.
  • 완성한 락을 Python 모듈로 만들고 FastAPI에서 사용합니다.

이 글을 읽는 방법

1장과 2장에서 무엇을 지키는지와 전체 흐름을 잡습니다. 3장은 코드 없이 redis-cli로 사전 테스트를 하고, 4장은 취약점과 방어 대책, 5장은 Double-Check Locking입니다. 6장이 고급 전략(Loser 생존 전략)이며, 7장과 8장에서 지금까지의 조각을 모듈 하나와 FastAPI 앱으로 조립합니다. 9장부터는 redis-py 내장 Lock 비교, 실습, 운영 점검입니다. 유닛 7-2의 3.1절(Locking 최소 구현)을 먼저 읽었다고 가정합니다.

이 글의 사실 근거

명령과 동작은 Redis 공식 문서(SET, PUBLISH, Pub/Sub, Distributed Locks with Redis), redis-py 6.4.0 소스, AWS Architecture Blog "Exponential Backoff And Jitter", Martin Kleppmann "How to do distributed locking"을 확인해 정리했습니다. 모든 명령 출력과 코드는 Redis 8.8.1 · redis-py 6.4.0 · Python 3.13에서 직접 실행한 결과입니다.

1. 락으로 무엇을 지키는가

1-1. 핵심 아이디어

핵심 아이디어

"결과가 어차피 같은 작업이라면, 한 명만 하고 나머지는 그 결과를 나눠 쓰면 된다."

text
[방어 없음]                          [락 적용]
  요청 1 -> DB                        요청 1 -> 락 획득 -> DB -> 캐시 저장 -> 락 해제
  요청 2 -> DB                        요청 2 -> 락 실패 -> 대기 -> 캐시 읽기
  요청 3 -> DB                        요청 3 -> 락 실패 -> 대기 -> 캐시 읽기
  ...                                 ...
  요청 N -> DB                        요청 N -> 락 실패 -> 대기 -> 캐시 읽기

  DB 쿼리 N개                         DB 쿼리 1개

이 글에서는 락을 잡은 한 요청을 Winner, 락을 잡지 못한 나머지를 Loser라고 부릅니다. 락 설계의 절반은 Winner가 안전하게 일하게 하는 것이고, 나머지 절반은 Loser가 자원을 아끼며 기다리게 하는 것입니다.

1-2. 두 종류의 락을 구분하십시오

Martin Kleppmann은 분산 락을 목적에 따라 둘로 나눕니다. 이 구분이 "얼마나 튼튼하게 만들어야 하는가"를 결정합니다.

구분 효율성 락 (이 글의 주제) 정합성 락 (유닛 5-8)
목적 같은 일을 여러 번 하지 않아 부하를 줄인다 동시에 실행되면 데이터가 망가지는 일을 막는다
예시 인기 랭킹 재계산, 상품 상세 캐시 채우기 재고 차감, 결제 승인, 좌석 예약
락이 뚫리면 DB 쿼리가 몇 개 더 나갈 뿐 결과는 정확하다 중복 결제, 초과 판매 같은 사고가 난다
필요한 수준 단일 Redis의 SET NX PX로 충분하다 DB 트랜잭션, 유니크 제약, 펜싱 토큰을 함께 쓴다

먼저 정할 것

캐시 스탬피드 방어용 락은 효율성 락입니다. 드물게 두 요청이 동시에 원본을 조회해도 결과는 같습니다. 그래서 이 글의 목표는 "완벽한 상호 배제"가 아니라 "대부분의 경우 원본 조회를 1회로 줄이면서, 락 때문에 서비스가 멈추지 않는 것"입니다. Redis 공식 문서도 단일 인스턴스 락을 "가끔의 경쟁 조건을 받아들일 수 있는 애플리케이션에서 쓸 만하다"고 설명합니다.

2. 기능 구현의 전체 흐름

락을 쓰는 캐시 조회는 아래 순서를 따릅니다. 이후 모든 장은 이 흐름의 한 칸을 자세히 보는 것입니다.

text
요청 도착
  |
  v
[1] 1차 확인      GET product:1 ------------------------ HIT --> 응답 (대부분 여기서 끝)
  | MISS
  v
[2] 락 획득 시도  SET product:1:lock <토큰> NX PX 5000
  |                              |
  | OK (Winner)                  | nil (Loser)
  v                              v
[3] 이중 확인     GET product:1  [6] 대기  (Polling & Backoff 또는 Pub/Sub 알림)
  |  HIT -> 락 해제 후 응답       |
  |  MISS                         v
  v                             [7] 깨어나면 GET product:1
[4] 원본 조회 (워치독이 락 연장)    |  HIT  -> 응답
  |                                |  MISS -> 대기 상한 전이면 [2]로, 넘었으면 503
  v
[5] 캐시 저장     SET product:1 <값> EX 60
    락 해제+알림  (Lua) 내 토큰이면 DEL 후 PUBLISH lock-released:product:1
    응답
단계 하는 일 빠뜨리면 장
[1] 1차 확인 락 없이 캐시를 읽는다 캐시 적중 요청까지 락 경쟁에 끼어 Redis 부하가 커진다 7
[2] 락 획득 토큰과 TTL을 넣어 한 명령으로 만든다 둘이 동시에 얻거나(상호 배제 실패), 영원히 남는다(데드락) 3, 4
[3] 이중 확인 락을 얻은 직후 캐시를 한 번 더 읽는다 뒤늦게 락을 얻은 요청이 원본을 또 조회한다(순차적 스탬피드) 5
[4] 원본 조회 조회가 길어지면 락을 연장한다 조회 도중 락이 만료되어 다른 요청이 들어온다 4
[5] 저장 · 해제 · 알림 토큰을 비교해서 지우고, 같은 원자 실행에서 알린다 남의 락을 지우거나, 대기자가 알림을 못 받는다 3, 6
[6] 대기 백오프+지터로 폴링하거나 Pub/Sub 알림을 기다린다 Loser 수천 명의 폴링이 Redis를 두드린다 6
[7] 재확인 · 상한 깨어나면 캐시부터 보고, 상한을 넘으면 빠르게 실패한다 무한 대기, 또는 DB 직행으로 스탬피드가 늦게 다시 일어난다 4

3. redis-cli로 하는 사전 테스트와 검수

코드를 쓰기 전에 락의 모든 동작을 redis-cli로 확인합니다. 명령 단위로 동작을 눈으로 본 뒤 코드를 쓰면, 나중에 문제가 생겼을 때 "Redis가 이상한지, 내 코드가 이상한지"를 바로 구분할 수 있습니다. 아래 출력은 Redis 8.8.1에서 실행한 결과입니다.

준비 — 터미널 두 개

터미널 A는 Winner 역할, 터미널 B는 Loser 역할입니다. 둘 다 redis-cli로 접속합니다. WSL2의 mirrored 네트워킹에서 localhost 접속이 멈춘다면 redis-cli -h 127.0.0.1을 쓰십시오. 실습 키는 모두 ex73:cli:로 시작하므로 끝난 뒤 이 접두어만 지우면 됩니다.

3-1. 테스트 1 — 획득은 한 명만, TTL은 같은 명령으로

터미널 A, 이어서 B

bash
127.0.0.1:6379> SET ex73:cli:product:1:lock owner-A NX PX 10000
OK                                  # A: 키가 없었으므로 획득
127.0.0.1:6379> SET ex73:cli:product:1:lock owner-B NX PX 10000
(nil)                               # B: 이미 있으므로 실패. 값은 바뀌지 않는다
127.0.0.1:6379> GET ex73:cli:product:1:lock
"owner-A"                           # 소유자 토큰
127.0.0.1:6379> PTTL ex73:cli:product:1:lock
(integer) 9986                      # 남은 수명(ms). A가 죽어도 10초 뒤 풀린다
  • NX: 키가 없을 때만 만든다. 실패하면 Null((nil))을 돌려주고, redis-py에서는 None입니다.
  • PX 10000: 밀리초 단위 TTL. 초 단위는 EX입니다. 락 TTL은 1초 미만 조정이 필요할 때가 많아 PX를 권합니다.
  • SET이 NX와 EX/PX를 함께 받게 된 것은 Redis 2.6.12부터입니다.

3-2. 테스트 2 — 두 명령으로 나누면 생기는 영구 락

bash
127.0.0.1:6379> SETNX ex73:cli:bad x
(integer) 1                         # 락은 만들어졌다
                                    # ... 여기서 프로세스가 죽었다고 가정 (EXPIRE 가 실행되지 않음)
127.0.0.1:6379> TTL ex73:cli:bad
(integer) -1                        # -1: 키는 있는데 만료가 없다. 아무도 이 락을 풀지 못한다
127.0.0.1:6379> DEL ex73:cli:bad
(integer) 1                         # 실습 정리

SETNX 다음 줄의 EXPIRE가 실행되지 않으면 락은 영원히 남고, 그 캐시 키는 다시는 채워지지 않습니다. 이것이 "획득과 TTL은 반드시 한 명령"인 이유입니다. 참고로 없는 키의 TTL/PTTL은 -2입니다.

3-3. 테스트 3 — 소유권 검증 연장

터미널 A의 락이 살아 있는 동안 실행합니다(10초가 지났다면 3-1을 다시 실행).

bash
127.0.0.1:6379> EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('PEXPIRE', KEYS[1], ARGV[2]) end return 0" 1 ex73:cli:product:1:lock owner-B 10000
(integer) 0                         # B의 토큰으로는 연장되지 않는다
127.0.0.1:6379> EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('PEXPIRE', KEYS[1], ARGV[2]) end return 0" 1 ex73:cli:product:1:lock owner-A 10000
(integer) 1                         # A의 토큰이면 TTL 이 다시 10초가 된다 (워치독이 하는 일)

3-4. 테스트 4 — 소유권 검증 해제와 해제 알림

터미널 B에서 먼저 알림 채널을 구독합니다. 구독한 연결은 알림을 기다리는 상태가 되므로, 이후 명령은 터미널 A에서 실행합니다.

터미널 B — Loser

bash
127.0.0.1:6379> SUBSCRIBE ex73:lock-released:ex73:cli:product:1
1) "subscribe"
2) "ex73:lock-released:ex73:cli:product:1"
3) (integer) 1                      # 구독 확인. 이제부터 발행되는 알림만 받는다

터미널 A — Winner

bash
127.0.0.1:6379> EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then redis.call('DEL', KEYS[1]) redis.call('PUBLISH', ARGV[2], '1') return 1 end return 0" 1 ex73:cli:product:1:lock owner-B ex73:lock-released:ex73:cli:product:1
(integer) 0                         # 남의 토큰: 지우지도, 알리지도 않는다
127.0.0.1:6379> EXISTS ex73:cli:product:1:lock
(integer) 1                         # 락은 그대로
127.0.0.1:6379> EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then redis.call('DEL', KEYS[1]) redis.call('PUBLISH', ARGV[2], '1') return 1 end return 0" 1 ex73:cli:product:1:lock owner-A ex73:lock-released:ex73:cli:product:1
(integer) 1                         # 내 토큰: DEL 과 PUBLISH 가 한 번에 실행된다
127.0.0.1:6379> EXISTS ex73:cli:product:1:lock
(integer) 0

터미널 B에 도착한 알림

bash
1) "message"
2) "ex73:lock-released:ex73:cli:product:1"
3) "1"

터미널 B에서 Ctrl+C로 구독을 끝낸 뒤, 터미널 A에서 한 번 더 발행해 봅니다.

터미널 A

bash
127.0.0.1:6379> PUBLISH ex73:lock-released:ex73:cli:product:1 1
(integer) 0                         # 받은 구독자 0명. 이 알림은 저장되지 않고 사라진다

이 테스트에서 꼭 기억할 것 — Pub/Sub는 at-most-once

PUBLISH의 반환값은 "지금 받은 클라이언트 수"입니다. Redis 공식 문서는 Pub/Sub 전달을 at-most-once(최대 한 번)라고 설명합니다. 발행 순간에 구독하고 있지 않았거나, 연결이 끊겨 있었다면 그 알림은 다시 오지 않습니다. 그래서 6장의 Pub/Sub 대기는 반드시 "구독 먼저, 확인 나중" 순서와 타임아웃 재확인을 함께 씁니다.

3-5. 참고 — Redis 8.4 이상의 DELEX IFEQ

Redis 8.4+

bash
127.0.0.1:6379> SET ex73:cli:product:1:lock owner-A NX PX 10000
OK
127.0.0.1:6379> DELEX ex73:cli:product:1:lock IFEQ owner-B
(integer) 0                         # 값이 다르면 지우지 않는다
127.0.0.1:6379> DELEX ex73:cli:product:1:lock IFEQ owner-A
(integer) 1                         # 값이 같을 때만 지운다

Redis 8.4부터는 DELEX key IFEQ value 한 명령으로 "비교 후 삭제"를 할 수 있고, 공식 분산 락 문서도 이를 소개합니다. 다만 해제와 알림을 한 번에 묶어야 하고 구버전 호환도 필요하므로 이 글의 구현은 Lua 스크립트를 씁니다.

3-6. 검수 체크리스트

아래 표의 기대 결과가 모두 나오면 Redis 쪽 준비는 끝났습니다. 하나라도 다르면 코드를 쓰기 전에 원인을 찾으십시오.

검수 항목 명령 기대 결과
상호 배제: 두 번째 획득은 실패한다 SET ... NX PX 두 번 OK 다음 (nil)
데드락 방지: 락에 수명이 있다 PTTL lock 0보다 큰 값. -1이면 불합격
소유권: 남의 토큰으로 못 지운다 해제 스크립트 + 다른 토큰 0, 락 유지
소유권: 내 토큰으로 지운다 해제 스크립트 + 내 토큰 1, EXISTS는 0
연장: 내 락만 연장된다 연장 스크립트 남의 토큰 0, 내 토큰 1
알림: 해제와 동시에 구독자에게 간다 SUBSCRIBE 후 해제 구독 터미널에 message 도착
알림의 한계: 구독자가 없으면 사라진다 PUBLISH (integer) 0
정리 SCAN 0 MATCH ex73:cli:* 남은 키 없음(락은 해제되었거나 TTL로 사라짐)

4. 락의 취약점과 방어 대책

락은 방어 장치이지만 잘못 만들면 새로운 장애 원인이 됩니다. 아래 표는 흔한 취약점과 대책의 전체 목록이고, 이어지는 절에서 하나씩 구현합니다.

취약점 무슨 일이 생기는가 방어 대책
1. TTL 없는 락 Winner가 죽으면 락이 영원히 남고 그 키는 다시 채워지지 않는다 Lock TTL 적용: SET NX PX 한 명령 (4-1)
2. 남의 락 해제 조회가 TTL보다 길어 락이 넘어간 뒤, 늦게 끝난 Winner가 새 소유자의 락을 지운다 요청마다 고유 토큰 + Lua 비교 후 삭제 (4-2)
3. 작업보다 짧은 TTL 조회 도중 락이 만료되어 다른 요청이 원본으로 간다 TTL을 p99의 3배 이상 + 워치독 연장 (4-3)
4. 무한 연장 원본 조회가 멈췄는데 워치독이 계속 연장해 락이 영원히 유지된다 워치독의 최대 보유 시간(max_hold) (4-3)
5. 순차적 스탬피드 기다리던 Loser가 락을 넘겨받을 때마다 원본을 다시 조회한다 Double-Check Locking (5장)
6. Loser의 폴링 폭주 수천 명이 같은 간격으로 Redis를 두드린다 백오프+지터 또는 Pub/Sub 알림 (6장)
7. 무한 대기 · DB 직행 Winner가 느리면 전원이 느려지고, 타임아웃 뒤 DB로 몰리면 스탬피드가 늦게 다시 일어난다 대기 상한 + 빠른 실패(503) (4-4)
8. Redis 장애 · 페일오버 락 서버가 죽거나, 복제 지연으로 새 마스터에서 같은 락이 두 번 발급된다 효율성 락은 수용. 장애 시 부하 차단으로 강등 (4-5)

4-1. Lock TTL 적용 — 크기는 어떻게 정하는가

TTL 생기는 문제
너무 짧다 (조회 p99보다 짧음) 조회 도중 락이 풀려 두 번째 Winner가 생긴다. 방어가 새는 것
너무 길다 (예: 60초) Winner가 죽으면 그 시간 동안 아무도 캐시를 채우지 못한다. 복구가 늦는 것

① 원본 조회 + 캐시 저장의 p99를 잰다. 평균이 아니라 p99입니다. 느린 순간에 락이 새기 때문입니다.

② 락 TTL = p99 x 3 정도로 시작한다. p99가 1초면 3 - 5초입니다.

③ 조회 시간이 들쭉날쭉하면(리포트, 대량 집계) TTL을 늘리지 말고 워치독을 붙인다. 짧은 TTL로 빠른 복구를, 연장으로 긴 작업의 완주를 동시에 얻습니다.

④ 대기 상한(max_wait)은 락 TTL과 클라이언트 타임아웃 사이에서 정한다. Winner가 죽었을 때 Loser가 이어받으려면 max_wait가 락 TTL보다 길어야 하고, 사용자가 먼저 끊지 않으려면 HTTP 타임아웃보다 짧아야 합니다.

4-2. 소유권 토큰과 Lua 해제

text
[소유권 검증이 없을 때의 사고]
  t0  A: SET lock NX PX 5000       -> A 획득
  t1  A: 원본 조회가 6초 걸림
  t5  락 자동 만료
  t5  B: SET lock NX PX 5000       -> B 획득 (정상)
  t6  A: DEL lock                  -> B의 락을 A가 지운다
  t6  C: SET lock NX PX 5000       -> C도 획득 -> B와 C가 동시에 원본 조회

release_and_notify.lua

lua
-- KEYS[1] = 락 키, ARGV[1] = 내 토큰, ARGV[2] = 해제 알림 채널
-- GET-비교-DEL-PUBLISH 가 하나의 원자 실행이므로 중간에 다른 명령이 끼어들 수 없다.
if redis.call('GET', KEYS[1]) == ARGV[1] then
  redis.call('DEL', KEYS[1])
  redis.call('PUBLISH', ARGV[2], '1')
  return 1
end
return 0
  • 토큰은 요청마다 새로 만든다(uuid.uuid4().hex). Redis 공식 문서도 "모든 클라이언트와 모든 락 요청에서 고유해야 한다"고 요구합니다.
  • 반환값 0을 무시하지 않는다. 0은 "내가 일하는 동안 락이 만료되어 넘어갔다"는 뜻이므로 TTL이 짧다는 신호입니다. 로그와 지표로 남깁니다.
  • 해제 다음에 알린다. 알림을 먼저 보내면 깨어난 Loser가 아직 남아 있는 락과 부딪혀 다시 잠들고, 그 뒤에는 알림이 오지 않습니다. 둘을 한 스크립트에 넣으면 순서 문제가 사라집니다.

4-3. 워치독 — 작업이 끝날 때까지 락을 연장한다

워치독은 Winner가 원본을 조회하는 동안 옆에서 도는 작은 태스크입니다. TTL의 1/3마다 "내 락이면 TTL을 되돌린다"를 실행합니다. Winner 프로세스가 죽으면 워치독도 함께 죽으므로 락은 곧 만료됩니다.

워치독

python
EXTEND_LUA = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('PEXPIRE', KEYS[1], ARGV[2])
end
return 0
"""


async def watchdog(extend, lock_key: str, token: str, lock_ttl_ms: int, max_hold: float) -> None:
    """TTL 의 1/3 마다 연장한다. 소유권을 잃었거나 max_hold 를 넘기면 멈춘다."""
    loop = asyncio.get_running_loop()
    stop_at = loop.time() + max_hold
    while loop.time() < stop_at:
        await asyncio.sleep(lock_ttl_ms / 3000)
        if not await extend(keys=[lock_key], args=[token, lock_ttl_ms]):
            return  # 이미 남의 락이다. 연장을 멈춘다
    # max_hold 초과: 원본 조회가 멈췄다고 보고 연장을 포기한다. 락은 TTL 뒤에 풀린다.

① 락을 얻은 직후 워치독 태스크를 띄운다. asyncio.create_task(watchdog(...))

② 원본 조회와 캐시 저장이 끝나면 finally에서 태스크를 취소한다. 해제보다 먼저 취소해야 해제 뒤에 연장이 실행되는 일이 없습니다.

③ max_hold를 반드시 둔다. 원본이 멈춰 응답하지 않으면 워치독이 락을 영원히 붙잡습니다. Redis 공식 문서도 락 연장은 제한된 횟수까지만 하라고 권합니다.

워치독이 해결하지 못하는 것

프로세스 전체가 멈추는 경우(긴 GC 일시 정지, VM 정지)에는 워치독도 같이 멈추고, 그사이 락이 만료되어 다른 요청이 들어갑니다. 깨어난 Winner는 자신이 여전히 소유자라고 생각하고 캐시를 씁니다. 효율성 락에서는 결과가 같으므로 문제가 되지 않지만, 정합성 락이라면 저장소 쪽에서 오래된 쓰기를 거부하는 펜싱 토큰(단조 증가 번호)이 필요합니다(Kleppmann).

4-4. 대기 상한과 빠른 실패

Loser는 영원히 기다리면 안 됩니다. 그렇다고 상한을 넘은 Loser가 모두 DB로 가면 스탬피드가 2초 늦게 다시 일어날 뿐입니다. 유닛 7-2와 같은 기준으로, 상한을 넘으면 HTTP 503 + Retry-After로 빠르게 실패합니다.

상한 초과 시 정책 장점 단점 / 쓰는 곳
빠른 실패 (기본값) DB를 확실히 보호한다. 클라이언트가 재시도 시점을 안다 일부 사용자에게 오류가 보인다
낡은 값 반환 오류 없이 즉시 응답 낡은 값이 남아 있을 때만 가능. 유닛 7-2의 SWR과 결합
DB 직행 구현이 단순하다 방어 효과가 사라진다. 쓴다면 DB 동시 접근 상한(유닛 7-2 5장) 아래에서만

4-5. Redis 장애와 페일오버

  • 페일오버 중 중복 발급: Redis 복제는 비동기라서 락을 쓴 직후 마스터가 죽으면 승격된 복제본에는 락이 없을 수 있습니다(Redis 공식 분산 락 문서). 효율성 락이라면 원본 조회가 한 번 더 일어날 뿐이므로 받아들입니다.
  • Redis 자체가 죽은 경우: 캐시도 락도 없습니다. 모든 요청을 DB로 보내면 그것이 곧 스탬피드입니다. 프로세스 안의 SingleFlight와 DB 동시 접근 상한(유닛 7-2)으로 강등하거나 503으로 응답합니다.
  • Redlock: 여러 독립 마스터의 과반에서 락을 얻는 알고리즘입니다. 효율성 락에는 과한 비용이며, 정합성 락에서도 Kleppmann은 펜싱 토큰 없이는 안전하지 않다고 비판했습니다. 이 글의 락에는 쓰지 않습니다.

5. 방어 기법 — Double-Check Locking

5-1. 문제 — 순차적 스탬피드(Sequential Stampede)

락은 "동시에" 원본으로 가는 것을 막습니다. 그런데 Loser가 캐시가 아니라 락 자체를 기다리는 구조라면, 락이 한 명씩 넘어갈 때마다 원본 조회가 한 번씩 차례대로 일어납니다. 동시 폭주가 아니라 줄 서서 하는 폭주입니다.

text
시간      Winner                  Loser 1                 Loser 2                 DB 조회
------------------------------------------------------------------------------------------
T0        MISS, 락 획득           MISS, 락 실패           MISS, 락 실패           -
T0~200ms  DB 조회                 락을 기다림             락을 기다림             1회
T0+200ms  캐시 저장, 해제, 응답   락을 기다림             락을 기다림
          (1) Winner 가 DB 를 조회하고 캐시를 갱신한 뒤 나간다.
T0+210ms                          락 획득                 락을 기다림
          (2) 오래 기다리던 첫 번째 Loser 가 깨어나 드디어 락을 얻는다.
T0+211ms                          DB 조회 (캐시 확인 X)   락을 기다림             2회
          (3) 캐시가 이미 채워진 줄 모르고 DB 에 쿼리를 날린다.
T0+411ms                          캐시 덮어쓰기, 해제     락 획득
T0+412ms                                                  DB 조회 (캐시 확인 X)   3회
          (4) 그 뒤에 기다리던 두 번째 Loser 도 깨어나서 DB 로 간다.

결과: 동시에 몰리지는 않았지만 Loser 수만큼 DB 조회가 줄지어 일어난다.
      대기열이 길면 뒤쪽 요청은 대기 상한을 넘겨 503 이 된다.

이 구조는 생각보다 흔합니다. redis-py의 Lock(blocking=True), Java의 ReentrantLock처럼 "락을 얻을 때까지 기다린다"는 일반 뮤텍스 API를 그대로 캐시 조회에 쓰면 정확히 이 모양이 됩니다.

5-2. 해결책 — 락을 획득한 직후 캐시를 한 번 더 조회한다

Double-Check Locking

확인(락 없이) - 락 획득 - 다시 확인(락 안에서) - 그래도 없을 때만 원본 조회. 락을 기다리는 동안 앞선 Winner가 캐시를 채웠을 수 있습니다. 락을 얻었다는 사실은 "원본을 조회할 권리"일 뿐, "캐시가 비어 있다"는 증거가 아닙니다.

text
시간     Loser 1 (락 획득 후)                     원본 조회
------------------------------------------------------------------
T0+210ms 락 획득
T0+211ms GET product:1 -> HIT  (이중 확인)       조회 안 함
T0+212ms 락 해제, 캐시 값으로 응답
         Loser 2 도 같은 방식으로 HIT            조회 안 함

결과: 원본 조회는 Winner 의 1회뿐이다.

5-3. How to — 단계별 구현

① 락 획득 전 1차 확인을 둔다. 캐시 적중 요청은 락을 건드리지 않고 끝납니다.

② 락을 얻으면 try 블록의 첫 줄에서 캐시를 다시 읽는다. 값이 있으면 원본을 건너뛰고 그 값을 응답합니다. 이때도 finally에서 락을 해제해야 합니다.

③ 이중 확인 적중 횟수를 지표로 남긴다. 이 값이 크다는 것은 Loser들이 락을 기다리는 구조라는 뜻이므로, 6장의 대기 방식으로 바꿀 신호입니다.

④ Loser의 대기 루프에서도 깨어날 때마다 캐시부터 본다. 이중 확인이 "락 안의 확인"이라면, 이것은 락 경쟁에 끼기 전의 확인입니다. 둘을 함께 쓰면 락은 원본 조회가 정말 필요할 때만 넘어갑니다.

sequential_stampede.py — 이중 확인 유무 비교

python
"""Loser 들이 락 자체를 기다리는 구조에서 이중 확인 유무에 따른 원본 조회 수를 비교한다."""

import asyncio
import json

import redis.asyncio as redis
from redis.exceptions import LockError

r = redis.from_url("redis://localhost:6379", decode_responses=True)
db_calls = 0


async def load_from_db() -> dict:
    global db_calls
    db_calls += 1
    await asyncio.sleep(0.1)  # 느린 원본 조회
    return {"price": 1000}


async def get_product(key: str, double_check: bool) -> dict | None:
    cached = await r.get(key)  # (1) 1차 확인
    if cached is not None:
        return json.loads(cached)
    # (2) 락을 얻을 때까지 기다린다. 요청마다 Lock 객체를 새로 만든다.
    lock = r.lock(f"{key}:lock", timeout=5, sleep=0.05, blocking_timeout=3, thread_local=False)
    try:
        async with lock:
            if double_check:
                cached = await r.get(key)  # (3) 이중 확인
                if cached is not None:
                    return json.loads(cached)
            value = await load_from_db()  # (4) 원본 조회
            await r.set(key, json.dumps(value), ex=60)
            return value
    except LockError:
        return None  # 대기 상한 초과 (실무에서는 503)


async def main() -> None:
    global db_calls
    for double_check in (False, True):
        key = f"ex73:demo:product:{int(double_check)}"
        await r.delete(key)
        db_calls = 0
        results = await asyncio.gather(*[get_product(key, double_check) for _ in range(20)])
        failed = sum(v is None for v in results)
        print(f"double_check={double_check!s:<5}  요청 20건 | DB 조회 {db_calls}회 | 대기 상한 초과 {failed}건")
        await r.delete(key)
    await r.aclose()


asyncio.run(main())

5-4. 실행 결과

bash
$ python sequential_stampede.py
double_check=False  요청 20건 | DB 조회 20회 | 대기 상한 초과 0건
double_check=True   요청 20건 | DB 조회 1회 | 대기 상한 초과 0건

결과 읽기

이중 확인이 없으면 20건 모두가 락을 차례로 넘겨받으며 DB를 조회했습니다. 동시에 몰리지는 않았지만 조회가 20번이고, 마지막 요청은 약 2초(0.1초 x 20)를 기다렸습니다. 요청이 더 많거나 조회가 더 느리면 뒤쪽 요청은 대기 상한을 넘겨 실패합니다 (10장 실습의 mutex double_check=false 줄: 원본 1초, 요청 200건 중 197건이 503). 이중 확인을 넣으면 DB 조회는 1회이고, 락을 넘겨받은 요청은 캐시 값을 읽고 곧바로 락을 돌려줍니다.

5-5. 장단점

구분 내용
장점 순차적 스탬피드를 없앤다. 락을 넘겨받은 요청이 원본을 다시 조회하지 않으므로 원본 조회는 키당 1회로 수렴한다
비용이 거의 없다. 락을 얻은 요청에만 GET 1회가 추가된다. 원본 조회(수백 ms)에 비하면 무시할 만하다
1차 확인과 락 획득 사이의 틈을 막는다. 1차 확인이 미스인 순간과 락을 얻는 순간 사이에 다른 Winner가 캐시를 채우고 떠나는 경쟁도 함께 잡힌다
단점 락 대기 자체는 줄이지 못한다. 락을 기다리는 구조라면 이중 확인이 있어도 락이 한 명씩 넘어가므로 뒤쪽 요청의 지연이 길다. 이 비용은 6장의 대기 방식으로 줄인다
"캐시에 값이 있다 = 최신"이라는 가정에 기댄다. 무효화가 빈번한 키에서 이중 확인이 방금 무효화된 값 대신 직전 값을 읽을 수 있다. 무효화 설계(유닛 7-1)와 함께 판단한다
원본 조회가 실패하면 효과가 없다. 캐시가 채워지지 않았으므로 다음 소유자도 원본으로 간다. 실패가 반복되면 한 명씩 차례로 실패하므로, 원본 장애 시에는 빠른 실패나 낡은 값 응답이 필요하다

일반 프로그래밍의 Double-Checked Locking과의 차이

싱글턴 초기화에 쓰는 메모리 안의 Double-Checked Locking은 가시성 문제(다른 스레드가 반쯤 만들어진 객체를 보는 문제) 때문에 까다롭기로 유명합니다. Redis 캐시에서는 SET이 원자적이라 "반쯤 쓰인 값"이 보이지 않으므로 이 문제가 없습니다. 확인 - 락 - 재확인이라는 모양만 같습니다.

6. 고급 전략 — 기다리는 Loser의 생존 전략

락은 원본 조회를 1회로 줄였습니다. 이제 남은 비용은 락을 못 잡은 수천 개의 요청이 기다리는 방식에서 나옵니다. Loser가 자원을 아끼는 방법은 두 가지입니다. 스스로 일정 주기로 확인하는 Polling & Backoff, 그리고 신호가 올 때까지 잠드는 Pub/Sub(Event-Driven)입니다.

6-1. 왜 대기 방식이 중요한가 — 비용 계산

Winner의 원본 조회가 1초 걸리고, 그동안 Loser 1,000명이 기다린다고 해 봅니다.

대기 방식 Loser 1인당 확인 횟수 1초 동안 Redis 명령 해제 후 응답까지
고정 50ms 폴링 1초 / 50ms = 약 20회 약 20,000회 최대 50ms
지수 백오프 + Full Jitter (base 20ms, cap 500ms) 기대값 약 8 - 9회 약 8,000 - 9,000회 최대 cap(500ms)
Pub/Sub 알림 대기 전 1회 + 깨어난 뒤 1회 (+ 안전망 확인 0.5초마다 1회) 약 2,000 - 3,000회 (+ 발행 1회) 알림 전달 시간(수 ms)

Winner 한 명이 1초 일하는 동안 Loser들이 만드는 명령이 수만 개가 될 수 있습니다. 원본은 지켰지만 Redis와 애플리케이션의 CPU · 네트워크를 대기에 쓰는 것입니다. 6-4의 실측으로 이 계산을 확인합니다.

6-2. 전략 1 — Polling & Backoff

이론 — 무엇을 늘리고 무엇을 흩뿌리는가

폴링은 "잠깐 자고 캐시를 확인한다"의 반복입니다. 개선 포인트는 두 가지입니다.

  • 지수적 대기 증가(Exponential Backoff): 확인할 때마다 대기 시간을 두 배로 늘립니다. 원본 조회가 금방 끝나면 앞쪽의 짧은 대기에서 빨리 응답하고, 오래 걸리면 뒤쪽의 긴 대기로 확인 횟수를 줄입니다. 대기 시간이 무한히 커지지 않도록 상한(cap)을 둡니다.
  • 무작위 지연(Jitter): 같은 순간에 미스를 본 Loser들은 백오프만 쓰면 같은 순간에 같이 깨어납니다. 20ms, 40ms, 80ms마다 수천 개가 한꺼번에 몰리는 작은 파도가 생깁니다. 대기 시간을 무작위로 흩뿌려 이 동기화를 깹니다.

AWS Architecture Blog(Marc Brooker, "Exponential Backoff And Jitter")가 비교한 공식은 다음과 같습니다. n은 몇 번째 대기인지입니다.

방식 n번째 대기 시간 특징
지수 백오프만 min(cap, base x 2^n) 동시에 시작한 대기자가 동시에 깨어난다
Full Jitter random(0, min(cap, base x 2^n)) 총 작업량(요청 수)이 가장 적은 편. 이 글의 기본값
Equal Jitter t/2 + random(0, t/2), t = min(cap, base x 2^n) 최소 대기를 보장하지만 비교에서 가장 성적이 나빴다
Decorrelated Jitter min(cap, random(base, 직전 대기 x 3)) Full Jitter와 비슷하게 좋다. 직전 값을 기억해야 한다

동작 방식

text
base = 20ms, cap = 500ms, Full Jitter. Winner 의 원본 조회는 T0 ~ T0+1000ms.
(zzN = N ms 동안 잠든다)

Loser 1: MISS zz7 GET zz31 GET zz12 GET zz143 GET zz98 GET ... zz410 GET HIT
Loser 2: MISS zz18 GET zz2 GET zz66 GET zz21 GET zz260 GET ... GET HIT
Loser 3: MISS zz3 GET zz39 GET zz74 GET zz130 GET ... GET HIT
         |<-- 앞쪽: 대기가 짧다 -->|<-- 뒤쪽: 대기가 길다(최대 cap) -->|
         빨리 끝나면 빨리 응답      오래 걸리면 적게 확인

- 같은 시각에 미스를 봤지만 확인 시각이 흩어져 Redis 에 동시에 몰리지 않는다.
- 캐시가 채워진 뒤에도 각자 다음 확인 시각까지 기다리므로
  응답이 최대 cap 만큼 늦을 수 있다.

How to — 단계별 구현

① base를 정한다. 원본 조회가 아주 빨리 끝나는 경우의 첫 확인 간격입니다. 10 - 50ms가 보통입니다.

② cap을 정한다. "캐시가 채워진 뒤 응답이 최대 얼마나 늦어도 되는가"입니다. 사용자 요청이면 200 - 500ms를 넘기지 않습니다.

③ 대기 시간 생성기를 만든다. 매 대기마다 Full Jitter 공식으로 다음 값을 꺼냅니다. 지수는 적당히 끊어 큰 수가 float로 바뀔 때 넘치지 않게 합니다.

④ 남은 대기 상한보다 길게 자지 않는다. min(다음 대기, 남은 시간)만큼 잡니다.

⑤ 깨어나면 캐시부터 확인하고, 없으면 락 획득을 다시 시도한다. 락 재시도가 있어야 Winner가 죽었을 때 락 TTL이 지난 뒤 누군가 이어받습니다.

⑥ 상한을 넘으면 CacheBusyError로 빠져나온다.

Python 구현

Polling & Backoff 대기

python
import asyncio
import random
from collections.abc import Iterator


def full_jitter_delays(base: float = 0.02, cap: float = 0.5) -> Iterator[float]:
    """AWS Full Jitter: n 번째 대기 = random(0, min(cap, base * 2^n))."""
    attempt = 0
    while True:
        # 지수는 32 에서 멈춘다. 그 이상은 어차피 cap 에 걸리고, 큰 지수는 float 변환에서 넘친다.
        yield random.uniform(0.0, min(cap, base * 2 ** min(attempt, 32)))
        attempt += 1


# Loser 루프 (7장 모듈의 일부를 줄여 보인 것)
delays = full_jitter_delays()
while True:
    if await r.set(lock_key, token, nx=True, px=lock_ttl_ms):   # 락 재시도
        ...                                                      # Winner 가 되었다 (5장의 이중 확인부터)
    remaining = deadline - loop.time()
    if remaining <= 0:
        raise CacheBusyError(key)                                # 대기 상한 초과
    await asyncio.sleep(min(next(delays), remaining))           # 백오프 + 지터
    cached = await r.get(key)                                    # 깨어나면 캐시부터
    if cached is not None:
        return json.loads(cached)

장단점

구분 내용
장점 구현이 단순하고 추가 인프라가 없다. GET과 sleep만 쓴다
유실되는 신호가 없다. 스스로 확인하므로 알림을 놓치는 문제 자체가 없고, Redis 재연결에도 영향받지 않는다
연결을 추가로 점유하지 않는다. 자는 동안에는 연결을 돌려주므로 커넥션 풀을 공유할 수 있다
단점 확인 횟수가 대기 시간에 비례해 늘어난다. Loser가 많고 원본이 느리면 명령 수가 커진다
응답이 늦게 깨어난다. 캐시가 채워져도 다음 확인 시각까지 기다린다. 최대 지연은 cap이다
파라미터에 민감하다. base가 너무 작으면 초반에 명령이 몰리고, cap이 너무 크면 응답이 늦다

6-3. 전략 2 — Pub/Sub (Event-Driven)

이론 — 확인하지 말고 알려 달라

Winner는 락을 해제하는 순간 lock-released:{키} 채널에 신호를 발행하고, Loser는 그 채널을 구독한 채 잠듭니다. Loser는 신호가 왔을 때만 깨어나 캐시를 한 번 읽습니다. 기다리는 동안 명령이 없습니다. 다만 Pub/Sub의 성질 세 가지를 설계에 반영해야 합니다.

Pub/Sub의 성질 그대로 쓰면 생기는 문제 설계 규칙
at-most-once, 저장 안 함 구독하기 전에 발행된 신호는 받지 못한다. "락 실패 확인 - 구독" 사이에 해제되면 이미 지나간 신호를 영원히 기다린다(lost wakeup) 구독(등록)을 먼저 하고, 그다음 락 시도와 캐시 확인을 한다
연결이 끊기면 그동안의 신호가 사라진다 재연결 사이에 온 해제 신호를 놓친다 대기에 짧은 타임아웃을 두어 신호가 없어도 주기적으로 캐시를 확인한다. 끊김을 감지하면 대기자를 모두 깨운다
구독은 전용 연결을 쓴다 redis-py의 PubSub 객체는 풀에서 연결 하나를 따로 점유한다. Loser마다 구독하면 Loser 수만큼 연결이 필요하다(RESP2에서는 구독 중인 연결로 다른 명령도 못 보낸다) 프로세스당 구독 연결 하나로 패턴 구독하고, 프로세스 안에서 대기자에게 나눠 준다

동작 방식

text
[앱 시작] 리스너: PSUBSCRIBE lock-released:*   (프로세스당 전용 연결 1개)

시간       Winner                            Loser (같은 프로세스의 대기자)
------------------------------------------------------------------------------------------
T0         SET lock NX PX -> OK
T0+1ms     GET -> MISS (이중 확인)           [1] 대기표에 등록 (메모리, 명령 없음)
T0+2ms     원본 조회 시작                    [2] SET lock NX PX -> nil
T0+3ms                                       [3] 알림 또는 0.5초를 기다리며 잠든다
  ...      (원본 조회 중. Loser 의 명령은 0)
T0+1000ms  SET product 값 EX 60
T0+1001ms  Lua: 토큰 확인 -> DEL -> PUBLISH
           Redis -> 리스너 -> 대기표의 Future 를 모두 깨운다
T0+1002ms  응답                              [4] GET product -> HIT, 응답

- [1] 등록이 [2] 락 시도보다 먼저라 lost wakeup 이 없다.
  [2] 직후에 해제되어도 이미 등록된 대기자는 신호를 받는다.
- 신호를 놓쳐도 [3] 의 0.5초 타임아웃 뒤 [4] 로 가서 스스로 확인한다.

How to — 단계별 구현

① 채널 이름을 캐시 키에서 만든다. lock-released:{캐시키}. 락 키와 1:1입니다.

② 해제 스크립트에 PUBLISH를 넣는다. 4-2의 Lua 스크립트입니다. 해제에 성공했을 때만 발행합니다.

③ 앱 시작 시 리스너를 띄운다. PSUBSCRIBE lock-released:*를 한 번 하고, 서버가 구독 확인(psubscribe 메시지)을 보낸 뒤에 트래픽을 받습니다. 그 전에 발행된 신호는 받을 수 없기 때문입니다.

④ 대기표를 만든다. 채널 - Future 집합의 사전입니다. 신호가 오면 그 채널의 Future를 모두 완료시킵니다.

⑤ Loser는 등록 - 락 시도 - 대기 - 확인 순서를 지킨다. 대기는 asyncio.wait_for(future, min(0.5, 남은 시간))이고, 끝나면 반드시 등록을 지웁니다(finally).

⑥ 리스너가 끊기면 재연결하고, 대기자를 모두 깨운다. 끊긴 동안의 신호는 사라졌으므로 각자 캐시를 다시 확인하게 합니다.

⑦ 클러스터라면 채널 설계를 다시 본다. 일반 PUBLISH는 클러스터 전체로 퍼집니다. Redis 7.0부터는 샤드 안에서만 전달되는 SPUBLISH/SSUBSCRIBE(Sharded Pub/Sub)가 있어 버스 트래픽을 줄일 수 있습니다. 다만 샤드 채널은 패턴 구독을 지원하지 않으므로 리스너 구조가 달라집니다.

Python 구현 1 — 대기자마다 구독하는 단순형

개념을 익히기 좋은 형태입니다. Loser마다 PubSub 연결 하나를 씁니다.

대기자마다 구독

python
async def wait_for_release(r, key: str, channel: str, timeout: float):
    """구독 먼저, 확인 나중. 신호가 오거나 timeout 이 지나면 캐시를 확인해 돌려준다."""
    async with r.pubsub() as pubsub:                 # 전용 연결 하나를 점유한다
        await pubsub.subscribe(channel)              # (1) 구독 먼저
        cached = await r.get(key)                    # (2) 구독 직전에 이미 채워졌을 수 있다
        if cached is not None:
            return json.loads(cached)
        loop = asyncio.get_running_loop()
        deadline = loop.time() + timeout
        while (remaining := deadline - loop.time()) > 0:
            # 구독 확인 응답은 None 으로 걸러지므로, 실제 메시지가 올 때까지 남은 시간만큼 다시 기다린다
            message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=remaining)
            if message is not None:
                break                                # (3) 해제 신호
    cached = await r.get(key)                        # (4) 신호를 받았든 시간이 지났든 캐시를 확인
    return json.loads(cached) if cached is not None else None

Python 구현 2 — 프로세스당 리스너 하나 (권장)

Loser가 수천 명이면 단순형은 연결 수천 개를 씁니다. 리스너 하나가 모든 해제 신호를 받고, 프로세스 안에서 asyncio.Future로 나눠 줍니다. Loser의 대기는 메모리 안의 Future를 기다리는 것이므로 연결도 명령도 쓰지 않습니다.

LockReleaseNotifier — 7장 모듈의 일부

python
class LockReleaseNotifier:
    """프로세스당 Pub/Sub 연결 하나로 모든 락 해제 알림을 받아 대기자를 깨운다."""

    def __init__(self, redis: Redis, pattern: str = "lock-released:*") -> None:
        self.r = redis
        self.pattern = pattern
        self.ready = asyncio.Event()
        self._waiters: dict[str, set[asyncio.Future]] = {}
        self._task: asyncio.Task | None = None

    async def start(self, timeout: float = 3.0) -> None:
        self._task = asyncio.create_task(self._listen())
        await asyncio.wait_for(self.ready.wait(), timeout)  # 구독 확인 전에는 트래픽을 받지 않는다

    def register(self, channel: str) -> asyncio.Future:
        """락 획득을 시도하기 전에 등록한다. 시도와 대기 사이에 온 알림을 놓치지 않기 위해서다."""
        future = asyncio.get_running_loop().create_future()
        self._waiters.setdefault(channel, set()).add(future)
        return future

    async def _listen(self) -> None:
        backoff = 0.1
        while True:
            try:
                async with self.r.pubsub() as pubsub:
                    await pubsub.psubscribe(self.pattern)
                    async for message in pubsub.listen():
                        if message["type"] == "psubscribe":
                            self.ready.set()
                            backoff = 0.1
                        elif message["type"] == "pmessage":
                            self._wake(message["channel"])
            except asyncio.CancelledError:
                raise
            except (RedisError, OSError):
                self.ready.clear()
                for channel in list(self._waiters):  # 끊긴 동안의 알림은 사라졌다. 모두 깨워 다시 확인하게 한다
                    self._wake(channel)
                await asyncio.sleep(backoff)
                backoff = min(backoff * 2, 5.0)

    # unregister(), _wake(), stop() 은 7장 전체 코드를 보십시오.

Loser 루프 — Pub/Sub 대기

python
while True:
    future = notifier.register(channel)                          # [1] 등록 먼저
    try:
        if await r.set(lock_key, token, nx=True, px=lock_ttl_ms):  # [2] 락 시도
            ...                                                    # Winner 가 되었다
        remaining = deadline - loop.time()
        if remaining <= 0:
            raise CacheBusyError(key)
        try:
            await asyncio.wait_for(future, min(0.5, remaining))    # [3] 알림 또는 0.5초
        except TimeoutError:
            pass                                                   # 알림을 놓쳤어도 스스로 확인한다
    finally:
        notifier.unregister(channel, future)
    cached = await r.get(key)                                      # [4] 깨어나면 캐시부터
    if cached is not None:
        return json.loads(cached)

장단점

구분 내용
장점 기다리는 동안 명령이 0이다. Loser 1인당 명령 수가 대기 시간과 무관하게 거의 일정하다
해제 즉시 깨어난다. 폴링 간격만큼의 지연이 없어 대기자 응답이 가장 빠르다
원본이 느릴수록 이득이 커진다. 폴링은 대기 시간에 비례해 비싸지지만 알림은 그렇지 않다
단점 신호가 유실될 수 있다(at-most-once). 타임아웃 재확인이 없으면 대기자가 상한까지 멈춘다
구조가 복잡하다. 리스너 수명 관리, 구독 확인 대기, 재연결, 대기표 정리가 필요하다
연결 관리가 필요하다. 단순형은 대기자 수만큼 연결을 쓰고, 공유형도 프로세스마다 전용 연결 하나를 상시 점유한다
해제 신호가 몰리면 전원이 동시에 깨어난다. 캐시가 채워졌다면 GET 한 번씩으로 끝나지만, Winner가 실패해 캐시가 비었다면 깨어난 전원이 락을 다시 다툰다

6-4. 두 전략의 비교와 선택

비교 항목 Polling & Backoff Pub/Sub (공유 리스너)
Loser 1인당 Redis 명령 대기 시간에 비례 (로그 + 선형) 거의 일정 (등록 후 1 - 2회)
해제 후 응답까지 최대 cap 수 ms
신호 유실 해당 없음 있음. 타임아웃 재확인으로 보완
추가 연결 없음 프로세스당 1개
구현 난도 낮음 중간
어울리는 곳 원본이 빠르다(수백 ms 이하), Loser가 적다, 단순함이 중요하다 원본이 느리다(초 단위), 핫키에 Loser가 수천 명이다, 대기자 지연이 중요하다

실무 권장 — 알림을 기본으로, 폴링을 안전망으로

두 전략은 배타적이지 않습니다. Pub/Sub 알림으로 즉시 깨어나되, 알림이 없으면 짧은 주기로 스스로 확인하는 조합이 가장 튼튼합니다. 7장의 모듈이 이 구조이며, waiting="backoff"와 waiting="pubsub"을 호출마다 고를 수 있게 했습니다. 원본이 빠른 키는 backoff, 느리고 뜨거운 키는 pubsub으로 시작하십시오.

실측 — Loser 200명, 원본 조회 1초

아래 스크립트는 Winner 한 명이 1초 동안 원본을 조회하는 사이, 같은 프로세스의 Loser 200명이 대기 방식별로 캐시를 기다리게 합니다. 비교를 단순하게 하려고 Loser는 캐시 확인만 합니다(락 재시도 명령은 세지 않음).

loser_cost.py

python
"""Loser 200명이 대기 방식별로 쓰는 Redis 명령 수와, 해제 후 응답까지의 지연을 잰다."""

import asyncio
import json
import random
import statistics
import uuid

import redis.asyncio as redis

LOSERS = 200
HOLD = 1.0  # Winner 의 원본 조회 시간(초)
KEY = "ex73:demo:ranking"
LOCK = f"{KEY}:lock"
CHANNEL = f"lock-released:{KEY}"
RELEASE_AND_NOTIFY = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
  redis.call('DEL', KEYS[1])
  redis.call('PUBLISH', ARGV[2], '1')
  return 1
end
return 0
"""

r = redis.from_url("redis://localhost:6379", decode_responses=True)


async def winner(released: dict) -> None:
    token = uuid.uuid4().hex
    await r.set(LOCK, token, nx=True, px=5000)
    await asyncio.sleep(HOLD)
    await r.set(KEY, json.dumps({"top": ["A", "B", "C"]}), ex=60)
    released["at"] = asyncio.get_running_loop().time()
    await r.eval(RELEASE_AND_NOTIFY, 1, LOCK, token, CHANNEL)


async def fixed_polling(stats: dict) -> None:
    while True:
        stats["commands"] += 1
        if await r.get(KEY) is not None:
            return
        await asyncio.sleep(0.05)


async def backoff_polling(stats: dict) -> None:
    attempt = 0
    while True:
        stats["commands"] += 1
        if await r.get(KEY) is not None:
            return
        await asyncio.sleep(random.uniform(0, min(0.5, 0.02 * 2 ** min(attempt, 32))))
        attempt += 1


class Notifier:
    """프로세스당 구독 연결 하나로 해제 신호를 받아 대기자를 깨운다(6-3 구현 2의 축약판)."""

    def __init__(self) -> None:
        self.waiters: set[asyncio.Future] = set()
        self.ready = asyncio.Event()

    async def listen(self) -> None:
        async with r.pubsub() as pubsub:
            await pubsub.subscribe(CHANNEL)
            async for message in pubsub.listen():
                if message["type"] == "subscribe":
                    self.ready.set()
                elif message["type"] == "message":
                    for future in self.waiters:
                        if not future.done():
                            future.set_result(None)


async def pubsub_wait(stats: dict, notifier: Notifier) -> None:
    while True:
        future = asyncio.get_running_loop().create_future()
        notifier.waiters.add(future)  # 등록 먼저
        try:
            stats["commands"] += 1
            if await r.get(KEY) is not None:
                return
            try:
                await asyncio.wait_for(future, 0.5)  # 알림 또는 0.5초
            except TimeoutError:
                pass
        finally:
            notifier.waiters.discard(future)


async def run(label: str, make_loser) -> None:
    await r.delete(KEY, LOCK)
    stats, released, done_at = {"commands": 0}, {}, []
    loop = asyncio.get_running_loop()

    async def loser() -> None:
        await make_loser(stats)
        done_at.append(loop.time())

    await asyncio.gather(winner(released), *[loser() for _ in range(LOSERS)])
    lag = [max(0.0, t - released["at"]) * 1000 for t in done_at]
    print(
        f"{label:<16} Redis 명령 {stats['commands']:>5}회 (1인당 {stats['commands'] / LOSERS:4.1f}) | "
        f"해제 후 응답까지 p50 {statistics.median(lag):5.1f}ms, max {max(lag):5.1f}ms"
    )


async def main() -> None:
    await run("fixed 50ms", fixed_polling)
    await run("backoff+jitter", backoff_polling)
    notifier = Notifier()
    listener = asyncio.create_task(notifier.listen())
    await notifier.ready.wait()
    await run("pubsub", lambda stats: pubsub_wait(stats, notifier))
    listener.cancel()
    await r.delete(KEY)
    await r.aclose()


asyncio.run(main())
bash
$ python loser_cost.py
fixed 50ms       Redis 명령  3600회 (1인당 18.0) | 해제 후 응답까지 p50  47.7ms, max  62.5ms
backoff+jitter   Redis 명령  1835회 (1인당  9.2) | 해제 후 응답까지 p50 152.8ms, max 457.0ms
pubsub           Redis 명령   600회 (1인당  3.0) | 해제 후 응답까지 p50  17.5ms, max  33.1ms

결과 읽기

명령 수: 1초 대기에 고정 폴링은 1인당 18회, 백오프+지터는 9.2회, Pub/Sub은 3회입니다. 6-1의 계산(20회, 8 - 9회, 2 - 3회)과 맞습니다. Pub/Sub이 2회가 아니라 3회인 것은 원본 조회(1초)가 안전망 주기(0.5초)보다 길어 알림 전에 한 번 스스로 확인했기 때문입니다. 응답 지연: 캐시가 채워진 뒤 응답까지 백오프는 p50 153ms, 최대 457ms로 cap(500ms) 근처까지 늦었고, Pub/Sub은 p50 18ms로 가장 빨랐습니다. 고정 폴링은 50ms 간격만큼 늦습니다. 의미: 백오프는 명령을 절반으로 줄이는 대신 응답이 늦어지고, Pub/Sub은 명령과 지연을 함께 줄입니다. 원본이 느리고 Loser가 많을수록 이 차이는 커집니다. 두 번 실행해도 명령 수는 같았고 지연은 수 ms 안에서 달라졌습니다.

7. 파이썬 모듈로 구현하기

3장부터 6장까지의 조각을 cache_lock.py 모듈 하나로 조립합니다. 애플리케이션 코드는 이 모듈의 get_or_load() 한 함수만 부르면 1차 확인, 락, 이중 확인, 워치독, 대기, 해제와 알림이 모두 처리됩니다.

7-1. 모듈 설계

구성 요소 역할
CacheLock.get_or_load(key, loader, ttl, *, waiting, watchdog, lock_ttl, max_wait) (값, 출처)를 돌려준다. 출처는 cache(1차 확인 적중), origin(내가 원본 조회), double_check(락을 얻고 보니 채워져 있음), waited(기다린 뒤 캐시 적중)
LockReleaseNotifier 프로세스당 구독 연결 하나로 해제 신호를 받아 대기자를 깨운다(6-3)
full_jitter_delays() Full Jitter 대기 시간 생성기(6-2)
CacheBusyError 대기 상한 초과. 웹 계층에서 503으로 바꾼다(4-4)
LockStats 락 획득 · 경합 · 대기 확인 · 알림 · 이중 확인 적중 · 타임아웃 · 연장 · 소유권 상실 횟수(11장 지표)

7-2. How to — 단계별 구현

① Lua 스크립트 두 개를 등록한다. 해제+알림(4-2), 연장(4-3). register_script는 EVALSHA로 실행하고, 서버에 스크립트가 없으면 자동으로 다시 올립니다.

② 1차 확인을 둔다. 적중이면 곧바로 (값, "cache")를 돌려줍니다.

③ 요청마다 토큰과 마감 시각을 만든다. uuid.uuid4().hex, loop.time() + max_wait. 마감 시각은 벽시계가 아니라 이벤트 루프의 단조 시계로 잽니다.

④ 대기 루프를 쓴다. pubsub 대기라면 매 반복 첫 줄에서 대기표에 등록하고, SET NX PX를 시도합니다.

⑤ Winner 경로: 워치독 시작 - 이중 확인 - 원본 조회 - 캐시 저장, finally에서 워치독 취소 후 해제+알림. 해제 결과가 0이면 소유권 상실로 기록합니다.

⑥ Loser 경로: 상한 검사 - 대기(백오프 또는 알림) - 등록 해제 - 캐시 확인. 적중이면 (값, "waited"), 아니면 4단계로 돌아가 락을 다시 시도합니다.

⑦ 지표를 센다. 각 분기에서 LockStats의 해당 필드를 1씩 올립니다.

7-3. 전체 코드

cache_lock.py

python
"""캐시 재계산 락: 1차 확인 -> 락 획득 -> 이중 확인 -> 원본 조회 -> 캐시 저장 -> 해제 + 알림."""

from __future__ import annotations

import asyncio
import contextlib
import json
import logging
import random
import uuid
from collections.abc import Awaitable, Callable, Iterator
from dataclasses import asdict, dataclass
from typing import Any, Literal

from redis.asyncio import Redis
from redis.exceptions import RedisError

log = logging.getLogger(__name__)

Loader = Callable[[], Awaitable[Any]]
Waiting = Literal["backoff", "pubsub"]

# 토큰이 내 것일 때만 락을 지우고, 지웠다면 같은 원자 실행 안에서 "락이 풀렸다"를 발행한다.
RELEASE_AND_NOTIFY_LUA = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
  redis.call('DEL', KEYS[1])
  redis.call('PUBLISH', ARGV[2], '1')
  return 1
end
return 0
"""

# 토큰이 내 것일 때만 TTL 을 되돌린다(워치독).
EXTEND_LUA = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('PEXPIRE', KEYS[1], ARGV[2])
end
return 0
"""


class CacheBusyError(Exception):
    """대기 상한을 넘었다. 호출자는 HTTP 503 + Retry-After 로 응답한다."""


def full_jitter_delays(base: float = 0.02, cap: float = 0.5) -> Iterator[float]:
    """AWS Full Jitter: n 번째 대기 = random(0, min(cap, base * 2^n))."""
    attempt = 0
    while True:
        yield random.uniform(0.0, min(cap, base * 2 ** min(attempt, 32)))
        attempt += 1


@dataclass
class LockStats:
    acquired: int = 0  # 락을 잡은 횟수(Winner)
    contended: int = 0  # 락 획득에 실패한 횟수
    waiter_reads: int = 0  # 대기자가 깨어나 캐시를 다시 읽은 횟수
    wakeups: int = 0  # Pub/Sub 알림으로 깨어난 횟수
    double_check_hits: int = 0  # 락을 잡고 보니 캐시가 이미 채워져 원본을 건너뛴 횟수
    timeouts: int = 0  # 대기 상한 초과
    extensions: int = 0  # 워치독 연장 성공
    lost_ownership: int = 0  # 해제나 연장 시점에 이미 락을 잃은 횟수

    def as_dict(self) -> dict[str, int]:
        return asdict(self)


class LockReleaseNotifier:
    """프로세스당 Pub/Sub 연결 하나로 모든 락 해제 알림을 받아 대기자를 깨운다."""

    def __init__(self, redis: Redis, pattern: str = "lock-released:*") -> None:
        self.r = redis
        self.pattern = pattern
        self.ready = asyncio.Event()
        self._waiters: dict[str, set[asyncio.Future]] = {}
        self._task: asyncio.Task | None = None

    async def start(self, timeout: float = 3.0) -> None:
        self._task = asyncio.create_task(self._listen())
        await asyncio.wait_for(self.ready.wait(), timeout)  # 구독 확인 전에는 트래픽을 받지 않는다

    async def stop(self) -> None:
        if self._task is not None:
            self._task.cancel()
            with contextlib.suppress(asyncio.CancelledError):
                await self._task

    def register(self, channel: str) -> asyncio.Future:
        """락 획득을 시도하기 전에 등록한다. 시도와 대기 사이에 온 알림을 놓치지 않기 위해서다."""
        future = asyncio.get_running_loop().create_future()
        self._waiters.setdefault(channel, set()).add(future)
        return future

    def unregister(self, channel: str, future: asyncio.Future) -> None:
        waiters = self._waiters.get(channel)
        if waiters is not None:
            waiters.discard(future)
            if not waiters:
                del self._waiters[channel]

    def _wake(self, channel: str) -> None:
        for future in self._waiters.pop(channel, ()):
            if not future.done():
                future.set_result(None)

    async def _listen(self) -> None:
        backoff = 0.1
        while True:
            try:
                async with self.r.pubsub() as pubsub:
                    await pubsub.psubscribe(self.pattern)
                    async for message in pubsub.listen():
                        if message["type"] == "psubscribe":
                            self.ready.set()
                            backoff = 0.1
                        elif message["type"] == "pmessage":
                            self._wake(message["channel"])
            except asyncio.CancelledError:
                raise
            except (RedisError, OSError):
                log.warning("lock notifier disconnected; reconnecting", exc_info=True)
                self.ready.clear()
                for channel in list(self._waiters):  # 끊긴 동안의 알림은 사라졌다. 모두 깨워 다시 확인하게 한다
                    self._wake(channel)
                await asyncio.sleep(backoff)
                backoff = min(backoff * 2, 5.0)


class CacheLock:
    """캐시 미스 시 한 요청만 원본을 조회하게 하는 분산 락."""

    def __init__(
        self,
        redis: Redis,
        *,
        notifier: LockReleaseNotifier | None = None,
        channel_prefix: str = "lock-released:",
        lock_ttl: float = 5.0,
        max_wait: float = 2.0,
        backoff_base: float = 0.02,
        backoff_cap: float = 0.5,
        pubsub_fallback: float = 0.5,
        max_hold: float = 30.0,
    ) -> None:
        self.r = redis
        self.notifier = notifier
        self.channel_prefix = channel_prefix
        self.lock_ttl = lock_ttl
        self.max_wait = max_wait
        self.backoff_base = backoff_base
        self.backoff_cap = backoff_cap
        self.pubsub_fallback = pubsub_fallback
        self.max_hold = max_hold
        self.stats = LockStats()
        self._release = redis.register_script(RELEASE_AND_NOTIFY_LUA)
        self._extend = redis.register_script(EXTEND_LUA)

    def channel(self, key: str) -> str:
        return f"{self.channel_prefix}{key}"

    async def get_or_load(
        self,
        key: str,
        loader: Loader,
        ttl: int,
        *,
        waiting: Waiting = "backoff",
        watchdog: bool = False,
        lock_ttl: float | None = None,
        max_wait: float | None = None,
    ) -> tuple[Any, str]:
        """(값, 출처)를 돌려준다. 출처: cache / origin / double_check / waited."""
        raw = await self.r.get(key)  # 1차 확인
        if raw is not None:
            return json.loads(raw), "cache"

        if waiting == "pubsub" and self.notifier is None:
            raise ValueError("pubsub waiting needs a LockReleaseNotifier")
        lock_ttl = self.lock_ttl if lock_ttl is None else lock_ttl
        max_wait = self.max_wait if max_wait is None else max_wait
        lock_key = f"{key}:lock"
        channel = self.channel(key)
        token = uuid.uuid4().hex
        loop = asyncio.get_running_loop()
        deadline = loop.time() + max_wait
        delays = full_jitter_delays(self.backoff_base, self.backoff_cap)

        while True:
            future = self.notifier.register(channel) if waiting == "pubsub" else None
            try:
                # 락 획득: NX 로 한 명만, PX 로 소유자가 죽어도 풀리게. 한 명령이라 원자적이다.
                if await self.r.set(lock_key, token, nx=True, px=int(lock_ttl * 1000)):
                    self.stats.acquired += 1
                    return await self._load_as_winner(
                        key, lock_key, token, channel, loader, ttl, lock_ttl, watchdog
                    )
                self.stats.contended += 1

                remaining = deadline - loop.time()
                if remaining <= 0:
                    self.stats.timeouts += 1
                    raise CacheBusyError(key)  # DB 로 직행하지 않는다
                if future is None:
                    await asyncio.sleep(min(next(delays), remaining))
                else:
                    try:
                        # 알림이 오면 즉시 깨어난다. 알림을 잃어도 fallback 주기마다 다시 확인한다.
                        await asyncio.wait_for(future, min(self.pubsub_fallback, remaining))
                        self.stats.wakeups += 1
                    except TimeoutError:
                        pass
            finally:
                if future is not None:
                    self.notifier.unregister(channel, future)

            # 깨어난 대기자는 락보다 캐시를 먼저 본다. 이미 채워졌으면 락 경쟁에 끼지 않는다.
            self.stats.waiter_reads += 1
            raw = await self.r.get(key)
            if raw is not None:
                return json.loads(raw), "waited"

    async def _load_as_winner(
        self,
        key: str,
        lock_key: str,
        token: str,
        channel: str,
        loader: Loader,
        ttl: int,
        lock_ttl: float,
        watchdog: bool,
    ) -> tuple[Any, str]:
        lock_ttl_ms = int(lock_ttl * 1000)
        guard = asyncio.create_task(self._watchdog(lock_key, token, lock_ttl_ms)) if watchdog else None
        try:
            raw = await self.r.get(key)  # 이중 확인: 앞선 Winner 가 채우고 떠났을 수 있다
            if raw is not None:
                self.stats.double_check_hits += 1
                return json.loads(raw), "double_check"
            value = await loader()
            await self.r.set(key, json.dumps(value), ex=ttl)
            return value, "origin"
        finally:
            if guard is not None:  # 해제보다 먼저 워치독을 멈춘다
                guard.cancel()
                with contextlib.suppress(asyncio.CancelledError):
                    await guard
            if not await self._release(keys=[lock_key], args=[token, channel]):
                self.stats.lost_ownership += 1
                log.warning("lock expired before release (owned by someone else now): %s", lock_key)

    async def _watchdog(self, lock_key: str, token: str, lock_ttl_ms: int) -> None:
        """TTL 의 1/3 마다 연장한다. 소유권을 잃었거나 max_hold 를 넘기면 멈춘다."""
        loop = asyncio.get_running_loop()
        stop_at = loop.time() + self.max_hold
        while loop.time() < stop_at:
            await asyncio.sleep(lock_ttl_ms / 3000)
            try:
                if not await self._extend(keys=[lock_key], args=[token, lock_ttl_ms]):
                    self.stats.lost_ownership += 1
                    return
                self.stats.extensions += 1
            except RedisError:
                log.warning("lock extend failed: %s", lock_key, exc_info=True)

코드에서 놓치기 쉬운 네 줄

  • future = self.notifier.register(channel)가 SET NX보다 먼저입니다. 순서가 바뀌면 lost wakeup이 생깁니다.
  • raise CacheBusyError(key): 상한을 넘은 Loser는 loader를 부르지 않습니다.
  • guard.cancel()이 해제보다 먼저입니다. 해제 뒤에 연장이 실행되면 해제한 락을 되살리는 것이 아니라(토큰이 달라 0) 불필요한 명령이 나갈 뿐이지만, 순서를 지키면 그런 일 자체가 없습니다.
  • 해제 반환값 0을 기록합니다. 소유권 상실은 락 TTL이 원본 조회보다 짧다는 가장 직접적인 신호입니다.

8. FastAPI에서 모듈로 사용하기

8-1. How to — 단계별 통합

① lifespan에서 Redis 클라이언트를 하나 만든다. 요청마다 만들지 않습니다. 커넥션 풀을 공유합니다.

② 리스너를 시작하고 구독 확인을 기다린 뒤에 트래픽을 받는다. await notifier.start()가 끝나기 전에는 lifespan이 yield하지 않으므로 서버가 요청을 받지 않습니다.

③ CacheLock을 app.state에 두고 의존성으로 꺼낸다. 테스트에서 가짜로 바꾸기 쉽습니다.

④ CacheBusyError를 503 + Retry-After로 바꾸는 예외 처리기를 등록한다. 엔드포인트마다 try를 쓰지 않습니다.

⑤ 엔드포인트는 loader만 넘긴다. 캐시 키, TTL(지터 포함), 대기 방식만 정합니다.

⑥ 반복되면 데코레이터로 감싼다. 키는 해시로 줄이되 사람이 읽을 수 있는 접두어를 붙입니다(유닛 6-1 네이밍).

⑦ 종료 시 리스너를 먼저 멈추고 클라이언트를 닫는다.

8-2. 전체 코드

main.py

python
"""FastAPI 에서 cache_lock 모듈을 쓰는 예. 실행: uvicorn main:app --port 8000"""

import asyncio
import functools
import hashlib
import random
import time
from contextlib import asynccontextmanager
from typing import Annotated, Literal

import redis.asyncio as redis
from fastapi import Depends, FastAPI, Request
from fastapi.responses import JSONResponse

from cache_lock import CacheBusyError, CacheLock, LockReleaseNotifier


@asynccontextmanager
async def lifespan(app: FastAPI):
    r = redis.from_url("redis://localhost:6379", decode_responses=True)
    notifier = LockReleaseNotifier(r)
    await notifier.start()  # 구독 확인 후에 트래픽을 받는다
    app.state.redis = r
    app.state.locks = CacheLock(r, notifier=notifier, lock_ttl=5.0, max_wait=2.0)
    app.state.db_calls = 0
    try:
        yield
    finally:
        await notifier.stop()
        await r.aclose()


app = FastAPI(lifespan=lifespan)


def get_locks(request: Request) -> CacheLock:
    return request.app.state.locks


Locks = Annotated[CacheLock, Depends(get_locks)]


@app.exception_handler(CacheBusyError)
async def cache_busy(request: Request, exc: CacheBusyError) -> JSONResponse:
    return JSONResponse(
        status_code=503,
        content={"detail": "cache is being rebuilt; retry shortly"},
        headers={"Retry-After": "1"},
    )


def ttl_with_jitter(base: int, ratio: float = 0.2) -> int:
    delta = int(base * ratio)
    return base + random.randint(-delta, delta)


async def load_ranking(board: str) -> dict:
    """무거운 집계 쿼리를 흉내 낸다."""
    app.state.db_calls += 1
    await asyncio.sleep(0.3)
    return {"board": board, "top": ["A", "B", "C"], "generated_at": time.time()}


@app.get("/rankings/{board}")
async def ranking(board: str, locks: Locks, waiting: Literal["backoff", "pubsub"] = "pubsub"):
    value, source = await locks.get_or_load(
        f"ranking:{board}", lambda: load_ranking(board), ttl_with_jitter(60), waiting=waiting
    )
    return {"source": source, "db_calls": app.state.db_calls, "data": value}


def cached(prefix: str, ttl: int, waiting: Literal["backoff", "pubsub"] = "pubsub"):
    """위치 인자로 키를 만들어 스탬피드 방어와 함께 캐싱하는 데코레이터."""

    def decorator(func):
        @functools.wraps(func)
        async def wrapper(*args):
            digest = hashlib.sha1(repr(args).encode()).hexdigest()[:16]
            key = f"{prefix}:{digest}"  # 사람이 읽을 수 있는 접두어 + 해시
            value, _ = await app.state.locks.get_or_load(
                key, lambda: func(*args), ttl_with_jitter(ttl), waiting=waiting
            )
            return value

        return wrapper

    return decorator


@cached("product-detail", ttl=300)
async def product_detail(product_id: int) -> dict:
    app.state.db_calls += 1
    await asyncio.sleep(0.2)
    return {"id": product_id, "name": f"product-{product_id}"}


@app.get("/products/{product_id}")
async def product(product_id: int):
    return {"data": await product_detail(product_id), "db_calls": app.state.db_calls}


@app.get("/stats")
async def stats(locks: Locks):
    return {"db_calls": app.state.db_calls, "lock": locks.stats.as_dict()}

검증 결과 (httpx ASGITransport로 앱을 직접 호출)

캐시를 비운 상태에서 동시 요청을 보낸 결과입니다. /rankings/weekly(pubsub) 200건: DB 조회 1회, 출처는 origin 1 + waited 199. /rankings/daily?waiting=backoff 200건: DB 조회 1회. 데코레이터를 쓴 /products/7 100건: DB 조회 1회, 캐시 TTL은 지터 범위(240 - 360초) 안. 락을 잡은 채 죽은 소유자를 흉내 내어 락 키를 5초짜리로 미리 만들어 두면, 20건 모두 대기 상한(2초) 뒤 503 + Retry-After: 1이고 DB 조회는 없었습니다.

데코레이터 키 설계 주의

  • 키워드 인자를 받지 않게 했습니다. f(1, lang="ko")와 f(1, "ko")가 다른 키가 되는 혼란을 막기 위해서입니다. 받아야 한다면 inspect.signature(func).bind(...)로 정규화한 뒤 키를 만드십시오.
  • repr(args)가 안정적인 타입만 넘기십시오. 객체의 기본 repr에는 메모리 주소가 들어가 요청마다 키가 달라집니다.
  • 반환값은 JSON으로 직렬화할 수 있어야 합니다. 모듈이 json.dumps로 저장합니다.

9. redis-py 내장 Lock과 비교

redis-py에는 r.lock()으로 쓰는 Lock 클래스가 있습니다. 토큰 저장과 Lua 비교 해제를 이미 구현하고 있어 정합성 락의 기본 도구로 좋습니다. 다만 캐시 스탬피드 방어에 그대로 쓰기 전에 알아야 할 점이 있습니다. 아래는 redis-py 6.4.0 소스에서 확인한 내용입니다.

항목 redis-py Lock 이 글의 CacheLock
획득 SET name token NX PX timeout. timeout=None(기본값)이면 TTL이 없다 항상 PX
대기 sleep(기본 0.1초) 고정 간격 폴링. 백오프와 지터 없음. 기다리는 대상은 락 Full Jitter 백오프 또는 Pub/Sub. 기다리는 대상은 캐시
대기 상한 blocking_timeout. 초과하면 acquire()는 False, async with는 LockError max_wait, 초과 시 CacheBusyError
이중 확인 없음(범용 락이므로 캐시를 모른다). 직접 넣지 않으면 순차적 스탬피드 내장
연장 extend(추가 시간), reacquire()를 직접 호출. 자동 워치독은 없다 watchdog=True면 자동 연장, max_hold 상한
해제 실패 남의 락이면 LockNotOwnedError(LockError의 하위 클래스) 반환값 0을 지표로 기록
재진입 지원하지 않는다. 같은 코드가 다시 acquire()하면 자기 락에 막힌다 지원하지 않는다(캐시 적재에 필요 없음)
토큰 보관 thread_local=True(기본값)면 스레드 로컬. asyncio 태스크는 모두 같은 스레드라 태스크끼리 분리되지 않는다 호출마다 지역 변수

redis-py Lock을 캐시에 쓸 때의 올바른 형태

python
lock = r.lock(
    f"{key}:lock",
    timeout=5,            # 반드시 지정한다. None 이면 TTL 없는 락이다
    sleep=0.05,           # 고정 폴링 간격
    blocking_timeout=2,   # 대기 상한
    thread_local=False,   # asyncio 에서는 의미가 없다. 대신 요청마다 Lock 객체를 새로 만든다
)
try:
    async with lock:
        cached = await r.get(key)          # 이중 확인은 직접 넣는다
        if cached is None:
            value = await loader()
            await r.set(key, json.dumps(value), ex=300)
except LockError:
    raise CacheBusyError(key)              # 대기 상한 초과(LockNotOwnedError 도 LockError 의 하위 클래스)

선택 기준

정합성 락(짧은 임계 구역, 대기자가 적음)이라면 redis-py Lock으로 충분합니다. 캐시 스탬피드 방어(핫키, 대기자 수천 명, 락이 아니라 캐시를 기다려야 함)라면 이 글의 구조처럼 "캐시를 기다리는 대기 + 이중 확인 + 알림"을 갖춘 전용 경로가 맞습니다. 5-4의 실측이 그 차이입니다.

10. 실습 — 예제 저장소로 방어 효과 측정

example/part7/7-3-locking 예제는 이 글의 모듈을 서버에 넣고, 시나리오별로 원본 호출 수와 대기자 비용을 비교합니다. 명령은 example 폴더에서 실행합니다.

① redis-cli 사전 테스트를 코드로 확인

bash
$ PYTHONPATH=. uv run python part7/7-3-locking/basic.py
1) acquire: {'first': True, 'second': None}
2) pttl(ms): 5000
3) extend: {'other': 0, 'mine': 1}
4) release with other token: 0 exists: 1
5) release with my token: 1 exists: 0
6) waiter received: {'channel': 'ex73:lock-released:ex73:basic:product:1', 'data': '1'}
7) publish after unsubscribe -> receivers: 0
8) full jitter delays(ms): [6, 6, 52, 12, 171, 183, 29, 254]

② 서버 실행 (원본 조회 1초)

bash
$ ORIGIN_DELAY=1.0 uv run uvicorn server.server:app --app-dir part7/7-3-locking/scenario_examples --port 8003

③ 부하 클라이언트 실행 (시나리오별 동시 요청 200건)

bash
$ uv run python part7/7-3-locking/scenario_examples/client/load_client.py
200 concurrent requests per scenario against http://127.0.0.1:8003

mutex double_check=false   origin   3 | waiters 197 | redis calls/waiter     - | p50  2472.8ms | max  3111.7ms | status {200: 3, 503: 197} | sources {'origin': 3}
mutex double_check=true    origin   1 | waiters  20 | redis calls/waiter     - | p50  3641.6ms | max  4940.8ms | status {200: 200} | sources {'origin': 1, 'double_check': 20, 'cache': 179}
                           {'double_check_hits': 20}
backoff                    origin   1 | waiters 199 | redis calls/waiter  12.9 | p50  1474.5ms | max  2050.7ms | status {200: 200} | sources {'origin': 1, 'waited': 199}
pubsub                     origin   1 | waiters  19 | redis calls/waiter   3.1 | p50  3763.4ms | max  5162.8ms | status {200: 200} | sources {'origin': 1, 'waited': 19, 'cache': 180}
                           {'wakeups': 19}
pubsub + crashed owner     origin   1 | waiters 199 | redis calls/waiter   5.8 | p50  2253.6ms | max  2795.4ms | status {200: 200} | sources {'waited': 199, 'origin': 1}
                           {'wakeups': 199}
report watchdog=false      origin   3 | waiters  47 | redis calls/waiter  18.7 | p50  2906.6ms | max  4702.5ms | status {200: 50} | sources {'origin': 3, 'waited': 46, 'double_check': 1}
                           {'double_check_hits': 1, 'lost_ownership': 3}
report watchdog=true       origin   1 | waiters  49 | redis calls/waiter  25.4 | p50  2744.9ms | max  3068.5ms | status {200: 50} | sources {'origin': 1, 'waited': 49}
                           {'extensions': 7}

④ 결과 읽기

줄 확인할 것 장
mutex double_check=false redis-py 내장 Lock으로 락 자체를 기다리면 락을 넘겨받은 요청마다 원본을 1초씩 조회합니다(순차적 스탬피드). 대기 상한 2초 안에 3명만 응답하고 나머지 197건은 503입니다 5-1
mutex double_check=true 이중 확인만 넣어도 원본 호출은 1회입니다. 락을 넘겨받은 20건(double_check)은 캐시 값을 읽고 바로 락을 돌려줬습니다 5-2
backoff, pubsub 둘 다 원본 1회. 대기자 1인당 Redis 명령이 백오프 약 13회, Pub/Sub 약 3회입니다 6-2, 6-3
pubsub + crashed owner 해제 명령 없이 죽은 소유자의 락이 TTL(1초) 뒤 풀리고 다음 요청이 이어받아 원본 1회로 복구됐습니다. 소유자가 죽어 알림이 없는 동안은 0.5초 안전망 확인으로 버티다가, 새 Winner의 해제 알림으로 199명 모두 깨어났습니다(wakeups) 4-1, 6-3
report watchdog=false 원본(2.5초)이 락 TTL(1초)보다 길어 조회 도중 락이 넘어갔습니다. 원본 3회. 늦게 끝난 소유자의 해제는 남의 락을 지우지 않고 lost_ownership으로 기록됐습니다 4-2, 4-3
report watchdog=true 워치독이 락을 7번 연장해 원본 1회입니다. 대기자 1인당 명령이 큰 것은 이 경로가 백오프 대기이고 대기가 2.5초로 길었기 때문입니다 4-3, 6-2

지연 수치를 읽을 때 주의

클라이언트가 HTTP 연결 200개를 여는 동안 요청이 서버에 순차적으로 도착하므로, 일부 요청은 캐시가 채워진 뒤에 도착해 cache로 응답합니다. 그래서 줄마다 실제 대기자 수(waiters)가 다릅니다. 대기 방식끼리는 총 명령 수가 아니라 대기자 1인당 명령 수(redis calls/waiter)로 비교하십시오. 모든 요청이 정확히 동시에 도착하는 조건의 비교는 6-4의 loser_cost.py와 예제의 integration 테스트가 담당합니다.

11. 운영 점검 — 실패 모드와 지표

증상 원인 대응
원본 호출이 여전히 키당 여러 번 락 TTL < 원본 조회 시간(lost_ownership 증가) TTL을 p99의 3배로, 또는 watchdog=True
double_check_hits가 크다 Loser가 락을 기다리는 구조이거나 1차 확인이 없다 캐시를 기다리는 대기(6장)로 바꾼다
503(timeouts)이 잦다 max_wait가 원본 조회보다 짧거나, 소유자가 자주 죽는다 max_wait를 원본 p99보다 길게. 소유자 예외 로그 확인
Redis 명령 수가 캐시 미스 때마다 급증 폴링 대기 + 느린 원본 + 많은 Loser 핫키는 waiting="pubsub"
Pub/Sub 대기자가 알림 없이 0.5초마다 깨어난다(wakeups가 0에 가깝다) 리스너가 끊겼거나 채널 이름이 해제 스크립트와 다르다 리스너 로그, PUBSUB NUMPAT, 채널 접두어 확인
대기 중 클라이언트가 먼저 끊는다 max_wait > HTTP 타임아웃 max_wait < 클라이언트 타임아웃
락이 오래 남아 키가 채워지지 않는다 원본이 멈췄는데 워치독이 연장 max_hold 확인, 원본 호출에도 타임아웃
Redis 장애 시 전체 오류 캐시 · 락 모두 사용 불가 프로세스 내 SingleFlight + DB 동시 접근 상한으로 강등(유닛 7-2 5장)

11-1. 남겨야 할 지표

지표 (LockStats) 정상 모습과 경보 기준
원본 호출 수 / 캐시 미스 수 키당 1에 수렴해야 한다. 1보다 크면 락이 새고 있다
acquired 대비 contended 핫키 만료 순간에는 contended가 훨씬 커야 정상. acquired만 크면 락이 동작하지 않는다
(contended + waiter_reads) / 대기자 수 대기자 1인당 Redis 명령. 폴링은 수 - 수십, Pub/Sub은 2 - 3 근처
wakeups Pub/Sub 대기자 수와 비슷해야 한다. 0에 가까우면 알림 경로 고장
double_check_hits 작을수록 좋다. 크면 대기 구조를 점검
timeouts 0에 가까워야 한다. 증가 시 즉시 원인 분석
lost_ownership, extensions lost_ownership은 0이어야 한다. extensions가 꾸준히 크면 원본이 느려지고 있다는 신호

12. 정리

구성 요소 막는 문제
SET key token NX PX 동시 획득(상호 배제), 소유자 사망 시 영구 락(Lock TTL)
요청별 토큰 + Lua 해제 남의 락 해제
해제와 PUBLISH를 한 스크립트에 알림 후 남아 있는 락과의 충돌
워치독 + max_hold 작업보다 짧은 TTL, 무한 연장
1차 확인 + 이중 확인 불필요한 락 경쟁, 순차적 스탬피드
Polling & Backoff (Full Jitter) 대기자의 동기화된 폴링 파도
Pub/Sub 공유 리스너 + 타임아웃 재확인 폴링 비용, 대기 지연, 알림 유실, 연결 폭증
max_wait + 503 무한 대기, DB 직행으로 인한 지연된 스탬피드

12-1. Locking 전략의 장단점 총괄

구분 내용
장점 서버가 몇 대든 원본 조회를 키당 1회로 줄인다. 콜드 스타트에서도 동작하는 방어다
낡은 값을 응답하지 않는다. 가격 · 재고 표시처럼 최신성이 중요한 캐시에 맞다
Redis 명령 몇 개와 Lua 두 개로 구현되어 인프라 추가가 없다
단점 대기자의 응답이 원본 조회 시간만큼 늦다. 대기 자체를 없애려면 유닛 7-2의 PER · SWR과 조합해야 한다
대기 방식 · TTL · 상한 · 알림 경로 등 조정할 값과 실패 모드가 많다
Redis가 락과 캐시의 공통 의존점이 된다. 장애 시 강등 경로를 따로 설계해야 한다

한 줄 요약

캐시 스탬피드 방어용 락의 목표는 완벽한 상호 배제가 아니라 원본 조회를 한 번으로 줄이면서 나머지를 싸게 기다리게 하는 것입니다. TTL이 있는 원자적 획득, 토큰 해제, 이중 확인으로 Winner를 안전하게 만들고, 백오프와 Pub/Sub 알림으로 Loser를 싸게 만들고, 대기 상한으로 락이 새로운 장애 원인이 되지 않게 하십시오.


7-4. GEO — 좌표를 넣고 반경 검색을 밀리초로

이 글에서 다룰 위치 검색

  • Redis GEO 기능의 탄생 배경과 기본 특징을 파악합니다.
  • Redis가 위도와 경도 같은 위치 데이터를 어떻게 저장하는지 원리(Geohash)를 이해합니다.
  • GEO 기능이 사실은 우리가 앞서 배운 Sorted Sets의 응용이라는 점을 파악합니다.
  • 위치 데이터를 다루는 기본 문법을 익히고, CLI 환경에서 테스트해 봅니다.
  • Redis GEO의 한계점을 인지하고, 실무에서 쓰이는 전문 공간 데이터베이스(Spatial DB)와의 역할 분담을 배웁니다.

1. Redis가 위치 데이터를 다루는 방식

Redis GEO는 위도(Latitude) 와 경도(Longitude) 로 이루어진 공간(위치) 데이터를 메모리에 저장하고, 이를 기반으로 다양한 공간 연산을 빠르게 수행할 수 있게 해주는 기능입니다.

  • 공식 명칭: Geospatial(지리공간) 인덱스.
  • 출시: 2016년에 발표된 Redis 3.2 버전부터 코어 엔진에 공식적으로 도입.
  • 탄생 배경:
    • 위치 기반 서비스가 늘어나면서, 메모리 기반의 빠른 근접 검색에 대한 수요가 증가했습니다.
    • 공간 DB를 사용하지 않는 환경에서는 위도·경도를 기반으로 거리 계산(Haversine 공식 등)을 직접 수행해야 했습니다.
    • PostGIS 같은 공간 데이터베이스를 활용하면 인덱스를 기반으로 효율적인 공간 검색과 정확한 연산이 가능하지만, 배달·택시와 같은 실시간 서비스에서는 이러한 공간 쿼리를 높은 빈도로 반복 수행해야 하므로 부담이 될 수 있습니다.
    • 특히 데이터 규모가 커지고 요청이 급증하는 환경에서는 CPU 부하와 처리 비용이 증가하면서 전체 시스템 성능에 영향을 줄 수 있습니다.
    • Redis GEO는 이 문제를 다른 방식으로 접근합니다. 좌표를 Geohash로 변환해 정렬 가능한 값으로 만들고, 이를 Sorted Set 기반으로 관리하며 메모리(In-memory)에서 처리함으로써 근처 데이터를 아주 빠르게 탐색할 수 있도록 설계되었습니다. 이를 통해 실시간 위치 검색을 효율적으로 처리할 수 있습니다.

💡 TIP. 위도(Latitude)와 경도(Longitude)

위도 (Latitude / Y축) - 적도를 기준으로 위쪽(북쪽) / 아래쪽(남쪽) 위치를 나타냅니다. - 즉, 얼마나 위나 아래에 있는지를 나타내는 값(각도)입니다. (예: 서울은 북위 약 37도) - (적도) 지구를 북반구와 남반구로 나누는 기준선

경도 (Longitude / X축) - 본초자오선을 기준으로 오른쪽(동쪽) / 왼쪽(서쪽) 위치를 나타냅니다. - 즉, 얼마나 좌우로 떨어져 있는지를 나타내는 값(각도)입니다. (예: 서울은 동경 약 127도) - (본초자오선) 지구를 동서로 나누는 기준선 (영국 런던 그리니치 천문대를 지나는 선)

위도선(가로)은 적도, 경도선(세로)은 본초자오선을 기준으로 잰다

그림 1. 위도는 적도(0°)에서 남북으로 ±90°, 경도는 본초자오선(0°)에서 동서로 ±180°까지 잽니다. 서울은 북위 약 37.5°, 동경 약 127°에 있습니다.

서울의 위도 — 적도에서 북쪽으로 37.5°

세계 주요 도시의 위도 비교: 서울 37.5°, 도쿄 35.7°, 뉴욕 40.7°, 싱가포르 1.3°, 시드니 -33.9°

그림 2. 위도는 Y축입니다. 서울(37.5°)은 도쿄(35.7°)와 비슷한 높이이고, 적도 아래의 시드니는 음수(-33.9°)가 됩니다.

서울의 경도 — 본초자오선에서 동쪽으로 127°

세계 주요 도시의 경도 비교: 런던 0°, 서울 127°, 뉴욕 -74°, LA -118°

그림 3. 경도는 X축입니다. 런던(0°)에서 동쪽으로 127° 떨어진 곳이 서울이고, 서쪽의 뉴욕·LA는 음수가 됩니다. Redis GEO 명령어에 좌표를 넣을 때 이 X값(경도)이 먼저 옵니다.

2. 거리 계산과 반경 검색으로 할 수 있는 것

  1. 거리 계산
    • 두 지점 사이의 거리를 미터(m), 킬로미터(km) 등의 단위로 계산합니다.
    • 내부적으로는 위도·경도를 기반으로 한 구면 거리 계산을 사용합니다. (Haversine 공식 사용)
  2. 반경 검색 (내 주변 찾기)
    • 특정 위치(또는 등록된 장소)를 기준으로 지정한 반경(예: 2km) 내의 데이터를 빠르게 탐색할 수 있습니다.
  3. 초고속 성능
    • 모든 연산이 메모리(In-memory)에서 이루어지며, Geohash와 Sorted Set 기반으로 최적화되어 매우 빠른 응답 속도를 제공합니다.
    • 특히 배달, 모빌리티(택시 호출), 매칭 서비스 등 실시간 위치 탐색을 빠르게 처리하기 위한 용도로 자주 활용됩니다.

3. 실체는 Sorted Set — Geohash로 좌표를 점수로 만든다

Redis에서 위치 정보(위도, 경도)를 다루는 GEO 기능은 강력하지만, 사실 'GEO'라는 독립된 자료형(Data Type)은 존재하지 않습니다.

GEO 기능의 정체는 바로 이전 레슨에서 배운 Sorted Sets(ZSET)입니다!

💡 어떻게 위도/경도를 Sorted Set에 넣을까? (Geohash 변환 원리)

Sorted Set은 데이터를 정렬하기 위해 1차원 숫자인 Score(점수)가 필요합니다. Redis는 2차원 좌표(위도/경도)를 1차원 숫자로 바꾸는 Geohash 알고리즘을 사용하여 이 문제를 해결합니다.

좌표 입력(2차원) → Geohash 변환(52비트 정수) → Sorted Set 저장의 3단계

그림 4. 2차원 좌표(X, Y)가 1차원 Score 하나로 바뀌어 Sorted Set에 들어갑니다. 그림의 Score 숫자는 설명용 예시이며, 실제 값은 아래 코드 블록에서 확인합니다.

text
[ 1단계: 좌표 입력 ]
경도(X): 126.9780 / 위도(Y): 37.5665  (서울시청)
         │
         ▼
[ 2단계: Geohash 변환 (2차원 ➔ 1차원) ]
Redis 내부에서 경도와 위도의 비트(bit)를 교차로 섞어서
52비트의 정수(Integer)로 변환합니다.
(실제 값: 4077564854911468)
         │
         ▼
[ 3단계: Sorted Set (ZSET)에 저장 ]
변환된 정수를 점수(Score)로 삼아 데이터를 한 줄로 세웁니다.

┌──────────────────────────────────────────────┐
│  Score (Geohash)     |  Member (이름)         │
│──────────────────────|───────────────────────│
│ 4077564816960180     | "N_Seoul_Tower"       │
│ 4077564854911468     | "Seoul_City_Hall"     │
│ 4077564871222068     | "Gyeongbokgung_Palace"│
└──────────────────────────────────────────────┘

원문 예시 숫자 정정: 원본 PDF의 예시 Score 4050965385614946은 설명용으로 적힌 값으로, Redis가 실제로 만드는 값과 다릅니다. 5절 실습의 세 좌표를 Redis와 같은 알고리즘(경도 -180~180, 위도 -85.05112878~85.05112878 구간을 각각 26비트로 나눠 비트를 교차)으로 계산하면 위 표의 값이 나옵니다. 실습에서 ZSCORE seoul:places "Seoul_City_Hall"로 직접 확인할 수 있습니다.

🔍 핵심 원리: 왜 이렇게 할까요?

  • 근접성 유지의 마법: Geohash 알고리즘의 가장 큰 특징은 "물리적으로 가까운 위치에 있는 좌표들은 변환된 52비트 정수(Score) 값도 서로 비슷하게 나온다" 는 것입니다.
  • 초고속 범위 검색의 비밀: 숫자가 비슷하다는 것은 Sorted Set에서 서로 옆자리에 나란히 줄을 선다는 뜻입니다. 따라서 "내 주변 1km"를 찾을 때 저장된 모든 장소와 거리를 계산할 필요 없이, Sorted Set에서 내 점수 근처 범위의 데이터만 빠르게 긁어와(Range Search) 그 후보들에 대해서만 거리를 계산하면 되므로 초고속 검색이 가능합니다!

Score가 비슷한 멤버끼리 Sorted Set에서 나란히 정렬되어, 내 주변 1km가 연속된 범위가 된다

그림 5. Sorted Set 안에서 멤버는 Score 순서로 한 줄로 서 있습니다. 가까운 장소(시청·덕수궁·광화문)는 Score도 이웃하므로, "내 주변" 후보는 이 줄의 연속된 구간을 잘라 오는 문제로 바뀝니다.

3.1 실제 검색은 두 단계다 — 9칸 후보 수집 + 거리 필터

"Score만 비교하면 끝"은 절반만 맞는 설명입니다. Geohash는 격자 칸 경계에서 근접성이 깨집니다. 경계선 바로 건너편의 장소는 몇 미터 거리라도 Score가 크게 다를 수 있습니다. 그래서 Redis는 중심 칸 하나만 보지 않고 이웃 8칸까지 함께 조회한 뒤, 모은 후보마다 실제 거리를 계산해 범위 밖을 버립니다(Redis 소스 geo.c, geohash_helper.c).

text
GEOSEARCH seoul:places FROMLONLAT 126.98 37.57 BYRADIUS 2 km

 1) 반경에 맞는 격자 크기(step) 선택     반경이 클수록 거친 칸(낮은 비트 수)을 쓴다
 2) 중심 칸 + 이웃 8칸 = 9개 칸 계산
        ┌────┬────┬────┐
        │ NW │ N  │ NE │
        ├────┼────┼────┤      ● = 검색 중심
        │ W  │ ●  │ E  │      원이 중심 칸 경계를 넘어도 이웃 칸이 덮는다
        ├────┼────┼────┤
        │ SW │ S  │ SE │
        └────┴────┴────┘
 3) 칸마다 Score 범위 [min, max)를 만들어 Sorted Set 범위 조회   → O(log M)
 4) 후보마다 Haversine 거리 계산, 원(또는 사각형) 밖이면 버림    → O(N)
 5) ASC/DESC 정렬, COUNT 적용 후 반환

공식 문서가 밝히는 GEOSEARCH의 시간 복잡도는 O(N + log(M)) 입니다. 여기서 N은 검색 모양을 감싸는 격자 영역 안의 원소 수, M은 모양 안에 든 원소 수입니다. 즉 성능을 좌우하는 것은 키 전체 크기가 아니라 "검색 영역 주변에 몰려 있는 원소 수" 입니다. 강남역처럼 밀집한 곳에서 반경을 크게 잡으면 후보(N)가 급증해 느려집니다.

4. GEOADD·GEOSEARCH 문법 짚기

명령어 기본 문법 역할 및 설명
GEOADD GEOADD [key] [NX\|XX] [CH] [경도] [위도] [멤버] ... 데이터 추가. 지정한 키에 경도와 위도 좌표, 그리고 그 장소의 이름을 저장합니다. 여러 개를 한 번에 넣을 수 있고, 같은 멤버를 다시 넣으면 좌표가 덮어써집니다. 반환값은 새로 추가된 멤버 수입니다.
GEODIST GEODIST [key] [멤버1] [멤버2] [단위] 거리 계산. 이미 저장된 두 멤버 사이의 구면(대원) 거리를 계산합니다. 단위는 m(기본값), km, mi, ft이고, 멤버가 하나라도 없으면 nil을 반환합니다.
GEOSEARCH GEOSEARCH [key] <FROMLONLAT 경도 위도 \| FROMMEMBER 멤버> <BYRADIUS 반경 단위 \| BYBOX 가로 세로 단위> [ASC\|DESC] [COUNT n [ANY]] [WITHCOORD] [WITHDIST] [WITHHASH] 주변 검색. 좌표 또는 이미 등록된 멤버를 중심으로 반경(BYRADIUS)이나 사각형(BYBOX) 안의 멤버를 찾습니다. (Redis 6.2 이상 표준)
GEOSEARCHSTORE GEOSEARCHSTORE [저장키] [원본키] ...검색조건... [STOREDIST] 검색 결과 저장. GEOSEARCH 결과를 새 키(Sorted Set)에 저장합니다. (Redis 6.2 이상)
GEOPOS GEOPOS [key] [멤버] ... 좌표 확인. 저장된 멤버의 경도와 위도를 꺼냅니다. 52비트로 저장했다 복원한 값이라 입력값과 소수점 아래가 미세하게 다릅니다(6절).
GEOHASH GEOHASH [key] [멤버] ... Geohash 문자열. 멤버 위치를 표준 11자리 Geohash 문자열로 돌려줍니다. 외부 지도 도구와 위치를 주고받을 때 씁니다.

GEOSEARCH 옵션 정리

옵션 의미 주의점
FROMLONLAT lon lat 좌표를 검색 중심으로 사용 사용자 현재 위치처럼 저장되지 않은 좌표에 사용
FROMMEMBER member 이미 저장된 멤버의 위치를 중심으로 사용 멤버가 없으면 에러
BYRADIUS r unit 원 모양 검색 반경이 클수록 후보가 늘어 느려짐
BYBOX w h unit 중심 기준 가로 w × 세로 h 사각형 검색 지도 화면(뷰포트) 안의 마커 조회에 적합
ASC / DESC 거리 기준 가까운 순 / 먼 순 정렬 생략하면 정렬되지 않은 순서로 반환. 단 COUNT만 주고 ANY가 없으면 서버가 ASC로 정렬
COUNT n 최대 n개만 반환 영역 안 전체를 찾고 정렬한 뒤 자르므로, 영역이 크면 COUNT가 작아도 느릴 수 있음
COUNT n ANY n개를 찾는 즉시 반환 빠르지만 가장 가까운 n개라는 보장이 없음
WITHCOORD / WITHDIST / WITHHASH 좌표 / 중심까지 거리(검색 단위) / 52비트 Score를 함께 반환 결과 항목 순서는 이름, 거리, 해시, 좌표

⚠ 주의사항: 구글 지도 등에서 좌표를 복사하면 보통 '위도, 경도' 순서로 나오지만, Redis의 GEOADD 명령어는 반드시 '경도(Longitude), 위도(Latitude)' 순서로 입력해야 합니다. 순서를 바꾸면 결과는 두 가지입니다.

  • 서울(경도 126.978)처럼 경도가 85.05를 넘는 좌표는 위도 허용 범위(±85.05112878)를 벗어나 에러가 납니다(5절 ⑨ 참고).
  • 파리(경도 2.35, 위도 48.86)처럼 두 값이 모두 ±85 안에 드는 좌표는 에러 없이 엉뚱한 곳(경도 48.86, 위도 2.35 — 소말리아 앞 인도양)에 저장됩니다. 이쪽이 발견하기 어려운 조용한 버그라 더 위험합니다. 입력 단계에서 좌표 범위와 서비스 지역 범위를 검증하십시오.

암기 팁: 수학의 2차원 그래프 좌표 (x, y)를 떠올려 보세요! 레디스의 GEOADD는 (X축, Y축) 순서대로 값을 넣는 것과 같아서 '경도(X), 위도(Y)' 순서로 입력해야 한다고 기억하면 헷갈리지 않습니다.

5. [실습] 좌표 넣고 내 주변 찾아보기

터미널의 redis-cli를 열고 직접 위경도 데이터를 다뤄봅시다.

① 위치 데이터 추가 (GEOADD)

데이터를 넣을 때는 반드시 경도(Longitude) → 위도(Latitude) → 이름(Member) 순서로 입력해야 합니다.

redis
# 서울의 주요 랜드마크 3곳 추가 (경도, 위도, 이름)
GEOADD seoul:places 126.9780 37.5665 "Seoul_City_Hall"
(integer) 1

GEOADD seoul:places 126.9770 37.5759 "Gyeongbokgung_Palace" 126.9882 37.5512 "N_Seoul_Tower"
(integer) 2

② 두 지점 사이의 거리 계산 (GEODIST)

두 위치 사이의 거리(지구 표면을 따라 잰 구면 거리)를 즉시 계산합니다. 마지막에 단위(m 미터, km 킬로미터, mi 마일, ft 피트)를 지정할 수 있습니다.

redis
# 서울시청과 경복궁 사이의 거리 (킬로미터 단위)
GEODIST seoul:places "Seoul_City_Hall" "Gyeongbokgung_Palace" km
"1.0491"

# 서울시청과 남산 서울타워 사이의 거리 (미터 단위)
GEODIST seoul:places "Seoul_City_Hall" "N_Seoul_Tower" m
"1924.8823"

③ 내 주변 검색 (GEOSEARCH) 🌟 핵심

과거에는 GEORADIUS라는 명령어를 썼지만, Redis 6.2 버전부터는 GEOSEARCH 명령어로 통합되어 이를 사용하는 것이 공식 권장 사항입니다.

"특정 좌표를 기준으로 반경 N킬로미터 이내에 있는 장소들을 가까운 순서대로 검색"해 봅니다. 참고로 반경을 뜻하는 BYRADIUS 외에도, 가로/세로 길이를 지정해 사각형 범위로 찾는 BYBOX 옵션도 제공하여 더욱 디테일한 검색이 가능합니다.

redis
# 기준 좌표: 126.9800(경도), 37.5700(위도)
# 조건: 반경 2km 이내 (BYRADIUS 2 km)
# 옵션: 거리(WITHDIST)와 좌표(WITHCOORD)도 함께 보여주고, 가까운 순서대로 정렬(ASC)
GEOSEARCH seoul:places FROMLONLAT 126.9800 37.5700 BYRADIUS 2 km WITHDIST WITHCOORD ASC
1) 1) "Seoul_City_Hall"
   2) "0.4274"   # 약 427m 떨어져 있음
   3) 1) "126.9779..."
      2) "37.56650..."
2) 1) "Gyeongbokgung_Palace"
   2) "0.7074"   # 약 700m 떨어져 있음
   3) 1) "126.9770..."
      2) "37.5758..."

④ 좌표와 해시값 확인 (GEOPOS, GEOHASH)

저장된 장소의 원래 위경도 좌표를 다시 꺼내보거나, Redis가 내부적으로 생성한 Geohash 문자열을 확인할 수 있습니다.

redis
# 서울시청의 위경도 좌표 확인
GEOPOS seoul:places "Seoul_City_Hall"
1) 1) "126.977998316288"      # 경도
   2) "37.566500306287246"    # 위도

# Geohash 문자열 확인
GEOHASH seoul:places "Seoul_City_Hall"
1) "wydm9qy89z0"

GEOHASH가 돌려주는 11자리 문자열은 표준 Geohash 형식이라 다른 지도 도구와 호환됩니다. 오른쪽 글자를 잘라 내면 같은 지점을 더 넓은 칸으로 표현한 값이 됩니다. 내부 52비트는 약 10.4글자 분량이라 11번째 글자는 0비트로 채워지며, 그래서 결과가 0으로 끝나는 경우가 많습니다.

⑤ 등록된 멤버를 중심으로 사각형 검색 (FROMMEMBER + BYBOX)

좌표 대신 이미 저장된 멤버를 중심으로 쓸 수 있습니다. BYBOX 3 3 km는 중심에서 동서·남북으로 각각 1.5km까지인 사각형입니다.

redis
# 서울시청을 중심으로 가로 3km × 세로 3km 사각형 안의 장소
GEOSEARCH seoul:places FROMMEMBER "Seoul_City_Hall" BYBOX 3 3 km ASC WITHDIST
1) 1) "Seoul_City_Hall"
   2) "0.0000"
2) 1) "Gyeongbokgung_Palace"
   2) "1.0491"

남산 서울타워는 시청과의 거리가 1.92km라 반경 2km 원에는 들어가지만, 남북 방향으로 약 1.7km 떨어져 있어 세로 1.5km 한계를 넘으므로 사각형에서는 빠집니다. 원과 사각형은 결과가 다릅니다. 지도 화면에 보이는 마커를 그릴 때는 BYBOX, "반경 N km 이내"라는 비즈니스 규칙에는 BYRADIUS가 맞습니다.

⑥ 개수 제한과 ANY (COUNT)

redis
# 가장 가까운 1곳만 (ASC + COUNT)
GEOSEARCH seoul:places FROMLONLAT 126.9800 37.5700 BYRADIUS 5 km ASC COUNT 1 WITHDIST
1) 1) "Seoul_City_Hall"
   2) "0.4274"

# ANY: 조건에 맞는 1곳을 찾는 즉시 반환 (가장 가깝다는 보장 없음)
GEOSEARCH seoul:places FROMLONLAT 126.9800 37.5700 BYRADIUS 5 km COUNT 1 ANY
1) "N_Seoul_Tower"      # 2.21km로 셋 중 가장 먼 곳이 먼저 나왔다

실제로 Redis 8.8에서 실행하면 ANY 결과로 가장 가까운 시청(0.43km)이 아니라 가장 먼 남산 서울타워가 나옵니다. 어떤 멤버가 나올지는 격자 칸을 훑는 순서에 달려 있어 보장되지 않습니다.

COUNT만 쓰면 Redis는 영역 안의 후보를 모두 찾아 정렬한 다음 앞에서 자릅니다. "근처에 배차 가능한 기사가 한 명이라도 있는가?"처럼 가장 가까울 필요가 없는 질문이면 ANY로 서버 작업량을 줄일 수 있습니다. 반대로 "가장 가까운 기사 배정"에 ANY를 쓰면 먼 기사가 배정되는 버그가 됩니다.

⑦ GEO 키는 결국 zset — ZSCORE·WITHHASH·ZCARD

redis
# 3절 표의 Score를 직접 확인
ZSCORE seoul:places "Seoul_City_Hall"
"4077564854911468"

# WITHHASH는 같은 52비트 Score를 정수로 돌려준다
GEOSEARCH seoul:places FROMMEMBER "Seoul_City_Hall" BYRADIUS 10 m WITHHASH
1) 1) "Seoul_City_Hall"
   2) (integer) 4077564854911468

# 등록된 장소 수
ZCARD seoul:places
(integer) 3

⑧ 검색 결과를 키로 저장 (GEOSEARCHSTORE)

redis
# 반경 2km 결과를 새 키에 저장. STOREDIST: Score 자리에 중심까지의 거리(km)를 저장
GEOSEARCHSTORE seoul:near:cityhall seoul:places FROMLONLAT 126.9800 37.5700 BYRADIUS 2 km ASC STOREDIST
(integer) 2

ZRANGE seoul:near:cityhall 0 -1 WITHSCORES
1) "Seoul_City_Hall"
2) "0.42739..."
3) "Gyeongbokgung_Palace"
4) "0.70740..."

# 결과 키는 캐시처럼 쓰고 반드시 수명을 준다
EXPIRE seoul:near:cityhall 60
  • STOREDIST 없이 저장하면 Score에 Geohash가 그대로 들어가 결과 키도 GEO 키로 계속 검색할 수 있습니다.
  • STOREDIST로 저장하면 Score가 거리로 바뀌므로 결과 키에 GEO 명령어를 쓰면 엉뚱한 값이 나옵니다. 이 키는 ZRANGE로 거리순 목록을 읽는 용도로만 쓰십시오.
  • Redis Cluster에서는 원본 키와 저장 키가 같은 해시 슬롯이어야 합니다. 다르면 CROSSSLOT 에러가 나므로 {seoul}:places, {seoul}:near:cityhall처럼 해시 태그로 묶습니다.

⑨ 조건부 추가·갱신과 잘못된 좌표 (NX·XX·CH, Redis 6.2+)

redis
# XX: 이미 있는 멤버만 좌표 갱신 / CH: "새로 추가 + 좌표가 바뀐" 개수를 반환
GEOADD seoul:places XX CH 126.9771 37.5760 "Gyeongbokgung_Palace"
(integer) 1

# NX: 새 멤버만 추가 (이미 있으면 아무것도 하지 않음)
GEOADD seoul:places NX 126.9800 37.5700 "Gyeongbokgung_Palace"
(integer) 0

# 위도·경도 순서를 바꿔 넣으면? 126.978은 위도 허용 범위 밖이라 에러
GEOADD seoul:places 37.5665 126.9780 "Wrong_Order"
(error) ERR invalid longitude,latitude pair 37.566500,126.978000

CH 없이 기존 멤버의 좌표만 바꾸면 반환값이 0이라 "갱신 실패"로 오해하기 쉽습니다. 실시간 위치 갱신처럼 기존 멤버를 덮어쓰는 흐름에서 변경 여부를 확인하려면 CH를 붙이십시오. NX와 XX는 함께 쓸 수 없습니다.

6. 정밀도·거리 오차·좌표 범위 — 숫자를 어디까지 믿을 수 있나

항목 사실 실무 의미
좌표 허용 범위 경도 -180 ~ 180, 위도 -85.05112878 ~ 85.05112878 (Web Mercator 계열 범위). 벗어나면 ERR invalid longitude,latitude pair 극지방 근처는 저장 자체가 안 됩니다. 입력 검증을 이 범위로 맞추십시오
저장 정밀도 경도·위도를 각각 26비트로 나눠 52비트 정수로 저장. Sorted Set의 double Score는 52비트 정수를 손실 없이 담습니다 격자 한 칸은 적도 기준 경도 약 0.6m, 위도 약 0.3m(계산값)입니다. GEOPOS가 126.9780을 126.977998316288로 돌려주는 이유입니다
원본 좌표 보존 GEO 키는 칸 안의 대표 좌표만 돌려줌 정산·법적 기록처럼 입력 좌표 그대로가 필요하면 Hash나 DB에 따로 저장하십시오
거리 모델 지구를 반지름 6,372,797.560856m의 완전한 구로 보고 Haversine 공식으로 계산. 실제 타원체 대비 최대 0.5% 오차 10km 거리에서 최대 약 50m 차이. "반경 2km 무료배송" 경계 판정에 여유를 두십시오
거리의 의미 지구 표면을 따라 잰 두 점 사이 최단 거리(대원 거리) 도로 거리·이동 시간이 아닙니다. 강·산·일방통행은 반영되지 않습니다

7. 쓰기 전에 알아야 할 구조적 제약

  • 내부 타입 확인: CLI에서 TYPE seoul:places를 확인해보세요. geo가 아니라 zset(Sorted Set)이라고 나오는 것을 확인할 수 있습니다.
  • 데이터 삭제: GEO 전용 삭제 명령어는 없습니다. 내부가 Sorted Set이므로, 데이터를 지울 때는 ZREM seoul:places "Seoul_City_Hall" 명령어를 사용하면 됩니다.
  • Big Key 주의: "전국의 모든 식당을 하나의 키(geo:korea:restaurants)에 저장하면 메모리와 운영 부담이 커질 뿐만 아니라, 검색 시 후보 데이터가 많아져 거리 계산 비용이 증가합니다. 따라서 실무에서는 국가, 도시, 지역 단위로 키를 나누어(예: geo:seoul:gangnam:restaurants) 검색 범위를 줄이는 것이 효율적입니다. 단, 키를 지역으로 나누면 경계 근처 사용자는 이웃 지역 키도 함께 검색해야 합니다(강남·서초 경계에 선 사용자의 "반경 1km"는 두 구에 걸칩니다).
  • 멤버 단위 TTL 없음: EXPIRE는 키 전체에만 걸립니다. "30초 동안 위치를 안 보낸 라이더는 목록에서 제외" 같은 요구는 마지막 갱신 시각을 담은 별도 Sorted Set으로 직접 청소해야 합니다(8절 실습).
  • 정렬 기본값: ASC/DESC를 생략하면 결과가 거리순으로 정렬되지 않습니다. "가까운 순"이 필요하면 항상 ASC를 명시하십시오.
  • 구 명령어 GEORADIUS: GEORADIUS·GEORADIUSBYMEMBER는 Redis 6.2부터 폐기 예정(deprecated)입니다. STORE 옵션 때문에 쓰기 명령으로 분류되어 읽기 전용 복제본에서 거부되거나 클러스터 마스터로 리다이렉트되는 문제가 있어 GEORADIUS_RO 변형까지 생겼습니다. 신규 코드는 읽기 전용인 GEOSEARCH와 저장용 GEOSEARCHSTORE로 역할을 나눠 쓰십시오.
  • Geohash 접두어로 직접 검색하지 않기: 가까운 위치는 대체로 같은 접두어를 공유하지만, 칸 경계를 사이에 둔 가까운 두 점은 접두어가 완전히 다를 수 있습니다(3.1절). ZRANGEBYSCORE나 문자열 접두어로 "근처"를 직접 구현하지 말고 GEOSEARCH를 쓰십시오.
  • 위치는 개인정보: 사용자·라이더 위치는 민감정보입니다. 멤버 이름에 전화번호·이름 대신 불투명 ID를 쓰고(6-1 ⑥), 필요 이상으로 오래 보관하지 마십시오.

8. [실습] Python — 실시간 라이더 위치 추적

배달·택시 서비스에서 Redis GEO가 가장 많이 쓰이는 형태입니다. 라이더 앱이 몇 초마다 위치를 보내면 같은 멤버로 GEOADD해 좌표를 덮어쓰고, 주문이 들어오면 가게 주변 라이더를 가까운 순으로 찾습니다. 멤버 단위 TTL이 없으므로 마지막 갱신 시각을 두 번째 Sorted Set에 함께 기록해, 위치가 끊긴 라이더를 주기적으로 걷어냅니다.

rider_location.py

python
import time

import redis

r = redis.Redis(host="localhost", port=6379, decode_responses=True)

GEO_KEY = "geo:seoul:riders"          # 위치 인덱스 (zset: score = geohash)
SEEN_KEY = "geo:seoul:riders:seen"    # 마지막 위치 수신 시각 (zset: score = unix time)
STALE_SEC = 30                         # 30초 동안 위치가 없으면 오프라인으로 본다

MAX_LAT = 85.05112878                  # Redis GEO가 받는 위도 한계


def update_location(rider_id: str, lon: float, lat: float) -> None:
    """앱이 보낸 위치를 반영한다. 같은 member로 GEOADD하면 좌표가 덮어써진다."""
    if not (-180 <= lon <= 180 and -MAX_LAT <= lat <= MAX_LAT):
        raise ValueError(f"invalid coordinate: lon={lon}, lat={lat}")
    pipe = r.pipeline(transaction=True)          # 위치와 수신 시각을 한 번에 기록
    pipe.geoadd(GEO_KEY, [lon, lat, rider_id])
    pipe.zadd(SEEN_KEY, {rider_id: time.time()})
    pipe.execute()


def remove_stale_riders() -> int:
    """STALE_SEC 넘게 위치가 없는 라이더를 두 키에서 함께 지운다 (스케줄러로 주기 실행)."""
    cutoff = time.time() - STALE_SEC
    stale = r.zrangebyscore(SEEN_KEY, "-inf", cutoff)
    if not stale:
        return 0
    pipe = r.pipeline(transaction=True)
    pipe.zrem(GEO_KEY, *stale)                    # GEO 전용 삭제 명령은 없다 -> ZREM
    pipe.zrem(SEEN_KEY, *stale)
    pipe.execute()
    return len(stale)


def nearest_riders(lon: float, lat: float, radius_km: float = 3, limit: int = 5):
    """가게 주변 라이더를 가까운 순으로 limit명. ASC를 명시해야 '가장 가까운' 결과가 보장된다."""
    return r.geosearch(
        GEO_KEY, longitude=lon, latitude=lat,
        radius=radius_km, unit="km",
        sort="ASC", count=limit, withdist=True,
    )


if __name__ == "__main__":
    r.delete(GEO_KEY, SEEN_KEY)
    update_location("rider:1001", 126.9790, 37.5670)   # 시청 근처
    update_location("rider:1002", 126.9770, 37.5760)   # 경복궁 근처
    update_location("rider:1003", 127.0276, 37.4979)   # 강남역 근처 (약 10km)
    r.zadd(SEEN_KEY, {"rider:1002": time.time() - 60})  # 1002는 60초 전에 마지막 수신

    print("청소된 라이더 수:", remove_stale_riders())
    print("시청 주변 3km:", nearest_riders(126.9780, 37.5665))

실행 결과

bash
$ python rider_location.py
청소된 라이더 수: 1
시청 주변 3km: [['rider:1001', 0.1043]]
  • rider:1002는 마지막 수신이 30초를 넘어 청소되었고, rider:1003은 반경 3km 밖이라 빠졌습니다.
  • redis-py의 geosearch는 withdist=True일 때 [이름, 거리(float)] 목록을 돌려줍니다. withcoord를 더하면 (경도, 위도) 튜플이 뒤에 붙습니다.
  • remove_stale_riders는 조회(ZRANGEBYSCORE)와 삭제(ZREM) 사이에 위치를 새로 보낸 라이더를 지울 수 있습니다. 다음 위치 전송(수 초 뒤)에 곧바로 다시 등록되므로 대부분의 배차 서비스에서는 허용 가능한 틈입니다. 허용할 수 없다면 두 단계를 Lua 스크립트 하나로 묶으십시오(4-6 참고).

9. 어디까지 Redis로 하고 어디부터 PostGIS인가

Redis GEO는 "특정 좌표에서 반경 N km 이내의 점(Point) 찾기"에는 아주 훌륭한 솔루션 중 하나입니다. 하지만 본격적인 지도 서비스나 배달 앱을 구축할 때는 명확한 한계가 존재합니다.

⚠ Redis GEO의 한계점

  • 다각형(Polygon) 검색 한계 GEO 명령어(GEOADD·GEOSEARCH 계열)는 원(BYRADIUS)과 사각형(BYBOX) 검색만 제공하고, 다각형 기반 영역 검색(ex: 이 식당이 '강남구 배달 가능 구역' 안에 포함되는가?)은 지원하지 않습니다. 복잡한 배달 가능 구역(Polygon) 판별은 별도의 로직이 필요합니다. 단, 이는 GEO 자료형의 한계입니다. Redis 7.2 이상에서 Redis Query Engine(구 RediSearch, Redis Stack·Redis 8에 포함)의 GEOSHAPE 필드를 쓰면 WKT 형식 다각형에 대해 WITHIN·CONTAINS 질의가 가능하고, RediSearch 2.10부터 INTERSECTS·DISJOINT도 지원합니다. GEO 명령어와는 별개의 검색 인덱스 기능이며, 좌표계는 SPHERICAL(기본, 경위도)과 FLAT(평면) 중에서 고릅니다.
  • 경로 및 방향 계산 불가 내비게이션처럼 실제 도로 경로, 이동 시간, 방향 계산은 제공하지 않으며, 오직 직선 거리(구면 거리) 기반 계산만 지원합니다.
  • 복합 조건 검색의 한계 "반경 + 평점 + 영업 상태"와 같은 다중 조건 검색(ex: 강남역 1km 이내에 있으면서, 평점이 4.5 이상이고, 현재 영업 중인 중국집)은 Redis GEO 기능만으로는 처리하기 까다로우며, RediSearch 등 별도 인덱싱 구조를 병행해야 합니다. GEO만으로 처리하려면 반경 검색으로 후보를 넉넉히(원하는 개수보다 많이) 가져온 뒤 Hash나 DB에서 속성을 읽어 애플리케이션에서 걸러야 하고, 걸러진 뒤 개수가 모자라면 다시 조회해야 합니다.

참고: Redis Query Engine으로 다각형 + 조건 검색 (Redis Stack / Redis 8)

redis
# 배달 구역을 Hash에 WKT 다각형으로 저장하고 GEOSHAPE 인덱스 생성
FT.CREATE idx:zones ON HASH PREFIX 1 zone: SCHEMA name TEXT open TAG area GEOSHAPE SPHERICAL
HSET zone:gangnam name "강남역 배달구역" open "Y" area "POLYGON((127.02 37.49, 127.04 37.49, 127.04 37.51, 127.02 37.51, 127.02 37.49))"

# 이 주문 위치(POINT)를 포함하면서 영업 중인 구역 찾기
FT.SEARCH idx:zones "@open:{Y} @area:[CONTAINS $pt]" PARAMS 2 pt "POINT(127.0276 37.4979)" DIALECT 3

WKT 좌표도 GEO 명령어와 같이 경도 위도 순서입니다. 다각형은 시작점과 끝점이 같아야 닫힙니다. 도형 질의는 DIALECT 1에서 동작하지 않으므로 DIALECT 2 이상을 지정하십시오.

💡 [실무] 업계 표준 공간 데이터베이스 (Spatial DB)

본격적인 배달 앱, 모빌리티(택시/지도), 부동산 서비스 등 복잡한 지리 공간 연산이 필요한 시스템에서는 전문 Spatial DB를 함께 사용합니다.

  1. PostgreSQL + PostGIS (업계 표준 👑)
    • 가장 널리 사용되는 오픈소스 공간 DB 확장 플러그인
    • 점(Point), 선(Line), 다각형(Polygon/MultiPolygon)과 함께 Z값(고도)을 포함한 3차원(XYZ) 및 M값(시간/거리)을 포함한 4차원(XYZM) 공간 데이터까지 지원
    • 배달 구역, 행정 경계, 영역 포함 여부 판단 등 정밀한 공간 연산에 핵심적으로 사용
  2. Elasticsearch
    • geo_point(점) / geo_shape(영역) 기반 위치 검색 지원
    • 텍스트 검색 + 위치 필터 결합에 강점 (예: "내 주변 리뷰 좋은 카페 검색")
    • 검색 중심 서비스에서 유용
  3. MongoDB
    • GeoJSON 기반 공간 데이터 지원
    • $geoWithin: 포함 관계 (안에 있냐?), $geoIntersects: 교차 관계 (겹치냐?)
    • 비교적 단순한 위치 기반 서비스에서 활용 가능

실무 아키텍처

클라이언트 → 백엔드가 Redis GEO(실시간 근접 검색), Elasticsearch(텍스트+위치 검색), PostGIS(정밀 공간 연산, 원본)로 요청을 나눠 보낸다

그림 6. 라이더 앱의 실시간 위치는 PostGIS를 거치지 않고 Redis GEO로 바로 갱신합니다. 원본(Source of Truth)은 PostGIS에 두고, CDC·배치로 Elasticsearch 인덱스와 Redis 캐시를 채웁니다.

  • 실무에서는 PostGIS와 같은 공간 DB를 통해 복잡한 배달 구역 및 다각형 연산을 처리하고,
  • Elasticsearch는 위치 기반 검색과 텍스트 검색을 결합한 조회 엔진으로 활용합니다.
  • Redis GEO는 "내 주변 접속 중인 라이더(택시) 목록"처럼 실시간으로 빠르게 변하는 위치 데이터를 처리하는 초고속 근접 검색 엔진으로 사용하여 각 시스템의 역할을 분리하는 구조가 일반적입니다.

🚩 정리 — GEO를 실무에 넣는 기준

  • Redis GEO는 복잡한 거리 계산을 내부적으로 처리하여, 점들 사이의 거리와 반경 검색을 초고속으로 수행합니다.
  • Redis GEO는 위경도를 Geohash로 변환하여 Sorted Set에 저장합니다. 정렬된 구조를 이용해 주변 위치를 범위 검색으로 빠르게 찾아낼 수 있습니다.
  • 배달 구역과 같은 복잡한 다각형 연산은 PostGIS와 같은 Spatial DB에 맡기고, Redis는 실시간으로 빠르게 변하는 위치 데이터를 처리하는 근접 검색 엔진으로 사용하는 것이 실무에서 일반적으로 사용되는 아키텍처 패턴입니다.
  • 실제 검색은 9칸 후보 수집 + 후보별 거리 계산의 두 단계이며, 비용은 키 전체가 아니라 검색 영역 주변의 원소 수에 비례합니다. 밀집 지역에서는 반경과 COUNT를 제한하십시오.
  • 좌표는 경도, 위도 순서이고 위도는 ±85.05112878까지만 받습니다. 거리는 구 모델 기반이라 최대 0.5% 오차가 있으며, 가까운 순서가 필요하면 ASC를 반드시 명시합니다.
  • 멤버 단위 TTL이 없으므로 실시간 위치는 갱신 시각 Sorted Set으로 함께 관리하고, Cluster에서 GEOSEARCHSTORE를 쓸 때는 해시 태그로 슬롯을 맞춥니다.

참고 문서


이전 글: Redis 실무 가이드 ③ — FastAPI로 구현하는 백엔드 실무 패턴 9선 (Part 5)

전체 시리즈 1. 왜 Redis인가, 그리고 자료형 완전 정복 (Part 1~3) 2. Pub/Sub, Stream, Redis Stack, 트랜잭션과 Lua Script (Part 4) 3. FastAPI로 구현하는 백엔드 실무 패턴 9선 (Part 5) 4. 운영·장애 대응과 대용량 캐시 설계 (Part 6~7) — 현재 글