Spring 12: 캐싱
성능 최적화를 위한 캐시 전략
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. 캐시에서 데이터 조회
- 2. 캐시 히트: 데이터 반환
- 3. 캐시 미스: DB에서 조회
- 4. 조회된 데이터를 캐시에 저장
- 5. 데이터 반환
쓰기 과정
- 1. DB에 데이터 저장
- 2. 캐시에서 해당 데이터 제거
- 3. 다음 읽기 시 캐시 미스 발생
- 4. DB에서 최신 데이터 조회
- 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과 캐시 계층 통합
- • 메시지 큐를 활용한 캐시 동기화
- • 마이크로서비스 환경에서의 캐시 전략