티켓 예매 사이트 - 4

토스 모의결제 API에 연결, 좌석 선정에 대해 여러가지 방법을 사용해서 테스트를 해보았다.
성능 테스트는 k6을 사용했다.

토스 연결

서버에서 좌석을 5분간 HOLD 상태로 변경하고, 주문 번호와 결제 금액을 포함한 예약을 생성한다. 결제창 진행 후에는 프론트엔드에서 전달받은 paymentKey, orderId, amount를 이용해 백엔드가 토스 결제 승인 API를 호출한다.

승인에 성공하면 별도 DB 트랜잭션에서 예약 상태를 CONFIRMED, 좌석 상태를 RESERVED로 변경한다.
이때 저장된 예약 금액과 전달받은 금액을 비교한다.

아직은 결제는 승인됐지만 DB 저장이 실패하는 상황에 대한 대처는 하지 않았다.

예매번호와 좌석에 랜덤UUID 번호가 부여되어 있다. 도중에 결제 도중에 나간다면 UUID번호와 대조 후
다른 사람의 선점 상태를 확인 후 처리하게 하였다. (A가 5분 동안 결제를 하지 않고 선점은 풀렸지만 B가
예약 후 A가 취소한다면 바로 선점이 풀려버리기 때문에 도입하였다)

image.png

락 테스트

비관적 락

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")})
    @Query("SELECT s FROM Seat s WHERE s.schedule.id = :scheduleId AND s.seatNumber IN :seatNumbers ORDER BY s.seatNumber ASC")
    List<Seat> findByScheduleIdAndSeatNumberInWithLock(
            @Param("scheduleId") Long scheduleId,
            @Param("seatNumbers") List<String> seatNumbers
    );
TOTAL RESULTS 

    checks_total.......: 4000   355.397566/s
    checks_succeeded...: 24.82% 993 out of 4000
    checks_failed......: 75.17% 3007 out of 4000

    ✗ 성공(201)
      ↳  0% — ✓ 1 / ✗ 999
    ✗ 품절마감(409)
      ↳  88% — ✓ 883 / ✗ 117
    ✗ 서버 연결 튕김(Status 0)
      ↳  10% — ✓ 109 / ✗ 891
    ✗ 서버 내부 에러(500)
      ↳  0% — ✓ 0 / ✗ 1000

    HTTP
    http_req_duration..............: avg=536.18ms min=0s      med=536.5ms  max=1.1s    p(90)=1.01s   p(95)=1.06s  
      { expected_response:true }...: avg=52.73ms  min=11.93ms med=52.73ms  max=93.53ms p(90)=85.37ms p(95)=89.45ms
    http_req_failed................: 99.80% 999 out of 1001
    http_reqs......................: 1001   88.938241/s

    EXECUTION
    iteration_duration.............: avg=587.34ms min=48.13ms med=587.26ms max=1.15s   p(90)=1.04s   p(95)=1.07s  
    iterations.....................: 1000   88.849392/s
    vus............................: 271    min=0           max=271 
    vus_max........................: 1000   min=1000        max=1000

    NETWORK
    data_received..................: 366 kB 33 kB/s
    data_sent......................: 197 kB 18 kB/s

1명만 통과 후 나머지는 실패한다. 하지만 비관적 락으로 여러 사용자가 한번에 요청을 날리니 서버 자체가
커넥트 풀을 다 써서 응답 자체를 못하게 되어버렸다.


p95를 확인해보면 1.06초 정도 응답에 걸렸다.

낙관적 락

    @Query("SELECT s FROM Seat s WHERE s.schedule.id = :scheduleId AND s.seatNumber IN :seatNumbers ORDER BY s.seatNumber ASC")
    List<Seat> findByScheduleIdAndSeatNumberIn(
            @Param("scheduleId") Long scheduleId,
            @Param("seatNumbers") List<String> seatNumbers
    );
TOTAL RESULTS 

    checks_total.......: 4000   355.630345/s
    checks_succeeded...: 24.77% 991 out of 4000
    checks_failed......: 75.22% 3009 out of 4000

    ✗ 성공(201)
      ↳  0% — ✓ 1 / ✗ 999
    ✗ 품절마감(409)
      ↳  68% — ✓ 681 / ✗ 319
    ✗ 서버 연결 튕김(Status 0)
      ↳  30% — ✓ 309 / ✗ 691
    ✗ 서버 내부 에러(500)
      ↳  0% — ✓ 0 / ✗ 1000

    HTTP
    http_req_duration..............: avg=421.95ms min=0s      med=462.68ms max=998.38ms p(90)=882.67ms p(95)=941.03ms
      { expected_response:true }...: avg=115ms    min=24.26ms med=115ms    max=205.75ms p(90)=187.6ms  p(95)=196.68ms
    http_req_failed................: 99.80% 999 out of 1001
    http_reqs......................: 1001   88.996494/s

    EXECUTION
    iteration_duration.............: avg=556.19ms min=89.61ms med=596.58ms max=1.19s    p(90)=1.02s    p(95)=1.06s   
    iterations.....................: 1000   88.907586/s
    vus............................: 272    min=0           max=272 
    vus_max........................: 1000   min=1000        max=1000

    NETWORK
    data_received..................: 304 kB 27 kB/s
    data_sent......................: 153 kB 14 kB/s

version을 사용해서 낙관적 락으로 걸었을 시 941.03ms로 빨라지기는 했으나 똑같이 한번에 요청 시 커넥트 풀이
감당을 할 수 없어서 요청이 다 죽어버렸다.


Redis를 활용한 분산 락

redisson 의존성을 사용했다.

@Transactional(propagation = Propagation.NEVER)
    public ReservationResponse createSimpleReservation(String memberEmail, Long scheduleId, List<String> seatNumbers) {
        if (scheduleId == null || seatNumbers == null || seatNumbers.isEmpty()
                || seatNumbers.stream().anyMatch(seat -> seat == null || seat.isBlank())) {
            throw new IllegalArgumentException("회차와 좌석을 지정해야 합니다.");
        }
        return seatLockService.withLocks(scheduleId, seatNumbers, () ->
                new TransactionTemplate(transactionManager).execute(status ->
                        createReservationInTransaction(memberEmail, scheduleId, seatNumbers)));
    }

내부적으로 빈에 등록이 되더라도 트랜잭션 함수를 실행할 수 없으므로 TransactionTemplate로 감쌌다.
이렇게 하지 않으면 Lock을 걸고 Unlock이 먼저 실행되고 Commit이 실행 될 수 있기 때문에 트랜잭션이
종료 되었을 경우 UnLock을 실행하게 했다.

TOTAL RESULTS 

    checks_total.......: 4000   350.084764/s
    checks_succeeded...: 25.00% 1000 out of 4000
    checks_failed......: 75.00% 3000 out of 4000

    ✗ 성공(201)
      ↳  0% — ✓ 1 / ✗ 999
    ✗ 품절마감(409)
      ↳  99% — ✓ 999 / ✗ 1
    ✗ 서버 연결 튕김(Status 0)
      ↳  0% — ✓ 0 / ✗ 1000
    ✗ 서버 내부 에러(500)
      ↳  0% — ✓ 0 / ✗ 1000

    HTTP
    http_req_duration..............: avg=169.41ms min=16.28ms med=149.65ms max=883.54ms p(90)=244.79ms p(95)=258.48ms
      { expected_response:true }...: avg=376.68ms min=16.28ms med=230.23ms max=883.54ms p(90)=752.88ms p(95)=818.21ms
    http_req_failed................: 99.70% 999 out of 1002
    http_reqs......................: 1002   87.696233/s

    EXECUTION
    iteration_duration.............: avg=292.7ms  min=82.4ms  med=283.76ms max=463.18ms p(90)=391.09ms p(95)=406.45ms
    iterations.....................: 1000   87.521191/s
    vus............................: 1000   min=0           max=1000
    vus_max........................: 1000   min=1000        max=1000

    NETWORK
    data_received..................: 437 kB 38 kB/s
    data_sent......................: 221 kB 19 kB/s

DB가 처리하기 전 LOCK을 해서 중간에 들어오는 응답들을 쳐냈더니
258.48ms으로 확실하게 좋아졌으며, 커넥트 풀로 인해 쳐내는 상황 자체가 사라졌다.


결론

비관적 락은 완벽하게 처리해야 하는 은행업무 같은 상황에 사용하는게 좋을 것 같고,
낙관적 락은 딱 하나만 통과해야되는 상황에 쓰면 좋을 것 같다.

하지만 둘 다 대규모 처리할 때는 커넥트 풀이 한정적이기 때문에 redis를 둬서 db 접근을 최소한으로 해야한다.
대규모에서 둘 다 사용해야하는 상황이 생긴다면 redis로 대기열을 주고 하나씩 처리하면 될 것 같다.

댓글 0

댓글을 불러오는 중입니다.