좋아요 기능은 같은 사용자가 같은 대상에 중복 좋아요를 할 수 없도록 likes 테이블에 unique 제약을 두고, 토글 방식으로 좋아요/취소를 처리하고 있었습니다. DB 레벨에서 중복은 방지되지만, 동시 요청이 들어올 경우 unique 제약 위반 예외가 클라이언트에게 노출되는 문제가 있었습니다.
단위 테스트에서 동일 사용자의 좋아요 요청 20개를 동시에 보내자 예외가 17번 발생했습니다. 실제 서비스에서도 네트워크 지연이나 버튼 중복 클릭 등 짧은 시간에 같은 요청이 중복으로 들어오는 상황은 충분히 재현 가능합니다. 예외가 클라이언트에 그대로 노출되면 사용자는 에러 화면을 보게 됩니다.
토글 로직은 다음 구조였습니다.
Like existing = findExisting(targetType, targetId, type, user, guestIdentifier);
if (existing != null) {
likeRepository.delete(existing);
applyDelta(targetType, type, targetId, -1);
} else {
Like like = (user != null)
? Like.createByMember(type, targetType, targetId, user)
: Like.createByGuest(type, targetType, targetId, guestIdentifier);
likeRepository.save(like);
applyDelta(targetType, type, targetId, 1);
}
동시 요청이 들어오면 여러 스레드가 모두 "아직 좋아요가 없다"고 판단해 insert 경쟁에 들어갑니다. 한 스레드만 insert에 성공하고 나머지는 DB unique 제약에 걸려 SQLIntegrityConstraintViolationException이 발생합니다.
같은 사용자가 같은 대상에 좋아요 요청을 20개 동시에 보내는 테스트를 진행했습니다.
예외가 17번 발생했고 3번만 성공했습니다.
JMeter로 동일 사용자-동일 게시글에 20개 스레드로 동시 요청을 재현한 결과, post.like_count와 likes 테이블의 row 수는 일치해 중복 row 생성은 방지됐지만, Duplicate entry 'guest-1-POST-832-LIKE' 예외가 발생하는 경쟁 상태를 확인했습니다.
토글 구조(toggleLike)를 유지하면서 race condition을 없애는 방법으로 비관적 락을 검토했습니다. Like row가 아직 없으면 잠글 대상이 없으므로, 부모 엔티티(Post/Comment)에 락을 걸어 좋아요 작업 전체를 직렬화하는 방식입니다.
// PostRepository
@Lock(LockModeType.PESSIMISTIC_WRITE) // SELECT ... FOR UPDATE
@Query("SELECT p FROM Post p WHERE p.id = :id")
Post findByIdWithLock(@Param("id") Long id);
// LikeService
@Transactional
public LikeResponse toggleLike(LikeRequest req, UserPrincipal principal) {
Post post = postRepository.findByIdWithLock(req.targetId());
boolean exists = likeRepository.existsByUserAndTarget(...);
if (!exists) {
likeRepository.save(like);
post.incrementLikeCount();
} else {
likeRepository.delete(existing);
post.decrementLikeCount();
}
return new LikeResponse(...);
}
비관적 락은 race condition을 방지할 수 있지만, 인기 게시글/댓글은 락 경합이 집중되는 hot row가 됩니다. 같은 대상에 대한 좋아요 요청이 모두 직렬화되어 처리량이 급격히 떨어지고, 락 대기 시간이 길어지면 DB 커넥션 점유 시간도 증가합니다. 또한 Post와 Comment에 락을 거는 순서가 경로마다 달라지면 데드락이 발생할 수 있습니다.
비관적 락은 이미 존재하는 row를 여러 명이 동시에 수정하는 상황(재고 감소, 좌석 예약 등)에 적합합니다. 좋아요처럼 존재하지 않는 row를 insert하는 중복 방지 문제에는 DB의 atomic 연산을 활용하는 방식이 더 맞습니다.
토글을 생성/삭제로 분리하고, saveAndFlush()로 즉시 SQL을 실행해 DataIntegrityViolationException을 try-catch로 잡아 멱등 처리하는 방식을 시도했습니다.
try {
likeRepository.saveAndFlush(like);
applyDelta(targetType, type, targetId, 1);
} catch (DataIntegrityViolationException e) {
// 다른 스레드가 먼저 insert한 경우: 멱등 처리
return new LikeResponse(targetId, targetType, LikeResponse.LikeAction.LIKED);
}
성공 횟수가 늘었지만 여전히 에러가 발생했습니다. 트랜잭션 안에서 SQL 실패가 발생하면 스프링이 해당 트랜잭션을 rollback-only로 마킹하기 때문에, 애플리케이션 레벨에서 예외를 잡아 정상 반환하더라도 커밋 시점에 UnexpectedRollbackException이 던져졌습니다.
조회 후 insert하는 대신, DB가 중복 여부를 직접 판단하도록 변경했습니다. 사전 조회 없이 insert를 먼저 시도하고, 중복이면 예외 대신 무시합니다(rollback-only 문제 없음). 실제 insert된 경우에만 like_count를 증가시킵니다. 삭제도 동일한 방식으로, 삭제된 row 수를 반환받아 실제 삭제된 경우에만 like_count를 감소시킵니다.
public LikeResponse addLike(LikeRequest req, UserPrincipal principal) {
...
int inserted = likeRepository.insertIgnore(
req.targetType().name(), req.targetId(),
req.type().name(),
user != null ? user.getId() : null,
guestIdentifier
);
if (inserted > 0) {
applyDelta(req.targetType(), req.type(), req.targetId(), 1);
}
return new LikeResponse(req.targetId(), req.targetType(), LikeResponse.LikeAction.LIKED);
}
처음에는 INSERT IGNORE로 구현했으나 이후 ON DUPLICATE KEY UPDATE id = id로 교체했습니다. INSERT IGNORE는 유니크 키 위반뿐 아니라 NOT NULL 위반, FK 위반 등 모든 제약 오류를 에러 대신 경고로 강등시켜 조용히 삼킵니다. type, target_type 컬럼이 nullable = false인데 잘못된 값이 들어와도 에러가 나지 않는 구조였습니다. ON DUPLICATE KEY UPDATE id = id는 유니크 키 충돌 시에만 UPDATE 경로로 전환하고 나머지 오류는 정상 propagate합니다.
@Modifying
@Query(value = """
insert into likes (target_type, target_id, type, user_id, guest_identifier)
values (:targetType, :targetId, :type, :userId, :guestIdentifier)
on duplicate key update id = id
""", nativeQuery = true)
int insertIgnore(...);
ON DUPLICATE KEY UPDATE로 교체 후 LikeConcurrencyTest가 실패했습니다. 원인은 MySQL Connector/J의 기본 연결 동작에 있었습니다.
MySQL Connector/J는 기본값(useAffectedRows=false)으로 CLIENT_FOUND_ROWS 플래그를 활성화한 채 연결합니다. 이 상태에서 두 쿼리의 affected rows 반환값이 다릅니다.
| 상황 | INSERT IGNORE | ON DUPLICATE KEY UPDATE id=id |
|---|---|---|
| 신규 삽입 | 1 | 1 |
| 중복(no-op) | 0 (IGNORE라 무시) | 1 (CLIENT_FOUND_ROWS: 행을 찾았으므로 1) |
INSERT IGNORE는 CLIENT_FOUND_ROWS 설정과 무관하게 중복 시 항상 0을 반환하지만, ON DUPLICATE KEY UPDATE id=id는 CLIENT_FOUND_ROWS가 켜져 있으면 "찾은 행"으로 간주해 1을 반환합니다. 결과적으로 20개 동시 스레드 모두 inserted > 0으로 판단해 applyDelta를 중복 실행했습니다.
JDBC URL에 useAffectedRows=true를 추가해 MySQL이 "찾은 행" 대신 "실제 변경된 행" 수를 반환하도록 수정했습니다.
jdbc:mysql://...&useAffectedRows=true
useAffectedRows=true는 JDBC URL 수준의 전역 설정이므로 이 애플리케이션의 모든 쿼리에 적용됩니다. 기존 UPDATE 쿼리에 사이드이펙트가 없는지 검토했습니다.
likeCount / dislikeCount 증감 쿼리 (SET count = count + :delta): delta가 항상 ±1이므로 항상 실제 값이 변경됩니다. found rows = affected rows가 보장되므로 영향 없습니다.reconcileLikeCounts 배치 쿼리: WHERE like_count != ... 조건으로 실제 불일치 행만 타게팅하므로 업데이트 대상은 항상 값이 바뀝니다. 영향 없습니다.deleteByActor: DELETE는 found rows / affected rows 구분이 없습니다. 영향 없습니다.@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("UPDATE Comment c SET c.likeCount = c.likeCount + :delta WHERE c.id = :commentId")
int updateLikeCount(@Param("commentId") Long commentId, @Param("delta") int delta);
UPDATE comment SET like_count = like_count + 1은 REPEATABLE READ에서도 current read(locking read)로 실행되어 실행 시점의 최신 커밋 값을 읽습니다. 트랜잭션 시작 시점 스냅샷을 읽는 consistent read와 달리, UPDATE 문은 행 락 획득 후 최신 값을 기준으로 증감하므로 동시 요청에서도 Lost Update가 발생하지 않습니다.
동일 사용자의 중복 좋아요 요청 20건을 예외 없이 멱등하게 처리하는 데 성공했습니다.
20개의 동시 요청이 모두 예외 없이 처리됐습니다.
실제 insert된 요청에서만 like_count update 쿼리가 나갔고, 나머지 요청은 ON DUPLICATE KEY UPDATE가 실행됐지만 중복으로 no-op 처리됐습니다. row 생성과 like_count 증가가 각각 1회만 반영된 것을 확인했습니다.
ON DUPLICATE KEY UPDATE는 MySQL 전용 문법입니다. 다른 DB로 전환할 경우 PostgreSQL의 ON CONFLICT DO NOTHING, 재시도 전략 등 DB별 대안이 필요합니다.
토글은 현재 상태에 따라 의미가 달라지는 비멱등 연산입니다. 동시 요청 상황에서는 의도가 모호해질 수 있어 addLike / removeLike로 분리했는데, API 구조가 바뀌면서 응답 구조와 캐시 전략도 함께 조정이 필요했습니다.
이 문제의 본질은 유니크 제약이 없어서가 아니라, 토글 기반의 check-then-act 구조가 동시 요청에서 원자성을 보장하지 못했다는 점이었습니다. 존재 여부를 먼저 조회하는 방식을 버리고, ON DUPLICATE KEY UPDATE와 영향 row 수를 이용해 DB가 중복 여부를 직접 판단하는 멱등 구조로 변경했습니다.
그 결과 동일 사용자 요청 20건이 동시에 들어와도 예외가 클라이언트에 노출되지 않았고, row 생성과 like_count 증가도 각각 1회만 반영됐습니다. 구현 과정에서는 CLIENT_FOUND_ROWS와 useAffectedRows=true 같은 드라이버 동작까지 함께 검증해야 했고, MySQL 전용 문법에 의존하므로 다른 DB로 전환할 경우 해당 DB에 맞는 멱등 전략으로 다시 설계해야 하는 제약은 남아 있습니다.