오늘은 Spring Boot 애플리케이션에서 리뷰 서비스 기능 구현 중 발생한 캐시 삭제 문제와 예외 처리의 안전성 부족에 대해 해결한 경험을 정리해보려 합니다.
@Service
public class ReviewService {
@Cacheable(value = "averageRatingCache", key = "#storeId")
public Double calculateAverageRating(UUID storeId) {
// 실제 평균 평점 계산 로직 (예: 데이터베이스 조회)
double rating = 0.0; // 예시로 데이터베이스에서 평점 가져오기
return rating;
}
// @CacheEvict(value = "averageRatingCache", key = "#storeId")
public void reportReview(UUID reviewId, String reportMessage, Long userId) {
// 리뷰를 신고하는 로직
// ...
UUID storeId = getStoreIdFromReview(reviewId); // 리뷰에서 가게 ID를 가져오는 메서드
evictCache(storeId); // 이 호출은 프록시를 거치지 않음 (Self-Invocation 문제 발생)
}
@CacheEvict(value = "averageRatingCache", key = "#storeId")
public void evictCache(UUID storeId) {
System.out.println("Cache evicted for storeId: " + storeId);
}
private UUID getStoreIdFromReview(UUID reviewId) {
// 리뷰 ID로부터 가게 ID를 가져오는 로직
return UUID.randomUUID(); // 실제 로직으로 변경 필요
}
}
Spring Boot 애플리케이션에서 리뷰 서비스를 구현하는 과정에서, 캐시 삭제와 관련된 문제가 발생했습니다. 리뷰를 신고할 때 해당 가게의 평균 평점 캐시를 삭제해야 하는 요구사항이 있었고, 이를 위해 @CacheEvict 어노테이션을 사용했습니다. 하지만, 캐시가 예상대로 삭제되지 않는 문제가 발생했습니다.
또한, 캐시 삭제 과정에서 cacheManager.getCache 메서드 호출 시, 캐시 객체가 존재하지 않을 경우 NullPointerException (NPE)가 발생할 수 있는 위험이 있었습니다. 이와 같은 상황을 충분히 고려하지 않고 구현한 점이 아쉬웠습니다.
이 문제는 두 가지 원인과 관련이 있었습니다:
Spring AOP의 프록시 메커니즘과 Self-Invocation 문제
프록시 메커니즘: Spring AOP는 프록시 객체를 사용하여 메서드 호출을 가로채고, 부가적인 로직(예: 캐싱, 트랜잭션 관리)을 추가합니다. 그러나 프록시 객체는 외부에서 메서드를 호출할 때만 개입합니다.
Self-Invocation 문제: 동일 클래스 내의 메서드 호출은 프록시를 거치지 않고 직접 호출되기 때문에, AOP 기능(예:
@CacheEvict)이 적용되지 않았습니다.reviewReport메서드 내에서evictCache메서드를 직접 호출했을 때, 프록시를 경유하지 않아 캐시 무효화가 수행되지 않았습니다.
캐시 관리와 예외 처리의 안전성 부족:
cacheManager.getCache가 null을 반환할 경우 NPE가 발생할 가능성을 충분히 고려하지 않았습니다. 이는 예외 처리에 대한 안전성 부족을 의미하며, 안정적인 애플리케이션 동작을 보장하기 위해 개선이 필요했습니다.문제를 해결하기 위해 두 가지 접근 방안을 고려했습니다:
CacheManager를 사용한 명시적 캐시 무효화:
@CacheEvict 대신 Spring의 CacheManager를 사용하여 캐시를 직접 무효화하도록 수정했습니다. 이 방법을 통해 프록시 문제를 우회할 수 있었고, 캐시가 정상적으로 삭제되었습니다. 또한, cacheManager.getCache 호출 시 null 체크를 추가하여 예외를 방지했습니다.public class ReviewService {
private final CacheManager cacheManager;
public ReviewService(CacheManager cacheManager) {
this.cacheManager = cacheManager;
}
public ReviewResponseDto reportReview(UUID reviewId, String reportMessage, Long userId) {
// 리뷰를 신고하는 로직
UUID storeId = getStoreIdFromReview(reviewId);
clearAverageRatingCache(storeId); // 명시적으로 캐시 무효화
return new ReviewResponseDto(); // 실제 필요한 반환 값
}
private void clearAverageRatingCache(UUID storeId) {
Cache cache = cacheManager.getCache("averageRatingCache");
if (cache != null) {
cache.evict(storeId);
System.out.println("Cache evicted for storeId: " + storeId);
} else {
System.out.println("Cache not found: averageRatingCache");
}
}
private UUID getStoreIdFromReview(UUID reviewId) {
// 리뷰 ID로부터 가게 ID를 가져오는 로직
return UUID.randomUUID(); // 실제 로직으로 변경 필요
}
}
서비스 구조 리팩토링:
CacheManager를 사용하여 캐시를 명시적으로 무효화하고 null 체크를 추가한 후, 리뷰 신고 기능에서 캐시가 정상적으로 삭제되는 것을 확인했습니다. 다양한 테스트 케이스를 통해 캐시 무효화가 올바르게 작동하고 예외가 발생하지 않는지 검증할 수 있었습니다.
Spring AOP의 프록시 메커니즘과 자기 호출에 대한 이해가 중요함을 배웠습니다. 캐시 무효화와 같은 기능은
CacheManager를 사용하여 명시적으로 관리하는 것이 더 안정적일 수 있습니다.
예외 처리의 중요성 또한 다시 한번 깨닫게 되었습니다. 예상치 못한 예외 상황을 충분히 고려하여, 애플리케이션의 안정성을 높여야 합니다.
- 캐시 관련 로직을 더욱 모듈화하여 코드의 재사용성과 유지보수성을 높일 계획입니다.
- 캐시 무효화의 성능과 영향을 면밀히 분석하여 최적화할 수 있는 부분을 찾고 개선할 예정입니다.
- 예외 상황에 대한 처리를 강화하고, 예상치 못한 예외 발생 시에도 애플리케이션이 안정적으로 동작할 수 있도록 더욱 면밀히 검토할 것입니다.
- 로그와 모니터링 도구를 활용하여 캐시 동작과 무효화가 적절히 이루어지고 있는지 지속적으로 모니터링하고, 문제 발생 시 빠르게 대응할 수 있도록 준비합니다.