티켓팅을 하다보면 심심찮게 보게되는 "이미 선택된 좌석입니다"
문득 이 문구를 보니까 티켓팅 서비스는 어떻게 동시성 문제들을 해결하는지 궁금해졌다.
그래서 직접 티켓팅 서비스를 구현한 뒤 부하 테스트를 진행하였고, 이 과정에서 발생한 동시성 문제를 해결해보았다.
대상 | 내용 |
---|---|
SpringBoot 리소스 | CPU 2코어, 메모리 2GB |
MySQL 리소스 | CPU 2코어, 메모리 2GB |
테스트 스크립트 | k6 |
APM | NewRelic |
MySQL 모니터링 | MySQL Expoter, Prometheus, Grafana |
테스트 결과 대시보드 | InfluxDB, Grafana |
데이터(row) | 공연 549, 회차 6067, 티켓 2690310 |
테스트 회차 | 1차 | 2차 | 3차 | 4차 | 5차 | avg |
---|---|---|---|---|---|---|
예매된 티켓 수 | 417 | 419 | 420 | 432 | 422 | 422 |
구매 제한 초과 | 1명 | 1명 | 1명 | 1명 | 0명 | 0.8명 |
모든 테스트에서 예매된 티켓수가 총 티켓 수 400을 초과
구매 제한 수량인 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)
}
임시 예매는 아래와 같은 과정으로 이루어진다.
하지만 같은 티켓에 여러 트랜잭션이 동시에 접근하면 조회 시점과 갱신 시점의 차이로 동시성 문제가 발생한다.
트랜잭션 A와 B가 동시에 같은 티켓을 조회한다. 조회 시에는 둘 다 예매 가능 상태였기 때문에 서로 업데이트를 하게되고, 결국 먼저 업데이트를 한 트랜잭션 A의 예매는 손실되는 갱신 손실이 발생한다.
Java의 synchronized
(Kotlin은 @Synchronized
) 키워드를 붙히면 여러 쓰레드가 동시에 메서드를 실행하지 못하게 하므로 동시성 문제를 방지할 수 있다.
하지만, synchronized
가 "여러 쓰레드가 동시에 메서드를 실행하지 못하게 하는 것"은 하나의 프로세스 에서만 가능하다. 그래서, scale out 환경에서는 동시성 문제가 발생할 수 있다.
해결 방법은 간단하다. 임시 예매 시 조회하는 티켓들이 동시 접근이 이루어질 것이라고 가정하고 바로 락을 걸어서 읽거나 쓰지 못하게 하면 된다.
이렇게 비관적으로 동시 접근이 이루어질 것이라고 가정하고 먼저 락을 거는 방식을 비관적 락(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 |
---|---|---|---|---|---|---|
예매된 티켓 수 | 400 | 400 | 400 | 400 | 400 | 400 |
이로써, 중복 예매 동시성 문제는 해결되었다.
하지만, 비관적 락은 미리 충돌을 가정하고 Lock을 걸기 때문에, 충돌이 자주 발생하지 않는 경우에도 무조건 Lock 획득을 위해 대기해야 하므로 충돌이 자주 발생하는 경우에 유리하다. 또한, 데드락이 발생할 가능성이 높아진다.
그렇다면 다른 방법은 없을까?
비관적 락은 충돌을 가정하고 미리 락을 걸기 때문에, 성능 저하 및 데드락과 같은 오버헤드가 있다.
그래서, 등장한 것이 낙관적 락이다. 낙관적 락은 데이터 조회에 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 |
---|---|---|---|---|---|---|
예매된 티켓 수 | 400 | 400 | 400 | 400 | 400 | 400 |
비관적 락과 마찬가지로, 낙관적 락 또한 중복 예매 동시성 문제가 해결된 모습이다.
중복 예매 동시성 문제는 비관적 락과 낙관적 락 모두 해결 가능하다는 것을 알게되었다. 그렇다면 어떤 락이 더 효과적일까? 앞서 진행한 두 부하 테스트 결과를 비교해보자.
시나리오는 앞서 설명한 내용과 동일, 각각 10회 수행 후 평균 응답시간 최상위와 최하위 결과 제외
API Endpoint | 낙관적 락 (ms) | 비관적 락 (ms) | 차이 (비관 – 낙관) | 성공 | 실패 |
---|---|---|---|---|---|
전체 평균 | 1516.34 | 1753.79 | +236.66 | 3871.29 | 894.45 |
공연 상세 | 1,492.73 | 1,593.01 | +100.28 | 500 | 0 |
좌석 영역 | 1,898.49 | 2,078.93 | +180.44 | 500 | 0 |
티켓 상태 | 1,422.89 | 1,643.15 | +220.26 | 1879.13 | 0 |
임시 예매 | 1,547.03 | 1,836.41 | +289.38 | 251.26 | 881.57 |
할인 목록 | 1,521.53 | 1,797.80 | +276.27 | 251.26 | 0 |
결제 시작 | 1,520.03 | 2,037.48 | +517.45 | 251.26 | 0 |
결제 승인 | 1,159.35 | 1,556.88 | +397.53 | 238.38 | 12.88 |
모든 API에서 낙관적 락이 비관적 락 보다 응답속도가 빨랐다.
또한, 티켓의 락에 영향을 받는 API들과, 티켓팅 과정의 후반에 실행되는 API들의 응답시간이 더 크게 상승했다.
분명 임시 예매 API는 충돌이 많이 일어난다(총 1133번의 호출 중 80%가 실패). 근데, 왜 비관적 락 보다 낙관적 락이 왜 더 빨랐을까? 그 이유를 분석해보자.
티켓이 업데이트 되는 경우는 1. 임시 예매
, 2. 결제 승인
이렇게 두 가지 경우다. 그렇기 때문에, 임시 예매 과정에서 충돌이 났다면 다른 사용자가 임시 예매 혹은 결제를 완료한 것이므로 재시도를 할 필요가 없다.
물론, 충돌 시 트랜잭션 Roll Back을 수행해야 한다. 하지만, 낙관적 락의 가장 큰 오버헤드는 잦은 충돌과 이로 인해 반복되는 재시도로 인한 응답 시간 지연이다.
그렇기 때문에, 재시도를 고려하지 않아도 되므로 낙관적 락의 응답속도가 더 빨랐다고 생각한다.
만약 API의 응답 시간이 50ms고, 5명의 유저가 동시에 하나의 티켓에 접근한다고 가정해보자.
비관적 락이 걸린 경우는 락을 먼저 차지한 사용자가 트랜잭션을 Commit할 때 까지 다른 사용자들은 대기해야한다. 그러므로 5건의 요청을 모두 처리하기 위해선 250ms가 소모된다. (물론 쓰기 시간 및 다른 작업을 고려하지 않은 것이므로 더 빠를 것이다.)
반면에 낙관적 락이 걸린 경우는 두 사용자 모두 대기 없이 트랜잭션을 수행하고, 먼저 Commit한 유저가 예매에 성공하게된다. 그러므로 5건의 요청을 모두 처리하기 위해선 50ms가 소모된다. (물론 Roll Back 및 DB 부하는 고려하지 않은 것이므로 더 느릴 것이다.)
단순히 이론적인 내용으로만 생각했을 때는 충돌이 잦으니 비관적 락을 적용해야 된다고 생각했다.
하지만, 직접 테스트를 통해 결과를 비교하고 원인을 분석하며 더 적절한 락을 도입할 수 있었다.
추후 분산 환경으로 옮기게 된다면 분산 락도 고려해볼 수 있을 것 같다.