카테고리 없음

좋아요 등록/취소 기능 멱등성 처리하기

yeonsu00 2025. 11. 14. 15:30

 

TL;DR

좋아요 API의 멱등성을 Application -> Domain -> DB 3단계에서 보장했다. 
좋아요 기능을 락을 쓰지 않고 멱등성을 처리했다. 

 

 

들어가며

좋아요 등록/취소 기능은 단순해보이지만 반드시 멱등성을 고려해야 한다. 

이번 포스팅에서는 좋아요 등록/취소 API에 멱등성을 어떻게 적용했는지, 어떤 문제를 해결할 수 있었는지, 그리고 실제 코드 구조는 어떻게 되어 있는지에 대해 글을 쓰고자 한다. 

 

 

멱등성이란

동일한 요청을 여러 번 보내도 결과가 한 번 보낸 것과 동일하게 유지되어야 한다. 

 

왜 좋아요 API에 멱등성이 필요할까?

좋아요 기능은 다음과 같이 동작하고, 두 개의 테이블을 사용한다. 

  • 좋아요 등록
    • likes 테이블에 (user_id, product_id) 레코드 INSERT
    • products 테이블의 like_count를 +1 
  • 좋아요 취소
    • likes 테이블에 (user_id, product_id) 레코드 DELETE
    • products 테이블의 like_count를 -1

 

 

 

문제는 여러 번 요청이 들어왔을 때 발생한다. 

 

문제 상황 : 중복 클릭

만약 멱등성 처리가 되어있지 않다면, 사용자가 "좋아요" 버튼을 빠르게 두 번 누르는 경우 문제가 발생한다. 

  1. 요청1 : "좋아요" API 호출 -> DB에 Like INSERT
  2. 요청2 : "좋아요" API 호출 -> DB에 Like INSERT (중복 발생)
  3. 결과 : likes 테이블에 동일한 user_idproduct_id를 가진 데이터가 2개 쌓이고, products 테이블의 like_count도 2가 증가하게 돼서 데이터가 완전히 꼬여버린다. 

 

 

좋아요 기능에서 멱등성을 적용한다면

  • 이미 좋아요한 상태에서 좋아요 요청 -> 아무 변화도 일어나지 않음
  • 좋아요하지 않은 상태에서 취소 요청 -> 아무 변화도 일어나지 않음

즉, 상태 변화가 일어나지 않아야 한다. 

 

멱등성을 적용하기 위해 계층별로 아래와 같은 로직으로 구현하였다. 

 


 

1. Application 계층 - Facade

이미 "좋아요"한 상태라면, 불필요한 작업을 하지 않고 현재 상태를 즉시 반환한다. 

@Transactional
public LikeInfo recordLike(LikeCommand.LikeProductCommand command) {
    User user = userService.findUserByLoginId(command.loginId())
            .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."));

    // 1️⃣ 
    // "좋아요" 기록이 있는지 먼저 체크하기 
    if (likeService.existsByUserIdAndProductId(user.getId(), command.productId())) {
        // 이미 존재한다면, 증가 로직을 실행하지 않고
        // 현재 Product의 likeCount를 바로 반환한다.
        Product product = productService.findProductById(command.productId())
                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
        
        return LikeInfo.from( // 멱등성을 보장하는 응답
                product.getId(),
                product.getLikeCount().getCount()
        );
    }

    // "좋아요" 기록이 없을 때만 좋아요 수 증가 로직 수행한다.
    Product product = productService.increaseLikeCount(command.productId());
    likeService.recordLike(user.getId(), product.getId());

    return LikeInfo.from(
            product.getId(),
            product.getLikeCount().getCount()
    );
}

// ... cancelLike 로직도 동일 ...

 

이렇게 하면, 중복 요청은 SELECT 쿼리 한 번으로 끝나게 된다. 불필요한 UPDATEINSERT를 막아 성능을 확보할 수 있고, 사용자에게는 항상 동일한 결과를 반환해서 멱등성을 보장할 수 있다. 

 

2. Domain 계층 - Service

Facade에서 1차로 걸렀지만, 도메인 서비스인 LikeService는 다른 곳에서도 호출될 수도 있기 때문에 LikeService 자체에도 멱등성을 갖도록 설계했다. 

@Service
public class LikeService {

    private final LikeRepository likeRepository;

    public void recordLike(Long userId, Long productId) {
        // 2️⃣ 
        // Facade에서 체크했더라도, Service 스스로가 한 번 더 체크한다.
        // LikeService는 어디서 호출되든 멱등성을 보장한다. 
        if (likeRepository.existsByUserIdAndProductId(userId, productId)) {
            return; // 이미 있으면 아무것도 안 함
        }
        likeRepository.save(userId, productId);
    }

    public void cancelLike(Long userId, Long productId) {
        // 2️⃣ 
        if (!likeRepository.existsByUserIdAndProductId(userId, productId)) {
            return; // 이미 없으면 아무것도 안 함
        }
        likeRepository.delete(userId, productId);
    }
}

 

 

 

3. Infra 계층

만약 Application 계층과 Domain 계층의 exists() 체크가 소용없는 Race Condition 상황이 발생하면 다음과 같은 문제가 발생할 수 있다. 

 

  1. 요청 A : Application, Domain 계층 exists() 체크 통과 (결과 : false)
  2. 요청 B : Application, Domain 계층 exists() 체크 통과 (결과 : false)
  3. 요청 A : like_count + 1 UPDATE 및 likes에 INSERT (성공)
  4. 요청 B : like_count+1 UPDATE 및 likes에 INSERT (시도)

이때, 4번 요청 B의 INSERT를 막지 못하면 like_count는 2가 오르고, likes 테이블에도 중복 데이터가 쌓이게 된다. 

 

 

DB Unique 제약조건 

Like 엔티티에 user_idproduct_id를 묶어 복합 유니트 제약조건을 설정해줬다. 

@Entity
@Table(
        name = "likes",
        uniqueConstraints = {
                // 3️⃣-1 [DB 제약조건]
                // user_id와 product_id의 조합은 유일해야 함
                @UniqueConstraint(name = "uk_user_product", columnNames = {"user_id", "product_id"})
        }
)
@Getter
public class Like extends BaseEntity {
    ...
}

 

이렇게 하면 Race Condition이 발생해도 DB가 물리적으로 중복 INSERT를 차단하도록 한다. 

 

Repository 예외 처리 

public class LikeRepositoryImpl implements LikeRepository {
    
    // ...
    
    @Override
    public void save(Long userId, Long productId) {
        try {
            Like like = Like.createLike(userId, productId);
            likeJpaRepository.save(like);
        } catch (DataIntegrityViolationException e) {
            // 3️⃣-2 [예외 처리]
            // Race Condition 등으로 DB 제약조건 위반 예외가 발생하면,
            // 커스텀 예외로 핸들링한다. 
            throw new CoreException(ErrorType.CONFLICT, "이미 좋아요가 등록된 상품입니다.");
        }
    }
    // ...
}

 

DB에서 유니크 제약조건으로 에러가 발생하면, Spring은 DataIntegrityViolationException을 던잔다.

이 예외를 try-catch로 잡아서 커스텀 예외로 핸들링해준다. 

 

이 로직과 @Transactional이 결합되면, 요청 B의 INSERT가 실패할 때 CoreException이 발생하고, 이로 인해 트랜잭션 B 전체가 롤백되면서 데이터의 정합성과 일관성을 지킬 수 있다. 

 

 


왜 락을 사용하지 않았을까?

처음에 좋아요 기능을 설계하면서 락을 걸면 되지 않을까?하는 고민을 하게 됐다.

 

하지만 좋아요 기능은 단순해 보이지만 유저 요청 빈도가 매우 높은 기능이라고 생각했고,

락을 쓰면 이 단순한 로직 하나 때문에 서비스 전체 성능이 떨어질 수 있다고 판단했다.

 

좋아요 기능은 "읽기 많은 작업"이다. 

  • 좋아요 여부 조회 
  • 좋아요 수 조회
  • 상품 목록 조회 (각 상품의 좋아요 수 조회)
  • 상품 상세 정보 조회 (상품의 좋아요 수 조회)

그래서 좋아요 등록을 위해 상품에 락을 걸면 읽기 요청까지 줄줄이 대기하게 된다. 

그래서  비관적 락 대신 "멱득성 + DB 제약조건"이 적절하다고 생각했다.