🔥 들어가며 — TTL 없는 캐시는 시한폭탄이다
지난 편에서 Redis를 설치하고 첫 PING → PONG을 확인했습니다. 이제 Redis를 "그냥 빠른 저장소"로 쓰기 전에, 반드시 알아야 할 규칙이 있습니다.
TTL(Time To Live, 데이터 유효 시간)을 설정하지 않은 캐시는 메모리가 조용히 차오르다가 어느 날 갑자기 서버가 멈추는 원인이 됩니다. 실제로 "Redis가 이유 없이 느려졌다"는 장애 신고의 상당수는 TTL 없이 쌓인 키들이 메모리를 다 써버린 경우입니다.
오늘은 딱 세 가지를 배웁니다. 키 이름을 어떻게 짓는지, TTL을 어떻게 설정하고 확인하는지, 그리고 "만료됐다 = 즉시 삭제된다"는 흔한 오해를 바로잡는 것입니다.
또 한 가지 — 지난 편에서 KEYS *를 잠깐 썼는데, 이 명령어는 운영 서버에서 절대 쓰면 안 됩니다. 대신 SCAN을 써야 합니다. 이유도 오늘 설명합니다.
🧠 핵심 개념 1 — 키 이름을 잘 짓는 법
Redis의 키는 그냥 문자열입니다. 이론상 어떤 이름도 쓸 수 있어요. 하지만 실무에서 수천~수만 개의 키가 쌓이면, 이름 규칙 없이 만든 키들은 나중에 무엇이 무엇인지 전혀 알 수 없게 됩니다.
가장 널리 쓰이는 방식은 콜론(:)으로 계층을 구분하는 prefix 패턴입니다. 마치 파일 경로처럼 큰 범주 → 작은 범주 → 식별자 순으로 씁니다.
| 규칙 | 좋은 예 | 나쁜 예 |
|---|---|---|
| prefix로 용도 구분 | cache:user:1000 |
user1000 |
| 환경(dev/prod) 포함 | cache:prod:product:55 |
product55 |
| 버전 관리 | rank:v2:weekly |
weeklyrank |
| 소문자 + 콜론 구분자 | session:user:abc123 |
Session_User_ABC123 |
🧠 핵심 개념 2 — TTL과 EXPIRE: 데이터에 유효기간 달기
TTL(Time To Live)은 "이 키는 몇 초 후에 자동으로 사라져도 된다"는 유효기간입니다. 우유 유통기한처럼 생각하면 됩니다. 기한이 지나면 Redis가 알아서 삭제해주기 때문에, 개발자가 직접 삭제 코드를 짤 필요가 없습니다.
TTL 관련 명령어 총정리
| 명령어 | 설명 | 반환값 |
|---|---|---|
TTL key |
키의 남은 유효시간 조회 (초 단위) | 양수: 남은 초 / -1: 만료 없음 / -2: 키 없음 |
PTTL key |
남은 유효시간 조회 (밀리초 단위) | 동일, 단위만 ms |
EXPIRE key 초 |
기존 키에 TTL 설정 | 1: 성공 / 0: 키 없음 |
EXPIREAT key 타임스탬프 |
Unix 타임스탬프로 만료 시점 지정 | 1: 성공 / 0: 키 없음 |
PERSIST key |
TTL 제거 (영구 보관으로 변경) | 1: 성공 / 0: TTL 없었음 |
SET key value EX 초 |
저장과 TTL을 한 번에 | OK |
TTL이 -1이면 → 키는 존재하지만 만료 시간이 없음 (무한 보관)TTL이 -2이면 → 키 자체가 존재하지 않음 (만료되거나 처음부터 없었거나)코드에서
GET이 nil을 반환하면 -2 상태입니다.🧠 핵심 개념 3 — "만료 = 즉시 삭제"는 오해다
많은 초심자가 "TTL이 0이 되면 즉시 메모리에서 삭제된다"고 알고 있지만, Redis의 실제 동작은 조금 다릅니다.
Redis는 만료된 키를 두 가지 방법으로 처리합니다:
| 방식 | 언제? | 설명 |
|---|---|---|
| 지연 삭제 (Lazy Expiration) |
누군가 그 키에 접근할 때 | GET/SET 등 명령이 들어왔을 때 만료 여부를 확인하고 그제서야 삭제. 아무도 접근 안 하면 메모리에 남아있을 수 있음. |
| 주기적 삭제 (Active Expiration) |
Redis 내부 타이머 | 주기적으로 만료된 키를 랜덤 샘플링해서 삭제. 100% 즉시 청소를 보장하지 않음. |
TTL이 지난 키도 아직 메모리를 점유하고 있을 수 있습니다. 대량의 키가 동시에 만료되는 설계라면 메모리가 순간적으로 급증할 수 있어요. 이 때문에 만료 시간을 약간 랜덤하게 분산시키는 "Jitter(지터)" 기법을 쓰는 것이 좋습니다. (예: TTL을 정확히 60초가 아니라 55~65초 사이로 랜덤 설정)
🚫 KEYS * 대신 SCAN을 써야 하는 이유
지난 편에서 KEYS *로 저장된 키 목록을 확인했습니다. 이 명령어는 학습용으로는 편리하지만 운영 서버에서는 절대 금지입니다.
이유는 단순합니다. KEYS는 Redis 전체 키를 한 번에 스캔하는 동안 다른 모든 명령을 블로킹합니다. Redis는 싱글 스레드로 동작하기 때문에, 키가 100만 개인 서버에서 KEYS *를 실행하면 그 순간 서비스 전체가 수 초간 멈출 수 있습니다.
| KEYS * | SCAN (권장) | |
|---|---|---|
| 블로킹 여부 | ✅ 전체 스캔 동안 블로킹 | ✅ 커서 기반으로 조금씩 → 비블로킹 |
| 사용 환경 | 학습/개발 전용 | 운영 환경 사용 가능 |
| 결과 보장 | 전체 키 즉시 반환 | 반복 호출로 점진적 탐색 |
# ❌ 운영 서버에서 절대 금지
KEYS *
# ✅ SCAN으로 안전하게 조회 (cursor=0에서 시작)
# COUNT는 한 번에 처리할 대략적인 키 수 힌트
SCAN 0 MATCH cache:user:* COUNT 100
# 출력 예시:
# 1) "128" ← 다음 커서 (0이 되면 순회 완료)
# 2) 1) "cache:user:1001"
# 2) "cache:user:1003"
# ...
# 커서가 0으로 돌아올 때까지 반복 호출하면 전체 순회 완료
SCAN 128 MATCH cache:user:* COUNT 100
⚙️ Lab 1 — TTL·만료 실습
SET → EXPIRE → TTL → 만료 확인 흐름을 직접 눈으로 확인한다.🖥️ redis-cli 실습
docker exec -it redis redis-cli
# 키 저장 (TTL 없음)
SET cache:user:1000 "kim"
# OK
# TTL 확인: -1이면 만료 시간 없음 (무한 보관 상태!)
TTL cache:user:1000
# (integer) -1
# 10초 TTL 설정
EXPIRE cache:user:1000 10
# (integer) 1 ← 성공
# 남은 시간 확인 (10에서 줄어드는 중)
TTL cache:user:1000
# (integer) 8
# SET과 TTL을 한 번에 (더 권장하는 방식)
SET cache:user:2000 "lee" EX 30
TTL cache:user:2000
# (integer) 30
# 10초가 지난 후에 실행...
# 값 조회: 만료됐으면 nil 반환
GET cache:user:1000
# (nil)
# TTL 조회: -2면 키 자체가 없음
TTL cache:user:1000
# (integer) -2
🐍 Python — Cache-Aside 패턴 구현 (redis-py)
이제 실제로 쓸 수 있는 코드를 작성해봅시다. Cache-Aside 패턴은 "캐시에 먼저 조회 → 없으면 DB에서 가져와서 캐시에 저장" 흐름입니다. 가장 기본적이고 널리 쓰이는 캐시 패턴입니다.
PYTHONimport redis
import time
import random
# Redis 연결
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
# ── 가짜 DB 함수 (실제로는 MySQL/PostgreSQL 등) ──
def fake_db_get_user(user_id: int) -> str:
print(f" [DB 조회] user_id={user_id} (느린 작업 시뮬레이션)")
time.sleep(0.1) # DB 조회 지연 흉내
return f"유저이름_{user_id}"
# ── Cache-Aside 함수 ──
def get_user_cached(user_id: int) -> str:
key = f"cache:prod:user:{user_id}" # 키 네이밍 규칙 적용
# 1단계: 캐시 먼저 조회
cached = r.get(key)
if cached is not None:
print(f" [캐시 HIT] key={key}, value={cached}")
return cached
# 2단계: 캐시 MISS → DB 조회
print(f" [캐시 MISS] key={key} → DB 조회 시작")
value = fake_db_get_user(user_id)
# 3단계: 캐시에 저장 (TTL: 55~65초 랜덤 → Jitter 기법)
ttl = 60 + random.randint(-5, 5)
r.set(key, value, ex=ttl)
print(f" [캐시 저장] key={key}, TTL={ttl}초")
return value
# ── 테스트 ──
print("=== 첫 번째 호출 (캐시 MISS 예상) ===")
result = get_user_cached(1000)
print(f"결과: {result}\n")
print("=== 두 번째 호출 (캐시 HIT 예상) ===")
result = get_user_cached(1000)
print(f"결과: {result}\n")
# TTL 확인
key = "cache:prod:user:1000"
print(f"남은 TTL: {r.ttl(key)}초")
TERMINAL
python cache_aside.py
# 기대 출력:
# === 첫 번째 호출 (캐시 MISS 예상) ===
# [캐시 MISS] key=cache:prod:user:1000 → DB 조회 시작
# [DB 조회] user_id=1000 (느린 작업 시뮬레이션)
# [캐시 저장] key=cache:prod:user:1000, TTL=58초
# 결과: 유저이름_1000
#
# === 두 번째 호출 (캐시 HIT 예상) ===
# [캐시 HIT] key=cache:prod:user:1000, value=유저이름_1000
# 결과: 유저이름_1000
#
# 남은 TTL: 57초
🟢 Node.js — Cache-Aside 패턴 구현 (node-redis)
NODE.JSimport { createClient } from 'redis';
// Redis 연결
const client = createClient({ url: 'redis://localhost:6379' });
client.on('error', (err) => console.error('Redis 오류:', err));
await client.connect();
// ── 가짜 DB 함수 ──
async function fakeDbGetUser(userId) {
console.log(` [DB 조회] userId=${userId}`);
await new Promise(resolve => setTimeout(resolve, 100)); // 지연 흉내
return `유저이름_${userId}`;
}
// ── Cache-Aside 함수 ──
async function getUserCached(userId) {
const key = `cache:prod:user:${userId}`; // 키 네이밍 규칙 적용
// 1단계: 캐시 먼저 조회
const cached = await client.get(key);
if (cached !== null) {
console.log(` [캐시 HIT] key=${key}, value=${cached}`);
return cached;
}
// 2단계: 캐시 MISS → DB 조회
console.log(` [캐시 MISS] key=${key} → DB 조회 시작`);
const value = await fakeDbGetUser(userId);
// 3단계: 캐시 저장 (TTL: 55~65초 랜덤 → Jitter 기법)
const ttl = 60 + Math.floor(Math.random() * 11) - 5;
await client.set(key, value, { EX: ttl });
console.log(` [캐시 저장] key=${key}, TTL=${ttl}초`);
return value;
}
// ── 테스트 ──
console.log('=== 첫 번째 호출 (캐시 MISS 예상) ===');
const r1 = await getUserCached(1000);
console.log(`결과: ${r1}\n`);
console.log('=== 두 번째 호출 (캐시 HIT 예상) ===');
const r2 = await getUserCached(1000);
console.log(`결과: ${r2}\n`);
// TTL 확인
const ttlLeft = await client.ttl('cache:prod:user:1000');
console.log(`남은 TTL: ${ttlLeft}초`);
await client.disconnect();
TERMINAL
node cache_aside.mjs
💼 실무 팁 — 캐시 설계의 흔한 실수 TOP 5
가장 흔하고 치명적인 실수입니다. TTL 없는 키는 영구 보관되어 메모리를 조금씩 갉아먹습니다. 모든 캐시 키에는 반드시 TTL을 설정하세요. 영구 보관이 필요한 데이터라면 Redis가 아닌 일반 DB를 쓰는 것이 맞습니다.
예를 들어 모든 캐시를 정확히 60초로 설정하면, 60초마다 수천 개의 키가 동시에 만료되면서 DB에 갑자기 대량 요청이 몰립니다. 이를 Cache Stampede(캐시 스탬피드)라고 합니다. 앞서 소개한 Jitter(랜덤 편차) 기법으로 만료 시점을 분산시키세요.
이미 설명했지만 다시 강조합니다.
KEYS *는 학습·개발 환경 전용입니다. 운영 서버에서는 무조건 SCAN을 사용하세요.TTL이 너무 짧으면 캐시 적중률이 낮아져서 Redis를 쓰는 의미가 없어집니다. 데이터 성격에 맞게 설정하세요. (세션: 수십 분, 상품 목록: 수 분~수십 분, 실시간 재고: 수 초)
DB 데이터가 업데이트됐는데 캐시가 그대로면 사용자에게 낡은 데이터(Stale Data)를 보여주게 됩니다. 데이터가 업데이트될 때 관련 캐시 키를
DEL로 삭제하거나 즉시 갱신하는 로직을 함께 구현해야 합니다.✅ 캐시 TTL 설계 가이드라인
| 데이터 종류 | 권장 TTL | 이유 |
|---|---|---|
| 로그인 세션 | 30분 ~ 2시간 | 보안 + UX 균형 |
| 상품/카테고리 목록 | 5 ~ 30분 | 자주 안 바뀌지만 최신성 필요 |
| 실시간 랭킹 | 30초 ~ 5분 | 빈번한 업데이트 |
| API 응답 캐시 | 1 ~ 10분 | 외부 API 호출 비용 절감 |
| 실시간 재고 | 5 ~ 30초 | 정확성이 중요 |
📋 오늘의 핵심 요약 5줄
- 키 이름은 prefix:환경:타입:ID 패턴으로 콜론(colon)으로 계층을 구분해 짓는다.
TTL반환값 -1은 만료 없음, -2는 키 없음이다. 이 차이를 반드시 기억하자.- 만료된 키는 즉시 삭제되지 않을 수 있다 — 지연 삭제 + 주기적 삭제 두 가지 방식으로 처리된다.
KEYS *는 학습용 전용, 운영에서는 반드시SCAN을 사용한다.- Cache-Aside 패턴 = 캐시 조회 → MISS 시 DB 조회 → 캐시 저장(TTL 포함). 모든 캐시에 TTL은 필수다.
☑️ 오늘의 체크리스트
SET cache:user:1000 "kim"후TTL이 -1임을 확인했다EXPIRE설정 후 TTL이 줄어드는 것을 눈으로 확인했다- 10초 후
GET이nil,TTL이 -2임을 확인했다 - Python 또는 Node.js로 Cache-Aside 함수를 직접 작성해봤다
- 첫 번째 호출(MISS)과 두 번째 호출(HIT)의 로그 차이를 확인했다
- Jitter(랜덤 TTL 편차)가 왜 필요한지 이해했다
🧩 확인 퀴즈
TTL mykey가 -1을 반환했습니다. 이 키는 어떤 상태인가요?- 100만 개의 키가 있는 운영 Redis 서버에서 특정 prefix의 키를 조회해야 합니다. 어떤 명령어를 써야 할까요?
- 모든 캐시를 정확히 60초 TTL로 설정했더니 매 60초마다 서비스가 느려집니다. 원인과 해결책은?
▶ 정답 확인 (클릭)
- 키는 존재하지만 만료 시간이 설정되어 있지 않은 상태입니다. 삭제하지 않는 한 영구 보관됩니다.
SCAN 0 MATCH prefix:* COUNT 100을 커서가 0이 될 때까지 반복 호출합니다.KEYS *는 절대 사용하지 않습니다.- 원인: Cache Stampede — 동시에 대량의 키가 만료되어 DB에 요청이 폭주합니다. 해결: TTL에 랜덤 편차(Jitter)를 추가합니다. 예)
60 + random(-5, 5)초로 설정.
1. redis-cli에서 키를 하나 만들고,
EXPIRE로 TTL 설정 후 PERSIST 명령으로 TTL을 제거해보세요. TTL이 다시 -1로 돌아오는지 확인하세요.2. Python 코드에서 Cache-Aside 함수를 수정해 같은 user_id를 10번 연속 호출하고, DB 조회가 몇 번 발생했는지 카운트해보세요. (정답: 첫 번째 1번만 발생해야 합니다)
3.
SET mykey "hello" EX 5로 5초 TTL 키를 만들고, 3초 후에 SET mykey "hello" EX 5를 다시 실행하면 TTL이 어떻게 되는지 확인해보세요. (힌트: TTL이 리셋됩니다)✅ #1 Redis 시작하기: 설치·실행·첫 명령(PING)
✅ #2 키·TTL·만료: 캐시의 핵심 규칙 ← 현재 글
⬜ #3 Strings: 카운터·간단 캐시 만들기
⬜ #4 Hashes: 세션·프로필 저장소 만들기
⬜ #5 Lists·Sets: 큐와 중복 방지
⬜ #6 Sorted Sets: 랭킹/리더보드
⬜ #7 Pub/Sub vs Streams: 실시간 메시징
⬜ #8 동시성 제어: 트랜잭션(MULTI/EXEC)·WATCH
⬜ #9 퍼시스턴스: RDB·AOF·복구 체크
⬜ #10 복제·고가용성: Replication·Sentinel
⬜ #11 클러스터: 샤딩·해시 슬롯·제약
⬜ #12 보안·성능·관측: ACL·eviction·latency
'개발 > REDIS' 카테고리의 다른 글
| #6🏆 Sorted Sets: 랭킹/리더보드 (1) | 2026.03.19 |
|---|---|
| #5 📬 Lists·Sets: 큐와 중복 방지 (0) | 2026.03.19 |
| #4 List와 Queue 활용하기: 비동기 작업 큐 만들기 (0) | 2026.03.17 |
| #3 Strings: 카운터·간단 캐시 만들기 (0) | 2026.03.17 |
| #1 Redis 시작하기: 설치·실행·첫 명령(PING) (0) | 2026.03.17 |