Today I Learned
#리뷰 좋아요 API 구현
In-Memory Cache 와 동시성 문제를 해결해보고자 하는 마음으로 리뷰 도메인에 좋아요 기능을 추가 구현했다.
리뷰 좋아요는 리뷰에 대한 반응이므로 리뷰 도메인 속한다고 판단했고, 도메인 내부에 `like` 패키지를 만들어줬다.
public class Review extends BaseEntity {
@Column(nullable = false)
private int likeCount = 0;
public void incrementLikeCount() {
this.likeCount++;
}
public void decrementLikeCount() {
this.likeCount = Math.max(0, this.likeCount - 1);
}
`likeCount` 필드를 통해 좋아요 수를 관리하며, 증가/감소 메서드를 통해 값을 조정한다.
`Math.max(0, this.likeCount - 1)`을 활용해 음수로 내려가는 것을 방지해줬다.
@Service
@RequiredArgsConstructor
public class ReviewLikeService {
private final ReviewLikeRepository reviewLikeRepository;
private final ReviewExternalService reviewService;
private final UserExternalService userService;
@Transactional
public void likeReview(Long reviewId, Long userId) {
Review review = reviewService.findByReviewId(reviewId);
User user = userService.findUserById(userId);
if (reviewLikeRepository.existsByReviewIdAndUserId(reviewId, userId)) {
throw new ReviewLikeException(ReviewLikeErrorCode.REVIEW_LIKE_ALREADY_EXISTS);
}
reviewLikeRepository.save(ReviewLike.builder()
.review(review)
.user(user)
.build()
);
review.incrementLikeCount();
}
@Transactional
public void unlikeReview(Long reviewId, Long userId) {
Review review = reviewService.findByReviewId(reviewId);
User user = userService.findUserById(userId);
ReviewLike reviewLike = reviewLikeRepository.findByReviewIdAndUserId(reviewId, userId)
.orElseThrow(() -> new ReviewLikeException(ReviewLikeErrorCode.REVIEW_LIKE_NOT_FOUND));
reviewLikeRepository.delete(reviewLike);
review.decrementLikeCount();
}
}
좋아요 등록 시 이미 좋아요된 리뷰인지 확인하고, 존재하지 않다면 `likeCount`를 증가시킨다.
좋아요 취소 시에는 좋아요한 리뷰인지를 확인하고, 좋아요가 있다면 `likeCount`를 감소시킨다.
# 리뷰 목록 조회 캐시 적용
영화 상세 조회 시 함께 조회되는 리뷰 목록을 캐싱해서 조회 성능을 향상시키고 DB 부하를 감소시키려했다.
@Cacheable(value = "topReviewPageCache", key = "#movieId", condition = "#pageable.pageNumber == 0")
@Transactional(readOnly = true)
public Page<ReviewWithUserResponse> getReviews(Long movieId, Pageable pageable) {
movieService.existsByMovieId(movieId);
log.info("DB에서 리뷰 조회중: movieId={}, page={}", movieId, pageable.getPageNumber()); // 로그 최초 1회만 출력 시 캐시 적용 성공
Page<Review> reviewPage = reviewRepository.findAllByMovieId(movieId, pageable);
return reviewPage.map(ReviewWithUserResponse::from);
}
@Cacheable(value = "myReviewPageCache", key = "#userId", condition = "#pageable.pageNumber == 0")
@Transactional(readOnly = true)
public Page<ReviewWithMovieResponse> getMyReviews(Long userId, Pageable pageable) {
Page<Review> myReviewPage = reviewRepository.findAllByUserId(userId, pageable);
return myReviewPage.map(ReviewWithMovieResponse::from);
}
`pageable.pageNumber == 0` 설정으로 첫 페이지만 캐싱되도록 해줬다.
그리고 DB에서 가져오는 경우엔 로그 메시지가 출력되도록 해서 캐시 적용이 제대로 되고 있는지 확인했다.
CacheConfig 클래스에서는 Caffeine 기반 인메모리 캐시를 사용했는데, 코드는 아래와 같다.
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager(
"directorDetailCache", // 여기에 사용할 캐시 이름 넣으면 됩니다
"actorDetailCache",
"searchTitleCache",
"searchActorCache",
"searchDirectorCache",
"popularSearchCache",
"movieDetailsCache",
"topReviewPageCache",
"myReviewPageCache"
);
cacheManager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES) // TTL
.maximumSize(1000) // 캐시 최대 크기
);
return cacheManager;
}
}
CaffeineCacheManager란?
Spring에서 제공하는 고성능 로컬 인메모리 캐시 매니저로, 내부적으로 `Caffeine` 라이브러리를 사용한다.
빠른 읽기/쓰기 성능과 자동 캐시 만료, 용량 제한 기능을 제공한다.
`expireAfterWrite()`: 캐시 항목이 생성된 후 일정 시간이 지나면 자동 삭제
`maximumSize`: 캐시에 저장할 수 있는 최대 항목 수를 제한하여 메모리 과다 사용 방지
#캐시된 리뷰 데이터의 정확한 반영을 위한 캐시 무효화
캐시를 적용한 이후 좋아요 증감을 테스트해보니,
캐싱되어 조회된 목록에는 변경 사항이 바로바로 반영되지 않는다는 것을 알게 되었다.
그리고 리뷰 생성, 수정, 삭제와 같은 변경 작업시에도 동일한 문제가 발생했다.
@Caching(evict = {
@CacheEvict(value = "topReviewPageCache", key = "#movieId"),
@CacheEvict(value = "myReviewPageCache", key = "#userId")
})
@Transactional
public ReviewResponse createReview(Long movieId, ReviewRequest.Create request, Long userId) {
Movie movie = movieService.findMovieById(movieId);
if (reviewRepository.existsByMovieIdAndUserId(movieId, userId)) {
throw new ReviewException(ReviewErrorCode.REVIEW_ALREADY_EXISTS);
}
User user = userService.findUserById(userId);
Review review = Review.builder()
.content(request.content())
.rating(request.rating())
.movie(movie)
.user(user)
.build();
Review savedReview = reviewRepository.save(review);
return ReviewResponse.from(savedReview);
}
`@Caching`은 여러 개의 캐시 어노테이션을 한 메서드에 묶어서 적용할 수 있도록 해주는 메타 어노테이션이다. 복수의 캐시를 동시에 무효화하거나 갱신해야할 때 사용된다.
`key = "#movieId"`: 해당 영화의 리뷰 목록 캐시만 무효화
`key = "#userId"`: 해당 사용자의 리뷰 목록 캐시만 무효화
캐시 키는 메서드 파라미터를 기준으로 지정하는데, 이를 통해 불필요한 전체 캐시 삭제를 방지할 수 있다.
@Caching(evict = {
@CacheEvict(value = "topReviewPageCache", allEntries = true),
@CacheEvict(value = "myReviewPageCache", key = "#userId")
})
@Transactional
public void likeReview(Long reviewId, Long userId) {
Review review = reviewService.findByReviewId(reviewId);
User user = userService.findUserById(userId);
if (reviewLikeRepository.existsByReviewIdAndUserId(reviewId, userId)) {
throw new ReviewLikeException(ReviewLikeErrorCode.REVIEW_LIKE_ALREADY_EXISTS);
}
reviewLikeRepository.save(ReviewLike.builder()
.review(review)
.user(user)
.build()
);
review.incrementLikeCount();
}
좋아요 수는 리뷰의 인기 순위에 직접적인 영향을 주기 때문에 특정 영화만 무효화해선 안 됐다.
따라서 `topReviewPageCache`에 대해서는 `allEntries = true`를 설정하여 전체 캐시를 삭제하도록 했다.
#마치며
짧은 기능 하나에도 캐시, 동시성, 도메인 책임 등 다양한 고민이 얽혀 있다는 걸 다시 느꼈다.
단순한 좋아요 기능이었지만, 사용자 경험과 시스템 성능을 함께 고려하면서 리뷰 도메인에 대한 이해도도 한층 깊어진 하루였다.