@Transactional
public ReservationCreateResDto createReservation(Long restaurantId, Long memberId, ReservationPostReqDto reservationPostReqDto) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> CustomException.of(ErrorCode.NOT_FOUND, "존재하지 않는 회원입니다"));
if (!lockingService.atomicDecreaseRemainingTableCount(restaurantId)) {
throw new IllegalArgumentException("해당 식당이 없거나 여유 테이블이 없습니다.");
}
Restaurant restaurant = restaurantRepository.findById(restaurantId)
.orElseThrow(() -> CustomException.of(ErrorCode.NOT_FOUND, "존재하지 않는 식당입니다"));
Reservation newReservation = Reservation.builder()
.member(member)
.restaurant(restaurant)
.reservationTime(reservationPostReqDto.reservationTime())
.build();
reservationRepository.save(newReservation);
return ReservationCreateResDto.from(newReservation);
}
@Service
@RequiredArgsConstructor
public class RestaurantLockingService {
private final RestaurantRepository restaurantRepository;
@Transactional
public boolean atomicDecreaseRemainingTableCount(Long restaurantId) {
return (restaurantRepository.decreaseRemainingTableCount(restaurantId) > 0);
}
}
// 영속성 컨텍스트와 DB 간 동기화를 위한 옵션들
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query(
"UPDATE Restaurant r " +
"SET r.remainingTableCount = r.remainingTableCount - 1 " +
"WHERE r.id = :restaurantId AND r.remainingTableCount > 0"
)
int decreaseRemainingTableCount(@Param("restaurantId") Long restaurantId);
@Service
@RequiredArgsConstructor
public class ReservationPessimisticLockService {
private final ReservationRepository reservationRepository;
private final RestaurantRepository restaurantRepository;
private final MemberRepository memberRepository;
@Transactional
public ReservationCreateResDto createReservation(@LockKey Long restaurantId, Long memberId, ReservationPostReqDto reservationPostReqDto) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> CustomException.of(ErrorCode.NOT_FOUND, "존재하지 않는 회원입니다"));
Restaurant restaurant = restaurantRepository.findFirstById(restaurantId)
.orElseThrow(() -> CustomException.of(ErrorCode.NOT_FOUND, "존재하지 않는 식당입니다"));
restaurant.decreaseRemainingTableCount();
Reservation newReservation = Reservation.builder()
.member(member)
.restaurant(restaurant)
.reservationTime(reservationPostReqDto.reservationTime())
.build();
reservationRepository.save(newReservation);
return ReservationCreateResDto.from(newReservation);
}
}
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Restaurant> findFirstById(Long id);
로그인 → 예약 요청의 순서로 진행되며 각 작업들의 응답 속도를 별개로 측정할 수 있도록 구성하였다.
(마지막 예약 저장 시간) - (첫 예약 저장 시간) = 24.215745(초)
평균 예약 저장 시간: 24.239(밀리초)
(마지막 예약 저장 시간) - (첫 예약 저장 시간) = 17.751056(초)
평균 예약 저장 시간: 17.768(밀리초)
똑같은 쿼리를 수행한다면 성능 차이가 사실상 나지 않을 것이라고 판단하고, 각각의 경우에 어떤 쿼리가 수행되는지 정리해보았다.
- 1차 최적화의 경우
SELECT member → UPDATE restaurant(즉시 flush) → clear context → SELECT restaurant → INSERT reservation
- 비관적 lock의 경우
SELECT member → SELECT restaurant FOR UPDATE → Dirty Checking UPDATE restaurant → INSERT reservation(이 시점에 flush)
비교해보니 1차 최적화 방식이 flush를 1번 더 많이 수행하고 clear 또한 수행하는 점이 눈에 걸렸다.
또한 각 방식 수행 시 실제 MySQL 쿼리가 어떤 식으로 생성되는지 p6spy를 통해 확인해보았다.
- 1차 최적화의 경우
select m1_0.id,m1_0.created_at,m1_0.email,m1_0.is_deleted,m1_0.member_type,m1_0.name,m1_0.password,m1_0.updated_at
from member m1_0
where m1_0.email='test1@mail.com';
select m1_0.id,m1_0.created_at,m1_0.email,m1_0.is_deleted,m1_0.member_type,m1_0.name,m1_0.password,m1_0.updated_at
from member m1_0
where m1_0.id=1;
update restaurant r1_0
set remaining_table_count=(r1_0.remaining_table_count-1)
where r1_0.id=1 and r1_0.remaining_table_count>0;
select r1_0.id,r1_0.address,r1_0.category,r1_0.created_at,r1_0.is_deleted,r1_0.max_table_count,r1_0.name,r1_0.member_id,r1_0.remaining_table_count,r1_0.updated_at
from restaurant r1_0
where r1_0.id=1;
insert into reservation (created_at,is_deleted,member_id,reservation_time,restaurant_id,status,updated_at)
values ('2025-03-07T15:34:45.068+0900',false,1,'2025-03-18T18:30:00.000+0900',1,'CONFIRMED','2025-03-07T15:34:45.068+0900');
- 비관적 lock의 경우
select m1_0.id,m1_0.created_at,m1_0.email,m1_0.is_deleted,m1_0.member_type,m1_0.name,m1_0.password,m1_0.updated_at
from member m1_0
where m1_0.email='test1@mail.com';
select m1_0.id,m1_0.created_at,m1_0.email,m1_0.is_deleted,m1_0.member_type,m1_0.name,m1_0.password,m1_0.updated_at
from member m1_0
where m1_0.id=1;
select r1_0.id,r1_0.address,r1_0.category,r1_0.created_at,r1_0.is_deleted,r1_0.max_table_count,r1_0.name,r1_0.member_id,r1_0.remaining_table_count,r1_0.updated_at
from restaurant r1_0
where r1_0.id=1
limit 1 for update;
insert into reservation (created_at,is_deleted,member_id,reservation_time,restaurant_id,status,updated_at)
values ('2025-03-07T15:40:56.768+0900',false,1,'2025-03-18T18:30:00.000+0900',1,'CONFIRMED','2025-03-07T15:40:56.768+0900');
update restaurant
set address='addr1',category='KOREAN',created_at='2025-02-11T10:00:00.000+0900',is_deleted=false,max_table_count=1001,name='restaurant1',member_id=2,remaining_table_count=1000,updated_at='2025-03-07T15:27:24.321+0900'
where id=1;
이 중에서 공통된 쿼리를 제외하고 각 쿼리들의 성능을 EXPLAIN_ANALYZE
, performance_schema
로 측정해보았다.
- 1차 최적화


- 비관적 lock


측정 결과 오히려 비관적 lock의 경우가 더 실행시간이 오래 걸리는 것으로 나타났다.
따라서 @Modifying(flushAutomatically = true, clearAutomatically = true)
로 인해 발생하는 영속성 컨텍스트 오버헤드가 크게 작용한다고 결론을 내릴 수 있었다.
현재 @Modifying(flushAutomatically = true, clearAutomatically = true)
는 JpaRepository 상에서 UPDATE 쿼리를 사용할 경우 영속성 컨텍스트를 무시하고 바로 DB에 쿼리를 전송하기에 DB와 컨텍스트를 동기화시키기 위해 도입하였다.
하지만 이러한 옵션들로 인해 해당 UPDATE 쿼리가 수행될 때 마다 컨텍스트 전체가 flush(), clear()되며 이로 인해 오버헤드 문제가 발생했다.
따라서 이러한 작업의 타겟을 특정 Restaurant 객체로 한정시켜 평균 응답 시간을 어느 정도 향상시켰다.
@Service
@RequiredArgsConstructor
public class RestaurantLockingService {
private final RestaurantRepository restaurantRepository;
// 컨텍스트 작업을 위해 EntityManager 객체를 직접 사용
@PersistenceContext
private EntityManager entityManager;
@Transactional
public boolean atomicDecreaseRemainingTableCount(Long restaurantId) {
int updated = restaurantRepository.decreaseRemainingTableCount(restaurantId);
// 수동으로 flush()를 호출해 DB와 동기화
entityManager.flush();
Restaurant restaurant = entityManager.find(Restaurant.class, restaurantId);
if (restaurant != null && entityManager.contains(restaurant)) {
// detach()로 해당 restaurant만 캐싱 해제
entityManager.detach(restaurant);
}
return updated > 0;
}
}
하지만 여전히 간단하게 적용 가능한 비관적 lock과 비슷해진 수준이기에, 추가적으로 최적화할 수 있는 방안들에 대해 고민해봐야 할 것 같다.