Redis 실무 가이드 4 — 운영·장애 대응과 대용량 캐시 설계
목차
Part 6. [Ops] Redis 운영 및 장애 대응
- 6-1. Key 이름 짓기 — 6개월 뒤에도 읽히는 규칙
- 6-2. KEYS *를 치는 순간 서버가 멈추는 이유와 SCAN
- 6-3. 메모리 상한과 꽉 찼을 때의 행동 정하기
- 6-4. RDB와 AOF — 재기동 후 데이터가 남는 원리와 복구 실습
- 6-5. INFO·MONITOR·SLOWLOG로 서버 상태 읽어내기
- 6-6. [절대 금기] 운영 서버를 무너뜨리는 안티패턴 8가지
- 6-7. Prometheus·Grafana로 Redis 대시보드 세우기
- 6-8. Part 6 마무리 — 운영자로서 갖출 판단 기준
Part 7. 대용량 서비스 설계
- 7-1. 캐시가 틀려지는 것을 전제로 한 무효화 설계
- 7-2. 캐시 하나가 만료되는 순간 DB가 무너지는 문제
- 7-3. 스탬피드 방어 #1 — 실전 분산 락 완성하기
- 7-4. GEO — 좌표를 넣고 반경 검색을 밀리초로
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. 실무 표준 — 콜론으로 계층을 만드는 법
가장 널리 쓰이고 권장되는 방식은 콜론(:) 을 구분자로 사용하여 데이터의 범위를 큰 범주에서 작은 범주로 좁혀나가는 방식입니다.
💡 권장 포맷
[서비스/프로젝트]:[도메인/엔티티]:[식별자]:[속성]
❌ 나쁜 예시
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를 열고 아래 명령어들을 차례대로 입력해 보세요.
# 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에서 확인하기
- RedisInsight 데스크톱 앱(또는 웹 UI)을 엽니다.
- 좌측 데이터 브라우저(Browser) 패널을 새로고침합니다.
- 아래와 같이 콜론(
:)을 기준으로 완벽한 트리(Tree) 폴더 구조가 자동 생성된 것을 눈으로 확인하세요!
📁 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대 특징
- Non-blocking (논블로킹): 한 번 호출할 때 아주 짧은 시간만 작업하고 결과를 반환하므로, 다른 일반 명령어(GET/SET 등)가 실행될 틈을 줍니다.
- Cursor 기반: 0번 커서로 시작해 서버가 '다음 커서 번호'를 알려주면, 클라이언트는 그 번호로 다시 요청하는 과정을 반복합니다. (페이징과 유사)
- 상태 미유지 (Stateless): Redis 서버는 커서의 상태를 따로 메모리에 저장하지 않으므로 서버 자원을 소모하지 않습니다.
4. [실습] MATCH·COUNT로 안전하게 키 찾기
① 기본 사용 (커서 0으로 시작)
# SCAN [커서번호] [MATCH 패턴] [COUNT 개수]
127.0.0.1:6379> SCAN 0
1) "17" # 서버가 알려주는 '다음번에 사용할 커서 번호'
2) 1) "user:1" # 이번 턴에 찾은 키 목록들...
2) "item:100"
② 패턴 검색과 개수 조절
이전 강의에서 배운 계층형 구조(Colon)가 여기서 강력한 힘을 발휘합니다. 특정 도메인의 데이터만 안전하게 검색할 수 있습니다.
# '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. 사고를 예방하는 운영 습관
- 설정으로 원천 차단하기:
redis.conf파일에서 아래와 같이 설정하여KEYS명령어 자체를 아무도 쓰지 못하게 비활성화하는 것이 실무 표준입니다.
rename-command KEYS ""
- 애플리케이션 코드 (Python): 파이썬 코드에서 전체 키 목록을 가져오는 로직은 가급적 지양해야 합니다. 꼭 필요하다면
redis-py라이브러리에서 제공하는scan_iter()함수를 사용하여 내부적으로 커서를 자동 관리하게 만드세요. - 모니터링: 누군가 무거운 명령어를 치고 있지 않은지
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% 정도로 한계치를 설정하여 시스템 안정성을 확보해야 합니다.
# 현재 설정된 최대 메모리 설정 값 확인
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. 현재 설정 확인하고 바꿔보기
# 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-계열의 정책을 사용 중이라면 이야기가 달라집니다.
- allkeys-lru / lfu — TTL 설정 여부와 상관없이 메모리가 부족하면 모든 키를 삭제 후보로 봅니다. 즉, 영구 저장용 데이터도 오랫동안 안 썼다면 가차 없이 삭제될 수 있습니다.
- 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 방식을 제공합니다.
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)
[ 메모리 데이터 ]
│ (특정 조건 만족)
▼
[ BGSAVE 실행 ]
│ ① fork() → 자식 프로세스 생성
├────────────────────────────┐
▼ ▼
[ 부모 프로세스 ] [ 자식 프로세스 ]
계속해서 클라이언트의 메모리 데이터를 읽어
새로운 요청 처리 RDB 파일로 저장
│
▼
[ dump.rdb 생성 ]
⚙ RDB 저장 조건 확인 (redis.conf 설정 예시)
# 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)을 로그 파일에 순서대로 기록하는 방식입니다. 메모리 상태가 아닌 "명령어 자체"를 저장합니다.
💡 동작 원리
[ 클라이언트 명령 실행 ]
(예: SET user:1 "kim" / INCR view:100 / LPUSH log "login")
│
▼
[ Redis 메모리에 반영 ]
│
▼
[ AOF 파일(appendonly.aof 또는 Redis 7의 incr.aof)에 명령 기록 ]
복구 시: 서버 재시작 시 이 AOF 파일을 읽어 명령어를 처음부터 끝까지 순서대로 재실행(Replay) 하여 메모리 상태를 복구합니다.
⚙ AOF 동기화 정책 (appendfsync)
디스크에 얼마나 자주 쓸 것인지 결정하는 옵션입니다.
CONFIG GET appendonly # AOF 기능이 켜져 있는지 확인
CONFIG GET appendfsync # AOF 동기화 정책 확인
| 옵션 | 의미 | 특징 |
|---|---|---|
| always | 매 명령마다 디스크에 기록 | 가장 안전하지만, 디스크 I/O로 인해 느림 |
| everysec | 1초마다 기록 | 실무 기본값. 성능과 안전성의 최적의 타협점 |
| no | OS에 맡김 | 가장 빠르지만, OS 크래시 시 데이터 유실 위험 큼 |
no옵션은 Redis가 AOF 로그를 OS 버퍼까지만 쓰고 디스크 동기화는 운영체제에 맡기는 방식입니다.
🗜 AOF Rewrite (파일 압축)
AOF는 명령어를 계속 파일에 추가하므로 시간이 지나면 파일 크기가 무한정 커집니다. 이를 해결하기 위해 Redis는 현재 메모리 상태를 기준으로 로그를 재작성(압축)합니다.
[기존 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는 하드디스크에 저장된 백업 파일을 찾아 메모리로 다시 끌어올립니다. 이때 복구의 우선순위는 다음과 같습니다.
[ 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 기반 복구가 이뤄지지 않습니다. 이를 방지하기 위해 컨테이너를 삭제하고 완전히 초기화합니다.
docker rm -f my-redis
docker run -d --name my-redis -p 127.0.0.1:6379:6379 redis
2. 초기 데이터(1~100) 입력 및 수동 RDB 저장(스냅샷 저장)
# [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
# [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) 입력 (스냅샷 저장 안 함)
사진(스냅샷)을 찍은 이후에 들어온 데이터입니다.
# [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
# [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. 장애 상황 재현 및 복구 확인
# 서버 전원 강제 차단 (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로 새로 띄우기
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 명령어 없음!)
이번에는 수동으로 저장하는 과정 없이 그냥 데이터를 밀어 넣습니다.
# [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
# [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. 장애 상황 재현 및 복구 확인
# 서버 전원 강제 차단 (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를 관리합니다.
- 자동 저장 (
redis.conf): 설정된 조건을 만족하면 백그라운드에서 저장합니다. - [실무]: 실무에서는 트래픽이 폭주할 때 조건이 충족되어 예기치 않게 스냅샷 작업이 돌면서 서버에 렉(Latency Spike)이 걸리는 것을 방지하기 위해 자동 저장을 아예 끄는 경우가 있습니다.save ""(빈 문자열)로 설정하여 자동 저장을 끄고, AOF만 켜두거나 배치 백업을 활용합니다.- 배치 백업 (
BGSAVE): 트래픽이 가장 적은 새벽 시간에 자식 프로세스를 생성해 비동기적으로 저장하는BGSAVE명령어를 사용합니다.- 클라우드 스냅샷: 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에 데이터를 제대로 쏘고 있나?"를 확인하고 싶을 때, 개발/테스트 환경에서 훌륭한 디버깅 도구가 됩니다.
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 비활성화 | 거의 사용 안함 |
⚙ 설정 및 조회 방법
# 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
🧐 로그 결과 해석하기
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에 의도적으로 큰 데이터를 밀어 넣습니다.
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)' 찾기
데이터가 생성되었다면 터미널에서 다음 명령어를 실행하여 리포트를 확인합니다.
# 운영 중인 서버에 부하를 최소화하며 타입별로 가장 큰 키를 찾아줍니다.
docker exec my-redis redis-cli --bigkeys
💡 [운영 서버 실행 가이드]
--bigkeys는 운영 중에 사용해도 안전할까요?네, 비교적 안전합니다! 내부적으로 위험한
KEYS *가 아닌 안전한SCAN명령어를 사용하여 데이터를 조금씩 끊어서 검사하기 때문에 싱글 스레드인 레디스 서버가 멈추는 대형 장애를 유발하지 않습니다.하지만 전체 키를 순회하며 크기를 계산하므로 CPU 자원을 꽤 소모합니다. 따라서 트래픽이 폭발하는 피크 타임에는 실행을 피하고, 부하가 1%라도 걱정되는 매우 민감한 서버라면 아래와 같이
-i(interval) 옵션을 주어 스캔 중간중간에 휴식 시간을 부여하는 방식을 권장합니다.
# 0.1초 간격으로 쉬어가며 더욱 안전하게 스캔 (부하 최소화)
docker exec my-redis redis-cli --bigkeys -i 0.1
[결과 분석 및 해석 가이드]
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개의 명령을 뭉쳐서 보내고, 결과도 한 번에 받아옵니다. 속도가 수십 배 향상됩니다.
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에 담아두고 전역적으로 재사용해야 합니다.
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에서 대시보드까지의 데이터 경로
- Docker Compose(오케스트레이션): 여러 컨테이너를 한 번에 실행하고 서로 통신할 수 있게 묶어주는 도구입니다.
- Redis Exporter (통역사): Redis의
INFO데이터를 읽어서 → Prometheus가 수집할 수 있는/metrics형식으로 변환해 노출합니다. - Prometheus (데이터 수집 + 시계열 DB): Exporter의
/metrics를 주기적으로 수집(Scrape)해서 → 시계열 데이터베이스에 저장합니다. - Grafana (시각화 도구): Prometheus 데이터를 가져와 → Chart, Gauge, Dashboard 형태로 시각화합니다.
"Prometheus가 데이터를 모아서 저장하고, Grafana가 그것을 시각화한다"
3. 🛠 [실습] Docker Compose로 한 번에 띄우기
① 작업 폴더 만들기
원하는 위치에 redis-monitor라는 이름의 새 폴더를 하나 만듭니다. 앞으로 만들 두 개의 설정 파일은 모두 이 폴더 안에 저장합니다.
② docker-compose.yml 파일 작성
새로 만든 폴더 안에 docker-compose.yml 파일을 생성하고 아래 내용을 붙여넣습니다.
❓ 기존에 생성한 Redis를 모니터링하나요?
아래 설정은 아무것도 없는 상태에서 테스트용 새 Redis 컨테이너와 모니터링 도구를 함께 띄우는 기본 구성입니다.
[기본 설정: 새 Redis + 모니터링 도구 함께 실행]
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 포트를 사용 중이라서 발생하는 에러입니다. 두 가지 해결책이 있습니다.
- 기존 것 끄기: 터미널에서
docker rm -f my-redis명령어로 기존 컨테이너를 지워버린 후 다시 실행하세요.- 기존 것 그대로 쓰기 (추천): 현재 복사한 내용을 지우고, 바로 아래에 있는 🔥 [기존 Redis 사용] 코드를 복사해서 사용하세요!
🔥 기존 Redis 사용
이전 실습에서 생성한 my-redis를 모니터링하려면 아래 설정을 사용하세요.
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를 수집할 수 있도록 아래와 같이 설정합니다.
global:
# 실습에서는 그래프 변화를 빨리 보기 위해 5초로 설정
# (실무에서는 보통 15초를 많이 사용)
scrape_interval: 5s
scrape_configs:
- job_name: 'redis_exporter'
static_configs:
- targets: ['redis-exporter:9121']
④ 실행 및 접속
터미널을 열고 해당 폴더(redis-monitor)로 이동한 뒤 아래 명령어를 실행합니다. ("docker-compose.yml에 정의된 모든 컨테이너를 백그라운드에서 한 번에 실행하는 명령어")
docker compose up -d
이제 브라우저에서 http://localhost:3000으로 Grafana에 접속합니다. 초기 로그인 계정은 admin/admin입니다.
4. 📊 대시보드에서 실제로 볼 지표 고르기
Grafana에 접속한 후, 먼저 Data Sources에서 Prometheus(http://prometheus:9090)를 연결합니다. 그 다음 좌측 메뉴에서 Dashboards → Import를 클릭하여 대시보드 ID를 입력합니다.
🎨 추천 대시보드 ID
- ID 14091 (추천 ⭐): 최신 UI 기반으로 가장 직관적이고 실습에 적합한 템플릿.
- ID 763 (클래식 대안): Redis Exporter 공식 대시보드로 꾸준히 사용됨.
- 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를 유발, 적중률 그래프를 출렁이게 합니다.
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)하는 명령입니다.
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에서 다룬 것을 한 장으로
[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를 도입할 때 아래 순서대로 자문하면 대부분의 설계 실수를 예방할 수 있습니다.
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초 안에 상태 파악하기
# 응답 여부와 지연 확인
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. 대용량 서비스 설계
7-1. 캐시가 틀려지는 것을 전제로 한 무효화 설계
섹션 7. [Appendix] 보너스 트랙 · 유닛 45 / 48 · 보충 학습 자료
이 글에서 설계할 것
- 캐시와 원본 데이터의 불일치가 언제, 왜 발생하는지 구조적으로 이해합니다.
- TTL 기반 · 명시적 삭제 · 버전 · 태그 · 이벤트 기반 무효화 전략을 비교하고 선택 기준을 세웁니다.
- Cache-Aside의 고전적 경쟁 조건과 지연 이중 삭제(Delayed Double Delete) 해법을 구현합니다.
- 다중 서버 · 2계층 캐시 환경에서 무효화를 전파하는 방법을 익힙니다.
- 무효화 실패를 전제로 한 안전망 설계(백스톱 TTL)를 코드에 반영합니다.
1. 문제 정의 — 캐시는 반드시 원본과 어긋난다
캐시는 원본의 사본입니다. 사본이 존재하는 순간부터 원본과 어긋날 가능성이 생깁니다. 이 어긋난 데이터를 스테일 데이터(Stale Data) 라고 부르고, 이를 걷어내는 작업이 캐시 무효화입니다.
t=0 DB: price = 10000 Redis: price = 10000 일치
t=1 DB: price = 8000 Redis: price = 10000 불일치 발생 (스테일)
^^^^^^^^^^^^
이 순간부터 사용자는 잘못된 가격을 본다.
결제 시스템이 DB를 본다면 "장바구니 가격과 결제 가격이 다름" 사고로 이어진다.
왜 어려운가
컴퓨터 과학에서 가장 어려운 두 가지가 캐시 무효화와 이름 짓기라는 오래된 농담이 있습니다. 무효화가 어려운 이유는 명확합니다.
- 어떤 캐시 키가 이 데이터에 영향을 받는지 코드가 알기 어렵다(목록 캐시, 집계 캐시, 상세 캐시가 모두 얽혀 있다).
- 무효화 작업 자체가 실패할 수 있다(Redis 일시 장애, 애플리케이션 크래시).
- 무효화와 조회가 동시에 일어나면 순서가 뒤바뀔 수 있다(경쟁 조건).
- 서버가 여러 대면 모든 서버의 로컬 캐시까지 전파해야 한다.
2. 먼저 정할 것 — 이 데이터는 얼마나 틀려도 되는가
모든 데이터에 최고 수준의 정합성을 적용하면 캐시를 쓰는 의미가 사라집니다. 데이터별로 허용 가능한 스테일 시간(staleness budget) 을 먼저 정의하는 것이 설계의 출발점입니다.
| 정합성 수준 | 허용 지연 | 데이터 예시 | 권장 전략 |
|---|---|---|---|
| 강한 정합성 | 0초 (불일치 불가) | 잔액, 재고, 쿠폰 사용 여부, 좌석 예약 | 캐시하지 않거나 Redis를 원본으로 사용. 캐시한다면 쓰기 시 즉시 삭제 + 짧은 TTL |
| 준실시간 | 1-10초 | 상품 가격, 재고 표시용 수치, 프로필 | 쓰기 시 명시적 삭제 + 백스톱 TTL(수 분) |
| 최종적 정합성 | 1-10분 | 상품 목록, 카테고리, 배너, 추천 | TTL 만료에 위임 + 관리자 변경 시에만 삭제 |
| 느슨함 | 1시간 이상 | 인기 검색어, 통계, 집계 리포트 | 스케줄러가 주기적으로 갱신(사전 계산) |
현업 대화법
기획자에게 "정합성이 필요합니까?"라고 물으면 100% "네"라는 답이 돌아옵니다. 대신 "관리자가 가격을 바꾼 뒤 사용자 화면에 30초 뒤에 반영되면 문제가 됩니까?"처럼 구체적인 시간으로 물어야 실제 요구 수준이 드러납니다.
3. 전략 1 — TTL에 맡기는 수동적 만료
가장 단순하고 가장 강력한 안전망입니다. 아무것도 하지 않아도 시간이 지나면 캐시가 사라집니다.
# 5분 뒤 자동 소멸
r.setex(f"product:{pid}", 300, json.dumps(product))
| 구분 | 내용 |
|---|---|
| 장점 | 구현 비용 0. 무효화 코드 누락이 있어도 최대 TTL 시간이면 자동 복구된다 |
| 단점 | TTL 동안은 스테일 데이터가 노출된다. 짧게 잡으면 DB 부하가 커진다 |
| 필수 조건 | 모든 캐시 키에는 예외 없이 TTL을 건다. TTL 없는 키는 메모리 누수이자 영원한 스테일 데이터다 |
3.1 만료 시각을 흩뿌리는 지터
동일한 TTL로 대량의 키를 한꺼번에 채우면, 같은 시각에 모두 만료되어 DB에 순간적인 폭탄이 떨어집니다(7-2에서 다룰 캐시 스탬피드의 한 형태). TTL에 무작위 편차를 더해 만료 시점을 흩뜨립니다.
import random
BASE_TTL = 300 # 5분
def ttl_with_jitter(base: int = BASE_TTL, ratio: float = 0.2) -> int:
"""base의 +/- 20% 범위로 TTL을 흩뿌린다."""
delta = int(base * ratio)
return base + random.randint(-delta, delta) # 240 ~ 360초
r.setex(f"product:{pid}", ttl_with_jitter(), payload)
4. 전략 2 — 쓰기 시점에 직접 걷어내기
데이터를 변경하는 쪽에서 관련 캐시를 직접 걷어냅니다. 여기서 첫 번째 중요한 선택이 등장합니다.
4.1 갱신이 아니라 삭제여야 하는 이유
| 방식 | 동작 | 평가 |
|---|---|---|
| 캐시 갱신 (Write-Update) | DB를 수정하면서 캐시에도 새 값을 써 넣는다 | 동시 쓰기 시 순서가 뒤집혀 오래된 값이 최종적으로 남을 수 있다. 또한 아무도 조회하지 않을 데이터를 미리 계산하는 낭비 발생 |
| 캐시 삭제 (Write-Invalidate) | DB를 수정하고 캐시 키를 지운다. 다음 조회가 DB에서 새로 채운다 | 권장. 삭제는 멱등(여러 번 해도 결과 동일)하고 순서 뒤집힘의 피해가 훨씬 작다 |
[캐시 갱신의 위험 - 쓰기 순서 역전]
요청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을 읽어 캐시에 채움 <- 항상 최신
4.2 DB 먼저인가 캐시 먼저인가
# 권장 순서
db.update(product) # 1) 원본을 먼저 확정
r.delete(f"product:{pid}") # 2) 그 다음 캐시 제거
# 위험한 순서
r.delete(f"product:{pid}") # 1) 캐시를 먼저 지우면
db.update(product) # 이 사이에 들어온 조회가 "옛날 DB 값"을 다시 캐싱한다
5. 쓰기 전략을 한 장에 놓고 비교하기
| 패턴 | 동작 | 장점 | 단점 |
|---|---|---|---|
| Cache-Aside (Lazy Loading) | 앱이 캐시 미스 시 DB 조회 후 캐시에 저장. 쓰기 시에는 캐시 삭제 | 가장 보편적. 캐시 장애 시에도 서비스 동작 | 첫 조회는 항상 느림. 무효화 코드를 앱이 관리 |
| Read-Through | 캐시 계층이 미스 시 스스로 DB를 조회 | 애플리케이션 코드 단순화 | 캐시 라이브러리/프록시 의존 |
| Write-Through | 쓰기를 캐시에 하면 캐시가 DB에도 동기 기록 | 캐시가 항상 최신. 정합성 우수 | 쓰기 지연 증가. 안 읽을 데이터도 캐싱 |
| Write-Behind (Write-Back) | 캐시에만 기록하고 DB에는 비동기로 반영 | 쓰기 성능 최고(조회수 카운팅 등) | 캐시 유실 시 데이터 손실. 영속성 설정 필수 |
실무 선택
대부분의 웹 서비스는 Cache-Aside + 쓰기 시 삭제 + 백스톱 TTL 조합으로 충분합니다. 조회수·좋아요처럼 쓰기가 매우 잦고 약간의 유실이 허용되는 데이터에만 선택적으로 Write-Behind를 적용하십시오 (섹션 5-6 참고).
6. 삭제해도 되살아나는 스테일 데이터
DB 먼저 → 캐시 삭제 순서를 지켜도 완전하지 않습니다. 읽기 요청이 캐시를 채우는 시점과 쓰기 요청이 캐시를 지우는 시점이 교차하면 스테일 데이터가 TTL 만료 시까지 살아남습니다.
시간 읽기 요청 R 쓰기 요청 W
t1 캐시 조회 -> MISS
t2 DB 조회 -> price=10000
t3 DB 갱신 -> price=8000
t4 캐시 DEL (지울 것이 없음)
t5 캐시에 10000 기록 <- 지워진 뒤에 옛날 값이 들어온다
결과: 캐시=10000, DB=8000이 TTL 끝날 때까지 유지된다
6.1 해법 — 잠시 뒤 한 번 더 지우기
쓰기 직후 한 번 지우고, 읽기 요청이 캐시를 채우고도 남을 시간(수백 ms ~ 수 초)이 지난 뒤 한 번 더 지웁니다.
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 같은 영속 큐에 지연 작업을 등록하십시오.- 지연 시간은 "DB 조회 + 직렬화 + 캐시 쓰기"의 p99보다 커야 의미가 있습니다. 보통 0.5-2초로 설정합니다.
- 그래도 100% 보장은 아닙니다. TTL은 언제나 최후의 안전망으로 함께 걸어야 합니다.
- 더 강한 보장이 필요하면 조회 경로에 락을 걸어 채우기 자체를 직렬화합니다(7-3 참고).
7. 전략 3 — 버전 키로 통째로 갈아치우기
"카테고리 전체 캐시를 한 번에 날려야 한다" 같은 대량 무효화 상황에서 키를 하나씩 지우는 것은 비현실적입니다. 이때 키 이름에 버전을 포함시키고 버전 숫자만 올려 기존 캐시를 통째로 고아(orphan)로 만듭니다.
async def get_version(namespace: str) -> int:
v = await r.get(f"ver:{namespace}")
return int(v) if v else 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}" # 버전이 키에 포함된다
cached = await r.get(key)
if cached:
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:v1:shoes:* 키들은 더 이상 아무도 조회하지 않는다.
# TTL이 지나면 자연히 메모리에서 제거된다.
| 구분 | 내용 |
|---|---|
| 장점 | INCR 한 번(O(1))으로 수천 개 키를 무효화. KEYS/SCAN 불필요 |
| 단점 | 구버전 키가 TTL까지 메모리에 남는다(메모리 여유 필요). 조회 시 버전 조회로 왕복 1회 추가 |
| 최적화 | 버전 값을 애플리케이션 로컬에 짧게(1-3초) 캐싱해 왕복을 줄인다 |
절대 금지 — KEYS 패턴 삭제
# 운영 서버를 멈추게 하는 코드
for k in r.keys("list:shoes:*"): # O(N) 전수 조사 + 싱글 스레드 블로킹
r.delete(k)
수백만 키가 있는 서버에서
KEYS는 수 초간 Redis 전체를 정지시킵니다(6-2 참고). 부득이하게 패턴 삭제가 필요하면SCAN을 커서로 나눠 돌리되, 애초에 버전 무효화나 태그 방식으로 설계를 바꾸는 것이 정답입니다.
8. 전략 4 — 태그를 걸어 묶음으로 무효화하기
하나의 데이터 변경이 여러 캐시 키에 영향을 줄 때 사용합니다. "이 상품이 포함된 모든 캐시 키"를 Set으로 관리해 두고, 변경 시 그 Set을 순회해 삭제합니다.
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}"
keys = await r.smembers(tag_key)
if keys:
await r.delete(*keys)
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이 무한히 커지면(수십만 원소)
SMEMBERS가 O(N) 블로킹을 유발합니다. 태그당 원소 수를 제한하거나SSCAN으로 나눠 처리하고, 태그 Set에는 반드시 TTL을 부여하십시오.
9. 전략 5 — 이벤트를 뿌려 모든 서버에 전파하기
애플리케이션 서버가 로컬 캐시(인메모리)를 함께 쓰는 2계층 구조라면, Redis만 지워서는 각 서버의 로컬 캐시가 남습니다. 이때 Pub/Sub으로 무효화 신호를 방송합니다.
[ 관리자가 상품 수정 ]
|
DB 갱신 + Redis DEL
|
PUBLISH cache:invalidate "product:1001"
|
+---------------------+---------------------+
v v v
[WAS #1] [WAS #2] [WAS #3]
로컬캐시 제거 로컬캐시 제거 로컬캐시 제거
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":
key = msg["data"]
local_cache.pop(key, 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차: 로컬 메모리 (가장 빠름)
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). 메시지 발행 순간 구독 중이 아닌 서버(배포 중, 네트워크 순단)는 신호를 영구히 놓칩니다. 따라서 로컬 캐시의 TTL은 반드시 짧게(수 초 ~ 수십 초) 유지해야 합니다. 신호 유실이 치명적이라면 Pub/Sub 대신 Redis Stream(섹션 4-2)이나 Kafka 같은 영속 메시징을 사용하십시오.
9.1 참고 — 키스페이스 알림과 RESP3 클라이언트 캐싱
| 기능 | 개요 | 평가 |
|---|---|---|
| 키스페이스 알림 (Keyspace Notification) | notify-keyspace-events 설정으로 키 변경·만료 이벤트를 Pub/Sub으로 수신 |
Redis 자체 변경을 감지할 때 유용. 단 이벤트 유실 가능하고 부하가 있어 전면 활성화는 비권장 |
| 클라이언트 사이드 캐싱 (RESP3 Tracking) | Redis 6+ 기능. 서버가 클라이언트별로 조회한 키를 추적해 변경 시 무효화 메시지를 직접 통지 | 2계층 캐시를 Redis가 대신 관리해 준다. 지원 클라이언트가 필요하며 서버 메모리(추적 테이블) 사용 |
| CDC (Change Data Capture) | DB의 binlog/WAL를 읽어 변경을 감지하고 무효화 이벤트 발행(Debezium 등) | 애플리케이션 코드가 무효화를 빠뜨려도 동작한다. 대규모 조직의 최종 해법이지만 인프라 비용이 큼 |
# 만료 이벤트만 켜기 (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
# 다른 창에서 키가 만료되면 여기로 키 이름이 도착한다
10. 무효화가 실패한다는 가정에서 다시 설계하기
현실 인식
무효화는 반드시 어딘가에서 실패합니다. 배포 누락, 예외로 인한 삭제 코드 미실행, Redis 순단, 개발자가 새 캐시 키를 추가하며 무효화 코드를 빠뜨림 등. 따라서 "무효화가 100% 동작한다"는 가정 위에 시스템을 세우면 안 됩니다.
| 안전장치 | 내용 |
|---|---|
| 백스톱 TTL | 모든 캐시에 TTL 부여. 무효화가 실패해도 손실 시간의 상한이 생긴다 |
| 무효화 실패 로깅 | 삭제 명령의 예외를 삼키지 말고 로그·메트릭으로 남긴다 |
| 단일 진입점 | 캐시 읽기/쓰기/삭제를 저장소 계층 한 곳으로 모은다. 흩어진 r.get·r.delete 호출은 누락의 원인 |
| 키 규칙 문서화 | 어떤 도메인 변경이 어떤 키에 영향을 주는지 표로 관리(6-1 네이밍 전략과 함께) |
| 수동 퍼지 도구 | 운영자가 특정 네임스페이스를 즉시 비울 수 있는 관리 API를 준비(사고 시 복구 시간 단축) |
| 정합성 모니터링 | 샘플링으로 캐시값과 DB값을 비교해 불일치율을 지표화 |
10.1 캐시 계층을 한 곳으로 감싸는 예시
import json
import logging
from typing import Any, Callable, Awaitable
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을 선택 사항으로 두지 않는다 (누락 방지)
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
11. 여기서 자주 나오는 안티패턴
| 안티패턴 | 무엇이 문제인가 | 대안 |
|---|---|---|
| TTL 없는 캐시 키 | 무효화가 실패하면 영원히 스테일. 메모리 누수 | 모든 키에 TTL. Eviction 정책도 함께 설정(6-3) |
| KEYS로 패턴 삭제 | O(N) 블로킹으로 전체 장애 유발 | 버전 무효화 또는 태그 무효화 |
| 캐시를 원본처럼 사용 | Eviction으로 데이터가 사라짐 | 원본은 DB. Redis를 원본으로 쓰려면 영속성·복제 설계 필요(6-4) |
| 캐시 갱신(Write-Update) 남용 | 동시 쓰기 시 옛 값이 최종으로 남음 | 삭제 후 다음 조회에 채우기 |
모든 곳에서 직접 r.delete |
무효화 누락이 필연적으로 발생 | 저장소 계층으로 캡슐화 |
| 동일 TTL 대량 세팅 | 동시 만료로 DB 폭탄 | 지터 적용(그리고 7-2의 스탬피드 방어) |
12. 실습 가이드 — 스테일 데이터를 직접 만들어보기
1️⃣ 지연을 넣은 캐시 채우기 코드 준비
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()
print("DB :", DB["price"])
print("Redis :", r.get("product:1"))
2️⃣ 실행 결과 확인
$ python stale_demo.py
[writer] DB 갱신 + 캐시 삭제
[reader] 캐시에 기록: 10000
DB : 8000
Redis : 10000 <- 스테일 데이터가 60초간 살아남는다
3️⃣ 지연 이중 삭제를 추가하고 재실행
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 갱신 + 지연 이중 삭제 예약")
확인 결과
- 결과: 1차 삭제만 있을 때는 Redis에 10000이 남지만, 지연 이중 삭제를 추가하면 1.5초 뒤 캐시가 비워지고 다음 조회에서 8000이 채워집니다.
- 의미: 캐시 무효화는 "지웠다"가 아니라 "언제 지웠는가"의 문제입니다. 읽기와 쓰기가 교차하는 짧은 구간을 어떻게 덮을 것인지가 설계의 핵심이며, 그 위에는 항상 TTL이라는 마지막 안전망이 있어야 합니다.
13. 정리 — 무효화 전략 선택표
| 전략 | 언제 쓰는가 | 비용 / 주의 |
|---|---|---|
| TTL 만료 | 모든 캐시의 기본값 | 비용 0. 반드시 지터와 함께 |
| 쓰기 시 삭제 | 준실시간 정합성이 필요한 데이터 | DB 먼저, 캐시 나중. 갱신이 아니라 삭제 |
| 지연 이중 삭제 | 읽기·쓰기 경합이 잦은 핫 데이터 | 지연 작업의 영속성 확보 필요 |
| 버전 무효화 | 목록·집계 등 대량 키 일괄 무효화 | 구버전 키가 TTL까지 메모리 점유 |
| 태그 무효화 | 하나의 변경이 여러 키에 영향 | 태그 Set 크기 관리 필요 |
| 이벤트 방송 | 다중 서버 + 로컬 캐시 2계층 | Pub/Sub 유실 가능. 로컬 TTL 짧게 |
한 줄 요약
캐시 무효화의 목표는 완벽한 일치가 아니라 허용 가능한 불일치 시간의 관리입니다. 데이터별 정합성 예산을 정하고, 능동적 삭제로 평균을 낮추고, TTL로 최악을 제한하십시오. 다음 유닛(7-2)에서는 무효화 직후에 찾아오는 또 다른 위협, 캐시 스탬피드를 다룹니다.
7-2. 캐시 하나가 만료되는 순간 DB가 무너지는 문제
섹션 7. [Appendix] 보너스 트랙 · 유닛 46 / 48 · 보충 학습 자료
이 글에서 막을 동시 폭주
- 캐시 스탬피드(Thundering Herd / Dogpile)가 어떤 메커니즘으로 전체 장애로 번지는지 이해합니다.
- 스탬피드가 발생하는 세 가지 유형(동시 만료 · 핫키 만료 · 콜드 스타트)을 구분합니다.
- 지터 · 락 · 논리적 만료 · 확률적 조기 갱신 · 사전 워밍업 등 방어 전략을 비교하고 선택합니다.
- 각 전략의 코드를 작성하고, 실제로 스탬피드를 재현해 방어 효과를 측정합니다.
1. 현상 정의 — 만료 직후 몰려드는 요청들
캐시 스탬피드는 하나의 캐시 키가 만료되는 순간, 그 키를 기다리던 모든 요청이 동시에 원본(DB)으로 몰려가는 현상입니다. 소 떼가 한꺼번에 몰려가는 모습에 빗대어 Thundering Herd, 개들이 먹이에 달려드는 모습에 빗대어 Dogpile이라고도 부릅니다.
[평상시 - 캐시 히트율 99%]
10,000 req/s -> Redis -> 응답 (DB 부하: 거의 0)
[캐시 만료 순간 - 스탬피드]
10,000 req/s -> Redis (MISS) -> DB로 10,000개 쿼리 동시 유입
|
v
커넥션 풀 100개 -> 즉시 고갈
대기 큐 폭증 -> 타임아웃
DB CPU 100% -> 모든 쿼리 지연
|
v
캐시를 채우지 못함 -> 다음 요청도 MISS
|
v
[ 연쇄 장애 / 서비스 다운 ]
가장 위험한 점
스탬피드는 스스로 악화되는 양의 되먹임(positive feedback) 구조입니다. DB가 느려지면 캐시를 채우는 시간이 길어지고, 그 사이 미스가 더 쌓이며, 다시 DB가 더 느려집니다. 트래픽을 줄이지 않는 한 자연 회복되지 않습니다. 실제로 대형 서비스 장애의 상당수가 "캐시 하나가 만료된 것"에서 시작합니다.
1.1 트래픽 수치로 체감해보기
| 조건 | 값 | 결과 |
|---|---|---|
| 인기 상품 페이지 트래픽 | 5,000 req/s | - |
| 원본 쿼리 소요 시간 | 200 ms | - |
| 캐시 만료 후 채워지기까지 | 200 ms | 그 사이 유입되는 요청 = 5,000 × 0.2 = 1,000건 |
| DB 커넥션 풀 | 100개 | 1,000건 중 100건만 즉시 처리, 900건 대기 |
| 대기 타임아웃 | 3초 | 대기 큐가 길어지며 타임아웃 에러 폭증 |
즉, 단 200밀리초의 빈틈이 1,000개의 중복 쿼리를 만듭니다. 이 중 999개는 완전히 불필요한 낭비입니다. 결과는 모두 같기 때문입니다.
2. 폭주가 시작되는 세 가지 경로
| 유형 | 발생 상황 | 주 방어 전략 |
|---|---|---|
| A. 동시 만료 (Mass Expiration) | 배치로 한꺼번에 채운 수천 개의 키가 같은 TTL을 가져 같은 초에 동시 만료. 배포 직후 캐시를 일괄 적재한 경우 전형적 | TTL 지터, 사전 갱신 |
| B. 핫키 만료 (Hot Key) | 메인 배너, 실시간 랭킹처럼 단일 키에 트래픽이 집중. 그 키 하나가 만료되면 전체 트래픽이 DB로 직행 | 락, 논리적 만료, 확률적 조기 갱신 |
| C. 콜드 스타트 (Cold Start) | Redis 재기동, FLUSHALL, 장애 복구, 신규 노드 투입으로 캐시가 통째로 비어 있는 상태에서 트래픽 유입 | 워밍업, 부하 차단(서킷 브레이커), 점진적 트래픽 투입 |
유형 C가 가장 무섭다
A와 B는 특정 키의 문제지만, C는 모든 키가 동시에 미스인 상황입니다. Redis를 재시작하거나 장애 복구 후 그대로 트래픽을 전량 투입하면 DB가 즉시 무너집니다. 운영 절차로 "캐시 워밍업 후 트래픽 투입"을 반드시 문서화하십시오.
3. 방어 수단을 한 장에 펼쳐놓기
[ 캐시 스탬피드 방어 ]
|
+---------------------+---------------------+
| | |
[분산시키기] [단일화하기] [비우지 않기]
| | |
1) TTL 지터 3) 분산 락 5) 논리적 만료
2) 확률적 조기 갱신 4) 요청 병합 6) 사전 갱신(워밍업)
| | |
만료 시점을 흩뿌려 한 명만 DB에 가고 캐시를 절대 비우지 않고
동시 미스를 줄인다 나머지는 기다린다 백그라운드로 교체한다
4. 전략 1 — 만료 시각을 흩뿌리는 지터
가장 저렴하고 즉시 적용 가능한 방어책입니다. 유형 A(동시 만료)에 직접적으로 효과가 있습니다.
import random
def ttl_with_jitter(base: int, ratio: float = 0.2) -> int:
"""TTL을 base의 +/- ratio 범위로 흩뿌린다."""
delta = int(base * ratio)
return base + random.randint(-delta, delta)
# 잘못된 예: 1000개 키가 정확히 같은 초에 만료된다
for pid in product_ids:
r.setex(f"product:{pid}", 600, payload)
# 올바른 예: 480 ~ 720초 사이로 분산되어 만료된다
for pid in product_ids:
r.setex(f"product:{pid}", ttl_with_jitter(600), payload)
| 구분 | 내용 |
|---|---|
| 효과 | 유형 A를 거의 완전히 해소. 만료가 시간축에 고르게 퍼진다 |
| 한계 | 유형 B(핫키)에는 무력하다. 키가 하나뿐이면 분산할 대상이 없다 |
| 권장 | 모든 캐시의 기본값으로 적용. 다른 전략과 항상 함께 사용 |
5. 전략 2 — 만료 전에 미리 다시 만들기 (XFetch)
TTL이 다가올수록 확률적으로 미리 캐시를 갱신합니다. 만료 직전에 어느 한 요청이 먼저 갱신을 수행하므로, 실제 만료 시점에는 이미 새 값이 들어 있습니다. 학계에서는 XFetch라는 알고리즘으로 알려져 있습니다.
5.1 확률로 조기 갱신을 결정하는 아이디어
TTL 600초짜리 캐시
0 ~ 500초 : 갱신 확률 거의 0 -> 대부분 그냥 캐시 사용
500 ~ 600초 : 남은 시간이 짧아질수록 확률 상승
일부 요청이 "내가 먼저 갱신하겠다"고 나선다
600초 : 이미 갱신되어 있으므로 만료 순간의 미스 폭발이 없다
import json
import math
import random
import time
import redis
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
BETA = 1.0 # 클수록 더 이르게 갱신을 시도한다
def get_with_early_recompute(key: str, ttl: int, loader):
"""
캐시 값과 함께 '생성에 걸린 시간(delta)'을 저장해 두고,
남은 TTL이 delta * beta * ln(rand)보다 작아지면 미리 갱신한다.
"""
raw = r.get(key)
if raw is not None:
payload = json.loads(raw)
delta = payload["delta"] # 원본 조회에 걸린 시간(초)
remaining = r.ttl(key)
# XFetch: 재계산 비용이 크고 만료가 가까울수록 조기 갱신 확률 증가
threshold = delta * BETA * -math.log(random.random())
if remaining > threshold:
return payload["value"] # 아직 여유 있음 -> 캐시 사용
# 여기로 내려오면 "내가 미리 갱신한다"
# 원본 조회 + 소요 시간 측정
started = time.perf_counter()
value = loader()
delta = time.perf_counter() - started
r.setex(key, ttl, json.dumps({"value": value, "delta": delta}))
return value
| 구분 | 내용 |
|---|---|
| 장점 | 락이 없어 대기 지연이 0. 만료 순간의 미스 자체를 사전에 제거 |
| 단점 | 일부 요청이 갱신 비용을 부담(응답이 느려짐). 완전한 중복 제거는 아님 |
| 적합 | 재계산 비용이 크고 트래픽이 꾸준한 핫키(랭킹, 집계, 추천) |
6. 전략 3 — 락으로 재계산을 한 번만 돌리기
가장 직관적인 방어입니다. 캐시 미스가 발생하면 락을 잡은 하나의 요청만 DB에 가고, 나머지는 기다렸다가 채워진 캐시를 읽습니다. 구현 상세는 다음 유닛(7-3)에서 전부 다룹니다.
요청 1000개 동시 도착 -> 캐시 MISS
|
SET lock NX 경쟁
|
+-----+-----------------------------+
| |
[1개 성공] [999개 실패]
DB 조회 (200ms) 잠깐 대기 후 캐시 재조회
캐시 저장 -> 채워진 값을 읽고 응답
락 해제
|
DB 쿼리: 1000개 -> 1개 (99.9% 감소)
import json
import time
import uuid
def get_with_lock(key: str, ttl: int, loader, wait: float = 0.05, max_wait: float = 2.0):
cached = r.get(key)
if cached is not None:
return json.loads(cached)
lock_key = f"lock:{key}"
token = str(uuid.uuid4())
# 락 획득 시도 (원자적)
if r.set(lock_key, token, nx=True, ex=5):
try:
value = loader() # 나만 DB에 간다
r.setex(key, ttl, json.dumps(value))
return value
finally:
release_lock(lock_key, token) # Lua로 소유권 확인 후 삭제
# 락 획득 실패 -> 채워질 때까지 짧게 대기하며 재조회
deadline = time.time() + max_wait
while time.time() < deadline:
time.sleep(wait)
cached = r.get(key)
if cached is not None:
return json.loads(cached)
# 끝내 못 받으면 폴백 (7-3에서 상세히 다룬다)
return loader()
| 구분 | 내용 |
|---|---|
| 장점 | DB 쿼리를 정확히 1개로 줄인다. 효과가 가장 확실 |
| 단점 | 대기하는 요청의 응답 지연이 증가. 락 소유자가 죽으면 대기자 전체가 지연 |
| 필수 | 락 TTL, 소유권 검증 해제, 대기 상한, 폴백 경로 — 하나라도 빠지면 새로운 장애 원인이 된다 |
7. 전략 4 — 만료시키지 않고 낡은 값을 내주기
Redis의 TTL을 아예 걸지 않거나 아주 길게 잡고, 값 안에 논리적 만료 시각을 넣습니다. 만료된 값을 읽으면 일단 낡은 값을 그대로 응답하고, 갱신은 백그라운드에서 처리합니다.
캐시 값 = { data: {...}, expire_at: 1735800000 }
조회 시:
now < expire_at -> 그대로 응답 (정상)
now >= expire_at -> 낡은 값을 즉시 응답 (사용자는 기다리지 않는다)
+ 락을 잡은 한 명만 백그라운드에서 갱신
=> 어떤 요청도 DB를 기다리지 않는다. 미스 자체가 존재하지 않는다.
import asyncio
import json
import time
import redis.asyncio as redis
r = redis.from_url("redis://localhost:6379", decode_responses=True)
LOGICAL_TTL = 300 # 논리적 신선도 (초)
PHYSICAL_TTL = 86400 # 물리적 보관 기간 (훨씬 길게)
async def get_with_logical_expiry(key: str, loader):
raw = await r.get(key)
# 캐시가 아예 없는 경우(콜드 스타트)는 어쩔 수 없이 동기 로딩
if raw is None:
value = await loader()
await _store(key, value)
return value
payload = json.loads(raw)
# 아직 신선하면 그대로 반환
if time.time() < payload["expire_at"]:
return payload["value"]
# 논리적으로 만료됨: 낡은 값을 즉시 반환하고, 갱신은 백그라운드로
if await r.set(f"lock:{key}", "1", nx=True, ex=10):
asyncio.create_task(_refresh(key, loader))
return payload["value"] # 사용자는 기다리지 않는다
async def _store(key: str, value):
await r.setex(key, PHYSICAL_TTL, json.dumps({
"value": value,
"expire_at": time.time() + LOGICAL_TTL,
}))
async def _refresh(key: str, loader):
try:
value = await loader()
await _store(key, value)
finally:
await r.delete(f"lock:{key}")
| 구분 | 내용 |
|---|---|
| 장점 | 대기 지연이 0. 사용자 응답 시간이 항상 캐시 수준으로 일정하다 |
| 단점 | 갱신 중에는 낡은 값이 응답된다. 정합성 요구가 높은 데이터에는 부적합 |
| 주의 | 물리적 TTL이 길어 메모리 사용량이 증가한다. Eviction 정책과 함께 검토(6-3) |
| 적합 | 랭킹, 추천, 인기 목록, 통계처럼 수 분의 지연이 허용되는 데이터 |
참고
HTTP 캐시 표준에도 같은 개념이 있습니다(
Cache-Control: stale-while-revalidate). CDN이 이미 널리 쓰는 검증된 패턴이며, 애플리케이션 캐시에도 동일하게 적용할 수 있습니다.
8. 전략 5 — 만료 전에 미리 채워두기
사용자 요청이 캐시를 채우게 두지 않고, 스케줄러나 배치가 미리 채워 둡니다. 캐시 미스라는 사건 자체를 없애는 접근입니다.
# 1) 주기적 사전 갱신 (APScheduler 예시)
from apscheduler.schedulers.asyncio import AsyncIOScheduler
scheduler = AsyncIOScheduler()
@scheduler.scheduled_job("interval", seconds=240) # TTL(300초)보다 짧게
async def refresh_hot_keys():
"""TTL 만료 전에 미리 새 값으로 덮어쓴다. 사용자는 영원히 미스를 겪지 않는다."""
for pid in await get_hot_product_ids(limit=100):
value = await db.get_product(pid)
await r.setex(f"product:{pid}", 300, json.dumps(value))
# 2) 배포/재기동 시 워밍업
async def warmup():
"""트래픽을 받기 전에 핵심 캐시를 채운다. 헬스체크 통과 조건에 포함시킨다."""
top = await db.get_top_products(limit=500)
pipe = r.pipeline()
for row in top:
pipe.setex(f"product:{row['id']}", ttl_with_jitter(600), json.dumps(row))
await pipe.execute()
운영 절차로 고정하십시오
- Redis를 재기동했다면 워밍업 완료 전까지 로드밸런서에 붙이지 않습니다.
- 워밍업 완료 여부를
/health/ready응답에 반영해 오케스트레이터가 판단하게 합니다.- 불가피하게 콜드 상태로 트래픽을 받아야 한다면 점진적 투입(10% → 50% → 100%)으로 완충합니다.
9. 전략 6 — 같은 요청을 하나로 합치기
같은 프로세스 안에서 동일 키에 대한 중복 요청을 하나의 실행으로 합칩니다. Redis 락이 서버 간 중복을 막는다면, 요청 병합은 서버 내부 중복을 막습니다. 두 가지를 함께 쓰면 방어가 완성됩니다.
import asyncio
_inflight: dict[str, asyncio.Future] = {}
async def coalesced_load(key: str, loader):
"""같은 키에 대한 동시 호출을 하나의 loader 실행으로 병합한다."""
fut = _inflight.get(key)
if fut is not None:
return await fut # 이미 누군가 로딩 중 -> 결과만 공유받는다
loop = asyncio.get_running_loop()
fut = loop.create_future()
_inflight[key] = fut
try:
value = await loader()
fut.set_result(value)
return value
except Exception as exc:
fut.set_exception(exc)
raise
finally:
_inflight.pop(key, None)
서버 1대당 절감 효과
WAS 1대가 초당 500건을 처리한다면, 요청 병합만으로도 그 서버의 DB 쿼리가 500 → 1로 줄어듭니다. 서버가 10대라면 DB에는 최대 10개의 쿼리만 도달합니다. 여기에 Redis 락을 더하면 최종적으로 1개가 됩니다.
10. 전략별 비용과 효과 비교
| 전략 | DB 절감 | 응답 지연 | 구현 난도 | 주 대상 유형 |
|---|---|---|---|---|
| TTL 지터 | 중 | 영향 없음 | 매우 낮음 | A(동시 만료) |
| 확률적 조기 갱신 | 상 | 일부 요청만 증가 | 중 | B(핫키) |
| 분산 락 | 최상 | 대기 발생 | 중~상 | B(핫키) |
| 논리적 만료 | 최상 | 영향 없음 | 중 | B(핫키) |
| 사전 갱신 / 워밍업 | 최상 | 영향 없음 | 낮음 | A, C(콜드 스타트) |
| 요청 병합 | 중~상 | 영향 없음 | 낮음 | B |
10.1 실무에서 권하는 조합
| 데이터 성격 | 권장 조합 |
|---|---|
| 일반 상세 페이지 (상품, 게시글) | TTL 지터 + 요청 병합 + 분산 락. 정합성을 유지하면서 미스 폭발만 차단 |
| 핫키 집계 (랭킹, 인기글, 배너) | 논리적 만료 + 사전 갱신. 사용자는 절대 기다리지 않고, 약간의 지연된 데이터를 허용 |
| 강한 정합성 데이터 (재고, 잔액) | 캐시하지 않거나 분산 락 + 짧은 TTL. 낡은 값을 응답하는 전략은 금지 |
| 재기동 · 장애 복구 | 워밍업 + 점진적 트래픽 투입 + 서킷 브레이커 |
마지막 방어선 — 부하 차단
모든 방어가 뚫렸을 때를 대비해 DB 앞단에 상한을 두십시오. 커넥션 풀 크기 제한, 쿼리 타임아웃, 세마포어로 동시 실행 수 제한, 서킷 브레이커. "일부 요청이 실패하는 것"이 "전체 서비스가 죽는 것"보다 낫습니다.
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 ServiceBusyError("일시적으로 요청이 많습니다")
try:
return await loader()
finally:
DB_SEMAPHORE.release()
11. 실습 가이드 — 재현하고 방어 효과 재기
1️⃣ 스탬피드를 재현하는 스크립트
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
lock = threading.Lock()
KEY = "hot:ranking"
def slow_db_query():
"""무거운 집계 쿼리를 흉내 낸다 (200ms)."""
global db_calls
with lock:
db_calls += 1
time.sleep(0.2)
return {"rank": [1, 2, 3], "at": time.time()}
# --- 방어 없음 ---
def naive_get():
cached = r.get(KEY)
if cached:
return json.loads(cached)
value = slow_db_query()
r.setex(KEY, 60, json.dumps(value))
return value
# --- 분산 락 적용 ---
UNLOCK = """
if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end
return 0
"""
unlock = r.register_script(UNLOCK)
def locked_get():
cached = r.get(KEY)
if cached:
return json.loads(cached)
token = str(uuid.uuid4())
if r.set(f"lock:{KEY}", token, nx=True, ex=5):
try:
value = slow_db_query()
r.setex(KEY, 60, json.dumps(value))
return value
finally:
unlock(keys=[f"lock:{KEY}"], args=[token])
# 대기 후 재조회
for _ in range(40):
time.sleep(0.05)
cached = r.get(KEY)
if cached:
return json.loads(cached)
return slow_db_query() # 최종 폴백
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()
elapsed = time.perf_counter() - started
print(f"{label:<12} 요청 {n}건 | DB 호출 {db_calls}회 | 소요 {elapsed:.2f}s")
if __name__ == "__main__":
run(naive_get, "방어 없음")
run(locked_get, "분산 락")
2️⃣ 실행
$ python stampede_demo.py
방어 없음 요청 500건 | DB 호출 431회 | 소요 0.61s
분산 락 요청 500건 | DB 호출 1회 | 소요 0.36s
확인 결과
- 결과: 방어가 없을 때는 500건의 요청 중 431건이 각자 DB를 호출했습니다. (정확한 수치는 실행 환경과 스레드 스케줄링에 따라 달라집니다.) 분산 락을 적용하자 DB 호출이 1회로 줄었습니다.
- 의미: 430개의 쿼리는 결과가 완전히 같은 순수한 낭비였습니다. 실제 서비스에서 이 낭비는 커넥션 풀 고갈과 연쇄 장애로 이어집니다. 한 줄의
SET NX가 DB 부하를 99.8% 줄인다는 사실을 수치로 확인하십시오.
12. 운영 점검 목록
| 번호 | 점검 항목 |
|---|---|
| 1 | 모든 캐시 TTL에 지터가 적용되어 있는가 |
| 2 | 트래픽 상위 키(핫키)를 파악하고 있는가 (redis-cli --hotkeys, OBJECT FREQ) |
| 3 | 핫키에 락 또는 논리적 만료 중 하나가 적용되어 있는가 |
| 4 | Redis 재기동 시 워밍업 절차가 문서화·자동화되어 있는가 |
| 5 | DB 커넥션 풀 크기와 쿼리 타임아웃이 설정되어 있는가 |
| 6 | 캐시 히트율을 모니터링하고 급락 시 알림이 오는가 (6-7 참고) |
| 7 | 락 획득 실패율과 대기 시간을 지표로 수집하고 있는가 |
| 8 | 캐시 계층이 완전히 죽어도 서비스가 (느리게라도) 동작하는가 |
한 줄 요약
캐시 스탬피드는 캐시가 잘 동작할수록 더 위험해지는 역설을 가집니다. 히트율이 99%라는 것은 곧 미스 순간에 100배의 부하가 DB로 향한다는 뜻이기 때문입니다. 방어의 원칙은 세 가지입니다. 만료를 흩뿌리고(지터), 재계산을 한 명에게 몰아주고(락 · 병합), 가능하면 캐시를 비우지 않는다(논리적 만료 · 사전 갱신). 다음 유닛(7-3)에서는 이 중 가장 널리 쓰이는 락 기반 방어를 완성도 있게 구현합니다.
7-3. 스탬피드 방어 #1 — 실전 분산 락 완성하기
섹션 7. [Appendix] 보너스 트랙 · 유닛 47 / 48 · 보충 학습 자료
이 글에서 완성할 락
- 캐시 재계산을 한 요청에게만 허용하는 락(Mutex) 패턴을 완전한 형태로 구현합니다.
- 락의 3대 필수 요건 — 상호 배제 · 데드락 방지(TTL) · 소유권 검증 — 을 코드로 보장합니다.
- 락 획득 실패 시의 네 가지 정책(대기 · 스테일 반환 · 즉시 실패 · 폴백)을 상황에 맞게 선택합니다.
- 스핀 대기의 낭비를 Pub/Sub 통지 대기로 개선하고, 워치독으로 TTL 딜레마를 해결합니다.
- Redlock 논쟁을 이해하고, 캐시 락과 정합성 락의 요구 수준이 다르다는 점을 구분합니다.
1. 락이 지키는 것은 정확히 무엇인가
7-2에서 본 것처럼 캐시가 만료되는 순간 수천 개의 요청이 동시에 DB로 향합니다. 락 기반 방어의 핵심 아이디어는 단순합니다.
핵심 아이디어
"결과가 어차피 같은 작업이라면, 한 명만 하고 나머지는 그 결과를 나눠 쓰면 된다."
[방어 없음] [락 적용]
요청 1 -> DB 요청 1 -> 락 획득 -> DB -> 캐시 저장
요청 2 -> DB 요청 2 -> 락 실패 -> 대기 -> 캐시 읽기
요청 3 -> DB 요청 3 -> 락 실패 -> 대기 -> 캐시 읽기
... ...
요청 N -> DB 요청 N -> 락 실패 -> 대기 -> 캐시 읽기
DB 쿼리 N개 DB 쿼리 1개
1.1 상호 배제 락과 리더 선출 락 구분하기
| 구분 | 캐시 재계산 락 (이 문서의 주제) | 비즈니스 정합성 락 (5-8) |
|---|---|---|
| 목적 | 중복 계산을 줄여 부하를 절감 | 중복 실행을 막아 데이터 정합성 보장 |
| 예시 | 인기 랭킹 재계산, 상품 상세 캐시 채우기 | 재고 차감, 결제 승인, 좌석 예약 |
| 락이 실패하면 | DB 쿼리가 몇 개 더 나갈 뿐 결과는 정확 | 중복 결제, 오버셀 등 사고 발생 |
| 요구 안전성 | 최선 노력(best-effort)으로 충분 | 매우 높음. 펜싱 토큰 등 추가 장치 필요 |
이 구분이 중요한 이유
캐시 락은 실패해도 손해가 크지 않습니다. 락을 못 잡아 DB에 쿼리 두 개가 나가도 결과는 같습니다. 그래서 단일 Redis 인스턴스의
SET NX만으로 충분합니다. 반면 결제·재고 같은 정합성 락은 같은 구현으로는 부족합니다(9장 Redlock 논의 참고). "내가 지금 만드는 락이 어느 쪽인가"를 먼저 정하십시오.
2. 쓸 만한 락이 갖춰야 할 세 가지
| 요건 | 보장하지 않으면 | 구현 방법 |
|---|---|---|
| 상호 배제 (Mutual Exclusion) | 둘 이상이 동시에 락을 획득 — 락의 의미가 없음 | SET key value NX (원자적 조건부 생성) |
| 데드락 방지 (Deadlock Free) | 락 소유자가 죽으면 영원히 아무도 못 들어감 — 전체 장애 | EX / PX로 TTL 필수 부여 |
| 소유권 검증 (Ownership) | 남의 락을 해제해 상호 배제가 깨짐 | 고유 토큰 저장 + Lua로 비교 후 삭제 |
2.1 획득은 반드시 한 명령으로 — SET NX EX
# 위험한 구현 - 두 명령 사이에 프로세스가 죽으면 TTL 없는 락이 영구 잔존
SETNX lock:ranking "token"
EXPIRE lock:ranking 5 # 이 줄이 실행되지 않으면 영구 데드락
# 원자적 - Redis 2.6.12 이후 SET의 옵션으로 한 번에 처리
SET lock:ranking "token" NX EX 5
# OK -> 획득 성공
# (nil) -> 이미 누군가 보유 중
2.2 해제는 내 것인지 확인하고 — 소유권 토큰
[소유권 검증이 없을 때의 사고]
t0 A: SET lock NX EX 5 -> A 획득
t1 A: DB 조회가 6초 걸림
t5 락 자동 만료
t5 B: SET lock NX EX 5 -> B 획득 (정상)
t6 A: DEL lock -> B의 락을 A가 삭제 (상호 배제 붕괴)
t6 C: SET lock NX EX 5 -> C도 획득 -> B와 C가 동시 실행
release.lua
-- 내 토큰과 일치할 때만 삭제한다 (비교와 삭제가 원자적이어야 한다)
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
3. 락을 못 잡았을 때 무엇을 할 것인가
락을 잡지 못한 나머지 999개 요청을 어떻게 처리할 것인가가 실제 설계의 핵심입니다. 정답은 하나가 아니며, 서비스 특성에 따라 다릅니다.
| 정책 | 동작 | 장점 | 단점 / 적합한 상황 |
|---|---|---|---|
| A. 대기 후 재조회 | 짧게 자면서 캐시가 채워졌는지 반복 확인 | 정확한 최신 값을 응답 | 응답 지연 증가. 가장 보편적인 기본값 |
| B. 스테일 값 반환 | 이전 값(논리적 만료 캐시)을 즉시 응답 | 지연 0. 사용자 경험 최상 | 낡은 데이터 노출. 랭킹·추천에 적합 |
| C. 즉시 실패 | 503/429로 빠르게 거절 | 시스템 보호 효과가 확실 | 사용자에게 에러 노출. 최후의 방어선 |
| D. 폴백 직행 | 락을 무시하고 DB를 직접 조회 | 구현이 단순 | 스탬피드 방어 효과가 사라진다. 대기 상한 초과 시의 최종 수단으로만 |
실무 조합
B(있으면 스테일) → A(없으면 대기) → D(대기 상한 초과 시 폴백) 순서의 3단 방어가 가장 균형이 좋습니다. 캐시가 아예 없는 콜드 스타트에서는 B를 쓸 수 없으므로 A가 동작하고, 락 소유자가 죽어 대기가 길어지면 D가 서비스를 살립니다.
4. 대기 방식 — 계속 두드릴 것인가, 알림을 받을 것인가
4.1 방식 1 — 고정 간격으로 재시도
for _ in range(40): # 최대 2초
time.sleep(0.05) # 50ms 간격 폴링
cached = r.get(key)
if cached:
return json.loads(cached)
구현이 쉽지만, 대기자 1000명이 50ms마다 GET을 날리면 초당 2만 번의 불필요한 Redis 요청이 발생합니다.
4.2 방식 2 — 지수 백오프에 지터 얹기 (기본값 권장)
import random
import time
def backoff_delays(base: float = 0.02, factor: float = 2.0,
cap: float = 0.4, max_wait: float = 2.0):
"""지수적으로 늘어나는 대기 간격 생성기 (지터 포함)."""
elapsed = 0.0
delay = base
while elapsed < max_wait:
# 지터: 대기자들이 동시에 깨어나 재차 몰리는 것을 방지
wait = random.uniform(delay * 0.5, delay)
yield wait
elapsed += wait
delay = min(delay * factor, cap)
4.3 방식 3 — Pub/Sub 알림을 받고 깨어나기
폴링 대신 "캐시가 채워졌다"는 신호를 받아 즉시 깨어나는 방식입니다. Redis 요청 수가 급감하고, 대기 시간도 최소화됩니다.
락 소유자 대기자들
DB 조회 SUBSCRIBE cache:ready:hot:ranking
캐시 저장 (블로킹 대기 - 폴링 없음)
PUBLISH cache:ready:hot:ranking --> 즉시 깨어남
락 해제 GET으로 값 획득 -> 응답
import asyncio
import json
import redis.asyncio as redis
r = redis.from_url("redis://localhost:6379", decode_responses=True)
async def wait_for_cache(key: str, timeout: float = 2.0):
"""캐시 준비 신호를 구독하며 대기한다. 폴링하지 않는다."""
channel = f"cache:ready:{key}"
async with r.pubsub() as pubsub:
await pubsub.subscribe(channel)
# 구독 직전에 이미 채워졌을 수 있으므로 반드시 한 번 확인한다 (경쟁 조건 방지)
cached = await r.get(key)
if cached is not None:
return json.loads(cached)
try:
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
msg = await pubsub.get_message(
ignore_subscribe_messages=True, timeout=0.5
)
if msg and msg["type"] == "message":
cached = await r.get(key)
if cached is not None:
return json.loads(cached)
finally:
await pubsub.unsubscribe(channel)
return None # 타임아웃
반드시 지킬 순서
구독을 시작한 뒤 캐시를 한 번 더 확인해야 합니다. 구독 등록과 신호 발행 사이의 아주 짧은 틈에 캐시가 채워지면, 이 확인이 없는 대기자는 이미 지나간 신호를 영원히 기다리게 됩니다(lost wakeup).
5. TTL을 짧게도 길게도 잡기 어려운 이유
락 TTL을 정하는 것은 생각보다 까다롭습니다.
| TTL 설정 | 발생하는 문제 |
|---|---|
| 너무 짧다 (1초) | 작업이 끝나기 전에 락이 만료 → 다른 요청이 동시에 DB로 감. 방어 실패 |
| 너무 길다 (60초) | 소유자가 죽으면 60초 동안 아무도 캐시를 채우지 못함. 그동안 대기자는 폴백으로 몰림 |
5.1 해법 — 작업이 끝날 때까지 자동 연장하기
짧은 TTL로 시작하고, 작업이 끝나지 않았다면 백그라운드에서 주기적으로 연장합니다. 프로세스가 죽으면 연장도 멈추므로 락은 곧 만료됩니다. 두 문제를 동시에 해결합니다.
extend.lua
-- 내가 소유한 락일 때만 TTL을 연장한다
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('PEXPIRE', KEYS[1], ARGV[2])
end
return 0
EXTEND = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('PEXPIRE', KEYS[1], ARGV[2])
end
return 0
"""
extend_script = r.register_script(EXTEND)
async def watchdog(lock_key: str, token: str, ttl_ms: int, stop: asyncio.Event):
"""TTL의 1/3 주기로 락을 연장한다. 소유권을 잃으면 스스로 종료."""
interval = ttl_ms / 3000 # 초 단위
while not stop.is_set():
try:
await asyncio.wait_for(stop.wait(), timeout=interval)
return # 정상 종료 신호
except asyncio.TimeoutError:
ok = await extend_script(keys=[lock_key], args=[token, ttl_ms])
if not ok:
# 이미 만료되어 다른 소유자에게 넘어갔다 - 연장 중단
return
6. 완성 코드 — 동기 버전 (redis-py)
cache_lock.py
import json
import logging
import random
import time
import uuid
from typing import Any, Callable
import redis
log = logging.getLogger(__name__)
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
RELEASE = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
"""
release_script = r.register_script(RELEASE)
def ttl_with_jitter(base: int, ratio: float = 0.2) -> int:
delta = int(base * ratio)
return base + random.randint(-delta, delta)
def get_or_load(
key: str,
loader: Callable[[], Any],
ttl: int = 300,
lock_ttl: int = 5,
max_wait: float = 2.0,
) -> Any:
"""
캐시 조회 -> 미스 시 락을 잡은 하나만 loader 실행.
나머지는 대기 후 캐시를 읽고, 상한을 넘기면 폴백으로 직접 로딩한다.
"""
# 1) 캐시 히트 - 가장 빠른 경로
cached = r.get(key)
if cached is not None:
return json.loads(cached)
lock_key = f"lock:{key}"
token = str(uuid.uuid4())
# 2) 락 획득 시도
if r.set(lock_key, token, nx=True, ex=lock_ttl):
try:
# 락 획득과 동시에 다른 요청이 이미 채웠을 수 있으므로 재확인 (double-check)
cached = r.get(key)
if cached is not None:
return json.loads(cached)
value = loader()
r.setex(key, ttl_with_jitter(ttl), json.dumps(value))
# 대기자들에게 준비 완료를 알린다
r.publish(f"cache:ready:{key}", "1")
return value
finally:
if release_script(keys=[lock_key], args=[token]) == 0:
log.warning("락이 이미 만료되어 다른 소유자에게 넘어갔습니다: %s", lock_key)
# 3) 락 실패 -> 지수 백오프로 대기하며 캐시 재조회
elapsed, delay = 0.0, 0.02
while elapsed < max_wait:
wait = random.uniform(delay * 0.5, delay)
time.sleep(wait)
elapsed += wait
delay = min(delay * 2, 0.4)
cached = r.get(key)
if cached is not None:
return json.loads(cached)
# 4) 최종 폴백 - 서비스를 멈추지 않는 것이 우선
log.warning("락 대기 시간 초과, 폴백 실행: %s", key)
return loader()
코드 핵심 포인트
- 이중 확인(double-check): 락을 잡은 직후에도 캐시를 다시 확인합니다. 직전 소유자가 이미 채웠을 수 있습니다.
- finally의 해제: 예외가 나도 락은 반드시 풀립니다. 해제 실패는 조용히 넘기지 말고 로그로 남깁니다.
- 발행 후 해제 순서: 캐시 저장 → 신호 발행 → 락 해제 순으로 두어, 깨어난 대기자가 값을 확실히 찾도록 합니다.
- 폴백 존재: 락 소유자가 죽어도 서비스는 계속됩니다. 방어 장치가 새로운 장애 원인이 되면 안 됩니다.
7. 완성 코드 — FastAPI 비동기 버전
main.py
import asyncio
import json
import random
import time
import uuid
from contextlib import asynccontextmanager
from typing import Any, Awaitable, Callable
import redis.asyncio as redis
from fastapi import FastAPI
RELEASE = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
"""
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.redis = redis.from_url(
"redis://localhost:6379", decode_responses=True, max_connections=50
)
app.state.release = app.state.redis.register_script(RELEASE)
yield
await app.state.redis.aclose()
app = FastAPI(lifespan=lifespan)
db_call_count = 0
async def slow_ranking_query() -> dict:
"""무거운 집계 쿼리를 흉내 낸다."""
global db_call_count
db_call_count += 1
await asyncio.sleep(0.3)
return {"top": ["A", "B", "C"], "generated_at": time.time()}
async def cached_call(
rds, release, key: str, loader: Callable[[], Awaitable[Any]],
ttl: int = 60, lock_ttl: int = 5, max_wait: float = 2.0,
) -> tuple[Any, str]:
cached = await rds.get(key)
if cached is not None:
return json.loads(cached), "cache"
lock_key, token = f"lock:{key}", str(uuid.uuid4())
if await rds.set(lock_key, token, nx=True, ex=lock_ttl):
try:
cached = await rds.get(key) # 이중 확인
if cached is not None:
return json.loads(cached), "cache"
value = await loader()
await rds.setex(key, ttl, json.dumps(value))
await rds.publish(f"cache:ready:{key}", "1")
return value, "db"
finally:
await release(keys=[lock_key], args=[token])
# 대기자: Pub/Sub으로 깨어나기를 기다린다
value = await _wait_for_cache(rds, key, timeout=max_wait)
if value is not None:
return value, "waited"
return await loader(), "fallback"
async def _wait_for_cache(rds, key: str, timeout: float):
async with rds.pubsub() as pubsub:
await pubsub.subscribe(f"cache:ready:{key}")
cached = await rds.get(key) # lost wakeup 방지
if cached is not None:
return json.loads(cached)
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
while loop.time() < deadline:
msg = await pubsub.get_message(
ignore_subscribe_messages=True, timeout=0.3
)
if msg and msg["type"] == "message":
cached = await rds.get(key)
if cached is not None:
return json.loads(cached)
return None
@app.get("/ranking")
async def ranking():
value, source = await cached_call(
app.state.redis, app.state.release,
key="hot:ranking", loader=slow_ranking_query, ttl=60,
)
return {"source": source, "db_calls": db_call_count, "data": value}
@app.delete("/ranking/cache")
async def clear_cache():
"""스탬피드 상황을 만들기 위한 테스트용 엔드포인트."""
global db_call_count
db_call_count = 0
await app.state.redis.delete("hot:ranking", "lock:hot:ranking")
return {"ok": True}
8. 데코레이터로 감싸 재사용하기
import functools
import hashlib
def cached(ttl: int = 300, lock_ttl: int = 5, prefix: str = "fn"):
"""함수 결과를 스탬피드 방어와 함께 캐싱하는 데코레이터."""
def decorator(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
raw = f"{func.__module__}.{func.__qualname__}:{args}:{sorted(kwargs.items())}"
key = f"{prefix}:{hashlib.sha1(raw.encode()).hexdigest()[:16]}"
value, _ = await cached_call(
app.state.redis, app.state.release,
key=key,
loader=lambda: func(*args, **kwargs),
ttl=ttl, lock_ttl=lock_ttl,
)
return value
return wrapper
return decorator
@cached(ttl=60, prefix="ranking")
async def get_daily_ranking(category: str) -> dict:
return await db.aggregate_ranking(category)
키 생성 주의
인자를 그대로 문자열화해 키로 쓰면 길이 폭발과 특수문자 문제가 생깁니다. 해시를 쓰되, 운영 중 어떤 키인지 추적할 수 있도록 사람이 읽을 수 있는 접두어를 반드시 함께 붙이십시오(6-1 네이밍 전략 참고).
9. 직접 만들 것인가, 라이브러리를 쓸 것인가
9.1 redis-py 내장 Lock으로 대체하기
# redis-py 내장 Lock: 토큰 관리, Lua 해제, 워치독(연장)까지 구현되어 있다
lock = r.lock(
"lock:hot:ranking",
timeout=5, # 락 TTL
blocking=True, # 획득 실패 시 대기
blocking_timeout=2, # 대기 상한
thread_local=True, # 토큰을 스레드 로컬로 관리 (기본값)
)
if lock.acquire():
try:
value = loader()
r.setex(key, 300, json.dumps(value))
finally:
try:
lock.release()
except redis.exceptions.LockNotOwnedError:
# TTL 만료로 소유권을 잃은 경우 - 로그만 남기고 진행
log.warning("락 소유권 상실")
권장
학습 목적이 아니라면 검증된 내장 락을 쓰는 편이 안전합니다. 직접 구현에서 가장 흔히 빠뜨리는 것이 소유권 검증 해제와 재진입 처리인데, 라이브러리는 이를 이미 다루고 있습니다. 다만 내부에서 무슨 일이 일어나는지 아는 상태로 쓰는 것과 모르고 쓰는 것은 장애 대응에서 큰 차이를 만듭니다.
9.2 Redlock에 제기된 비판과 그 한계
Redis 진영은 여러 독립 노드의 과반수에서 락을 얻는 Redlock 알고리즘을 제안했고, 분산 시스템 연구자 Martin Kleppmann은 이를 비판했습니다. 요지는 다음과 같습니다.
| 쟁점 | 내용 |
|---|---|
| GC 일시 정지 / 스케줄 지연 | 락을 잡은 프로세스가 수 초간 멈추면(예: JVM Full GC) 그사이 락이 만료되고 다른 프로세스가 획득합니다. 깨어난 첫 프로세스는 자신이 여전히 소유자라고 착각한 채 작업을 계속합니다 |
| 시계 의존성 | Redlock의 안전성은 각 노드의 시계가 크게 어긋나지 않는다는 가정에 기댑니다. NTP 점프나 VM 정지 시 가정이 깨집니다 |
| 대안 — 펜싱 토큰 | 락 획득 시 단조 증가하는 번호를 발급하고, 보호 대상 저장소가 더 낮은 번호의 쓰기를 거부해야 진짜 안전합니다. Redis 락만으로는 이를 제공할 수 없습니다 |
결론 — 용도를 나누십시오
- 효율성(efficiency) 목적의 락 — 중복 계산 줄이기, 캐시 스탬피드 방어: 단일 Redis +
SET NX EX로 충분합니다. 드물게 두 번 실행돼도 결과는 같습니다.- 정합성(correctness) 목적의 락 — 결제, 재고, 정산: Redis 락만 믿지 말고 DB 트랜잭션 · 유니크 제약 · 멱등키 · 펜싱 토큰을 함께 쓰십시오.
이 문서가 다루는 캐시 락은 전자입니다. 그래서 구현이 단순해도 괜찮습니다.
10. 실습 가이드 — 방어 전후를 수치로 비교하기
1️⃣ 서버 실행
$ uvicorn main:app --port 8000 --workers 1
2️⃣ 캐시를 비우고 동시 요청 폭탄 투입
bench.py
import asyncio
import time
import httpx
URL = "http://localhost:8000/ranking"
N = 300
async def main():
async with httpx.AsyncClient(timeout=10.0) as client:
await client.delete("http://localhost:8000/ranking/cache")
started = time.perf_counter()
responses = await asyncio.gather(*[client.get(URL) for _ in range(N)])
elapsed = time.perf_counter() - started
sources = {}
for res in responses:
body = res.json()
sources[body["source"]] = sources.get(body["source"], 0) + 1
print(f"요청 {N}건 / 소요 {elapsed:.2f}s")
print("응답 출처 분포:", sources)
print("DB 호출 횟수 :", responses[0].json()["db_calls"], "(최종값은 아래 확인)")
asyncio.run(main())
3️⃣ 결과 확인
$ python bench.py
요청 300건 / 소요 0.42s
응답 출처 분포: {'db': 1, 'waited': 299}
$ curl -s http://localhost:8000/ranking | python -c "import sys,json; print(json.load(sys.stdin)['db_calls'])"
1
확인 결과
- 결과: 300건의 동시 요청 중 DB를 호출한 것은 1건이고, 나머지 299건은
waited(대기 후 캐시 읽기)로 응답했습니다. 전체 소요 시간은 DB 쿼리 1회(300ms)와 거의 같습니다.- 의미: 락이 없었다면 300개의 동일한 집계 쿼리가 DB로 향했을 것입니다. 락 방어의 비용은 대기자들의 약간의 지연뿐이며, 그 대가로 DB 부하를 99.7% 제거했습니다.
fallback이 응답 분포에 나타난다면 락 TTL이 작업 시간보다 짧거나 대기 상한이 너무 짧다는 신호이니 값을 조정하십시오.
11. 실패했을 때 어떻게 되는가
| 증상 | 원인 | 대응 |
|---|---|---|
| 여전히 DB 쿼리가 여러 개 나간다 | 락 TTL < 실제 작업 시간. 만료 후 다음 요청이 락을 재획득 | 락 TTL을 p99 쿼리 시간의 2-3배로. 또는 워치독 도입 |
| fallback 비율이 높다 | 대기 상한이 짧거나 락 소유자가 자주 실패 | max_wait를 늘리고, 소유자 측 예외 로그를 확인 |
| 대기 중 요청이 타임아웃 | 대기 상한이 HTTP 타임아웃보다 김 | 대기 상한 < 클라이언트 타임아웃이 되도록 조정 |
| 락 해제 실패 경고가 잦다 | TTL 만료로 소유권 상실이 빈번 | 작업 시간을 줄이거나 TTL 상향. 소유권 상실 시 캐시 쓰기도 건너뛸지 검토 |
| Redis 커넥션 고갈 | 대기자마다 Pub/Sub 연결을 새로 생성 | 커넥션 풀 상한 설정. 대기자가 많으면 백오프 폴링이 오히려 유리할 수 있음 |
| Redis 장애 시 전체 마비 | 락 조회 실패를 예외로 전파 | Redis 실패 시 락 없이 DB로 진행(degrade). 캐시는 부가 기능이지 필수 경로가 아니다 |
11.1 반드시 지표로 남겨야 할 것
| 지표 | 의미 |
|---|---|
| 캐시 히트율 | 급락 시 스탬피드 전조. 알림 대상 |
| 락 획득 성공 / 실패 수 | 실패가 정상(대다수가 실패해야 정상). 성공이 많으면 락이 동작하지 않는 것 |
| 대기 시간 분포(p50/p99) | p99가 대기 상한에 붙어 있으면 폴백이 임박 |
| 폴백 발생 횟수 | 0에 가까워야 정상. 증가하면 즉시 원인 분석 |
| loader 실행 횟수 | 단위 시간당 키별 1회에 수렴해야 한다 |
12. 정리 — 락을 쓸 자리와 피할 자리
| 구성 요소 | 이유 |
|---|---|
SET key token NX EX |
상호 배제 + 데드락 방지를 원자적으로 동시 달성 |
| UUID 토큰 | 남의 락을 해제하는 사고 방지 |
| Lua 비교 후 삭제 | 소유권 확인과 삭제 사이의 틈 제거 |
| 락 획득 후 이중 확인 | 직전 소유자가 이미 채운 캐시를 중복 계산하지 않음 |
| Pub/Sub 준비 신호 | 폴링 부하 제거 + 대기 시간 최소화 |
| 대기 상한 + 폴백 | 방어 장치가 새로운 장애 원인이 되지 않도록 |
| 워치독 연장 | 짧은 TTL의 안전성과 긴 작업의 완주를 동시에 |
| TTL 지터 | 락으로 막지 못하는 동시 만료(유형 A)를 함께 방어 |
한 줄 요약
캐시 스탬피드 방어용 락의 목표는 완벽한 상호 배제가 아니라 부하의 극적인 절감입니다. 그래서 단일 Redis의
SET NX EX로 충분하며, 대신 반드시 빠져나갈 길(대기 상한과 폴백)을 함께 설계해야 합니다. 락이 서비스를 지키는 장치가 아니라 서비스를 멈추는 장치가 되는 순간, 그것은 방어가 아니라 새로운 단일 장애점입니다.
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도) - (본초자오선) 지구를 동서로 나누는 기준선 (영국 런던 그리니치 천문대를 지나는 선)
2. 거리 계산과 반경 검색으로 할 수 있는 것
- 거리 계산
- 두 지점 사이의 거리를 미터(m), 킬로미터(km) 등의 단위로 계산합니다.
- 내부적으로는 위도·경도를 기반으로 한 구면 거리 계산을 사용합니다. (Haversine 공식 사용)
- 반경 검색 (내 주변 찾기)
- 특정 위치(또는 등록된 장소)를 기준으로 지정한 반경(예: 2km) 내의 데이터를 빠르게 탐색할 수 있습니다.
- 초고속 성능
- 모든 연산이 메모리(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 알고리즘을 사용하여 이 문제를 해결합니다.
[ 1단계: 좌표 입력 ]
경도(X): 126.9770 / 위도(Y): 37.5665 (서울시청)
│
▼
[ 2단계: Geohash 변환 (2차원 ➔ 1차원) ]
Redis 내부에서 경도와 위도의 비트(bit)를 교차로 섞어서
52비트의 정수(Integer)로 변환합니다.
(예: 4050965385614946)
│
▼
[ 3단계: Sorted Set (ZSET)에 저장 ]
변환된 정수를 점수(Score)로 삼아 데이터를 한 줄로 세웁니다.
┌─────────────────────────────────────────┐
│ Score (Geohash) | Member (이름) │
│──────────────────────|──────────────────│
│ ... | ... │
│ 4050965385614946 | "Seoul_City_Hall"│
│ 4050965387123000 | "Deoksugung" │
│ ... | ... │
└─────────────────────────────────────────┘
🔍 핵심 원리: 왜 이렇게 할까요?
- 근접성 유지의 마법: Geohash 알고리즘의 가장 큰 특징은 "물리적으로 가까운 위치에 있는 좌표들은 변환된 52비트 정수(Score) 값도 서로 비슷하게 나온다" 는 것입니다.
- 초고속 범위 검색의 비밀: 숫자가 비슷하다는 것은 Sorted Set에서 서로 옆자리에 나란히 줄을 선다는 뜻입니다. 따라서 "내 주변 1km"를 찾을 때 복잡한 거리 계산 공식을 돌릴 필요 없이, Sorted Set에서 내 점수와 비슷한 앞뒤 데이터만 빠르게 긁어오면(Range Search) 초고속 검색이 가능합니다!
4. GEOADD·GEOSEARCH 문법 짚기
| 명령어 | 기본 문법 | 역할 및 설명 |
|---|---|---|
| GEOADD | GEOADD [key] [경도] [위도] [멤버] |
데이터 추가. 지정한 키에 경도와 위도 좌표, 그리고 그 장소의 이름을 저장합니다. 여러 개를 한 번에 넣을 수도 있습니다. |
| GEODIST | GEODIST [key] [멤버1] [멤버2] [단위] |
거리 계산. 이미 저장된 두 멤버 사이의 직선거리를 계산합니다. 단위로는 m(미터), km(킬로미터) 등을 쓸 수 있습니다. |
| GEOSEARCH | GEOSEARCH [key] FROMLONLAT [경도] [위도] BYRADIUS [반경] [단위](또는 BYBOX [가로] [세로] [단위]) |
주변 검색. 특정 좌표를 기준으로 지정한 반경(BYRADIUS) 또는 사각형 범위(BYBOX) 이내에 있는 멤버들을 찾아줍니다. (Redis 6.2 이상 표준) |
| GEOPOS | GEOPOS [key] [멤버] |
좌표 확인. 저장된 멤버의 원래 경도와 위도 값을 정확하게 다시 꺼내 보여줍니다. |
⚠ 주의사항: 구글 지도 등에서 좌표를 복사하면 보통 '위도, 경도' 순서로 나오지만, Redis의
GEOADD명령어는 반드시 '경도(Longitude), 위도(Latitude)' 순서로 입력해야 합니다. 반대로 넣으면 데이터가 지구 반대편에 찍히게 됩니다!암기 팁: 수학의 2차원 그래프 좌표 (x, y)를 떠올려 보세요! 레디스의
GEOADD는 (X축, Y축) 순서대로 값을 넣는 것과 같아서 '경도(X), 위도(Y)' 순서로 입력해야 한다고 기억하면 헷갈리지 않습니다.
5. [실습] 좌표 넣고 내 주변 찾아보기
터미널의 redis-cli를 열고 직접 위경도 데이터를 다뤄봅시다.
① 위치 데이터 추가 (GEOADD)
데이터를 넣을 때는 반드시 경도(Longitude) → 위도(Latitude) → 이름(Member) 순서로 입력해야 합니다.
# 서울의 주요 랜드마크 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 피트)를 지정할 수 있습니다.
# 서울시청과 경복궁 사이의 거리 (킬로미터 단위)
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 옵션도 제공하여 더욱 디테일한 검색이 가능합니다.
# 기준 좌표: 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 문자열을 확인할 수 있습니다.
# 서울시청의 위경도 좌표 확인
GEOPOS seoul:places "Seoul_City_Hall"
1) 1) "126.977998316288" # 경도
2) "37.566500306287246" # 위도
# Geohash 문자열 확인
GEOHASH seoul:places "Seoul_City_Hall"
1) "wydm9qy89z0"
6. 쓰기 전에 알아야 할 구조적 제약
- 내부 타입 확인: 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) 검색 범위를 줄이는 것이 효율적입니다.
7. 어디까지 Redis로 하고 어디부터 PostGIS인가
Redis GEO는 "특정 좌표에서 반경 N km 이내의 점(Point) 찾기"에는 아주 훌륭한 솔루션 중 하나입니다. 하지만 본격적인 지도 서비스나 배달 앱을 구축할 때는 명확한 한계가 존재합니다.
⚠ Redis GEO의 한계점
- 다각형(Polygon) 검색 한계 Redis GEO는 다각형 기반 영역 검색(ex: 이 식당이 '강남구 배달 가능 구역' 안에 포함되는가?)을 직접 지원하지 않습니다. 복잡한 배달 가능 구역(Polygon) 판별은 별도의 로직이 필요합니다.
- 경로 및 방향 계산 불가 내비게이션처럼 실제 도로 경로, 이동 시간, 방향 계산은 제공하지 않으며, 오직 직선 거리(구면 거리) 기반 계산만 지원합니다.
- 복합 조건 검색의 한계 "반경 + 평점 + 영업 상태"와 같은 다중 조건 검색(ex: 강남역 1km 이내에 있으면서, 평점이 4.5 이상이고, 현재 영업 중인 중국집)은 Redis GEO 기능만으로는 처리하기 까다로우며, RediSearch 등 별도 인덱싱 구조를 병행해야 합니다.
💡 [실무] 업계 표준 공간 데이터베이스 (Spatial DB)
본격적인 배달 앱, 모빌리티(택시/지도), 부동산 서비스 등 복잡한 지리 공간 연산이 필요한 시스템에서는 전문 Spatial DB를 함께 사용합니다.
- PostgreSQL + PostGIS (업계 표준 👑)
- 가장 널리 사용되는 오픈소스 공간 DB 확장 플러그인
- 점(Point), 선(Line), 다각형(Polygon/MultiPolygon)과 함께 Z값(고도)을 포함한 3차원(XYZ) 및 M값(시간/거리)을 포함한 4차원(XYZM) 공간 데이터까지 지원
- 배달 구역, 행정 경계, 영역 포함 여부 판단 등 정밀한 공간 연산에 핵심적으로 사용
- Elasticsearch
geo_point(점) /geo_shape(영역) 기반 위치 검색 지원- 텍스트 검색 + 위치 필터 결합에 강점 (예: "내 주변 리뷰 좋은 카페 검색")
- 검색 중심 서비스에서 유용
- MongoDB
- GeoJSON 기반 공간 데이터 지원
$geoWithin: 포함 관계 (안에 있냐?),$geoIntersects: 교차 관계 (겹치냐?)- 비교적 단순한 위치 기반 서비스에서 활용 가능
실무 아키텍처
- 실무에서는 PostGIS와 같은 공간 DB를 통해 복잡한 배달 구역 및 다각형 연산을 처리하고,
- Elasticsearch는 위치 기반 검색과 텍스트 검색을 결합한 조회 엔진으로 활용합니다.
- Redis GEO는 "내 주변 접속 중인 라이더(택시) 목록"처럼 실시간으로 빠르게 변하는 위치 데이터를 처리하는 초고속 근접 검색 엔진으로 사용하여 각 시스템의 역할을 분리하는 구조가 일반적입니다.
🚩 정리 — GEO를 실무에 넣는 기준
- Redis GEO는 복잡한 거리 계산을 내부적으로 처리하여, 점들 사이의 거리와 반경 검색을 초고속으로 수행합니다.
- Redis GEO는 위경도를 Geohash로 변환하여 Sorted Set에 저장합니다. 정렬된 구조를 이용해 주변 위치를 범위 검색으로 빠르게 찾아낼 수 있습니다.
- 배달 구역과 같은 복잡한 다각형 연산은 PostGIS와 같은 Spatial DB에 맡기고, Redis는 실시간으로 빠르게 변하는 위치 데이터를 처리하는 근접 검색 엔진으로 사용하는 것이 실무에서 일반적으로 사용되는 아키텍처 패턴입니다.
'Database > redis' 카테고리의 다른 글
| Redis 실무 가이드 3 - FastAPI로 구현하는 백엔드 실무 패턴 9선 (1) | 2026.09.08 |
|---|---|
| Redis 실무 가이드 2 - Pub/Sub, Stream, Redis Stack, 트랜잭션과 Lua Script (0) | 2026.09.08 |
| Redis 실무 가이드 1 - 왜 Redis인가, 그리고 자료형 완전 정복 (0) | 2026.09.08 |
| Redis Stream에 대한 정리 (0) | 2025.01.30 |
| Redis-OM 객체 매핑을 지원하는 redis 라이브러리 (0) | 2025.01.30 |
댓글