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 : "좋아요" API 호출 -> DB에 Like INSERT
- 요청2 : "좋아요" API 호출 -> DB에 Like INSERT (중복 발생)
- 결과 : likes 테이블에 동일한 user_id와 product_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 쿼리 한 번으로 끝나게 된다. 불필요한 UPDATE나 INSERT를 막아 성능을 확보할 수 있고, 사용자에게는 항상 동일한 결과를 반환해서 멱등성을 보장할 수 있다.
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 상황이 발생하면 다음과 같은 문제가 발생할 수 있다.
- 요청 A : Application, Domain 계층 exists() 체크 통과 (결과 : false)
- 요청 B : Application, Domain 계층 exists() 체크 통과 (결과 : false)
- 요청 A : like_count + 1 UPDATE 및 likes에 INSERT (성공)
- 요청 B : like_count+1 UPDATE 및 likes에 INSERT (시도)
이때, 4번 요청 B의 INSERT를 막지 못하면 like_count는 2가 오르고, likes 테이블에도 중복 데이터가 쌓이게 된다.
DB Unique 제약조건
Like 엔티티에 user_id와 product_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 제약조건"이 적절하다고 생각했다.