[Cloudwave] CGV 대용량 트래픽 예매 서비스 구현 - 2
서론
이전 글에서 개발자의 락 명시의 중요성에 대해 알게 되었다.
그래서 이번엔 락을 개발자가 직접 명시해 데드락을 방지하고 동시성을 제어해보자.
대표적인 방식은 비관적락과 낙관적 락이 있다.
비관적 락 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를 가지는 테이블에 적용하지 못한다는 사실을 알게되면서,
왜 관계를 맺지 않는 지를 조금이나마 체감한 것 같다.