Theory
중급 2-3시간 이론 + 실습

Spring 12: 캐싱

성능 최적화를 위한 캐시 전략

CacheRedis@CacheableCache Eviction

1. 캐싱 개념

1.1 캐시의 필요성

캐시는 자주 사용되는 데이터를 빠르게 접근할 수 있는 저장소에 임시로 저장하는 기술입니다. 웹 애플리케이션에서 캐시는 성능 향상과 시스템 부하 감소에 핵심적인 역할을 합니다.

캐시 사용 시나리오

  • 데이터베이스 조회 최적화: 복잡한 쿼리 결과를 캐시하여 DB 부하 감소
  • 외부 API 호출 최적화: 외부 서비스 응답을 캐시하여 네트워크 지연 감소
  • 계산 결과 저장: 복잡한 연산 결과를 캐시하여 CPU 사용량 감소
  • 세션 데이터 관리: 사용자 세션 정보를 빠르게 조회

성능 개선 효과

// 캐시 없이 데이터베이스 조회
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
    // 매번 DB 조회 (평균 100ms)
    return userRepository.findById(id);
}

// 캐시 적용 후
@GetMapping("/users/{id}")
@Cacheable("users")
public User getUser(@PathVariable Long id) {
    // 첫 번째: DB 조회 (100ms)
    // 이후: 캐시에서 조회 (1ms)
    return userRepository.findById(id);
}

1.2 캐시 전략

효과적인 캐싱을 위해서는 적절한 캐시 전략을 선택해야 합니다. 각 전략은 데이터의 특성과 애플리케이션 요구사항에 따라 선택됩니다.

Cache-Aside (Lazy Loading)

애플리케이션이 캐시를 직접 관리하는 방식

  • • 캐시 미스 시 DB에서 조회 후 캐시에 저장
  • • 가장 일반적인 패턴
  • • Spring @Cacheable이 이 방식

Write-Through

데이터 쓰기 시 캐시와 DB에 동시 저장

  • • 데이터 일관성 보장
  • • 쓰기 성능 저하
  • • 중요한 데이터에 적합

Write-Behind (Write-Back)

캐시에만 쓰고 나중에 DB에 비동기 저장

  • • 높은 쓰기 성능
  • • 데이터 손실 위험
  • • 로그, 통계 데이터에 적합

Refresh-Ahead

만료 전에 미리 캐시를 갱신하는 방식

  • • 캐시 미스 방지
  • • 복잡한 구현
  • • 예측 가능한 접근 패턴에 적합

캐시 전략 선택 가이드

// 읽기 중심 데이터 (사용자 프로필, 상품 정보)
@Cacheable("users")
public User findUser(Long id) { ... }

// 자주 변경되는 데이터 (재고, 가격)
@CachePut("products")
public Product updateProduct(Product product) { ... }

// 통계성 데이터 (조회수, 좋아요)
@Async
@CacheEvict("statistics")
public void updateStatistics(Long id) { ... }

1.3 로컬 vs 분산 캐시

캐시는 저장 위치에 따라 로컬 캐시와 분산 캐시로 구분됩니다. 각각의 특성을 이해하고 적절한 상황에서 사용해야 합니다.

로컬 캐시 (Local Cache)

특징
  • • 애플리케이션 메모리에 저장
  • • 매우 빠른 접근 속도
  • • 서버별로 독립적인 캐시
  • • 메모리 사용량 제한
적합한 용도
  • • 설정 정보, 코드 테이블
  • • 자주 사용되는 소량 데이터
  • • 단일 서버 환경

분산 캐시 (Distributed Cache)

특징
  • • 별도 캐시 서버에 저장
  • • 여러 서버가 공유
  • • 네트워크 지연 발생
  • • 확장성과 일관성 보장
적합한 용도
  • • 세션 데이터
  • • 대용량 데이터
  • • 멀티 서버 환경

구현 예시

// 로컬 캐시 설정 (Caffeine)
@Configuration
@EnableCaching
public class LocalCacheConfig {
    
    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager();
        cacheManager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(10, TimeUnit.MINUTES));
        return cacheManager;
    }
}

// 분산 캐시 설정 (Redis)
@Configuration
@EnableCaching
public class RedisCacheConfig {
    
    @Bean
    public CacheManager cacheManager(RedisConnectionFactory factory) {
        RedisCacheConfiguration config = RedisCacheConfiguration
            .defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .serializeKeysWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new GenericJackson2JsonRedisSerializer()));
                
        return RedisCacheManager.builder(factory)
            .cacheDefaults(config)
            .build();
    }
}

하이브리드 캐시 전략

L1 (로컬) + L2 (분산) 캐시를 조합하여 각각의 장점을 활용할 수 있습니다.

@Service
public class HybridCacheService {
    
    @Cacheable(value = "local-cache", condition = "#size < 1000")
    @Cacheable(value = "redis-cache", condition = "#size >= 1000")
    public List<Product> getProducts(int size) {
        return productRepository.findAll();
    }
}

1.4 캐시 성능 지표

캐시의 효과를 측정하고 최적화하기 위해서는 주요 성능 지표를 모니터링해야 합니다.

Cache Hit Ratio

캐시에서 데이터를 찾은 비율

Hit Ratio = Cache Hits / (Cache Hits + Cache Misses)

목표: 80% 이상

Response Time

캐시 조회 응답 시간

  • • 로컬 캐시: 1ms 이하
  • • Redis: 1-5ms
  • • DB 조회: 10-100ms

모니터링 구현

@Component
public class CacheMetrics {
    
    private final MeterRegistry meterRegistry;
    private final Counter cacheHits;
    private final Counter cacheMisses;
    
    public CacheMetrics(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
        this.cacheHits = Counter.builder("cache.hits")
            .description("Cache hits")
            .register(meterRegistry);
        this.cacheMisses = Counter.builder("cache.misses")
            .description("Cache misses")
            .register(meterRegistry);
    }
    
    @EventListener
    public void handleCacheHit(CacheHitEvent event) {
        cacheHits.increment(Tags.of("cache", event.getCacheName()));
    }
    
    @EventListener
    public void handleCacheMiss(CacheMissEvent event) {
        cacheMisses.increment(Tags.of("cache", event.getCacheName()));
    }
}

2. Spring Cache Abstraction

2.1 @EnableCaching 설정

Spring Cache Abstraction은 다양한 캐시 구현체를 통합된 인터페이스로 사용할 수 있게 해주는 추상화 레이어입니다. @EnableCaching 어노테이션으로 캐시 기능을 활성화합니다.

기본 설정

@Configuration
@EnableCaching
public class CacheConfig {
    
    // 기본 CacheManager 설정
    @Bean
    public CacheManager cacheManager() {
        // Simple 캐시 매니저 (개발용)
        return new ConcurrentMapCacheManager("users", "products", "orders");
    }
    
    // 캐시 키 생성 전략 커스터마이징
    @Bean
    public KeyGenerator keyGenerator() {
        return new KeyGenerator() {
            @Override
            public Object generate(Object target, Method method, Object... params) {
                StringBuilder sb = new StringBuilder();
                sb.append(target.getClass().getSimpleName());
                sb.append(".");
                sb.append(method.getName());
                for (Object param : params) {
                    sb.append("_").append(param.toString());
                }
                return sb.toString();
            }
        };
    }
    
    // 캐시 에러 핸들러
    @Bean
    public CacheErrorHandler errorHandler() {
        return new CacheErrorHandler() {
            @Override
            public void handleCacheGetError(RuntimeException exception, 
                                          Cache cache, Object key) {
                log.error("Cache get error: cache={}, key={}", 
                         cache.getName(), key, exception);
            }
            
            @Override
            public void handleCachePutError(RuntimeException exception, 
                                          Cache cache, Object key, Object value) {
                log.error("Cache put error: cache={}, key={}", 
                         cache.getName(), key, exception);
            }
            
            @Override
            public void handleCacheEvictError(RuntimeException exception, 
                                            Cache cache, Object key) {
                log.error("Cache evict error: cache={}, key={}", 
                         cache.getName(), key, exception);
            }
            
            @Override
            public void handleCacheClearError(RuntimeException exception, Cache cache) {
                log.error("Cache clear error: cache={}", cache.getName(), exception);
            }
        };
    }
}

고급 설정

@Configuration
@EnableCaching
public class AdvancedCacheConfig implements CachingConfigurer {
    
    @Override
    public CacheManager cacheManager() {
        CompositeCacheManager cacheManager = new CompositeCacheManager();
        
        // 여러 캐시 매니저 조합
        List<CacheManager> managers = Arrays.asList(
            localCacheManager(),
            redisCacheManager()
        );
        
        cacheManager.setCacheManagers(managers);
        cacheManager.setFallbackToNoOpCache(false);
        
        return cacheManager;
    }
    
    private CacheManager localCacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(5, TimeUnit.MINUTES)
            .recordStats());
        return manager;
    }
    
    private CacheManager redisCacheManager() {
        // Redis 캐시 매니저 설정 (Section 3에서 상세 설명)
        return RedisCacheManager.builder(redisConnectionFactory())
            .cacheDefaults(redisCacheConfiguration())
            .build();
    }
    
    @Override
    public CacheResolver cacheResolver() {
        return new SimpleCacheResolver(cacheManager());
    }
    
    @Override
    public KeyGenerator keyGenerator() {
        return new SimpleKeyGenerator();
    }
    
    @Override
    public CacheErrorHandler errorHandler() {
        return new SimpleCacheErrorHandler();
    }
}

2.2 @Cacheable 어노테이션

@Cacheable은 메서드의 결과를 캐시에 저장하는 가장 기본적인 어노테이션입니다. 동일한 파라미터로 호출 시 캐시된 결과를 반환합니다.

기본 사용법

@Service
public class UserService {
    
    @Autowired
    private UserRepository userRepository;
    
    // 기본 캐시 적용
    @Cacheable("users")
    public User findById(Long id) {
        log.info("DB에서 사용자 조회: {}", id);
        return userRepository.findById(id).orElse(null);
    }
    
    // 복합 키 사용
    @Cacheable(value = "users", key = "#id + '_' + #includeDeleted")
    public User findById(Long id, boolean includeDeleted) {
        if (includeDeleted) {
            return userRepository.findByIdIncludingDeleted(id);
        }
        return userRepository.findById(id).orElse(null);
    }
    
    // 조건부 캐싱
    @Cacheable(value = "users", condition = "#id > 0", unless = "#result == null")
    public User findByIdConditional(Long id) {
        return userRepository.findById(id).orElse(null);
    }
    
    // SpEL 표현식 활용
    @Cacheable(value = "users", key = "#user.id", condition = "#user.active")
    public User findByUser(User user) {
        return userRepository.findById(user.getId()).orElse(null);
    }
}

고급 키 생성 전략

@Service
public class ProductService {
    
    // 커스텀 키 생성기 사용
    @Cacheable(value = "products", keyGenerator = "customKeyGenerator")
    public List<Product> findByCategory(String category, int page, int size) {
        return productRepository.findByCategory(category, 
            PageRequest.of(page, size));
    }
    
    // 복잡한 키 생성
    @Cacheable(value = "products", 
               key = "T(java.lang.String).format('%s_%d_%d_%s', #category, #page, #size, #sort)")
    public List<Product> findByCategoryWithSort(String category, int page, 
                                               int size, String sort) {
        Sort sortObj = Sort.by(Sort.Direction.ASC, sort);
        return productRepository.findByCategory(category, 
            PageRequest.of(page, size, sortObj));
    }
    
    // 메서드 파라미터 기반 키
    @Cacheable(value = "products", key = "#root.methodName + '_' + #root.args[0]")
    public Product findByName(String name) {
        return productRepository.findByName(name);
    }
    
    // 객체 속성 기반 키
    @Cacheable(value = "search-results", 
               key = "#criteria.category + '_' + #criteria.priceRange + '_' + #criteria.brand")
    public List<Product> search(SearchCriteria criteria) {
        return productRepository.findByCriteria(criteria);
    }
}

주의사항

  • Self-invocation 문제: 같은 클래스 내에서 메서드 호출 시 캐시가 동작하지 않음
  • Proxy 기반: public 메서드에만 적용 가능
  • 예외 처리: 예외 발생 시 캐시에 저장되지 않음
// 잘못된 사용 예시
@Service
public class BadCacheService {
    
    @Cacheable("users")
    public User findById(Long id) {
        return userRepository.findById(id).orElse(null);
    }
    
    // 같은 클래스 내 호출 - 캐시 동작 안함
    public User findByIdInternal(Long id) {
        return this.findById(id); // 캐시 무시됨
    }
}

// 올바른 사용 예시
@Service
public class GoodCacheService {
    
    @Autowired
    private UserCacheService userCacheService;
    
    public User findByIdInternal(Long id) {
        return userCacheService.findById(id); // 캐시 동작함
    }
}

2.3 @CacheEvict 어노테이션

@CacheEvict는 캐시에서 데이터를 제거하는 어노테이션입니다. 데이터가 변경되었을 때 캐시를 무효화하여 일관성을 유지합니다.

기본 사용법

@Service
public class UserService {
    
    // 특정 키 제거
    @CacheEvict(value = "users", key = "#id")
    public void deleteUser(Long id) {
        userRepository.deleteById(id);
    }
    
    // 전체 캐시 제거
    @CacheEvict(value = "users", allEntries = true)
    public void deleteAllUsers() {
        userRepository.deleteAll();
    }
    
    // 메서드 실행 전 캐시 제거
    @CacheEvict(value = "users", key = "#user.id", beforeInvocation = true)
    public void updateUser(User user) {
        // 캐시 제거 후 업데이트 실행
        userRepository.save(user);
    }
    
    // 조건부 캐시 제거
    @CacheEvict(value = "users", key = "#id", condition = "#result == true")
    public boolean deactivateUser(Long id) {
        User user = userRepository.findById(id).orElse(null);
        if (user != null && user.isActive()) {
            user.setActive(false);
            userRepository.save(user);
            return true;
        }
        return false;
    }
    
    // 여러 캐시 동시 제거
    @CacheEvict(value = {"users", "user-profiles", "user-statistics"}, key = "#id")
    public void removeUserFromAllCaches(Long id) {
        // 사용자 관련 모든 캐시 제거
    }
}

고급 캐시 제거 패턴

@Service
public class OrderService {
    
    // 연관된 캐시들 일괄 제거
    @Caching(evict = {
        @CacheEvict(value = "orders", key = "#order.id"),
        @CacheEvict(value = "user-orders", key = "#order.userId"),
        @CacheEvict(value = "order-statistics", allEntries = true)
    })
    public Order updateOrder(Order order) {
        return orderRepository.save(order);
    }
    
    // 패턴 기반 캐시 제거 (커스텀 구현)
    @CacheEvict(value = "products", allEntries = true)
    public void updateProductCategory(Long categoryId, String newName) {
        // 카테고리 변경 시 관련 상품 캐시 모두 제거
        categoryRepository.updateName(categoryId, newName);
    }
    
    // 스케줄링과 함께 사용
    @Scheduled(fixedRate = 3600000) // 1시간마다
    @CacheEvict(value = {"temp-data", "session-cache"}, allEntries = true)
    public void clearTemporaryCache() {
        log.info("임시 캐시 정리 완료");
    }
}

2.4 @CachePut 어노테이션

@CachePut은 메서드를 항상 실행하고 결과를 캐시에 저장하는 어노테이션입니다. 캐시를 업데이트하면서 최신 데이터를 유지할 때 사용합니다.

기본 사용법

@Service
public class UserService {
    
    // 업데이트 후 캐시 갱신
    @CachePut(value = "users", key = "#user.id")
    public User updateUser(User user) {
        User updated = userRepository.save(user);
        log.info("사용자 업데이트 및 캐시 갱신: {}", updated.getId());
        return updated;
    }
    
    // 생성 후 캐시에 저장
    @CachePut(value = "users", key = "#result.id")
    public User createUser(User user) {
        User created = userRepository.save(user);
        return created;
    }
    
    // 조건부 캐시 업데이트
    @CachePut(value = "users", key = "#user.id", 
              condition = "#user.active == true", 
              unless = "#result == null")
    public User activateUser(User user) {
        user.setActive(true);
        user.setLastLoginDate(LocalDateTime.now());
        return userRepository.save(user);
    }
    
    // 여러 캐시 동시 업데이트
    @Caching(put = {
        @CachePut(value = "users", key = "#result.id"),
        @CachePut(value = "active-users", key = "#result.id", condition = "#result.active")
    })
    public User updateUserStatus(Long id, boolean active) {
        User user = userRepository.findById(id).orElseThrow();
        user.setActive(active);
        return userRepository.save(user);
    }
}

@Cacheable vs @CachePut 비교

@Service
public class ComparisonService {
    
    // @Cacheable: 캐시에 있으면 메서드 실행 안함
    @Cacheable(value = "users", key = "#id")
    public User findUser(Long id) {
        log.info("DB 조회 실행"); // 캐시 히트 시 실행되지 않음
        return userRepository.findById(id).orElse(null);
    }
    
    // @CachePut: 항상 메서드 실행하고 결과를 캐시에 저장
    @CachePut(value = "users", key = "#id")
    public User refreshUser(Long id) {
        log.info("DB 조회 실행"); // 항상 실행됨
        return userRepository.findById(id).orElse(null);
    }
    
    // 실제 사용 패턴
    @Cacheable(value = "users", key = "#id")
    public User getUser(Long id) {
        return userRepository.findById(id).orElse(null);
    }
    
    @CachePut(value = "users", key = "#user.id")
    public User updateUser(User user) {
        return userRepository.save(user);
    }
    
    @CacheEvict(value = "users", key = "#id")
    public void deleteUser(Long id) {
        userRepository.deleteById(id);
    }
}

2.5 @Caching과 복합 캐시 작업

@Caching 어노테이션을 사용하여 여러 캐시 작업을 하나의 메서드에서 수행할 수 있습니다. 복잡한 캐시 시나리오에서 유용합니다.

복합 캐시 작업

@Service
public class ComplexCacheService {
    
    // 여러 캐시에서 동시 조회
    @Caching(cacheable = {
        @Cacheable(value = "users", key = "#id"),
        @Cacheable(value = "user-profiles", key = "#id", condition = "#includeProfile")
    })
    public UserWithProfile getUserWithProfile(Long id, boolean includeProfile) {
        User user = userRepository.findById(id).orElse(null);
        if (user == null) return null;
        
        UserProfile profile = includeProfile ? 
            profileRepository.findByUserId(id) : null;
            
        return new UserWithProfile(user, profile);
    }
    
    // 업데이트 시 관련 캐시들 처리
    @Caching(
        put = {
            @CachePut(value = "users", key = "#user.id"),
            @CachePut(value = "user-summaries", key = "#user.id")
        },
        evict = {
            @CacheEvict(value = "user-lists", allEntries = true),
            @CacheEvict(value = "statistics", allEntries = true)
        }
    )
    public User updateUserAndRefreshCaches(User user) {
        User updated = userRepository.save(user);
        
        // 통계 재계산 트리거
        statisticsService.recalculateUserStatistics();
        
        return updated;
    }
    
    // 조건부 복합 캐시 작업
    @Caching(
        cacheable = @Cacheable(value = "products", key = "#id", 
                              condition = "#useCache == true"),
        put = @CachePut(value = "recent-products", key = "#id", 
                       condition = "#updateRecent == true")
    )
    public Product getProduct(Long id, boolean useCache, boolean updateRecent) {
        Product product = productRepository.findById(id).orElse(null);
        
        if (updateRecent && product != null) {
            // 최근 조회 상품 목록 업데이트
            recentProductService.addToRecent(product);
        }
        
        return product;
    }
}

실전 캐시 패턴

@Service
public class ECommerceService {
    
    // 상품 조회 시 조회수 증가 및 캐시 관리
    @Caching(
        cacheable = @Cacheable(value = "products", key = "#id"),
        put = @CachePut(value = "product-views", key = "#id", 
                       condition = "#incrementView == true")
    )
    public Product viewProduct(Long id, boolean incrementView) {
        Product product = productRepository.findById(id).orElse(null);
        
        if (incrementView && product != null) {
            product.incrementViewCount();
            productRepository.save(product);
        }
        
        return product;
    }
    
    // 주문 생성 시 재고 캐시 업데이트
    @Caching(
        put = @CachePut(value = "orders", key = "#result.id"),
        evict = {
            @CacheEvict(value = "product-stock", key = "#order.productId"),
            @CacheEvict(value = "user-cart", key = "#order.userId")
        }
    )
    public Order createOrder(Order order) {
        // 재고 확인 및 차감
        Product product = productRepository.findById(order.getProductId())
            .orElseThrow();
        
        if (product.getStock() < order.getQuantity()) {
            throw new InsufficientStockException();
        }
        
        product.decreaseStock(order.getQuantity());
        productRepository.save(product);
        
        return orderRepository.save(order);
    }
}

3. Redis 설정

3.1 Spring Data Redis 의존성

Redis를 Spring Boot 애플리케이션에서 사용하기 위해서는 Spring Data Redis 의존성을 추가하고 적절한 설정을 해야 합니다.

Maven 의존성

<dependencies>
    <!-- Spring Boot Starter Data Redis -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    
    <!-- Redis Connection Pool (Lettuce) -->
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-pool2</artifactId>
    </dependency>
    
    <!-- JSON 직렬화를 위한 Jackson -->
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
    </dependency>
    
    <!-- 캐시 추상화 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-cache</artifactId>
    </dependency>
</dependencies>

Gradle 의존성

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    implementation 'org.springframework.boot:spring-boot-starter-cache'
    implementation 'org.apache.commons:commons-pool2'
    implementation 'com.fasterxml.jackson.core:jackson-databind'
    
    // 테스트용 임베디드 Redis
    testImplementation 'it.ozimov:embedded-redis:0.7.3'
}

application.yml 기본 설정

spring:
  redis:
    host: localhost
    port: 6379
    password: # Redis 비밀번호 (옵션)
    database: 0
    timeout: 2000ms
    
    # Lettuce 연결 풀 설정
    lettuce:
      pool:
        max-active: 8
        max-idle: 8
        min-idle: 0
        max-wait: -1ms
    
    # Jedis 연결 풀 설정 (대안)
    jedis:
      pool:
        max-active: 8
        max-idle: 8
        min-idle: 0
        max-wait: -1ms
        
  cache:
    type: redis
    redis:
      time-to-live: 600000 # 10분 (밀리초)
      cache-null-values: false
      use-key-prefix: true
      key-prefix: "myapp:"

3.2 RedisTemplate 설정

RedisTemplate은 Redis와 상호작용하기 위한 핵심 클래스입니다. 적절한 직렬화 설정과 함께 구성해야 합니다.

기본 RedisTemplate 설정

@Configuration
@EnableCaching
public class RedisConfig {
    
    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        LettuceConnectionFactory factory = new LettuceConnectionFactory(
            new RedisStandaloneConfiguration("localhost", 6379)
        );
        
        // 연결 풀 설정
        factory.setPoolConfig(jedisPoolConfig());
        factory.setValidateConnection(true);
        
        return factory;
    }
    
    @Bean
    public GenericObjectPoolConfig jedisPoolConfig() {
        GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig();
        poolConfig.setMaxTotal(128);
        poolConfig.setMaxIdle(128);
        poolConfig.setMinIdle(16);
        poolConfig.setTestOnBorrow(true);
        poolConfig.setTestOnReturn(true);
        poolConfig.setTestWhileIdle(true);
        poolConfig.setMinEvictableIdleTimeMillis(Duration.ofSeconds(60).toMillis());
        poolConfig.setTimeBetweenEvictionRunsMillis(Duration.ofSeconds(30).toMillis());
        poolConfig.setNumTestsPerEvictionRun(3);
        poolConfig.setBlockWhenExhausted(true);
        return poolConfig;
    }
    
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        
        // 키 직렬화 설정
        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        
        // 값 직렬화 설정
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
        
        // 기본 직렬화 설정
        template.setDefaultSerializer(new GenericJackson2JsonRedisSerializer());
        
        template.afterPropertiesSet();
        return template;
    }
    
    @Bean
    public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {
        StringRedisTemplate template = new StringRedisTemplate();
        template.setConnectionFactory(factory);
        return template;
    }
}

고급 RedisTemplate 설정

@Configuration
public class AdvancedRedisConfig {
    
    @Bean
    @Primary
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        
        // Jackson ObjectMapper 커스터마이징
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        objectMapper.activateDefaultTyping(
            LaissezFaireSubTypeValidator.instance,
            ObjectMapper.DefaultTyping.NON_FINAL,
            JsonTypeInfo.As.PROPERTY
        );
        objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        objectMapper.registerModule(new JavaTimeModule());
        
        GenericJackson2JsonRedisSerializer serializer = 
            new GenericJackson2JsonRedisSerializer(objectMapper);
        
        // 직렬화 설정
        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(serializer);
        template.setHashValueSerializer(serializer);
        
        template.afterPropertiesSet();
        return template;
    }
    
    // 특정 용도별 RedisTemplate
    @Bean("userRedisTemplate")
    public RedisTemplate<String, User> userRedisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, User> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new Jackson2JsonRedisSerializer<>(User.class));
        
        return template;
    }
    
    @Bean("counterRedisTemplate")
    public RedisTemplate<String, Long> counterRedisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Long> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericToStringSerializer<>(Long.class));
        
        return template;
    }
}

RedisTemplate 사용 예시

@Service
public class RedisService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    
    // 문자열 데이터 저장/조회
    public void setString(String key, String value, long timeout) {
        stringRedisTemplate.opsForValue().set(key, value, timeout, TimeUnit.SECONDS);
    }
    
    public String getString(String key) {
        return stringRedisTemplate.opsForValue().get(key);
    }
    
    // 객체 데이터 저장/조회
    public void setObject(String key, Object value, long timeout) {
        redisTemplate.opsForValue().set(key, value, timeout, TimeUnit.SECONDS);
    }
    
    public Object getObject(String key) {
        return redisTemplate.opsForValue().get(key);
    }
    
    // Hash 데이터 조작
    public void setHash(String key, String field, Object value) {
        redisTemplate.opsForHash().put(key, field, value);
    }
    
    public Object getHash(String key, String field) {
        return redisTemplate.opsForHash().get(key, field);
    }
    
    // List 데이터 조작
    public void pushToList(String key, Object value) {
        redisTemplate.opsForList().rightPush(key, value);
    }
    
    public Object popFromList(String key) {
        return redisTemplate.opsForList().leftPop(key);
    }
    
    // Set 데이터 조작
    public void addToSet(String key, Object value) {
        redisTemplate.opsForSet().add(key, value);
    }
    
    public Set<Object> getSet(String key) {
        return redisTemplate.opsForSet().members(key);
    }
    
    // 키 만료 시간 설정
    public void expire(String key, long timeout) {
        redisTemplate.expire(key, timeout, TimeUnit.SECONDS);
    }
    
    // 키 삭제
    public void delete(String key) {
        redisTemplate.delete(key);
    }
}

3.3 직렬화 전략

Redis에 데이터를 저장할 때는 적절한 직렬화 방식을 선택해야 합니다. 성능, 가독성, 호환성을 고려하여 직렬화 전략을 결정합니다.

StringRedisSerializer

  • • UTF-8 문자열로 직렬화
  • • 가장 빠르고 가벼움
  • • 키에 주로 사용
  • • Redis CLI에서 읽기 가능

Jackson2JsonRedisSerializer

  • • JSON 형태로 직렬화
  • • 가독성이 좋음
  • • 타입 안전성 제공
  • • 크기가 상대적으로 큼

GenericJackson2JsonRedisSerializer

  • • 타입 정보 포함 JSON
  • • 다양한 타입 지원
  • • 역직렬화 시 타입 보장
  • • 크기가 가장 큼

JdkSerializationRedisSerializer

  • • Java 기본 직렬화
  • • 바이너리 형태
  • • 가독성 없음
  • • 호환성 문제 가능

커스텀 직렬화 구현

// 커스텀 User 직렬화
public class UserRedisSerializer implements RedisSerializer<User> {
    
    private final ObjectMapper objectMapper;
    
    public UserRedisSerializer() {
        this.objectMapper = new ObjectMapper();
        this.objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        this.objectMapper.registerModule(new JavaTimeModule());
    }
    
    @Override
    public byte[] serialize(User user) throws SerializationException {
        if (user == null) {
            return new byte[0];
        }
        try {
            return objectMapper.writeValueAsBytes(user);
        } catch (JsonProcessingException e) {
            throw new SerializationException("Error serializing User", e);
        }
    }
    
    @Override
    public User deserialize(byte[] bytes) throws SerializationException {
        if (bytes == null || bytes.length == 0) {
            return null;
        }
        try {
            return objectMapper.readValue(bytes, User.class);
        } catch (IOException e) {
            throw new SerializationException("Error deserializing User", e);
        }
    }
}

// 압축 직렬화 (대용량 데이터용)
public class CompressedJsonRedisSerializer implements RedisSerializer<Object> {
    
    private final ObjectMapper objectMapper = new ObjectMapper();
    
    @Override
    public byte[] serialize(Object object) throws SerializationException {
        if (object == null) {
            return new byte[0];
        }
        try {
            byte[] json = objectMapper.writeValueAsBytes(object);
            return compress(json);
        } catch (Exception e) {
            throw new SerializationException("Error serializing object", e);
        }
    }
    
    @Override
    public Object deserialize(byte[] bytes) throws SerializationException {
        if (bytes == null || bytes.length == 0) {
            return null;
        }
        try {
            byte[] decompressed = decompress(bytes);
            return objectMapper.readValue(decompressed, Object.class);
        } catch (Exception e) {
            throw new SerializationException("Error deserializing object", e);
        }
    }
    
    private byte[] compress(byte[] data) throws IOException {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        try (GZIPOutputStream gzipOut = new GZIPOutputStream(baos)) {
            gzipOut.write(data);
        }
        return baos.toByteArray();
    }
    
    private byte[] decompress(byte[] compressed) throws IOException {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        try (GZIPInputStream gzipIn = new GZIPInputStream(new ByteArrayInputStream(compressed))) {
            byte[] buffer = new byte[1024];
            int len;
            while ((len = gzipIn.read(buffer)) != -1) {
                baos.write(buffer, 0, len);
            }
        }
        return baos.toByteArray();
    }
}

3.4 Redis Cache Manager 설정

Spring Cache Abstraction과 Redis를 연동하기 위해서는 RedisCacheManager를 설정해야 합니다. 캐시별로 다른 TTL과 설정을 적용할 수 있습니다.

기본 RedisCacheManager 설정

@Configuration
@EnableCaching
public class RedisCacheConfig {
    
    @Bean
    public CacheManager cacheManager(RedisConnectionFactory factory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .serializeKeysWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new GenericJackson2JsonRedisSerializer()))
            .disableCachingNullValues();
            
        return RedisCacheManager.builder(factory)
            .cacheDefaults(config)
            .build();
    }
    
    // 캐시별 개별 설정
    @Bean
    public CacheManager customCacheManager(RedisConnectionFactory factory) {
        // 기본 설정
        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .serializeKeysWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new GenericJackson2JsonRedisSerializer()));
        
        // 캐시별 개별 설정
        Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
        
        // 사용자 캐시 - 30분 TTL
        cacheConfigurations.put("users", defaultConfig
            .entryTtl(Duration.ofMinutes(30))
            .prefixCacheNameWith("user:"));
        
        // 상품 캐시 - 1시간 TTL
        cacheConfigurations.put("products", defaultConfig
            .entryTtl(Duration.ofHours(1))
            .prefixCacheNameWith("product:"));
        
        // 세션 캐시 - 24시간 TTL
        cacheConfigurations.put("sessions", defaultConfig
            .entryTtl(Duration.ofHours(24))
            .prefixCacheNameWith("session:"));
        
        // 통계 캐시 - 5분 TTL, null 값 허용
        cacheConfigurations.put("statistics", defaultConfig
            .entryTtl(Duration.ofMinutes(5))
            .prefixCacheNameWith("stats:")
            .serializeNullValues());
        
        return RedisCacheManager.builder(factory)
            .cacheDefaults(defaultConfig)
            .withInitialCacheConfigurations(cacheConfigurations)
            .build();
    }
}

고급 캐시 설정

@Configuration
public class AdvancedRedisCacheConfig {
    
    @Bean
    public CacheManager advancedCacheManager(RedisConnectionFactory factory) {
        // Jackson ObjectMapper 설정
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        objectMapper.activateDefaultTyping(
            LaissezFaireSubTypeValidator.instance,
            ObjectMapper.DefaultTyping.NON_FINAL
        );
        objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        objectMapper.registerModule(new JavaTimeModule());
        
        GenericJackson2JsonRedisSerializer serializer = 
            new GenericJackson2JsonRedisSerializer(objectMapper);
        
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .serializeKeysWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair
                .fromSerializer(serializer))
            .computePrefixWith(cacheName -> "myapp:" + cacheName + ":")
            .disableCachingNullValues();
        
        return RedisCacheManager.RedisCacheManagerBuilder
            .fromConnectionFactory(factory)
            .cacheDefaults(config)
            .transactionAware() // 트랜잭션 지원
            .build();
    }
    
    // 캐시 키 생성 전략 커스터마이징
    @Bean
    public KeyGenerator cacheKeyGenerator() {
        return new KeyGenerator() {
            @Override
            public Object generate(Object target, Method method, Object... params) {
                StringBuilder sb = new StringBuilder();
                sb.append(target.getClass().getSimpleName());
                sb.append(".");
                sb.append(method.getName());
                sb.append("(");
                for (int i = 0; i < params.length; i++) {
                    if (i > 0) sb.append(",");
                    sb.append(params[i] != null ? params[i].toString() : "null");
                }
                sb.append(")");
                return sb.toString();
            }
        };
    }
    
    // 캐시 이벤트 리스너
    @EventListener
    public void handleCacheHitEvent(CacheHitEvent event) {
        log.debug("Cache hit: cache={}, key={}", 
                 event.getCacheName(), event.getKey());
    }
    
    @EventListener
    public void handleCacheMissEvent(CacheMissEvent event) {
        log.debug("Cache miss: cache={}, key={}", 
                 event.getCacheName(), event.getKey());
    }
    
    @EventListener
    public void handleCacheEvictEvent(CacheEvictEvent event) {
        log.debug("Cache evict: cache={}, key={}", 
                 event.getCacheName(), event.getKey());
    }
}

프로덕션 환경 설정

# application-prod.yml
spring:
  redis:
    cluster:
      nodes:
        - redis-node1:6379
        - redis-node2:6379
        - redis-node3:6379
      max-redirects: 3
    password: ${REDIS_PASSWORD}
    timeout: 3000ms
    
    lettuce:
      pool:
        max-active: 20
        max-idle: 10
        min-idle: 5
        max-wait: 3000ms
      cluster:
        refresh:
          adaptive: true
          period: 30s
          
  cache:
    redis:
      time-to-live: 600000
      cache-null-values: false
      use-key-prefix: true
      key-prefix: "${spring.application.name}:"
      
# Redis Sentinel 설정 (고가용성)
# spring:
#   redis:
#     sentinel:
#       master: mymaster
#       nodes:
#         - sentinel1:26379
#         - sentinel2:26379
#         - sentinel3:26379
#     password: ${REDIS_PASSWORD}

4. 캐시 전략

4.1 Cache-Aside 패턴

Cache-Aside는 가장 일반적인 캐시 패턴으로, 애플리케이션이 캐시를 직접 관리합니다. Spring의 @Cacheable이 이 패턴을 구현합니다.

Cache-Aside 동작 원리

읽기 과정
  1. 1. 캐시에서 데이터 조회
  2. 2. 캐시 히트: 데이터 반환
  3. 3. 캐시 미스: DB에서 조회
  4. 4. 조회된 데이터를 캐시에 저장
  5. 5. 데이터 반환
쓰기 과정
  1. 1. DB에 데이터 저장
  2. 2. 캐시에서 해당 데이터 제거
  3. 3. 다음 읽기 시 캐시 미스 발생
  4. 4. DB에서 최신 데이터 조회
  5. 5. 캐시에 최신 데이터 저장

Cache-Aside 구현

@Service
public class UserService {
    
    @Autowired
    private UserRepository userRepository;
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    // Spring Cache Abstraction 사용
    @Cacheable(value = "users", key = "#id")
    public User findById(Long id) {
        log.info("DB에서 사용자 조회: {}", id);
        return userRepository.findById(id).orElse(null);
    }
    
    @CacheEvict(value = "users", key = "#user.id")
    public User updateUser(User user) {
        return userRepository.save(user);
    }
    
    // 수동 Cache-Aside 구현
    public User findByIdManual(Long id) {
        String cacheKey = "user:" + id;
        
        // 1. 캐시에서 조회
        User cachedUser = (User) redisTemplate.opsForValue().get(cacheKey);
        if (cachedUser != null) {
            log.info("캐시에서 사용자 조회: {}", id);
            return cachedUser;
        }
        
        // 2. 캐시 미스 - DB에서 조회
        log.info("DB에서 사용자 조회: {}", id);
        User user = userRepository.findById(id).orElse(null);
        
        // 3. 캐시에 저장
        if (user != null) {
            redisTemplate.opsForValue().set(cacheKey, user, 
                Duration.ofMinutes(30));
        }
        
        return user;
    }
    
    public User updateUserManual(User user) {
        // 1. DB 업데이트
        User updated = userRepository.save(user);
        
        // 2. 캐시에서 제거
        String cacheKey = "user:" + user.getId();
        redisTemplate.delete(cacheKey);
        
        return updated;
    }
}

장점

  • • 구현이 간단함
  • • 캐시 장애 시에도 서비스 가능
  • • 데이터 일관성 문제 최소화
  • • 필요한 데이터만 캐시에 저장

단점

  • • 캐시 미스 시 지연 발생
  • • 초기 로딩 시 성능 저하
  • • 캐시와 DB 간 일시적 불일치
  • • 애플리케이션 복잡도 증가

4.2 Write-Through 패턴

Write-Through는 데이터를 쓸 때 캐시와 데이터베이스에 동시에 저장하는 패턴입니다. 데이터 일관성을 보장하지만 쓰기 성능이 저하됩니다.

Write-Through 구현

@Service
public class WriteThroughUserService {
    
    @Autowired
    private UserRepository userRepository;
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Transactional
    public User createUser(User user) {
        // 1. DB에 저장
        User savedUser = userRepository.save(user);
        
        // 2. 캐시에도 동시 저장
        String cacheKey = "user:" + savedUser.getId();
        redisTemplate.opsForValue().set(cacheKey, savedUser, 
            Duration.ofMinutes(30));
        
        log.info("사용자 생성 및 캐시 저장: {}", savedUser.getId());
        return savedUser;
    }
    
    @Transactional
    public User updateUser(User user) {
        try {
            // 1. DB 업데이트
            User updatedUser = userRepository.save(user);
            
            // 2. 캐시 업데이트
            String cacheKey = "user:" + user.getId();
            redisTemplate.opsForValue().set(cacheKey, updatedUser, 
                Duration.ofMinutes(30));
            
            return updatedUser;
        } catch (Exception e) {
            // 캐시 업데이트 실패 시 롤백
            log.error("사용자 업데이트 실패: {}", user.getId(), e);
            throw e;
        }
    }
    
    // Spring Cache Abstraction으로 Write-Through 구현
    @CachePut(value = "users", key = "#user.id")
    @Transactional
    public User updateUserWithCache(User user) {
        return userRepository.save(user);
    }
    
    // 배치 Write-Through
    @Transactional
    public List<User> createUsers(List<User> users) {
        List<User> savedUsers = userRepository.saveAll(users);
        
        // 캐시에 배치 저장
        Map<String, Object> cacheData = new HashMap<>();
        for (User user : savedUsers) {
            cacheData.put("user:" + user.getId(), user);
        }
        
        redisTemplate.opsForValue().multiSet(cacheData);
        
        // TTL 설정
        for (String key : cacheData.keySet()) {
            redisTemplate.expire(key, Duration.ofMinutes(30));
        }
        
        return savedUsers;
    }
}

Write-Through with 에러 처리

@Service
public class RobustWriteThroughService {
    
    @Autowired
    private UserRepository userRepository;
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Retryable(value = {Exception.class}, maxAttempts = 3)
    @Transactional
    public User updateUserSafely(User user) {
        User updatedUser = null;
        String cacheKey = "user:" + user.getId();
        
        try {
            // 1. DB 업데이트
            updatedUser = userRepository.save(user);
            
            // 2. 캐시 업데이트 시도
            redisTemplate.opsForValue().set(cacheKey, updatedUser, 
                Duration.ofMinutes(30));
                
            log.info("사용자 업데이트 및 캐시 동기화 완료: {}", user.getId());
            
        } catch (DataAccessException e) {
            // DB 오류 시 전체 롤백
            log.error("DB 업데이트 실패: {}", user.getId(), e);
            throw e;
            
        } catch (Exception e) {
            // 캐시 오류 시 캐시만 제거하고 DB는 유지
            log.warn("캐시 업데이트 실패, 캐시 제거: {}", user.getId(), e);
            try {
                redisTemplate.delete(cacheKey);
            } catch (Exception cacheDeleteError) {
                log.error("캐시 제거도 실패: {}", cacheKey, cacheDeleteError);
            }
        }
        
        return updatedUser;
    }
    
    @Recover
    public User recoverUpdateUser(Exception ex, User user) {
        log.error("사용자 업데이트 최종 실패: {}", user.getId(), ex);
        // 캐시 제거하여 다음 조회 시 DB에서 최신 데이터 가져오도록 함
        redisTemplate.delete("user:" + user.getId());
        throw new ServiceException("사용자 업데이트에 실패했습니다.", ex);
    }
}

4.3 Write-Behind (Write-Back) 패턴

Write-Behind는 캐시에만 먼저 쓰고, 나중에 비동기적으로 데이터베이스에 저장하는 패턴입니다. 높은 쓰기 성능을 제공하지만 데이터 손실 위험이 있습니다.

Write-Behind 구현

@Service
public class WriteBehindService {
    
    @Autowired
    private UserRepository userRepository;
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private TaskExecutor taskExecutor;
    
    private final Queue<User> writeQueue = new ConcurrentLinkedQueue<>();
    
    // 즉시 캐시에 저장, DB는 비동기 처리
    public User updateUserAsync(User user) {
        String cacheKey = "user:" + user.getId();
        
        // 1. 캐시에 즉시 저장
        redisTemplate.opsForValue().set(cacheKey, user, Duration.ofMinutes(30));
        
        // 2. 쓰기 큐에 추가
        writeQueue.offer(user);
        
        // 3. 비동기 DB 업데이트 스케줄링
        taskExecutor.execute(() -> {
            try {
                Thread.sleep(100); // 배치 처리를 위한 지연
                flushWriteQueue();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
        
        return user;
    }
    
    // 큐에 쌓인 데이터를 배치로 DB에 저장
    @Async
    public void flushWriteQueue() {
        List<User> usersToWrite = new ArrayList<>();
        User user;
        
        // 큐에서 사용자들을 가져옴
        while ((user = writeQueue.poll()) != null && usersToWrite.size() < 100) {
            usersToWrite.add(user);
        }
        
        if (!usersToWrite.isEmpty()) {
            try {
                userRepository.saveAll(usersToWrite);
                log.info("배치 DB 업데이트 완료: {} 건", usersToWrite.size());
            } catch (Exception e) {
                log.error("배치 DB 업데이트 실패", e);
                // 실패한 데이터를 다시 큐에 추가
                writeQueue.addAll(usersToWrite);
            }
        }
    }
    
    // 스케줄링을 통한 주기적 플러시
    @Scheduled(fixedDelay = 5000) // 5초마다
    public void scheduledFlush() {
        if (!writeQueue.isEmpty()) {
            flushWriteQueue();
        }
    }
    
    // 애플리케이션 종료 시 남은 데이터 처리
    @PreDestroy
    public void shutdown() {
        log.info("애플리케이션 종료 - 남은 쓰기 작업 처리");
        while (!writeQueue.isEmpty()) {
            flushWriteQueue();
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            }
        }
    }
}

고급 Write-Behind with Redis Streams

@Service
public class RedisStreamWriteBehindService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private UserRepository userRepository;
    
    private static final String STREAM_KEY = "user-updates";
    private static final String CONSUMER_GROUP = "db-writers";
    
    @PostConstruct
    public void initializeConsumerGroup() {
        try {
            redisTemplate.opsForStream().createGroup(STREAM_KEY, CONSUMER_GROUP);
        } catch (Exception e) {
            // 그룹이 이미 존재하는 경우 무시
        }
    }
    
    public User updateUserWithStream(User user) {
        String cacheKey = "user:" + user.getId();
        
        // 1. 캐시에 즉시 저장
        redisTemplate.opsForValue().set(cacheKey, user, Duration.ofMinutes(30));
        
        // 2. Redis Stream에 업데이트 이벤트 추가
        Map<String, Object> streamData = new HashMap<>();
        streamData.put("userId", user.getId());
        streamData.put("action", "update");
        streamData.put("data", user);
        streamData.put("timestamp", System.currentTimeMillis());
        
        redisTemplate.opsForStream().add(STREAM_KEY, streamData);
        
        return user;
    }
    
    // Stream Consumer로 DB 업데이트 처리
    @Async
    @Scheduled(fixedDelay = 1000)
    public void processStreamUpdates() {
        try {
            List<MapRecord<String, Object, Object>> records = 
                redisTemplate.opsForStream().read(
                    Consumer.from(CONSUMER_GROUP, "worker-1"),
                    StreamReadOptions.empty().count(10).block(Duration.ofSeconds(1)),
                    StreamOffset.create(STREAM_KEY, ReadOffset.lastConsumed())
                );
            
            for (MapRecord<String, Object, Object> record : records) {
                try {
                    processStreamRecord(record);
                    // 처리 완료 후 ACK
                    redisTemplate.opsForStream().acknowledge(STREAM_KEY, CONSUMER_GROUP, 
                        record.getId());
                } catch (Exception e) {
                    log.error("Stream record 처리 실패: {}", record.getId(), e);
                }
            }
        } catch (Exception e) {
            log.error("Stream 읽기 실패", e);
        }
    }
    
    private void processStreamRecord(MapRecord<String, Object, Object> record) {
        Map<Object, Object> data = record.getValue();
        String action = (String) data.get("action");
        Long userId = (Long) data.get("userId");
        
        if ("update".equals(action)) {
            User user = (User) data.get("data");
            userRepository.save(user);
            log.debug("DB 업데이트 완료: userId={}", userId);
        }
    }
}

4.4 TTL (Time To Live) 설정

TTL은 캐시 데이터의 생존 시간을 설정하여 자동으로 만료시키는 기능입니다. 데이터의 특성에 따라 적절한 TTL을 설정해야 합니다.

TTL 설정 전략

@Service
public class TTLCacheService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    // 데이터 특성별 TTL 설정
    public void cacheUserProfile(User user) {
        String key = "user:profile:" + user.getId();
        // 사용자 프로필: 1시간 TTL
        redisTemplate.opsForValue().set(key, user, Duration.ofHours(1));
    }
    
    public void cacheProductPrice(Long productId, BigDecimal price) {
        String key = "product:price:" + productId;
        // 상품 가격: 10분 TTL (자주 변경됨)
        redisTemplate.opsForValue().set(key, price, Duration.ofMinutes(10));
    }
    
    public void cacheSessionData(String sessionId, Object sessionData) {
        String key = "session:" + sessionId;
        // 세션 데이터: 30분 TTL
        redisTemplate.opsForValue().set(key, sessionData, Duration.ofMinutes(30));
    }
    
    public void cacheStatistics(String key, Object stats) {
        // 통계 데이터: 1일 TTL
        redisTemplate.opsForValue().set("stats:" + key, stats, Duration.ofDays(1));
    }
    
    // 동적 TTL 설정
    public void cacheWithDynamicTTL(String key, Object value, CacheType type) {
        Duration ttl = calculateTTL(type);
        redisTemplate.opsForValue().set(key, value, ttl);
    }
    
    private Duration calculateTTL(CacheType type) {
        switch (type) {
            case REAL_TIME:
                return Duration.ofMinutes(1);
            case FREQUENT_UPDATE:
                return Duration.ofMinutes(10);
            case MODERATE_UPDATE:
                return Duration.ofHours(1);
            case STATIC_DATA:
                return Duration.ofDays(1);
            default:
                return Duration.ofMinutes(30);
        }
    }
    
    // TTL 연장
    public boolean extendTTL(String key, Duration additionalTime) {
        Long currentTTL = redisTemplate.getExpire(key, TimeUnit.SECONDS);
        if (currentTTL != null && currentTTL > 0) {
            Duration newTTL = Duration.ofSeconds(currentTTL).plus(additionalTime);
            return redisTemplate.expire(key, newTTL);
        }
        return false;
    }
    
    // 조건부 TTL 갱신
    public void refreshTTLIfNeeded(String key, Duration threshold, Duration newTTL) {
        Long remainingTTL = redisTemplate.getExpire(key, TimeUnit.SECONDS);
        if (remainingTTL != null && remainingTTL < threshold.getSeconds()) {
            redisTemplate.expire(key, newTTL);
            log.debug("TTL 갱신: key={}, newTTL={}", key, newTTL);
        }
    }
}

Spring Cache with TTL

@Configuration
public class TTLCacheConfig {
    
    @Bean
    public CacheManager ttlCacheManager(RedisConnectionFactory factory) {
        Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
        
        // 캐시별 개별 TTL 설정
        cacheConfigurations.put("users", createCacheConfig(Duration.ofHours(1)));
        cacheConfigurations.put("products", createCacheConfig(Duration.ofMinutes(30)));
        cacheConfigurations.put("categories", createCacheConfig(Duration.ofHours(6)));
        cacheConfigurations.put("statistics", createCacheConfig(Duration.ofMinutes(5)));
        cacheConfigurations.put("sessions", createCacheConfig(Duration.ofMinutes(30)));
        
        return RedisCacheManager.builder(factory)
            .cacheDefaults(createCacheConfig(Duration.ofMinutes(10)))
            .withInitialCacheConfigurations(cacheConfigurations)
            .build();
    }
    
    private RedisCacheConfiguration createCacheConfig(Duration ttl) {
        return RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(ttl)
            .serializeKeysWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new GenericJackson2JsonRedisSerializer()))
            .disableCachingNullValues();
    }
}

@Service
public class TTLAwareCacheService {
    
    // 짧은 TTL - 실시간 데이터
    @Cacheable(value = "real-time-data", key = "#id")
    public RealTimeData getRealTimeData(Long id) {
        return realTimeDataRepository.findById(id);
    }
    
    // 중간 TTL - 일반 데이터
    @Cacheable(value = "products", key = "#id")
    public Product getProduct(Long id) {
        return productRepository.findById(id);
    }
    
    // 긴 TTL - 정적 데이터
    @Cacheable(value = "categories", key = "#id")
    public Category getCategory(Long id) {
        return categoryRepository.findById(id);
    }
    
    // 조건부 TTL
    @Cacheable(value = "conditional-cache", key = "#id", 
               condition = "#useCache == true")
    public Data getDataConditional(Long id, boolean useCache) {
        return dataRepository.findById(id);
    }
}

TTL 모니터링 및 최적화

@Component
public class TTLMonitoringService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private MeterRegistry meterRegistry;
    
    // TTL 통계 수집
    @Scheduled(fixedRate = 60000) // 1분마다
    public void collectTTLStatistics() {
        Set<String> keys = redisTemplate.keys("*");
        if (keys != null) {
            Map<String, List<Long>> ttlByPrefix = new HashMap<>();
            
            for (String key : keys) {
                Long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
                if (ttl != null && ttl > 0) {
                    String prefix = extractPrefix(key);
                    ttlByPrefix.computeIfAbsent(prefix, k -> new ArrayList<>()).add(ttl);
                }
            }
            
            // 메트릭 등록
            ttlByPrefix.forEach((prefix, ttls) -> {
                double avgTTL = ttls.stream().mapToLong(Long::longValue).average().orElse(0);
                meterRegistry.gauge("cache.ttl.average", Tags.of("prefix", prefix), avgTTL);
            });
        }
    }
    
    private String extractPrefix(String key) {
        int colonIndex = key.indexOf(':');
        return colonIndex > 0 ? key.substring(0, colonIndex) : "unknown";
    }
    
    // TTL 최적화 제안
    public void suggestTTLOptimization() {
        // 캐시 히트율과 TTL 상관관계 분석
        // 너무 짧은 TTL로 인한 불필요한 DB 조회 감지
        // 너무 긴 TTL로 인한 메모리 낭비 감지
    }
}

5. 실전 캐싱 패턴

5.1 조회 캐싱 패턴

단일 엔티티 조회는 가장 기본적인 캐싱 패턴입니다. ID 기반 조회, 복합 키 조회, 조건부 조회 등 다양한 시나리오를 다룹니다.

기본 엔티티 조회 캐싱

@Service
public class UserCacheService {
    
    @Autowired
    private UserRepository userRepository;
    
    // 기본 ID 조회
    @Cacheable(value = "users", key = "#id")
    public User findById(Long id) {
        return userRepository.findById(id).orElse(null);
    }
    
    // 이메일로 조회
    @Cacheable(value = "users-by-email", key = "#email")
    public User findByEmail(String email) {
        return userRepository.findByEmail(email);
    }
    
    // 복합 조건 조회
    @Cacheable(value = "users", key = "#email + '_' + #status")
    public User findByEmailAndStatus(String email, UserStatus status) {
        return userRepository.findByEmailAndStatus(email, status);
    }
    
    // 조건부 캐싱 (활성 사용자만)
    @Cacheable(value = "active-users", key = "#id", 
               condition = "#includeInactive == false")
    public User findActiveUser(Long id, boolean includeInactive) {
        if (includeInactive) {
            return userRepository.findById(id).orElse(null);
        }
        return userRepository.findByIdAndActiveTrue(id);
    }
    
    // null 값 캐싱 방지
    @Cacheable(value = "users", key = "#id", unless = "#result == null")
    public User findByIdNotNull(Long id) {
        return userRepository.findById(id).orElse(null);
    }
}

고급 조회 캐싱

@Service
public class AdvancedQueryCacheService {
    
    // 연관 엔티티 포함 조회
    @Cacheable(value = "user-with-profile", key = "#id")
    public UserWithProfile findUserWithProfile(Long id) {
        User user = userRepository.findById(id).orElse(null);
        if (user == null) return null;
        
        UserProfile profile = profileRepository.findByUserId(id);
        return new UserWithProfile(user, profile);
    }
    
    // 계층적 캐싱 (L1: 로컬, L2: Redis)
    @Cacheable(value = {"local-users", "redis-users"}, key = "#id")
    public User findUserMultiLevel(Long id) {
        return userRepository.findById(id).orElse(null);
    }
    
    // 캐시 워밍업을 위한 배치 조회
    @PostConstruct
    public void warmUpCache() {
        List<Long> popularUserIds = userRepository.findPopularUserIds();
        for (Long id : popularUserIds) {
            findById(id); // 캐시에 미리 로드
        }
    }
    
    // 캐시 미스 시 대체 데이터 제공
    @Cacheable(value = "users", key = "#id")
    public User findUserWithFallback(Long id) {
        User user = userRepository.findById(id).orElse(null);
        if (user == null) {
            // 기본 사용자 객체 반환
            return createDefaultUser(id);
        }
        return user;
    }
    
    private User createDefaultUser(Long id) {
        User defaultUser = new User();
        defaultUser.setId(id);
        defaultUser.setName("Unknown User");
        defaultUser.setActive(false);
        return defaultUser;
    }
}

5.2 목록 캐싱 패턴

목록 데이터는 페이징, 정렬, 필터링 등 복잡한 조건을 가지므로 적절한 키 생성 전략과 캐시 무효화 전략이 필요합니다.

페이징 목록 캐싱

@Service
public class ListCacheService {
    
    // 기본 페이징 목록
    @Cacheable(value = "user-lists", 
               key = "'page_' + #page + '_size_' + #size + '_sort_' + #sort")
    public Page<User> findUsers(int page, int size, String sort) {
        Sort sortObj = Sort.by(Sort.Direction.ASC, sort);
        Pageable pageable = PageRequest.of(page, size, sortObj);
        return userRepository.findAll(pageable);
    }
    
    // 필터링된 목록
    @Cacheable(value = "filtered-users", 
               key = "#filter.hashCode() + '_' + #page + '_' + #size")
    public Page<User> findFilteredUsers(UserFilter filter, int page, int size) {
        Pageable pageable = PageRequest.of(page, size);
        return userRepository.findByFilter(filter, pageable);
    }
    
    // 카테고리별 상품 목록
    @Cacheable(value = "products-by-category", 
               key = "#categoryId + '_' + #page + '_' + #size + '_' + #sortBy")
    public Page<Product> findProductsByCategory(Long categoryId, int page, 
                                               int size, String sortBy) {
        Sort sort = Sort.by(Sort.Direction.ASC, sortBy);
        Pageable pageable = PageRequest.of(page, size, sort);
        return productRepository.findByCategoryId(categoryId, pageable);
    }
    
    // 검색 결과 캐싱
    @Cacheable(value = "search-results", 
               key = "#keyword + '_' + #page + '_' + #size",
               condition = "#keyword.length() >= 3")
    public Page<Product> searchProducts(String keyword, int page, int size) {
        Pageable pageable = PageRequest.of(page, size);
        return productRepository.findByNameContaining(keyword, pageable);
    }
}

목록 캐시 무효화

@Service
public class ListCacheEvictionService {
    
    // 새 사용자 생성 시 관련 목록 캐시 제거
    @Caching(evict = {
        @CacheEvict(value = "user-lists", allEntries = true),
        @CacheEvict(value = "user-statistics", allEntries = true)
    })
    public User createUser(User user) {
        return userRepository.save(user);
    }
    
    // 상품 업데이트 시 카테고리별 목록 캐시 제거
    @CacheEvict(value = "products-by-category", key = "#product.categoryId + '_*'")
    public Product updateProduct(Product product) {
        return productRepository.save(product);
    }
    
    // 패턴 기반 캐시 제거 (커스텀 구현)
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    public void evictCacheByPattern(String pattern) {
        Set<String> keys = redisTemplate.keys(pattern);
        if (keys != null && !keys.isEmpty()) {
            redisTemplate.delete(keys);
            log.info("패턴 기반 캐시 제거: {} ({}개 키)", pattern, keys.size());
        }
    }
    
    // 카테고리 변경 시 관련 상품 목록 캐시 모두 제거
    public void updateProductCategory(Long productId, Long newCategoryId) {
        Product product = productRepository.findById(productId).orElseThrow();
        Long oldCategoryId = product.getCategoryId();
        
        product.setCategoryId(newCategoryId);
        productRepository.save(product);
        
        // 이전 카테고리와 새 카테고리의 목록 캐시 제거
        evictCacheByPattern("products-by-category::" + oldCategoryId + "_*");
        evictCacheByPattern("products-by-category::" + newCategoryId + "_*");
    }
}

5.3 카운터 캐싱 패턴

조회수, 좋아요 수, 재고 수량 등 자주 변경되는 카운터 데이터는 특별한 캐싱 전략이 필요합니다.

조회수 카운터

@Service
public class ViewCounterService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private ProductRepository productRepository;
    
    // 조회수 증가 (Redis 카운터 사용)
    public void incrementViewCount(Long productId) {
        String key = "product:views:" + productId;
        redisTemplate.opsForValue().increment(key);
        
        // TTL 설정 (1일)
        redisTemplate.expire(key, Duration.ofDays(1));
    }
    
    // 조회수 조회 (캐시 + DB 합산)
    public long getViewCount(Long productId) {
        String key = "product:views:" + productId;
        
        // Redis에서 증분 조회수 가져오기
        Long incrementalViews = (Long) redisTemplate.opsForValue().get(key);
        if (incrementalViews == null) {
            incrementalViews = 0L;
        }
        
        // DB에서 기본 조회수 가져오기
        Product product = productRepository.findById(productId).orElse(null);
        long baseViews = product != null ? product.getViewCount() : 0L;
        
        return baseViews + incrementalViews;
    }
    
    // 주기적으로 Redis 카운터를 DB에 반영
    @Scheduled(fixedRate = 300000) // 5분마다
    @Transactional
    public void syncViewCountsToDB() {
        Set<String> keys = redisTemplate.keys("product:views:*");
        if (keys == null || keys.isEmpty()) {
            return;
        }
        
        for (String key : keys) {
            try {
                Long productId = Long.parseLong(key.substring("product:views:".length()));
                Long incrementalViews = (Long) redisTemplate.opsForValue().get(key);
                
                if (incrementalViews != null && incrementalViews > 0) {
                    // DB 업데이트
                    productRepository.incrementViewCount(productId, incrementalViews);
                    
                    // Redis 카운터 리셋
                    redisTemplate.delete(key);
                    
                    log.debug("조회수 동기화: productId={}, views={}", 
                             productId, incrementalViews);
                }
            } catch (Exception e) {
                log.error("조회수 동기화 실패: key={}", key, e);
            }
        }
    }
}

좋아요/평점 카운터

@Service
public class LikeCounterService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    // 좋아요 추가/제거
    public boolean toggleLike(Long userId, Long productId) {
        String userLikeKey = "user:likes:" + userId;
        String productLikeKey = "product:likes:" + productId;
        
        // 사용자가 이미 좋아요를 눌렀는지 확인
        Boolean isLiked = redisTemplate.opsForSet().isMember(userLikeKey, productId);
        
        if (Boolean.TRUE.equals(isLiked)) {
            // 좋아요 제거
            redisTemplate.opsForSet().remove(userLikeKey, productId);
            redisTemplate.opsForValue().decrement(productLikeKey);
            return false;
        } else {
            // 좋아요 추가
            redisTemplate.opsForSet().add(userLikeKey, productId);
            redisTemplate.opsForValue().increment(productLikeKey);
            
            // TTL 설정
            redisTemplate.expire(userLikeKey, Duration.ofDays(30));
            redisTemplate.expire(productLikeKey, Duration.ofDays(30));
            
            return true;
        }
    }
    
    // 상품의 총 좋아요 수 조회
    public long getLikeCount(Long productId) {
        String key = "product:likes:" + productId;
        Long count = (Long) redisTemplate.opsForValue().get(key);
        return count != null ? count : 0L;
    }
    
    // 사용자가 좋아요한 상품 목록
    public Set<Long> getUserLikedProducts(Long userId) {
        String key = "user:likes:" + userId;
        Set<Object> likedProducts = redisTemplate.opsForSet().members(key);
        
        return likedProducts != null ? 
            likedProducts.stream()
                .map(obj -> (Long) obj)
                .collect(Collectors.toSet()) : 
            Collections.emptySet();
    }
    
    // 인기 상품 랭킹 (Sorted Set 사용)
    public void updatePopularityRanking(Long productId, double score) {
        String key = "popular:products";
        redisTemplate.opsForZSet().add(key, productId, score);
        redisTemplate.expire(key, Duration.ofHours(1));
    }
    
    public List<Long> getPopularProducts(int limit) {
        String key = "popular:products";
        Set<Object> products = redisTemplate.opsForZSet()
            .reverseRange(key, 0, limit - 1);
        
        return products != null ?
            products.stream()
                .map(obj -> (Long) obj)
                .collect(Collectors.toList()) :
            Collections.emptyList();
    }
}

재고 카운터 (동시성 처리)

@Service
public class StockCounterService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private ProductRepository productRepository;
    
    // 재고 차감 (원자적 연산)
    public boolean decreaseStock(Long productId, int quantity) {
        String key = "product:stock:" + productId;
        
        // Lua 스크립트로 원자적 재고 차감
        String luaScript = 
            "local current = redis.call('GET', KEYS[1]) " +
            "if current == false then " +
            "  return -1 " +
            "end " +
            "current = tonumber(current) " +
            "if current >= tonumber(ARGV[1]) then " +
            "  redis.call('DECRBY', KEYS[1], ARGV[1]) " +
            "  return current - tonumber(ARGV[1]) " +
            "else " +
            "  return -1 " +
            "end";
        
        DefaultRedisScript<Long> script = new DefaultRedisScript<>();
        script.setScriptText(luaScript);
        script.setResultType(Long.class);
        
        Long result = redisTemplate.execute(script, 
            Collections.singletonList(key), quantity);
        
        return result != null && result >= 0;
    }
    
    // 재고 조회
    public int getStock(Long productId) {
        String key = "product:stock:" + productId;
        Long stock = (Long) redisTemplate.opsForValue().get(key);
        
        if (stock == null) {
            // 캐시 미스 시 DB에서 로드
            Product product = productRepository.findById(productId).orElse(null);
            if (product != null) {
                stock = (long) product.getStock();
                redisTemplate.opsForValue().set(key, stock, Duration.ofMinutes(10));
            } else {
                stock = 0L;
            }
        }
        
        return stock.intValue();
    }
    
    // 재고 보충
    public void increaseStock(Long productId, int quantity) {
        String key = "product:stock:" + productId;
        redisTemplate.opsForValue().increment(key, quantity);
        redisTemplate.expire(key, Duration.ofMinutes(10));
    }
    
    // 재고 동기화 (DB ↔ Redis)
    @Scheduled(fixedRate = 60000) // 1분마다
    @Transactional
    public void syncStockToDB() {
        Set<String> keys = redisTemplate.keys("product:stock:*");
        if (keys == null || keys.isEmpty()) {
            return;
        }
        
        for (String key : keys) {
            try {
                Long productId = Long.parseLong(key.substring("product:stock:".length()));
                Long redisStock = (Long) redisTemplate.opsForValue().get(key);
                
                if (redisStock != null) {
                    productRepository.updateStock(productId, redisStock.intValue());
                }
            } catch (Exception e) {
                log.error("재고 동기화 실패: key={}", key, e);
            }
        }
    }
}

6. 캐시 문제 해결

6.1 캐시 스탬피드 (Cache Stampede)

캐시 스탬피드는 캐시가 만료될 때 동시에 여러 요청이 들어와서 모두 DB에 접근하는 문제입니다. 이를 해결하는 다양한 방법을 알아봅시다.

문제 상황

// 문제가 되는 코드
@Cacheable(value = "popular-products", key = "'top10'")
public List<Product> getTop10Products() {
    // 복잡한 쿼리 (5초 소요)
    return productRepository.findTop10ByOrderByPopularityDesc();
}

// 캐시 만료 시점에 100개 요청이 동시에 들어오면
// 모든 요청이 DB에 접근하여 시스템 과부하 발생

해결책 1: 분산 락 사용

@Service
public class StampedePreventionService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private ProductRepository productRepository;
    
    public List<Product> getTop10ProductsWithLock() {
        String cacheKey = "popular-products:top10";
        String lockKey = "lock:" + cacheKey;
        
        // 1. 캐시에서 조회 시도
        List<Product> cached = (List<Product>) redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) {
            return cached;
        }
        
        // 2. 분산 락 획득 시도
        String lockValue = UUID.randomUUID().toString();
        Boolean lockAcquired = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, lockValue, Duration.ofSeconds(10));
        
        if (Boolean.TRUE.equals(lockAcquired)) {
            try {
                // 3. 락을 획득한 스레드만 DB 조회
                cached = (List<Product>) redisTemplate.opsForValue().get(cacheKey);
                if (cached == null) {
                    log.info("DB에서 인기 상품 조회 시작");
                    cached = productRepository.findTop10ByOrderByPopularityDesc();
                    
                    // 캐시에 저장 (5분 TTL)
                    redisTemplate.opsForValue().set(cacheKey, cached, Duration.ofMinutes(5));
                }
                return cached;
            } finally {
                // 4. 락 해제 (Lua 스크립트로 안전하게)
                releaseLock(lockKey, lockValue);
            }
        } else {
            // 5. 락을 획득하지 못한 스레드는 잠시 대기 후 재시도
            try {
                Thread.sleep(100);
                return getTop10ProductsWithLock(); // 재귀 호출
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                throw new RuntimeException("캐시 조회 중 인터럽트 발생", e);
            }
        }
    }
    
    private void releaseLock(String lockKey, String lockValue) {
        String luaScript = 
            "if redis.call('GET', KEYS[1]) == ARGV[1] then " +
            "  return redis.call('DEL', KEYS[1]) " +
            "else " +
            "  return 0 " +
            "end";
        
        DefaultRedisScript<Long> script = new DefaultRedisScript<>();
        script.setScriptText(luaScript);
        script.setResultType(Long.class);
        
        redisTemplate.execute(script, Collections.singletonList(lockKey), lockValue);
    }
}

해결책 2: 확률적 조기 만료

@Service
public class ProbabilisticEarlyExpirationService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    public List<Product> getTop10ProductsWithProbabilisticRefresh() {
        String cacheKey = "popular-products:top10";
        
        // 캐시 데이터와 메타데이터 조회
        CacheData<List<Product>> cacheData = getCacheWithMetadata(cacheKey);
        
        if (cacheData != null) {
            // 확률적 조기 만료 계산
            long currentTime = System.currentTimeMillis();
            long age = currentTime - cacheData.getCreatedAt();
            long ttl = cacheData.getTtl();
            
            // 만료 시간의 80%가 지나면 확률적으로 갱신
            if (age > ttl * 0.8) {
                double refreshProbability = (double) age / ttl - 0.8;
                if (Math.random() < refreshProbability) {
                    // 비동기로 캐시 갱신
                    refreshCacheAsync(cacheKey);
                }
            }
            
            return cacheData.getData();
        }
        
        // 캐시 미스 시 동기 조회
        return refreshCacheSync(cacheKey);
    }
    
    @Async
    public void refreshCacheAsync(String cacheKey) {
        try {
            List<Product> freshData = productRepository.findTop10ByOrderByPopularityDesc();
            CacheData<List<Product>> cacheData = new CacheData<>(
                freshData, System.currentTimeMillis(), Duration.ofMinutes(5).toMillis()
            );
            redisTemplate.opsForValue().set(cacheKey, cacheData, Duration.ofMinutes(5));
            log.info("비동기 캐시 갱신 완료: {}", cacheKey);
        } catch (Exception e) {
            log.error("비동기 캐시 갱신 실패: {}", cacheKey, e);
        }
    }
    
    private List<Product> refreshCacheSync(String cacheKey) {
        List<Product> freshData = productRepository.findTop10ByOrderByPopularityDesc();
        CacheData<List<Product>> cacheData = new CacheData<>(
            freshData, System.currentTimeMillis(), Duration.ofMinutes(5).toMillis()
        );
        redisTemplate.opsForValue().set(cacheKey, cacheData, Duration.ofMinutes(5));
        return freshData;
    }
    
    @SuppressWarnings("unchecked")
    private CacheData<List<Product>> getCacheWithMetadata(String key) {
        return (CacheData<List<Product>>) redisTemplate.opsForValue().get(key);
    }
    
    // 캐시 데이터 래퍼 클래스
    public static class CacheData<T> {
        private T data;
        private long createdAt;
        private long ttl;
        
        public CacheData(T data, long createdAt, long ttl) {
            this.data = data;
            this.createdAt = createdAt;
            this.ttl = ttl;
        }
        
        // getters...
        public T getData() { return data; }
        public long getCreatedAt() { return createdAt; }
        public long getTtl() { return ttl; }
    }
}

6.2 캐시 워밍업 (Cache Warming)

캐시 워밍업은 애플리케이션 시작 시나 캐시 만료 전에 미리 데이터를 로드하여 캐시 미스를 방지하는 기법입니다.

애플리케이션 시작 시 워밍업

@Component
public class CacheWarmupService {
    
    @Autowired
    private UserService userService;
    
    @Autowired
    private ProductService productService;
    
    @Autowired
    private CategoryService categoryService;
    
    @EventListener(ApplicationReadyEvent.class)
    @Async
    public void warmupCacheOnStartup() {
        log.info("캐시 워밍업 시작");
        
        try {
            // 1. 인기 사용자 데이터 로드
            warmupPopularUsers();
            
            // 2. 인기 상품 데이터 로드
            warmupPopularProducts();
            
            // 3. 카테고리 데이터 로드
            warmupCategories();
            
            // 4. 설정 데이터 로드
            warmupConfigurations();
            
            log.info("캐시 워밍업 완료");
        } catch (Exception e) {
            log.error("캐시 워밍업 실패", e);
        }
    }
    
    private void warmupPopularUsers() {
        List<Long> popularUserIds = userRepository.findPopularUserIds(100);
        for (Long userId : popularUserIds) {
            try {
                userService.findById(userId); // 캐시에 로드
                Thread.sleep(10); // 부하 분산
            } catch (Exception e) {
                log.warn("사용자 캐시 워밍업 실패: userId={}", userId, e);
            }
        }
        log.info("인기 사용자 캐시 워밍업 완료: {} 건", popularUserIds.size());
    }
    
    private void warmupPopularProducts() {
        List<Long> popularProductIds = productRepository.findPopularProductIds(500);
        
        // 병렬 처리로 성능 향상
        popularProductIds.parallelStream()
            .forEach(productId -> {
                try {
                    productService.findById(productId);
                } catch (Exception e) {
                    log.warn("상품 캐시 워밍업 실패: productId={}", productId, e);
                }
            });
        
        log.info("인기 상품 캐시 워밍업 완료: {} 건", popularProductIds.size());
    }
    
    private void warmupCategories() {
        // 모든 카테고리는 자주 사용되므로 전체 로드
        List<Category> categories = categoryService.findAll();
        log.info("카테고리 캐시 워밍업 완료: {} 건", categories.size());
    }
    
    private void warmupConfigurations() {
        // 시스템 설정 데이터 로드
        configurationService.getAllConfigurations();
        log.info("설정 데이터 캐시 워밍업 완료");
    }
}

스케줄링 기반 워밍업

@Component
public class ScheduledCacheWarmupService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    // 매일 새벽 3시에 캐시 워밍업
    @Scheduled(cron = "0 0 3 * * *")
    public void dailyCacheWarmup() {
        log.info("일일 캐시 워밍업 시작");
        
        // 1. 기존 캐시 정리
        cleanupExpiredCache();
        
        // 2. 새로운 데이터로 워밍업
        warmupDailyData();
        
        log.info("일일 캐시 워밍업 완료");
    }
    
    // 매시간 인기 데이터 워밍업
    @Scheduled(fixedRate = 3600000) // 1시간마다
    public void hourlyPopularDataWarmup() {
        try {
            // 최근 1시간 인기 상품
            List<Long> recentPopularProducts = 
                analyticsService.getRecentPopularProducts(Duration.ofHours(1));
            
            for (Long productId : recentPopularProducts) {
                productService.findById(productId);
            }
            
            log.info("시간별 인기 데이터 워밍업 완료: {} 건", recentPopularProducts.size());
        } catch (Exception e) {
            log.error("시간별 워밍업 실패", e);
        }
    }
    
    // 캐시 만료 전 사전 갱신
    @Scheduled(fixedRate = 60000) // 1분마다
    public void proactiveRefresh() {
        Set<String> keys = redisTemplate.keys("*");
        if (keys == null) return;
        
        for (String key : keys) {
            try {
                Long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
                
                // TTL이 1분 이하로 남았으면 갱신
                if (ttl != null && ttl > 0 && ttl <= 60) {
                    refreshCacheProactively(key);
                }
            } catch (Exception e) {
                log.warn("사전 갱신 실패: key={}", key, e);
            }
        }
    }
    
    @Async
    private void refreshCacheProactively(String key) {
        // 키 패턴에 따라 적절한 서비스 메서드 호출
        if (key.startsWith("users::")) {
            String userId = extractIdFromKey(key);
            userService.findById(Long.parseLong(userId));
        } else if (key.startsWith("products::")) {
            String productId = extractIdFromKey(key);
            productService.findById(Long.parseLong(productId));
        }
        // ... 다른 패턴들
    }
    
    private void cleanupExpiredCache() {
        // 만료된 캐시 키 정리 로직
    }
    
    private void warmupDailyData() {
        // 일일 워밍업 로직
    }
    
    private String extractIdFromKey(String key) {
        return key.substring(key.lastIndexOf("::") + 2);
    }
}

6.3 동시성 문제 해결

캐시 환경에서 발생할 수 있는 동시성 문제들과 해결 방법을 알아봅시다. 특히 분산 환경에서의 데이터 일관성 문제를 다룹니다.

더블 체킹 락 패턴

@Service
public class ConcurrencySafeService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    private final ConcurrentHashMap<String, Object> localLocks = new ConcurrentHashMap<>();
    
    public User getUserSafely(Long userId) {
        String cacheKey = "user:" + userId;
        
        // 1차 캐시 확인
        User user = (User) redisTemplate.opsForValue().get(cacheKey);
        if (user != null) {
            return user;
        }
        
        // 로컬 락 획득
        Object lock = localLocks.computeIfAbsent(cacheKey, k -> new Object());
        
        synchronized (lock) {
            try {
                // 2차 캐시 확인 (더블 체킹)
                user = (User) redisTemplate.opsForValue().get(cacheKey);
                if (user != null) {
                    return user;
                }
                
                // 분산 락 획득
                String lockKey = "lock:" + cacheKey;
                String lockValue = UUID.randomUUID().toString();
                
                Boolean lockAcquired = redisTemplate.opsForValue()
                    .setIfAbsent(lockKey, lockValue, Duration.ofSeconds(5));
                
                if (Boolean.TRUE.equals(lockAcquired)) {
                    try {
                        // 3차 캐시 확인
                        user = (User) redisTemplate.opsForValue().get(cacheKey);
                        if (user == null) {
                            // DB에서 조회
                            user = userRepository.findById(userId).orElse(null);
                            if (user != null) {
                                redisTemplate.opsForValue().set(cacheKey, user, 
                                    Duration.ofMinutes(10));
                            }
                        }
                        return user;
                    } finally {
                        // 분산 락 해제
                        releaseLock(lockKey, lockValue);
                    }
                } else {
                    // 락 획득 실패 시 잠시 대기 후 재시도
                    Thread.sleep(50);
                    return getUserSafely(userId);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                throw new RuntimeException("사용자 조회 중 인터럽트", e);
            } finally {
                // 로컬 락 정리
                localLocks.remove(cacheKey, lock);
            }
        }
    }
}

버전 기반 낙관적 락

@Service
public class OptimisticLockingService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    public boolean updateUserWithOptimisticLock(User user) {
        String cacheKey = "user:" + user.getId();
        String versionKey = "user:version:" + user.getId();
        
        // 현재 버전 확인
        Long currentVersion = (Long) redisTemplate.opsForValue().get(versionKey);
        if (currentVersion == null) {
            currentVersion = 0L;
        }
        
        // 사용자 데이터 업데이트 시 버전도 함께 확인
        String luaScript = 
            "local currentVer = redis.call('GET', KEYS[2]) " +
            "if currentVer == false then currentVer = '0' end " +
            "if tonumber(currentVer) == tonumber(ARGV[2]) then " +
            "  redis.call('SET', KEYS[1], ARGV[1]) " +
            "  redis.call('INCR', KEYS[2]) " +
            "  redis.call('EXPIRE', KEYS[1], 600) " +
            "  redis.call('EXPIRE', KEYS[2], 600) " +
            "  return 1 " +
            "else " +
            "  return 0 " +
            "end";
        
        DefaultRedisScript<Long> script = new DefaultRedisScript<>();
        script.setScriptText(luaScript);
        script.setResultType(Long.class);
        
        Long result = redisTemplate.execute(script, 
            Arrays.asList(cacheKey, versionKey), 
            user, currentVersion);
        
        if (result != null && result == 1L) {
            // DB 업데이트
            userRepository.save(user);
            return true;
        } else {
            log.warn("낙관적 락 충돌 발생: userId={}, expectedVersion={}", 
                    user.getId(), currentVersion);
            return false;
        }
    }
    
    // 재시도 로직 포함
    @Retryable(value = {OptimisticLockingFailureException.class}, maxAttempts = 3)
    public User updateUserWithRetry(User user) {
        boolean success = updateUserWithOptimisticLock(user);
        if (!success) {
            throw new OptimisticLockingFailureException("사용자 업데이트 충돌");
        }
        return user;
    }
    
    @Recover
    public User recoverFromOptimisticLockFailure(OptimisticLockingFailureException ex, User user) {
        log.error("낙관적 락 재시도 실패: userId={}", user.getId());
        throw new ServiceException("사용자 업데이트에 실패했습니다. 다시 시도해주세요.");
    }
}

이벤트 기반 캐시 무효화

@Component
public class EventDrivenCacheInvalidation {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private ApplicationEventPublisher eventPublisher;
    
    // 사용자 업데이트 이벤트 처리
    @EventListener
    @Async
    public void handleUserUpdatedEvent(UserUpdatedEvent event) {
        Long userId = event.getUserId();
        
        // 관련 캐시들 무효화
        List<String> keysToEvict = Arrays.asList(
            "user:" + userId,
            "user:profile:" + userId,
            "user:preferences:" + userId
        );
        
        redisTemplate.delete(keysToEvict);
        
        // 목록 캐시도 무효화
        evictListCaches("user-lists:*");
        
        log.info("사용자 업데이트로 인한 캐시 무효화 완료: userId={}", userId);
    }
    
    // 상품 업데이트 이벤트 처리
    @EventListener
    @Async
    public void handleProductUpdatedEvent(ProductUpdatedEvent event) {
        Long productId = event.getProductId();
        Long categoryId = event.getCategoryId();
        
        // 상품 관련 캐시 무효화
        redisTemplate.delete("product:" + productId);
        
        // 카테고리별 상품 목록 캐시 무효화
        evictListCaches("products-by-category::" + categoryId + "_*");
        
        // 검색 결과 캐시 무효화
        evictListCaches("search-results::*");
        
        log.info("상품 업데이트로 인한 캐시 무효화 완료: productId={}", productId);
    }
    
    private void evictListCaches(String pattern) {
        Set<String> keys = redisTemplate.keys(pattern);
        if (keys != null && !keys.isEmpty()) {
            redisTemplate.delete(keys);
            log.debug("패턴 기반 캐시 무효화: {} ({}개 키)", pattern, keys.size());
        }
    }
    
    // 캐시 무효화 이벤트 발행
    @Service
    public class UserService {
        
        @CachePut(value = "users", key = "#user.id")
        public User updateUser(User user) {
            User updated = userRepository.save(user);
            
            // 이벤트 발행
            eventPublisher.publishEvent(new UserUpdatedEvent(updated.getId()));
            
            return updated;
        }
    }
    
    // 이벤트 클래스들
    public static class UserUpdatedEvent {
        private final Long userId;
        
        public UserUpdatedEvent(Long userId) {
            this.userId = userId;
        }
        
        public Long getUserId() {
            return userId;
        }
    }
    
    public static class ProductUpdatedEvent {
        private final Long productId;
        private final Long categoryId;
        
        public ProductUpdatedEvent(Long productId, Long categoryId) {
            this.productId = productId;
            this.categoryId = categoryId;
        }
        
        public Long getProductId() { return productId; }
        public Long getCategoryId() { return categoryId; }
    }
}

7. 정리

7.1 캐시 설계 가이드

효과적인 캐시 시스템을 설계하기 위한 핵심 원칙과 가이드라인을 정리합니다. 데이터 특성, 접근 패턴, 시스템 요구사항을 고려한 설계가 중요합니다.

캐시 설계 체크리스트

데이터 분석
  • ☐ 읽기/쓰기 비율 분석
  • ☐ 데이터 크기 및 구조 파악
  • ☐ 접근 빈도 패턴 분석
  • ☐ 데이터 변경 주기 확인
  • ☐ 일관성 요구사항 정의
기술적 고려사항
  • ☐ 캐시 계층 구조 설계
  • ☐ 직렬화 방식 선택
  • ☐ TTL 전략 수립
  • ☐ 무효화 전략 계획
  • ☐ 모니터링 방안 수립

데이터 유형별 캐시 전략

// 1. 정적 데이터 (설정, 코드 테이블)
@Configuration
public class StaticDataCacheConfig {
    
    @Bean
    public CacheManager staticDataCacheManager() {
        return RedisCacheManager.builder(redisConnectionFactory())
            .cacheDefaults(RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofHours(24)) // 긴 TTL
                .disableCachingNullValues())
            .build();
    }
}

// 2. 사용자 데이터 (프로필, 설정)
@Cacheable(value = "user-profiles", key = "#userId")
public UserProfile getUserProfile(Long userId) {
    return userProfileRepository.findByUserId(userId);
}

// 3. 동적 데이터 (실시간 가격, 재고)
@Cacheable(value = "real-time-prices", key = "#productId", 
           condition = "#useCache == true")
public ProductPrice getCurrentPrice(Long productId, boolean useCache) {
    return priceService.getCurrentPrice(productId);
}

// 4. 집계 데이터 (통계, 리포트)
@Cacheable(value = "daily-statistics", key = "#date")
public DailyStats getDailyStatistics(LocalDate date) {
    return statisticsService.calculateDailyStats(date);
}

// 5. 세션 데이터
@Cacheable(value = "user-sessions", key = "#sessionId")
public UserSession getUserSession(String sessionId) {
    return sessionRepository.findBySessionId(sessionId);
}

캐시 계층 아키텍처

@Configuration
public class MultiLevelCacheConfig {
    
    // L1: 로컬 캐시 (Caffeine)
    @Bean
    public CacheManager l1CacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(5, TimeUnit.MINUTES)
            .recordStats());
        return manager;
    }
    
    // L2: 분산 캐시 (Redis)
    @Bean
    public CacheManager l2CacheManager() {
        return RedisCacheManager.builder(redisConnectionFactory())
            .cacheDefaults(RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(30)))
            .build();
    }
    
    // 복합 캐시 매니저
    @Bean
    @Primary
    public CacheManager compositeCacheManager() {
        CompositeCacheManager manager = new CompositeCacheManager();
        manager.setCacheManagers(Arrays.asList(
            l1CacheManager(),
            l2CacheManager()
        ));
        manager.setFallbackToNoOpCache(false);
        return manager;
    }
}

7.2 모니터링

캐시 시스템의 성능과 상태를 지속적으로 모니터링하여 최적화 포인트를 찾고 문제를 조기에 발견할 수 있는 모니터링 체계를 구축합니다.

핵심 메트릭 수집

@Component
public class CacheMetricsCollector {
    
    @Autowired
    private MeterRegistry meterRegistry;
    
    @Autowired
    private CacheManager cacheManager;
    
    @PostConstruct
    public void initializeMetrics() {
        // 캐시별 히트율 메트릭
        cacheManager.getCacheNames().forEach(cacheName -> {
            Cache cache = cacheManager.getCache(cacheName);
            if (cache instanceof CaffeineCache) {
                com.github.benmanes.caffeine.cache.Cache<Object, Object> nativeCache = 
                    ((CaffeineCache) cache).getNativeCache();
                
                Gauge.builder("cache.hit.ratio")
                    .tag("cache", cacheName)
                    .register(meterRegistry, nativeCache, c -> c.stats().hitRate());
                
                Gauge.builder("cache.size")
                    .tag("cache", cacheName)
                    .register(meterRegistry, nativeCache, c -> c.estimatedSize());
            }
        });
    }
    
    // 캐시 이벤트 리스너
    @EventListener
    public void handleCacheHit(CacheHitEvent event) {
        Counter.builder("cache.operations")
            .tag("cache", event.getCacheName())
            .tag("operation", "hit")
            .register(meterRegistry)
            .increment();
    }
    
    @EventListener
    public void handleCacheMiss(CacheMissEvent event) {
        Counter.builder("cache.operations")
            .tag("cache", event.getCacheName())
            .tag("operation", "miss")
            .register(meterRegistry)
            .increment();
    }
    
    @EventListener
    public void handleCacheEvict(CacheEvictEvent event) {
        Counter.builder("cache.operations")
            .tag("cache", event.getCacheName())
            .tag("operation", "evict")
            .register(meterRegistry)
            .increment();
    }
    
    // Redis 연결 상태 모니터링
    @Scheduled(fixedRate = 30000) // 30초마다
    public void monitorRedisConnection() {
        try {
            redisTemplate.execute((RedisCallback<String>) connection -> {
                return connection.ping();
            });
            
            Gauge.builder("redis.connection.status")
                .register(meterRegistry, () -> 1.0); // 연결됨
                
        } catch (Exception e) {
            Gauge.builder("redis.connection.status")
                .register(meterRegistry, () -> 0.0); // 연결 끊김
                
            log.error("Redis 연결 상태 확인 실패", e);
        }
    }
}

성능 분석 도구

@RestController
@RequestMapping("/admin/cache")
public class CacheAnalysisController {
    
    @Autowired
    private CacheManager cacheManager;
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    // 캐시 통계 조회
    @GetMapping("/stats")
    public Map<String, Object> getCacheStats() {
        Map<String, Object> stats = new HashMap<>();
        
        cacheManager.getCacheNames().forEach(cacheName -> {
            Cache cache = cacheManager.getCache(cacheName);
            Map<String, Object> cacheStats = new HashMap<>();
            
            if (cache instanceof CaffeineCache) {
                com.github.benmanes.caffeine.cache.Cache<Object, Object> nativeCache = 
                    ((CaffeineCache) cache).getNativeCache();
                CacheStats caffeineStats = nativeCache.stats();
                
                cacheStats.put("hitCount", caffeineStats.hitCount());
                cacheStats.put("missCount", caffeineStats.missCount());
                cacheStats.put("hitRate", caffeineStats.hitRate());
                cacheStats.put("evictionCount", caffeineStats.evictionCount());
                cacheStats.put("size", nativeCache.estimatedSize());
            }
            
            stats.put(cacheName, cacheStats);
        });
        
        return stats;
    }
    
    // Redis 메모리 사용량 분석
    @GetMapping("/redis/memory")
    public Map<String, Object> getRedisMemoryInfo() {
        Properties info = redisTemplate.execute((RedisCallback<Properties>) connection -> {
            return connection.info("memory");
        });
        
        Map<String, Object> memoryInfo = new HashMap<>();
        if (info != null) {
            memoryInfo.put("used_memory", info.getProperty("used_memory"));
            memoryInfo.put("used_memory_human", info.getProperty("used_memory_human"));
            memoryInfo.put("used_memory_peak", info.getProperty("used_memory_peak"));
            memoryInfo.put("used_memory_peak_human", info.getProperty("used_memory_peak_human"));
        }
        
        return memoryInfo;
    }
    
    // 캐시 키 분석
    @GetMapping("/keys/analysis")
    public Map<String, Object> analyzeKeys() {
        Set<String> keys = redisTemplate.keys("*");
        Map<String, Long> keyPatterns = new HashMap<>();
        Map<String, Long> keySizes = new HashMap<>();
        
        if (keys != null) {
            for (String key : keys) {
                // 패턴별 분류
                String pattern = extractPattern(key);
                keyPatterns.merge(pattern, 1L, Long::sum);
                
                // 키별 크기 측정
                Long size = redisTemplate.execute((RedisCallback<Long>) connection -> {
                    return connection.memoryUsage(key.getBytes());
                });
                if (size != null) {
                    keySizes.put(key, size);
                }
            }
        }
        
        Map<String, Object> analysis = new HashMap<>();
        analysis.put("totalKeys", keys != null ? keys.size() : 0);
        analysis.put("keyPatterns", keyPatterns);
        analysis.put("topLargestKeys", getTopLargestKeys(keySizes, 10));
        
        return analysis;
    }
    
    private String extractPattern(String key) {
        int colonIndex = key.indexOf(':');
        return colonIndex > 0 ? key.substring(0, colonIndex) : "unknown";
    }
    
    private List<Map.Entry<String, Long>> getTopLargestKeys(Map<String, Long> keySizes, int limit) {
        return keySizes.entrySet().stream()
            .sorted(Map.Entry.<String, Long>comparingByValue().reversed())
            .limit(limit)
            .collect(Collectors.toList());
    }
}

7.3 실전 팁

실제 프로덕션 환경에서 캐시를 운영하면서 얻은 경험과 노하우를 공유합니다. 성능 최적화, 장애 대응, 운영 효율성 향상을 위한 실용적인 팁들입니다.

성능 최적화 팁

  • 배치 처리: 여러 키를 한 번에 조회/저장
  • 파이프라이닝: Redis 명령어 배치 실행
  • 압축: 큰 데이터는 압축하여 저장
  • 연결 풀: 적절한 연결 풀 크기 설정
  • 직렬화: 성능에 맞는 직렬화 방식 선택

장애 대응 팁

  • Circuit Breaker: 캐시 장애 시 자동 우회
  • Fallback: 캐시 실패 시 대체 로직
  • Health Check: 캐시 상태 주기적 확인
  • Graceful Degradation: 점진적 성능 저하
  • Monitoring: 실시간 알림 시스템

실전 코드 패턴

// 1. 배치 조회 패턴
@Service
public class BatchCacheService {
    
    public Map<Long, User> getUsersBatch(List<Long> userIds) {
        List<String> keys = userIds.stream()
            .map(id -> "user:" + id)
            .collect(Collectors.toList());
        
        List<Object> cachedUsers = redisTemplate.opsForValue().multiGet(keys);
        Map<Long, User> result = new HashMap<>();
        List<Long> missedIds = new ArrayList<>();
        
        for (int i = 0; i < userIds.size(); i++) {
            Long userId = userIds.get(i);
            User user = (User) cachedUsers.get(i);
            
            if (user != null) {
                result.put(userId, user);
            } else {
                missedIds.add(userId);
            }
        }
        
        // 캐시 미스된 데이터 DB에서 조회
        if (!missedIds.isEmpty()) {
            List<User> dbUsers = userRepository.findAllById(missedIds);
            
            // 배치로 캐시에 저장
            Map<String, Object> cacheData = new HashMap<>();
            for (User user : dbUsers) {
                result.put(user.getId(), user);
                cacheData.put("user:" + user.getId(), user);
            }
            
            if (!cacheData.isEmpty()) {
                redisTemplate.opsForValue().multiSet(cacheData);
                // TTL 설정
                cacheData.keySet().forEach(key -> 
                    redisTemplate.expire(key, Duration.ofMinutes(10)));
            }
        }
        
        return result;
    }
}

// 2. Circuit Breaker 패턴
@Component
public class CacheCircuitBreaker {
    
    private final AtomicInteger failureCount = new AtomicInteger(0);
    private final AtomicLong lastFailureTime = new AtomicLong(0);
    private final int failureThreshold = 5;
    private final long recoveryTimeout = 60000; // 1분
    
    public <T> T executeWithCircuitBreaker(Supplier<T> cacheOperation, 
                                          Supplier<T> fallbackOperation) {
        if (isCircuitOpen()) {
            log.warn("캐시 Circuit Breaker 열림 - Fallback 실행");
            return fallbackOperation.get();
        }
        
        try {
            T result = cacheOperation.get();
            onSuccess();
            return result;
        } catch (Exception e) {
            onFailure();
            log.warn("캐시 작업 실패 - Fallback 실행", e);
            return fallbackOperation.get();
        }
    }
    
    private boolean isCircuitOpen() {
        if (failureCount.get() >= failureThreshold) {
            return System.currentTimeMillis() - lastFailureTime.get() < recoveryTimeout;
        }
        return false;
    }
    
    private void onSuccess() {
        failureCount.set(0);
    }
    
    private void onFailure() {
        failureCount.incrementAndGet();
        lastFailureTime.set(System.currentTimeMillis());
    }
}

// 3. 캐시 압축 패턴
@Service
public class CompressedCacheService {
    
    public void setCacheCompressed(String key, Object value) {
        try {
            byte[] serialized = serialize(value);
            byte[] compressed = compress(serialized);
            
            redisTemplate.opsForValue().set(key + ":compressed", compressed);
            redisTemplate.expire(key + ":compressed", Duration.ofMinutes(10));
            
        } catch (Exception e) {
            log.error("압축 캐시 저장 실패: key={}", key, e);
        }
    }
    
    @SuppressWarnings("unchecked")
    public <T> T getCacheCompressed(String key, Class<T> type) {
        try {
            byte[] compressed = (byte[]) redisTemplate.opsForValue()
                .get(key + ":compressed");
            
            if (compressed != null) {
                byte[] decompressed = decompress(compressed);
                return (T) deserialize(decompressed);
            }
        } catch (Exception e) {
            log.error("압축 캐시 조회 실패: key={}", key, e);
        }
        return null;
    }
}

운영 체크리스트

일일 점검
  • ☐ 캐시 히트율 확인
  • ☐ 메모리 사용량 점검
  • ☐ 에러 로그 확인
  • ☐ 응답 시간 모니터링
주간 점검
  • ☐ 캐시 패턴 분석
  • ☐ TTL 최적화 검토
  • ☐ 용량 계획 수립
  • ☐ 성능 트렌드 분석

7.4 학습 정리

Spring 캐싱에 대한 핵심 내용을 정리하고, 추가 학습 방향을 제시합니다.

핵심 개념 요약

Spring Cache Abstraction
  • • @EnableCaching으로 캐시 활성화
  • • @Cacheable로 조회 캐싱
  • • @CachePut으로 캐시 업데이트
  • • @CacheEvict로 캐시 무효화
  • • SpEL을 활용한 동적 키 생성
Redis 연동
  • • RedisTemplate 설정
  • • 직렬화 전략 선택
  • • RedisCacheManager 구성
  • • 연결 풀 최적화
  • • 클러스터/센티널 설정

실전 패턴

조회 패턴
  • • 단일 엔티티 캐싱
  • • 복합 키 활용
  • • 조건부 캐싱
  • • null 값 처리
목록 패턴
  • • 페이징 캐싱
  • • 필터링 캐싱
  • • 검색 결과 캐싱
  • • 패턴 기반 무효화
카운터 패턴
  • • 조회수 카운터
  • • 좋아요 시스템
  • • 재고 관리
  • • 원자적 연산

다음 학습 단계

고급 주제
  • • Redis Cluster 운영
  • • 캐시 샤딩 전략
  • • 멀티 리전 캐시
  • • 캐시 보안 (암호화, 인증)
관련 기술
  • • Hazelcast, Ehcache 등 다른 캐시 솔루션
  • • CDN과 캐시 계층 통합
  • • 메시지 큐를 활용한 캐시 동기화
  • • 마이크로서비스 환경에서의 캐시 전략