1. 개요

최근 Do Eat Fit 프로젝트의 센터 예약 시스템을 고도화하면서, 회원들이 동시에 수강을 예약하거나 취소할 때 발생할 수 있는 동시성(Concurrency) 이슈데드락(Deadlock) 문제를 직면하게 되었습니다. 예약이라는 도메인 특성상 수강권(Ticket)의 잔여 횟수 차감과 스케줄(ClassSchedule)의 정원 관리가 동시에 이루어져야 하며, 데이터의 정합성이 조금이라도 어긋나면 치명적인 비즈니스 오류로 이어집니다.

이번 포스트에서는 이러한 동시성 문제를 어떻게 인지하고, **비관적 락(Pessimistic Lock)**과 락 획득 순서 정렬을 통해 데드락을 예방했는지, 그리고 **멱등성(Idempotency)**을 어떻게 보장했는지 회고해 봅니다.


2. 발생한 문제와 원인

2-1. 동시성 이슈와 갱신 손실 (Lost Update)

수십 명의 회원이 동시에 인기 있는 PT나 GX 스케줄을 예약하려고 할 때, 스케줄의 currentBookingCount를 단순히 읽고 +1 해서 저장하게 되면 갱신 손실이 발생합니다. 그 결과 정원이 10명인 클래스에 12명이 예약되는 초과 예약 버그가 발생할 위험이 있었습니다.

2-2. 데드락 (Deadlock) 발생 위험

예약(Booking)과 예약 취소(Cancel) 로직은 공통적으로 두 가지 엔티티의 상태를 변경합니다.

  1. ClassSchedule (스케줄 정원 증감)
  2. Ticket (수강권 횟수 차감/복구)

만약 A 회원은 예약을 진행하며 스케줄 수강권 순으로 락을 획득하고, B 회원은 예약을 취소하며 수강권 스케줄 순으로 락을 획득한다면 교착 상태(Deadlock)에 빠질 수 있습니다.

sequenceDiagram
    participant TxA as 트랜잭션 A (예약)
    participant TxB as 트랜잭션 B (취소)
    participant S as ClassSchedule (Row)
    participant T as Ticket (Row)

    TxA->>S: 1. SELECT FOR UPDATE (락 획득 성공)
    TxB->>T: 2. SELECT FOR UPDATE (락 획득 성공)
    TxA->>T: 3. SELECT FOR UPDATE (TxB가 점유 중이므로 대기)
    TxB->>S: 4. SELECT FOR UPDATE (TxA가 점유 중이므로 대기)
    Note over TxA,TxB: 💥 데드락(Deadlock) 발생!

3. 해결 방안: 비관적 락과 락 획득 순서 보장

3-1. 비관적 락 (Pessimistic Lock) 적용

초과 예약 방지와 수강권 횟수의 정확한 차감을 위해, 충돌이 빈번하게 일어날 것이라 가정(비관적)하고 DB 단에서 SELECT ... FOR UPDATE를 통해 **배타 락(Exclusive Lock)**을 걸었습니다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT s FROM ClassScheduleEntity s WHERE s.uuid = :uuid")
Optional<ClassScheduleEntity> findByUuidWithPessimisticLock(@Param("uuid") String uuid);

3-2. 락 획득 순서 강제 (Lock Ordering)

데드락의 4가지 발생 조건 중 환형 대기(Circular Wait) 조건을 끊어내기 위해 모든 트랜잭션에서 락을 획득하는 순서를 항상 ClassSchedule Ticket 순으로 통일했습니다.

sequenceDiagram
    participant TxA as 트랜잭션 A (예약)
    participant TxB as 트랜잭션 B (취소)
    participant S as ClassSchedule
    participant T as Ticket

    TxA->>S: 1. SELECT FOR UPDATE (락 획득)
    TxB->>S: 2. SELECT FOR UPDATE (TxA 대기)
    TxA->>T: 3. SELECT FOR UPDATE (락 획득)
    Note over TxA: 비즈니스 로직 수행 및 Commit (락 해제)
    TxB->>S: 4. 대기 해제 및 락 획득
    TxB->>T: 5. SELECT FOR UPDATE (락 획득)
    Note over TxB: 비즈니스 로직 수행 및 Commit

4. 멱등성 (Idempotency) 보장

네트워크 지연이나 클라이언트의 중복 요청(예: 취소 버튼 연타)으로 인해 동일한 요청이 여러 번 서버로 인입될 수 있습니다. 예약 취소 로직이 두 번 실행되어 수강권 횟수가 두 번 복구되는 끔찍한 사태를 막기 위해 멱등성(Idempotency) 처리를 추가했습니다.

로직 내부에서 예약의 현재 상태를 검증(if (booking.getStatus() == CANCELLED) throw...)하여, 이미 취소된 예약에 대한 중복 취소 요청은 무시하거나 적절한 에러를 반환하게 설계했습니다. 이를 통해 동일한 API를 여러 번 호출하더라도 시스템 상태는 동일하게 유지됩니다.


5. 마무리

이번 작업을 통해 단순히 기능 구현을 넘어서 트랜잭션의 ACID 특성동시성 제어의 중요성을 뼈저리게 느낄 수 있었습니다. 앞으로 남은 No-Show 페널티 로직이나 자동화 스케줄러 개선 작업에서도 이번에 확립한 동시성 처리 가이드라인을 철저히 준수하여 튼튼한 시스템을 만들어가야겠습니다.