예약 시스템에서 동일 좌석에 대해 동시에 예약이 들어오는 상황을 고려해야 한다. 단일 서버 환경에서도 트랜잭션 타이밍이 미세하게 겹치면 중복 예약이 발생할 수 있기 때문에, 우리는 이를 방지하기 위한 비관적 락(Pessimistic Lock) 전략을 적용했다.
비관적 락은 "동시 접근이 있을 수 있으니, 먼저 잡고 보자"는 전략이다. 해당 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
처음에는 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
)
)
}
@Lock
은 반드시 존재하는 row에 걸어야 실제 락이 발생한다. 없는 row를 조회하면 아무 효과가 없다.UNIQUE(schedule_id, seat_id)
)도 반드시 같이 적용해야 예외 케이스를 막을 수 있다.