Redis 캐싱 패턴 — Cache-Aside부터 세션까지

2026-05-02AWS SAA-C03 스터디

Redis 핵심 정리 시리즈 5편. 캐싱 패턴 4종(Cache-Aside·Write-Through·Write-Behind·Read-Through)을 사물함 직원 비유로 풀어가며 — 캐시 히트율, TTL 만료 전략, 슬라이딩 만료, 이벤트 기반 무효화, 세션 관리, 캐시 스탬피드 방지(분산 락·Probabilistic Early Expiration), Hot Key 분산까지 처음 보는 사람도 따라올 수 있게 친절하게 풀어쓴 5편.

📚 Redis 핵심 정리 · 5편 / 14편 — Cache-Aside부터 세션까지

이 글은 Redis 핵심 정리 시리즈의 다섯 번째 편입니다. 1~4편에서 Redis의 정체·데이터 구조·명령어·영속성을 차근차근 쌓았다면, 5편부터는 본격적으로 "실전에서 어떻게 쓰는가" 로 넘어갑니다. 그 시작이 바로 캐싱 패턴이에요.

캐시는 백엔드의 거의 모든 자리에 깔리지만 — 한 번에 헷갈리는 게 패턴이 한두 가지가 아니라는 점입니다. Cache-Aside·Write-Through·Write-Behind·Read-Through — 이름이 다 비슷해 보이는데 동작이 미묘하게 달라요. 이 5편에서는 사물함 직원 비유로 네 가지 캐싱 패턴을 한 번에 정리하고, 그다음 TTL·세션·스탬피드 방지까지 풀어 갑니다.

본문 흐름은 책상 위 메모리 사물함을 들고 있는 직원 한 명의 동선을 따라 풀어 가요. 직원이 사물함을 먼저 확인할지, 창고에 먼저 다녀올지, 사물함과 창고를 동시에 채울지 — 그 동선의 차이가 곧 캐싱 패턴의 정체입니다.

왜 캐싱 패턴이 처음엔 어렵게 느껴질까요

이유는 네 가지예요.

첫째, 이름이 너무 비슷비슷합니다. Cache-Aside·Write-Through·Write-Behind·Read-Through — 영어 단어 한두 개 차이라 머리에 매핑이 안 됩니다. 들을 때마다 "어, 그게 그거 아니야?" 싶어요.

둘째, 읽기와 쓰기 흐름이 섞여 있어요. "캐시에서 먼저 확인" vs "DB에서 먼저 가져온다" vs "DB와 캐시에 동시에" — 이 흐름이 한꺼번에 나오면 그림이 안 그려집니다.

셋째, TTL·무효화·일관성 트레이드오프가 직관에 어긋나요. "최신 데이터를 보장하면서 빠르게" 같은 욕심을 내면 두 가지가 동시에 안 잡힙니다. 어딘가에서 양보해야 하는데 그 양보 지점이 처음엔 안 보입니다.

넷째, 운영 함정(스탬피드·Hot Key)이 평범한 코드에서는 안 보여요. 평소엔 잘 돌다가 트래픽이 한 번 몰리면 DB가 통째로 멈추는 — 그런 함정들은 코드만 봐서는 예측이 안 됩니다.

해결법은 한 가지예요. 사물함 직원 한 명의 동선을 머리에 박는 겁니다. 직원이 책상 옆 사물함과 멀리 있는 창고를 어떤 순서로 다녀오는가 — 이 동선만 머리에 들어오면 네 가지 패턴이 갑자기 명확해집니다. 이 글은 그 비유를 따라 처음부터 풀어 갑니다.

캐싱이 도대체 왜 필요할까요

캐시(Cache) 는 자주 쓰거나 계산 비용이 비싼 데이터를 임시로 저장해 두고 빠르게 재사용하는 기술이에요. Redis는 인메모리 특성 덕분에 캐시 계층의 표준이 됐습니다.

회사 비유로 풀면 — DB는 멀리 있는 본사 자료 창고예요. 자주 보는 자료를 매번 창고까지 다녀오면 시간이 오래 걸립니다. 그래서 책상 옆 사물함(Redis) 에 자주 보는 자료를 미리 넣어 두는 거예요. 손만 뻗으면 1ms 안에 꺼낼 수 있습니다.

클라이언트 → Redis 캐시 (HIT)  → 즉시 반환 (~1ms)
클라이언트 → Redis 캐시 (MISS) → DB 조회 → Redis 저장 → 반환 (~100ms)

여기서 시험 함정이 하나 있어요. 캐시는 항상 "최신 데이터가 아닐 수 있다"는 트레이드오프를 안고 있습니다. 이걸 받아들이지 않으면 캐시가 오히려 버그의 원인이 돼요. 데이터 일관성과 성능 사이의 균형을 어디에서 잡을지가 캐싱 설계의 핵심입니다.

캐시 히트율 — 캐시가 잘 도는지 보는 한 가지 숫자

캐시가 잘 돌고 있는지 한 줄로 알려주는 숫자가 히트율(Hit Rate) 이에요. 전체 요청 중 캐시에서 바로 답을 찾은 비율입니다.

히트율 = 캐시 히트 수 / 전체 요청 수 × 100%

예시:
전체 요청 1000건
캐시 히트 850건
히트율 = 85%

일반적으로 80% 이상을 목표로 잡습니다. 80%면 100건 중 80건은 사물함에서, 20건만 창고에 다녀오는 셈이에요. Redis에서 직접 확인하는 명령은 다음과 같아요.

# Redis 캐시 통계 확인
redis-cli INFO stats

# 주요 지표
keyspace_hits:12345      # 캐시 히트 수
keyspace_misses:1234     # 캐시 미스 수
hit_rate = hits / (hits + misses) × 100

운영에서는 이 두 숫자(keyspace_hits·keyspace_misses)를 모니터링 대시보드에 박아 두는 게 표준이에요. 히트율이 갑자기 떨어지면 캐시 키 패턴이 깨졌거나 TTL이 너무 짧게 설정됐다는 신호입니다.

Cache-Aside 패턴 — 직원이 직접 사물함부터 확인

여기서부터 본격적인 캐싱 패턴 4종을 풀어 갑니다. 가장 흔한 첫 번째가 Cache-Aside(또는 Lazy Loading)예요.

회사 비유로 — 직원이 자료를 받아오라는 요청을 받고 먼저 책상 옆 사물함을 열어 봅니다. 사물함에 있으면 그대로 가져다 주고, 없으면 그제서야 창고까지 가서 자료를 찾고, 사물함에도 한 부 복사해 두고 돌아옵니다. 다음 번 같은 요청이 오면 사물함에서 바로 꺼낼 수 있게요.

1. 캐시 조회 → HIT: 반환
                MISS: DB 조회 → 캐시 저장 → 반환

코드로 풀면 이렇게 돼요.

// Cache-Aside 패턴 구현 (TypeScript/node-redis)
async function getUserById(userId: string) {
    const cacheKey = `users#${userId}`;
    
    // 1단계: 캐시 조회
    const cached = await client.get(cacheKey);
    if (cached) {
        console.log('Cache HIT');
        return JSON.parse(cached);   // 캐시 히트: 즉시 반환
    }
    
    // 2단계: 캐시 미스 → DB 조회
    console.log('Cache MISS');
    const user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
    
    // 3단계: 캐시 저장 (TTL 설정)
    await client.set(cacheKey, JSON.stringify(user), {
        EX: 3600   // 1시간 후 만료
    });
    
    return user;
}

이 캐싱 패턴의 장단점을 정리하면 다음과 같아요.

항목내용
장점필요한 데이터만 캐시에 저장, 캐시 장애 시 DB 직접 조회 가능
단점첫 요청 시 지연 발생(Cache Miss Penalty), 데이터 오염 가능성
적합한 경우읽기가 많은 워크로드, 일부 데이터만 자주 조회되는 경우

여기서 시험 함정이 하나 있어요. Cache-Aside는 첫 요청은 항상 느립니다. 캐시가 비어 있으니까요. 이걸 "Cache Miss Penalty"라고 부르는데, 트래픽이 처음 시작될 때나 캐시가 한꺼번에 만료될 때 이 지연이 누적되면 문제가 됩니다. 뒤에서 다룰 캐시 스탬피드도 이 지점에서 발생하는 함정이에요.

Write-Through 패턴 — 새 자료가 들어오면 사물함과 창고에 동시에

두 번째는 Write-Through입니다. 이름 그대로 — 자료를 쓸 때 캐시와 DB를 동시에 업데이트해요.

회사 비유로 — 새 자료가 회사에 들어오면 직원이 창고에도 한 부, 책상 옆 사물함에도 한 부 동시에 보관합니다. 그러면 다음 번 누가 그 자료를 찾을 때 사물함에서 바로 꺼낼 수 있고, 사물함의 자료도 항상 창고와 똑같이 최신이에요.

쓰기 요청 → DB 저장 → 캐시 저장 → 완료 응답
읽기 요청 → 캐시 조회 (항상 최신 데이터)

코드로 풀면 다음과 같아요.

// Write-Through 패턴 구현
async function updateUser(userId: string, userData: any) {
    const cacheKey = `users#${userId}`;
    
    // 1단계: DB 저장
    await db.query('UPDATE users SET ... WHERE id = ?', [userId, userData]);
    
    // 2단계: 캐시도 즉시 업데이트 (동시에)
    await client.set(cacheKey, JSON.stringify(userData), {
        EX: 3600
    });
    
    return userData;
}

// 또는 Pipeline을 이용한 원자적 처리
async function updateUserWithPipeline(userId: string, userData: any) {
    const cacheKey = `users#${userId}`;
    
    // DB 트랜잭션과 Redis 업데이트를 최대한 가깝게 처리
    await db.transaction(async (trx) => {
        await trx.query('UPDATE users SET ... WHERE id = ?', [userId, userData]);
    });
    
    await client.set(cacheKey, JSON.stringify(userData), { EX: 3600 });
}
항목내용
장점캐시 항상 최신 상태 보장, 읽기 성능 최대화
단점쓰기 지연 증가, 자주 쓰이지 않는 데이터도 캐시에 저장됨
적합한 경우읽기/쓰기 비율이 비슷한 경우, 데이터 일관성이 중요한 경우

잠깐, 이 부분이 헷갈리는데 — Write-Through는 쓰기가 항상 두 곳(DB + 캐시)을 모두 업데이트해야 하니까 쓰기는 살짝 느려집니다. 대신 읽기는 캐시가 늘 최신이라 폭발적으로 빠르죠. 읽기/쓰기 비율이 비슷하거나, 데이터 일관성이 결정적인 경우(잔액·주문 상태 등)에 적합한 캐싱 패턴이에요.

Write-Behind 패턴 — 사물함 먼저, 창고는 나중에

세 번째는 Write-Behind(또는 Write-Back)입니다. 캐시에만 먼저 쓰고, DB 저장은 비동기로 미루는 방식이에요.

회사 비유로 — 직원이 새 자료를 받으면 일단 사물함에만 넣어 두고 응답을 줍니다. 창고로 옮기는 일은 나중에 다른 직원이 따로 처리하게 큐에 쌓아 둬요. 응답은 빨라지지만, 사물함이 갑자기 부서지면 아직 창고로 옮기지 못한 자료가 사라질 위험이 있습니다.

쓰기 요청 → 캐시 저장 → 즉시 응답 (빠름)
           ↓ 비동기
           DB 저장 (나중에)
// Write-Behind 패턴 예시
async function updateViewCount(itemId: string) {
    const cacheKey = `items:views#${itemId}`;
    
    // 캐시에만 즉시 업데이트
    const newCount = await client.incr(cacheKey);
    
    // DB 저장은 비동기로 큐에 추가
    await client.lPush('pending:db:updates', JSON.stringify({
        type: 'updateViewCount',
        itemId,
        count: newCount,
        timestamp: Date.now()
    }));
    
    return newCount;
}

// 백그라운드 워커: DB에 플러시
async function flushToDatabase() {
    while (true) {
        const item = await client.rPop('pending:db:updates');
        if (item) {
            const data = JSON.parse(item);
            await db.query('UPDATE items SET views = ? WHERE id = ?', 
                          [data.count, data.itemId]);
        }
        await sleep(1000); // 1초마다 처리
    }
}

여기서 정말 중요한 시험 함정 — Write-Behind는 약간의 데이터 손실 위험을 받아들이는 패턴입니다. 캐시가 다운되면 큐에 있던 일부 쓰기가 사라질 수 있어요. 그래서 조회수·로그·집계 카운터처럼 한두 건 손실돼도 큰 문제 없는 데이터에만 사용합니다. 잔액·주문 같은 결정적 데이터에는 절대 쓰면 안 돼요.

Read-Through 패턴 — 사물함이 알아서 창고에 다녀온다

네 번째는 Read-Through입니다. Cache-Aside와 비슷한데 DB 조회 책임이 캐시 계층으로 옮겨간 형태예요.

회사 비유로 — Cache-Aside에서는 직원이 사물함을 열어 보고 없으면 직접 창고로 갔어요. Read-Through에서는 사물함이 똑똑해져서, 직원이 사물함에 자료를 달라고 하면 사물함이 알아서 비어 있을 때 창고에 다녀와 채워 줍니다. 직원은 사물함 한 곳만 상대하면 돼요.

// Read-Through 패턴 추상화 계층
class CacheProxy {
    async get(key: string, loader: () => Promise<any>, ttl: number = 3600) {
        const cached = await client.get(key);
        if (cached) return JSON.parse(cached);
        
        // 캐시 계층이 직접 로더 함수 호출
        const data = await loader();
        await client.set(key, JSON.stringify(data), { EX: ttl });
        return data;
    }
}

// 사용
const cache = new CacheProxy();
const user = await cache.get(
    `users#${userId}`,
    () => db.query('SELECT * FROM users WHERE id = ?', [userId]),
    3600
);

여기까지 따라오셨다면 한 가지 의문이 들 거예요. "그러면 Cache-Aside랑 Read-Through가 본질적으로 같은 거 아닌가요?" — 맞습니다. 결과 동작은 동일하고, 단지 DB 호출 책임을 누가 지느냐의 차이예요. Cache-Aside는 애플리케이션 코드가, Read-Through는 캐시 추상화 계층이 책임집니다. 그래서 Read-Through로 추상화해 두면 애플리케이션 코드가 훨씬 깔끔해져요.

TTL — 사물함 자료의 유통 기한

캐시에 들어간 자료를 언제 빼낼 것인가를 결정하는 게 캐시 무효화(Cache Invalidation) 전략이에요. 가장 단순한 방법이 TTL(Time To Live) — 자료에 유통 기한을 붙여 두는 겁니다.

# TTL 옵션
SET key value EX 3600      # 3600초(1시간) 후 만료
SET key value PX 60000     # 60000밀리초(1분) 후 만료
SET key value EXAT 1714500000  # Unix 타임스탬프 기준 만료
SET key value KEEPTTL      # 기존 TTL 유지하며 값만 변경

# TTL 조회 및 변경
TTL key       # 남은 TTL(초) 반환, -1: 영구, -2: 키 없음
PTTL key      # 남은 TTL(밀리초) 반환
EXPIRE key 3600   # TTL 재설정
PERSIST key       # TTL 제거 (영구 저장)

여기서 시험 함정이 하나 있어요. TTL을 너무 짧게 잡으면 캐시 효율이 떨어지고, 너무 길게 잡으면 오래된 데이터가 서빙됩니다. 데이터 변경 빈도에 맞춰 TTL을 잡는 게 핵심이에요. 가이드라인은 다음과 같아요.

데이터 유형권장 TTL이유
세션 토큰24시간 ~ 7일보안과 편의성 균형
인증 캐시1분 ~ 5분권한 변경 빠른 반영
상품 목록1분 ~ 10분자주 변경 가능
사용자 프로필30분 ~ 1시간변경 빈도 낮음
정적 설정1시간 ~ 24시간변경 드묾
임시 OTP3분 ~ 10분보안상 짧게
좋아요/조회수영구 (PERSIST)중요 데이터

슬라이딩 만료 — 사용할 때마다 유통 기한 갱신

세션처럼 "사용자가 활동 중인 동안에는 만료되면 안 되는" 데이터에는 슬라이딩 만료(Sliding Expiration) 가 어울려요. 접근할 때마다 TTL을 다시 늘려 주는 패턴입니다.

// 접근할 때마다 TTL 갱신 (슬라이딩 만료)
async function getSessionWithSliding(token: string) {
    const sessionKey = `sessions#${token}`;
    const session = await client.hGetAll(sessionKey);
    
    if (session && Object.keys(session).length > 0) {
        // 접근할 때마다 TTL 재설정
        await client.expire(sessionKey, 86400);  // 24시간 연장
    }
    
    return session;
}

회사 비유로 — 출입증 임시 보관함에서 출입증을 꺼낼 때마다 보관함의 유통 기한이 24시간씩 자동 연장되는 셈이에요. 24시간 동안 한 번도 안 꺼내면 그제야 폐기됩니다.

이벤트 기반 무효화 — 데이터가 바뀌면 캐시 즉시 삭제

TTL은 시간이 지나야 삭제되는 방식이라 그 사이 오래된 데이터가 서빙될 수 있어요. 데이터가 실제로 변경됐을 때 관련 캐시를 즉시 삭제하는 게 이벤트 기반 무효화입니다.

// 이벤트 기반 캐시 무효화
async function updateProduct(productId: string, data: any) {
    // DB 업데이트
    await db.query('UPDATE products SET ... WHERE id = ?', [productId, data]);
    
    // 관련 캐시 무효화
    const keysToInvalidate = [
        `products#${productId}`,          // 단일 상품 캐시
        `products:list`,                   // 상품 목록 캐시
        `categories:${data.categoryId}`,   // 카테고리별 목록 캐시
    ];
    
    await client.del(keysToInvalidate);
}

패턴 매칭으로 한 번에 지우고 싶을 때는 반드시 SCAN 으로 처리해요. KEYS *는 절대 운영에 쓰면 안 됩니다(3편에서 다뤘죠).

# 패턴으로 키 검색 후 삭제 (운영 환경 주의!)
# KEYS * 사용 대신 SCAN 사용

redis-cli --scan --pattern "users:*" | xargs redis-cli del

# TypeScript에서 SCAN 기반 삭제
async function deleteByPattern(pattern: string) {
    const keys: string[] = [];
    let cursor = 0;
    
    do {
        const result = await client.scan(cursor, {
            MATCH: pattern,
            COUNT: 100
        });
        cursor = result.cursor;
        keys.push(...result.keys);
    } while (cursor !== 0);
    
    if (keys.length > 0) {
        await client.del(keys);
    }
}

더 자세한 키 만료 이벤트와 keyspace notifications 사용은 Redis 공식 keyspace-notifications 문서에서 확인할 수 있어요.

세션 관리 — 출입증 임시 보관함

캐시 패턴 다음으로 흔한 Redis 사용처가 세션 관리입니다. 회사 비유로 — 직원이 출근할 때 받은 출입증을 보관하는 임시 사물함이에요.

서버 사이드 세션을 Redis에 두면 다음 효과가 있어요.

  • 여러 서버 인스턴스 간 세션 공유 가능 — Sticky Session 불필요
  • 빠른 인증 체크 — 인메모리라 1ms 안에 응답
  • TTL로 자동 세션 만료 — 24시간 후 자동 폐기
  • 서버 재시작에도 세션 유지 — Redis가 세션을 들고 있으니까
# 세션 데이터 저장 (Hash 사용)
HSET sessions#token123 userId "456" username "alice" role "admin"
EXPIRE sessions#token123 86400    # 24시간 세션 유지

# 세션 조회
HGETALL sessions#token123
# 결과:
# 1) "userId"
# 2) "456"
# 3) "username"
# 4) "alice"
# 5) "role"
# 6) "admin"

TypeScript로 세션 매니저를 직접 구현하면 다음과 같아요.

// session-manager.ts
import { createClient } from 'redis';
import { randomBytes } from 'crypto';

const client = createClient({ ... });

// 세션 생성
async function createSession(userId: string, userData: object): Promise<string> {
    const sessionToken = randomBytes(32).toString('hex');
    const sessionKey = `sessions#${sessionToken}`;
    
    await client.hSet(sessionKey, {
        userId,
        ...userData,
        createdAt: Date.now().toString(),
    });
    await client.expire(sessionKey, 86400);  // 24시간
    
    return sessionToken;
}

// 세션 조회
async function getSession(token: string): Promise<object | null> {
    const sessionKey = `sessions#${token}`;
    const session = await client.hGetAll(sessionKey);
    
    if (!session || Object.keys(session).length === 0) {
        return null;  // 세션 없음 (만료 또는 존재하지 않음)
    }
    
    // 세션 접근 시 TTL 갱신 (sliding expiration)
    await client.expire(sessionKey, 86400);
    
    return session;
}

// 세션 삭제 (로그아웃)
async function deleteSession(token: string): Promise<void> {
    const sessionKey = `sessions#${token}`;
    await client.del(sessionKey);
}

이커머스 마켓플레이스 예시 — 회원 가입 시 세션 생성 흐름

실제 이커머스 마켓플레이스 예시 코드를 따라가면 캐시 + 세션 패턴이 어떻게 묶이는지 한 번에 보여요.

// 키 헬퍼 함수 (1편에서 다룬 패턴)
export const usersKey = (id: string) => `users#${id}`;
export const sessionsKey = (id: string) => `sessions#${id}`;

// 사용자 생성 시 세션도 함께 생성
async function signup(username: string, password: string) {
    // 1. 사용자명 중복 확인
    const existingUserId = await client.zScore('users:usernames', username);
    if (existingUserId !== null) throw new Error('Username already exists');
    
    // 2. 사용자 생성
    const userId = randomBytes(8).toString('hex');
    const hashedPassword = await bcrypt.hash(password, 10);
    
    await client.hSet(usersKey(userId), {
        id: userId,
        username,
        password: hashedPassword,
    });
    
    // 3. 사용자명 → ID 매핑 (Sorted Set)
    await client.zAdd('users:usernames', {
        value: username,
        score: parseInt(userId.slice(0, 8), 16),  // ID를 점수로 변환
    });
    
    // 4. 세션 생성
    const sessionId = randomBytes(16).toString('hex');
    await client.hSet(sessionsKey(sessionId), { userId });
    await client.expire(sessionsKey(sessionId), 86400);
    
    return { userId, sessionId };
}

여기서 정말 중요한 시험 함정 — Redis에는 SQL의 UNIQUE 제약이 없습니다. 그래서 사용자명 중복 확인을 Sorted Set으로 따로 만들어 처리해요. zAddNX 옵션을 쓰면 이미 존재하는 사용자명은 추가가 거부돼서 SQL UNIQUE 제약을 흉내 낼 수 있습니다.

// 사용자명 중복 확인 및 등록
async function registerUsername(username: string, userId: string): Promise<boolean> {
    // NX 옵션: 이미 존재하면 추가하지 않음
    const added = await client.zAdd('users:usernames', {
        value: username,
        score: parseInt(userId.slice(0, 8), 16),
    }, { NX: true });
    
    return added === 1;  // 1: 추가됨, 0: 이미 존재
}

Pre-Calculation 패턴 — 읽을 때 집계하지 말고 쓸 때 미리 계산

좋아요 수·조회수 같은 집계 값은 읽을 때마다 COUNT하지 말고, 쓰는 시점에 미리 계산해 둡니다. 이 패턴이 Pre-Calculation이에요.

// Pre-Calculation 패턴: 좋아요 수를 Hash 필드로 유지
async function addLike(userId: string, itemId: string): Promise<void> {
    const pipeline = client.multi();
    
    // Set에 추가 (중복 방지)
    pipeline.sAdd(`users:likes#${userId}`, itemId);
    // Hash의 likes 필드 증가 (Pre-calculated count)
    pipeline.hIncrBy(`items#${itemId}`, 'likes', 1);
    
    await pipeline.exec();
}

// 아이템 조회 시 미리 계산된 좋아요 수 즉시 반환 (집계 연산 불필요)
async function getItem(itemId: string) {
    const item = await client.hGetAll(`items#${itemId}`);
    return {
        ...item,
        likes: parseInt(item.likes || '0'),  // 미리 계산된 값 바로 사용
        views: parseInt(item.views || '0'),
    };
}

회사 비유로 — 매번 누군가 좋아요 수를 물을 때마다 1만 명 명단을 처음부터 세지 않고, 좋아요가 추가될 때마다 카운터를 +1 해 두는 거예요. 읽기는 폭발적으로 빠르지만, 카운터가 실제 데이터와 어긋날 위험이 있어 정기적으로 검증해야 합니다.

캐시 스탬피드 — 인기 캐시가 동시에 만료되는 순간

운영에서 가장 무서운 함정이 캐시 스탬피드(Cache Stampede) 또는 Thundering Herd 입니다. 회사 비유로 — 인기 자료가 들어 있던 사물함의 유통 기한이 오후 3시 정각에 동시에 만료되면, 그 순간 1000명의 직원이 동시에 창고로 우르르 몰려가는 거예요. 창고 직원이 압사합니다.

캐시 만료 → 동시에 1000개 요청 → 모두 DB로 → DB 과부하!

해결법 두 가지를 정리하면 이렇게 돼요.

분산 락으로 해결

첫 번째 요청만 DB에 다녀오게 하고, 나머지는 잠깐 기다렸다가 캐시에서 가져가게 합니다.

// 캐시 스탬피드 방지: 분산 락 사용
async function getWithLock(key: string, loader: () => Promise<any>, ttl: number) {
    const lockKey = `lock:${key}`;
    
    // 캐시 히트 확인
    const cached = await client.get(key);
    if (cached) return JSON.parse(cached);
    
    // 분산 락 획득 시도 (NX: 없을 때만, EX: 10초 후 자동 해제)
    const lockAcquired = await client.set(lockKey, '1', {
        NX: true,
        EX: 10
    });
    
    if (lockAcquired) {
        try {
            // 락 획득 성공: DB 조회
            const data = await loader();
            await client.set(key, JSON.stringify(data), { EX: ttl });
            return data;
        } finally {
            await client.del(lockKey);
        }
    } else {
        // 다른 프로세스가 로딩 중: 잠시 기다렸다가 재시도
        await sleep(100);
        return getWithLock(key, loader, ttl);
    }
}

Probabilistic Early Expiration — 만료 직전 미리 갱신

또 다른 해결법은 TTL이 다 차기 전 미리 캐시를 갱신하는 방식이에요. 만료 직전 10% 구간에서 한 요청이 백그라운드로 캐시를 새로 채워 둡니다.

// Early 재생성으로 스탬피드 방지
async function getWithEarlyRenewal(key: string, loader: () => Promise<any>, ttl: number) {
    const cached = await client.get(key);
    const remainingTTL = await client.ttl(key);
    
    if (cached && remainingTTL > ttl * 0.1) {
        // TTL이 10% 이상 남아있으면 캐시 반환
        return JSON.parse(cached);
    }
    
    // TTL이 10% 미만 또는 캐시 없음: 새로 로드
    const data = await loader();
    await client.set(key, JSON.stringify(data), { EX: ttl });
    return data;
}

운영에서 자주 보는 함정 5가지

여기까지 패턴을 잡았다면 마지막으로 운영 함정 다섯 가지를 압축해서 짚고 갑니다.

1. 캐시 일관성 누락 — DB 업데이트 후 캐시 무효화 깜빡

// 잘못된 패턴: DB 업데이트 후 캐시 업데이트 누락
async function updateUser_WRONG(userId: string, data: any) {
    await db.update(userId, data);
    // 캐시 무효화 누락! → 오래된 데이터 서빙
}

// 올바른 패턴: DB 업데이트 후 반드시 캐시 무효화
async function updateUser_CORRECT(userId: string, data: any) {
    await db.update(userId, data);
    await client.del(`users#${userId}`);  // 캐시 삭제
    // 또는
    await client.set(`users#${userId}`, JSON.stringify(data), { EX: 3600 });
}

2. 변경 빈도 무시한 TTL 설정

// 잘못된 패턴: 자주 변경되는 데이터를 오래 캐싱
await client.set('stock:count', stockCount, { EX: 86400 });  // 재고는 자주 변경됨!

// 올바른 패턴: 변경 빈도에 맞는 TTL
await client.set('stock:count', stockCount, { EX: 10 });  // 재고는 짧은 TTL
await client.set('product:description', desc, { EX: 3600 });  // 설명은 긴 TTL

3. 키 충돌 — 같은 키 패턴에 다른 데이터

// 위험: 같은 키 패턴으로 다른 데이터 저장
await client.set('user:1', JSON.stringify(userData));      // 사용자 데이터
await client.set('user:1', JSON.stringify(userProfile));   // 덮어쓰기 위험!

// 안전: 명확한 키 네이밍
await client.set('users#1', JSON.stringify(userData));
await client.set('userProfiles#1', JSON.stringify(userProfile));

4. Hot Key — 인기 키 한 곳에 트래픽 집중

// 문제: 하나의 키에 과도한 트래픽 집중
// 예: 모든 사용자가 동일한 인기 상품 캐시에 접근

// 해결: 키 분산 (샤딩)
async function getPopularItem(itemId: string) {
    // 동일한 데이터를 여러 키에 복사하여 분산
    const shard = Math.floor(Math.random() * 3);
    const key = `items#${itemId}:shard:${shard}`;
    
    let item = await client.get(key);
    if (!item) {
        item = JSON.stringify(await db.getItem(itemId));
        // 모든 샤드에 저장
        for (let i = 0; i < 3; i++) {
            await client.set(`items#${itemId}:shard:${i}`, item, { EX: 300 });
        }
    }
    return JSON.parse(item);
}

회사 비유로 — 인기 자료가 들어 있는 사물함을 3~5개로 복제해 두고 직원들이 무작위로 한 곳을 골라 가게 만드는 거예요. 한 사물함 앞에 몰리는 일을 방지합니다.

5. 메모리 폭증 방지 — maxmemory 정책 설정

# 최대 메모리 설정 및 만료 정책
# redis.conf
maxmemory 2gb
maxmemory-policy allkeys-lru    # 가장 오래 사용하지 않은 키 삭제

# 정책 옵션:
# noeviction: 메모리 한계 도달 시 에러 반환 (기본값)
# allkeys-lru: 모든 키 중 LRU 방식으로 삭제
# volatile-lru: TTL 있는 키 중 LRU 방식으로 삭제
# allkeys-random: 무작위 삭제
# volatile-ttl: TTL이 가장 짧은 키 먼저 삭제

여기서 시험 함정이 하나 있어요. 기본값은 noeviction 으로, 메모리가 가득 차면 쓰기 명령에 에러를 반환합니다. 캐시 용도로 Redis를 쓴다면 거의 무조건 allkeys-lru 또는 volatile-lru 로 바꿔 두는 게 안전해요.

시험 직전 한 번 더 — 자주 헷갈리는 함정 모음

여기까지가 Redis 5편(캐싱 패턴)의 핵심입니다. 시험 직전·실무 실수 방지를 위한 압축 노트로 마무리할게요.

  • Cache-Aside — 직원이 사물함 먼저 확인, 없으면 창고 다녀와 사물함에 채움 (가장 일반적)
  • Write-Through — 새 자료 들어오면 사물함과 창고에 동시에 (읽기 최대화·쓰기 살짝 느림)
  • Write-Behind — 사물함 먼저, 창고는 나중 비동기 (쓰기 폭발적, 데이터 손실 위험)
  • Read-Through — Cache-Aside와 결과 동일, DB 호출 책임이 캐시 계층으로
  • 캐시는 항상 최신 데이터 보장 X — 일관성-성능 트레이드오프 받아들이기
  • 캐시 히트율 80%+ 목표keyspace_hits·keyspace_misses로 모니터링
  • TTL — 데이터 변경 빈도에 맞춰 (세션 24h, 인증 1~5분, 상품 1~10분, 설정 1~24h)
  • 슬라이딩 만료 — 접근할 때마다 TTL 갱신 (세션·활동 중인 데이터)
  • 이벤트 기반 무효화 — DB 업데이트 후 즉시 client.del()
  • 패턴 삭제는 SCAN으로KEYS * 절대 금지 (운영)
  • 세션 — Redis Hash + EXPIRE — Sticky Session 불필요·서버 재시작에도 유지
  • Redis는 UNIQUE 제약 없음 — 사용자명 중복은 Sorted Set + NX 옵션으로 흉내
  • Pre-Calculation — 읽을 때 COUNT 말고 쓸 때 카운터 증감 (Pipeline으로 묶기)
  • 캐시 스탬피드 — 인기 캐시 동시 만료 → DB 폭주 — 분산 락 또는 조기 갱신으로 해결
  • 분산 락SET lock NX EX 10 — 첫 요청만 DB 다녀오고 나머지는 대기
  • Probabilistic Early Expiration — TTL 10% 미만 남으면 미리 갱신
  • Hot Key 분산 — 인기 키를 3~5개 샤드로 복제, 무작위 선택
  • DB 업데이트 후 캐시 무효화 누락 — 가장 흔한 일관성 버그
  • 변경 빈도 ≠ TTL — 재고 86400초는 재앙, 1초~10초가 정답
  • maxmemory-policy — 캐시 용도라면 allkeys-lru 권장 (noeviction 기본값 위험)
  • 자주 헷갈리는 비교 — Cache-Aside vs Read-Through = 결과 같음·DB 호출 책임 위치만 다름
  • 자주 헷갈리는 비교 — Write-Through vs Write-Behind = 동시 쓰기 vs 비동기 쓰기

시리즈 다른 편

같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!