프로젝트/Ticketing

[Ticketing] Lock을 활용한 동시성 제어

이덩우 2024. 6. 4. 02:32

현재 프로젝트에는 동시성을 제어해야할 비지니스 상황이 있습니다.

  1. 선착순 쿠폰 발급
  2. 공연(좌석) 예약 

각 상황을 어떻게 대처했는지 단계별로 알아보겠습니다.

 

1. 선착순 쿠폰 발급

가정한 상황은 정해진 수량의 쿠폰에 대해서 순간적으로 동시 발급 요청이 몰리는 상황입니다.

이 때 별도의 동시성 제어가 없다면 다음과 같은 순서에 의해, 최초 수량보다 더 많은 사용자가 쿠폰을 발급받게 됩니다.

  1. 최초 수량 100개 가정
  2. 유저 A가 쿠폰 발급 요청, 수량 read (100개)
  3. 유저 B가 곧이어 쿠폰 발급 요청, 수량 read (100개)
  4. 유저 A가 쿠폰을 저장을 요청, read 시점에서 수량을 하나 빼고 저장 (99개)
  5. 유저 B가 쿠폰을 저장을 요청, read 시점에서 수량을 하나 빼고 저장 (99개)
  6. 유저 A와 B 모두 쿠폰을 발급했지만, 실제 수량 감소는 하나만 발생

정확히 수량 계산이 끝난 뒤, 다른 트랜잭션이 참여해야하므로 DB 레벨의 *비관적 락*을 사용하기로 결정했습니다.

 

- 비지니스 로직

@Service
@RequiredArgsConstructor
@Transactional
public class MemberCouponService {

    private final MemberRepository memberRepository;
    private final CouponRepository couponRepository;
    private final MemberCouponRepository memberCouponRepository;

    public void saveCoupon(Long memberId, Long couponId) {
        /**
         * 쿠폰 수량 예외 더블 체크
         * 1. 쿠폰 수량 확인 후, 0 이하라면 커스텀 예외 던짐
         * 2. 발생하지 않을 상황이겠지만, 중요한 비지니스 로직이므로 DB 체크 제약 조건을 추가
         *     -> JpaSystemException root cause SQLException 가능
         *     -> 체크 제약조건을 유일하므로 ControllerAdvice 에서 직접 처리
         *     -> SQL Error: 3819, SQLState: HY000
         */
        Coupon requestedCoupon = couponRepository.findById(couponId).orElseThrow();
        if (requestedCoupon.getQuantity() <= 0) {
            throw new InsufficientCouponException(SQLErrorCode.INSUFFICIENT_COUPON);
        }
        couponRepository.update(couponId);

        // NoSuchElementException 가능
        Member member = memberRepository.findById(memberId)
                .orElseThrow();
        Coupon coupon = couponRepository.findById(couponId)
                .orElseThrow();

        // 제약 조건 예외 가능 -> 중복 쿠폰 저장 상황
        try {
            memberCouponRepository.save(MemberCoupon.builder()
                    .member(member)
                    .coupon(coupon)
                    .build());
        } catch (DataIntegrityViolationException e) {
            throw new DuplicatedCouponException(e, SQLErrorCode.DUPLICATED_COUPON);
        }
    }
}
@Repository
@RequiredArgsConstructor
public class JpaCouponRepository implements CouponRepository{

    private final EntityManager em;
    private final JPAQueryFactory queryFactory;

    // 락은 하나의 트랜잭션이 종료될 때 까지 보유
    @Override
    public Optional<Coupon> findById(Long couponId) {
        return Optional.ofNullable(em.find(Coupon.class, couponId, LockModeType.PESSIMISTIC_WRITE));
    }

    // 수량 체크에서 락 획득, 여기엔 없어도 된다.
    @Override
    public void update(Long couponId) {
        Coupon coupon = em.find(Coupon.class, couponId);
        coupon.setQuantity(coupon.getQuantity() - 1);
    }
}

먼저 남은 수량 확인을 위해 `findById()`를 통해 쿠폰 엔티티를 가져옵니다.

이 때 `LockModeType.PESSIMISTIC_WRITE`옵션으로 락을 획득합니다. WRITE 모드를 사용해야 다른 트랜잭션의 읽기까지 막을 수 있습니다. 현재 획득한 락은 트랜잭션이 종료될 때 까지 보유하게 됩니다.

 

다음으로 쿠폰의 수량을 감소시키고 `MemberCoupon` 테이블에 회원과 쿠폰 정보를 묶어 저장하게 됩니다. 중간에 예외가 발생하더라도 트랜잭션 전체가 롤백되기 때문에 먼저 수량을 감소시킨 것은 괜찮습니다.

 

- 동시성 테스트

락이 제대로 구현되었는지 테스트 해보겠습니다.

@Test
@DisplayName("동시 쿠폰 발급 상황")
void ThreadSafeTest() throws InterruptedException {
    //given
    Coupon beforeCoupon = couponRepository.findById(9L).orElseThrow();

    ExecutorService executorService = Executors.newFixedThreadPool(30);
    CountDownLatch countDownLatch = new CountDownLatch(30);

    //when
    for (int i = 0; i < 30; i++) {
        executorService.execute(() -> {
            memberCouponService.saveCoupon(3L, 9L);
            countDownLatch.countDown();
        });
    }
    countDownLatch.await();
    executorService.shutdown();

    Coupon afterCoupon = couponRepository.findById(9L).orElseThrow();
    
    //then
    Assertions.assertThat(afterCoupon.getQuantity()).isEqualTo(beforeCoupon.getQuantity() - 30);
}

성공

 

 

2. 공연(좌석) 예약

비교적 느슨하게 한 공연의 같은 좌석에 대해 경합이 발생하는 상황을 가정했습니다.

선착순 쿠폰 이벤트와 같은 맥락으로, 현재 좌석 예약 가능 여부를 동시에 read 할 수 있어 별도의 동시성 제어가 없다면 한 좌석을 두 명이 예약하는 상황이 발생할 수 있습니다.

락이 없으면 읽기 시도 조차 못하게 하는 것은 애플리케이션의 효율을 크게 떨어트린다고 판단, 애플리케이션 레벨의 *낙관적 락*을 사용하기로 결정했습니다.

 

- 비지니스 로직

공연에 대한 좌석 정보를 담고있는 `SeatReservation` 엔티티를 살펴보겠습니다.

@Entity
@Getter
@Setter
public class SeatReservation extends BaseEntity {

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

    @ManyToOne
    @JoinColumn(name = "performance_detail_id")
    private PerformanceDetail performanceDetail;

    @ManyToOne
    @JoinColumn(name = "seat_id")
    private Seat seat;

    private boolean available;

    @Version
    private Long version;

    public SeatReservation() {
    }

    @Builder
    public SeatReservation(PerformanceDetail performanceDetail, Seat seat, boolean available) {
        this.performanceDetail = performanceDetail;
        this.seat = seat;
        this.available = available;
    }
}

JPA에서 지원하는 @Version 애노테이션을 통해, 읽기 시점의 버전과 커밋을 시도할 때의 버전이 다르다면 낙관적 락 예외를 발생킬 수 있습니다.

 

다음은 서비스 단의 코드입니다.

@Service
@RequiredArgsConstructor
@Transactional
public class MemSeatReservationService {

    private final MemSeatReservationRepository memSeatReservationRepository;
    private final SeatReservationRepository seatReservationRepository;
    private final MemberCouponRepository memberCouponRepository;

    public void reserveTicket(MemSeatReservationDto reservationDto) {
        double realTotalPrice = calculateTotalPrice(reservationDto);
        if (!priceIsSame(realTotalPrice, reservationDto.getTotalPrice())) {
            throw new InvalidPriceException(PriceErrorCode.INVALID_PRICE);
        }
        seatReservationRepository.updateAvailable(reservationDto.getSeatReservationId());
        memSeatReservationRepository.save(reservationDto);
    }
    // 생략
}

좌석 예약 시 클라이언트 측에서 보내온 결제 금액에 대한 검증과정이 먼저 이뤄집니다.

이 후 실제 예약하려는 공연에 대한 특정 좌석의 이용 가능 여부를 false로 업데이트하고 실제 티켓 예약을 진행합니다.

 

- 동시성 테스트

락이 제대로 구현되어 낙관적 락 예외를 발생시키는지 확인해보겠습니다.

@SpringBootTest
class MemSeatReservationServiceTest {
    @Autowired
    private MemSeatReservationService reserveService;

    @Test
    @DisplayName("동시 좌석 예약 상황")
    void ThreadSafeTest() throws InterruptedException {
        //given
        ExecutorService executorService = Executors.newFixedThreadPool(2);
        CountDownLatch countDownLatch = new CountDownLatch(2);
        MemSeatReservationDto reservationDto = MemSeatReservationDto.builder()
                .seatReservationId(105L)
                .memberId(3L)
                .totalPrice(11000)
                .build();
        AtomicBoolean isDuplicated = new AtomicBoolean(false);
        //when
        for (int i = 0; i < 2; i++) {
            executorService.execute(() -> {
                try {
                    reserveService.reserveTicket(reservationDto);
                } catch (ObjectOptimisticLockingFailureException e) {
                    isDuplicated.set(true);
                } finally {
                    countDownLatch.countDown();
                }
            });
        }
        countDownLatch.await();
        executorService.shutdown();
        //then
        Assertions.assertThat(isDuplicated).isTrue();
    }
}

실패

테스트에 실패했습니다. 로그를 확인하면 *데드락*이 발생함을 알 수 있습니다.

 

데드락..?

저는 애플리케이션 레벨의 낙관적 락을 사용했지, 데이터베이스에 락을 직접 걸지 않았습니다.

왜 데드락이 발생했을까요? MySQL InnoDB의 엔진 상태를 확인해봤습니다.

Transaction(1)

 

Transaction(2)

 

트랜잭션 1번은 SeatReservation 테이블에 대해 S락을 획득했고, MemberSeatReservation 테이블에 대해 S락을 기다리고 있습니다.

트랜잭션 2번은 MemberSeatReservation 테이블에 대해 X락을 획득했고, SeatReservation 테이블에 대해 X락을 기다리고 있습니다.

 

저는 락을 걸지 않았는데.. 왜 이런 상황이 발생했을까요? 

MySQL InnoDB에서는 연관관계를 가진 테이블에 대해서는 자동으로 락을 획득하는 매커니즘을 가지고있습니다.

따라서 아래와 같은 상황이 발생합니다.

  • 좌석 이용 가능 여부를 조회하고 변경을 시도한다. (SeatReservation 테이블에 대한 락 획득)
  • 티켓을 발행한다. (MemberSeatReservation 테이블에 대한 락 획득)

이 상황에서 서로 다른 두 트랜잭션이 테이블에 대한 락을 각각 획득하고, 무한정 서로의 락을 기다려 데드락 상황이 발생한 것입니다.

 

하지만 저는 좌석 이용 가능 여부를 변경하는 비지니스 로직을 먼저 세웠고, 해당 부분에서 버전이 다르다면 낙관적 락 예외를 발생시키는 상황이 발생해야한다고 생각하는데 왜 그렇지 않을까요?

바로 *JPA의 쓰기 지연 특성 때문에 그렇습니다.* 별도의 설정을 안했다면 트랜잭션이 커밋하는 시점에 좌석 이용 가능 여부가 Dirty Checking으로 인해 업데이트 되기 때문에, 티켓 발행하는 부분까지 두 트랜잭션이 모두 경합하게 된 것입니다.

 

저는 이 문제를 해결하기 위해 좌석 이용 가능 여부를 업데이트하는 Repository 메서드에서 `flush()`를 강제로 호출했습니다.

@Override
public void updateAvailable(Long reserveId) {
    SeatReservation seatReservation = em.find(SeatReservation.class, reserveId);
    seatReservation.setAvailable(false);
    em.flush();
}

성공

 

 

3. 남아있는 문제

적절한 락을 사용해 현재 상황에서는 문제를 해결할 수 있었습니다.

다만 생각해볼 문제는 남아있습니다.

 

처음 프로젝트를 기획하면서 당연하게도(?) DB 인스턴스를 하나만 사용하는 상황을 생각하며 문제를 해결했습니다.

트래픽이 커져 하나의 인스턴스로 감당하지 못해 클러스터링 및 Replication을 사용해야하는 상황을 고려하지 못했습니다. 

DB 인스턴스가 여러 개 있다면 현재의 방식만으로는 문제를 해결할 수 없을 것입니다.

 

또한 매번 RDB에 접근해 동작한다는 것은 효율적이지 못할 것입니다.

 

현재 생각하는 방향으로는 *Redis에 분산 락을 구현하는 방식*으로 러프하게 생각중이지만, 또 다른 문제는 없을지 신중하게 생각하고 발전시켜보겠습니다.