미팀 프로젝트에서는 사용자가 프로젝트에 좋아요를 토글 형식으로 누르는 상황이다.
좋아요는 한번만 누를수 있고, 한번더 누를 경우 좋아요가 취소가 된다.
하나의 프로젝트에 대해서 좋아요 요청시 동시성 문제가 발생한다고 판단했고, 이를 해결하기 위해 낙관적 락, 비관적 락의 성능비교를 통해 비관적 락을 선택하였다.
충돌이 적은 상황에서는 당연하게도 무의미한 성능차이가 발생하였고,
충돌이 많은 상황에서는 비관적 락 81.03ms, 낙관적 락 103.99ms로 측정되어 비관적 락이 더 안정적인 성능을 보였다.
하지만 여기서 좋아요 post 요청시 동시성 문제가 발생한 이유는 조회 시점과 업데이트 시점의 차이가 존재하여 발생하는 문제였고, 굳이 조회를 하지않고 DB 에서 원자적 쿼리를 통해 해결할 수 있지 않을까? 라는 생각이 들었다. update 시점에 where절을 사용하여 조회와 동시에 update를 진행하면, select for update 시점에 얻는 LOCK 보다 LOCK 소유시간이 적기 때문에 처리량이 올라가는것은 당연할 것이다.
write 쿼리(INSERT, UPDATE, DELETE)는 기본적으로 X락(Exclusive Lock)을 획득한다.데이터베이스에서 write 를 한 시점에 해당 row(레코드)에 대한 X Lock 을 획득한다. 이후 commit 일이 일어나기 전까지 소유하고 있다가 commit 이후에 Lock을 반납한다.
만약 트랜잭션A 가 Lock을 획득하면 commit 이 일어나기 전까지 다른 트랜잭션B는 Lock 을 획득하기 위해 대기한다. 트랜잭션 B는 설정한 LOCK_TIMEOUT 동안에 Lock 을 소유하기 위해 대기하고, LOCK_TIMEOUT 이후에는 타임아웃 오류가 발생한다.
조회시점에 X락을 획득하는 쿼리도 존재한다.
SELECT FROM UPDATE 쿼리가 X락을 획득하는 쿼리이다.
// ProjectLikeService.java (현재)
@Transactional
public ToggleLikeResponse toggleWithPessimistic(Long projectId, String email) {
// 1. Project 조회 (SELECT FOR UPDATE)
Project project = projectRepository.findByIdForUpdate(projectId) // ← X Lock 획득
.orElseThrow(...);
Member member = memberRepository.findByEmail(email)
.orElseThrow(...);
Optional<ProjectLike> existing =
projectLikeRepository.findByMemberAndProject(member, project);
if (existing.isPresent()) {
projectLikeRepository.delete(existing.get());
project.decreaseLike(); // like_count - 1
return new ToggleLikeResponse(
projectId,
false,
project.getLikeCount()
);
}
projectLikeRepository.save(ProjectLike.create(member, project));
project.increaseLike(); // like_count + 1
return new ToggleLikeResponse(
projectId,
true,
project.getLikeCount()
);
}
// COMMIT 시점에 X Lock 해제
여기서 그럼 원자적 쿼리 방식으로 Race Condition을 해결할 수 있을것 같다는 생각을 했다. 이렇게 할 경우 X Lock 의 소유 시점이 위의 select 시점에 X Lock 의 소유시점보다 짧기 때문에 DB에서의 처리량이 올라갈 것이라고 판단했다.
아래의 코드는 위의 비관적 락에서 원자적 쿼리 방식으로 진행한 코드이다.
// ProjectLikeService.java (원자적 쿼리 방식)
@Transactional
public ToggleLikeResponse toggleWithAtomicQuery(Long projectId, String email) {
// 1. Project 존재 확인 (Lock 없음)
if (!projectRepository.existsById(projectId)) {
throw new CustomException(ErrorCode.PROJECT_NOT_FOUND);
}
Member member = memberRepository.findByEmail(email)
.orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND));
// 2. 좋아요 존재 여부 확인
boolean exists =
projectLikeRepository.existsByMemberIdAndProjectId(
member.getId(),
projectId
);
if (exists) {
// 삭제 + 원자적 감소
projectLikeRepository.deleteByMemberIdAndProjectId(
member.getId(),
projectId
);
projectRepository.decreaseLikeCount(projectId); // X Lock 최소
int likeCount = projectRepository.countLikeById(projectId);
return new ToggleLikeResponse(
projectId,
false,
likeCount
);
}
// 삽입 + 원자적 증가
projectLikeRepository.save(
ProjectLike.create(member.getId(), projectId)
);
projectRepository.increaseLikeCount(projectId); // X Lock 최소
int likeCount = projectRepository.countLikeById(projectId);
return new ToggleLikeResponse(
projectId,
true,
likeCount
);
}
원자적 쿼리를 사용함으로 Project를 조회하는 쿼리는 존재하지 않는다.
countLikeById(projectId) 를 통해서 DB 내에서 조회와 업데이트를 진행할수 있다. 아래 코드는 원자적 쿼리이다.
// ProjectRepository.java
public interface ProjectRepository extends JpaRepository<Project, Long> {
// 원자적 증가 (like_count + 1)
@Modifying
@Query("""
UPDATE Project p
SET p.likeCount = p.likeCount + 1
WHERE p.id = :projectId
""")
void increaseLikeCount(@Param("projectId") Long projectId);
// 원자적 감소 (like_count - 1)
@Modifying
@Query("""
UPDATE Project p
SET p.likeCount = p.likeCount - 1
WHERE p.id = :projectId
""")
void decreaseLikeCount(@Param("projectId") Long projectId);
}
사용자가 매우 빠르게 좋아요 요청을 두번을 보내거나, 네트워크 끊김으로 인해 좋아요가 빠르게 2번 요청이 왔다고 가정하자.
실수로 중복 요청(일명 ‘따닥’)이 발생했을때를 말한다.
이미 좋아요를 한 상태로 좋아요를 취소하려고 POST 요청을 보냈지만 2번의 요청이 빠른 찰나에 온 상황이다.
먼저 온 요청을 요청1, 두번째로 온 요청을 요청2라고 하자.
먼저 요청1은 아래와 같이 좋아요 존재 여부를 ProjectLike 테이블에서 확인할것이다.
( 해당 ROW 가 존재 유무 확인)
// 2. 좋아요 존재 여부 확인
boolean exists =
projectLikeRepository.existsByMemberIdAndProjectId(
member.getId(),
projectId
);
요구사항은 좋아요를 취소하는(토글 방식) 상황이므로 TRUE 를 return 할 것이다.
TRUE이므로 아래와 같이 if 문에 진입을 하고 DELETE 를 통해서 해당 ROW을 삭제를 할것이다. 그 후 원자적 쿼리를 수행한다.( X LOCK 획득)
if (exists) {
// 삭제 + 원자적 감소
projectLikeRepository.deleteByMemberIdAndProjectId(
member.getId(),
projectId
);
projectRepository.decreaseLikeCount(projectId); // X Lock 최소
int likeCount = projectRepository.countLikeById(projectId);
return new ToggleLikeResponse(
projectId,
false,
likeCount
);
}
여기서 요청1이 DELETE 를 수행하기 전에 요청2가 좋아요 존재 여부 확인을 한다고 가정해보자. 빠른 2번의 요청이 올 경우 충분히 발생 가능한 상황이라고 판단했기 때문이다.
// 2. 좋아요 존재 여부 확인
boolean exists =
projectLikeRepository.existsByMemberIdAndProjectId(
member.getId(),
projectId
);
요청 1이 DELETE 를 하기 전 이므로 요청 2는 LIKE 테이블에 ROW가 존재한다고 판단하고 TRUE를 return 할 것이다.
그 후, 요청 2 역시 if (exists) 절에 진입을 할것이다.
여기서 요청1의 DELETE 여부와 상관없이 요청2 는 decreaseLikeCount 쿼리를 날릴것이다.
이 상황에서 원자적 쿼리로 count = count - 1 을 총 2번 수행을 하기 때문에
총 count 값이 2가 감소할것이다. 요청을 2번 보내면, 좋아요 수가 -1 이 되고, 한번 더 누를경우 다시 +1 이 되어 결과론 적으로는 좋아요 수 변화가 있으면 안되지만, 빠른 요청이 2번 올 경우 위 요구사항을 만족하지 못한다.
비록 좋아요 수가 비니지스 적으로 정확하게 일치하지 않아도 사용자 경험이 저하되지 않지만, 이러한 문제는 좋아요 뿐만 아니라 사용자에게 보상을 주거나 포인트가 차감될 경우 충분히 발생할수 있는 문제라고 생각했다.