1. 문제점
티켓 예매 특성상 특정시간에 엄청난 사람이 한꺼번에 몰려 예매를 시도하게 됩니다. 이 때 같은 좌석은 여러명이 예매할 수 없도록 해야하는데, 동시성 문제에 대한 처리가 없는 상황에서 여러명이 예매 요청을 할 경우에는 같은 좌석에 여러명의 사람들이 예매가 되는 문제가 발생하게 됩니다. 그렇다면 이런 상황이 왜 벌어지게 되는걸까요?
테스트 실행
멀티 쓰레드를 이용해 100명의 사용자가 동시에 예매를 진행하는 테스트 코드를 작성해 해당 문제를 확인해봅시다.
@Test
void 티켓예매시_좌석_동시성_테스트를_진행한다() throws InterruptedException {
List<Long> seatIds = List.of(1L);
ReservationRequest request = new ReservationRequest(1L, 1L, seatIds);
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
reservationService.registerReservation(1L, request);
} catch (Exception e) {
e.printStackTrace();
} finally{
latch.countDown();
}
});
}
executorService.shutdown();
latch.await();
List<Reservation> reservations = reservationRepository.findAll();
Assertions.assertThat(reservations.size()).isEqualTo(1);
}
테스트 결과
테스트 결과 같은 좌석에 무려 10개의 예매가 생성되는 대참사가 일어나는 것을 볼 수 있습니다..
그렇다면 왜 이런 일이 발생하는 걸까요?
여러 개의 스레드가 조회하는 시점에는 해당 좌석이 아직 예약되지 않았고, 이로 인해 예약되있는 좌석이 포함되어 있을 때 예외를 발생해주는 검증을 통과한 스레드가 여러개 발생하며 같은 좌석 여러 예매가 발생하는 대참사가 일어난 것입니다..
synchronized
동시성 문제를 해결하기 위해 자바에서 하나의 스레드만이 임계구역에 접근하도록 해주는 synchronized 키워드를 사용해 보겠습니다. synchronized를 적용한 후 테스트 코드를 다시 실행해봅시다.
public synchronized RegisterReservationResponse registerReservation(final Long userId, final ReservationRequest request) {
final List<ScheduleSeat> seats = scheduleSeatRepository.findAllById(request.getSeatIds());
validateSeatsSize(request.getSeatIds(), seats);
validateSeatsIsReserve(seats);
seats.forEach(ScheduleSeat::reserveSeatStatus);
final Reservation reservation = Reservation.builder()
.scheduleId(request.getScheduleId())
.ticketId(request.getTicketId())
.seats(seats)
.userId(userId)
.build();
reservationRepository.save(reservation);
return RegisterReservationResponse.of(reservation);
}
오잉..? 테스트를 진행한 결과 확실히 개수는 줄었지만 2개의 예약가 생성된 것을 볼 수 있습니다. 무엇이 문제일까요..?
synchronized + @Transactional 문제점
@Transactional은 해당 어노테이션이 붙은 메서드에 Spring AOP로 프록시 객체를 만들게 되고 프록시 메서드에서 실제 트랜잭션을 시작하고 종료하는 작업을 처리합니다. 이 때 synchronized는 프록시 메서드에 적용되지 않아 한 개의 스레드만 접근하는 것을 보장하지 못합니다.
ex)
- 스레드 1이 registerReservation을 호출해 해당 로직을 실행한다.
- 스레드 1의 registerReservation메서드가 끝나고 tx.commit을 호출하기 전에 스레드 2가 registerReservation메서드를 호출한다.
또한 synchronized는 하나의 프로세스에서만 보장하기 때문에 서버가 여러대일 경우 동시성이 보장되지 않는다는 단점이 있습니다.
비관적 락(Pessimistic Lock)
비관적 락은 트랜잭션이 대부분 충돌한다는 상황을 가정하에 트랜잭션이 시작될 때 데이터베이스에 락을 걸어 다른 트랜잭션이 접근하지 못하게 하는 방법으로 Shared Lock과 Exclusive Lock이 존재합니다.
공유락(Shared Lock)
- 공유 락이 걸린 데이터에 대해서는 읽기만 가능하다
- 공유 락이 걸린 데이터에 대해 다른 트랜잭션도 공유 락을 획득할 수 있으나 베타 락은 획득할 수 없다.
베타 락(Exclusive Lock)
- 베타 락을 획득한 트랜잭션은 읽기, 쓰기 연산 모두 실행할 수 있다.
- 다른 트랜잭션은 베타 락이 걸린 데이터에 대해 읽기, 쓰기 작업이 불가능하다
Spring Data JPA에서 비관적 락을 사용하기 위해서는 Repository 인터페이스의 메서드에 @Lock 어노테이션을 붙여주고 설정해주고자 하는 LockModeType을 지정해주면 됩니다.
public interface ScheduleSeatRepository extends JpaRepository<ScheduleSeat, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT s FROM ScheduleSeat s WHERE s.id IN :ids")
List<ScheduleSeat> findAllByIdWithLock(@Param("ids") List<Long> ids);
}
이 후 위에서 사용했던 테스트 코드를 돌려보면
정상적으로 테스트가 통과하는 것을 확인할 수 있습니다.
- 스레드1이 테이블의 해당 row를 읽으면 Lock이 걸린다.
- 스레드2는 해당 row에 Lock이 걸려있어 조회가 불가능하고 대기상태가 된다.
- 스레드1의 트랜잭션이 커밋되고 락이 해제된다.
- 스레드2가 조회하고 락을 획득한다.
위와 같이 한 트랜잭션이 데이터를 조회한 경우 SELECT FOR UPDATE문을 통해 x-Rock을 걸게되고 해당 트랜잭션이 끝나기 전까지 다른 트랜잭션이 접근하지 못하는 것을 볼 수 있습니다.
낙관적 락(Optimistic Lock)
낙관적 락은 대부분의 트랜잭션은 충돌이 발생하지 않는다고 낙관적으로 가정하는 방법으로, 실제로 DB에 Lock을 설정하지 않고 Version을 관리하는 컬럼을 추가해 데이터 수정 시마다 맞는 버전의 데이터를 수정하는지 판단하는 방식입니다.
- 스레드 1과 스레드 2가 좌석아이디가 1인 레코드를 읽어온다.
- 스레드 1이 먼저 좌석 상태를 update하고 version 정보를 +1 올려주고 commit 한다.
- 스레드 2도 좌석상태를 변경하고 update를 시도한다
- 스레드 2의 버전과 일치하지 않아 update가 실패하고 예외가 발생한다.
스프링에서 낙관적 락을 사용하기 위해서는 위에서 진행했던 비관적 락과 비슷하게 Lock 관련 어노테이션을 붙여주면 됩니다.
public interface ScheduleSeatRepository extends JpaRepository<ScheduleSeat, Long> {
@Lock(LockModeType.OPTIMISTIC)
@Query("SELECT s FROM ScheduleSeat s WHERE s.id IN :ids")
List<ScheduleSeat> findAllByIdWithLock(@Param("ids") List<Long> ids);
}
추가적으로 Version 컬럼을 엔티티에 추가한 후 @Version 어노테이션을 선언해줍니다.
@Entity
public class ScheduleSeat {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private boolean isReserved;
@Version
private Long version;
....
}
다시한번 이전의 테스트를 돌려보면 성공적으로 1개의 예매내역만 저장되는 것을 볼 수 있습니다.
또한 동시 요청으로 인해 버전이 맞지 않아 ObjectOptimisticLockingFailureException이 발생하게 되는데 해당 예외를 적절히 자신의 서비스에 맞게 처리해주면 됩니다.
정리
비관적 락은 DB의 Lock을 통해 동시성을 제어하기 때문에 데이터의 무결성을 보장할 수 있다는 장점이 있습니다. 그렇기 때문에 충돌이 많이 발생하고 잦은 롤백으로 인해 문제가 발생할 것이 예상되는 곳에 사용하면 좋습니다. 하지만 DB상에 Lock을 걸기 때문에 성능상에 손해를 본다는 단점이 있습니다.
낙관적 락은 실제 데이터 충돌이 자주 일어나지 않을 것이라 예상되는 시나리오에 사용하면 성능상의 이점을 가져갈 수 있으나, 추가적인 오류처리가 필요하고, 동시 접근이 많이 생기는 경우 재시도 로직으로 인한 더 많은 리소스가 소모되는 상황들이 일어날 수 있습니다.
이렇게 각각의 기술의 장단점을 확인하고 개발하고 있는 서비스나 주변환경에 맞는 기술을 선택하는 것이 중요할 것 같습니다.
참고
https://hu-bris.tistory.com/180
'Backend > 프로젝트' 카테고리의 다른 글
[스프링] 토스 간편결제 기능 구현하기 (0) | 2024.09.10 |
---|---|
Redis 분산 락(Distribution Lock)으로 동시성 문제 해결하기(+ AOP) (0) | 2024.08.18 |
QueryDsl을 활용한 카테고리별 조회(페이징) 기능 만들기 (0) | 2024.07.30 |
Github Actions를 이용한 CI 구성 (0) | 2024.07.10 |
Spring Security + jwt + OAuth2(소셜 로그인 2편) (0) | 2024.06.30 |