좋아요 구현

일단 좋아요 모듈을 만들고 전역 settings 파일에서 추가한다.
설정 파일 넣는거는 이미 다른 기능 구현하면서 했던 그대로 하면 된다.
@Table(name = "article_like")
@Getter
@Entity
@ToString
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ArticleLike {
@Id
private Long articleLikeId;
private Long articleId; // shard key
private Long userId;
private LocalDateTime createdAt;
public static ArticleLike create(Long articleLikeId, Long articleId, Long userId) {
ArticleLike articleLike = new ArticleLike();
articleLike.articleLikeId = articleLikeId;
articleLike.articleId = articleId;
articleLike.userId = userId;
articleLike.createdAt = LocalDateTime.now();
return articleLike;
}
}
좋아요 객체
어떤 유저가 특정 글에 좋아요를 누르면 이 객체가 생성되어 DB에 저장된다.
그냥 게시글에 좋아요 수 칼럼을 넣고 누가 버튼을 누르면 +1만 해주면 되지 않냐고?
그렇게 하면 유저 한명이 얼마든지 좋아요를 올릴 수 있으니까 안된다.
어떤 유저가 어떤 게시글에 좋아요를 이미 했는지를 기록으로 남겨놔야 한다. 그 로그 역할을 하는게 이 객체이다.
@Repository
public interface ArticleLikeRepository extends JpaRepository<ArticleLike, Long> {
Optional<ArticleLike> findByArticleIdAndUserId(Long articleId, Long userId);
}
리파지토리
DB에 이 서치 조건으로 인덱스를 만들어 줄 것이다. 그냥 인덱스가 아니라 유니크 인덱스라는걸.
@Service
@RequiredArgsConstructor
public class ArticleLikeService {
private final Snowflake snowflake = new Snowflake();
private final ArticleLikeRepository articleLikeRepository;
public ArticleLikeResponse read(Long articleId, Long userId) {
return articleLikeRepository.findByArticleIdAndUserId(articleId, userId)
.map(ArticleLikeResponse::from)
.orElseThrow();
}
@Transactional
public void like(Long articleId, Long userId) {
ArticleLike articleLike = articleLikeRepository.save(
ArticleLike.create(
snowflake.nextId(),
articleId,
userId
)
);
}
@Transactional
public void unlike(Long articleId, Long userId) {
articleLikeRepository.findByArticleIdAndUserId(articleId, userId)
.ifPresent(articleLikeRepository::delete);
}
}
서비스단.
유저가 좋아요를 눌렀을 때, 이미 좋아요를 누른 적이 있는지 체크할 필요가 있을까?
아니. 좋아요 테이블에 유니크 인덱스를 게시글id와 누른 유저id로 정렬해서 만들어주면 같은 유저가 같은 게시글에 좋아요를 누르더라도 인덱스 중복 에러로 DB에서 예외처리해준다.
좋아요 수 구현
하지만 아무리 인덱스가 있다고 해도 좋아요의 개수가 많아지면 count 함수로 좋아요의 수를 세는 것은 오래 걸릴 수밖에 없다.
그러므로 좋아요 객체가 DB에 저장될 때마다 좋아요 수 또한 +1 하는 식으로 미리 저장해두도록 하자.
그럼 게시글에 좋아요 수 칼럼을 만드는 것이 좋을까?
그렇지 않다.
MSA라서 좋아요 시스템과 게시글 시스템이 다른 서버, 다른 DB로 분리되어 있는 상태이다.
그런데 좋아요를 누를 때마다 좋아요 시스템에서 게시글 시스템에 요청을 보낸다면 트래픽 문제가 클 것이다.
좋아요 시스템의 DB에 게시글별 좋아요 수 테이블을 만들어서 기록하는 쪽이 좋아 보인다.
@Table(name = "article_like_count")
@Getter
@Entity
@ToString
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ArticleLikeCount {
@Id
private Long articleId; // shard key
private Long likeCount;
@Version
private Long version;
public static ArticleLikeCount init(Long articleId, Long likeCount) {
ArticleLikeCount articleLikeCount = new ArticleLikeCount();
articleLikeCount.articleId = articleId;
articleLikeCount.likeCount = likeCount;
articleLikeCount.version = 0L;
return articleLikeCount;
}
public void increase() {
this.likeCount++;
}
public void decrease() {
this.likeCount--;
}
}
좋아요 수 객체.
기본적으로는 게시글 id와 좋아요 수만 있으면 된다.
근데 동시에 좋아요가 여러 개 들어오면 동시에 같은 수를 읽고 +1을 해서 갱신하는 경우가 생길 수 있어서 락을 걸어 줘야 한다.
락을 거는 방법에는 여러가지가 있지만 낙관적 락(락이 있으면 대기했다 들어가는게 아니라 그냥 업데이트가 거부되는 방식. 성능 이점이 있다고 한다.)을 사용할 생각이면 version 칼럼이 필요하기에 넣어주었다. @Version 어노테이션을 넣으면 자동으로 낙관적 락이 동작한다.
@Repository
public interface ArticleLikeCountRepository extends JpaRepository<ArticleLikeCount, Long> {
// select ... for update
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<ArticleLikeCount> findLockedByArticleId(Long articleId);
@Query(
value = "update article_like_count set like_count = like_count + 1 where article_id = :articleId",
nativeQuery = true
)
@Modifying
int increase(@Param("articleId") Long articleId);
@Query(
value = "update article_like_count set like_count = like_count - 1 where article_id = :articleId",
nativeQuery = true
)
@Modifying
int decrease(@Param("articleId") Long articleId);
}
좋아요 수 리파지토리.
좋아요 수를 +1 하는 함수는 JPA리파지토리의 쿼리 자동생성 기능으로는 못 만든다.
따라서 그냥 객체를 가져온 후 +1해서 저장하거나 업데이트 쿼리를 직접 쏴줘야 한다.
업데이트 쿼리를 직접 쏘는 경우에는 db에서 자동으로 비관적 락이 걸린다.
객체를 직접 가져오는 거는 @Lock 어노테이션이 있어야 비관적 락을 걸 수 있다.
낙관적 락을 쓸 생각이면 그냥 자동제공되는 findById를 쓰면 된다.
서비스단 수정
@Service
@RequiredArgsConstructor
public class ArticleLikeService {
private final Snowflake snowflake = new Snowflake();
private final ArticleLikeRepository articleLikeRepository;
private final ArticleLikeCountRepository articleLikeCountRepository;
public ArticleLikeResponse read(Long articleId, Long userId) {
return articleLikeRepository.findByArticleIdAndUserId(articleId, userId)
.map(ArticleLikeResponse::from)
.orElseThrow();
}
/**
* update 구문
*/
@Transactional
public void likePessimisticLock1(Long articleId, Long userId) {
ArticleLike articleLike = articleLikeRepository.save(
ArticleLike.create(
snowflake.nextId(),
articleId,
userId
)
);
int result = articleLikeCountRepository.increase(articleId);
if (result == 0) {
// 최초 요청 시에는 update 되는 레코드가 없으므로, 1로 초기화한다.
// 트래픽이 순식간에 몰릴 수 있는 상황에는 유실될 수 있으므로, 게시글 생성 시점에 미리 0으로 초기화 해둘 수도 있다.
articleLikeCountRepository.save(
ArticleLikeCount.init(articleId, 1L)
);
}
}
@Transactional
public void unlikePessimisticLock1(Long articleId, Long userId) {
articleLikeRepository.findByArticleIdAndUserId(articleId, userId)
.ifPresent(articleLike -> {
articleLikeRepository.delete(articleLike);
articleLikeCountRepository.decrease(articleId);
});
}
/**
* select ... for update + update
*/
@Transactional
public void likePessimisticLock2(Long articleId, Long userId) {
articleLikeRepository.save(
ArticleLike.create(
snowflake.nextId(),
articleId,
userId
)
);
ArticleLikeCount articleLikeCount = articleLikeCountRepository.findLockedByArticleId(articleId)
.orElseGet(() -> ArticleLikeCount.init(articleId, 0L));
articleLikeCount.increase();
articleLikeCountRepository.save(articleLikeCount);
}
@Transactional
public void unlikePessimisticLock2(Long articleId, Long userId) {
articleLikeRepository.findByArticleIdAndUserId(articleId, userId)
.ifPresent(articleLike -> {
articleLikeRepository.delete(articleLike);
ArticleLikeCount articleLikeCount = articleLikeCountRepository.findLockedByArticleId(articleId).orElseThrow();
articleLikeCount.decrease();
});
}
@Transactional
public void likeOptimisticLock(Long articleId, Long userId) {
articleLikeRepository.save(
ArticleLike.create(
snowflake.nextId(),
articleId,
userId
)
);
ArticleLikeCount articleLikeCount = articleLikeCountRepository.findById(articleId)
.orElseGet(() -> ArticleLikeCount.init(articleId, 0L));
articleLikeCount.increase();
articleLikeCountRepository.save(articleLikeCount);
}
@Transactional
public void unlikeOptimisticLock(Long articleId, Long userId) {
articleLikeRepository.findByArticleIdAndUserId(articleId, userId)
.ifPresent(articleLike -> {
articleLikeRepository.delete(articleLike);
ArticleLikeCount articleLikeCount = articleLikeCountRepository.findById(articleId).orElseThrow();
articleLikeCount.decrease();
});
}
public Long count(Long articleId) {
return articleLikeCountRepository.findById(articleId)
.map(ArticleLikeCount::getLikeCount)
.orElse(0L);
}
}
서비스에서는 기본적으로 함수마다 @Transactional을 걸고 좋아요 객체를 저장하는 것과 좋아요 수 +1 하는것을 묶어서 수행한다.
특히 낙관적 락의 경우에는 동시에 요청이 들어오면 예외가 반환되는 방식으로 처리되기에 트랜잭션으로 묶어서 좋아요 객체 저장까지 같이 취소되도록 만들어주는 것이 중요하다.
좋아요와 좋아요 취소 함수를 여러 락 종류에 따라 만들어봤는데 실제로는 하나만 선택해서 넣어주면 된다.