예약 시스템 - 중복 예약 처리 2 synchronized 와@Trasactional
이어서 해당 코드는 동기화가 안돼있기 때문에 테스트를 실패한다.
이제 synchronized 를 이용해 동기화를 해보자.
@Transactional
public synchronized ReservationDto.Response createReservation(Long clubMemberId, ReservationDto.Create createDto) {
Long resourceId = createDto.getResourceId();
LocalDateTime startDateTime = createDto.getStartDateTime();
LocalDateTime endDateTime = createDto.getEndDateTime();
// 이미 예약이 있는지 검사
if (reservationRepository.existsBetween(startDateTime, endDateTime, resourceId)) {
throw new ServiceException(RESERVATION_UNAVAILABLE);
}
...
// 예약을 생성합니다.
Reservation reservation = reservationRepository.save(createDto.toEntity(clubMember, resource));
return ReservationDto.Response.of(reservation);
}
하지만 이상하게도 테스트 대부분이 통과하는데 몇 개가 실패한다.
synchronized를 사용했음에도 문제를 해결하지 못한 이유는 Spring AOP 때문이다. @Transactional을 사용하면 Spring AOP로 인해 프록시 객체가 만들어지고, 원래 객체인 reservationService의 createReservation()의 실행이 끝나고 트랜잭션이 커밋되기 전에 다른 스레드가 데이터를 읽었기 때문에 중복예약이 발생합니다.
이 때
synchronized
는 메서드 시그니처(=메서드 이름 + 파라미터 타입과 개수)가 아니기 때문에, 상속되지 않습니다. 따라서 프록시 객체의 createReservation()는 여러 스레드가 사용할 수 있게 됩니다.
// Proxy class
class ReservationServiceProxy extends ReservationService{
private ReservationService reservationService;
@Override
public void decrease(...) {
try{
tx.start();
reservationService.decrease();
} catch (Exception e) {
// ...
} finally {
tx.commit();
}
}
}
// Origin Class
class ReservationService {
public synchronized void createReservation( ... ) {
// ...
}
}
프록시가 문제였으니 @Transactional을 쓰지 않으면 됩니다. 따라서 save도 saveAndFlush로 변경하고 그리고 더티 책킹도 되지 않기 때문에 update도 따로 해줘야합니다.
// @Transactional
public synchronized ReservationDto.Response createReservation(Long clubMemberId, ReservationDto.Create createDto) {
Long resourceId = createDto.getResourceId();
LocalDateTime startDateTime = createDto.getStartDateTime();
LocalDateTime endDateTime = createDto.getEndDateTime();
// 이미 예약이 있는지 검사
if (reservationRepository.existsBetween(startDateTime, endDateTime, resourceId)) {
throw new ServiceException(RESERVATION_UNAVAILABLE);
}
...
// 예약을 생성합니다.
Reservation reservation = reservationRepository.saveAndFlush(createDto.toEntity(clubMember, resource));
return ReservationDto.Response.of(reservation);
}
synchronized는 한 프로세스 내에서만 동시성 제어를 할 수 있습니다. synchronized를 메서드에 사용한다면, 한 프로세스 내에서 한 번에 하나의 스레드만 해당 메서드에 접근하는 것은 보장할 수 있습니다.
따라서 운영 서버가 여러 대인 경우에는 어플리케이션 단에서 중복 예약을 막기는 어렵습니다. 다른 데이터베이스 락을 이용하거나 다른 방안을 모색해야하지만, 서비스 초기 단일 서버로 운영할 때는 임시방편으로 사용이 가능할 것 같습니다.
데이터베이스 단에서 해당 문제를 해결하기 위한 Lock 혹은 트랜잭션 격리 수준을 설정할 수 있습니다. 하지만 우리 서비스에서는 기존에 디비에 있는 어떤 레코드를 변경하는 것이 아닌, 새로운 예약을 만드는 과정이기 때문에 데이터베이스 락을 이용하는 방법은 적절치 않았습니다.