티스토리 뷰



문제의 발단

"오늘 예약 마감됐어요. 하지만 빈자리는 있어요."

MSA 프로젝트로 '대규모 트래픽 문제를 해결하는 호텔 예약 서비스'를 개발하였다.

 

300개의 예약이 가능할 때, 300명이 동시에 예약을 할 경우,

모두 예약 성공을 응답받지만 데이터베이스에는 기록이 되지 않는 이슈가 발생했다.

 

성공적인 jmeter

 

300명이 동시에 예약을 하면 성공적으로 예약된 데이터를 반환받았다.

하지만 막상 예약된 객실 수를 보면 아래와 같이 예상할 수 없는 숫자가 저장되었다.

 

300명이 풀예약했지만 아직 객실이 66개가 예약 가능하다. ^^

 

Lock 에서 발생한 문제라고 생각했지만 결과적으로,

원인은 더티체킹으로 업데이트를 처리하였기 때문에 발생했다.

 

 

원인

왜? JPA 를 쓰면 더티체킹으로 업데이트하지 않나?

문제의 오리지널 코드

 

RoomTypeInventoryEntity 는  예약된 객실 수 정보를 보관하는 앤티티다.

Inventory 는 RoomTypeInventoryEntity 를 다루는 도메인 객체이다.

 

예약 프로세스에서 결제까지 완료가 되면 마지막으로,

해당 메서드(increaseReserved)를 요청하여 인벤토리의 예약된 객실 수를 증가시킨다.

increaseReserved 의 동작
1. 증가시켜야 하는 객실 정보를 받는다 (호텔 아이디, 객실 타입, 예약 일자(하루단위))
2. findById() 로 영속성 앤티티를 조회한다.
3. RoomTypeInventoryEntity 내의 메서드인 increaseReserved() 를 활용하여 예약 객실 수를 +1 로 수정한다.
4. 예약 서비스로 복귀하여 트랜잭션이 종료되면 변경사항을 저장한다.

 

 


단건 예약 요청 시에는 아무 문제없었는데, 이게 왜 동시성 문제에만 실패가 발생할까?

 

 

Dirty Checking (변경감지)

더티 체킹이란 영속성 컨텍스트에서 관리하는 앤티티의 변경 사항을 트랜잭션이 커밋할 때 쓰기 지연을 통해 데이터베이스에 반영하는 것이다.

 

JPA 영속성 컨텍스트와 변경 감지 (Dirty checking)

JPA 를 활용하면 필드 일부를 수정하는 경우 @Transactional 을 함께 사용하여 UPDATE 쿼리를 요청할 수 있다.트랜잭션의 커밋 단위로 데이터베이스가 변경되며, 수정 사항이 있을 때마다 save() 를 할 필

disnotacat.tistory.com

 

포인트는 바로, 영속화된 앤티티 정보를 기준으로 처리된다는 것이다.

 

해당 메서드에서 RoomTypeInventoryEntity 를 조회한다.

조회 기준의 현재 예약된 객실 수를 가지고 올 것이고, 그 값을 기준으로 +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 의 데이터가 달라지게 되는 문제가 발생할 수 있다.

해결방법

  • em.refresh()
  • 벌크 연산 수행 직후 해당 엔티티를 사용해야 한다면, 다시 조회하면 된다.
  • 벌크 연산 후 조회
  • 반드시 연산이 먼저 실행되고 조회되게 한다.
  • 벌크 연산 후 영속성 컨텍스트 초기화

 

 

  • 벌크 연산의 사이드 이펙트를 예상했으나 아무 일도 없었던 이야기
 

왜 우리 예약 서비스는 정상 동작할까? (JPQL)

(이전 글에서 이어지는 내용) 더티 체킹으로 인한 업데이트 오류 트러블 슈팅문제의 발단"오늘 예약 마감됐어요. 하지만 빈자리는 있어요."MSA 프로젝트로 '대규모 트래픽 문제를 해결하는 호텔

disnotacat.tistory.com

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/05   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
글 보관함