cache

Cache — 캐싱 깊은 이야기

목표: "왜 빠른가"를 넘어 "왜 어렵고, 무엇을 포기하는가" 까지 설명할 수 있게 만들기

관련: kafka (page cache·offset cache) / queue (버퍼링 관점 비교) / msa (분산 캐시) / eventdriven (CDC 기반 무효화)


§1. 캐싱이란 무엇인가

캐시(Cache)는 "느리고 비싼 원본(origin)"에 대한 접근을 줄이기 위해, "빠르고 가까운 곳"에 결과를 미리 복사해두는 것이다. 캐싱은 그 복사본을 만들고 활용하는 행위 전체.

핵심은 캐시가 원본의 대체물이 아니라 사본이라는 점.

토론 포인트 1: "Redis에만 있고 DB엔 없는 데이터"는 캐시인가? (답: 아니다. 그건 primary storage.)

도서관 비유 (직관)

왜 필요한가

대부분의 시스템에서 비용은 균등하지 않다. 한 번 만든 결과를 다시 만드는 비용이 너무 크기 때문에 캐싱이 필요해진다. 비싼 작업의 예:

캐싱이 효과적인 진짜 이유 = 접근 패턴이 편향돼 있기 때문. 파레토(20% 데이터가 80% 요청). 모든 데이터가 균등하게 요청되면 캐시는 의미가 없다.


§2. 동작 원리 — 지역성과 메모리 계층

캐싱이 통하는 근본 이유 = 두 가지 지역성(Locality).

컴퓨터는 본질적으로 속도-용량-가격이 반비례하는 계층 구조다.

계층 대략 지연시간 특징
CPU 레지스터 / L1 ~1ns 매우 빠름, 매우 작음
L2 / L3 수~수십 ns
메인 메모리 (RAM) ~100ns
로컬 SSD 수십~수백 μs
같은 DC 네트워크 (Redis 등) 0.5~수 ms
디스크 / 원격 / 외부 API 수~수백 ms 느리고 큼

숫자는 정확한 벤치마크가 아니라 자릿수(order of magnitude) 감각으로. 핵심은 계층마다 대략 10~1000배씩 차이.

캐싱은 결국 이 계층에서 "더 위(빠른) 계층에 자주 쓰는 데이터를 끌어올려두는 것". CPU 캐시·Redis·CDN은 추상 수준만 다를 뿐 같은 원리다.


§3. 핵심 용어

토론 포인트 2: 적중률 99%인 캐시가 항상 좋은 캐시인가? 적중률만 높고 hit당 절약 비용이 작으면 무의미. 반대로 적중률 60%여도 miss 1건의 비용(외부 API 1초)이 크면 충분히 가치 있다. → "적중률 × hit당 절약 비용" 으로 보자.


§4. 캐시는 어디에나 있다 — 계층별 예시

주니어가 "캐시 = Redis"라고 생각하기 쉬운데, 실제로는 요청 경로 곳곳에 깔려 있다.

① 클라이언트 / 프론트엔드

② 네트워크 / 인프라

③ 애플리케이션 / 백엔드

④ 저장소 내부

⑤ 하드웨어

토론 포인트 3: 로컬 캐시(Caffeine) vs 분산 캐시(Redis), 언제 무엇을?


§5. 읽기/쓰기 전략 (Caching Patterns)

가장 중요한 부분이자 토론거리가 많은 부분. "어디서 캐시를 채우고, 쓰기는 누가 책임지느냐" 의 차이.

5-1. Cache-Aside (Look-Aside) — 가장 흔함

앱이 캐시를 직접 관리한다.

읽기:
  1) 캐시 조회
  2) hit → 반환
  3) miss → DB 조회 → 캐시에 저장 → 반환

쓰기:
  1) DB 업데이트
  2) 캐시 무효화(삭제) 또는 갱신

5-2. Read-Through

앱은 캐시만 보고, 캐시 라이브러리가 miss 시 알아서 DB 로딩.

5-3. Write-Through

쓰기 시 캐시와 DB에 동시(동기적) 기록.

5-4. Write-Back (Write-Behind)

쓰기를 일단 캐시에만 하고 DB는 나중에 비동기로 모아서.

5-5. Write-Around

쓰기는 DB로만, 캐시는 안 건드림. 다음 읽기 때 비로소 캐싱.

토론 포인트 4: 우리 서비스의 "조회수/좋아요"는? 쓰기 폭주 + 약간의 손실 허용 OK → Write-Back / 배치 집계가 후보. 반대로 결제 잔액이라면? → 절대 Write-Back 금지. Write-Through/synchronous만.


§6. 스케쥴링 정책

캐시 공간은 유한 → 무엇을 버릴지 정해야 함.

정책 기준 특징
LRU (Least Recently Used) 가장 오래 안 쓴 것 가장 흔함, 시간 지역성 활용
LFU (Least Frequently Used) 가장 적게 쓴 것 인기 데이터 유지에 강함. 과거 인기에 갇히는 단점
FIFO 먼저 들어온 것 단순. 지역성 무시
TTL 기반 만료된 것 시간 기준. 정합성 도구로도 쓰임
Random 무작위 의외로 나쁘지 않고 매우 쌈
W-TinyLFU LFU+LRU 혼합 Caffeine 기본. 현대적이고 적중률 우수

Redis는 maxmemory-policyallkeys-lru, allkeys-lfu, volatile-ttl 등을 설정.

토론 포인트 5: LRU의 약점은? Cache pollution — 스캔성 대량 조회(예: 야간 배치) 한 번에 인기 데이터가 전부 밀려남. 그래서 W-TinyLFU 같은 현대적 정책이 등장.


§7. 캐싱이 어려운 진짜 이유 — 장애 패턴 ⭐

"There are only two hard things in Computer Science: cache invalidation and naming things." — Phil Karlton

캐싱의 난이도는 "넣기"가 아니라 "언제 어떻게 무효화/방어하느냐".

7-1. 캐시 관통

존재하지 않는 데이터를 계속 조회 → 캐시 항상 miss → 매번 DB로 직행.

7-2. 캐시

대량의 키가 동시에 만료되거나 캐시 서버가 통째로 다운 → 트래픽이 한꺼번에 DB로 → DB 과부하/장애.

7-3. 캐시 스탬피드 / Thundering Herd (Hot Key)

인기 단일 키가 만료되는 순간 → 수많은 요청이 동시 miss → 전부 같은 DB 쿼리를 동시에 던짐.

7-4. 정합성 (Consistency) — 가장 근본적인 문제

캐시와 DB는 별개 저장소라 둘 사이에 반드시 시차가 생긴다. "DB 업데이트 → 캐시 삭제" 순서에서도 동시성 때문에 stale이 다시 캐싱될 수 있다.

대표적 함정 (race condition):

A: DB read (old=1)              ← A가 먼저 읽음
B: DB write (new=2)
B: cache delete
A: cache write(old=1)           ← A가 오래된 값을 다시 박아버림  ❌

토론 포인트 6: 캐시를 "업데이트"할까, "삭제"할까? 보통 삭제(invalidate)가 안전하다. 업데이트는 위 race에 더 취약하고, 어차피 안 읽힐 값을 계산하는 낭비도 있다. 삭제 후 다음 읽기에서 자연스럽게 채우는 게 단순·견고.


§8. 정합성을 다루는 현실적 패턴

완벽한 정합성은 비싸다. 실무는 "얼마나 stale을 허용할 수 있는가" 를 먼저 정한다.

  1. TTL을 짧게 — 가장 단순. "최대 N초 오래될 수 있음"을 명시적으로 허용
  2. 쓰기 시 캐시 삭제 (Cache-Aside invalidation) — 가장 흔한 기본형
  3. Write-Through — 정합성 중요하고 쓰기 빈도 낮을 때
  4. 지연 이중 삭제 (Delayed Double Delete) — 쓰기 시 삭제 + 짧은 지연 후 한 번 더 삭제 → race로 다시 박힌 stale 제거
  5. 변경 이벤트 기반 무효화 — DB 변경 → 메시지/CDC(eventdriven · Debezium) → 캐시 무효화. 다중 인스턴스 로컬 캐시 동기화에 유용
  6. 버전/세대(generation) 키 — 키에 버전을 붙여 갱신 시 키 자체를 바꿔버림 (product:v3:42)

핵심 사고방식: "이 데이터가 1초 / 10초 / 1분 오래되면 사용자/비즈니스에 무슨 일이 생기는가?" 를 먼저 질문한다.


§9. 언제 캐싱하나 — 장단점과 적합·부적합 상황

캐시는 무조건 좋은 게 아니다. 시니어와 주니어를 가르는 가장 흔한 지점이 이 결정 감각이다. "캐시 = 빠르고 좋은 것"으로 외운 채 모든 곳에 박으면 오히려 시스템이 무너진다.

9-1. 한 줄 트레이드오프

캐시는 "속도"를 얻기 위해 "데이터 실시간 정확성"과 "메모리 비용·복잡도"를 맞바꾸는 트레이드오프 기술이다.

이 한 줄을 외우면 나머지는 다 따라온다. "공짜 점심은 없다."

9-2. 장점 — 무엇을 얻나

장점 설명
극적 latency 감소 디스크/네트워크 안 거치고 RAM에서 → ms → μs
DB 부하 감소 캐시가 앞에서 막아주는 방패. 커넥션 풀 고갈 예방
컴퓨팅 비용 절감 무거운 집계/조인을 매번 계산하지 않고 한 번 계산 후 재사용
일시적 가용성 확보 DB가 잠깐 죽어도 캐시에 남은 값으로 일부 서비스 지속

9-3. 단점 — 무엇을 잃나

단점 설명 이 노트의 어디
데이터 불일치 (가장 치명적) 원본 변경 → 캐시 미반영 → stale 노출 §7-4, §8
메모리 비용·한계 RAM은 SSD보다 비싸고 작음. 관리 못 하면 OOM §6 eviction
시스템 복잡도 증가 무효화 로직·TTL·정합성 디버깅이 따라붙음 §7 전체
Cold Start 위험 캐시 비었을 때 트래픽이 DB로 몰림 (스탬피드) §7-3

9-4. 적합한 경우 — "Read Heavy, Write Light"

캐시 효율 극대화 = 자주 읽히지만 잘 안 변하는 데이터.

데이터 유형 예시
반복 조회 + 변경 적음 공지사항, FAQ, 카테고리, 배너 한 번 캐싱하면 hit율 압도적
연산 비용이 큰 결과 어제 베스트셀러, 시간 단위 통계 대시보드 무거운 집계를 한 번만
일시적 stale 허용 실시간 인기 검색어 (1~2분 오차 OK) "관대한 정합성"이 캐시와 궁합
세션·인증 정보 JWT, 로그인 세션, 블랙리스트 페이지마다 조회 + 짧은 TTL 자연스러움

9-5. 부적합한 경우 — 캐시가 독이 되는 곳

물처럼 시시각각 변하거나, 절대적 정확성이 필요한 데이터.

데이터 유형 예시 왜 안 되나
Write Heavy 배달 라이더 실시간 위치, 채팅 메시지 매번 무효화 → 캐시 오버헤드가 이득보다 큼
0원 오차 금지 (금융/결제) 계좌 잔액, 주식 호가, 결제 승인 상태 캐시 불일치 = 잘못된 송금 = 대형 사고
일회성·극단적 개인화 마이페이지 "지난달 영수증" 본인만 한 번 보고 끝 → hit 0, 메모리만 차지
보안 민감 정보 주민번호, 비밀번호, 카드 CVV RAM 덤프 등 메모리 공격면이 더 큼

9-6. 결정 매트릭스 (2×2)

판단 두 축: 읽기 빈도 × 쓰기/변경 빈도.

                    쓰기/변경 적음              쓰기/변경 많음
              ┌───────────────────────┬───────────────────────┐
   읽기       │  ⭐ 캐시 최적           │  ⚠️ 신중히             │
   많음       │                       │                       │
              │  공지·FAQ·카테고리       │  좋아요/조회수          │
              │  베스트셀러·랭킹         │  → Write-Back/배치     │
              │  세션·인증              │   (손실 허용 시만)      │
              ├───────────────────────┼───────────────────────┤
   읽기       │  ❓ 캐시 의미 적음      │  ❌ 캐시 금지           │
   적음       │                       │                       │
              │  하루 1회 조회 정적 정보 │  실시간 위치·채팅       │
              │  (TTL 짧게 or 안 함)   │  잔액·결제 (정확성 절대)│
              └───────────────────────┴───────────────────────┘

→ ⭐는 무조건 캐시, ❌는 절대 금지. ⚠️ / ❓ 영역이 진짜 토론거리.

9-7. 우리 도메인(티켓팅)에 매핑해보기

데이터 영역 권장 전략
공연/좌석 배치도 ⭐ Read↑ Write↓ 길게 캐시 + 변경 시 무효화
좌석 가용 상태 (실시간) ⚠️ Read↑ Write↑ 짧은 TTL + Redis 직접 + 락
결제 트랜잭션 절대 캐시 X — DB 직접
유저 프로필 Cache-Aside + 쓰기 시 무효화
사용자별 예매 내역 본인만 가끔 조회 → 굳이 캐시 안 해도 됨
실시간 인기 공연 랭킹 1분 TTL + 지터 + 스탬피드 방어

토론 진행 팁 — 위 표처럼 현재 프로젝트의 기능들을 직접 2×2에 매핑하는 연습을 시키면 "캐시 = 빠르고 좋음"이라는 외운 답이 깨지고 진짜 사고가 시작된다. 가장 토론이 뜨거워지는 칸은 ⚠️ (좋아요/조회수 류). 정답이 없고 비즈니스 요구사항에 따라 갈리기 때문.

9-8. 결정 체크리스트

캐시를 도입하기 전 스스로 던질 질문 5개:

  1. 읽기/쓰기 비율은? Read-heavy가 아니면 캐시의 효용이 작다.
  2. stale을 얼마나 허용하나? "초 단위도 안 됨" = 캐시 거의 불가, "분 단위 OK" = 자유.
  3. 캐시가 죽으면 시스템이 견디나? 못 견디면 그건 캐시가 아니라 primary store. (§1)
  4. hit율을 어떻게 측정/모니터링할 것인가? 측정 못 하면 캐시 효과를 모름.
  5. 장애 패턴(§7)을 어떻게 막을 것인가? 스탬피드·관통·눈사태 방어 미리.

→ 5개 중 2개 이상 "모르겠음"이면 아직 캐시 도입할 때가 아니다.


§10. 실사용 코드 (Spring Boot / Kotlin + Redis)

9-1. Spring @Cacheable (Cache-Aside)

@Service
class ProductService(
    private val productRepository: ProductRepository,
) {
    // 조회: 캐시 miss일 때만 DB 접근 후 'products' 캐시에 저장
    @Cacheable(cacheNames = ["products"], key = "#id")
    fun getProduct(id: Long): ProductDto =
        productRepository.findById(id)
            .orElseThrow { ProductNotFoundException(id) }
            .toDto()

    // 갱신: DB 업데이트 후 캐시 항목 삭제(무효화)
    @CacheEvict(cacheNames = ["products"], key = "#id")
    @Transactional
    fun updateProduct(id: Long, req: UpdateProductRequest) {
        val product = productRepository.findById(id).orElseThrow()
        product.update(req)
        // @Transactional 커밋 후 캐시가 비워지는 타이밍 주의
    }
}

⚠️ 함정 2개:

  1. @Cacheable프록시 기반 AOP라서 같은 클래스 내부 호출(self-invocation)에는 적용되지 않는다.
  2. @CacheEvict@Transactional 커밋 타이밍 차이로 stale이 생길 수 있어, 커밋 이후 무효화가 필요하면 TransactionSynchronization 또는 @TransactionalEventListener(AFTER_COMMIT) 고려.

9-2. Redis 직접 + 스탬피드 방어 (분산 락 + 지터)

fun getPopularRanking(): Ranking {
    redisTemplate.opsForValue().get(KEY)?.let { return it }   // ① 캐시 조회

    // ② miss → 락 획득한 1개 요청만 DB 계산 (SET NX)
    val locked = redisTemplate.opsForValue()
        .setIfAbsent(LOCK_KEY, "1", Duration.ofSeconds(5)) == true

    return if (locked) {
        try {
            val ranking = rankingRepository.calculateExpensiveRanking()  // 무거운 연산
            redisTemplate.opsForValue().set(KEY, ranking, ttlWithJitter())
            ranking
        } finally {
            redisTemplate.delete(LOCK_KEY)
        }
    } else {
        // ③ 락 못 잡은 요청은 잠깐 대기 후 캐시 재조회
        Thread.sleep(50)
        redisTemplate.opsForValue().get(KEY) ?: rankingRepository.calculateExpensiveRanking()
    }
}

// TTL 눈사태 방지: 기본 TTL ± 랜덤 지터
fun ttlWithJitter(): Duration =
    Duration.ofSeconds(300 + Random.nextLong(0, 60))

9-3. HTTP / CDN 캐싱

Cache-Control: public, max-age=3600, stale-while-revalidate=60
ETag: "abc123"

§11. 다른 노트와의 연결

이번 캐시 정리를 다른 스터디 노트와 묶어보면:


§12. 주니어와 토론할 질문 모음

설명 후 던지면 "암기"인지 "이해"인지 갈리는 질문들.

개념·정합성

  1. 캐시와 일반 저장소(DB)의 본질적 차이는? (사본 vs 원본, 유실 허용 여부)
  2. 적중률이 높으면 무조건 좋은 캐시인가? (§3)
  3. "DB 업데이트 후 캐시 업데이트" vs "DB 업데이트 후 캐시 삭제" — 무엇이 더 안전하고 왜? (§7-4)
  4. 로컬 캐시를 여러 인스턴스에서 쓰면 생기는 문제는? 어떻게 동기화할까? (CDC, pub/sub 무효화)

결정·트레이드오프 (§9)

  1. 좋아요/조회수 / 유저 프로필 / 결제 잔액 — 각각 어떤 캐싱 전략과 TTL이 맞을까? 왜?
  2. 당신이 담당한 기능을 §9-6 2×2 매트릭스에 매핑해보라. ⚠️/❓ 영역에 들어가는 게 있다면, 캐시 도입 여부를 어떻게 결정할까?
  3. §9-3 단점 4개 중 당신 서비스에 가장 큰 리스크는 무엇이라 생각하나? 어떻게 완화?
  4. "캐싱하면 빨라진다"가 항상 참인가? 캐싱이 오히려 손해인 경우는?
    • 낮은 적중률 (캐시 lookup 오버헤드만 추가)
    • 무거운 직렬화/역직렬화 비용
    • 정합성 버그로 인한 장애 비용 (디버깅 시간 + 사용자 신뢰)

장애·운영

  1. 캐시 서버(Redis)가 통째로 죽으면 우리 시스템은 어떻게 되는가? (캐시 의존도 점검 → fallback 전략)
  2. 캐시 스탬피드를 막는 방법을 2가지 이상 말해보라.
  3. CDN의 stale-while-revalidate는 어떤 장애 패턴을 자연스럽게 완화하는가? (스탬피드)
  4. Bloom Filter는 false positive는 있지만 false negative는 없다. 이게 캐시 관통 방어에 왜 충분한가?

한 줄 요약

캐싱은 "느린 원본의 사본을 가까운 곳에 둬서 빠르게 만드는 기술" 이지만, 그 대가로 정합성과 추가적인 장애 표면(failure surface)을 떠안는 트레이드오프다.

좋은 엔지니어는 "어떻게 캐싱할까" 보다 "무엇을 캐싱하지 않을까"* 와 *"얼마나 오래됨을 허용할까" 를 먼저 정한다.