Published on
👁️

백엔드 캐싱 II - Spring Cache 고급

Authors
  • avatar
    Name
    River
    Twitter
76Caffeine 로컬 캐시 설정과 최적화 방법은?보통
Caffeine은 Google Guava 캐시의 차세대 버전으로 JVM 내부에서 동작하는 고성능 로컬 캐시 라이브러리입니다. Spring Boot에서 CaffeineCacheManager를 통해 손쉽게 사용할 수 있으며, expireAfterWrite나 expireAfterAccess로 TTL 전략을 설정할 수 있습니다. 또 maximumSize로 메모리 사용량을 제한하고 recordStats 메서드로 통계 수집 기능을 활성화하면 Cache Hit Rate 등을 수집하여 캐시 효율성을 모니터링할 수 있습니다.
상세 설명

Caffeine 캐시란?

  • 등장 배경

    • Guava Cache의 한계
      • 대규모 데이터 처리 시 성능 이슈
      • 간단 LRU 기반으로 정교한 캐시 정책 부족 (LRU - 최근에 안 쓴 것부터 제거)
      • 고성능 동시성 처리 한계
    • Guava Cache의 교훈을 바탕으로 Caffeine 개발 (10배 이상 빠르다)


  • Caffeine 특징

    • Google Guava 캐시의 차세대 버전

    • JVM 메모리 기반 로컬 캐시

    • Java 8+ 람다와 스트림 최적화

      cache.get(key, k -> expensiveComputation(k));
      
      • 즉, 캐시 처리 시 Java 8+ 기능을 활용하여 최적화 가능
    • W-TinyLFU 알고리즘으로 효율적으로 캐시 제거

      • W-TinyLFU (Window Tiny Least Frequently Used)
        • 사용 빈도 + 최근성 + 메모리 효율성을 모두 고려
        • 즉, 진짜 필요한 데이터는 오래 유지
    • CompletableFuture 지원

      CompletableFuture<Product> future = cache.get(productId, id -> 
          CompletableFuture.supplyAsync(() -> loadProductFromDB(id))
      );
      
      • 비동기 캐시 로딩 지원
      • 즉, 무거운 작업을 비동기로 처리할 수 있다.


  • 로컬 캐시의 장단점

    • 장점
      • 네트워크 I/O 없어서 매우 빠름 (나노초 단위)
    • 단점
      • 서버별 독립적이라 데이터 불일치 가능

CaffeineCacheManager 기본 구성

  1. 의존성 추가

    dependencies {
       implementation("com.github.ben-manes.caffeine:caffeine")
       implementation("org.springframework.boot:spring-boot-starter-cache")
    }
    

  2. 기본 CaffeineCacheManager 설정

    @Configuration
    public class CacheConfig {
        @Bean
        public CacheManager caffeineCacheManager() {
            CaffeineCacheManager manager = new CaffeineCacheManager();
            manager.setCaffeine(Caffeine.newBuilder()
                .maximumSize(1000)                           // 최대 1000개 항목
                .expireAfterWrite(Duration.ofMinutes(10)));  // TTL (10분 후 만료)
            return manager;
        }
    }
    
    • 단, Spring Main Class@EnableCaching 설정 필요

  3. 사용 예시

    @Service
    public class ProductService {
    
        @Cacheable(value = "products")
        public Product findById(Long id) {
            return productRepository.findById(id);
        }
    
        @CacheEvict(value = "products", key = "#id")
        public void updateProduct(Long id, Product product) {
            productRepository.save(product);
        }
    }
    

참고
  • 여러 캐시 매니저를 구분해서 사용할 때만 cacheManager 지정
  • CacheManager를 지정하지 않으면 Spring은 기본 캐시 매니저를 사용
    • 등록된 캐시 매니저가 1개라면 그것이 기본 캐시 매니저가 된다.
    • 단, 여러 개의 캐시 매니저를 등록하고 지정을 생략하면 ⇒ 에러 발생
      • @Primary 등과 같은 추가 설정이 필요하다.

TTL 전략 (expireAfterWrite vs expireAfterAccess)

  1. expireAfterWrite (쓰기 기준 만료)

    @Bean
    public CacheManager writeBasedCacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCaffeine(Caffeine.newBuilder()
            .expireAfterWrite(Duration.ofMinutes(10))  // 저장된 지 10분 후 만료
            .maximumSize(1000));
        return manager;
    }
    
    • 특징

      • 데이터가 저장된 시점부터 시간 계산
      • 아무리 자주 조회해도 10분 후 무조건 만료
      • 데이터 일관성이 중요한 경우 사용

    • 사용 예시

      @Cacheable(value = "productPrices")
      public BigDecimal getProductPrice(Long productId) {
          return productRepository.findPriceById(productId);
      }
      
      • 상품 가격처럼 정확성이 중요한 데이터


  2. expireAfterAccess (접근 기준 만료)

    @Bean
    public CacheManager accessBasedCacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCaffeine(Caffeine.newBuilder()
            .expireAfterAccess(Duration.ofMinutes(30))  // 마지막 접근 후 30분간 유지
            .maximumSize(1000));
        return manager;
    }
    
    • 특징

      • 마지막 조회 시점부터 시간 계산
      • 자주 사용되는 데이터계속 유지
      • 성능과 메모리 효율성 우선인 경우 사용

    • 사용 예시

      @Cacheable(value = "userProfiles")
      public UserProfile getUserProfile(Long userId) {
          return userRepository.findProfile(userId);
      }
      
      • 사용자 권한 정보, 최근 조회 상품 목록


  3. 두 전략 비교

    구분expireAfterWriteexpireAfterAccess
    기준저장 시점마지막 접근 시점
    데이터 일관성높음보통
    메모리 효율성예측 가능높음
    적용 사례상품 정보, 가격사용자 프로필이나 권한 정보

Caffeine 캐시 TTL 설정 패턴

  1. 용도별 캐시 설정

    @Configuration
    public class CacheConfig {
    
        // 자주 변하는 데이터 - 짧은 TTL
        @Bean("shortLivedCache")
        public CacheManager shortLivedCacheManager() {
            CaffeineCacheManager manager = new CaffeineCacheManager();
            manager.setCaffeine(Caffeine.newBuilder()
                .maximumSize(5000)
                .expireAfterWrite(Duration.ofMinutes(1))
                .recordStats());
            return manager;
        }
    
        // 안정적인 데이터 - 긴 TTL
        @Bean("longLivedCache")
        public CacheManager longLivedCacheManager() {
            CaffeineCacheManager manager = new CaffeineCacheManager();
            manager.setCaffeine(Caffeine.newBuilder()
                .maximumSize(1000)
                .expireAfterWrite(Duration.ofHours(1))
                .recordStats());
            return manager;
        }
    }
    

  2. 사용 예시

    @Service
    public class ProductService {
    
        // 재고 정보 - 자주 변함
        @Cacheable(value = "stock", cacheManager = "shortLivedCache")
        public Integer getProductStock(Long productId) {
            return productRepository.getStock(productId);
        }
        
        
        // 상품 기본 정보 - 안정적
        @Cacheable(value = "productInfo", cacheManager = "longLivedCache")
        public Product getProductInfo(Long productId) {
            return productRepository.findById(productId);
        }
    }
    

maximumSize와 메모리 관리 전략

  1. 항목 수 기반 제한 (기본)

    @Bean
    public CacheManager sizeLimitedCacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(5000)                         // 최대 5000개 항목
            .expireAfterWrite(Duration.ofMinutes(15)));
        return manager;
    }
    
    • 최대 5000개 항목만 저장하도록 제한한다.

  2. 메모리 크기 기반 제한

    @Bean
    public CacheManager weightBasedCacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCaffeine(Caffeine.newBuilder()
            .maximumWeight(50_000_000)  // 50MB
            .weigher((String key, Object value) -> {
                // 간단한 객체 크기 추정
                if (value instanceof String) {
                    return ((String) value).length() * 2;  // char당 2바이트
                }
                return 1000;  // 기본값 1KB
            })
            .expireAfterWrite(Duration.ofMinutes(15)));
        return manager;
    }
    
    • 자주 쓰이지 않는다
    • 메모리 크기를 50 MB로 제한한다.
    • maximumWeight()로 최대 메모리 크기를 제한한다.
      • 다만, Caffeine은 객체 크기를 몰라서 weigher()로 개발자가 크기 계산로직을 제공해야 한다.

  3. 캐시별 다른 크기 설정

    @Configuration
    public class CacheConfig {
    
        @Bean
        public CacheManager caffeineCacheManager() {
            CaffeineCacheManager manager = new CaffeineCacheManager();
    
            // 기본 설정
            manager.setCaffeine(Caffeine.newBuilder()
                .maximumSize(1000)
                .expireAfterWrite(Duration.ofMinutes(10)));
    
            // 개별 설정
            manager.registerCustomCache("userProfiles",
                Caffeine.newBuilder()
                    .maximumSize(10000)
                    .expireAfterAccess(Duration.ofHours(1))
                    .build());
    
            manager.registerCustomCache("heavyReports",
                Caffeine.newBuilder()
                    .maximumSize(50)
                    .expireAfterWrite(Duration.ofMinutes(30))
                    .build());
    
            return manager;
        }
    }
    
    
    • registerCustomCache() 활용하여 개별 캐시를 설정한다.
      • 무거운 보고서 객체라면 개수 제한을 작게
      • 가벼운 프로필 같은 경우 개수 제한을 크게

통계 수집기본 모니터링

  1. 통계 수집 활성화

    @Bean
    public CacheManager monitoringCacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(Duration.ofMinutes(10))
            .recordStats()
            .removalListener((key, value, cause) -> {
                log.debug("캐시 제거 - Key: {}, Cause: {}", key, cause);
            }));
        return manager;
    }
    
    • 통계 수집 활성 : recordStats()

  2. 캐시 통계 확인

    @Component
    @RequiredArgsConstructor
    public class CacheStatsService {
    
        private final CacheManager cacheManager;
    
        public void printCacheStats(String cacheName) {
            Cache cache = cacheManager.getCache(cacheName);
            if (cache instanceof CaffeineCache) {
                com.github.benmanes.caffeine.cache.Cache<Object, Object> nativeCache =
                    ((CaffeineCache) cache).getNativeCache();
    
                CacheStats stats = nativeCache.stats();
    
                log.info("=== {} 캐시 통계 ===", cacheName);
                log.info("Hit Count: {}", stats.hitCount());
                log.info("Miss Count: {}", stats.missCount());
                log.info("Hit Rate: {:.2f}%", stats.hitRate() * 100);
                log.info("Eviction Count: {}", stats.evictionCount());
            }
        }
    }
    
    • CacheManager 객체를 활용하여 캐시 정보를 획득
    • Cache
      • Spring의 Cache 인터페이스
    • getNativeCache()
      • Caffeine 네이티브 객체 가져오기
      • Caffine 네이티브 객체의 통계와 같은 기능을 사용하기 위해서

  3. 사용 예시

    @Service
    @Slf4j
    public class ProductService {
    
        @Autowired
        private CacheStatsService cacheStatsService;
    
        @Cacheable(value = "products")
        public Product findById(Long id) {
            log.debug("DB에서 상품 조회 - ID: {}", id);
            return productRepository.findById(id);
        }
    
        @Scheduled(fixedRate = 300000)  // 5분마다
        public void logCacheStats() {
            cacheStatsService.printCacheStats("products");
        }
    }
    

메모리 사용량 최적화 팁

  1. 적절한 maximumSize 설정

    @Bean
    public CacheManager optimizedCacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(2000)                         // 적당한 크기
            .expireAfterWrite(Duration.ofMinutes(15))  // 적당한 TTL
            .recordStats());
        return manager;
    }
    
    • 메모리 사용량 추정

      • 작은 객체 (ID, 이름)
        • 100-500 바이트 ⇒ maximumSize: 10,000-50,000
      • 중간 객체 (Entity)
        • 1-5 KB ⇒ maximumSize: 1,000-5,000
      • 큰 객체 (리스트, 집계)
        • 10-100 KB ⇒ maximumSize: 100-1,000

    • 메모리 사용량을 추정하고 그에 맞는 적절한 maximumSize를 설정하는 것이 좋다.


  2. 불필요한 캐시 제거

    • 자주 조회되고 변경이 적은 데이터만 캐시 ✅
    • 자주 변하는 데이터는 캐시하지 않는다. ❌
77Redis 글로벌 캐시 설정과 직렬화 전략은?보통
Redis는 여러 서버가 캐시를 공유할 수 있는 글로벌 캐시로 활용할 수 있습니다. 로컬 캐시와 달리 서버 재시작 후에도 데이터가 유지되며, 세션 클러스터링과 대용량 데이터 캐싱에 적합합니다. Spring Boot는 Redis 의존성 추가만 되어 있으면 기본 설정을 자동 구성하지만, 세밀한 제어를 위해서 직접 RedisCacheManager를 설정할 수 있습니다. 직렬화는 기본 JDK 방식보다 JSON 방식이 권장되며, 이는 Redis CLI에서 데이터 확인이 가능하고 다른 언어 및 시스템과 호환성이 좋기 때문입니다. 또 Redis Connection Pool을 최적화하고 Actuator 모니터링을 활용하면 안정적인 운영이 가능합니다.
상세 설명

Redis 캐시의 특징과 장점

  • Redis vs Caffeine 비교

    구분Redis (글로벌캐시)Caffeine (로컬캐시)
    위치별도 서버JVM 내부
    공유서버 간 공유 가능서버별 독립적
    속도빠름 (밀리초)매우 빠름 (나노초)
    영속성재시작 후에도 유지 가능재시작 시 소실
    메모리Redis 서버 메모리JVM 힙 메모리
    확장성클러스터 확장 가능단일 서버 제한

  • Redis 캐시 사용 시나리오

    • 분산 환경의 데이터 공유
      • 여러 서버가 동일한 데이터를 일관성 있게 공유해야 할 때 사용
    • 세션 클러스터링
      • 로드 밸런서 환경에서 여러 서버가 사용자의 세션 정보를 공유하여 로그인 상태 등을 유지
    • 대용량 데이터 캐싱
      • 로컬 메모리에 부담을 주지 않으면서 대규모 데이터를 캐시에 저장해 빠르게 조회
    • 데이터 영속성
      • 서버가 재시작되어도 데이터를 디스크에 저장하여 유실 없이 보존

RedisCacheManager 기본 설정

  1. 의존성 추가

    dependencies {
        implementation("org.springframework.boot:spring-boot-starter-cache")
        implementation("org.springframework.boot:spring-boot-starter-data-redis")
    }
    

  2. application.yml 설정

    spring:
      redis:
        host: localhost
        port: 6379
        password: ${REDIS_PASSWORD:}
        timeout: 2000ms
        lettuce:
          pool:
            max-active: 8
            max-idle: 8
            min-idle: 0
    
    • Redis의 SSL/TLS 설정고가용성(Sentinel, Cluster) 설정은 이후 내용에서 다룬다.
    • Spring Boot는 spring-boot-starter-data-redis 의존성이 추가되면, 별도의 Java 설정 없이도 application.yml의 정보를 바탕으로 RedisConnectionFactoryRedisCacheManager자동으로 구성한다.
    • 아래의 @Bean을 통한 설정 방식은 세부 정책커스터마이징하고 싶을 때 사용하는 방법


  3. Bean을 통한 Redis 연결 설정

    @Configuration
    public class RedisCacheConfig {
    
        @Bean
        public RedisConnectionFactory redisConnectionFactory() {
            LettuceConnectionFactory factory = new LettuceConnectionFactory(
                new RedisStandaloneConfiguration("localhost", 6379)
            );
            return factory;
        }
    
        @Bean
        public CacheManager redisCacheManager(RedisConnectionFactory connectionFactory) {
            RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(30))  // 기본 30분 TTL
                .disableCachingNullValues();       // null 값 캐싱 비활성화
    
            return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(config)
                .build();
        }
    }
    
    • 단, Spring Main Class@EnableCaching 설정 필요


    • RedisConnectionFactory
      • Redis 서버와 어떻게 연결할지 설정
      • Lettuce
        • Redis Java 클라이언트 라이브러리 (비동기, 논블로킹)
        • 다른 방식 : Jedis (동기)
      • RedisStandaloneConfiguration
        • 단일 Redis 서버 (클러스터 X)
        • 다른 방식 : RedisClusterConfiguration, RedisSentinelConfiguration

    • CacheManager
      • 캐싱 정책 설정
      • RedisConnectionFactory를 사용하여 생성한다.
      • RedisTemplate도 마찬가지로 RedisConnectionFactory를 통해 생성하지만, RedisTemplate수동 캐싱을 구현할 때 사용한다.

직렬화 전략

  • 직렬화가 필요한 이유

    • Redis는 문자열이나 바이너리 데이터만 저장할 수 있는데, Java 객체는 더 복잡한 메모리 구조라 직렬화가 필요하다.
    • 즉, 저장이 가능한 형태로 바꾸는 것이다.
    • 이를 다시 역직렬화를 통해 Java 객체로 변환할 수도 있다.
    • null 값 캐싱 방지 : disableCachingNullValues()
    • 직렬화 설정 방법
      • .serializeKeysWith()
        • Key는 항상 문자열로
        • RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())
          • SerializationPair : 직렬화 + 역직렬화
          • fromSerializer() : 직렬화 방식 설정
          • StringRedisSerializer : 문자열 ↔ 바이트 변환 (Key 직렬화에 보통 사용)
          • "product:123"UTF-8 바이트

      • .serializeValuesWith()
        • Value는 직렬화 방식에 따라
        • 바이너리
          • serializeValuesWith(JdkSerializationRedisSerializer)
        • JSON
          • .serializeValuesWith(jsonSerializer)


  1. JDK 직렬화 (기본값)

    @Bean
    public CacheManager jdkSerializationCacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
    		    .disableCachingNullValues()
            .serializeKeysWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new JdkSerializationRedisSerializer()));
    
        return RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(config)
            .build();
    }
    
    • 직렬화 설정 추가

      • new JdkSerializationRedisSerializer()
        • Java 객체 ↔ 바이너리 변환
        • Value 직렬화에 사용
        • Java 객체 ⇒ \xac\xed\x00\x05sr...

    • 특징

      • Spring Cache의 기본 직렬화 방식

      • 바이너리 데이터로 저장되어 용량 효율적

      • Java 객체 그래프 완전 보존

        (객체 그래프는 Java 객체 포함 관계를 말한다)


    • 단점

      • Redis CLI에서 내용 확인 불가 (바이너리)
      • 클래스 변경 시 호환성 문제
        • 저장했던 클래스와 필드가 달라지거나 하면 읽을 수 없다 (InvalidClassException 발생)
      • 다른 언어와 호환성 없음
        • 바이너리 형식을 몰라서 Python에서 Redis의 데이터를 읽을 수 없다.


  2. JSON 직렬화 (권장)

    @Bean
    public CacheManager jsonSerializationCacheManager(RedisConnectionFactory connectionFactory) {
        // Jackson 설정
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        objectMapper.activateDefaultTyping(
            BasicPolymorphicTypeValidator.builder()
    	        .allowIfSubType(Object.class)
    	        .build(),
            ObjectMapper.DefaultTyping.NON_FINAL
        );
    
        // JSON 직렬화 설정
        GenericJackson2JsonRedisSerializer jsonSerializer =
            new GenericJackson2JsonRedisSerializer(objectMapper);
    
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
            .disableCachingNullValues()
            .serializeKeysWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair
                .fromSerializer(jsonSerializer))
            .entryTtl(Duration.ofMinutes(30));
    
        return RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(config)
            .build();
    }
    
    
    • 직렬화 설정 추가

      • ObjectMapper 설정

        • setVisibility( )
          • PropertyAccessor.ALL - 모든 접근 방식
          • Visibility.ANY - 접근 제한자 상관없이 모두 허용 (private 필드도 포함하기 위해)
        • activateDefaultTyping( )
          • 타입 정보 저장 (JSON에 클래스 정보 포함시켜서 정확한 복원 가능하도록)
          • BasicPolymorphicTypeValidator.builder().allowIfSubType(Object.class).build()
            • LaissezFaireSubTypeValidator.instance
              • 이렇게 설정할 수도 있다. 다만 이것은 어떤 클래스든 역직렬화 허용
              • 즉, 보안 위험 존재
          • ObjectMapper.DefaultTyping.NON_FINAL
            • final이 아닌 클래스에만 타입 정보 추가
            • 즉, String, Integer 같은 final 클래스는 타입이 명확하니까 제외

      • new GenericJackson2JsonRedisSerializer(objectMapper)
        • Java 객체 ↔ JSON 변환
        • Value 직렬화에 사용
        • Java 객체 ⇒ {"id":1,"name":"iPhone"}

    • 장점

      • Redis CLI에서 내용 확인 가능
      • 다른 언어/시스템과 호환성 좋음
      • 스키마 변경에 상대적으로 유연하다

    • 단점

      • 바이너리보다 용량 큼
      • 복잡한 객체 그래프 처리 시 성능 이슈 가능

캐시별 개별 TTL 설정

  1. 캐시별 다른 TTL 전략

    @Bean
    public CacheManager multiTtlCacheManager(RedisConnectionFactory connectionFactory) {
    
        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
            .serializeKeysWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new GenericJackson2JsonRedisSerializer()))
            .entryTtl(Duration.ofMinutes(30));  // 기본 30분
    
        // 개별 캐시 설정
        Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
    
        cacheConfigurations.put("stock", defaultConfig
            .entryTtl(Duration.ofMinutes(1)));     // 자주 변하는 데이터
    
        cacheConfigurations.put("userProfiles", defaultConfig
            .entryTtl(Duration.ofMinutes(30)));    // 사용자 프로필 - 중간 TTL
    
        cacheConfigurations.put("products", defaultConfig
            .entryTtl(Duration.ofHours(2)));       // 상품 정보 - 긴 TTL
    
        cacheConfigurations.put("categories", defaultConfig
            .entryTtl(Duration.ofHours(24)));      // 정적 데이터 - 매우 긴 TTL
    
        return RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(defaultConfig)
            .withInitialCacheConfigurations(cacheConfigurations)
            .build();
    }
    

Redis 연결 최적화

  1. Connection Pool 설정

    @Bean
    public RedisConnectionFactory optimizedRedisConnectionFactory() {
    
        GenericObjectPoolConfig<Object> poolConfig = new GenericObjectPoolConfig<>();
        poolConfig.setMaxTotal(20);       // 최대 연결 수
        poolConfig.setMaxIdle(10);        // 최대 유휴 연결 수
        poolConfig.setMinIdle(5);         // 최소 유휴 연결 수
        poolConfig.setTestOnBorrow(true); // 대여 시 연결 테스트
    
        LettucePoolingClientConfiguration clientConfig =
            LettucePoolingClientConfiguration.builder()
                .commandTimeout(Duration.ofMilliseconds(1000))
                .poolConfig(poolConfig)
                .build();
    
        LettuceConnectionFactory factory = new LettuceConnectionFactory(
            new RedisStandaloneConfiguration("localhost", 6379),
            clientConfig
        );
    
        return factory;
    }
    
    • RedisConnectionFactory를 생성할 때 세부 설정을 할 수 있다.

      • new LettuceConnectionFactory() 생성 시 커스텀 config 설정을 같이 넘긴다.

    • Lettuce 커넥션 풀 설정 (커스텀 config 설정)

      • GenericObjectPoolConfig<Object> poolConfig = new GenericObjectPoolConfig<>();

      • setMaxTotal(20)

        • 최대 연결 수 (커넥션)
      • setMaxIdle(10)

        • 최대 유휴 연결 수
        • 사용하지 않는 연결이 설정된 값을 넘으면 초과분 제거 (메모리 절약)
      • setMinIdle(5)

        • 최소 유휴 연결 수
        • 빠른 응답을 위한 워밍업으로 미리 준비하는 연결 수
      • setTestOnBorrow(true)

        • 연결을 빌려줄 때 연결 상태를 검증하는 것
        • 끊어진 연결 사용을 방지하기 위함으로, ping 테스트
      • 추가 튜닝 요소

        poolConfig.setMaxWaitMillis(3000);    // 연결 대기 시간
        poolConfig.setTimeBetweenEvictionRunsMillis(30000);  // 유휴 연결 정리 주기
        poolConfig.setTestWhileIdle(true);    // 유휴 중 연결 테스트
        poolConfig.setBlockWhenExhausted(true);    // 풀이 고갈될 때 대기 여부
        
        poolConfig.setJmxEnabled(true);    // JMX 모니터링 활성화
        

    • 앞서 말했듯 application.yml 설정만으로도 구성이 가능은 가능하다

      spring:
        redis:
          lettuce:
            pool:
              max-active: 20    # MaxTotal과 동일
              max-idle: 10      # MaxIdle과 동일  
              min-idle: 5       # MinIdle과 동일
              test-on-borrow: true
      
      • 세밀한 제어 필요 시 Bean을 생성하여 설정하면 된다.


  2. 실무 Redis 연결 설정 패턴

    @Configuration
    public class RedisConfig {
    
        @Value("${spring.redis.host}")
        private String redisHost;
    
        @Value("${spring.redis.port}")
        private int redisPort;
    
        @Value("${spring.redis.password:}")
        private String redisPassword;
    
        @Bean
        public RedisConnectionFactory redisConnectionFactory() {
            RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
            config.setHostName(redisHost);
            config.setPort(redisPort);
    
            if (!redisPassword.isEmpty()) {
                config.setPassword(redisPassword);
            }
    
            LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
                .commandTimeout(Duration.ofSeconds(2))
                .shutdownTimeout(Duration.ofMillis(100))
                .build();
    
            return new LettuceConnectionFactory(config, clientConfig);
        }
    }
    
    • RedisStandaloneConfiguration 설정

      • 마찬가지로 application.yml 설정만으로 Spring Boot가 자동으로 RedisConnectionFactory를 생성한다.

    • LettuceClientConfiguration 설정

      • commandTimeout()
        • Redis 명령 실행 시 최대 대기 시간
      • shutdownTimeout()
        • 애플리케이션 종료 시 Redis 연결을 정리하는데 걸리는 최대 시간

실무 사용 예시

  1. 멀티 레벨 캐시 전략

    @Service
    public class ProductService {
    
        // L1: Caffeine (초고속) - 자주 조회되는 핫데이터
        @Cacheable(value = "hotProducts", cacheManager = "caffeineCacheManager")
        public Product getHotProduct(Long productId) {
            return getProductFromRedis(productId);
        }
    
        // L2: Redis (분산 공유) - 일반적인 상품 데이터
        @Cacheable(value = "products", cacheManager = "redisCacheManager")
        public Product getProductFromRedis(Long productId) {
            return productRepository.findById(productId);
        }
    
        // 캐시 무효화는 양쪽 모두
        @Caching(evict = {
            @CacheEvict(value = "hotProducts", key = "#productId",
                       cacheManager = "caffeineCacheManager"),
            @CacheEvict(value = "products", key = "#productId",
                       cacheManager = "redisCacheManager")
        })
        public void updateProduct(Long productId, Product product) {
            productRepository.save(product);
        }
    }
    
    
    • 멀티 레벨 캐시로 세밀하게 캐싱 전략을 커스텀할 수 있다.


  2. 세션 공유

    @Service
    public class UserSessionService {
    
        @Cacheable(value = "userSessions", cacheManager = "redisCacheManager")
        public UserSession getUserSession(String sessionId) {
            // 
            return sessionRepository.findBySessionId(sessionId);
        }
    
        @CacheEvict(value = "userSessions", key = "#sessionId", 
                    cacheManager = "redisCacheManager")
        public UserSession updateUserSession(String sessionId, UserSession session) {
            return sessionRepository.save(session);
        }
    }
    
    
    • @Cacheable 활용하여 Redis에서 세션 정보 조회 (서버 간 공유)
    • @CacheEvict 활용하여 캐시 무효화

Redis 캐시 모니터링 기초

  1. Redis 상태 확인

    @Component
    public class RedisHealthIndicator implements HealthIndicator {
        
        private final RedisTemplate<String, Object> redisTemplate;
        
        @Override
        public Health health() {
            try {
                String ping = redisTemplate.getConnectionFactory()
                    .getConnection().ping();
                    
                return Health.up()
                    .withDetail("ping", ping)
                    .withDetail("totalKeys", redisTemplate.keys("*").size())
                    .build();
                    
            } catch (Exception e) {
                return Health.down()
                    .withException(e)
                    .build();
            }
        }
    }
    
    • HealthIndicator 클래스

      • Spring Boot Actuator의 헬스체크 인터페이스

        public interface HealthIndicator {
            Health health();
        }
        
      • 클래스명에 따라 /actuator/health/{이름} 엔드포인트 자동 생성

        • RedisHealthIndicator/actuator/health/redis 자동 생성
        • DatabaseHealthIndicator/actuator/health/database 자동 생성

    • application.yml 설정 필요

      management:
        endpoint:
          health:
            show-details: always      # when-authorized (인증 사용자), never (기본값)
      
      • 없으면 단순히 UP/DOWN만 보여준다.
      • 환경별로 다르게 설정하는 것이 일반적이다. (보안 이유)
      • 보안상 상세 정보를 숨기기 위해 when-authorized를 쓰거나, 관리용 포트로 분리할 수 있다.


  2. 캐시 메트릭 수집

    @Component
    public class RedisCacheMetrics {
        
        private final MeterRegistry meterRegistry;
        private final RedisTemplate<String, Object> redisTemplate;
        
        @Scheduled(fixedRate = 60000)
        public void collectCacheMetrics() {
            try {
                Properties info = redisTemplate.getConnectionFactory()
                    .getConnection().info();
                
                // Micrometer로 메트릭 등록
                Gauge.builder("redis.memory.used")
                    .register(meterRegistry, () -> parseMemory(info.getProperty("used_memory")));
                    
                Gauge.builder("redis.clients.connected")
                    .register(meterRegistry, () -> Integer.parseInt(info.getProperty("connected_clients")));
                    
            } catch (Exception e) {
                log.warn("Redis 메트릭 수집 실패", e);
            }
        }
    }
    
    • /actuator/metrics - 모든 메트릭 목록

    • /actuator/metrics/redis.memory.used - 특정 메트릭 값

    • /actuator/prometheus - Prometheus 형식으로 모든 메트릭


    • MeterRegistry 클래스

      • Micrometer의 메트릭 저장소
      • 다양한 메트릭(카운터, 게이지, 타이머)을 수집/저장
        • Gauge - 실시간 수치 측정 타입 (메모리 사용량 등)
        • Counter - 증가만 하는 값 (요청 수, 에러 수)
        • Timer - 실행 시간 측정
      • Prometheus, Grafana, CloudWatch 등으로 내보내기

    • application.yml 설정 필요

      management:
        endpoints:
          web:
            exposure:
              include: metrics, prometheus
        metrics:
          export:
            prometheus:
              enabled: true
      
78SpEL을 이용한 동적 키 생성과 고급 표현식은?보통
SpEL을 활용하면 정적인 캐시 키가 아닌 런타임 상황에 맞춰 동적으로 변화하는 캐시 키를 생성할 수 있습니다. 셀렉션 연산자로 컬렉션에서 조건에 맞는 요소를 선택하거나, 삼항 연산자를 통해 조건에 따라 다른 키를 만들 수도 있습니다. 이때 정규표현식을 사용하면 쉽게 원하는 정보를 추출하거나 처리할 수 있으며 커스텀 유틸리티 클래스, KeyGenerator나 CacheResolver를 통해 더 복잡한 로직을 처리할 수 있습니다. 이를 통해 사용자 권한별 키 분리, 시간 기반 자동 갱신, 시스템 환경별 캐시 분리 등 복잡한 캐싱 시나리오를 해결할 수 있습니다.
상세 설명

SpEL 기본 문법

  1. 셀렉션 연산자 (Selection Operators)

    *// 예시: #numbers = [3, 7, 2, 9, 1]*
    #numbers.^[#this > 5]  *// 결과: 7 (첫 번째)*
    #numbers.$[#this > 5]  *// 결과: 9 (마지막)*
    #numbers.?[#this > 5]  *// 결과: [7, 9] (모든 요소)*
    
    • ^[조건] - 조건을 만족하는 첫 번째 요소 반환
    • $[조건] - 조건을 만족하는 마지막 요소 반환
    • ?[조건] - 조건을 만족하는 모든 요소를 새 컬렉션으로 반환


  2. 프로젝션 연산자 (Projection Operator)

    #users.![#this.name]        *// 모든 사용자의 이름 추출*
    #numbers.![#this * 2]       *// 모든 숫자에 2를 곱함*
    
    • ![표현식] - 각 요소에 표현식을 적용하여 새 컬렉션 생성


  3. 조건식 타입

    • Boolean 리터럴
      • #users.^[true] - 항상 참 (첫 번째 요소)
    • 비교 연산
      • #users.^[#this.age >= 18] - 조건 만족하는 요소
    • 메서드 호출
      • #users.^[#this.isActive()] - 메서드 결과로 판단
    • 복합 조건
      • #users.^[#this.age > 20 && #this.isActive()] - AND/OR 연산
    • null 체크
      • #users.^[#this != null] - null이 아닌 요소


  4. 기본 접근 연산자

    • [인덱스] - 배열/리스트의 특정 위치 요소 접근
    • .length / .size() - 배열 길이 / 컬렉션 크기
    • .isEmpty() - 컬렉션이 비어있는지 확인

SpEL 메서드 호출과 동적 키 생성

  1. 객체 메서드 직접 호출

    • 객체의 메서드를 SpEL에서 직접 호출하여 동적 키 생성

    • 사용자 권한에 따른 동적 키

      @Service
      public class DynamicCacheService {
      
          @Cacheable(value = "userContent",
                  key = "#user.getRole().toUpperCase() + ':' + #contentType + ':' + #user.hasPermission('PREMIUM')")
          public Content getUserContent(User user, String contentType) {
              return contentService.getContentByUserRole(user, contentType);
          }
      }
      
      • #user.getRole().toUpperCase()
        • 메서드 체이닝으로 대문자 변환
      • #user.hasPermission('PREMIUM')
        • 권한 체크 메서드 직접 호출
      • 예시 - "MANAGER:video:false", "ADMIN:music:true"

    • 복잡한 메서드 체이닝

      @Service
      public class DynamicCacheService {
      
          @Cacheable(value = "regionData",
                  key = "#request.getLocation().getCountry().getCode() + ':' + #request.getLanguage().toLowerCase()")
          public RegionalData getRegionalData(LocalizedRequest request) {
              return regionService.getData(request.getLocation(), request.getLanguage());
          }
      }
      
      • 복잡한 메서드 체이닝이여도 SpEL에서 사용 가능하다.
      • 예시 - "KR:en", "US:es"


  2. 조건부 메서드 호출

    • 사용자 타입에 따른 다른 키 생성 로직

      @Service
      public class ConditionalCacheService {
      
          @Cacheable(value = "dynamicUserData",
                  key = "#user.isGuest() ? 'guest:' + #user.getSessionId() : 'member:' + #user.getId() + ':' + #user.getLastLoginDate().format(T(java.time.format.DateTimeFormatter).ofPattern('yyyyMM'))")
          public UserData getUserData(User user) {
              return userService.getData(user);
          }
      }
      
      • 삼항 연사자를 활용하여 조건에 따라 다른 키를 생성
      • 회원인 경우만 마지막 로그인 날짜를 넣기
      • 예시 - "guest:abc123xyz", "member:12345:202412"

    • 시간대별 다른 캐시 정책

      @Service
      public class ConditionalCacheService {
      
          @Cacheable(value = "timeBasedData",
                  key = "T(java.time.LocalTime).now().getHour() < 9 ? 'night:' + #dataType : 'day:' + #dataType + ':' + #priority.toString()")
          public BusinessData getTimeBasedData(String dataType, Priority priority) {
              return businessService.getData(dataType, priority);
          }
      }
      
      • 정적 메서드(ex. LocalTime.now())를 활용하여 조건에 따라 다른 키를 생성
      • 예시 - "night:sales", "day:inventory:MEDIUM"

SpEL에서 컬렉션과 배열 처리

  1. 컬렉션과 배열의 요소 접근

    • 리스트 첫 번째 요소 활용

      @Cacheable(value = "categoryProducts",
              key = "#categories.isEmpty() ? 'default' : #categories[0] + ':size:' + #categories.size()")
      public List<Product> getProductsByCategories(List<String> categories) {
          return productService.findByCategories(categories);
      }
      
      • 배열이 비어있는 경우 조건 처리
      • #categories[0] - 리스트 첫 번째 요소 접근 (무조건 첫번째 요소)
      • #categories.^[true] - 조건 만족하는 첫번째 요소
        • 조건이 true이므로 첫번째 요소를 바로 가져오므로 첫번째 요소 선택과 같다.
        • 다만, #categories[0] 보다 안전한 접근 방식이다.
      • 예시 - "default", "electronics:size:3", "books:size:1"

    • 배열 마지막 요소 활용

      @Cacheable(value = "pathData",
              key = "#pathSegments.length > 0 ? #pathSegments[#pathSegments.length-1] : 'root'")
      public PathData getDataByPath(String[] pathSegments) {
          return pathService.getData(pathSegments);
      }
      
      • #pathSegments[#pathSegments.length-1] - 배열 마지막 요소
      • #categories.$[true] - 조건 만족하는 마지막 요소
      • 예시 - "root", "profile", "file.txt"
        • 웹사이트 경로 : /admin/users/profile
        • 파일 시스템 경로 : /home/user/documents/file.txt

    • 컬렉션 필터링

      @Cacheable(value = "activeUsers",
              key = "'active:' + #users.![#this.isActive()].size() + ':total:' + #users.size()")
      public UserSummary getUserSummary(List<User> users) {
          return userService.summarize(users);
      }
      
      @Cacheable(value = "activeUsers",
              key = "'active:' + #users.?[#this.isActive()].size() + ':total:' + #users.size()")
      
      • #users.![#this.isActive()] - 컬렉션 프로젝션으로 필터링

        • 각 요소에 표현식을 적용 후 모든 결과 요소결과 컬렉션 반환
        • 여기서 프로젝션은 단순히 모든 요소를 메서드 호출 처리하는 것 뿐이다.
        • 예시 - "active:5:total:5"

      • #users.?[#this.isActive()] - 컬렉션 셀렉션으로 필터링

        • 각 요소에 표현식을 적용 후 조건에 맞는 요소만을 선택하여 필터링된 결과 컬렉션 반환
        • isActive() 결과가 크기에 영향을 미친다.
        • 예시 - "active:3:total:5"


  2. 컬렉션 연산자 활용

    • 컬렉션 조건 검사

      @Cacheable(value = "validatedData",
              key = "#ids.?[#this > 0].size() + ':validated:' + #validateAll")
      public ValidationResult validateIds(List<Long> ids, boolean validateAll) {
          return validationService.validate(ids, validateAll);
      }
      
      • boolean validateAll로 엄격히 검증하는 경우와 아닌 경우 나눠서 키 생성
      • #ids.?[#this > 0] - 조건에 맞는 요소 필터링
        • #this현재 반복되고 있는 컬렉션의 각 요소이다.
        • 즉, List<Long> ids = Arrays.asList(1L, -2L, 3L, 0L, 5L, -7L);에서 조건에 맞는 양수 ID만 필터링한다.
      • 예시 - "3:validated:true", "4:validated:false", "0:validated:true"

    • 컬렉션 변환 및 조합

      @Cacheable(value = "transformedData",
              key = "'sorted:' + #tags.![#this.toLowerCase()].^[true] + ':' + #tags.size()")
      public ProcessedData processWithTags(List<String> tags) {
          return processor.process(tags);
      }
      
      • #tags.![#this.toLowerCase()] - 모든 요소에 변환 적용
        • List<String> tags = Arrays.asList("JAVA", "Spring", "REDIS", "mysql"); 일 때, ["java", "spring", "redis", "mysql"]를 반환한다.
      • #tags.^[true] - 첫 번째 요소 선택
      • 예시 - "sorted:java:4", "sorted:null:0"

SpEL의 정규표현식문자열 고급 처리

  1. 정규표현식 활용

    • 이메일 도메인 추출

      @Cacheable(value = "domainStats",
              key = "#email.matches('.+@(.+)') ? #email.replaceAll('.+@(.+)', '$1') : 'invalid'")
      public DomainStatistics getDomainStats(String email) {
          return statsService.getByEmailDomain(email);
      }
      
      • .+@(.+) 정규표현식

        • . - 줄바꿈 문자 제외 모든 단일 문자
        • + - 바로 앞의 패턴이 1번 이상 반복된다는 의미
        • 즉, .+는 ".", "123", "!@#” 등 줄바꿈 제외 모든 문자가 1번이상 반복되는 것
        • ()는 이렇게 묶으면 별도의 하나의 그룹으로 캡처
        • $1은 첫 번째 캡처 그룹((...))에 의해 캡처된 문자열을 참조

      • String 클래스 메서드의 matches(), replaceAll() 활용

        • 정규표현식을 통해 이메일 형식이 맞는지 파악 후
          • 형식이 맞다면, 도메인만 가져오기
          • 형식이 틀리다면, invalid 설정
      • 예시 - "example.com", "sub.domain.co.kr", "invalid"



    • 전화번호 형식 정규화

      @Cacheable(value = "phoneData",
              key = "#phone.replaceAll('[^0-9]', '') + ':' + #region")
      public PhoneInfo getPhoneInfo(String phone, String region) {
          return phoneService.getInfo(phone, region);
      }
      
      • 전화번호 문자열에서 숫자가 아닌 모든 문자를 제거한다.
      • 예시 - "01012345678:KR", "1234567890:US"


    • URL 경로 파싱

      @Cacheable(value = "urlCache",
              key = "#url.matches('https?://[^/]+/(.+)') ? #url.replaceAll('https?://[^/]+/(.+)', '$1').split('/')[0] : 'home'")
      public PageData getPageData(String url) {
          return pageService.getData(url);
      }
      
      • 정규표현식
        • https? - ?는 수량자 표현으로 바로 앞의 s가 있거나 없을 수도 있다는 의미
        • [^/]+ - 문자 세트를 정의, 이것은 문자들 중 하나에 매칭된다.
          • ^ 는 부정의 의미로, [^/]/를 제외한 모든 문자에 매칭한다는 의미
          • +로 인해 이런한 매칭이 1번이상 반복된다는 의미
      • 도메인 이후의 경로만을 추출하고 이 경로를 /를 구분자로 분리 후 제일 첫 경로만을 사용
      • 예시 - "products", "home"


  2. 문자열 고급 연산

    • 문자열 슬라이싱과 변환

      @Cacheable(value = "textProcessing",
              key = "#text.length() > 50 ? #text.substring(0, 50).replaceAll('\\s+', '_') + '_truncated' : #text.replaceAll('\\s+', '_')")
      public ProcessedText processText(String text) {
          return textProcessor.process(text);
      }
      
      • \\s+ - 모든 공백 문자(\s)가 한 번 이상(+) 반복
      • 파라미터로 받은 문자열의 길이가 50 초과라면, 50까지만 추출하고 _truncated을 붙이고, 아니라면 그냥 사용
      • 모든 공백 문자_로 전환한다.
      • 예시 - "This_is_a_very_long_text_that_exceeds_fifty_c_truncated"

    • 해시 기반 키 생성

      @Cacheable(value = "hashedKeys",
              key = "T(java.lang.String).valueOf(T(java.util.Objects).hash(#param1, #param2, #param3)).substring(0, 8)")
      public ComplexData getComplexData(String param1, String param2, String param3) {
          return complexService.getData(param1, param2, param3);
      }
      
      • 여러 개의 입력 파라미터(param1, param2, param3)를 사용하여 고유한 해시 값 기반의 캐시 키를 생성
      • 해시 기반 문자열의 앞 8자리만 사용
      • 예시 - "3817f2a4", "9d2b5c7e"

SpEL의 환경 변수 및 시스템 속성 정보 활용

  1. 환경 변수와 시스템 속성

    • 환경별 캐시 분리

      @Cacheable(value = "envSpecificData",
              key = "T(System).getProperty('spring.profiles.active', 'default') + ':' + #dataType")
      public EnvironmentData getEnvironmentData(String dataType) {
          return environmentService.getData(dataType);
      }
      
      • 자바 런타임 시스템의 시스템 프로퍼티에 따라 캐시된 데이터를 분리한다.
        • Spring의 경우, spring.profiles.active와 같은 프로필 정보가 자동으로 JVM 시스템 프로퍼티로 설정되거나 환경 변수로 주입된다.
      • T(System).getProperty('spring.profiles.active', 'default')
        • System 클래스자바 런타임 시스템에 대한 표준 입출력, 시스템 프로퍼티, 환경 변수 등 다양한 시스템 관련 유틸리티 메서드를 제공
        • 이를 통해 설정된 profile 정보를 읽어온다. 단, 없다면 default를 사용
      • 예시 - "prod:users", "default:boards"


    • JVM 정보 기반 키

      @Cacheable(value = "jvmData",
              key = "'jvm:' + T(System).getProperty('java.version') + ':heap:' + (T(Runtime).getRuntime().maxMemory() / 1024 / 1024) + 'MB'")
      public JvmStatistics getJvmStats() {
          return jvmService.getStatistics();
      }
      
      • JVM의 자바 버전과 최대 힙 메모리를 읽어온다.
        • JVM의 시스템 속성현재 JVM 런타임 환경에 대한 정보를 직접 가져오는 자바 표준 라이브러리 함수 활용
        • Runtime 클래스는 자바 애플리케이션의 런타임 환경과 상호작용할 수 있는 클래스
      • 예시 - "jvm:17.0.3:heap:4096MB"


    • 서버 정보 포함

      @Cacheable(value = "serverData",
              key = "T(java.net.InetAddress).getLocalHost().getHostName() + ':' + #requestType")
      public ServerResponse getServerData(String requestType) {
          return serverService.process(requestType);
      }
      
      • 분산 환경에서 각 서버의 캐시를 고유하게 유지하거나 서버 인스턴스 문제를 디버깅할 때 유용하다.
      • java.net.InetAddress 클래스
        • IP 주소를 나타내는 Java 클래스
        • IP 주소 표현 (IPv4, IPv6)
        • 호스트 이름(예: www.google.com) 조회
        • 로컬 호스트 정보
      • 예시 - "my-app-server-01:config"


  2. 동적 시간 기반 키

    • 비즈니스 시간 기반 (9-18시는 1분, 그 외는 10분 캐시)

      @Cacheable(value = "businessHourData",
              key = "#dataId + ':' + (T(java.time.LocalTime).now().getHour() >= 9 && T(java.time.LocalTime).now().getHour() <= 18 ? T(System).currentTimeMillis() / 60000 : T(System).currentTimeMillis() / 600000)")
      public BusinessData getBusinessHourData(Long dataId) {
          return businessService.getData(dataId);
      }
      
      • T(System).currentTimeMillis() / 60000
        • 비즈니스 시간 내에 실행된다.
        • 분 단위 마다 캐시를 갱신하겠다는 의미 (분이 달라져야 새로운 키 저장)
      • T(System).currentTimeMillis() / 600000
        • 비즈니스 시간이 아닌 경우 실행된다.
        • 10분 단위 마다 캐시를 갱신하겠다는 의미
      • 예시 - "123:2598765432"


    • 월별/주별 데이터와 영구 데이터 동적 분기

      @Cacheable(value = "periodicData",
              key = "#isPermanent ? 'permanent:' + #userId : 'monthly:' + T(java.time.YearMonth).now().toString() + ':' + #userId")
      public UserData getPeriodicData(Long userId, boolean isPermanent) {
          return userDataService.getData(userId, isPermanent);
      }
      
      • boolean isPermanent을 통해 영구 데이터 여부를 판단 후 동적 키 생성
      • 예시 - "permanent:456", "monthly:2025-07:456"

커스텀 유틸리티 클래스 활용

  • 복잡한 캐시 키 생성 로직을 @Cacheable 애노테이션으로 직접 작성하는 대신, 별도의 유틸리티 메서드로 분리하여 관리하는 방식


  1. 캐시 키 생성 전용 유틸리티 생성

    public class CacheKeyUtil {
    
        public static String generateTimeSlotKey(String baseKey) {
            LocalTime now = LocalTime.now();
            String timeSlot;
    
            if (now.isBefore(LocalTime.of(6, 0))) {
                timeSlot = "dawn";
            } else if (now.isBefore(LocalTime.of(12, 0))) {
                timeSlot = "morning";
            } else if (now.isBefore(LocalTime.of(18, 0))) {
                timeSlot = "afternoon";
            } else {
                timeSlot = "evening";
            }
    
            return baseKey + ":" + timeSlot;
        }
    
        public static String hashLongParams(Object... params) {
            return String.valueOf(Objects.hash(params)).substring(0, 8);
        }
    }
    
    
    • baseKey를 인자로 받아 기본 키에 시간대 정보를 추가하는 방식
    • 복잡하거나 긴 파라미터를 해시 기반 키로 변환해야 할 때, 재사용


  2. 캐시 키 생성 전용 유틸리티 활용

    @Service
    public class UtilityBasedCacheService {
                                                                        
            // 시간대별 키 생성
            @Cacheable(value = "timeSlotData",
                    key = "T(com.example.util.CacheKeyUtil).generateTimeSlotKey(#baseKey)")
            public TimeSlotData getTimeSlotData(String baseKey) {
                return timeSlotService.getData(baseKey);
            }
            
            
            // 긴 파라미터 해시 처리
            @Cacheable(value = "complexParams",
                    key = "T(com.example.util.CacheKeyUtil).hashLongParams(#param1, #param2, #param3, #param4)")
            public ComplexResult processComplexParams(ComplexObject param1, AnotherObject param2,
                                                    String param3, List<String> param4) {
                return complexProcessor.process(param1, param2, param3, param4);
            }
    }
    
    
    • 이러한 유틸리티 클래스는 SpEL에서 정적 메서드 호출로 사용된다.

커스텀 KeyGenerator 활용

  1. 커스텀 keyGenerator 사용하기

    @Cacheable(value = "products", keyGenerator = "customKeyGenerator")
    public Product complexMethod(Object... params) { ... }
    
    • KeyGenerator 구현체 만들기

      @Component("customKeyGenerator")
      public class CustomKeyGenerator implements KeyGenerator {
          
          @Override
          public Object generate(Object target, Method method, Object... params) {
              StringBuilder keyBuilder = new StringBuilder();
              keyBuilder.append(target.getClass().getSimpleName());
              keyBuilder.append(":");
              keyBuilder.append(method.getName());
              
              for (Object param : params) {
                  keyBuilder.append(":");
                  keyBuilder.append(param.toString());
              }
              
              return keyBuilder.toString();
          }
      }
      
      • 이름은 원하는 이름으로 작성
      • 복잡한 키가 필요할 때만 keyGenerator 사용


  2. 컨텍스트 인식 KeyGenerator

    @Component("contextAwareKeyGenerator")
    public class ContextAwareKeyGenerator implements KeyGenerator {
    
        @Autowired
        private SecurityContextHolder securityContext;
    
        @Override
        public Object generate(Object target, Method method, Object... params) {
            StringBuilder keyBuilder = new StringBuilder();
    
            // 로그인 사용자 캐시 분리
            Authentication auth = SecurityContextHolder.getContext().getAuthentication();
            if (auth != null && auth.isAuthenticated()) {
                keyBuilder.append("user:").append(auth.getName()).append(":");
            } else {
                keyBuilder.append("anonymous:");
            }
    
            // 권한이 필요한 메서드 분리
            if (method.isAnnotationPresent(AdminOnly.class)) {
                keyBuilder.append("admin:");
            }
    
            // 파라미터 타입별 분리
            for (Object param : params) {
                if (param instanceof Pageable) {
                    Pageable page = (Pageable) param;
                    keyBuilder.append("page:").append(page.getPageNumber())
                            .append(":size:").append(page.getPageSize()).append(":");
                } else if (param instanceof SearchCriteria) {
                    SearchCriteria criteria = (SearchCriteria) param;
                    keyBuilder.append("search:").append(criteria.hashCode()).append(":");
                } else {
                    keyBuilder.append(param != null ? param.toString() : "null").append(":");
                }
            }
    
            return keyBuilder.toString();
        }
    }
    
    
    • 캐시 키를 단순히 메서드 인자로만 구성하는 것이 아니라, 특정 컨텍스트를 반영하여 세밀하게 제어할 수 있다.
    1. 로그인 사용자 캐시 분리
      • 로그인 사용자 정보가 있다면, 다른 사용자와 분리하여 캐시 키를 설정한다.
      • 예시 : user:alice:, anonymous:123:
    2. 권한이 필요한 메서드 분리
      • 캐시를 활용하는 메서드가 특정 권한을 가져야 한다면, 별도의 캐시 키를 설정한다.
      • 예시 : user:bob:admin:reportlist:, user:charlie:
    3. 파라미터 타입별 분리
      • 파라미터 중에서 페이징이나, 커스텀 검색 클래스 관련한 요소라면, 별도 설정한다.
      • 예시
        • user:dave:admin:page:0:size:20:
        • user:emma:search:-987654321:
        • user:frank:books:
        • anonymous:null:


  3. 성능 최적화 KeyGenerator

    @Component("performanceKeyGenerator")
    public class PerformanceOptimizedKeyGenerator implements KeyGenerator {
    
        private static final int MAX_KEY_LENGTH = 100;
        private final MessageDigest digest;
    
        public PerformanceOptimizedKeyGenerator() {
            try {
                this.digest = MessageDigest.getInstance("MD5");
            } catch (NoSuchAlgorithmException e) {
                throw new RuntimeException("MD5 not available", e);
            }
        }
    
        @Override
        public Object generate(Object target, Method method, Object... params) {
            StringBuilder rawKey = new StringBuilder();
            rawKey.append(method.getName()).append(":");
    
            for (Object param : params) {
                if (param != null) {
                    rawKey.append(param.toString()).append(":");
                }
            }
    
            String key = rawKey.toString();
    
            // 키가 너무 길면 해시 처리
            if (key.length() > MAX_KEY_LENGTH) {
                synchronized (digest) {
                    digest.reset();
                    byte[] hash = digest.digest(key.getBytes(StandardCharsets.UTF_8));
                    return Base64.getEncoder().encodeToString(hash);
                }
            }
    
            return key;
        }
    }
    
    • 메서드 이름을 포함하고, 값이 null이 아닌 모든 파라미터를 포함한다.


    • 다만, 캐시 키 생성 시 성능과 효율성에 초점을 둔다.

      • 복잡한 파라미터가 있는 경우 처럼 캐시 키가 너무 길어지면
        • 메모리 사용량 증가
        • 키를 저장/조회하는 데 필요한 네트워크 전송량 및 처리 시간도 길어질 수 있다.
      • MAX_KEY_LENGTH를 설정하여 지나치게 긴 경우 해시 처리

    • 해시 처리
      • 데이터 노출 최소화 (민감 정보 차단)
      • 키 비교 및 인덱싱에서 더 효율적

커스텀 CacheResolver 활용

  1. 커스텀 CacheResolver 구현체 생성

    @Component("smartCacheResolver")
    public class SmartCacheResolver implements CacheResolver {
        
        @Override
        public Collection<? extends Cache> resolveCaches(CacheOperationInvocationContext<?> context) {
            String methodName = context.getMethod().getName();
            Object[] args = context.getArgs();
            
            if (methodName.startsWith("getRealtime")) {
                return Arrays.asList(fastCache.getCache("realtime"));  // 빠른 캐시
            }
            
            if (args.length > 0 && args[0] != null && args[0].toString().startsWith("temp_")) {
                return Arrays.asList(fastCache.getCache("temporary"));  // 임시 캐시
            }
            
            return Arrays.asList(persistentCache.getCache("default"));  // Redis 캐시
        }
    }
    
    • 메서드 이름이나 파라미터로 판단
    • 런타임에 메서드 정보를 분석해서 적절한 캐시를 자동 선택


  2. 커스텀 CacheResolver 사용

    @Cacheable(cacheResolver = "smartCacheResolver")
    public Data getRealtimeData(String key) {
        return dataService.getData(key);
    }
    
    @Cacheable(cacheResolver = "smartCacheResolver") 
    public Data getTemporaryData(String temp_key) {
        return dataService.getData(temp_key);
    }
    
    @Cacheable(cacheResolver = "smartCacheResolver")
    public Data getNormalData(String key) {
        return dataService.getData(key);
    }
    
    • 메서드 이름, 파라미터 등을 보고 자동으로 캐시를 동적 선택
    • cacheManager보다 더 똑똑한 동적 선택
    • 언제 사용?
      • 매우 복잡한 캐시 선택 로직이 필요할 때만 실무에서 드물게 사용
79조건부 캐싱(condition, unless)과 최적화 전략은?보통
조건부 캐싱(condition, unless)과 최적화 전략에 대한 답변입니다.
상세 설명

d

진행중

현재 작성 중입니다

80sync 속성과 캐시 스탬피드 방지 전략은?어려움
sync 속성과 캐시 스탬피드 방지 전략에 대한 답변입니다.
상세 설명

캐시 스탬피드 방지와 동기화

  • 내용이 추가될 예정입니다
진행중

현재 작성 중입니다