Skip to content

캐시의 계층과 전략

🎯 핵심 질문

왜 어떤 웹사이트는 50밀리초 만에 열리고, 어떤 웹사이트는 5초나 걸릴까요? 이는 마치 "왜 책가방에서 책을 꺼내는 건 1초인데, 도서관에서 책을 찾는 건 10분이나 걸릴까?"라고 묻는 것과 같습니다. 그 답은 바로——캐시입니다. 이 장에서는 캐시의 핵심 원리, 설계 패턴, 실전 기법을 깊이 있게 이해하여 시스템 성능을 100배 향상시키는 방법을 알아봅니다.


1. 왜 "캐시"를 사용해야 할까요?

1.1 "매번 조회"에서 "자주 쓰는 데이터 기억하기"로의 진화

컴퓨터 세계의 초기에는 프로그래머가 데이터가 필요할 때마다 하드디스크나 데이터베이스에서 조회했습니다. 이는 마치 수학 문제를 풀 때마다 책을 뒤져 공식을 찾는 것과 같아서, 정확하긴 하지만 효율이 매우 낮았습니다. 시스템 규모가 커지면서 이런 "매번 조회" 방식은 심각한 문제를 드러내기 시작했습니다. 데이터베이스 CPU가 95%까지 치솟고, 응답 시간이 100밀리초에서 8초로 폭증하며, 결국 전체 시스템이 붕괴되었습니다.

이는 마치 학생이 매일 수업마다 기숙사에서 도서관까지 달려가 자료를 찾고, 하루에 50번을 뛰어다니다 결국 길에서 쓰러지는 것과 같습니다. 해결책은 간단합니다. 책가방에 자주 쓰는 공식 노트를 넣어두고, 필요할 때 바로 책가방에서 꺼내면 매번 도서관까지 달려가지 않아도 됩니다. 캐시는 바로 컴퓨터 시스템의 "공식 노트"로, 자주 사용하는 데이터를 빠르게 접근할 수 있는 곳에 저장해 시스템이 매번 "도서관"(데이터베이스)에 가지 않도록 합니다.

🐌 캐시 없음

  • 매 요청마다 데이터베이스 조회
  • 데이터베이스 CPU 사용률 95%
  • 응답 시간 5-8초
  • 시스템 붕괴 위험

🚀 캐시 있음

  • 95% 요청 즉시 반환
  • 데이터베이스 CPU 사용률 < 20%
  • 응답 시간 50밀리초
  • 시스템 안정적 운영

이것이 바로 "캐시"가 해결하려는 핵심 문제입니다. 자주 사용하는 데이터의 복사본을 저장해 느린 저장소(데이터베이스)에 대한 접근을 줄이고, 시스템을 더 빠르고 안정적으로 만드는 것입니다.

Without cache
5-8 s response, high DB pressure
With cache
50 ms response, most reads served from memory

1.2 실제로 겪은 실패 이야기: 캐시가 구명줄인 이유

"지금 시스템은 괜찮은데, 왜 미리 캐시를 설계해야 하나요?"라고 생각할 수 있습니다. 실제 이야기를 하나 들려드리면, 캐시가 왜 "선택 사항"이 아니라 "필수 사항"인지 이해하게 될 것입니다.

아강의 데이터베이스 폭발기

아강은 스타트업의 풀스택 엔지니어로, 회사에서 소셜 앱을 만들었습니다. 초기에는 사용자가 적어(수백 명) 시스템이 정상적으로 운영되었고, 아강은 캐시가 필요 없다고 생각하며 데이터베이스를 직접 조회했습니다.

반년 후, 사용자가 10만 명으로 늘어났고, 어느 날 유명인이 앱에 게시물을 올리자 순식간에 10만 명의 사용자가 몰려들었습니다. 그 결과 데이터베이스가 바로 폭발했습니다. CPU 100%, 응답 시간이 100ms에서 30초로 늘어나, 결국 앱 전체가 붕괴되고 사용자가 대거 이탈했습니다.

사후 분석 결과: 만약 간단한 캐시 레이어(예: Redis)가 있어서 인기 게시물을 캐싱했다면, 데이터베이스 부하를 최소 95% 줄일 수 있었고, 시스템은 이 트래픽 폭주를 충분히 견뎌냈을 것입니다.

아강은 이로부터 한 가지 교훈을 얻었습니다. 캐시는 금상첨화가 아니라, 고동시성 시스템의 생명줄입니다. 캐시를 추가하지 않는 것은 안전벨트 없이 운전하는 것과 같습니다——평소에는 괜찮지만, 사고가 나면 늦습니다.

💡 핵심 교훈

캐시의 가치는 단순히 "더 빠르게"가 아니라, 더 중요한 것은 "보호"입니다. 데이터베이스가 과부하로 무너지지 않도록 보호하고, 높은 트래픽 속에서도 시스템이 안정적으로 운영되도록 합니다. 시스템을 설계할 때, 문제가 발생한 후에야 캐시를 떠올리지 말고, 처음부터 핵심 아키텍처의 일부로 포함시켜야 합니다.


2. 핵심 개념: 캐시란 무엇인가?

🤔 캐시란 정확히 무엇일까?

간단히 말하면, 캐시는 데이터 복사본을 저장하는 공간입니다. 마치 책상 앞에 포스트잇을 붙여 자주 쓰는 전화번호를 적어두면, 매번 휴대폰 연락처를 뒤질 필요가 없는 것과 같습니다.

세 가지 핵심 포인트:

  1. 복사본: 캐시에 있는 데이터는 원본 데이터(데이터베이스)의 복사본이지, 주 데이터가 아닙니다
  2. 빠른 접근: 캐시는 보통 메모리에 있어서, 읽기 속도가 하드디스크보다 10만 배 빠릅니다
  3. 제한된 용량: 캐시 공간은 한정되어 있어서 가장 자주 사용하는 데이터만 저장할 수 있습니다

따라서, 캐시는 공간으로 시간을 사는 것입니다——약간의 메모리 공간을 희생해 극도로 빠른 데이터 접근 속도를 얻습니다.

구체적인 기술에 들어가기 전에, 몇 가지 핵심 개념을 먼저 명확히 해야 합니다. 이해를 돕기 위해 "학생의 책가방"에 비유하여 캐시 시스템을 설명하겠습니다.

2.1 "책가방 비유"로 이해하는 캐시의 핵심 개념

당신이 학생이고, 매일 다양한 자료를 찾아야 한다고 상상해 보세요. 이 과정은 캐시 시스템과 놀랍도록 유사합니다.

개념🎒 책가방 비유기술적 의미실제 예시
캐시 히트 (Cache Hit)찾으려는 공식이 포스트잇에 적혀 있음요청한 데이터가 캐시에서 발견됨사용자 정보 조회 시, Redis에 있어 바로 반환
캐시 미스 (Cache Miss)포스트잇에 없어서 책을 찾아야 함요청한 데이터가 캐시에 없음사용자 정보 조회 시, Redis에 없어 데이터베이스 조회 필요
히트율 (Hit Ratio)100번 공식을 찾을 때 95번은 포스트잇에 있음캐시 히트 비율히트율 95%는 95%의 요청이 데이터베이스 조회 불필요
TTL (Time To Live)포스트잇에 "3일 후 폐기"라고 적어둠캐시 만료 시간사용자 정보 캐시를 30분 후 자동 만료로 설정
축출 (Eviction)책가방이 꽉 차서 가장 오래된 포스트잇을 버림캐시가 가득 찼을 때 오래된 데이터 삭제Redis 메모리가 가득 차면 가장 적게 사용된 데이터 자동 삭제

2.2 캐시 히트 vs 캐시 미스

캐시 히트와 미스의 성능 차이는 엄청납니다. 구체적인 데이터를 살펴보겠습니다.

작업 유형응답 시간상대 속도적합한 시나리오
CPU L1 캐시~0.5 나노초매우 빠름 (기준)CPU 내부 연산
메모리 읽기~100 나노초200배 빠름로컬 캐시 (예: Caffeine)
Redis 조회~1 밀리초200만 배 느림분산 캐시
MySQL 조회~10 밀리초2000만 배 느림하드디스크 데이터베이스 조회

📊 이 표에서 무엇을 알 수 있나요?

성능 격차가 충격적입니다: 메모리 작업은 MySQL 조회보다 10만 배 빠릅니다! 이는 책상에서 책을 꺼내는 것(1초)과 도서관에서 책을 찾는 것(10만 초, 약 28시간)의 차이와 같습니다.

3단계 성능 계층:

  1. 로컬 캐시(메모리): 가장 빠르지만 용량이 작아 핫 데이터에 적합
  2. Redis 캐시: 중간 속도, 큰 용량, 분산 시나리오에 적합
  3. 데이터베이스: 가장 느리지만 용량이 무제한, 데이터의 최종 소스

실전 교훈: 시스템은 95% 이상의 요청이 캐시 레이어에서 반환되고, 5% 미만의 요청만 데이터베이스를 조회하도록 해야 합니다. 이렇게 하면 데이터베이스 부하가 작아지고, 시스템 전체 성능이 크게 향상됩니다.

🔍 "캐시 히트"와 "캐시 미스"의 실제 코드 보기

두 가지 상황을 코드로 비교해 보겠습니다.

javascript
// 시나리오: 사용자 정보 조회

// ===== 캐시 히트 (Cache Hit) =====
// 1. 먼저 Redis 캐시 조회
const userFromCache = await redis.get('user:123')
if (userFromCache) {
  // 히트! 바로 반환, 약 1밀리초 소요
  return JSON.parse(userFromCache)
}

// ===== 캐시 미스 (Cache Miss) =====
// 2. 캐시에 없으면 데이터베이스 조회
const userFromDB = await db.query('SELECT * FROM users WHERE id = 123')
// 미스! 데이터베이스 조회 필요, 약 10밀리초 소요, 10배 느림

// 3. 조회 후 캐시에 기록, 다음에 히트
await redis.set('user:123', JSON.stringify(userFromDB), 'EX', 1800)
return userFromDB

핵심 포인트:

  • 캐시 히트: 1밀리초 반환, 사용자 경험 최상
  • 캐시 미스: 10밀리초 반환, 사용자 경험 다소 저하
  • 캐시의 가치: 미스를 히트로 바꾸어 성능을 10배 향상

2.3 캐시의 생명주기

캐시 항목은 생성부터 소멸까지 완전한 생명주기를 거칩니다. 이 과정을 이해하는 것은 캐시 시스템 설계에 매우 중요합니다.

네 단계:

1단계: 쓰기 (Write)

  • 능동적 쓰기: 시스템 시작 시, 핫 데이터를 미리 캐시에 로드 (캐시 워밍업)
  • 지연 로딩: 첫 접근 시 데이터베이스에서 로드하여 캐시에 기록 (가장 일반적)

2단계: 히트/미스 (Hit/Miss)

  • 매 요청마다 먼저 캐시를 조회
  • 히트하면 바로 반환, 미스하면 데이터베이스 조회

3단계: 만료 (Expiration)

  • TTL (Time To Live): 캐시 생존 시간 설정 (예: 30분)
  • 만료 후 캐시 자동 무효화, 다음 접근 시 다시 로드 필요

4단계: 축출 (Eviction)

  • 캐시 공간이 제한되어 있어, 가득 차면 오래된 데이터 삭제 필요
  • 일반적인 축출 전략:
    • LRU (Least Recently Used): 가장 오랫동안 사용되지 않은 데이터 삭제 (가장 일반적)
    • LFU (Least Frequently Used): 접근 빈도가 가장 낮은 데이터 삭제
    • FIFO (First In First Out): 가장 먼저 기록된 데이터 삭제

👇 직접 해보기: 아래 데모는 캐시의 생명주기를 보여줍니다. "새 캐시 추가"를 클릭하여 캐시가 쓰기, 히트, 만료, 축출의 전 과정을 어떻게 거치는지 관찰해 보세요.

Cache Lifecycle Demo
Watch a cache entry move from creation to eviction
Cache storage (capacity: 0/6)
Hit rate: 0%Evictions: 0
Event timeline
New write
Cache hit
Expiring soon
Evicting

3. 캐시의 진화 과정: 단일 머신에서 분산까지

🤔 왜 다양한 유형의 캐시가 필요할까?

마치 공부할 때 여러 장소에 자료를 두는 것과 같습니다. 책상 위에는 가장 자주 쓰는 것(포스트잇), 책가방에는 자주 쓰는 것(노트북), 도서관에는 모든 자료(서고)를 둡니다.

캐시 시스템도 마찬가지입니다:

  • 로컬 캐시(책상): 가장 빠르고, 용량이 작으며, 슈퍼 핫 데이터 저장
  • 분산 캐시(공용 사물함): 비교적 빠르고, 용량이 크며, 자주 쓰는 데이터 저장
  • 데이터베이스(도서관): 가장 느리지만, 용량이 무제한이며, 모든 데이터 저장

왜 계층화해야 할까? 계층마다 성능과 비용이 다르기 때문에, 적절히 조합해야 최적의 효과를 얻을 수 있습니다.

개념을 많이 설명했으니, 이제 실제 사례를 살펴보겠습니다. 한 전자상거래 시스템이 어떻게 "캐시 없음"에서 "다중 계층 캐시 아키텍처"로 단계적으로 진화했는지 보여드립니다. 이 사례를 통해 캐시 설계의 중요성을 더 직관적으로 이해할 수 있을 것입니다.

3.1 1단계: 캐시 없는 시대——데이터베이스 맨몸으로 달리기

배경: 초기 시스템은 사용자가 적어(수백 명) 모든 요청이 직접 데이터베이스를 조회했으며, 캐시 레이어가 전혀 없었습니다.

기술 스택:

  • 데이터베이스: MySQL
  • 캐시 없음: Redis도, 로컬 캐시도 없음

시스템 아키텍처:

사용자 요청 → 애플리케이션 서버 → MySQL 데이터베이스

이 단계의 특징:

  • 장점: 아키텍처가 단순하고 개발이 빠름
  • 단점: 데이터베이스 부하가 크고, 성능이 나쁘며, 사용자가 천 명만 되어도 붕괴
당시 코드와 발생한 문제 보기

코드 예시 (매번 데이터베이스 조회):

javascript
// 상품 상세 정보 조회——매번 데이터베이스 조회
async function getProduct(productId) {
  // 직접 데이터베이스 조회, 캐시 없음
  const product = await db.query(
    'SELECT * FROM products WHERE id = ?',
    [productId]
  )
  return product
}

발생한 문제:

  1. 데이터베이스 CPU 급증: 매 요청마다 데이터베이스 조회, CPU 사용률 80%+
  2. 응답 지연: 복잡한 쿼리는 50-100밀리초 소요, 사용자 경험 저하
  3. 동시성 능력 부족: 데이터베이스 QPS(초당 쿼리 수) 상한이 2000에 불과, 그 이상이면 붕괴
  4. 핫 상품 문제: 인기 상품 상세 페이지가 빈번히 조회되어 데이터베이스가 병목 현상

당시의 임시 해결책:

  • 더 비싼 서버 구매 (CPU, 메모리 추가)——비용이 높고 효과는 제한적
  • 데이터베이스 읽기/쓰기 분리——읽기 부하를 완화할 수 있지만, 쓰기 부하는 여전히 존재
  • SQL 최적화——20-30% 향상 가능하지만 근본 문제를 해결하지 못함

이런 "맨몸" 모드는 사용자 수 < 1000일 때는 버틸 수 있었지만, 사용자가 1만, 10만으로 증가하면서 데이터베이스가 빈번히 붕괴되기 시작했고, 팀은 시급히 캐시 도입이 필요했습니다.

3.2 2단계: Redis 캐시 도입——성능 10배 향상

배경: 사용자가 1만 명으로 증가하고 데이터베이스가 버티지 못하게 되어, 팀은 Redis를 캐시 레이어로 도입하기로 결정했습니다.

기술 스택:

  • 데이터베이스: MySQL
  • 캐시: Redis (단일 머신 버전)

시스템 아키텍처:

사용자 요청 → 애플리케이션 서버 → Redis 캐시 (미스일 때만 조회) → MySQL 데이터베이스

이 단계의 특징:

  • 장점: 성능 10배 향상, 데이터베이스 부하 90% 감소
  • 단점: Redis 단일 장애 지점, 캐시와 데이터베이스 불일치 가능성
Redis 캐시 구현 코드 보기

코드 예시 (Redis 캐시 추가):

javascript
// 상품 상세 정보 조회——먼저 Redis 조회, 없으면 데이터베이스 조회
async function getProduct(productId) {
  // 1. 먼저 Redis 캐시 조회
  const cacheKey = `product:${productId}`
  const cached = await redis.get(cacheKey)

  if (cached) {
    // 캐시 히트! 바로 반환, 약 1밀리초
    return JSON.parse(cached)
  }

  // 2. 캐시 미스, 데이터베이스 조회
  const product = await db.query(
    'SELECT * FROM products WHERE id = ?',
    [productId]
  )

  // 3. 조회 후 Redis에 기록, 30분 만료 설정
  await redis.setex(
    cacheKey,
    1800,  // 30분 = 1800초
    JSON.stringify(product)
  )

  return product
}

성능 향상 비교:

시나리오캐시 없음Redis 캐시 있음향상 배수
일반 상품 조회50ms5ms (캐시 히트 시)10배
인기 상품 조회80ms1ms (히트율 95%)80배
데이터베이스 QPS2000 (최대 부하)200 (캐시가 90% 차단)데이터베이스 부하 10배 감소
시스템 최대 동시성2000 사용자20000 사용자10배

가져온 개선:

  1. 응답 속도: 캐시 히트 시, 응답 시간이 50ms에서 1-5ms로 감소
  2. 동시성 능력: 시스템이 수용할 수 있는 사용자 수가 2000에서 20000으로 증가
  3. 데이터베이스 부하: 90% 요청이 Redis에 의해 차단, 데이터베이스 CPU가 80%에서 20%로 감소
  4. 사용자 경험: 페이지 로딩 속도가 현저히 향상되어 사용자 불만 감소

새로운 도전 과제:

  1. 캐시 일관성 문제: 상품 가격이 변경되고 데이터베이스가 업데이트되었지만, 캐시는 여전히 이전 값
  2. 캐시 침투: 누군가 악의적으로 존재하지 않는 상품 ID(예: id=-1)를 조회하여 매번 데이터베이스까지 침투
  3. 캐시 눈사태: 시스템 재시작 후, 모든 캐시가 동시에 만료되어 순간적으로 대량의 요청이 데이터베이스로 유입
  4. Redis 단일 장애 지점: Redis가 다운되면, 모든 요청이 직접 데이터베이스로 가서 시스템이 붕괴될 수 있음

해결책:

  • 캐시 일관성: 데이터베이스 업데이트 시, 동기적으로 캐시 삭제
  • 캐시 침투: 존재하지 않는 데이터도 Redis에 캐싱 (value는 null, TTL은 5분 등 짧게 설정)
  • 캐시 눈사태: 캐시 만료 시간에 랜덤 값을 추가하여 동시 만료 방지

Redis 도입 후 시스템 성능이 크게 향상되었지만, 새로운 문제도 함께 나타났습니다. 팀은 이러한 캐시 관련 문제를 해결하는 방법을 연구하기 시작했습니다.

3.3 3단계: 다중 계층 캐시 아키텍처——성능 5배 추가 향상

배경: 사용자가 10만 명으로 증가하면서, Redis 캐시조차도 병목 현상이 발생하기 시작했습니다(단일 머신 Redis QPS 상한 약 10만). 팀은 다중 계층 캐시 도입을 결정했습니다.

기술 스택:

  • L1 캐시: 애플리케이션 로컬 캐시 (Caffeine)
  • L2 캐시: Redis 클러스터
  • 데이터베이스: MySQL 마스터-슬레이브 클러스터

시스템 아키텍처:

사용자 요청 → CDN 캐시 (정적 리소스) → 애플리케이션 서버

                          L1: 로컬 캐시 (Caffeine) → 미스 → L2: Redis → 미스 → MySQL

이 단계의 특징:

  • 장점: 극한의 성능 (로컬 캐시는 0.1밀리초만 소요), 고가용성 (Redis 다운 시에도 핫 데이터 영향 없음)
  • 단점: 아키텍처 복잡, 다중 계층 캐시의 일관성 보장이 어려움
다중 계층 캐시 구현 코드 보기

코드 예시 (로컬 캐시 + Redis 2계층 캐시):

javascript
// Caffeine 로컬 캐시 사용
const caffeine = require('caffeine')
const localCache = new caffeine.Cache({
  max: 1000,              // 최대 1000개 캐싱
  ttl: 30,                // 30초 만료
})

// 상품 상세 정보 조회——2계층 캐시
async function getProduct(productId) {
  const cacheKey = `product:${productId}`

  // L1: 먼저 로컬 캐시 조회 (가장 빠름, 약 0.1밀리초)
  const localCached = localCache.get(cacheKey)
  if (localCached) {
    console.log('L1 히트')
    return localCached
  }

  // L2: 로컬 캐시 미스, Redis 조회 (비교적 빠름, 약 1밀리초)
  const redisCached = await redis.get(cacheKey)
  if (redisCached) {
    console.log('L2 히트, L1에 다시 채우기')
    const product = JSON.parse(redisCached)
    // 로컬 캐시에 다시 채우기
    localCache.set(cacheKey, product)
    return product
  }

  // L3: Redis도 미스, 데이터베이스 조회 (가장 느림, 약 10밀리초)
  console.log('L3 히트, L2와 L1에 다시 채우기')
  const product = await db.query(
    'SELECT * FROM products WHERE id = ?',
    [productId]
  )

  // Redis에 다시 채우기 (30분 만료)
  await redis.setex(cacheKey, 1800, JSON.stringify(product))
  // 로컬 캐시에 다시 채우기
  localCache.set(cacheKey, product)

  return product
}

다중 계층 캐시 성능 비교:

캐시 계층응답 시간히트율저장에 적합한 데이터
L1: 로컬 캐시~0.1밀리초70% (슈퍼 핫)인기 상품, 시스템 설정, 사용자 세션
L2: Redis 캐시~1밀리초25% (일반 핫)대부분의 상품 데이터, 댓글 집계
L3: 데이터베이스~10밀리초5% (콜드 데이터)모든 상품의 전체 데이터

전체 성능 향상:

  • 평균 응답 시간: 5ms (2단계) → 1ms (3단계), 5배 추가 향상
  • 시스템 최대 동시성: 2만 사용자 (2단계) → 10만 사용자 (3단계), 5배 향상
  • 데이터베이스 QPS: 200 (2단계) → 50 (3단계), 4배 추가 감소

이 단계에서 해결한 새로운 문제:

  1. 로컬 캐시 일관성: 여러 애플리케이션 인스턴스의 로컬 캐시가 불일치할 수 있음 (A 인스턴스는 이전 가격, B 인스턴스는 새 가격을 캐싱)
    • 해결: 로컬 캐시 TTL을 짧게 설정(30초)하여 불일치 시간 창을 줄임
  2. 캐시 워밍업: 시스템 재시작 후, 로컬 캐시가 비어 있어 대량의 요청이 Redis로 침투
    • 해결: 시스템 시작 시, 핫 데이터를 능동적으로 로컬 캐시에 로드

다중 계층 캐시 아키텍처는 대형 인터넷 기업(예: 타오바오, 징동)에서 널리 사용되며, 백만 QPS 수준의 접근을 지원할 수 있습니다.

3.4 캐시 아키텍처 진화 전경도

단계아키텍처응답 시간최대 동시성핵심 변화
1단계: 캐시 없음애플리케이션 → 데이터베이스50ms2000 사용자데이터베이스 맨몸, 성능 저조
2단계: 단일 계층 캐시애플리케이션 → Redis → 데이터베이스5ms20000 사용자Redis 도입, 성능 10배 향상
3단계: 다중 계층 캐시애플리케이션 → 로컬 캐시 → Redis → 데이터베이스1ms100000 사용자로컬 캐시 + Redis, 성능 5배 추가 향상

📊 이 표에서 무엇을 알 수 있나요?

1단계 → 2단계: 질적 도약. Redis 도입 후 성능 10배 향상, 데이터베이스 부하 90% 감소. 이는 "사용 가능"에서 "충분히 사용 가능"으로 가는 핵심 단계입니다.

2단계 → 3단계: 극한의 최적화. 로컬 캐시 도입 후 성능 5배 추가 향상. 이는 "충분히 사용 가능"에서 "극한"으로의 진화로, 초대형 트래픽 시나리오에 적합합니다.

실전 조언:

  • 사용자 수 < 1만: 1단계(캐시 없음)로도 충분하지만, Redis 도입(2단계)을 권장
  • 사용자 수 1-10만: 2단계(Redis 캐시)가 최적의 선택
  • 사용자 수 > 10만: 3단계(다중 계층 캐시)를 고려하되, 일관성 복잡성에 주의

정리하면: 캐시 아키텍처 진화는 단순히 "더 많은 캐시 레이어 추가"가 아니라, 트래픽 규모에 따라 적절한 아키텍처를 선택하는 것입니다——과도한 설계는 복잡성을 증가시키고, 설계 부족은 성능 병목 현상을 초래합니다.


4. 캐시의 3대 고전적 문제: 침투, 브레이크다운, 눈사태

실전에서 캐시는 세 가지 고전적인 문제를 야기합니다. 이를 이해하지 못하면, 시스템이 어느 순간 갑자기 붕괴될 수 있습니다. 생활 속 비유로 이 문제들을 이해해 봅시다.

4.1 캐시 침투: 존재하지 않는 데이터 조회

문제 정의: 존재하지 않는 데이터(예: id=-1)를 조회할 때, 캐시에도 없고(저장된 적이 없으므로), 데이터베이스에도 없어서 매 요청마다 직접 데이터베이스까지 침투하게 됩니다.

🤔 "책 찾기"로 비유하는 캐시 침투

도서관에서 책을 찾는데, 사서에게 "《존재하지 않는 책》이 있나요?"라고 묻는다고 상상해 보세요.

정상 흐름:

  • 사서가 목록 확인: "그 책은 없습니다"
  • 당신은 떠남

캐시 침투 시나리오:

  • 1번째 방문, 사서가 데이터베이스 확인: "없음", 알려줌
  • 2번째 방문, 사서가 또 데이터베이스 확인: "없음"
  • 100번째 방문, 사서가 여전히 데이터베이스 확인: "없음"

문제: 사서(데이터베이스)가 지쳐버립니다. 답이 항상 "없음"인데도 매번 데이터베이스를 확인해야 합니다.

해결: 사서가 "《존재하지 않는 책》은 존재하지 않는다"라고 기억해두고, 다음에 물어보면 바로 "없음"이라고 말하며 데이터베이스를 확인할 필요가 없게 합니다. 이것이 바로 빈 객체 캐싱입니다.

실제 시나리오:

  • 악의적인 공격자가 존재하지 않는 ID를 대량으로 구성하여 조회 (예: id=-1, id=999999999)
  • 크롤러가 존재하지 않는 리소스 경로를 순회 (예: /api/products/invalid-id)
  • 비즈니스 로직 오류로 유효하지 않은 데이터 조회

해결책 1: 빈 객체 캐싱

javascript
async function getProduct(productId) {
  const cacheKey = `product:${productId}`

  // 1. 먼저 캐시 조회
  const cached = await redis.get(cacheKey)
  if (cached !== null) {
    // 주의: cached가 문자열 "null"일 수 있음
    if (cached === 'null') {
      // 캐시된 것이 "빈 객체"이므로, 데이터베이스에 이 데이터가 없음
      return null
    }
    return JSON.parse(cached)
  }

  // 2. 데이터베이스 조회
  const product = await db.query(
    'SELECT * FROM products WHERE id = ?',
    [productId]
  )

  // 3. 데이터베이스에 없어도 "null"을 캐싱, TTL은 짧게 설정 (예: 5분)
  if (!product) {
    await redis.setex(cacheKey, 300, 'null')
    return null
  }

  // 4. 데이터 조회 성공, 정상 캐싱
  await redis.setex(cacheKey, 1800, JSON.stringify(product))
  return product
}

해결책 2: 블룸 필터 (Bloom Filter)

블룸 필터는 "데이터가 존재하는지 빠르게 판단"하는 도구로, 마치 "슈퍼 인덱스"와 같습니다.

📖 블룸 필터란 무엇인가?

"마법의 블랙박스"가 있다고 상상해 보세요:

  • "ID 123 상품이 존재하나요?"라고 물으면
  • "확실히 존재하지 않음"이라고 하면 → 정말 존재하지 않으므로 데이터베이스 조회 불필요
  • "존재할 가능성 있음"이라고 하면 → 데이터베이스에 확인하러 감

특징:

  • 절대 누락 없음: 존재하지 않는다고 하면, 정말 존재하지 않음
  • 오판 가능성: 존재할 수 있다고 해도, 실제로는 존재하지 않을 수 있음 (확률이 낮고 조정 가능)

가치: 블룸 필터는 캐시 조회 전에 99%의 "존재하지 않음" 요청을 차단하여 데이터베이스를 보호할 수 있습니다.

javascript
// 블룸 필터 사용
const { BloomFilter } = require('bloom-filters')

// 블룸 필터 초기화 (최대 100만 개 상품 ID 가정)
const bloomFilter = new BloomFilter(1000000, 0.01)  // 오판율 1%

// 시스템 시작 시, 모든 상품 ID를 블룸 필터에 추가
async function initBloomFilter() {
  const allIds = await db.query('SELECT id FROM products')
  allIds.forEach(row => {
    bloomFilter.add(row.id)
  })
}

// 상품 조회 전, 블룸 필터로 먼저 판단
async function getProduct(productId) {
  // 1. 먼저 블룸 필터로 판단
  if (!bloomFilter.has(productId)) {
    // 확실히 존재하지 않음, 바로 null 반환, 데이터베이스 조회 불필요
    console.log('블룸 필터 차단: 상품이 존재하지 않음')
    return null
  }

  // 2. 블룸 필터가 "존재할 가능성 있음"이라고 함, 캐시 조회
  const cached = await redis.get(`product:${productId}`)
  if (cached) {
    return JSON.parse(cached)
  }

  // 3. 캐시 미스, 데이터베이스 조회
  const product = await db.query(
    'SELECT * FROM products WHERE id = ?',
    [productId]
  )

  if (!product) {
    // 블룸 필터 오판 (확률 매우 낮음), 실제로 존재하지 않음
    await redis.setex(`product:${productId}`, 300, 'null')
    return null
  }

  // 4. 데이터 조회 성공, 캐시에 기록
  await redis.setex(`product:${productId}`, 1800, JSON.stringify(product))
  return product
}

4.2 캐시 브레이크다운: 핫 데이터 만료

문제 정의: 어떤 핫 데이터(예: 인기 상품, 인기 검색 뉴스)가 캐시에서 만료(TTL 도달)될 때, 대량의 동시 요청이 동시에 도착하여 모두 데이터베이스를 조회하게 되어 데이터베이스 부하가 급증합니다.

🤔 "책 구하기"로 비유하는 캐시 브레이크다운

도서관에 《해리포터》가 있어서 매우 인기가 많아 100명이 모두 빌리고 싶어 한다고 상상해 보세요.

정상 상황:

  • 도서관이 《해리포터》를 "대출대"(캐시)에 둠
  • 모두가 대출대에서 바로 가져가고, 서가에 갈 필요 없음

캐시 브레이크다운 시나리오:

  • 대출대의 《해리포터》가 만료됨 (서가로 반환됨)
  • 100명이 동시에 빌리러 왔는데, 대출대에 없음
  • 100명이 모두 서가로 달려감 (데이터베이스)
  • 서가 관리자(데이터베이스)가 붐벼서 터짐

문제: "존재하지 않는 책"이 아니라, "매우 인기 있는 책"이 갑자기 캐시에서 사라져서, 순간적으로 대량의 요청이 데이터베이스로 유입됩니다.

실제 시나리오:

  • 웨이보 인기 검색어가 만료되는 순간, 수만 명이 동시 접근
  • 연예인 가십 뉴스 캐시가 무효화되어 팬들이 미친 듯이 접근
  • 타임 세일 시작 시 재고 데이터 만료

해결책 1: 뮤텍스 락 (Mutex Lock)

javascript
async function getProduct(productId) {
  const cacheKey = `product:${productId}`

  // 1. 먼저 캐시 조회
  const cached = await redis.get(cacheKey)
  if (cached) {
    return JSON.parse(cached)
  }

  // 2. 캐시 미스, 분산 락 획득
  const lockKey = `lock:${productId}`
  const lock = await redis.set(lockKey, '1', 'NX', 'EX', 10)  // 10초 락

  if (lock === 'OK') {
    // 3. 락 획득 성공, 데이터베이스 조회
    console.log('락 획득 성공, 데이터베이스 조회')
    const product = await db.query(
      'SELECT * FROM products WHERE id = ?',
      [productId]
    )

    // 4. 캐시에 기록
    await redis.setex(cacheKey, 1800, JSON.stringify(product))

    // 5. 락 해제
    await redis.del(lockKey)
    return product
  } else {
    // 6. 락 획득 실패, 50ms 대기 후 재시도
    console.log('락 획득 실패, 대기 후 재시도')
    await new Promise(resolve => setTimeout(resolve, 50))
    return getProduct(productId)  // 재귀 재시도
  }
}

해결책 2: 논리적 만료 (Logical Expiration)

javascript
async function getProduct(productId) {
  const cacheKey = `product:${productId}`

  // 1. 캐시 조회
  const cached = await redis.get(cacheKey)
  if (cached) {
    const data = JSON.parse(cached)

    // 2. 논리적 만료 시간 확인
    if (Date.now() < data.expireTime) {
      // 만료되지 않음, 바로 반환
      return data.product
    } else {
      // 3. 논리적 만료, 비동기로 캐시 재구축, 동시에 이전 데이터 반환
      console.log('논리적 만료, 비동기 캐시 재구축')
      rebuildCacheAsync(productId)  // 비동기 재구축
      return data.product  // 이전 데이터 반환
    }
  }

  // 4. 캐시가 존재하지 않음 (첫 로딩), 동기적으로 데이터베이스 조회
  const product = await db.query(
    'SELECT * FROM products WHERE id = ?',
    [productId]
  )

  // 5. 캐시에 기록 (논리적 만료 시간 포함)
  const cacheData = {
    product: product,
    expireTime: Date.now() + 30 * 60 * 1000  // 30분 후 논리적 만료
  }
  await redis.set(cacheKey, JSON.stringify(cacheData))

  return product
}

// 비동기 캐시 재구축
async function rebuildCacheAsync(productId) {
  const lockKey = `rebuild:${productId}`
  const lock = await redis.set(lockKey, '1', 'NX', 'EX', 10)

  if (lock === 'OK') {
    console.log('비동기 캐시 재구축 시작')
    const product = await db.query(
      'SELECT * FROM products WHERE id = ?',
      [productId]
    )

    const cacheData = {
      product: product,
      expireTime: Date.now() + 30 * 60 * 1000
    }
    await redis.set(`product:${productId}`, JSON.stringify(cacheData))
    await redis.del(lockKey)
    console.log('비동기 캐시 재구축 완료')
  }
}

4.3 캐시 눈사태: 대량 데이터 동시 만료

문제 정의: 대량의 캐시 데이터가 같은 시점에 집중적으로 만료되거나(또는 Redis 다운), 모든 요청이 동시에 데이터베이스로 침투하여 데이터베이스를 순간적으로 압도합니다.

🤔 "도서관 대량 반납"으로 비유하는 캐시 눈사태

도서관의 "대출대"(캐시)에 1000권의 책이 있다고 상상해 보세요.

정상 상황:

  • 이 책들의 반납 시간은 분산되어 있습니다: 오늘 반납하는 것도, 내일 반납하는 것도, 모레 반납하는 것도 있음
  • 매일 수십 권만 만료되어, 관리자(데이터베이스)가 쉽게 처리 가능

캐시 눈사태 시나리오:

  • 시스템 재시작 후, 관리자가 1000권 모두 "30일 후 만료"로 설정
  • 30일 후, 이 1000권이 동시에 만료
  • 1000명이 동시에 책을 빌리러 왔는데, 대출대에 없음
  • 1000명이 모두 서가로 달려감
  • 서가 관리자(데이터베이스)가 순간적으로 붐벼서 터짐

문제: 한 권의 책 문제가 아니라, 대량의 데이터가 동시에 만료되어 데이터베이스에 순간적인 압력이 폭증합니다.

실제 시나리오:

  • 시스템 재시작 후, 모든 캐시가 0부터 재구축되며 동일한 TTL(예: 30분)로 설정
  • 스케줄 작업이 캐시를 일괄 갱신하며 동일한 만료 시간 설정
  • 캐시 서비스(Redis) 다운 또는 네트워크 파티션

해결책 1: 랜덤 TTL

javascript
async function getProduct(productId) {
  const cacheKey = `product:${productId}`

  const cached = await redis.get(cacheKey)
  if (cached) {
    return JSON.parse(cached)
  }

  const product = await db.query(
    'SELECT * FROM products WHERE id = ?',
    [productId]
  )

  // 핵심: 기본 TTL(30분)에 랜덤 값(±5분) 추가
  const baseTTL = 1800  // 30분
  const randomOffset = Math.floor(Math.random() * 600) - 300  // -5분 ~ +5분
  const finalTTL = baseTTL + randomOffset

  console.log(`캐시 TTL: ${finalTTL}초 (${Math.floor(finalTTL / 60)}분)`)
  await redis.setex(cacheKey, finalTTL, JSON.stringify(product))

  return product
}

해결책 2: 캐시 워밍업 (Cache Preheating)

javascript
// 시스템 시작 시, 핫 데이터를 능동적으로 캐시에 로드
async function cacheWarmup() {
  console.log('캐시 워밍업 시작...')

  // 1. 가장 인기 있는 1000개 상품 조회 (방문량 기준 정렬)
  const hotProducts = await db.query(`
    SELECT * FROM products
    ORDER BY view_count DESC
    LIMIT 1000
  `)

  // 2. Redis에 일괄 기록
  for (const product of hotProducts) {
    const cacheKey = `product:${product.id}`
    const ttl = 1800 + Math.floor(Math.random() * 600)  // 30분 ± 5분
    await redis.setex(cacheKey, ttl, JSON.stringify(product))
  }

  console.log(`캐시 워밍업 완료, ${hotProducts.length}개 인기 상품 로드됨`)
}

// 애플리케이션 시작 시 실행
cacheWarmup()

해결책 3: 서킷 브레이커 (Circuit Breaker)

javascript
// 서킷 브레이커로 데이터베이스 보호
const CircuitBreaker = require('opossum')

// 서킷 브레이커 설정
const dbQueryBreaker = new CircuitBreaker(
  async (productId) => {
    return await db.query('SELECT * FROM products WHERE id = ?', [productId])
  },
  {
    timeout: 3000,  // 3초 타임아웃
    errorThresholdPercentage: 50,  // 오류율 50% 초과 시 차단
    resetTimeout: 30000  // 30초 후 복구 시도
  }
)

// 차단 후의 디그레이드 처리
dbQueryBreaker.fallback(() => {
  console.log('데이터베이스 차단됨, 디그레이드 데이터 반환')
  return {
    id: productId,
    name: '서비스 혼잡, 잠시 후 다시 시도해 주세요',
    status: 'degraded'
  }
})

async function getProduct(productId) {
  const cacheKey = `product:${productId}`

  const cached = await redis.get(cacheKey)
  if (cached) {
    return JSON.parse(cached)
  }

  // 서킷 브레이커를 통해 데이터베이스 조회
  const product = await dbQueryBreaker.fire(productId)

  if (product.status === 'degraded') {
    return product  // 디그레이드 데이터 반환
  }

  await redis.setex(cacheKey, 1800, JSON.stringify(product))
  return product
}

👇 직접 해보기: 아래 데모는 캐시 침투, 브레이크다운, 눈사태 세 가지 문제의 시나리오와 해결책을 비교합니다.

Three Common Cache Problems
Scenarios and fixes for penetration, breakdown, and avalanche
What is cache penetration?
A request queries nonexistent data, such as malicious id=-1. The cache misses and the database also has no record, so every request hits the database.
Scenario simulation
🔥
Request id=-999
Cache miss
🗄️
Database query (not found)
Database pressure
0%
Solutions
1Bloom Filter
Add a filter before the cache to quickly decide that an id definitely does not exist.
Can prove absence, but may have false positives.
2Cache empty objects
When a record does not exist, cache a NULL value with a short TTL such as 5 minutes.
Problem comparison
ProblemCauseImpactMain fixes
Cache penetrationQuerying nonexistent dataHigher database pressureBloom filter, cache empty objects
Cache breakdownHot data expiresInstant database pressureMutex lock, logical expiration
Cache avalancheMany entries expire togetherDatabase overloadRandom TTL, cache warm-up

5. 캐시 일관성 전략: 캐시와 데이터베이스를 동기화하는 방법

캐시의 본질은 데이터의 복사본이며, 복사본과 원본 데이터(데이터베이스) 사이에는 필연적으로 불일치 시간 창이 존재합니다. 이 시간 창을 어떻게 제어할 것인가가 캐시 설계의 핵심 과제입니다.

5.1 왜 캐시와 데이터베이스가 불일치할까?

🤔 "포스트잇과 책"으로 비유하는 불일치

포스트잇에 "민수 전화번호: 123456"이라고 적어두었다고 상상해 보세요. 이는 연락처(데이터베이스)의 복사본입니다.

불일치 시나리오:

  • 연락처를 업데이트하여 민수 전화번호를 "7654321"로 변경
  • 하지만 포스트잇 업데이트를 잊음
  • 다음에 전화번호를 확인할 때 포스트잇을 보면 여전히 이전 번호 "123456"

문제: 포스트잇(캐시)과 연락처(데이터베이스)가 불일치합니다.

원인: 원본 데이터를 업데이트했지만 복사본을 동기화하지 않았습니다. 컴퓨터 시스템에서는 "데이터베이스 업데이트"와 "캐시 업데이트"가 두 개의 독립적인 작업이기 때문에, 그 사이에 시간 창이 있고 다른 작업에 의해 방해받을 수 있습니다.

실제 동시성 시나리오:

시간스레드 A (사용자 나이 업데이트)스레드 B (사용자 조회)데이터베이스캐시
T1데이터베이스 업데이트 시작-age=20age=20
T2데이터베이스 age=25로 업데이트캐시 조회, age=20 히트age=25age=20 ❌
T3캐시 삭제-age=25-
T4--age=25DB에서 age=25 로드 ✅

문제: T2 시점에 스레드 B가 캐시에서 이전 값 20을 읽었지만, 데이터베이스는 이미 25입니다. 이것이 바로 캐시 불일치입니다.

5.2 모범 사례: 먼저 데이터베이스 업데이트, 그다음 캐시 삭제

🤔 왜 캐시를 "업데이트"하지 않고 "삭제"할까?

"직접 캐시를 업데이트하지 않고 캐시를 삭제하는" 이유가 궁금할 수 있습니다.

캐시 업데이트의 문제점:

  • 동시 업데이트 시, A 스레드가 먼저 캐시를 업데이트하고, B 스레드가 나중에 데이터베이스를 업데이트하지만 캐시는 업데이트되지 않을 수 있음
  • 캐시 업데이트 비용이 매우 높을 수 있음 (예: 여러 테이블의 데이터를 집계해야 함)
  • 업데이트 후 데이터가 다시 삭제되면 노력이 허사

캐시 삭제의 장점:

  • 다음 조회 시 자동으로 데이터베이스에서 최신 데이터 로드 (지연 로딩)
  • 동시 업데이트로 인한 더티 데이터 방지
  • 간단하고 신뢰할 수 있으며, 업계 모범 사례

표준 흐름:

javascript
// 상품 정보 업데이트
async function updateProduct(productId, updateData) {
  // 1. 먼저 데이터베이스 업데이트
  await db.query(
    'UPDATE products SET name = ?, price = ? WHERE id = ?',
    [updateData.name, updateData.price, productId]
  )

  // 2. 그다음 캐시 삭제 (캐시 업데이트가 아님!)
  await redis.del(`product:${productId}`)

  // 3. 다음 조회 시, 캐시 미스가 발생하여 자동으로 데이터베이스에서 최신 데이터 로드
  console.log('업데이트 완료, 캐시 삭제됨')
}
"먼저 DB 업데이트, 그다음 캐시 삭제"가 최적의 방안인 이유 보기

세 가지 업데이트 전략 비교:

전략 1: 먼저 캐시 업데이트, 그다음 데이터베이스 업데이트 ❌ 비추천

javascript
// 문제: 데이터베이스 업데이트 실패 시, 캐시는 새 값, 데이터베이스는 이전 값으로 불일치
await redis.set('product:1', newProduct)  // 캐시 업데이트 성공
await db.query('UPDATE products SET ...')  // 데이터베이스 업데이트 실패!
// 결과: 캐시는 새 값, 데이터베이스는 이전 값, 영구적 불일치!

전략 2: 먼저 캐시 삭제, 그다음 데이터베이스 업데이트 ❌ 비추천

javascript
// 문제: 삭제와 업데이트 사이에 다른 스레드가 조회하면 이전 데이터를 캐시에 로드
await redis.del('product:1')  // 캐시 삭제
// 이때 스레드 B가 조회하여 캐시가 없음을 발견, 데이터베이스 조회 (여전히 이전 값), 캐시에 기록
await db.query('UPDATE products SET ...')  // 데이터베이스 업데이트
// 결과: 캐시는 이전 값, 데이터베이스는 새 값, 불일치!

전략 3: 먼저 데이터베이스 업데이트, 그다음 캐시 삭제 ✅ 추천

javascript
// 장점: 데이터베이스 업데이트 시 행 락이 걸려 다른 스레드는 대기해야 하므로 더티 데이터 방지
await db.query('UPDATE products SET ...')  // 데이터베이스 업데이트 (행 락 획득)
await redis.del('product:1')  // 캐시 삭제
// 캐시 삭제가 실패하더라도, 다음 조회 시 원본 소스로 돌아갈 뿐, 더티 데이터가 오래 지속되지 않음

전략 3이 최적인 이유는?

  1. 데이터베이스 락 보호: 업데이트 작업이 행 락을 획득하므로, 다른 읽기/쓰기 작업은 대기해야 함
  2. 삭제 실패 영향이 적음: 캐시 삭제가 실패하더라도, 다음 읽기 시 원본 소스로 돌아갈 뿐 더티 데이터가 발생하지 않음
  3. 간단하고 신뢰할 수 있음: 추가적인 복잡한 로직이 필요 없음

5.3 지연 이중 삭제: 극한 시나리오의 일관성 보장

시나리오: 높은 동시성 시나리오에서는 "먼저 DB 업데이트, 그다음 캐시 삭제"도 극히 낮은 확률로 불일치가 발생할 수 있습니다. 지연 이중 삭제는 두 번의 삭제를 통해 일관성을 최대한 보장합니다.

흐름:

1. 캐시 삭제
2. 데이터베이스 업데이트
3. 일정 시간 대기 (예: 500ms)
4. 캐시 다시 삭제
javascript
async function updateProduct(productId, updateData) {
  const cacheKey = `product:${productId}`

  // 1. 첫 번째 캐시 삭제
  await redis.del(cacheKey)

  // 2. 데이터베이스 업데이트
  await db.query(
    'UPDATE products SET name = ?, price = ? WHERE id = ?',
    [updateData.name, updateData.price, productId]
  )

  // 3. 500ms 대기 (다른 스레드의 조회가 완료되도록)
  await new Promise(resolve => setTimeout(resolve, 500))

  // 4. 두 번째 캐시 삭제 (다른 스레드가 로드했을 수 있는 이전 데이터 삭제)
  await redis.del(cacheKey)

  console.log('지연 이중 삭제 완료, 데이터 동기화됨')
}

세 가지 일관성 전략 비교:

전략일관성 수준성능 영향복잡도적용 시나리오
먼저 DB 업데이트, 그다음 캐시 삭제최종 일관성 (불일치 창 < 100ms)낮음낮음대부분의 시나리오, 기본 방안으로 권장
지연 이중 삭제강한 최종 일관성 (불일치 창 < 10ms)중간 (500ms 지연)중간일관성 요구가 높은 시나리오 (예: 금융, 재고)
먼저 캐시 삭제, 그다음 DB 업데이트약함 (불일치 창이 큼)낮음낮음❌ 비추천, 불일치 발생 가능성 높음

👇 직접 해보기: 아래 데모는 세 가지 일관성 전략의 효과를 비교합니다. "데이터 업데이트"를 클릭하여 캐시와 데이터베이스의 일관성 변화를 관찰해 보세요.

Update DB, then delete cache

Low complexity and a short inconsistency window; works for most products.

Delayed double delete

Deletes cache twice to reduce stale reads in high consistency scenarios.

Avoid delete-before-update

Deleting cache first can reload old database values under concurrency.


6. 실전: 완전한 캐시 시스템 구축하기

지금까지 많은 원리를 설명했으니, 실제 사례를 살펴보겠습니다. 전자상거래 상품 상세 페이지를 위한 완전한 캐시 시스템을 설계하는 방법입니다.

6.1 비즈니스 시나리오 분석

요구사항: 사용자가 상품 상세 페이지에 접근할 때, 상품 기본 정보, 가격, 재고, 평가 등의 데이터를 표시해야 합니다.

특징:

  • 읽기 많고 쓰기 적음: 100번 조회에 1번 업데이트 (읽기/쓰기 비율 100:1)
  • 핫스팟 집중: 20%의 상품이 80%의 트래픽을 기여
  • 데이터 복잡성: 상품 기본 정보 + 가격 + 재고 + 평가 집계
  • 일관성 요구사항: 가격, 재고는 강한 일관성, 기타는 최종 일관성

성능 지표:

  • P99 응답 시간 < 100ms (99% 요청이 100ms 이내 반환)
  • 데이터베이스 QPS 피크 < 5000
  • 캐시 히트율 > 95%

6.2 아키텍처 설계

다중 계층 캐시 아키텍처:

사용자 요청

CDN 캐시 (정적 리소스: 이미지, CSS, JS)
  ↓ 미스
Nginx 로컬 캐시 (상품 기본 정보 집계)
  ↓ 미스
애플리케이션 서버

  ├─ L1: 로컬 캐시 (Caffeine, 핫 상품)
  │   ↓ 미스
  ├─ L2: Redis 캐시 (모든 상품 데이터)
  │   ↓ 미스
  └─ L3: MySQL 데이터베이스 (전체 데이터)

6.3 핵심 코드 구현

완전한 다중 계층 캐시 구현 (간소화 버전):

javascript
const caffeine = require('caffeine')

// L1: 로컬 캐시 (30초 만료)
const localCache = new caffeine.Cache({
  max: 1000,
  ttl: 30,
})

// 상품 상세 정보 조회 (다중 계층 캐시)
async function getProduct(productId) {
  const cacheKey = `product:${productId}`

  // L1: 로컬 캐시 (약 0.1밀리초)
  const localCached = localCache.get(cacheKey)
  if (localCached) {
    console.log('L1 히트')
    return localCached
  }

  // L2: Redis 캐시 (약 1밀리초)
  const redisCached = await redis.get(cacheKey)
  if (redisCached) {
    console.log('L2 히트, L1에 다시 채우기')
    const product = JSON.parse(redisCached)
    localCache.set(cacheKey, product)
    return product
  }

  // L3: 데이터베이스 (약 10밀리초, 분산 락으로 브레이크다운 방지)
  const lockKey = `lock:${productId}`
  const lock = await redis.set(lockKey, '1', 'NX', 'EX', 10)

  if (lock === 'OK') {
    console.log('L3 히트, 데이터베이스 조회')
    const product = await db.query(
      'SELECT * FROM products WHERE id = ?',
      [productId]
    )

    if (product) {
      // Redis에 기록 (30분 + 랜덤 TTL)
      const ttl = 1800 + Math.floor(Math.random() * 600) - 300
      await redis.setex(cacheKey, ttl, JSON.stringify(product))
      // 로컬 캐시에 다시 채우기
      localCache.set(cacheKey, product)
    }

    await redis.del(lockKey)
    return product
  } else {
    // 락 획득 실패, 대기 후 재시도
    await new Promise(resolve => setTimeout(resolve, 50))
    return getProduct(productId)
  }
}

// 상품 정보 업데이트 (먼저 DB 업데이트, 그다음 캐시 삭제)
async function updateProduct(productId, updateData) {
  const cacheKey = `product:${productId}`

  // 1. 데이터베이스 업데이트
  await db.query(
    'UPDATE products SET name = ?, price = ? WHERE id = ?',
    [updateData.name, updateData.price, productId]
  )

  // 2. 로컬 캐시 삭제
  localCache.del(cacheKey)

  // 3. Redis 캐시 삭제
  await redis.del(cacheKey)

  console.log('업데이트 완료, 캐시 삭제됨')
}

👇 직접 해보기: 아래 데모는 다중 계층 캐시 시스템의 완전한 워크플로우를 보여줍니다. "상품 조회"를 클릭하여 요청이 각 캐시 계층에서 어떻게 흐르는지 관찰해 보세요.

E-commerce Cache Architecture Demo

Shows multi-level cache architecture in e-commerce systems, including product, inventory, and user caches.


7. 정리 및 학습 경로

7.1 핵심 지식 포인트 복습

지식 포인트한 문장 설명해결하는 문제실전 포인트
캐시 히트데이터가 캐시에서 발견됨성능 10-100배 향상히트율 목표 > 95%
캐시 침투존재하지 않는 데이터 조회, 매번 데이터베이스 조회악의적 조회로 데이터베이스 마비블룸 필터 + 빈 객체 캐싱
캐시 브레이크다운핫 데이터 만료, 대량 요청이 데이터베이스로 유입데이터베이스 순간 부하 폭증뮤텍스 락 + 논리적 만료
캐시 눈사태대량 데이터 동시 만료데이터베이스 압도랜덤 TTL + 캐시 워밍업
다중 계층 캐시로컬 캐시 + Redis + 데이터베이스성능 극한 최적화L1 로컬 캐시 히트율 70%, L2 Redis 히트율 25%
캐시 일관성캐시와 데이터베이스 동기화데이터 정확성먼저 DB 업데이트, 그다음 캐시 삭제
지연 이중 삭제업데이트 전후 각각 한 번씩 캐시 삭제극한 시나리오의 일관성500ms 대기 후 다시 삭제

7.2 학습 경로 제안

1단계: 원리 이해 (1-2일)

  • 캐시의 본질 파악 (데이터 복사본, 공간으로 시간을 사는 것)
  • 캐시 히트율, TTL, 축출 등 핵심 개념 이해
  • 다양한 저장 매체의 성능 차이 이해 (메모리 vs 하드디스크)

2단계: 기초 습득 (2-3일)

  • Redis로 캐시 사용법 익히기 (SET, GET, SETEX 명령)
  • 간단한 캐시 읽기/쓰기 로직 구현 (먼저 캐시 조회, 미스 시 데이터베이스 조회)
  • "업데이트 시 캐시를 업데이트하지 않고 삭제하는 이유" 이해

3단계: 고전적 문제 해결 (1주)

  • 캐시 침투 해결: 블룸 필터 또는 빈 객체 캐싱 구현
  • 캐시 브레이크다운 해결: 뮤텍스 락 또는 논리적 만료 구현
  • 캐시 눈사태 해결: 랜덤 TTL 및 캐시 워밍업 구현

4단계: 다중 계층 캐시 (1-2주)

  • 로컬 캐시 도입 (Caffeine/Guava)
  • 로컬 캐시 + Redis 2계층 아키텍처 설계
  • 다중 계층 캐시의 일관성 문제 처리

5단계: 프로덕션급 실전 (지속적)

  • 완전한 상품 상세 페이지 캐시 시스템 설계
  • 모니터링 구축 (캐시 히트율, 응답 시간)
  • 부하 테스트 검증 및 성능 튜닝

💡 마지막으로

캐시는 고동시성 시스템의 기초입니다. 타오바오의 상품 상세 페이지부터 웨이보의 인기 검색어, 위챗의 모멘트부터 틱톡의 비디오 스트림까지, 모든 고성능 시스템 뒤에는 정교하게 설계된 캐시 아키텍처가 있습니다.

캐시를 이해한다는 것은 단순히 기술 하나를 배우는 것이 아니라, 공간으로 시간을 사고, 복사본으로 주 데이터를 보호하는 아키텍처 사상을 이해하는 것입니다. 캐시를 진정으로 마스터하면, 시스템 성능이 "사용 가능"에서 "사용하기 좋음"으로, 그리고 최종적으로 "극한"에 도달할 것입니다.

이 글이 캐시 시스템에 대한 완전한 이해를 구축하는 데 도움이 되길 바랍니다. 실제 프로젝트에서 성능 문제가 발생했을 때, "캐시로 해결할 수 있을까?"라고 생각할 수 있게 되기를 바랍니다.