본문으로 바로가기
기술

Redis 캐싱 전략 실전 가이드 — 서비스 속도를 10배 높이는 방법

A
AlwaysCorp 기술팀· 기술·개발 콘텐츠 전문
||13분 읽기
#Redis#캐싱#성능최적화#백엔드#인메모리#분산시스템#TTL#세션관리#레이트리미팅

가장 빠른 데이터베이스 쿼리는 "실행하지 않는 쿼리"

아무리 인덱스를 최적화하고 쿼리를 튜닝해도, 디스크 I/O가 필요한 데이터베이스 쿼리는 인메모리 캐시에 비하면 느릴 수밖에 없습니다. Redis의 평균 응답 시간은 1ms 미만인 반면, 잘 최적화된 PostgreSQL 쿼리도 5~50ms가 걸립니다. 이 차이가 사용자가 체감하는 서비스 속도를 결정합니다.

Redis Labs의 2024 벤치마크에 따르면, 캐싱 레이어를 도입한 서비스의 평균 응답 시간이 85% 감소했다고 보고합니다. 물론 캐싱이 만능은 아니지만, 올바르게 적용하면 인프라 비용을 줄이면서 사용자 경험을 극적으로 개선할 수 있습니다.

---

Redis 기초 — 왜 Redis인가

Redis(Remote Dictionary Server)는 인메모리 데이터 구조 스토어입니다. 단순한 키-값 저장소를 넘어, 문자열, 해시, 리스트, 셋, 정렬 셋, 스트림 등 다양한 데이터 구조를 네이티브로 지원합니다.

```bash # 기본 문자열 SET user:123:name "김개발" EX 3600 # 1시간 TTL GET user:123:name

# 해시 — 객체 저장 HSET user:123 name "김개발" email "[email protected]" role "admin" HGET user:123 name HGETALL user:123

# 정렬 셋 — 리더보드, 랭킹 ZADD leaderboard 9500 "player:1" 8800 "player:2" 9200 "player:3" ZREVRANGE leaderboard 0 9 WITHSCORES # 상위 10명

# 리스트 — 최근 활동, 큐 LPUSH recent:user:123 "viewed:post:456" LTRIM recent:user:123 0 49 # 최근 50개만 유지 ```

---

캐싱 패턴 — 어떻게 캐시할 것인가

Cache-Aside (Lazy Loading)

가장 널리 사용되는 패턴입니다. 애플리케이션이 캐시를 먼저 확인하고, 없으면 DB에서 읽어와 캐시에 저장합니다.

```typescript async function getUser(userId: string): Promise<User> { // 1. 캐시 확인 const cached = await redis.get(`user:${userId}`); if (cached) { return JSON.parse(cached); // Cache Hit }

// 2. DB 조회 const user = await db.user.findUnique({ where: { id: userId } }); if (!user) throw new NotFoundError();

// 3. 캐시 저장 (TTL 1시간) await redis.set(`user:${userId}`, JSON.stringify(user), 'EX', 3600); return user; } ```

이 패턴의 장점은 실제로 요청된 데이터만 캐시에 올라간다는 것입니다. 단점은 최초 요청 시(Cache Miss) 항상 DB 조회가 필요하다는 점이죠.

Write-Through

데이터를 쓸 때 DB와 캐시를 동시에 업데이트합니다.

```typescript async function updateUser(userId: string, data: UpdateUserInput): Promise<User> { // DB 업데이트 const user = await db.user.update({ where: { id: userId }, data, });

// 캐시도 동시 업데이트 await redis.set(`user:${userId}`, JSON.stringify(user), 'EX', 3600); return user; } ```

Cache-Aside와 Write-Through를 조합하면 읽기와 쓰기 모두에서 캐시 일관성을 유지할 수 있습니다.

Write-Behind (Write-Back)

쓰기 작업을 캐시에 먼저 반영하고, 비동기로 DB에 저장합니다. 쓰기 성능이 극적으로 향상되지만, 캐시 장애 시 데이터 유실 위험이 있어 주의가 필요합니다.

Redis 캐싱 패턴 비교 — Cache-Aside, Write-Through, Write-Behind
Redis 캐싱 패턴 비교 — Cache-Aside, Write-Through, Write-Behind

---

TTL 설계 — 캐시의 "유통기한"

TTL(Time To Live)은 캐시 데이터의 유효 기간입니다. 너무 짧으면 Cache Hit율이 낮아지고, 너무 길면 오래된 데이터가 제공됩니다.

```typescript // 데이터 특성에 따른 TTL 설계 const TTL = { // 거의 변하지 않는 데이터 STATIC_CONFIG: 24 60 60, // 24시간 USER_PROFILE: 60 * 60, // 1시간

// 자주 변하는 데이터 TIMELINE: 5 * 60, // 5분 SEARCH_RESULTS: 60, // 1분

// 실시간 데이터 RATE_LIMIT: 60, // 1분 SESSION: 30 * 60, // 30분 } as const; ```

캐시 스탬피드(Cache Stampede) 방지

인기 있는 키의 TTL이 만료되면, 동시에 수십~수백 개의 요청이 DB로 몰려갈 수 있습니다. 이것을 "캐시 스탬피드" 또는 "썬더링 허드(Thundering Herd)"라고 합니다.

```typescript async function getUserWithLock(userId: string): Promise<User> { const cached = await redis.get(`user:${userId}`); if (cached) return JSON.parse(cached);

// 분산 락으로 중복 DB 조회 방지 const lockKey = `lock:user:${userId}`; const acquired = await redis.set(lockKey, '1', 'EX', 10, 'NX');

if (acquired) { try { const user = await db.user.findUnique({ where: { id: userId } }); await redis.set(`user:${userId}`, JSON.stringify(user), 'EX', 3600); return user!; } finally { await redis.del(lockKey); } } else { // 다른 프로세스가 캐시를 갱신 중 — 잠시 후 재시도 await new Promise(r => setTimeout(r, 100)); return getUserWithLock(userId); } } ```

---

캐시 무효화 — "CS에서 가장 어려운 두 가지 문제"

"컴퓨터 과학에서 진정으로 어려운 두 가지 문제는 캐시 무효화와 이름 짓기다." — Phil Karlton

캐시 무효화 전략은 크게 세 가지입니다.

이벤트 기반 무효화: 데이터가 변경될 때 관련 캐시를 즉시 삭제합니다. 가장 정확하지만, 모든 변경 지점을 파악해야 합니다.

TTL 기반 만료: 일정 시간이 지나면 자동으로 만료됩니다. 간단하지만, TTL 동안 오래된 데이터가 제공될 수 있습니다.

버전 기반: 캐시 키에 버전 번호를 포함합니다. 데이터가 변경되면 버전을 올려 이전 캐시를 자연스럽게 무효화합니다.

```typescript // 이벤트 기반 + TTL 조합 (권장) async function deleteUser(userId: string) { await db.user.delete({ where: { id: userId } });

// 관련 캐시 모두 삭제 await redis.del(`user:${userId}`); await redis.del(`user:${userId}:posts`); await redis.del(`user:${userId}:followers`); } ```

---

Redis 활용 패턴 — 캐싱을 넘어서

세션 관리

```typescript // 세션 저장 await redis.set(`session:${sessionId}`, JSON.stringify(sessionData), 'EX', 1800);

// 세션 조회 + TTL 갱신 (슬라이딩 윈도우) const session = await redis.get(`session:${sessionId}`); if (session) { await redis.expire(`session:${sessionId}`, 1800); // TTL 리셋 } ```

레이트 리미팅 (슬라이딩 윈도우)

```typescript async function checkRateLimit(userId: string, limit: number, windowSec: number): Promise<boolean> { const key = `ratelimit:${userId}`; const now = Date.now(); const windowStart = now - windowSec * 1000;

const pipeline = redis.pipeline(); pipeline.zremrangebyscore(key, 0, windowStart); // 윈도우 밖 요청 제거 pipeline.zadd(key, now, `${now}`); // 현재 요청 추가 pipeline.zcard(key); // 윈도우 내 요청 수 pipeline.expire(key, windowSec); // TTL 설정

const results = await pipeline.exec(); const requestCount = results?.[2]?.[1] as number; return requestCount <= limit; } ```

Redis 메모리 관리와 Eviction 정책 비교
Redis 메모리 관리와 Eviction 정책 비교

---

운영 주의사항

Redis는 인메모리 데이터베이스이므로, 메모리 관리가 핵심입니다. `maxmemory`를 설정하지 않으면 시스템 메모리를 모두 소진하여 OOM(Out of Memory)이 발생할 수 있습니다. `maxmemory-policy`는 `allkeys-lru`(가장 오래 미사용된 키 삭제)가 캐싱 용도에 적합합니다.

프로덕션에서는 Redis Sentinel이나 Redis Cluster로 고가용성을 확보하세요. 단일 Redis 인스턴스에 장애가 발생하면 캐시 스탬피드와 함께 서비스 전체가 느려질 수 있으니까요.

캐싱은 "속도를 높이는 마법"이 아니라 "복잡도를 관리하는 기술"입니다. 캐시를 도입하면 데이터 일관성, 무효화 전략, 메모리 관리라는 새로운 과제가 생깁니다. 가장 효과적인 접근은 측정 가능한 병목이 있는 곳에만 선택적으로 캐싱을 적용하는 것입니다.
A

AlwaysCorp 기술팀

기술·개발 콘텐츠 전문

얼웨이즈 블로그에서 유용한 정보와 인사이트를 공유합니다.