Inventory 는 RoomTypeInventoryEntity 를 다루는 도메인 객체이다.
예약 프로세스에서 결제까지 완료가 되면 마지막으로,
해당 메서드(increaseReserved)를 요청하여 인벤토리의 예약된 객실 수를 증가시킨다.
increaseReserved 의 동작 1. 증가시켜야 하는 객실 정보를 받는다 (호텔 아이디, 객실 타입, 예약 일자(하루단위)) 2. findById() 로 영속성 앤티티를 조회한다. 3. RoomTypeInventoryEntity 내의 메서드인 increaseReserved() 를 활용하여 예약 객실 수를 +1 로 수정한다. 4. 예약 서비스로 복귀하여 트랜잭션이 종료되면 변경사항을 저장한다.
단건 예약 요청 시에는 아무 문제없었는데, 이게 왜 동시성 문제에만 실패가 발생할까?
Dirty Checking (변경감지)
더티 체킹이란 영속성 컨텍스트에서 관리하는 앤티티의 변경 사항을 트랜잭션이 커밋할 때 쓰기 지연을 통해 데이터베이스에 반영하는 것이다.
조회 기준의 현재 예약된 객실 수를 가지고 올 것이고, 그 값을 기준으로 +1 하여 수정요청을 반환한다.
동시성 문제가 발견할 때, 어떤 상황일까?
아래 그림을 참고해 보자.
사용자 A, B, C 가 동시에 동일한 타입의 객실을 예약했을 때 발생하는 문제
1. 사용자 A 와 B 가 거의 동시에 업데이트를 준비한다. 2. 현재 테이블의 '예약된 객실 수'를 가져온다. (TOTAL_RESERVED = 122) 3. 사용자 B 가 먼저 예약 트랜잭션을 종료하며, 객실 수를 업데이트한다.(TOTAL_RESERVED = 123) 4. 사용자 A 가 결제로 인해 트랜잭션이 끝나지 않는 동안, 다른 사용자 C 가 동일한 업데이트 과정을 진행한다. (TOTAL_RESERVED = 123+1 = 124) 5. 뒤늦게 사용자 A 가 트랜잭션을 종료하며 업데이트를 요청한다. 이때, 사용자 A 의 업데이트 요청 값은 초기 조회한 값(122)에서 1을 더한 값이 된다. (UPDATE T SET TOTAL_RESERVED = 123) 6. 업데이트가 적용되어 TOTAL_RESERVED = 123 이 된다.
사용자 C 가 A 보다 먼저 종료하며 값을 124까지 올려두었지만,
사용자 A의 업데이트가 적용되며 123으로 바뀌어 버린다.
즉, 처음 문제를 발견했을 때 DB에 저장되었던 숫자 TOTAL_RESERVED = 34 는
마지막에 업데이트한 사용자의 값이라는 것
해결과정
해결해야 하는 문제 : (TOTAL_RESERVED = TOTAL_RESERVED + 1)
업데이트 시 현재 DB의 TOTAL_RESERVED 값을 기준으로 변경할 수 있도록 수정이 필요하다.
❌ 해결안 1: 다른 Data Lock을 도입하기
이미 분산락이 도입된 상태로 새로운 Lock 을 추가할 필요는 없었다. 업데이트 방식만 변경을 해주면 되는 상황이었다.
❌ 해결안 2: save()를 활용하여 직접 저장
연박을 하게 될 경우 해당 숙박 기간 동안 모두 업데이트를 처리해야 한다.
그럴 경우 중간에 다른 요청으로 꼬이게 되면? 이것 또한 문제가 될 수 있다.
또한 추가적으로 코드를 수정하고 싶은 부분이 있었기 때문에 단건씩 저장하는 방식은 필요하지 않았다.
❓ 함께 해결하고 싶은 문제 : 단건 업데이트 방식의 MVP 코드 수정
기존 업데이트 방식은 단건으로 요청이 진행되고 있다.
'2025-02-01 ~ 2025-02-10' 로 장기 숙박을 예약하게 될 경우 업데이트 쿼리문이 10번 날아가는 상황이다.
✅해결안 3:JPQL을 활용하여 직접 실행
JPQL 을 활용하여 빠르게 벌크 수정을 처리하도록 하여 코드를 수정하기로 결정하였다.
수정된 코드
adapter
@Transactional
public class InventoryCommandAdaptor implements InventoryCommandOutputPort {
private final InventoryRepository inventoryRepository;
private final InventoryQueryDslRepository inventoryQueryDslRepository;
@Override
public List<Inventory> increaseReservedInventory(Reservation reservation) {
inventoryRepository.increaseReserved(reservation);
return getInventoryListByReservation(reservation);
}
}
repository
@Repository
public interface InventoryRepository extends JpaRepository<RoomTypeInventoryEntity, RoomTypeInventoryId> {
@Modifying
@Query("""
UPDATE RoomTypeInventoryEntity rt
SET rt.totalReserved = rt.totalReserved + 1
WHERE rt.id.date BETWEEN :#{#reservation.startDate} AND :#{#reservation.endDate}
AND rt.id.hotelId = :#{#reservation.hotelId}
AND rt.id.roomType = :#{#reservation.roomType.toEntity()}
""")
void increaseReserved(Reservation reservation);
}
결과
성공적인 결과물
코드를 수정하며 문제는 바로 해결되었다.
추가로 벌크 연산으로 수정하며 응답 성능이 약 30배 개선된 것도 확인하였다.
벌크 연산은 수정뿐만 아니라 수정한 내용을 다시 조회곳에서도 적용되었다.
(왼쪽) 단일 수정 , (오른쪽) 벌크 수정
문제 원인을 이해하는 과정에 시간을 충분히 투자하였고, 각 기능들이 언제 어떻게 사용되는지 명확히 이해할 필요성을 느꼈다.
무의식적으로 작성하는 코드보다는 상황에 따라 어떤 설계를 해야 하는지 충분히 고민이 필요할 듯하다.
벌크 연산 시 주의해야 하는 사항
벌크 연산이 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리 한다는 점에 주의해야 한다. 영속성 컨텍스트에서 데이터를 조회하여 관리되고 있는 중에, 벌크 연산을 하게되면. 영속성 컨텍스트와 DB 의 데이터가 달라지게 되는 문제가 발생할 수 있다.