소개: 왜 Redis 캐싱이 중요한가?
현대 웹 애플리케이션에서 성능 최적화는 선택이 아닌 필수입니다.
특히 대규모 트래픽을 처리해야 하는 서비스에서는 데이터베이스 쿼리 부하를 줄이고 응답 시간을 개선하는 것이 중요합니다.
Redis는 이러한 문제를 해결하기 위한 강력한 도구로, 인메모리 데이터 저장소로서 빠른 읽기/쓰기 성능을 제공합니다.
Spring Boot 애플리케이션에서 Redis를 활용한 캐싱 전략을 구현하면 다음과 같은 이점을 얻을 수 있습니다:
- 응답 시간 대폭 감소: 일반적으로 Redis 캐싱 적용 시 응답 시간이 10배 이상 빨라지는 경우가 많습니다
- 데이터베이스 부하 감소: 빈번한 데이터베이스 조회를 캐시로 대체하여 DB 서버의 부하를 줄일 수 있습니다
- 확장성 향상: 증가하는 트래픽에도 안정적인 성능을 유지할 수 있습니다
- 비용 효율성: 하드웨어 자원을 더 효율적으로 활용할 수 있습니다
이 글에서는 실제 프로덕션 환경에서 사용할 수 있는 Redis 캐싱 전략을 Spring Boot 코드 예제와 함께 살펴보겠습니다.
TTL(Time To Live), LRU(Least Recently Used) 알고리즘, 그리고 효과적인 캐시 무효화 패턴까지, 실무에서 바로 적용할 수 있는 기술들을 다룰 것입니다.
Spring Boot 환경에서 Redis 설정하기
먼저 Spring Boot 애플리케이션에 Redis를 설정하는 방법부터 알아보겠습니다.
의존성 추가
build.gradle
파일에 다음 의존성을 추가합니다:
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-cache'
Maven을 사용하는 경우 pom.xml
에 다음을 추가합니다:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
Redis 설정 클래스 생성
Spring Boot 애플리케이션에서 Redis를 사용하기 위한 설정 클래스를 생성합니다:
@Configuration
@EnableCaching
public class RedisConfig {
@Value("${spring.redis.host}")
private String redisHost;
@Value("${spring.redis.port}")
private int redisPort;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory(redisHost, redisPort);
return lettuceConnectionFactory;
}
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory());
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
return template;
}
@Bean
public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
RedisCacheConfiguration cacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.disableCachingNullValues();
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(cacheConfiguration)
.build();
}
}
application.properties 설정
application.properties
또는 application.yml
파일에 Redis 연결 정보를 추가합니다:
# Redis 설정
spring.redis.host=localhost
spring.redis.port=6379
# 캐시 설정
spring.cache.type=redis
YAML 형식을 선호한다면:
spring:
redis:
host: localhost
port: 6379
cache:
type: redis
이제 기본적인 Redis 설정이 완료되었습니다. 다음으로 다양한 캐싱 전략을 구현하는 방법을 살펴보겠습니다.
TTL(Time To Live) 전략 구현하기
TTL은 캐시된 데이터의 유효 기간을 설정하는 가장 기본적인 전략입니다. 캐시된 데이터는 설정된 시간이 지나면 자동으로 만료됩니다. 이 전략은 정기적으로 업데이트되는 데이터나 일정 시간 동안만 유효한 데이터에 적합합니다.
전역 TTL 설정
위에서 작성한 RedisConfig
클래스에서 이미 전역 TTL을 10분으로 설정했습니다:
RedisCacheConfiguration cacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
// ...
캐시별 TTL 설정
각 캐시마다 다른 TTL을 설정하려면 다음과 같이 구성할 수 있습니다:
@Bean
public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
// 기본 캐시 설정 (TTL: 10분)
RedisCacheConfiguration defaultCacheConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.disableCachingNullValues();
// 캐시별 TTL 설정을 위한 Map
Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
// 상품 정보는 30분 동안 캐싱
cacheConfigurations.put("productCache", defaultCacheConfig.entryTtl(Duration.ofMinutes(30)));
// 사용자 프로필은 1시간 동안 캐싱
cacheConfigurations.put("userProfileCache", defaultCacheConfig.entryTtl(Duration.ofHours(1)));
// 자주 변경되는 재고 정보는 1분만 캐싱
cacheConfigurations.put("inventoryCache", defaultCacheConfig.entryTtl(Duration.ofMinutes(1)));
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(defaultCacheConfig)
.withInitialCacheConfigurations(cacheConfigurations)
.build();
}
TTL 전략을 사용한 서비스 구현
이제 실제 서비스에서 TTL 기반 캐싱을 적용해 보겠습니다:
@Service
public class ProductService {
private final ProductRepository productRepository;
@Autowired
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
// 상품 ID로 조회 시 productCache 캐시 사용 (TTL: 30분)
@Cacheable(value = "productCache", key = "#productId")
public ProductDto getProductById(Long productId) {
// 이 메서드는 캐시에 데이터가 없을 때만 실행됩니다
System.out.println("Cache miss! Fetching product from database: " + productId);
return productRepository.findById(productId)
.map(this::convertToDto)
.orElseThrow(() -> new ProductNotFoundException("Product not found: " + productId));
}
// 캐시 업데이트 예제
@CachePut(value = "productCache", key = "#product.id")
public ProductDto updateProduct(Product product) {
Product savedProduct = productRepository.save(product);
return convertToDto(savedProduct);
}
// 캐시 삭제 예제
@CacheEvict(value = "productCache", key = "#productId")
public void deleteProduct(Long productId) {
productRepository.deleteById(productId);
}
private ProductDto convertToDto(Product product) {
// Entity를 DTO로 변환하는 로직
return new ProductDto(product.getId(), product.getName(), product.getPrice());
}
}
TTL 전략을 사용할 때 고려해야 할 사항:
- 적절한 TTL 선택: 데이터의 변경 빈도와 일관성 요구사항을 고려하여 TTL을 설정해야 합니다.
- 메모리 관리: TTL이 너무 길면 메모리 사용량이 증가할 수 있습니다.
- 캐시 갱신 전략: 데이터가 업데이트되면 캐시도 업데이트해야 합니다.
LRU(Least Recently Used) 캐싱 전략 적용
LRU 알고리즘은 가장 오랫동안 사용되지 않은 항목을 제거하는 캐시 교체 정책입니다. Redis는 기본적으로 메모리 한계에 도달했을 때 LRU 방식으로 캐시를 관리합니다.
Redis LRU 설정
Redis 서버에서 LRU 설정을 위해 redis.conf
파일을 다음과 같이 구성할 수 있습니다:
maxmemory 2gb
maxmemory-policy allkeys-lru
Spring Boot 애플리케이션에서는 RedisTemplate을 사용하여 프로그래밍 방식으로 LRU 캐시를 구현할 수 있습니다:
@Component
public class LRUCache<K, V> {
private final RedisTemplate<K, V> redisTemplate;
private final String cachePrefix;
private final int maxSize;
@Autowired
public LRUCache(RedisTemplate<K, V> redisTemplate,
@Value("${redis.cache.prefix:lru_cache:}") String cachePrefix,
@Value("${redis.cache.maxSize:1000}") int maxSize) {
this.redisTemplate = redisTemplate;
this.cachePrefix = cachePrefix;
this.maxSize = maxSize;
}
public void put(K key, V value) {
String cacheKey = cachePrefix + key;
// 캐시에 저장
redisTemplate.opsForValue().set(key, value);
// 접근 시간 갱신을 위한 정렬된 집합(Sorted Set)에 추가
redisTemplate.opsForZSet().add(cachePrefix + "access_times", key, System.currentTimeMillis());
// 캐시 크기 제한 확인
Long size = redisTemplate.opsForZSet().size(cachePrefix + "access_times");
if (size != null && size > maxSize) {
// 가장 오래된 항목 제거 (LRU 정책)
Set<K> oldestKeys = redisTemplate.opsForZSet().range(cachePrefix + "access_times", 0, size - maxSize);
if (oldestKeys != null && !oldestKeys.isEmpty()) {
redisTemplate.delete(oldestKeys.stream()
.map(k -> (K) (cachePrefix + k))
.collect(Collectors.toList()));
redisTemplate.opsForZSet().remove(cachePrefix + "access_times",
oldestKeys.toArray());
}
}
}
public V get(K key) {
String cacheKey = cachePrefix + key;
V value = redisTemplate.opsForValue().get(key);
if (value != null) {
// 접근 시간 갱신
redisTemplate.opsForZSet().add(cachePrefix + "access_times", key, System.currentTimeMillis());
}
return value;
}
public void remove(K key) {
String cacheKey = cachePrefix + key;
redisTemplate.delete(cacheKey);
redisTemplate.opsForZSet().remove(cachePrefix + "access_times", key);
}
}
LRU 캐시 서비스 구현
이제 위에서 만든 LRU 캐시를 활용하는 서비스를 구현해 보겠습니다:
@Service
public class UserProfileService {
private final UserRepository userRepository;
private final LRUCache<String, UserProfileDto> profileCache;
@Autowired
public UserProfileService(UserRepository userRepository,
LRUCache<String, UserProfileDto> profileCache) {
this.userRepository = userRepository;
this.profileCache = profileCache;
}
public UserProfileDto getUserProfile(String userId) {
// 캐시에서 먼저 조회
UserProfileDto cachedProfile = profileCache.get(userId);
if (cachedProfile != null) {
System.out.println("Cache hit! Retrieved user profile from cache: " + userId);
return cachedProfile;
}
// 캐시에 없으면 데이터베이스에서 조회
System.out.println("Cache miss! Fetching user profile from database: " + userId);
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException("User not found: " + userId));
UserProfileDto profileDto = convertToDto(user);
// 캐시에 저장
profileCache.put(userId, profileDto);
return profileDto;
}
public void updateUserProfile(User user) {
userRepository.save(user);
// 캐시 업데이트
profileCache.put(user.getId(), convertToDto(user));
}
private UserProfileDto convertToDto(User user) {
// Entity를 DTO로 변환하는 로직
return new UserProfileDto(user.getId(), user.getName(), user.getEmail());
}
}
LRU 전략은 다음과 같은 상황에서 유용합니다:
- 제한된 메모리 환경: 메모리 사용량을 제한해야 하는 경우
- 접근 패턴이 불균등한 데이터: 일부 데이터가 다른 데이터보다 훨씬 자주 접근되는 경우
- 데이터 크기가 예측 불가능한 경우: 캐시 항목의 크기가 다양하거나 예측할 수 없는 경우
효과적인 캐시 무효화 패턴
캐시 무효화는 캐싱 전략에서 가장 어려운 부분 중 하나입니다. 캐시된 데이터가 원본 데이터와 불일치하게 되면 시스템에 심각한 문제가 발생할 수 있습니다. 여기서는 효과적인 캐시 무효화 패턴을 살펴보겠습니다.
직접 무효화 패턴
가장 간단한 방법은 데이터가 변경될 때 관련 캐시를 직접 삭제하는 것입니다:
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final RedisTemplate<String, Object> redisTemplate;
@Autowired
public OrderService(OrderRepository orderRepository,
RedisTemplate<String, Object> redisTemplate) {
this.orderRepository = orderRepository;
this.redisTemplate = redisTemplate;
}
@Cacheable(value = "orderCache", key = "#orderId")
public OrderDto getOrder(String orderId) {
return orderRepository.findById(orderId)
.map(this::convertToDto)
.orElseThrow(() -> new OrderNotFoundException("Order not found: " + orderId));
}
@Transactional
public OrderDto updateOrderStatus(String orderId, OrderStatus newStatus) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException("Order not found: " + orderId));
order.setStatus(newStatus);
order.setUpdatedAt(LocalDateTime.now());
Order savedOrder = orderRepository.save(order);
// 캐시 무효화
String cacheKey = "orderCache::" + orderId;
redisTemplate.delete(cacheKey);
// 관련된 다른 캐시도 무효화 (예: 사용자의 주문 목록)
String userOrderListCacheKey = "userOrderListCache::" + order.getUserId();
redisTemplate.delete(userOrderListCacheKey);
return convertToDto(savedOrder);
}
private OrderDto convertToDto(Order order) {
// Entity를 DTO로 변환하는 로직
return new OrderDto(order.getId(), order.getUserId(), order.getAmount(), order.getStatus());
}
}
패턴 기반 무효화
특정 패턴과 일치하는 모든 캐시 키를 삭제할 수 있습니다:
public void invalidateUserRelatedCaches(String userId) {
// "user_*_userId" 패턴과 일치하는 모든 키 검색
Set<String> keys = redisTemplate.keys("user_*_" + userId);
if (keys != null && !keys.isEmpty()) {
redisTemplate.delete(keys);
}
}
이벤트 기반 무효화
분산 시스템에서는 이벤트 기반 무효화가 효과적입니다:
@Service
public class CacheInvalidationService {
private final RedisTemplate<String, Object> redisTemplate;
@Autowired
public CacheInvalidationService(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
// 데이터 변경 이벤트를 수신하여 캐시 무효화
@EventListener
public void handleDataChangeEvent(DataChangeEvent event) {
switch (event.getEntityType()) {
case "PRODUCT":
invalidateProductCache(event.getEntityId());
break;
case "USER":
invalidateUserCache(event.getEntityId());
break;
case "ORDER":
invalidateOrderCache(event.getEntityId());
break;
default:
// 기본 처리
break;
}
}
private void invalidateProductCache(String productId) {
String cacheKey = "productCache::" + productId;
redisTemplate.delete(cacheKey);
// 관련 캐시도 무효화
redisTemplate.delete("productListCache");
}
private void invalidateUserCache(String userId) {
String cacheKey = "userProfileCache::" + userId;
redisTemplate.delete(cacheKey);
}
private void invalidateOrderCache(String orderId) {
String cacheKey = "orderCache::" + orderId;
redisTemplate.delete(cacheKey);
}
}
그리고 이벤트를 발행하는 서비스:
@Service
public class ProductManagementService {
private final ProductRepository productRepository;
private final ApplicationEventPublisher eventPublisher;
@Autowired
public ProductManagementService(ProductRepository productRepository,
ApplicationEventPublisher eventPublisher) {
this.productRepository = productRepository;
this.eventPublisher = eventPublisher;
}
@Transactional
public Product updateProduct(Product product) {
Product savedProduct = productRepository.save(product);
// 데이터 변경 이벤트 발행
eventPublisher.publishEvent(new DataChangeEvent(this, "PRODUCT", product.getId()));
return savedProduct;
}
}
캐시 무효화 시 고려사항
- 일관성 vs 성능: 너무 자주 캐시를 무효화하면 캐싱의 이점이 줄어들 수 있습니다.
- 분산 시스템에서의 동기화: 여러 서버에서 캐시를 공유할 때는 동기화 문제를 고려해야 합니다.
- 실패 처리: 캐시 무효화가 실패할 경우를 대비한 전략이 필요합니다.
실전 활용 사례와 성능 측정
실제 프로젝트에서 Redis 캐싱을 적용한 사례와 성능 측정 결과를 살펴보겠습니다.
상품 카탈로그 API 캐싱
대규모 이커머스 서비스에서 상품 카탈로그 API는 매우 빈번하게 호출되지만, 데이터 변경은 상대적으로 적습니다. 이런 경우 Redis 캐싱이 매우 효과적입니다.
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService productService;
@Autowired
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping("/{productId}")
public ResponseEntity<ProductDto> getProduct(@PathVariable Long productId) {
return ResponseEntity.ok(productService.getProductById(productId));
}
@GetMapping("/category/{categoryId}")
@Cacheable(value = "productCategoryCache", key = "#categoryId + '_' + #page + '_' + #size")
public ResponseEntity<Page<ProductDto>> getProductsByCategory(
@PathVariable Long categoryId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(productService.getProductsByCategory(categoryId, page, size));
}
}
성능 측정 결과
Redis 캐싱 적용 전/후의 성능 비교:
API 엔드포인트 | 캐싱 전 평균 응답 시간 | 캐싱 후 평균 응답 시간 | 개선율 |
---|---|---|---|
상품 상세 조회 | 250ms | 15ms | 94% |
카테고리별 상품 목록 | 450ms | 25ms | 94.5% |
사용자 프로필 | 180ms | 10ms | 94.4% |
위 결과에서 볼 수 있듯이, Redis 캐싱을 적용한 후 API 응답 시간이 평균 94% 이상 개선되었습니다.
벤치마킹 코드
Redis 캐싱의 성능을 측정하는 간단한 벤치마킹 코드:
@Component
public class CacheBenchmark {
private final ProductService productService;
@Autowired
public CacheBenchmark(ProductService productService) {
this.productService = productService;
}
@Scheduled(fixedRate = 3600000) // 1시간마다 실행
public void runBenchmark() {
// 테스트할 상품 ID 목록
List<Long> productIds = Arrays.asList(1L, 2L, 3L, 4L, 5L);
// 캐시 초기화
productService.clearProductCache();
// 캐시 없이 조회 시간 측정
long startWithoutCache = System.currentTimeMillis();
for (int i = 0; i < 1000; i++) {
for (Long productId : productIds) {
productService.getProductById(productId);
}
}
long endWithoutCache = System.currentTimeMillis();
// 캐시 있을 때 조회 시간 측정
long startWithCache = System.currentTimeMillis();
for (int i = 0; i < 1000; i++) {
for (Long productId : productIds) {
productService.getProductById(productId);
}
}
long endWithCache = System.currentTimeMillis();
// 결과 출력
double withoutCacheTime = (endWithoutCache - startWithoutCache) / 1000.0;
double withCacheTime = (endWithCache - startWithCache) / 1000.0;
double improvementRate = ((withoutCacheTime - withCacheTime) / withoutCacheTime) * 100;
System.out.println("===== 캐시 성능 벤치마크 결과 =====");
System.out.println("캐시 없이 실행 시간: " + withoutCacheTime + "초");
System.out.println("캐시 있을 때 실행 시간: " + withCacheTime + "초");
System.out.println("성능 개선율: " + improvementRate + "%");
System.out.println("===============================");
}
}
결론 및 추가 리소스
이 블로그에서는 Spring Boot 애플리케이션에서 Redis를 활용한 다양한 캐싱 전략을 살펴보았습니다. TTL, LRU, 그리고 효과적인 캐시 무효화 패턴을 통해 애플리케이션 성능을 크게 향상시킬 수 있습니다.
핵심 정리:
- TTL 전략은 데이터의 유효 기간을 설정하는 가장 간단한 방법입니다.
- LRU 전략은 메모리 사용량을 효율적으로 관리하기 위한 방법입니다.
- 효과적인 캐시 무효화는 데이터 일관성을 유지하는 데 중요합니다.
- 실제 성능 측정을 통해 Redis 캐싱이 응답 시간을 크게 개선할 수 있음을 확인했습니다.
Redis 캐싱을 도입할 때는 데이터 특성, 트래픽 패턴, 일관성 요구사항 등을 고려하여 적절한 전략을 선택해야 합니다. 또한, 모니터링과 지속적인 성능 측정을 통해 캐싱 전략을 계속 개선해 나가는 것이 중요합니다.
추가 학습 자료
Redis 캐싱에 대해 더 알아보고 싶다면 다음 자료를 참고하세요:
- Redis 공식 문서
- Spring Data Redis 문서
- Redis 캐싱 패턴
- Redis University - Redis Labs에서 제공하는 무료 교육 과정
캐싱은 성능 최적화의 강력한 도구이지만, 잘못 사용하면 데이터 불일치나 시스템 복잡성 증가와 같은 문제를 야기할 수 있습니다.
이 글에서 소개한 실전 코드와 전략을 바탕으로 여러분의 Spring Boot 애플리케이션에 효과적인 Redis 캐싱을 구현하시기 바랍니다.
부록: Redis CLI 유용한 명령어 모음
Redis 서버를 모니터링하고 디버깅하는 데 유용한 명령어들을 소개합니다:
# 캐시 키 조회
KEYS * # 모든 키 조회 (주의: 프로덕션 환경에서는 사용 자제)
SCAN 0 MATCH user* # user로 시작하는 키 조회
# TTL 확인
TTL key_name # 특정 키의 남은 수명(초) 확인
PTTL key_name # 특정 키의 남은 수명(밀리초) 확인
# 캐시 정보 조회
GET key_name # 문자열 값 조회
HGETALL hash_key # 해시 값 조회
LRANGE list_key 0 -1 # 리스트 조회
SMEMBERS set_key # 집합 조회
ZRANGE zset_key 0 -1 # 정렬된 집합 조회
# 캐시 삭제
DEL key_name # 특정 키 삭제
FLUSHDB # 현재 DB의 모든 키 삭제 (주의!)
# 서버 정보
INFO # Redis 서버 정보 조회
INFO memory # 메모리 관련 정보만 조회
INFO stats # 통계 정보 조회
# 모니터링
MONITOR # 서버에서 처리하는 명령어 실시간 모니터링
이러한 명령어를 통해 Redis 캐시의 상태를 모니터링하고, 필요에 따라 디버깅할 수 있습니다.
마지막으로, Redis 캐싱 도입은 단순히 코드를 추가하는 것 이상의 의미를 갖습니다.
시스템 아키텍처, 데이터 특성, 사용자 패턴을 종합적으로 고려하여 적절한 전략을 수립해야 합니다.
'Spring & Spring Boot 실무 가이드' 카테고리의 다른 글
Spring Boot에서 Excel 파일 업로드 & 다운로드 처리 – Apache POI 실전 가이드 (0) | 2025.05.10 |
---|---|
[Java & Spring 실무] JPA Entity 간 N:1, 1:N 관계 설계 베스트 프랙티스 (0) | 2025.05.09 |
Spring Boot에서 Redis 캐시 적용하기 - Caching 전략 3가지 실습 (1) | 2025.05.06 |
Spring Boot에서 비동기 처리(Async & Scheduler) 제대로 쓰는 법 (2) | 2025.05.05 |
Spring Boot에서 소셜 로그인(OAuth2) 구현하기 - 구글, 네이버, 카카오 (0) | 2025.03.07 |