Cloudwave

[Cloudwave] CGV 대용량 트래픽 예매 서비스 구현 - 2

냄B뚜껑 2025. 4. 24. 00:49

서론

이전 글에서 개발자의 락 명시의 중요성에 대해 알게 되었다.

그래서 이번엔 락을 개발자가 직접 명시해 데드락을 방지하고 동시성을 제어해보자.

대표적인 방식은 비관적락과 낙관적 락이 있다.

 

 

비관적 락 vs 낙관적 락

먼저 비관적 락을 먼저 적용해보겠다.

 

비관적 락은

  • PESSIMISTIC_READ
  • PESSIMISTIC_WRITE

이 두 가지 락을 제공하는데, s-lock과 x-lock이다. 

개발자가 s-lock과 x-lock을 직접 제어해 데드락을 방지하고 정합성을 확실히 보장할 수 있다.

 

@Lock(LockModeType.PESSIMISTIC_WRITE)  // 비관적 락을 설정
@Query("SELECT s FROM Seat s WHERE s.id = :seatId")
Optional<Seat> findBySeatIdWithRock(@Param("seatId") Long seatId);

 

 

findSeatBySeatId()는 기본 jpa 메서드이기 때문에 다른 클래스도 많이 사용한다.

그래서 따로 x-lock을 거는 메서드를 하나 만들어준다.

 

@Transactional
    public ReservationRes createReservation(String userName, Long seatId){
        Seat seat=seatRepository.findBySeatIdWithRock(seatId)
                .orElseThrow(() -> new CustomException(StatusCode.SEAT_NOT_EXIST));

        if(!seat.getIsReserved())
            seat.soldout();
        else throw new CustomException(StatusCode.SEAT_SOLD_OUT);


        Reservation reservation= Reservation.builder()
                .userName(userName)
                .status(Status.RESERVED)
                .seat(seat)
                .build();


        return ReservationRes.from(reservationRepository.save(reservation));
    }

 

이제 서비스 코드에서 첫 문장에서 바로 x-lock을 검으로 써 기존 s-lock -> x-lock을 거는 순서로 향하지 않기 때문에,

트랜잭션끼리 s-lock을 해제할 때 까지 기다리게 되지 않는다. 이렇게 데드락을 방지하고 x-lock이 해제될 때 까지 대기함으로 써 모든 스레드가 좌석 예매 코드를 끝까지 실행할 수 있게 된다.

 

 

 

이번엔 낙관적 락을 한번 적용해보자!

 

낙관적 락은 Version을 통해 애플리케이션 수준에서 충돌을 감지해 예외를 던지는 형식이다.

update를 하면 version이 1씩 오르게된다. 따라서 동시에 update할 시 version이 맞지 않으면 예외를 던지게 된다.

 

public class Seat {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "row_index",nullable = false)
    private Integer rowIndex;

    @Column(name = "column_index",nullable = false)
    private Integer columnIndex;

    @Column(name = "is_reserved", nullable = false)
    private Boolean isReserved;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "schedule_id", nullable = false)
    private Schedule schedule;

    @Version
    private Long version;
}

 

이렇게 동시성 제어가 필요한 엔티티에 @Version을 붙여주면 jpa가 자동으로 버전 관리를 해준다.

 

 

 

예상 작동 방식

사용자 3명이 동시에 한 좌석을 예매하려고 시도하는 상황일 때

 

비관적 락: 사용자 1 락 획득 -> 예매 성공 -> 사용자 2,3 대기 -> 사용자 2 락 획득 -> 예매 실패 -> 사용자 3 락 획득 -> 예매 실패

 

낙관적 락: 사용자 1,2,3 예매 시도 -> 사용자 1 예매 성공(Version 1 증가) -> 사용자 2,3 예매 실패(Version 안맞음,예외 발생)

 

이런 방식으로 흘러가는 것으로 예상이 된다. 이제 테스트를 진행해 확인해보도록 하자!

 

 

테스트 코드

 

@Test
    @DisplayName("좌석 예매 정합성 테스트")
    void testConcurrentReservation() throws InterruptedException {
        int numberOfThreads = 1000; // 요청 수
        ExecutorService executorService = Executors.newFixedThreadPool(10);
        CountDownLatch latch = new CountDownLatch(numberOfThreads);
        AtomicInteger successCount = new AtomicInteger(0);
        List<Exception> exceptions = new ArrayList<>();
        List<Long> responseTimes = new ArrayList<>();

        // 테스트 시작 시간 기록
        long testStartTime = System.nanoTime();

        for (int i = 0; i < numberOfThreads; i++) {
            final String username = "user" + i;

            executorService.submit(() -> {
                long startTime = System.nanoTime(); // 시작 시간 기록
                try {
                    reservationService.createReservation(username, seatId);
                    successCount.incrementAndGet();
                } catch (OptimisticLockException e) {
                    synchronized (exceptions) {
                        exceptions.add(e);
                    }

                } catch (CustomException e) {
                    synchronized (exceptions) {
                        // 다른 예외도 처리
                        exceptions.add(e);
                    }
                } catch (Exception e) {
                    synchronized (exceptions) {
                        // 다른 예외도 처리
                        exceptions.add(e);
                    }
                }

                finally {
                    long endTime = System.nanoTime(); // 종료 시간 기록
                    long responseTime = endTime - startTime; // 응답 시간 계산
                    synchronized (responseTimes) {
                        responseTimes.add(responseTime); // 응답 시간 리스트에 추가
                    }
                    latch.countDown();
                }
            });
        }

        latch.await();
        executorService.shutdown();

        // 테스트 종료 시간 기록
        long testEndTime = System.nanoTime();

        // 전체 테스트 시간 계산 (단위: 밀리초)
        long totalTestTimeMillis = (testEndTime - testStartTime) / 1_000_000; // nano -> milliseconds


        Seat seat = seatRepository.findById(seatId)
                .orElseThrow(() -> new RuntimeException("Seat not found"));

        System.out.println("성공한 예약 수: " + successCount.get());
        System.out.println("좌석 예약 여부: " + seat.getIsReserved());
        System.out.println("발생한 예외 수: " + exceptions.size());

        // 응답 시간 계산
        List<Long> responseTimeList = new ArrayList<>(responseTimes);
        long fastestResponse = responseTimeList.stream().min(Long::compare).orElse(0L) / 1_000_000; // nanoseconds -> milliseconds
        long slowestResponse = responseTimeList.stream().max(Long::compare).orElse(0L) / 1_000_000; // nanoseconds -> milliseconds
        double averageResponse = responseTimeList.stream().mapToLong(Long::longValue).average().orElse(0) / 1_000_000.0; // nanoseconds -> milliseconds


        System.out.println("최단 응답 시간: " + fastestResponse + "ms");
        System.out.println("최장 응답 시간: " + slowestResponse + "ms");
        System.out.println("평균 응답 시간: " + averageResponse + "ms");
        // 전체 테스트 시간 출력
        System.out.println("전체 테스트 시간: " + totalTestTimeMillis + "ms");

        exceptions.forEach(ex -> System.out.println("예외 종류: " + ex.getClass().getSimpleName()));


        assertTrue(seat.getIsReserved(), "좌석은 예약 상태여야 합니다");
        assertEquals(1, successCount.get(), "동시에 하나만 성공해야 합니다");
        assertEquals(numberOfThreads - 1, exceptions.size(), "나머지는 예외가 발생해야 합니다");

    }

 

 

 

테스트 결과

- 비관적 락

데드락이 생기지 않고 모든 스레드가 실행을 끝마쳤다.

사용자 수 1000명 5000명 10000명
최단 응답 시간 (ms) 15 12 11
최장 응답 시간 (ms) 114 94 159
평균 응답 시간 (ms) 22.54 18.48 18.74
전체 테스트 시간 (ms)  2266(2초) 9259(9초) 18763(18초)

 

 

- 낙관적 락

낙관적 락을 적용했지만 데드락이 생겼다??

낙관적 락이 어플리케이션 수준에서 충돌을 감지한다 해도, mysql에서 자동으로 적용하는 락은 어찌 할 수가 없는 것이다.

 

따라서, FK를 가지고 있는 테이블은 낙관적 락을 사용할 수 없다.

mysql에서 FK를 가지고 있는 테이블이 s-lock을 걸기 때문에 update 시 x-lock을 얻기위해 s-lock 해제를 기다려 데드락이 걸리는 것이다.

 

 

 

테스트 결과 분석

사실 낙관적 락의 성능이 뛰어날 줄 알고, 낙관적 락을 써야겠다 생각했는데, 비관적 락만 사용가능하다는 것이 충격적이었다.

CGV 좌석 예매 서비스는 한 좌석에 대해 예매를 시도하는 것이기 때문에 낙관적 락을 사용함으로 써,

한 좌석만 성공하고 나머지는 락을 대기할 것도 없이 예외처리함으로 써 응답시간을 줄이고, 사용자가 재빨리 다른 자리를 선점할 수 있도록 하고싶었다.

 

비관적 락은 사용자 5000명이 예매를 시도하면 테스트 시간이 9초이고, 스레드풀이 10이므로, 각 스레드 풀의 500번째 사람은 9초가 걸려 좌석을 예매하지 못했다는 응답을 받기 때문이다. 

 

 

 

개선

그럼 비관적 락을 낙관적 락처럼 사용할 순 없을까?

낙관적 락을 사용하려 했던 이유는, 1개를 뺀 나머지 요청은 롤백시켜 예외처리를 하려했기 때문이다.

그렇다면 비관적 락도 락 획득을 하기위한 대기시간을 0초로 만들면 1개를 뺀 나머지 요청을 바로 예외처리할 수 있지 않을까?

 

@Lock(LockModeType.PESSIMISTIC_WRITE)  // x-lock 설정
@Query("SELECT s FROM Seat s WHERE s.id = :seatId")
@QueryHints(
        @QueryHint(name = "jakarta.persistence.lock.timeout", value = "0")  // 락 못 잡으면 바로 예외
)
Optional<Seat> findSeatBySeatIdWithRock(@Param("seatId") Long seatId);

 

@QeuryHint로 jpa에게 lock.timeout을 0으로 만들어 x-lock을 못얻으면 PessimisticLockingFailureException을 던지도록 했다.

 

 

 

성능 비교

개선 전

사용자 수 1000명 5000명 10000명
최단 응답 시간 (ms) 15 12 11
최장 응답 시간 (ms) 114 94 159
평균 응답 시간 (ms) 22.54 18.48 18.74
전체 테스트 시간 (ms) 2266(2초) 9259(9초) 18763(18초)

 

 

개선 후

사용자 수 1000명 5000명 10000명
최단 응답 시간 (ms) 8 5 5
최장 응답 시간 (ms) 90 107 147
평균 응답 시간 (ms) 13.48 11.98 11.53
전체 테스트 시간 (ms) 1358(1초) 6009(6초) 11555(11초)

 

 

사용자 1000명 기준으로 40.06%,

사용자 5000명 기준으로 35.16%,

사용자 10000명 기준으로 38.44% 성능이 개선되었다.

 

 

마무리

최종적으로 비관적 락을 적용하여 데드락을 방지하였다. 또한, 비관적 락을 사용할 때 락을 못 잡은 요청은 바로 예외를 던져 더 이상 대기하지 않도록 처리했다.

이로써 응답 시간을 크게 단축해, 서비스에 맞는 동시성 처리 방식으로, 예매 시스템의 성능을 개선하였다.

 

실무에서는 관계를 맺지않는 경우가 많다고 들었다. 제일 와닿는 경험으로는 n+1문제가 있지만

낙관적 락도 fk를 가지는 테이블에 적용하지 못한다는 사실을 알게되면서,

왜 관계를 맺지 않는 지를 조금이나마 체감한 것 같다.