@Service
@RequiredArgsConstructor
public class LikeService {
private final ReviewRepository reviewRepository;
private final MenuRepository menuRepository;
private final LikeRepository likeRepository;
private final UserRepository userRepository;
private final RedissonClient redissonClient;
// 리뷰 좋아요 토글
@Transactional
public boolean toggleReviewLike(Long reviewId, String email) {
Review review = reviewRepository.findById(reviewId)
.orElseThrow(() -> new IllegalArgumentException("리뷰를 찾을 수 없습니다."));
User user = findUserByEmail(email);
return toggleLike(review, user, likeRepository::findByReviewAndUser, lockKeyForReview(reviewId));
}
// 메뉴 좋아요 토글
@Transactional
public boolean toggleMenuLike(Long storeId, Long menuId, Long userId) {
Menu menu = findMenu(storeId, menuId);
User user = findUserById(userId);
return toggleLike(menu, user, likeRepository::findByMenuAndUser, lockKeyForMenu(storeId, menuId));
}
// 공통 좋아요 토글 메서드
private <T> boolean toggleLike(T entity, User user, BiFunction<T, User, Optional<Like>> findLike, String lockKey) {
RLock lock = redissonClient.getLock(lockKey);
try {
if (lock.tryLock(10, 2, TimeUnit.SECONDS)) {
Optional<Like> existingLike = findLike.apply(entity, user);
if (existingLike.isPresent()) {
likeRepository.delete(existingLike.get());
return false; // 좋아요 취소
} else {
Like like = entity instanceof Review ? Like.forReview((Review) entity, user) : Like.forMenu((Menu) entity, user);
likeRepository.save(like);
return true; // 좋아요 추가
}
} else {
throw new RuntimeException("Rock 획득에 실패했습니다. 잠시 후 다시 시도해주세요.");
}
} catch (InterruptedException e) {
throw new RuntimeException("Rock 획득 중 인터럽트가 발생했습니다.", e);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
...
이 코드에서 사용된 분산락은 Redisson 라이브러리를 이용한 Redis 기반의 분산락입니다. 분산락은 여러 인스턴스나 서버에서 동일한 자원에 동시에 접근할 때 발생할 수 있는 문제를 방지하기 위해 사용됩니다. 이를 통해 여러 서버 간에서의 데이터 일관성을 보장하며, 여기서는 좋아요(toggle like) 기능에서 이를 사용하고 있습니다.
RedissonClient는 Redis 서버와의 연결을 관리하고, Redis를 기반으로 다양한 동시성 도구를 제공합니다. 그중 RLock 객체는 분산락 기능을 수행하며, 이를 통해 여러 서버가 동시에 자원에 접근할 때 동시성 문제가 발생하지 않도록 방지합니다.
- 락 획득
RLock lock = redissonClient.getLock(lockKey);
redissonClient.getLock(lockKey) 메서드를 통해 특정 키(lockKey)를 기반으로 락 객체를 가져옵니다. 이 lockKey는 리뷰나 메뉴 ID에 기반하여 생성됩니다.
- 락 시도
if (lock.tryLock(10, 2, TimeUnit.SECONDS)) { ... }
tryLock 메서드는 락을 획득할 때 대기 시간과 타임아웃을 설정합니다. 이 예제에서 tryLock(10, 2, TimeUnit.SECONDS)는 최대 10초 동안 락을 기다리고, 락을 획득하면 2초 동안 유지합니다.
이 과정은 두 가지 목적이 있습니다 :
- 락을 기다리는 시간이 지나면 오류를 발생시켜 처리할 수 있습니다.
- 락이 유지되는 시간을 제한하여 Deadlock(교착 상태)을 방지합니다.
- 락 해제
if (lock.isHeldByCurrentThread()) { lock.unlock(); }
마지막으로 lock.unlock()로 락을 해제합니다. 여기서 isHeldByCurrentThread() 메서드를 통해 현재 스레드가 락을 가지고 있을 때만 해제하는 안전 장치를 마련했습니다.
lockKeyForReview와 lockKeyForMenu 메서드는 각각 리뷰와 메뉴에 대한 고유한 락 키를 생성합니다. 이 키들은 Redis 서버에 저장되고, 이를 통해 여러 서버가 동일한 자원에 대해 고유한 락을 요청하도록 만들어줍니다.