봄봄 챌린지에 코멘트 좋아요 기능이 추가된다.
좋아요가 많은 코멘트라고 항상 더 가치있다고 보장할 순 없지만, 더 가치있을 확률이 높다.
그래서 사용자에게 더 가치있는 정보를 먼저 보여줄 수 있도록 좋아요가 많은 순으로 정렬해서 보여주고자 좋아요 기능을 추가한다.
좋아요 기능 도입을 위해 고려해야할 사항들은 다음과 같다.
- 좋아요를 등록/취소하는 API를 추가한다.
- 코멘트 목록을 조회할 땐 좋아요 개수, 내가 좋아요를 눌렀는지 여부를 추가한다.
하나의 코멘트에는 한 사람 당 하나의 좋아요만 누를 수 있다. 누가 어떤 코멘트에 좋아요를 눌렀는지 저장하기 위해 코멘트 좋아요 엔티티를 추가한다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ChallengeCommentLike extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long participantId; // 좋아요 누른 회원 아이디 (FK)
@Column(nullable = false)
private Long commentId; // 좋아요 코멘트 (FK)
@Builder
public ChallengeCommentLike(@NonNull Long memberId, @NonNull Long commentId) {
this.memberId = memberId;
this.commentId = commentId;
}
}
코멘트 목록을 조회할 때 좋아요 개수가 필요한데, 이 때 두 가지 계산 방법이 있다.
1. 목록을 조회할 때마다 commentId 에 해당하는 ChallengeCommentLike 개수 계산하기
ChallengeCommentLike 를 직접 count() 하는 것이 원천이기 때문이다.2. 반정규화로 ChallengeComment에 좋아요 개수 컬럼을 추가해 이를 보여주기
나는 두 번째 방법 좋아요 개수 컬럼 추가하기를 선택했다.
첫 번째 방법은 코멘트 목록 조회 횟수 ≤ 코멘트 좋아요 등록/취소 횟수 인 케이스에서 유리하다고 생각한다.
반면 두 번째 방법은 코멘트 목록 조회 횟수 > 코멘트 좋아요 등록/취소 횟수 일 경우 유리하다.
좋아요 등록/취소 횟수가 더 많은 케이스는 보통 실시간 스트리밍에서 한 사람이 좋아요를 여러 개 누를 수 있는 경우가 대부분일 것이다. 짧은 시간 내에 좋아요를 많이 눌러야 하는 이벤트 상황에 폭발적으로 좋아요 개수 변동이 일어날 것이므로 좋아요 등록/취소 횟수가 조회보다 더 많을 것이다.
하지만 우리 서비스 특성상 좋아요 등록/취소보다 조회는 이벤트처럼 짧은 시간 내에 많이 발생할 케이스도 아니고, 한 사람 당 하나의 좋아요가 제한되기에 폭발적으로 증가하는 일은 없을 것이다.
반정규화가 필요한 시점은 '정규화로 인한 조회 성능 하락'일 때 필요하다.
특히나 데이터 집계가 많이 필요한 테이블일 경우에 가치있는 솔루션이다. (ex: 개수 총합, 계산)
반정규화의 단점은 데이터 중복을 허용하게 되는 것이므로 정합성에서 문제가 발생할 수 있다는 점이다.
따라서 조회 횟수가 등록/취소 횟수보다 월등히 높을 것이라고 판단해 조회 성능을 챙기고자 2번 방식으로 결정했고, 코멘트 엔티티에 좋아요 개수 컬럼을 추가했다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ChallengeComment extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long participantId;
@Column(nullable = false)
private String comment;
...
@Builder.Default
private int likeCount; // 좋아요 개수 컬럼 추가, 명시를 안하면 기본값은 0이다.
크게 구현 방법은 두 가지가 있을 것이다.
- '좋아요 상태 업데이트' 관점으로
PATCHAPI 하나로 사용하기- '좋아요 등록'/'좋아요 취소'의
PUT/DELETEAPI 두 개로 사용하기
좋아요 업데이트 API를 PATCH 하나로 두어, 좋아요 등록/취소 여부 상관없이 하나의 API로 처리한다.
@Transactional
public ChallengeCommentLikeResponse updateChallengeCommentLike(Long memberId, Long challengeId, Long commentId) {
// 해당 챌린지에 참여 정보 조회
ChallengeParticipant participant = getChallengeParticipant(challengeId, memberId);
// 코멘트 조회 해오기
ChallengeComment comment = challengeCommentRepository.findById(commentId)
.orElseThrow(() -> new CIllegalArgumentException(ErrorDetail.ENTITY_NOT_FOUND);
// 좋아요 존재 여부에 따른 분기처리
Optional<ChallengeCommentLike> like = challengeCommentLikeRepository.findByParticipantIdAndCommentId(participant.getId(), comment.getId());
if(like.isEmpty()){ // 좋아요 누르지 않았을 경우
challengeCommentLikeRepository.save( // 좋아요 등록
ChallengeCommentLike.builder()
.participantId(participant.getId())
.commentId(comment.getId())
.build()
);
comment.updateLikeCount(+1); // 좋아요 개수 업데이트
return;
}
// 좋아요 눌렀을 경우
challengeCommentLikeRepository.delete(like.get()); // 좋아요 데이터 삭제
comment.updateLikeCount(-1); // 좋아요 개수 업데이트
return ChallengeCommentLikeResponse.of(comment); // 반영된 좋아요 개수 반환
}
좋아요 등록은 PUT, 좋아요 취소는 DELETE를 사용하도록 API를 분리하는 방식이다.
@Transactional
public ChallengeCommentLikeResponse addCommentLike(Long memberId, Long challengeId, Long commentId){
// 해당 챌린지에 참여 정보 조회
ChallengeParticipant participant = getChallengeParticipant(challengeId, memberId);
// 코멘트 조회 해오기
ChallengeComment comment = challengeCommentRepository.findById(commentId)
.orElseThrow(() -> new CIllegalArgumentException(ErrorDetail.ENTITY_NOT_FOUND);
// 좋아요 데이터 등록
challengeCommentLikeRepository.save(
ChallengeCommentLike.builder()
.participantId(participant.getId())
.commentId(comment.getId())
.build()
);
comment.updateLikeCount(+1); // 좋아요 개수 업데이트
return ChallengeCommentLikeResponse.of(comment); // 반영된 좋아요 개수 반환
}
@Transactional
public ChallengeCommentLikeResponse deleteCommentLike(Long memberId, Long challengeId, Long commentId){
// 해당 챌린지에 참여 정보 조회
ChallengeParticipant participant = getChallengeParticipant(challengeId, memberId);
// 코멘트 조회 해오기
ChallengeComment comment = getChallengeComment(commentId);
// 좋아요 데이터 삭제
challengeCommentLikeRepository.deleteByPariticpantIdAndCommentId(participant.getId(), comment.getId());
comment.updateLikeCount(-1); // 좋아요 개수 업데이트
return ChallengeCommentLikeResponse.of(comment); // 반영된 좋아요 개수 반환
}
API의 멱등성은 클라이언트가 동일한 요청을 여러 번 보내더라도 동일한 서버 상태를 보장받는 개념이다.
일시적인 네트워크 오류에 따라 클라이언트가 API 실패 또는 타임아웃을 받게 될 경우, 안전하게 재시도해서 의도했던 결과를 받을 수 있도록 설계해야한다. 결제같은 중요한 API는 멱등성이 보장되지 않을 경우 이중결제가 발생하면서 사용자 의도와 다른 서버 결과를 초래할 수 있다.
HTTP 메서드 중 멱등성을 보장하는 메서드는 대표적으로 PUT, DELETE, GET 이 있으며, 보장하지 않는 메서드는 POST, PATCH 가 있다.
여기서 ‘네트워크 상태로 인한 재시도가 자주 발생하는가?’ 라는 질문이 발생할 수 있는데, 지금 API는 자주 발생할 수 있다.
사용자 입장에서 좋아요 버튼을 눌렀을 때 잠시 네트워크 상태로 인해 즉각적인 변화가 보이지 않으면, 다시 한 번 눌러보는 경우는 빈번한 케이스이다.
또한, 모바일에서는 HTTP 클라이언트 라이브러리가 연결 문제 발생 시, 조용히 복구하기 위해 재시도하기도 한다.
위처럼 재호출이 자주 발생할 수 있는 좋아요 기능은 API 멱등성을 보장해 사용자 의도를 해치지 않는 것이 더 중요할 것이라고 판단했다. 그래서 PUT/DELETE 메서드로 API를 분리하고자 한다.
PUT 메서드는 데이터가 존재하지 않으면 삽입, 존재하면 그대로 skip해 멱등성을 보장한다.
- 챌린지 참여 정보를 조회한다.
- 해당 코멘트를 조회한다.
- 현재 사용자에 대한 좋아요 정보를 가져온다.
4-1. 존재하지 않을 경우, 새로 생성하고 insert 한다. likeCount++도 수행한다.
4-2. 존재할 경우, return 한다.
PUT의 멱등성 보장 방법1️⃣ UNIQUE 제약 사용해서 예외 catch 후 처리하기
4-1, 4-2 과정을 ChallengeCommentLike 의 unique 제약 {comment_id, participant_id}로 보장한다. 중복 insert가 발생할 경우, unique 제약으로 예외가 발생하면서 조용히 return해 넘어가는 방식으로 구현한다.
@Transactional
public ChallengeCommentLikeResponse addCommentLike(Long memberId, Long challengeId, Long commentId){
// 해당 챌린지에 참여 정보 조회
ChallengeParticipant participant = getChallengeParticipant(challengeId, memberId);
// 코멘트 조회 해오기
ChallengeComment comment = challengeCommentRepository.findById(commentId)
.orElseThrow(() -> new CIllegalArgumentException(ErrorDetail.ENTITY_NOT_FOUND);
// 좋아요 데이터 등록
try {
challengeCommentLikeRepository.save(
ChallengeCommentLike.builder()
.participantId(participant.getId())
.commentId(comment.getId())
.build()
);
comment.updateLikeCount(+1); // 좋아요 개수 업데이트
} catch (DataIntegrityViolationException e){
String violated = extractConstraintName(e);
if (UK_COMMENT_LIKE.equalsIgnoreCase(violated)) {
log.warn("코멘트 좋아요가 이미 존재합니다. -> skip. participantId={}, commentId={}", participant.getId(), comment.getId());
return;
}
throw e;
}
return ChallengeCommentLikeResponse.of(comment); // 반영된 좋아요 개수 반환
}
private String extractConstraintName(Throwable e) {
Throwable cur = e;
while (cur != null) {
if (cur instanceof ConstraintViolationException cve) {
return cve.getConstraintName();
}
cur = cur.getCause();
}
return null;
}
2️⃣ MySQL의 INSERT IGNORE 쿼리를 사용해 insert 된 행 개수에 따라 처리하기
INSERT IGNORE 을 사용하면 UNIQUE 제약으로 인해 중복되는 데이터 삽입일 경우 무시한다. 이렇게 삽입한 데이터 개수를 repository의 return 값으로 받으면 1일 경우 좋아요 데이터가 추가된 것이고, 0일 경우 중복 데이터로 삽입되지 않은 것이다. insert 된 개수가 1일때만 좋아요 개수를 업데이트 한다.
@Transactional
public ChallengeCommentLikeResponse addCommentLike(Long memberId, Long challengeId, Long commentId){
// 해당 챌린지에 참여 정보 조회
ChallengeParticipant participant = getChallengeParticipant(challengeId, memberId);
// 코멘트 조회 해오기
ChallengeComment comment = challengeCommentRepository.findById(commentId)
.orElseThrow(() -> new CIllegalArgumentException(ErrorDetail.ENTITY_NOT_FOUND);
// 좋아요 데이터 등록
int insertCount = challengeCommentLikeRepository.insertIgnoreByParticipantIdAndCommentId()
participant.getId(),
comment.getId()
);
if(insertCount == 1){ // 좋아요가 insert 된 경우
comment.updateLikeCount(+1); // 좋아요 개수 업데이트
}
return ChallengeCommentLikeResponse.of(comment); // 반영된 좋아요 개수 반환
}
public interface ChallengeCommentLikeRepository extends JpaRepository<ChallengeCommentLike, Long> {
@Modifying
@Query(value = """
INSERT IGNORE INTO challenge_comment_like (participant_id, comment_id)
VALUES (:participantId, :commentId)
""", nativeQuery = true)
int insertIgnoreByParticipantIdAndCommentId(@Param("participantId") Long participantId,
@Param("commentId") Long commentId);
}
첫 번째 방식에서 나타나는 DataIntegrityViolationException 의 경우, 원래 UNIQUE 제약 뿐 만 아니라 데이터 삽입 시 발생할 수 있는 다양한 제약으로 인해 발생할 수 있는 예외이다. 이를 파싱하고 UNIQUE 제약으로 발생한 예외인지 추출해야한다. 이는 추후에 UNIQUE 제약의 이름이 변경될 경우 이 코드도 건들여 수정해야 하므로 유지보수에서도 유리하지 못하다.
두 번째 방식을 사용하면 INSERT IGNORE을 통해 명확하게 내가 원하는 멱등성을 보장할 수 있으며, 코드도 더 깔끔해 가독성이 좋기도 하다.
따라서 두 개 방식 중 2번째 INSERT IGNORE 사용하기를 채택했다.
DELETE 메서드는 데이터가 존재하면 삭제, 존재하지 않으면 그대로 skip해 멱등성을 보장한다.
- 챌린지 참여 정보를 조회한다.
- 해당 코멘트를 조회한다.
- 현재 사용자에 대한 좋아요 정보를 가져온다.
4-1. 존재할 경우, 좋아요 정보를 delete 한다. likeCount--도 수행한다.
4-2. 존재하지 않을 경우, return한다.
delete는 query에서부터 멱등성이 보장된다. 따라서 insert와 마찬가지로 delete한 row 수 케이스에 따라 1개일 경우 좋아요 개수를 업데이트하고 0일 경우는 skip해 멱등성을 보장한다.
@Transactional
public ChallengeCommentLikeResponse deleteCommentLike(Long memberId, Long challengeId, Long commentId){
// 해당 챌린지에 참여 정보 조회
ChallengeParticipant participant = getChallengeParticipant(challengeId, memberId);
// 코멘트 조회 해오기
ChallengeComment comment = challengeCommentRepository.findById(commentId)
.orElseThrow(() -> new CIllegalArgumentException(ErrorDetail.ENTITY_NOT_FOUND);
// 좋아요 데이터 삭제
int deletedCount = challengeCommentLikeRepository.deleteByPariticpantIdAndCommentId(
participant.getId(),
comment.getId()
);
if(deletedCount == 1) { // 좋아요가 delete 된 경우
comment.updateLikeCount(-1); // 좋아요 개수 업데이트
}
return ChallengeCommentLikeResponse.of(comment); // 반영된 좋아요 개수 반환
}
일단 여기까지 좋아요 기능 설계 시 멱등성을 고려하는 방법이었다.
다음에는 좋아요 기능의 대표적 문제점인 동시성 문제에 대해 정리하고자한다.