Skip to the content.

CacheAdapter 가이드

목적: Domain Aggregate를 Redis에 캐싱하는 Cache Adapter 구현 가이드


1️⃣ CacheAdapter란?

역할

Domain Aggregate ↔ Redis Cache

캐시 저장/조회/무효화만 담당하는 단순 캐시 저장소 역할만 수행합니다.

책임

핵심 원칙

Application Layer (UseCase)
  ├─ 1. Cache 조회 (CachePort)
  │     └─ CacheAdapter.get(key)
  ├─ 2. Cache Miss → DB 조회 (QueryPort)
  │     └─ QueryAdapter.findById(id)
  └─ 3. Cache 저장 (CachePort)
        └─ CacheAdapter.set(key, value, ttl)

2️⃣ 핵심 원칙

원칙 1: Cache-Aside 패턴 (권장)

// 조회
public Order getOrder(Long orderId) {
    String key = "cache::orders::" + orderId;

    // 1. Cache 조회
    Order cached = cachePort.get(key, Order.class);
    if (cached != null) return cached;

    // 2. Cache Miss → DB 조회
    Order order = queryPort.findById(OrderId.of(orderId));

    // 3. Cache 저장
    cachePort.set(key, order, Duration.ofMinutes(30));
    return order;
}

원칙 2: Key Naming Convention

// 패턴: {namespace}:{entity}:{id}
private static final String KEY_PREFIX = "cache::orders::";

private String generateKey(Long id) {
    return KEY_PREFIX + id;
}

원칙 3: TTL 필수

// ❌ TTL 없이 저장 금지
redisTemplate.opsForValue().set(key, value);

// ✅ 항상 TTL 지정
redisTemplate.opsForValue().set(key, value, Duration.ofMinutes(30));

원칙 4: Transaction 외부에서 Cache 무효화

// ❌ Transaction 내 무효화 금지
@Transactional
public void updateOrder(Order order) {
    persistPort.persist(order);
    cachePort.evict("cache::orders::" + order.getId());  // ❌ Rollback 시 문제
}

// ✅ Transaction 외부에서 무효화
@Transactional
public void updateOrder(Order order) {
    persistPort.persist(order);
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void evictCache(OrderUpdatedEvent event) {
    cachePort.evict("cache::orders::" + event.getOrderId());  // ✅
}

3️⃣ 템플릿 코드

기본 템플릿

package com.ryuqq.adapter.out.persistence.redis.order.adapter;

import com.ryuqq.application.order.port.out.OrderCachePort;
import com.ryuqq.domain.order.Order;
import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.util.Optional;

/**
 * Order Cache Adapter
 *
 * <p><strong>책임:</strong></p>
 * <ul>
 *   <li>Order Domain 객체를 Redis에 캐싱</li>
 *   <li>Cache-Aside 패턴 지원</li>
 *   <li>TTL 기반 자동 만료</li>
 * </ul>
 *
 * @author Development Team
 * @since 1.0.0
 */
@Component
public class OrderCacheAdapter implements OrderCachePort {

    private static final String KEY_PREFIX = "cache::orders::";
    private static final Duration DEFAULT_TTL = Duration.ofMinutes(30);

    private final RedisTemplate<String, Object> redisTemplate;

    public OrderCacheAdapter(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * Order 캐시 저장
     *
     * @param orderId Order ID
     * @param order   저장할 Order (Domain)
     */
    @Override
    public void cache(Long orderId, Order order) {
        String key = generateKey(orderId);
        redisTemplate.opsForValue().set(key, order, DEFAULT_TTL);
    }

    /**
     * Order 캐시 조회
     *
     * @param orderId Order ID
     * @return Optional<Order> (Cache Hit 시 Order, Miss 시 Empty)
     */
    @Override
    public Optional<Order> get(Long orderId) {
        String key = generateKey(orderId);
        Order cached = (Order) redisTemplate.opsForValue().get(key);
        return Optional.ofNullable(cached);
    }

    /**
     * Order 캐시 무효화
     *
     * @param orderId Order ID
     */
    @Override
    public void evict(Long orderId) {
        String key = generateKey(orderId);
        redisTemplate.delete(key);
    }

    /**
     * Order 전체 캐시 무효화 (패턴 기반)
     *
     * <p>SCAN 명령어를 사용하여 안전하게 키를 삭제합니다.</p>
     * <p>⚠️ KEYS 명령어는 절대 사용 금지 (Redis 블로킹)</p>
     */
    @Override
    public void evictAll() {
        ScanOptions options = ScanOptions.scanOptions()
            .match(KEY_PREFIX + "*")
            .count(100)
            .build();

        try (Cursor<String> cursor = redisTemplate.scan(options)) {
            while (cursor.hasNext()) {
                redisTemplate.delete(cursor.next());
            }
        }
    }

    private String generateKey(Long orderId) {
        return KEY_PREFIX + orderId;
    }
}

4️⃣ TTL 전략

용도별 TTL 권장값

캐시 타입 TTL 예시
Static Data 24시간 코드 테이블, 설정
Reference Data 1시간 카테고리, 상품 목록
User Data 10-30분 프로필, 설정
Session 30분 로그인 세션
Rate Limit 1분-1시간 API 요청 제한
Temporary 5분 OTP, 인증 토큰

TTL 설정 방법

// 방법 1: 상수로 관리
private static final Duration USER_CACHE_TTL = Duration.ofMinutes(30);
private static final Duration PRODUCT_CACHE_TTL = Duration.ofHours(1);

// 방법 2: 메서드 파라미터로 받기
@Override
public void cache(Long id, Order order, Duration ttl) {
    redisTemplate.opsForValue().set(generateKey(id), order, ttl);
}

5️⃣ Do / Don’t

❌ Bad Examples

// ❌ 비즈니스 로직 포함
@Override
public void cache(Order order) {
    if (order.getStatus() == OrderStatus.PLACED) {  // ❌ 비즈니스 판단
        redisTemplate.opsForValue().set(generateKey(order.getId()), order);
    }
}

// ❌ TTL 없이 저장
@Override
public void cache(Long id, Order order) {
    redisTemplate.opsForValue().set(generateKey(id), order);  // ❌ 영구 저장
}

// ❌ DB 접근
@Override
public Optional<Order> get(Long orderId) {
    Order cached = (Order) redisTemplate.opsForValue().get(generateKey(orderId));
    if (cached == null) {
        return orderRepository.findById(orderId);  // ❌ DB 접근 금지
    }
    return Optional.of(cached);
}

// ❌ @Transactional 사용
@Transactional  // ❌
@Override
public void cache(Long id, Order order) {
    redisTemplate.opsForValue().set(generateKey(id), order, Duration.ofMinutes(30));
}

✅ Good Examples

// ✅ 단순 저장/조회만
@Override
public void cache(Long id, Order order) {
    redisTemplate.opsForValue().set(generateKey(id), order, Duration.ofMinutes(30));
}

// ✅ Optional 반환
@Override
public Optional<Order> get(Long orderId) {
    Order cached = (Order) redisTemplate.opsForValue().get(generateKey(orderId));
    return Optional.ofNullable(cached);
}

// ✅ Key Naming Convention 준수
private String generateKey(Long orderId) {
    return "cache::orders::" + orderId;
}

// ✅ TTL 명시
private static final Duration DEFAULT_TTL = Duration.ofMinutes(30);

6️⃣ Port 인터페이스 예시

package com.ryuqq.application.order.port.out;

import com.ryuqq.domain.order.Order;

import java.time.Duration;
import java.util.Optional;

/**
 * Order Cache Port (출력 포트)
 */
public interface OrderCachePort {

    /**
     * Order 캐시 저장
     */
    void cache(Long orderId, Order order);

    /**
     * Order 캐시 저장 (TTL 커스텀)
     */
    void cache(Long orderId, Order order, Duration ttl);

    /**
     * Order 캐시 조회
     */
    Optional<Order> get(Long orderId);

    /**
     * Order 캐시 무효화
     */
    void evict(Long orderId);

    /**
     * Order 전체 캐시 무효화
     */
    void evictAll();
}

7️⃣ 금지 명령어 (중요!)

❌ KEYS 명령어 절대 금지

// ❌ KEYS 사용 금지 (Production 환경에서 Redis 블로킹)
Set<String> keys = redisTemplate.keys("cache::orders::*");  // ❌
keys.forEach(redisTemplate::delete);

문제점:

✅ SCAN 사용 (안전)

// ✅ SCAN 사용 (페이지네이션 지원, 블로킹 없음)
@Override
public void evictAll() {
    ScanOptions options = ScanOptions.scanOptions()
        .match(KEY_PREFIX + "*")
        .count(100)  // 한 번에 100개씩
        .build();

    try (Cursor<String> cursor = redisTemplate.scan(options)) {
        while (cursor.hasNext()) {
            redisTemplate.delete(cursor.next());
        }
    }
}

장점:


8️⃣ 체크리스트

필수 규칙 (Zero-Tolerance)

선택적 규칙 (상황에 따라)

참고: 단순 String key-value 캐시의 경우 ObjectMapper, evictByPattern, scanKeys가 불필요합니다. Object 타입 캐시(JSON 직렬화)나 패턴 기반 삭제가 필요한 경우에만 구현하세요.


9️⃣ 참고 문서


작성자: Development Team 최종 수정일: 2025-12-09 버전: 1.1.0

변경 이력