캐싱 전략이란 무엇인가
웹사이트를 이용하다 보면 같은 페이지를 다시 열 때
처음보다 훨씬 빠르게 로딩되는 경험을 한 적이 있을 것이다.
이는 마치 냉장고에 자주 먹는 음식을 미리 꺼내놓는 것과 같다.
매번 냉장고를 열어 찾는 대신,
테이블 위에 꺼내두면 바로 먹을 수 있다.
캐싱(Caching)은 바로
자주 사용되는 데이터를 빠른 저장소에 미리 저장해두고
필요할 때 즉시 가져다 쓰는 성능 최적화의 핵심 기술이다.
왜 캐싱 전략이 필요할까?
문제 1: 느린 응답 속도
DB 조회는 10~100ms 걸리지만, 캐시는 1ms 이내 (10~100배 빠름)
문제 2: 데이터베이스 과부하
같은 데이터를 수천 명이 조회하면 DB가 병목이 된다.
캐시 사용 시 DB 부하 80% 이상 감소
문제 3: 비용 증가
DB 서버 확장은 비용이 많이 든다.
문제 4: 외부 API 의존성
외부 API 호출은 네트워크 지연과 비용 발생
기본 개념 요약
🏷️ 캐싱 계층
1. 브라우저 캐시
이미지, CSS, JavaScript를 브라우저에 저장
2. CDN 캐시
정적 파일을 전 세계에 분산 저장
3. 애플리케이션 캐시 (Redis/Memcached)
API 응답, 계산 결과, 세션 데이터 저장
4. 데이터베이스 캐시
쿼리 결과를 메모리에 저장
🏷️ 주요 캐싱 패턴
1. Cache-Aside (가장 일반적)
1. 캐시 확인
2. 캐시 히트 → 반환
3. 캐시 미스 → DB 조회 → 캐시 저장 → 반환
장점: 필요한 데이터만 캐싱
단점: 첫 요청은 느림
2. Write-Through
데이터 쓰기 시 캐시와 DB에 동시 저장
장점: 데이터 일관성 보장
단점: 쓰기 성능 저하
3. Write-Behind
캐시에만 먼저 쓰고, DB는 비동기 저장
장점: 쓰기 성능 매우 빠름
단점: 캐시 장애 시 데이터 손실 위험
🏷️ TTL (Time To Live)
일정 시간 후 자동으로 캐시 삭제
권장 TTL:
- 정적 데이터: 24시간
- 동적 데이터: 1시간
- 실시간 데이터: 1분
Redis 캐싱 실전 예시
Cache-Aside 패턴
import redis
import json
cache = redis.Redis(host='localhost', port=6379, decode_responses=True)
def get_user(user_id):
cache_key = f"user:{user_id}"
# 1. 캐시 확인
cached = cache.get(cache_key)
if cached:
return json.loads(cached)
# 2. DB 조회
user_data = db.query(f"SELECT * FROM users WHERE id = {user_id}")
# 3. 캐시 저장 (1시간)
cache.setex(cache_key, 3600, json.dumps(user_data))
return user_data
캐시 무효화
def update_user(user_id, data):
# DB 업데이트
db.update(user_id, data)
# 캐시 삭제 (중요!)
cache.delete(f"user:{user_id}")
Cache Stampede 방지
문제: 인기 데이터의 캐시 만료 시 동시 요청이 DB로 몰림
def get_with_lock(key):
cached = cache.get(key)
if cached:
return json.loads(cached)
# Lock 획득 (10초 타임아웃)
lock_key = f"lock:{key}"
if cache.set(lock_key, "1", nx=True, ex=10):
try:
data = db.query("SELECT ...")
cache.setex(key, 300, json.dumps(data))
return data
finally:
cache.delete(lock_key)
else:
# Lock 대기
time.sleep(0.1)
return get_with_lock(key)
캐시 Warming
def warm_up_cache():
"""서버 시작 시 인기 데이터 미리 캐싱"""
# 인기 게시글
posts = db.query("SELECT * FROM posts ORDER BY views DESC LIMIT 100")
for post in posts:
cache.setex(f"post:{post['id']}", 3600, json.dumps(post))
# 활성 사용자
users = db.query("SELECT * FROM users WHERE last_login > NOW() - INTERVAL 1 DAY")
for user in users:
cache.setex(f"user:{user['id']}", 7200, json.dumps(user))
다중 조회 최적화
def get_multiple_users(user_ids):
"""여러 사용자를 한 번에 조회"""
keys = [f"user:{uid}" for uid in user_ids]
cached = cache.mget(keys) # 한 번에 조회
result = []
missing_ids = []
for i, data in enumerate(cached):
if data:
result.append(json.loads(data))
else:
missing_ids.append(user_ids[i])
# 캐시 미스만 DB 조회
if missing_ids:
users = db.query(f"SELECT * FROM users WHERE id IN ({','.join(map(str, missing_ids))})")
for user in users:
cache.setex(f"user:{user['id']}", 3600, json.dumps(user))
result.append(user)
return result
캐시 모니터링
캐시 히트율 측정
def calculate_hit_rate():
info = cache.info('stats')
hits = info['keyspace_hits']
misses = info['keyspace_misses']
hit_rate = (hits / (hits + misses)) * 100
print(f"캐시 히트율: {hit_rate:.2f}%")
# 목표: 80% 이상
목표 히트율:
- 80% 이상: 우수
- 60~80%: 양호
- 60% 이하: 개선 필요
메모리 사용 모니터링
def check_memory():
info = cache.info('memory')
used = info['used_memory_human']
max_mem = info['maxmemory_human']
print(f"메모리: {used} / {max_mem}")
실전 최적화 기법
1. 적절한 TTL 설정
TTL_CONFIG = {
'static': 86400, # 24시간
'user': 3600, # 1시간
'session': 1800, # 30분
'trending': 300, # 5분
'realtime': 60, # 1분
}
def cache_with_smart_ttl(key, data, type='static'):
ttl = TTL_CONFIG.get(type, 3600)
cache.setex(key, ttl, json.dumps(data))
2. 명확한 캐시 키 설계
# ✅ 좋은 예
cache.set("user:123", data)
cache.set("post:456", data)
cache.set("user:123:posts", data)
# ❌ 나쁜 예
cache.set("u123", data)
cache.set("p456", data)
3. 데이터 압축
import zlib
def set_compressed(key, data, ttl=3600):
json_str = json.dumps(data)
compressed = zlib.compress(json_str.encode())
cache.setex(key, ttl, compressed)
def get_compressed(key):
compressed = cache.get(key)
if not compressed:
return None
return json.loads(zlib.decompress(compressed).decode())
4. 캐시 제거 정책
# redis.conf
# LRU: 가장 오래 사용 안 된 키 삭제
maxmemory-policy allkeys-lru
# LFU: 가장 적게 사용된 키 삭제
maxmemory-policy allkeys-lfu
권장: allkeys-lru
캐싱 안티패턴
❌ 안티패턴 1: 모든 데이터 캐싱
# 잘못된 예
for user in all_users: # 100만 명
cache.set(f"user:{user['id']}", user)
# ✅ 올바른 예: 자주 조회되는 데이터만
if access_count > 10:
cache.setex(key, 3600, data)
❌ 안티패턴 2: 캐시 무효화 누락
# 잘못된 예
def update_user(user_id, data):
db.update(user_id, data)
# 캐시 무효화 안 함!
# ✅ 올바른 예
def update_user(user_id, data):
db.update(user_id, data)
cache.delete(f"user:{user_id}")
❌ 안티패턴 3: 너무 큰 데이터
# 잘못된 예: 10MB 데이터 캐싱
cache.set("huge_data", big_object)
# ✅ 올바른 예: 필요한 필드만
cache.set("summary", {'id': 1, 'name': 'test'})
실전 체크리스트
✅ 캐시 설계
- 자주 조회되는 데이터만 캐싱
- 적절한 TTL 설정
- 명확한 키 네이밍 규칙
- 데이터 타입별 전략 수립
✅ 캐시 무효화
- 데이터 변경 시 캐시 삭제
- 연관 캐시도 함께 무효화
- TTL로 자동 만료 보장
✅ 모니터링
- 캐시 히트율 80% 이상 유지
- 메모리 사용률 모니터링
- 응답 시간 측정
✅ 안정성
- Cache Stampede 방지
- 캐시 장애 시 대응 방안
- 캐시 Warming 전략
요약
캐싱은 성능 최적화의 가장 효과적인 방법이다.
💎 핵심 포인트:
- Cache-Aside 패턴이 가장 일반적
- TTL 설정으로 자동 만료 관리
- 캐시 무효화는 반드시 구현
- 히트율 80% 이상 목표
- Cache Stampede 반드시 방지
- 모니터링으로 지속적 개선
🚀 적용 순서:
1단계: 자주 조회되는 데이터 식별
2단계: Redis로 Cache-Aside 구현
3단계: 적절한 TTL 설정
4단계: 캐시 무효화 로직 추가
5단계: 히트율 모니터링
6단계: 최적화 (압축, 다중 조회 등)
⚠️ 주의사항:
- 모든 데이터를 캐싱하지 말 것
- 캐시 무효화를 잊지 말 것
- 너무 큰 데이터는 캐싱 지양
- 캐시 장애 대비 필수
📊 기대 효과:
좋은 캐싱 전략은 숫자로 증명된다.
DB 부하 80% 감소, 응답 시간 10~100배 빠름,
서버 비용 50% 절감 등의 효과를 기대할 수 있다.
캐싱은 한 번 구현하면 지속적으로 성능 향상 효과를 제공한다.
데이터 특성에 맞는 전략을 선택하고,
모니터링을 통해 지속적으로 최적화하는 것이 중요하다.