[동시성제어] JPA 비관적 락으로 중복 예약 막기

y001·2025년 4월 15일
0
post-thumbnail

예약 시스템에서 동일 좌석에 대해 동시에 예약이 들어오는 상황을 고려해야 한다. 단일 서버 환경에서도 트랜잭션 타이밍이 미세하게 겹치면 중복 예약이 발생할 수 있기 때문에, 우리는 이를 방지하기 위한 비관적 락(Pessimistic Lock) 전략을 적용했다.

1. 비관적 락 개념 및 동작 방식

비관적 락은 "동시 접근이 있을 수 있으니, 먼저 잡고 보자"는 전략이다. 해당 row를 SELECT FOR UPDATE 방식으로 미리 락을 걸어, 다른 트랜잭션의 접근을 차단한다. 락을 건 트랜잭션이 완료될 때까지 다른 트랜잭션은 대기하거나 예외를 발생시킨다. 이는 낙관적 락과 반대되는 개념으로, 데이터 정합성이 중요한 예약 시스템에서 강력한 제어 수단이 된다.

JPA에서는 다음과 같이 설정한다:

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT s FROM SeatEntity s WHERE s.id = :id")
fun findByIdWithLock(@Param("id") id: Long): SeatEntity

이 쿼리는 Hibernate가 내부적으로 다음과 같이 변환해준다:

SELECT * FROM seats WHERE id = ? FOR UPDATE

2. 실제 적용 방식

처음에는 reservation 테이블에 직접 비관적 락을 적용했지만, 기대한 동시성 제어 효과는 나타나지 않았다. 테스트 과정에서 여러 요청이 동시에 들어올 경우, @Lock(PESSIMISTIC_WRITE)가 적용된 쿼리가 SELECT ... FOR UPDATE로 실행되었음에도 불구하고, 여전히 중복 예약이 발생했다. 그 이유는 명확했다. 예약 레코드는 처음에는 존재하지 않기 때문에, 해당 row에 락을 걸 수 없었던 것이다.

seat는 좌석 정보로서 이미 데이터베이스에 존재하므로, 이를 기준으로 락을 걸면 동시성 상황에서 하나의 트랜잭션만 선점할 수 있다. 락이 선점되면 나머지 요청은 대기 상태에 들어가고, 예약 여부 판단과 저장이 순차적으로 이뤄지며 중복 예약은 자연스럽게 방지된다.

@Transactional
fun reserve(command: ReserveCommand) {
    val seat = seatRepository.findByIdWithLock(command.seatId)
    val schedule = scheduleRepository.getReferenceById(command.scheduleId)

    val alreadyReserved = reservationRepository.existsByScheduleIdAndSeatId(
        scheduleId = command.scheduleId,
        seatId = command.seatId
    )
    if (alreadyReserved) throw AlreadyReservedException("이미 예약된 좌석입니다.")

    reservationRepository.save(
        Reservation(
            userId = command.userId,
            schedule = schedule,
            seat = seat,
            status = ReservationStatus.PENDING
        )
    )
}

3. 정리: 실무 적용 시 고려 사항

  • @Lock은 반드시 존재하는 row에 걸어야 실제 락이 발생한다. 없는 row를 조회하면 아무 효과가 없다.
  • 비관적 락만으로도 동시성 제어가 가능하지만, DB 차원의 유니크 제약 조건(UNIQUE(schedule_id, seat_id))도 반드시 같이 적용해야 예외 케이스를 막을 수 있다.
  • 트랜잭션 타임아웃이나 데드락 발생에 유의해야 한다. 적절한 timeout 설정과 예외 처리 로직이 병행되어야 한다.

0개의 댓글