좋아요 기능 도입하기 - 1. 멱등한 API

o_z·2026년 1월 19일

봄봄 챌린지에 코멘트 좋아요 기능이 추가된다.
좋아요가 많은 코멘트라고 항상 더 가치있다고 보장할 순 없지만, 더 가치있을 확률이 높다.
그래서 사용자에게 더 가치있는 정보를 먼저 보여줄 수 있도록 좋아요가 많은 순으로 정렬해서 보여주고자 좋아요 기능을 추가한다.

좋아요 기능 도입을 위해 고려해야할 사항들은 다음과 같다.

  1. 좋아요를 등록/취소하는 API를 추가한다.
  2. 코멘트 목록을 조회할 땐 좋아요 개수, 내가 좋아요를 눌렀는지 여부를 추가한다.

1. 좋아요 도메인 세팅하기

코멘트 좋아요 엔티티 추가

하나의 코멘트에는 한 사람 당 하나의 좋아요만 누를 수 있다. 누가 어떤 코멘트에 좋아요를 눌렀는지 저장하기 위해 코멘트 좋아요 엔티티를 추가한다.

@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에 좋아요 개수 컬럼을 추가해 이를 보여주기

  • 장점 : 코멘트 목록 조회 시 비용이 싸다. ‘좋아요 많은 순’ 같은 정렬/필터링도 쉽게 적용 가능하다. 성능 저하 우려가 거의 없다.
  • 단점 : 동시성/정합성을 맞춰야 한다. 좋아요 추가/취소 시 좋아요 개수 컬럼도 함께 업데이트 되어야 하는데, 이 작업이 트랜잭션 경계에 서로 들어갈 수 있어 race condition이 발생할 수 있다. 컬럼 업데이트 요청이 순간적으로 몰리면 해당 레코드에 대한 UPDATE 락 경합/대기가 발생할 수 있다.

나는 두 번째 방법 좋아요 개수 컬럼 추가하기를 선택했다.

첫 번째 방법은 코멘트 목록 조회 횟수 ≤ 코멘트 좋아요 등록/취소 횟수 인 케이스에서 유리하다고 생각한다.

반면 두 번째 방법은 코멘트 목록 조회 횟수 > 코멘트 좋아요 등록/취소 횟수 일 경우 유리하다.

좋아요 등록/취소 횟수가 더 많은 케이스는 보통 실시간 스트리밍에서 한 사람이 좋아요를 여러 개 누를 수 있는 경우가 대부분일 것이다. 짧은 시간 내에 좋아요를 많이 눌러야 하는 이벤트 상황에 폭발적으로 좋아요 개수 변동이 일어날 것이므로 좋아요 등록/취소 횟수가 조회보다 더 많을 것이다.

하지만 우리 서비스 특성상 좋아요 등록/취소보다 조회는 이벤트처럼 짧은 시간 내에 많이 발생할 케이스도 아니고, 한 사람 당 하나의 좋아요가 제한되기에 폭발적으로 증가하는 일은 없을 것이다.

반정규화가 필요한 시점은 '정규화로 인한 조회 성능 하락'일 때 필요하다.
특히나 데이터 집계가 많이 필요한 테이블일 경우에 가치있는 솔루션이다. (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이다.

2. API 설계 고민하기

크게 구현 방법은 두 가지가 있을 것이다.

  1. '좋아요 상태 업데이트' 관점으로 PATCH API 하나로 사용하기
  2. '좋아요 등록'/'좋아요 취소'의 PUT/DELETE API 두 개로 사용하기

1️⃣ PATCH API 하나로 사용하기

좋아요 업데이트 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); // 반영된 좋아요 개수 반환
}
  • 장점: API 구조가 단순해진다.
  • 단점: 네트워크 상황에 의해 재시도 되는 로직이 수행될 경우, 사용자 의도와는 다르게 API가 두 번 호출되면서 좋아요가 취소될 수 있다. 좋아요가 추가됐는지/삭제됐는지 추적이 어렵다.

2️⃣ PUT/DELETE API 분리

좋아요 등록은 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를 명확하게 분리하면서 PUT/DELETE로 멱등성을 기대할 수 있다. 네트워크 상황에 의한 의도치않은 재시도가 발생하더라도 멱등성으로 인해 사용자 의도를 해치지 않을 수 있다. (지금 당장의 코드에서는 멱등성 보장이 안보이지만, 구체적으로 구현 시 보장하도록 수정할 예정이다.)
  • 단점: API 분리로 복잡해진다. 클라이언트는 상황에 따라 API를 다르게 요청해야한다.

❓ 멱등성이란?

API의 멱등성클라이언트가 동일한 요청을 여러 번 보내더라도 동일한 서버 상태를 보장받는 개념이다.

일시적인 네트워크 오류에 따라 클라이언트가 API 실패 또는 타임아웃을 받게 될 경우, 안전하게 재시도해서 의도했던 결과를 받을 수 있도록 설계해야한다. 결제같은 중요한 API는 멱등성이 보장되지 않을 경우 이중결제가 발생하면서 사용자 의도와 다른 서버 결과를 초래할 수 있다.
HTTP 메서드 중 멱등성을 보장하는 메서드는 대표적으로 PUT, DELETE, GET 이 있으며, 보장하지 않는 메서드는 POST, PATCH 가 있다.

여기서 ‘네트워크 상태로 인한 재시도가 자주 발생하는가?’ 라는 질문이 발생할 수 있는데, 지금 API는 자주 발생할 수 있다.

사용자 입장에서 좋아요 버튼을 눌렀을 때 잠시 네트워크 상태로 인해 즉각적인 변화가 보이지 않으면, 다시 한 번 눌러보는 경우는 빈번한 케이스이다.

또한, 모바일에서는 HTTP 클라이언트 라이브러리가 연결 문제 발생 시, 조용히 복구하기 위해 재시도하기도 한다.

위처럼 재호출이 자주 발생할 수 있는 좋아요 기능은 API 멱등성을 보장해 사용자 의도를 해치지 않는 것이 더 중요할 것이라고 판단했다. 그래서 PUT/DELETE 메서드로 API를 분리하고자 한다.


3. API의 멱등성 보장하기

좋아요 등록 API

PUT 메서드는 데이터가 존재하지 않으면 삽입, 존재하면 그대로 skip해 멱등성을 보장한다.

  • 좋아요 등록 API flow
  1. 챌린지 참여 정보를 조회한다.
  2. 해당 코멘트를 조회한다.
  3. 현재 사용자에 대한 좋아요 정보를 가져온다.
    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 사용하기를 채택했다.

좋아요 취소 API

DELETE 메서드는 데이터가 존재하면 삭제, 존재하지 않으면 그대로 skip해 멱등성을 보장한다.

  • 좋아요 취소 API flow
  1. 챌린지 참여 정보를 조회한다.
  2. 해당 코멘트를 조회한다.
  3. 현재 사용자에 대한 좋아요 정보를 가져온다.
    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); // 반영된 좋아요 개수 반환
}

일단 여기까지 좋아요 기능 설계 시 멱등성을 고려하는 방법이었다.
다음에는 좋아요 기능의 대표적 문제점인 동시성 문제에 대해 정리하고자한다.


참고
https://developer.mozilla.org/ko/docs/Glossary/Idempotent![](https://velog.velcdn.com/images/o_z/post/c4b1fde5-716f-474c-81c4-2d67bca69a16/image.png)

profile
트러블슈팅과 구현기를 위주로 기록합니다-

0개의 댓글