Project/티켓 예매 서비스

[동시성 제어] 티켓 예매 중복 예매 문제 해결

hu6r1s 2024. 4. 21. 23:43

동시성 문제

콘서트 티켓 예매 서비스 프로젝트를 진행하는 중 여러 사용자가 한 좌석에 대해 거의 동시에 예매 요청을 보내게 되면 여러 개의 같은 예매가 생성되는 문제를 확인했다.

좌석 예매 서비스라면 많이 본 `이미 선택된 좌석입니다`처럼 동시성을 제어하기 위해 낙관적 락, 비관적 락 등 다양한 방식을 적용해 보며 문제를 해결해 나갔다.

예매 코드와 테스트 코드

예매 코드

public void createReservation(Long userId, Long concertId,  
    ReservationRequestDto requestDto) {  
  
    Seat seat = seatRepository.findSeatForReservation(concertId,  
        requestDto.getHorizontal(), requestDto.getVertical());  
  
    if (!seat.isReservable()) {  
        throw new CustomRuntimeException("예약 불가능한 좌석입니다.");  
    }  
    seat.reserve();  
  
    Reservation reservation = Reservation.builder()  
        .status("Y")  
        .userId(userId)  
        .concertId(concertId)  
        .seatId(seat.getId())  
        .build();  
	reservationRepository.save(reservation);

이 프로젝트에서 좌석 번호는 행과 열로 다루고 있어 `findSeatForReservation()` 메서드를 통해 콘서트의 좌석 행, 열 번호로 좌석을 찾고 해당 좌석이 예매 가능한 상태인지를 확인한다.

테스트 코드

@DisplayName("동시에 한자리 예매시 첫번째 요청만 예매성공한다.")  
@Test  
void concurrency_test() throws InterruptedException {  
    //given  
    int tryCount = 20;  
    long userId = 1L;  
    Long concertId = 1L;  
    ReservationRequestDto reservationRequestDto = ReservationRequestDto.builder()  
        .horizontal("A")  
        .vertical("1")  
        .build();  
    ExecutorService executor = Executors.newFixedThreadPool(10);  
  
    //when  
    CountDownLatch latch = new CountDownLatch(tryCount);  
    for (int i = 0; i < tryCount; i++) {  
        int finalI = i;  
        executor.submit(() -> {  
            try {  
                reservationService.createReservation(userId + finalI, concertId,  
                    reservationRequestDto);  
            } catch (Exception e) {  
                log.error(e.getMessage());  
            } finally {  
                latch.countDown();  
            }  
        });  
    }  
    latch.await();  
  
    //then  
    assertThat(reservationRepository.count()).isEqualTo(1);  
}

아이디가 1번인 콘서트의 A-1번 좌석에 대해 20명의 유저가 동시에 예매하려는 상황을 가정하고 테스트를 진행했다.

 

이렇게 한 명의 요청만 처리해야 하지만 10 명의 요청이 모두 들어와 있는 것을 확인할 수 있다.

동시성 제어

이러한 문제는 동시성을 제어하지 않았기 때문에 발생한 문제이다.

2개의 스레드를 통해 예시를 들자면, Thread1이 조회를 하고 수정을 하기 전에 Thread2가 조회를 할 수 있다.

그러면 두 스레드 모두 수정을 진행할 수 있게 된다. 이러한 방식으로 10개의 스레드를 생성한다면 스레드 수만큼의 결과를 받게 될 것이다.

이러한 문제를 해결하기 위해서 먼저 들어온 요청의 스레드가 처리를 하기 전까지 다른 스레드는 wait할 수 있도록 해줘야 한다.

즉, 좌석 예매를 임계영역(Critical Section)으로 설정하고 스레드 간의 경쟁상태(Race Condition)를 제어해줘야 한다.

synchronized

자바는 임계영역에 synchronized 키워드를 사용함으로써 동기화하여 여러 스레드가 접근하는 것을 막을 수 있다.

public synchronized void createReservation(Long userId, Long concertId,  
    ReservationRequestDto requestDto) {  
  
    Seat seat = seatRepository.findSeatForReservation(concertId,  
        requestDto.getHorizontal(), requestDto.getVertical());  
  
    if (!seat.isReservable()) {  
        throw new CustomRuntimeException("예약 불가능한 좌석입니다.");  
    }  
    seat.reserve();  
  
    Reservation reservation = Reservation.builder()  
        .status("Y")  
        .userId(userId)  
        .concertId(concertId)  
        .seatId(seat.getId())  
        .build();  
	reservationRepository.save(reservation);

간단하게 예매 메서드에 synchronized 키워드를 사용해주면 동시성 문제를 제어할 수 있다.

 

하지만 테스트에서는 2개의 요청이 받아들어진 것을 확인할 수 있다.

synchronized의 문제점

synchronized는 `@Transactional`과 함께 사용 시, 동시성을 제대로 제어할 수 없다.

@Transactional은 해당 어노테이션이 붙은 메서드에 트랜잭션 환경을 제공하기 위해 프록시 메서드를 만들고 그 프록시 메서드에서 실제 트랜잭션을 시작하고 종료하는 작업을 처리한다.

이 때 예매 메서드의 synchronized는 프록시 메서드에 적용되지 않는다.

그렇기 때문에 프록시 메서드에 한 개의 스레드만 접근하는 것을 보장하지 못한다.

락(Lock)

락은 무엇인가 열리지 않도록 막는 장치라는 사전적 의미처럼 대표적인 동시성 제어 기법 중 하나로, 데이터베이스의 일관성과 무관성을 유지하기 위해 트랜잭션의 순차적 진행을 보장할 수 있는 직렬화 장치이다.

JPA에서 제공하는 락에는 낙관적 락과 비관적 락이 있다.

낙관적 락

"트랜잭션들은 충돌하지 않을거야~"

 

트랜잭션 대부분이 충돌이 발생하지 않아 동시성 문제가 발생하지 않는다고 낙관적으로 가정하는 방법이다.
일반적으로 낙관적 락은 JPA가 제공하는 Version의 의미를 가지고 있는 컬럼을 사용하는데, 읽어올 때 Version과 수정 후
트랜잭션이 commit되는 시정에 Version이 다르면 충돌이 일어나게 된다.
그래서 낙관적 락은 DB에서 락을 거는 것이 아닌 Application level에서 잡아주는 락이다.
한마디로 일단 수정할 데이터 조회 시, 자원에 락을 걸어서 선점하지 말고 커밋할 때 동시성 문제가 발생하면 그 때 처리하자는 방법론이라고 할 수 있다.

 

낙관적 락 동작방식

 

  1. 유저 A가 테이블의 해당 row를 select한다. 이때 version은 1
  2. 거의 동시에 유저 B가 해당 row를 select한다. 이때도 version은 1
  3. 유저 A가 제목을 업데이트하면 version이 2로 수정
  4. 유저 B가 제목을 수정하려 했지만, 가지고 있던 version은 1이고, 테이블에 있는 version이 2로 바뀌었으므로 수정하지 못하고 예외 발생

비관적 락

"트랜잭션들은 무조건 충돌할 수 있다. 이 데이터는 중요하니깐 먼저 락부터 걸자."

 

트랜잭션이 시작될 때, 충동이 발생할 것이라고 비관적으로 보고 수정할 데이터를 조회할 때부터 먼저 락을 거는 방법이다.
DB가 제공하는 락 기능을 사용하며 주로 SQL 쿼리에 SELECT FOR UPDATE 구문을 사용하면서 시작하고 버전 정보는 사용하지 않는다.
데이터를 수정하는 즉시 트랜잭션 충돌을 감지할 수 있다.
공유락(S Lock)과 베타락(X Lock)이 있다.
비관적 락을 사용하면 락을 획득할 때까지 트랜잭션이 대기하는데 무한정 기다릴 수 없으므로 타임아웃 시간을 줄 수 있다.
한마디로 이 데이터는 중요하니깐 일단 조회 시, 자원에 Lock을 걸어서 동시성 문제가 발생하지 못하게 미리 처리하자는 방법론이라고 할 수 있다.

비관적 락 동작방식

  1. 유저 A가 테이블의 해당 row를 읽으면 Lock이 걸린다.
  2. 동시에 유저 B는 조회가 불가능하다.
  3. 유저 A의 Transaction1이 commit될 때까지 기다린다.
  4. 유저 A의 Transaction1이 커밋되서 락이 해제된다.
  5. 유저 B가 조회하고 유저 B의 Transaction2가 락을 획득한다.
  6. 유저 B가 수정을 하고 커밋하면 락이 해제된다.

낙관적 락 테스트

where절에 있는 version으로 조회 시의 버전과 update 시점의 버전이 다르면 예외가 발생하게 된다.

테스트는 성공했다.

비관적 락 테스트

 

조회할때 그냥 select 대신 select for update로 조회하고 해당 데이터에 배타적 lock을 걸어 lock을 획득한 트랜잭션의 update가 실행될 때까지 다른 트랜잭션의 데이터 조회를 막을 수 있다.

비관적 락을 통한 테스트도 성공했다.

둘 중 뭘 써야 하는가?

낙관적 락

충돌이 일어나면 롤백 및 복구처리를 해야 하지만, 실제로 데이터 충돌이 자주 일어나지 않을 것이라고 예상되는 시나리오
실제로 락을 잡진 않으므로 읽기가 잦아 성능적으로 중요할 때 그러나, 실제로 트랜잭션 충돌이 많이 일어나 복구 작업을 많이 해야 하는 로직이라면 좋지 않다.

비관적 락

데이터의 무결성이 중요하고, 충돌이 많이 발생하여 잦은 롤백 문제가 발생할 것이 예상되는 시나리오
그러나, row 자체에 락이 걸려있어 데드락이 일어날 가능성이 있고, 동시성이 떨어져 성능 저하가 있으므로 읽기가 많이
이루어지는 데이터베이스에는 좋지 않다.

 

 

낙관적 락, 비관적 락 모두 예매 중복 생성 방지라는 목표를 달성했다.

현재 프로젝트의 예매 기능에서는 비관적 락을 선택하는 것이 좋을 것 같지만, 낙관적락은 update, 비관적 락은 select for update쿼리 모두 스레드 요청만큼 발생하게 된다.

DB에 요청 수만큼 쿼리가 날아가면 부하가 심해질 것이다.

현재 프로젝트의 비즈니스 로직에서 첫번째로 좌석을 예매하는 요청이 오면 다른 요청은 모두 예외로 처리하면 된다. 첫 번째 요청 이외에는 락을 획득하거나 DB 조회를 할 이유가 없다.

 

그래서 해당 프로젝트에는 Redis가 싱글 스레드인 것과 인 메모리 데이터베이스인 점을 활용하였다.

Redis의 Set 자료구조를 활용하였다.

127.0.0.1:6379> sadd 1 A1 A2 A3
(integer) 3
127.0.0.1:6379> smembers 1
1) "A1"
2) "A2"
3) "A3"
127.0.0.1:6379> sadd 1 A1
(integer) 0

이러한 구조로 아이디가 1번인 콘서트의 A1, A2, A3 좌석이 예매된 것을 확인 할 수 있게 된다.

만약 유저가 아이디가 1번인 콘서트에서 A1에 대한 요청이 동시에 여러 개가 들어온다면 첫 요청에 대해 데이터를 넣어주고 이후에는 해당 값이 존재하면 예외를 발생하도록 했다.

마무리

처음 부분에서 말씀드린것처럼 Redis는 인메모리 기반으로 작동하기 때문에 서버 재시작시 모든 데이터가 사라진다
따라서 Redis를 캐시 이외의 용도로 사용할시에는 적절한 데이터 백업이 필요한데 이에는 두가지 방법이 있다.

  1. RDB 스냅샷 저장 방식으로 당시의 메모리 그대로 파일로 저장. 특정 조건이 만족되면 스냅샷을 찍는 방식이므로 조건 전에 Redis가 종료되면 그 사이 데이터는 유실된다.
  2. AOF 데이터 변경 커맨드를 모두 저장 모든 쓰기 명령에 대한 로그를 남기기 때문에 장애 상황 직전까지 모든 데이터가 보장된다.

프로젝트에는 당연히 예매 기능에서 Redis의 db정보가 중요한 역할을 하기 때문에 AOF를 선택하였다.

AOF 설정은 redis.conf파일의 appendonly를 yes로 변경하면 AOF가 적용되고 서버 시작시 aof파일을 읽어서 db에 그대로 다시 저장하게 된다.