티켓팅 서비스 동시성 문제 해결하기 - 1. 중복 예매 문제

정훈희·3일 전
3

Ticket World

목록 보기
1/1

배경

티켓팅을 하다보면 심심찮게 보게되는 "이미 선택된 좌석입니다"

문득 이 문구를 보니까 티켓팅 서비스는 어떻게 동시성 문제들을 해결하는지 궁금해졌다.

그래서 직접 티켓팅 서비스를 구현한 뒤 부하 테스트를 진행하였고, 이 과정에서 발생한 동시성 문제를 해결해보았다.

요구사항

  1. 티켓팅 과정을 수행할 수 있다.
  2. 티켓팅 과정에서 동시성 문제가 발생해선 안된다.
    • 전체 티켓 보다 많은 티켓이 예매되면 안된다.
    • 1인당 최대 4개의 티켓을 예매할 수 있다.

테스트 환경

대상내용
SpringBoot 리소스CPU 2코어, 메모리 2GB
MySQL 리소스CPU 2코어, 메모리 2GB
테스트 스크립트k6
APMNewRelic
MySQL 모니터링MySQL Expoter, Prometheus, Grafana
테스트 결과 대시보드InfluxDB, Grafana
데이터(row)공연 549, 회차 6067, 티켓 2690310
  • Money Issue로 Local 환경에서 진행하였다.

테스트 시나리오

테스트 결과 - 동시성 문제 발생

테스트 회차1차2차3차4차5차avg
예매된 티켓 수417419420432422422
구매 제한 초과1명1명1명1명0명0.8명

1. 중복 예매 문제 발생

모든 테스트에서 예매된 티켓수가 총 티켓 수 400을 초과

2. 구매 제한 초과 문제 발생

구매 제한 수량인 4개를 초과한 유저가 4명 발생

중복 예매 동시성 문제 해결하기

두 가지 동시성 문제 중 예매된 티켓 수가 총 티켓 수를 초과하는 중복 예매 동시성 문제를 먼저 해결해보자.

원인 분석

@Transactional
fun tempReserve(request: TempReserveRequest): TempReserveResponse {
	// 예매할 티켓들 조회
    val tickets = reservationRepository.getTicketsByIds(request.ticketIds)
    
	// 전부 예매 가능한 상태면 예매 생성
    val reservation = Reservation.createTempReservation(tickets, request.userId)
    
    /** 중간 로직 생략 */
    
    // 예매 저장
    reservationRepository.save(reservation)
    
    return TempReserveResponse(reservation.id)
}

임시 예매는 아래와 같은 과정으로 이루어진다.

  1. 예매할 티켓들을 조회
  2. 모든 티켓이 예매가 가능한 상태면 임시 예매 생성 후 저장

하지만 같은 티켓에 여러 트랜잭션이 동시에 접근하면 조회 시점과 갱신 시점의 차이로 동시성 문제가 발생한다.

트랜잭션 A와 B가 동시에 같은 티켓을 조회한다. 조회 시에는 둘 다 예매 가능 상태였기 때문에 서로 업데이트를 하게되고, 결국 먼저 업데이트를 한 트랜잭션 A의 예매는 손실되는 갱신 손실이 발생한다.

해결 방법 1. synchronized → Scale Out 환경에서는 해결 불가

Java의 synchronized(Kotlin은 @Synchronized) 키워드를 붙히면 여러 쓰레드가 동시에 메서드를 실행하지 못하게 하므로 동시성 문제를 방지할 수 있다.

하지만, synchronized가 "여러 쓰레드가 동시에 메서드를 실행하지 못하게 하는 것"은 하나의 프로세스 에서만 가능하다. 그래서, scale out 환경에서는 동시성 문제가 발생할 수 있다.

해결 방법 2. 비관적 락

해결 방법은 간단하다. 임시 예매 시 조회하는 티켓들이 동시 접근이 이루어질 것이라고 가정하고 바로 락을 걸어서 읽거나 쓰지 못하게 하면 된다.

이렇게 비관적으로 동시 접근이 이루어질 것이라고 가정하고 먼저 락을 거는 방식을 비관적 락(Pessimistic Lock)이라고 한다.

@Query("SELECT t FROM TicketEntity t WHERE t.id IN :ids")
@Lock(LockModeType.PESSIMISTIC_WRITE)
fun findTicketsByIdsWithPessimistic(ids: List<UUID>): List<TicketEntity>

구현은 위와 같이 매우 간단하다. 기존 티켓 조회 쿼리에 @Lock(LockModeType.PESSIMISTIC_WRITE)을 붙히면 된다.

이렇게 하면, 조회 쿼리에 FOR UPDATE가 추가되어 다른 트랜잭션에서 Lock을 획득한 상태면 해당 트랜잭션 Commit 시점까지 대기한다.

비관적 락 적용 후 테스트 결과

테스트 회차1차2차3차4차5차avg
예매된 티켓 수400400400400400400

이로써, 중복 예매 동시성 문제는 해결되었다.

하지만, 비관적 락은 미리 충돌을 가정하고 Lock을 걸기 때문에, 충돌이 자주 발생하지 않는 경우에도 무조건 Lock 획득을 위해 대기해야 하므로 충돌이 자주 발생하는 경우에 유리하다. 또한, 데드락이 발생할 가능성이 높아진다.

  • MySQL Expoter로 수집한 락 대기시간을 모니터링한 결과, 비관적 락을 적용한 뒤 락 대기시간이 약 7배 증가하였다.

그렇다면 다른 방법은 없을까?

해결 방법 3. 낙관적 락

비관적 락은 충돌을 가정하고 미리 락을 걸기 때문에, 성능 저하 및 데드락과 같은 오버헤드가 있다.

그래서, 등장한 것이 낙관적 락이다. 낙관적 락은 데이터 조회에 Lock을 걸지 않고, 데이터 업데이트 시점에 충돌을 검사하는 식으로 동시성 문제를 해결한다.

@Entity
@Table(name = "ticket")
class TicketEntity(
    //...
    @Version
    @Column(name = "version")
    val version: Long = 0L,
)

위와 같이, JPA에서는 간단하게 @Version을 활용하여 낙관적 락을 구현할 수 있다.

낙관적 락을 적용하면 id가 1이고, version이 3인 티켓에 대한 업데이트 시 아래와 같은 쿼리가 날라간다.

UPDATE ticket
SET reservation_id = ?, version = 4
WHERE id = 1 AND version = 3

만약 충돌이 일어나면 ObjectOptimisticLockingFailureException예외가 발생하고, 재시도 로직을 구현하거나 롤백 처리를 해야한다.

이를 적용한 뒤 테스트를 진행해보자.

낙관적 락 적용 후 테스트 결과

테스트 회차1차2차3차4차5차avg
예매된 티켓 수400400400400400400

비관적 락과 마찬가지로, 낙관적 락 또한 중복 예매 동시성 문제가 해결된 모습이다.

중복 예매 동시성 문제 해결 - 비관적 락 vs 낙관적 락

중복 예매 동시성 문제는 비관적 락과 낙관적 락 모두 해결 가능하다는 것을 알게되었다. 그렇다면 어떤 락이 더 효과적일까? 앞서 진행한 두 부하 테스트 결과를 비교해보자.

시나리오는 앞서 설명한 내용과 동일, 각각 10회 수행 후 평균 응답시간 최상위와 최하위 결과 제외

API Endpoint낙관적 락 (ms)비관적 락 (ms)차이 (비관 – 낙관)성공실패
전체 평균1516.341753.79+236.663871.29894.45
공연 상세1,492.731,593.01+100.285000
좌석 영역1,898.492,078.93+180.445000
티켓 상태1,422.891,643.15+220.261879.130
임시 예매1,547.031,836.41+289.38251.26881.57
할인 목록1,521.531,797.80+276.27251.260
결제 시작1,520.032,037.48+517.45251.260
결제 승인1,159.351,556.88+397.53238.3812.88

결과 분석 - 낙관적 락 win

모든 API에서 낙관적 락이 비관적 락 보다 응답속도가 빨랐다.

또한, 티켓의 락에 영향을 받는 API들과, 티켓팅 과정의 후반에 실행되는 API들의 응답시간이 더 크게 상승했다.

분명 임시 예매 API는 충돌이 많이 일어난다(총 1133번의 호출 중 80%가 실패). 근데, 왜 비관적 락 보다 낙관적 락이 왜 더 빨랐을까? 그 이유를 분석해보자.

낙관적 락이 더 빠른 이유 - 1. 충돌 시 재시도할 필요가 없다.

티켓이 업데이트 되는 경우는 1. 임시 예매, 2. 결제 승인 이렇게 두 가지 경우다. 그렇기 때문에, 임시 예매 과정에서 충돌이 났다면 다른 사용자가 임시 예매 혹은 결제를 완료한 것이므로 재시도를 할 필요가 없다.

물론, 충돌 시 트랜잭션 Roll Back을 수행해야 한다. 하지만, 낙관적 락의 가장 큰 오버헤드는 잦은 충돌과 이로 인해 반복되는 재시도로 인한 응답 시간 지연이다.

그렇기 때문에, 재시도를 고려하지 않아도 되므로 낙관적 락의 응답속도가 더 빨랐다고 생각한다.

낙관적 락이 더 빠른 이유 - 2. 락이 없다. (당연히..)

만약 API의 응답 시간이 50ms고, 5명의 유저가 동시에 하나의 티켓에 접근한다고 가정해보자.

비관적 락이 걸린 경우는 락을 먼저 차지한 사용자가 트랜잭션을 Commit할 때 까지 다른 사용자들은 대기해야한다. 그러므로 5건의 요청을 모두 처리하기 위해선 250ms가 소모된다. (물론 쓰기 시간 및 다른 작업을 고려하지 않은 것이므로 더 빠를 것이다.)

반면에 낙관적 락이 걸린 경우는 두 사용자 모두 대기 없이 트랜잭션을 수행하고, 먼저 Commit한 유저가 예매에 성공하게된다. 그러므로 5건의 요청을 모두 처리하기 위해선 50ms가 소모된다. (물론 Roll Back 및 DB 부하는 고려하지 않은 것이므로 더 느릴 것이다.)

결론 - 낙관적 락 도입

단순히 이론적인 내용으로만 생각했을 때는 충돌이 잦으니 비관적 락을 적용해야 된다고 생각했다.

하지만, 직접 테스트를 통해 결과를 비교하고 원인을 분석하며 더 적절한 락을 도입할 수 있었다.

추후 분산 환경으로 옮기게 된다면 분산 락도 고려해볼 수 있을 것 같다.

profile
DB를 사랑하는 백엔드 개발자입니다. 열심히 공부하고 열심히 기록합니다.

0개의 댓글

관련 채용 정보