스프링부트 실무 가이드 2편: 캐싱 전략과 Redis 활용

스프링부트 실무 가이드 2편: 캐싱 전략과 Redis 활용


시리즈 네비게이션

이전현재다음
1편: 동시성 제어2편: 캐싱 전략3편: 이벤트 드리븐

서론

캐시는 성능 향상의 강력한 도구지만, 잘못 사용하면 오히려 복잡성만 증가시킨다. 이번 편에서는 언제 캐시를 도입해야 하는지, 그리고 어떻게 올바르게 구현하는지 다룬다.

2편에서 다루는 내용:

  • 캐시 도입 시점 판단 기준
  • 데이터 특성별 캐싱 전략 선택
  • Cache-Aside 패턴과 올바른 구현 (DTO 캐싱)
  • 캐시 데이터 불일치 문제 해결
  • 캐시 문제들 (Stampede, Penetration, Avalanche)

목차


1. 캐시란 무엇인가?

자주 접근하는 데이터를 빠른 저장소에 보관하여 응답 시간을 단축하고 DB 부하를 줄이는 기술입니다.

1.1 응답 시간 비교

┌─────────────────────────────────────────────────────────────┐
│  저장소별 응답 시간                                           │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  DB 조회:     ~10ms   (네트워크 + 디스크 I/O)                │
│  Redis 조회:  ~1ms    (네트워크 + 메모리)                    │
│  로컬 캐시:   ~0.01ms (메모리만)                             │
│                                                             │
│  ※ 로컬 캐시는 Redis보다 100배 빠름                          │
│                                                             │
└─────────────────────────────────────────────────────────────┘

1.2 캐시 효과 계산

QPS 1000, DB 조회 10ms 가정:

캐시 없음:     1000 × 10ms = 10초/초의 DB 부하
캐시 90% 히트: 100 × 10ms = 1초/초의 DB 부하 (10배 감소!)

1.3 캐시 적합성 판단

적합한 데이터부적합한 데이터
자주 읽히는 데이터자주 변경되는 데이터
변경이 드문 데이터실시간 정확성 필수 데이터
계산 비용이 높은 데이터사용자별 민감 데이터
공유 가능한 데이터일회성 데이터
마켓플레이스 예시:

✅ 적합: 상품 목록, 카테고리, 인기 상품, 설정값
❌ 부적합: 재고 수량, 결제 상태, 실시간 가격

2. 캐시 도입 시점

핵심: 캐시는 “있으면 좋다”가 아니라 문제가 발생했을 때 도입합니다.

2.1 도입 신호 (이런 상황이면 검토)

1. DB CPU 사용률 지속 70% 이상
2. 동일 쿼리가 반복 실행됨 (슬로우 쿼리 로그 분석)
3. API 응답 시간 SLA 미달 (예: p95 > 500ms)
4. DB 커넥션 풀 고갈 현상
5. 트래픽 급증 예정 (이벤트, 프로모션)

2.2 SLA/SLO/SLI 용어 정리

용어의미예시
SLI (Indicator)실제 측정값p95 응답시간 = 320ms
SLO (Objective)내부 목표p95 < 500ms
SLA (Agreement)외부 약속 (위반 시 보상)p95 < 1000ms
SLA 예시:

[응답 시간]
- p50: 100ms 이하   (50%의 요청이 100ms 안에 응답)
- p95: 500ms 이하   (95%의 요청이 500ms 안에 응답)
- p99: 1000ms 이하  (99%의 요청이 1초 안에 응답)

[가용성]
- 99.9%  → 한 달에 약 43분 다운타임 허용
- 99.99% → 한 달에 약 4분 다운타임 허용

2.3 도입 판단 플로우

                        시작


                  ┌───────────────┐
                  │ 응답 시간이    │
                  │ 느린가?       │
                  └───────────────┘
                     │         │
                   YES        NO
                     │         │
                     ▼         ▼
              ┌──────────┐   캐시 불필요
              │ 원인이   │   (섣부른 최적화 금지)
              │ DB인가?  │
              └──────────┘
                 │    │
               YES   NO
                 │    │
                 ▼    ▼
          ┌──────────┐  다른 병목 해결
          │ 쿼리 최적화│  (네트워크, 외부 API)
          │ 가능한가?  │
          └──────────┘
             │    │
           YES   NO
             │    │
             ▼    ▼
       인덱스/쿼리  ┌──────────┐
       튜닝 먼저    │ 읽기:쓰기 │
                   │ > 10:1?  │
                   └──────────┘
                      │    │
                    YES   NO
                      │    │
                      ▼    ▼
                 캐시 도입  DB 스케일업 검토

2.4 도입 전 체크리스트

□ 현재 병목 지점을 측정했는가? (APM, 슬로우 쿼리 로그)
□ 캐시 없이 해결 가능한 방법을 검토했는가? (인덱스, 쿼리 최적화)
□ 캐시할 데이터의 읽기/쓰기 비율을 파악했는가?
□ 데이터 불일치 허용 범위를 정의했는가?
□ 캐시 장애 시 fallback 전략이 있는가?
□ 캐시 히트율 모니터링 계획이 있는가?

2.5 도입하지 말아야 할 상황

❌ "나중에 트래픽 많아지면 필요하니까" → 섣부른 최적화
❌ "다른 회사도 Redis 쓰니까" → 근거 없는 도입
❌ 쓰기가 읽기보다 많은 데이터 → 캐시 효과 미미
❌ 실시간 정확성이 필수인 데이터 → 재고, 결제 상태
❌ 사용자별로 다른 데이터 → 캐시 히트율 낮음

2.6 단계별 도입 전략

[1단계] 로컬 캐시로 시작 (Caffeine)
        - 인프라 추가 없이 즉시 적용
        - 단일 서버 또는 데이터 불일치 허용 시

[2단계] 분산 캐시 전환 (Redis)
        - 다중 서버 환경
        - 데이터 일관성 필요 시

[3단계] 다단계 캐시 구성 (Caffeine + Redis)
        - Hot 데이터는 로컬, 전체는 Redis
        - 최적의 성능 필요 시

3. 데이터 특성별 전략 선택

핵심: 단일 전략으로 모든 데이터를 처리하지 마세요. 특성에 따라 다르게!

3.1 특성별 권장 전략

데이터 특성예시권장 전략TTL
거의 안 변함카테고리, 약관, 설정Read-Through + Refresh-Ahead1시간~1일
가끔 변함상품 정보, 프로필Cache-Aside + 명시적 무효화5~30분
자주 변함재고, 가격캐시 안 함 또는 매우 짧은 TTL10~30초
쓰기가 많음조회수, 좋아요Write-BehindN/A (배치)
계산 비용 높음통계, 랭킹, 집계Cache-Aside + 긴 TTL5분~1시간

3.2 판단 기준

1. 읽기:쓰기 비율
   - 100:1 이상 → 적극 캐싱
   - 10:1 정도 → 선택적 캐싱
   - 1:1 이하 → 캐싱 효과 미미

2. 불일치 허용 범위
   - 허용 불가 (재고, 결제) → 캐시 안 함
   - 수 초 허용 → 짧은 TTL (10~30초)
   - 수 분 허용 → 일반 TTL + 무효화

3. 접근 패턴
   - Hot Data (인기 상품) → 로컬 캐시 + Redis (다단계)
   - Cold Data (오래된 상품) → Redis만 또는 캐시 안 함

4. 계산 비용
   - 단순 조회 → 캐시 효과 작음
   - 집계/정렬/조인 → 캐시 효과 큼

4. Cache-Aside 패턴 (Lazy Loading)

가장 널리 사용되는 패턴. 애플리케이션이 캐시와 DB를 직접 관리합니다.

4.1 동작 방식

[읽기 - Cache Hit]
Client → App → Cache (HIT) → 데이터 반환

[읽기 - Cache Miss]
Client → App → Cache (MISS) → DB 조회 → Cache 저장 → 데이터 반환

[쓰기]
Client → App → DB 저장 → Cache 무효화 (또는 갱신)

4.2 Entity 캐싱은 안티패턴!

// ❌ 잘못된 예시: Entity 직접 캐싱
@Cacheable(value = ["products"], key = "#id")
fun getProduct(id: Long): Product {  // Entity 반환
    return productRepository.findById(id).orElseThrow()
}

Entity 캐싱이 문제인 이유:

문제설명
Lazy Loading 오류캐시에서 꺼낸 Entity는 영속성 컨텍스트 밖 → LazyInitializationException
직렬화 문제Hibernate Proxy 객체 직렬화 실패 가능
불필요한 데이터 노출내부 필드, 연관 Entity까지 캐싱/노출
캐시 크기 증가Entity 전체 저장 → 메모리 낭비
변경 감지 오작동캐시된 Entity 수정 시 의도치 않은 DB 반영

4.3 올바른 구현 (DTO 사용)

// ✅ 올바른 예시: DTO 캐싱

// 1. 캐시용 DTO 정의
data class ProductCacheDto(
    val id: Long,
    val name: String,
    val price: BigDecimal,
    val status: ProductStatus,
    val stockQuantity: Int,
    val categoryId: Long,
    val categoryName: String
) {
    companion object {
        fun from(product: Product): ProductCacheDto {
            return ProductCacheDto(
                id = product.id!!,
                name = product.name,
                price = product.price,
                status = product.status,
                stockQuantity = product.stockQuantity,
                categoryId = product.category.id!!,
                categoryName = product.category.name
            )
        }
    }
}

// 2. 직접 구현
fun getProduct(id: Long): ProductCacheDto {
    val cacheKey = "product:$id"

    // 1. 캐시 조회
    redisTemplate.opsForValue().get(cacheKey)?.let { return it }

    // 2. Cache Miss → DB 조회 후 DTO 변환
    val product = productRepository.findById(id)
        .orElseThrow { BusinessException(ErrorCode.PRODUCT_NOT_FOUND) }

    val dto = ProductCacheDto.from(product)

    // 3. 캐시에 DTO 저장 (TTL 10분)
    redisTemplate.opsForValue().set(cacheKey, dto, Duration.ofMinutes(10))

    return dto
}

// 3. Spring @Cacheable 사용 (권장)
@Cacheable(value = ["products"], key = "#id")
fun getProductWithCache(id: Long): ProductCacheDto {
    val product = productRepository.findById(id)
        .orElseThrow { BusinessException(ErrorCode.PRODUCT_NOT_FOUND) }
    return ProductCacheDto.from(product)
}

// 4. 캐시 무효화
@CacheEvict(value = ["products"], key = "#id")
fun updateProduct(id: Long, request: UpdateProductRequest): ProductResponse {
    val product = productRepository.findById(id)
        .orElseThrow { BusinessException(ErrorCode.PRODUCT_NOT_FOUND) }
    product.update(request.name, request.price, request.description)
    return ProductResponse.from(productRepository.save(product))
}

4.4 DTO vs Entity 캐싱 비교

항목Entity 캐싱DTO 캐싱
Lazy Loading오류 발생문제 없음
직렬화Proxy 문제안전
캐시 크기큼 (전체 필드)작음 (필요한 것만)
API 응답 변환추가 작업 필요바로 사용 가능
연관 관계N+1 위험미리 평탄화

5. 캐시 데이터 불일치 문제

Cache-Aside는 데이터 불일치가 발생할 수 있습니다.

5.1 케이스 1: 쓰기 후 읽기 경쟁 (가장 흔함)

[요청 A: 상품 가격 수정]          [요청 B: 상품 조회]
         │                              │
         ├─ DB 업데이트 (1000 → 2000)   │
         │                              ├─ 캐시 조회 (HIT: 1000) ← 오래된 데이터!
         ├─ 캐시 삭제                   │
         │                              └─ 응답: 1000원
         └─ 완료

원인: DB 업데이트와 캐시 삭제 사이에 다른 요청이 캐시를 읽음

5.2 케이스 2: 캐시 갱신 경쟁 조건

두 개의 읽기 요청 이 거의 동시에 들어오고, 그 사이에 쓰기 요청 이 끼어드는 상황입니다.

[요청 A]                           [요청 B]
   │                                  │
   ├─ 캐시 조회 (MISS)                ├─ 캐시 조회 (MISS)
   ├─ DB 조회 (가격: 1000)            ├─ DB 조회 (가격: 1000)
   │                                  │
   │  ← 이 시점에 다른 요청이 가격을 2000으로 수정 + 캐시 삭제 →
   │                                  │
   │                                  ├─ 캐시 저장 (1000) ← 삭제된 캐시에 옛날 값 저장!
   ├─ 캐시 저장 (1000)                │

결과: DB는 2000인데 캐시는 1000 (TTL까지 불일치)

구체적인 타임라인:

상품 ID: 123, 현재 가격: 1000원

[09:00:00.000] 사용자 A: 상품 123 조회 요청
[09:00:00.001] 사용자 B: 상품 123 조회 요청
[09:00:00.002] A: 캐시 MISS
[09:00:00.003] B: 캐시 MISS
[09:00:00.010] A: DB 조회 시작
[09:00:00.011] B: DB 조회 시작
[09:00:00.050] A: DB 조회 완료 (가격: 1000원)
[09:00:00.051] B: DB 조회 완료 (가격: 1000원)

[09:00:00.060] ★ 관리자: 가격 2000원으로 수정 + 캐시 삭제

[09:00:00.070] B: 캐시에 1000원 저장  ← 삭제된 캐시에 옛날 값 저장!
[09:00:00.071] A: 캐시에 1000원 저장  ← 덮어쓰기

[09:00:00.100 ~ 09:10:00.070]
    → TTL 동안 모든 사용자가 1000원으로 보게 됨 (실제는 2000원)

5.3 해결 방법

방법설명적합한 상황
짧은 TTL불일치 시간 최소화 (30초~1분)대부분의 경우 (권장)
Write-Through삭제 대신 갱신 (@CachePut)일관성 중요
지연 삭제삭제 후 500ms 뒤 한 번 더 삭제경쟁 조건 대비 (케이스 2, 3)
분산 락캐시 갱신 시 락 획득강한 일관성 필요
버전 키product:1:v5 처럼 버전 포함복잡하지만 확실

지연 삭제 (Delayed Double Delete) 구현:

@Transactional
fun updateProduct(id: Long, request: UpdateRequest): ProductResponse {
    // 1. 캐시 먼저 삭제
    redisTemplate.delete("product:$id")

    // 2. DB 업데이트
    val product = productRepository.save(...)

    // 3. 500ms 후 한 번 더 삭제 (경쟁 조건 방어)
    CompletableFuture.delayedExecutor(500, TimeUnit.MILLISECONDS).execute {
        redisTemplate.delete("product:$id")
    }

    return ProductResponse.from(product)
}

왜 효과적인가?

케이스 2 상황에서:

[09:00:00.060] 관리자: 가격 수정 + 캐시 삭제 (1차)
[09:00:00.070] B: 캐시에 1000원 저장 ← 오래된 값 저장됨
[09:00:00.560] 관리자: 캐시 삭제 (2차, 지연 삭제) ← 오래된 값 제거!
[09:00:00.600] 다음 요청: 캐시 MISS → DB 조회 (2000원) → 정상!

실무 권장: 대부분 짧은 TTL 만으로 충분합니다. “TTL 동안 잠깐 옛날 데이터가 보여도 비즈니스에 문제없다”면 복잡한 해결책은 불필요합니다.


6. 기타 캐싱 패턴

6.1 Read-Through

캐시가 DB 조회를 대행합니다. 애플리케이션은 캐시만 바라봅니다.

@Bean
fun categoryCache(): LoadingCache<String, List<CategoryResponse>> {
    return Caffeine.newBuilder()
        .maximumSize(100)
        .expireAfterWrite(Duration.ofHours(1))
        .build { _ ->
            // Cache Miss 시 자동으로 호출됨
            categoryRepository.findAll()
                .sortedBy { it.displayOrder }
                .map { CategoryResponse.from(it) }
        }
}

6.2 Write-Through

쓰기 시 캐시와 DB에 동시에 저장합니다.

// Spring @CachePut 사용 - DB 저장 후 캐시도 함께 갱신
@CachePut(value = ["products"], key = "#result.id")
fun createProduct(request: CreateProductRequest): ProductResponse {
    val product = Product.create(request)
    val saved = productRepository.save(product)
    return ProductResponse.from(saved)
}

@CachePut과 트랜잭션 문제:

@CachePut트랜잭션 커밋 전에 캐시에 저장합니다:

@Transactional + @CachePut 실행 순서:

1. 트랜잭션 시작
2. 메서드 실행 (DB 저장)
3. 메서드 반환값으로 캐시 저장  ← 여기서 캐시 저장!
4. 트랜잭션 커밋

문제: 3번에서 캐시에 저장되지만, 4번에서 롤백되면?
     → DB에는 없고, 캐시에만 데이터가 존재하는 불일치 발생!

왜 @CacheEvict를 더 많이 쓰는가?

방식동작DB 롤백 시
@CacheEvict캐시 삭제 → 다음 조회 시 DB에서 캐싱✅ 안전
@CachePut캐시 즉시 갱신⚠️ 불일치 가능

6.3 Write-Behind (Write-Back)

쓰기를 캐시에만 하고, DB 저장은 비동기로 처리합니다.

@Service
class ProductViewService(
    private val redisTemplate: RedisTemplate<String, String>,
    private val productRepository: ProductRepository
) {
    // 조회 시 Redis에만 기록 (빠름)
    fun incrementViewCount(productId: Long) {
        redisTemplate.opsForValue().increment("viewCount:$productId")
    }

    // 1분마다 DB 동기화
    @Scheduled(fixedRate = 60_000)
    fun syncViewCountsToDB() {
        val keys = redisTemplate.keys("viewCount:*") ?: return

        keys.chunked(100).forEach { batch ->
            val updates = batch.mapNotNull { key ->
                val productId = key.substringAfter("viewCount:").toLongOrNull()
                val count = redisTemplate.opsForValue().getAndDelete(key)?.toLongOrNull() ?: 0
                productId?.let { it to count }
            }
            productRepository.bulkUpdateViewCounts(updates)
        }
    }
}

적합한 상황: 조회수, 좋아요 등 일시적 유실 허용 가능한 데이터

6.4 Refresh-Ahead

TTL 만료 전에 미리 캐시를 갱신합니다.

@Bean
fun popularProductsCache(): LoadingCache<String, List<ProductResponse>> {
    return Caffeine.newBuilder()
        .maximumSize(10)
        .expireAfterWrite(Duration.ofMinutes(10))
        .refreshAfterWrite(Duration.ofMinutes(8))  // 8분 후 백그라운드 갱신
        .build { _ ->
            productRepository.findByStatusOrderBySalesCountDesc(
                ProductStatus.ON_SALE,
                PageRequest.of(0, 10)
            ).map { ProductResponse.from(it) }
        }
}

7. 캐시 무효화 전략

7.1 TTL 기반

// 10분 후 자동 만료
redisTemplate.opsForValue().set("key", value, Duration.ofMinutes(10))

7.2 명시적 무효화

// 단일 키 삭제
@CacheEvict(value = ["products"], key = "#id")
fun updateProduct(id: Long, request: UpdateRequest)

// 전체 삭제
@CacheEvict(value = ["products"], allEntries = true)
fun bulkUpdateProducts()

// 여러 캐시 동시 무효화
@Caching(evict = [
    CacheEvict(value = ["products"], key = "#id"),
    CacheEvict(value = ["popularProducts"], allEntries = true)
])
fun deleteProduct(id: Long)

allEntries=true vs key 지정:

방식동작적합한 상황
key = "#id"특정 키 1개 삭제개별 상품 캐시
allEntries = true해당 캐시의 모든 키 삭제목록/집계 캐시

8. 캐시 문제와 해결책

8.1 Cache Stampede (Thundering Herd)

문제: 캐시 만료 시 다수의 요청이 동시에 DB 조회

TTL 만료 시점

     ├── Request 1 → Cache Miss → DB 조회
     ├── Request 2 → Cache Miss → DB 조회  ← DB 폭주!
     ├── Request 3 → Cache Miss → DB 조회
     └── ...

해결책: 분산 락

fun getProductWithLock(id: Long): ProductCacheDto {
    val cacheKey = "product:$id"
    val lockKey = "lock:product:$id"

    // 캐시 확인
    redisTemplate.opsForValue().get(cacheKey)?.let { return it }

    // 분산 락 획득 (SETNX)
    val acquired = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, "locked", Duration.ofSeconds(5))

    if (acquired == true) {
        try {
            // Double-check
            redisTemplate.opsForValue().get(cacheKey)?.let { return it }

            // 1개만 DB 조회
            val product = productRepository.findById(id).orElseThrow()
            val dto = ProductCacheDto.from(product)
            redisTemplate.opsForValue().set(cacheKey, dto, Duration.ofMinutes(10))
            return dto
        } finally {
            redisTemplate.delete(lockKey)
        }
    } else {
        // 락 획득 실패 → 잠시 대기 후 재시도
        Thread.sleep(50)
        return getProductWithLock(id)
    }
}

8.2 Cache Penetration

문제: 존재하지 않는 데이터 반복 조회 → 매번 DB 조회

해결책: Null 캐싱

fun getProductSafe(id: Long): ProductCacheDto? {
    val cacheKey = "product:$id"

    // EMPTY 마커 체크
    if (redisTemplate.hasKey("$cacheKey:empty") == true) {
        return null
    }

    redisTemplate.opsForValue().get(cacheKey)?.let { return it }

    val product = productRepository.findById(id).orElse(null)

    if (product == null) {
        // 없는 데이터도 짧은 TTL로 캐싱
        redisTemplate.opsForValue().set("$cacheKey:empty", "1", Duration.ofMinutes(1))
        return null
    }

    val dto = ProductCacheDto.from(product)
    redisTemplate.opsForValue().set(cacheKey, dto, Duration.ofMinutes(10))
    return dto
}

8.3 Cache Avalanche

문제: 다수의 캐시가 동시에 만료 → DB 과부하

해결책: TTL Jitter

fun cacheWithJitter(key: String, value: Any, baseTtlMinutes: Long) {
    // 기본 TTL에 ±20% 랜덤 추가
    val jitter = (baseTtlMinutes * 0.2 * Random.nextDouble()).toLong()
    val ttl = baseTtlMinutes + jitter

    redisTemplate.opsForValue().set(key, value, Duration.ofMinutes(ttl))
}

// 예: 기본 10분 → 8~12분 사이로 분산

8.4 Hot Key 문제

문제: 특정 키에 요청 집중 → 단일 Redis 노드 과부하

해결책: 로컬 캐시 조합 (다단계)

// L1: 로컬 캐시 (Caffeine) - 30초 (빠름)
// L2: Redis - 10분 (서버 간 공유)

private val localCache = Caffeine.newBuilder()
    .maximumSize(100)
    .expireAfterWrite(Duration.ofSeconds(30))
    .build<String, List<ProductResponse>>()

fun getPopularProducts(): List<ProductResponse> {
    val cacheKey = "popularProducts:top10"

    // L1 조회 (로컬)
    localCache.getIfPresent(cacheKey)?.let { return it }

    // L2 조회 (Redis)
    val products = redisTemplate.opsForValue().get(cacheKey)
        ?: fetchAndCacheToRedis()

    // L1에 저장
    localCache.put(cacheKey, products)
    return products
}

9. 로컬 캐시 vs 분산 캐시

9.1 비교

항목로컬 캐시 (Caffeine)분산 캐시 (Redis)
속도~0.01ms~1ms
용량JVM 힙 제한수십 GB 이상
일관성서버 간 불일치일관성 보장
장애 영향서버별 독립전체 영향

9.2 선택 가이드

Q1. 여러 서버에서 동일한 데이터가 필요한가?
    YES → 분산 캐시 (Redis)
    NO  → Q2로

Q2. 데이터가 자주 변경되는가?
    YES → 분산 캐시
    NO  → 로컬 캐시 (Caffeine)

10. 실제 프로젝트 적용 사례

10.1 카테고리 목록 (Cache-Aside)

카테고리는 거의 변하지 않으므로 캐싱 효과가 큽니다.

@Service
class CategoryService(
    private val categoryJpaRepository: CategoryJpaRepository
) {
    // 캐시에서 조회, 없으면 DB 조회 후 캐싱
    @Cacheable(value = ["categories"], key = "'all'")
    fun getAllCategories(): List<CategoryResponse> {
        return categoryJpaRepository.findAll()
            .sortedBy { it.displayOrder }
            .map { CategoryResponse.from(it) }  // Entity → DTO 변환
    }

    // 카테고리 생성 시 캐시 전체 무효화
    @Transactional
    @CacheEvict(value = ["categories"], allEntries = true)
    fun createCategory(req: CreateCategoryRequest): CategoryResponse {
        // ... 생성 로직
    }
}

10.2 인기 상품 목록 (Cache-Aside)

인기 상품은 계산 비용이 높고(정렬), 약간의 지연이 허용됩니다.

@Service
class ProductService(
    private val productJpaRepository: ProductJpaRepository
) {
    // 인기 상품 TOP 10 캐싱
    @Cacheable(value = ["popularProducts"], key = "'top10'")
    fun getPopularProducts(): List<ProductResponse> {
        return productJpaRepository.findByStatusOrderBySalesCountDesc(
            ProductStatus.ON_SALE,
            PageRequest.of(0, 10)
        ).map { ProductResponse.from(it) }
    }

    // 상품 수정 시 인기 상품 캐시 무효화
    @Transactional
    @CacheEvict(value = ["popularProducts"], allEntries = true)
    fun updateProduct(sellerId: Long, productId: Long, req: UpdateProductRequest): ProductResponse {
        // ... 수정 로직
    }
}

10.3 캐시 설정 (CacheConfig)

@Configuration
@EnableCaching
@Profile("local")  // 로컬에서는 Caffeine, Docker/Prod에서는 Redis
class CacheConfig {

    @Bean
    fun cacheManager(): CacheManager {
        return CaffeineCacheManager("popularProducts", "categories").apply {
            setCaffeine(
                Caffeine.newBuilder()
                    .expireAfterWrite(10, TimeUnit.MINUTES)  // TTL 10분
                    .maximumSize(1000)
                    .recordStats()  // 히트율 모니터링
            )
        }
    }
}

11. FAQ (자주 묻는 질문)

Q1. 캐시 도입 전 무엇을 먼저 해야 하나요?

현재 병목을 측정하세요. APM이나 슬로우 쿼리 로그로 원인을 파악한 후, 인덱스/쿼리 최적화로 해결 가능한지 먼저 검토하세요.

Q2. Entity를 캐싱하면 왜 안 되나요?

5가지 문제가 있습니다:

  1. LazyInitializationException 발생
  2. Hibernate Proxy 직렬화 문제
  3. 불필요한 데이터 노출
  4. 캐시 크기 증가
  5. 변경 감지 오작동

반드시 DTO로 변환 후 캐싱하세요.

Q3. TTL은 어떻게 설정해야 하나요?

데이터 특성에 따라 다릅니다:

  • 거의 안 변함 (카테고리): 1시간~1일
  • 가끔 변함 (상품 정보): 5~30분
  • 자주 변함 (재고): 캐시 안 함 또는 10~30초

불일치 허용 범위를 정의하고 그에 맞게 설정하세요.

Q4. 캐시 무효화가 실패하면 어떻게 되나요?

DB는 새 값, 캐시는 옛날 값이 됩니다. 해결책:

  • TTL을 짧게 설정 (최종 방어선)
  • 지연 삭제 (500ms 후 한 번 더)
  • 무효화 실패 시 로깅/알람

Q5. 캐시 장애 시 어떻게 대응하나요?

fallback 전략을 준비하세요:

fun getPopularProducts(): List<ProductResponse> {
    return try {
        redisTemplate.opsForValue().get("popularProducts:top10")
            ?: fetchFromDB()
    } catch (e: RedisConnectionException) {
        log.warn("Redis 연결 실패, DB fallback")
        fetchFromDB()  // DB 직접 조회
    }
}

Q6. 재고 같은 실시간 데이터도 캐싱해야 하나요?

아니요. 실시간 정확성이 필요한 데이터는 캐싱하지 마세요. 원자적 UPDATE로 DB에서 직접 처리하세요.


정리

데이터 특성별 전략

데이터 특성권장 전략TTL예시
거의 안 변함Read-Through + Refresh-Ahead1시간~1일카테고리, 설정
가끔 변함Cache-Aside + 명시적 무효화5~30분상품 정보
자주 변함캐시 안 함-재고, 결제 상태
쓰기 많음Write-Behind배치조회수, 좋아요
계산 비용 높음Cache-Aside + 긴 TTL5분~1시간랭킹, 통계

캐싱 패턴 비교

패턴핵심적합한 상황
Cache-Aside앱이 캐시/DB 직접 관리범용, 읽기 위주 (권장)
Read-Through캐시가 DB 조회 대행일관된 캐시 로직
Write-Through캐시+DB 동시 저장일관성 중요
Write-Behind캐시만 저장, DB는 비동기쓰기 성능 중요
Refresh-AheadTTL 전 미리 갱신Hot Key

문제별 해결책

문제해결책
Cache Stampede분산 락, 확률적 조기 갱신
Cache PenetrationNull 캐싱
Cache AvalancheTTL Jitter
Hot Key로컬 캐시 조합, 키 복제
데이터 불일치짧은 TTL, 지연 삭제

Quick Checklist

  • 캐시 도입 전 병목 지점을 측정했는가?
  • 인덱스/쿼리 최적화로 해결 가능한지 먼저 검토했는가?
  • Entity가 아닌 DTO로 캐싱하고 있는가?
  • 데이터 특성에 맞는 TTL을 설정했는가?
  • 캐시 무효화 전략이 명확한가?
  • 캐시 장애 시 fallback 전략이 있는가?
  • 캐시 히트율 모니터링이 가능한가?

다음 편에서는 이벤트 드리븐 아키텍처와 Kafka 에 대해 다룹니다.

👉 다음: 3편 - 이벤트 드리븐 아키텍처

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