트러블슈팅

리뷰 좋아요 기능의 DB 직접 반영 구조를 Redis write-back 방식으로 개선한 과정

sudaruuu 2026. 3. 18. 17:20

문제 상황

리뷰 좋아요 기능을 처음 구현할 때는
사용자가 특정 리뷰에 대해 좋아요를 누르거나 취소하면
DB의 `likeCount` 값을 직접 증가/감소시키는 방식으로 구현하였다.

초기 구현은 다음과 같았다.

public void likeReview(Long reviewId) {
    Review review = getReviewEntity(reviewId);
    review.increaseLikeCount(1L);
}
public void unlikeReview(Long reviewId) {
    Review review = getReviewEntity(reviewId);
    review.decreaseLikeCount(1L);
}

 

또한 리뷰 목록 조회 시 캐시를 사용하고 있었기 때문에
좋아요 수가 변경되면 기존 캐시를 제거하여 데이터 일관성을 유지하도록 처리했다.

@CacheEvict(value = "storeReviews", allEntries = true)

 

처음에는 기능이 정상적으로 동작했지만,
구조를 다시 검토하면서 다음과 같은 문제를 인지하게 되었다.

  • 요청이 매우 자주 발생한다.
  • 동일한 리뷰에 대한 update가 반복된다.
  • 사용자는 빠른 응답을 기대한다.

이러한 특성을 고려했을 때
매 요청마다 DB에 직접 update를 수행하는 구조는 비효율적이라고 판단했다.


원인 분석

기존 구조의 가장 큰 문제는
좋아요 요청 1건당 DB update가 1번씩 발생한다는 점이었다.

 

예를 들어 특정 리뷰에 좋아요 요청이 짧은 시간 안에 100번 들어오면
DB에는 100번의 update가 발생하게 된다.

 

이 구조는 다음과 같은 문제를 유발할 수 있다.

1. DB 부하 증가

단순 카운트성 데이터임에도 불구하고
불필요하게 DB 쓰기 작업이 반복된다.

2. 동시성 상황에서 성능 저하

동일한 데이터에 대한 update가 동시에 몰리면서
트랜잭션 비용이 증가한다.

3. 즉시 DB 반영이 필수는 아님

좋아요는 빠른 응답이 중요하지만
반드시 즉시 DB에 반영될 필요는 없는 데이터라고 판단했다.

즉,

  • 사용자 응답은 빠르게 처리해야 하고
  • DB 반영은 일정 지연이 허용되는 데이터

라는 특징이 있었다.


해결 과정

이 문제를 해결하기 위해
Redis의 write-back 방식을 적용하기로 했다.

 

write-back 방식은

  • 요청 시 DB에 바로 반영하지 않고
  • Redis에 변경 값을 누적한 뒤
  • 일정 주기마다 DB에 반영하는 방식이다.

기존 구조 :

요청 → DB update → 응답

 

개선 구조 :

요청 → Redis 반영 → 응답 → (스케줄러) → DB 반영

적용 방법

1. Redis에 증감값 누적

@CacheEvict(value = "storeReviews", allEntries = true)
public void likeReview(Long reviewId) {
    validateReviewExists(reviewId);
    stringRedisTemplate.opsForHash()
            .increment(REVIEW_LIKE_KEY, String.valueOf(reviewId), 1);
}

@CacheEvict(value = "storeReviews", allEntries = true)
public void unlikeReview(Long reviewId) {
    validateReviewExists(reviewId);
    stringRedisTemplate.opsForHash()
            .increment(REVIEW_LIKE_KEY, String.valueOf(reviewId), -1);
}

 

좋아요 요청이 발생하면 DB에 직접 반영하지 않고
Redis에 리뷰별 증감값을 누적하도록 처리하였다.

 

2. 스케줄러를 통한 DB 반영

@Scheduled(fixedRate = 60000)
@Transactional
public void flushLikeCountToDb() {
    Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(REVIEW_LIKE_KEY);

    if (entries.isEmpty()) return;

    for (Map.Entry<Object, Object> entry : entries.entrySet()) {
        Long reviewId = Long.parseLong(entry.getKey().toString());
        Long delta = Long.parseLong(entry.getValue().toString());

        if (delta == 0L) continue;

        Review review = reviewRepository.findById(reviewId)
                .orElseThrow(() -> new ReviewException(ErrorCode.REVIEW_NOT_FOUND));

        review.applyLikeDelta(delta);
    }

    stringRedisTemplate.delete(REVIEW_LIKE_KEY);
}

 

Redis에 누적된 증감값을 일정 주기마다 조회하여
DB의 likeCount에 반영하도록 구현하였다.

이 과정을 통해 여러 요청을 한 번에 처리할 수 있도록 하였다.

 

3. 캐시 일관성

@CacheEvict(value = "storeReviews", allEntries = true)

 

좋아요 변경 시 기존 캐시를 제거하여

사용자가 최신 데이터를 조회할 수 있도록 처리하였다.


결론

  • Redis를 활용하여 요청 처리 속도가 개선되었다.
  • DB update 횟수를 줄여 부하를 감소시켰다.
  • 고빈도 요청에 대응할 수 있는 구조로 개선되었다.

아쉬운 점

1. DB 반영 지연

DB 값이 즉시 최신 상태가 아닐 수 있다.

2. Redis 장애 시 데이터 유실 가능성

DB에 반영되지 않은 데이터가 유실될 수 있다.


느낀 점

이번 작업을 통해
기능 구현에 그치지 않고 성능 개선까지 확장하는 경험을 할 수 있었다.

 

또한 Redis write-back 방식을 직접 적용하면서
고빈도 요청을 처리할 때는 DB 직접 반영보다
인메모리 기반 처리 방식이 더 효율적일 수 있다는 점을 이해할 수 있었다.

 

특히 성능 개선은 단순히 속도를 높이는 것이 아니라

  • 어떤 데이터는 즉시 반영해야 하는지
  • 어떤 데이터는 지연 반영이 가능한지
  • 캐시와 DB의 일관성을 어떻게 유지할지

까지 함께 고려해야 하는 설계의 문제라는 점을 느꼈다.

 

앞으로는

  • 스케줄 주기 최적화
  • Redis 장애 시 데이터 유실 방지 전략

등을 추가로 고민하며
더 안정적인 구조로 개선해보고자 한다.