본문 바로가기
JPA

OptimisticLocking 을 이용한 최대인원 예약 동시성 처리하기

by doyoungKim 2022. 4. 23.

OptimisticLocking

안녕하세요. 오늘은 아래의 요구사항을 OptimisticLocking 을 활용하여 해결해보겠습니다.

요구사항

진료에 대해서 하루에 최대 인원 까지 진료 예약을 할 수 있다.

 

주의사항: 남은 예약 인원이 1자리 일 경우 여러명이 동시에 예약을 신청할 경우에는 가장 빠른 한명만 예약 되어야 합니다.
잘못해서 최대 인원보다 많이 예약될 경우가 없어야겠죠 !

가장 간단하게 해결하는 방법은 예약하는 기능을 담당하는 스레드를 직렬화 하는 synchronized 키워드를 활용하는 방법도 있습니다. 하지만 deadlock 이 발생할 수 있어 좋은 해결방안은 아닙니다.

deadlock  ReentrantLock 을 활용하여 해결할 수 있지만 또 다른 문제점이 있습니다.

만약 다중 서버에서 분산해서 예약을 담당한다면 각 서버의 스레드를 직렬화 한다고 동시성 문제를 해결할 수 없습니다.  이에 대한 해결방안으로 OptimisticLocking 을 활용해서 동시성을 해결할 수 있습니다.

 

OptimisticLocking 

일명 낙관적 락 이라고도 불립니다.  낙관적인 만큼 동시성 문제가 발생하지 않는다는 가정하는 컨셉입니다. DB 의 Lock 과는 관계 없이 JPA 에서 제공하는 버전 관리 기능 (@Version) 을 사용합니다.

 

@Version

JPA Entity 클래스의 필드에 @Version 을 활용한 프로퍼티를 선언하는 것으로 버전 관리가 시작됩니다.

진료를 다루는 Treatment Entity 를 예시로 들겠습니다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode(of = "id")
public class Treatment {

    private static final String EMPTY_NAME_ERROR_MESSAGE = "진료명 입력해주세요.";
    public static final int DEFAULT_CAPACITY = 10;

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

    @Column(unique = true)
    private String name;

    private int capacity;

    @Version
    private Integer version; // 버전 관리

    public Treatment(String name) {
        validateName(name);
        this.capacity = DEFAULT_CAPACITY;
        this.name = name;
    }

    private void validateName(String name) {
        if (!StringUtils.hasText(name)) {
            throw new IllegalArgumentException(EMPTY_NAME_ERROR_MESSAGE);
        }
    }
}

 

Version 에 매핑되는 변수의 타입은 숫자나 시간 일 수 있습니다. (Long, Integer, Instant ...)

 

Treatment 를 저장하는 테스트
Entity 가 최초의 Persist 되는 시점에 Version 은 자동으로 현재시간이나 0 으로 저장됩니다.

@DataJpaTest
class TreatmentTest {
    @Autowired
    TreatmentRepository treatmentRepository;

    @Test
    void save() {
        final Treatment treatment = new Treatment("오전진료");
        final Treatment savedTreatment = treatmentRepository.save(treatment);
        assertThat(savedTreatment.getId()).isNotNull();
    }
}

/* 실행 후 로그 일부분
Hibernate: 
    insert 
    into
        treatment
        (id, capacity, name, version) 
    values
        (null, ?, ?, ?)
: binding parameter [1] as [INTEGER] - [10]
: binding parameter [2] as [VARCHAR] - [오전진료]
: binding parameter [3] as [INTEGER] - [0]
*/

 

최대 예약 인원이 10명인 오전진료가 DB에 잘 저장되었습니다. Version 또한 초기값으로 잘 저장되었습니다.

 

 

OPTIMISTIC_FORCE_INCREMENT

version 은 트랜잭션이 끝날때 Entity가 변경되었을 경우증가되지만 강제로 증가하는 시키는 방법도 있습니다.

OPTIMISTIC_FORCE_INCREMENT 모드를 사용하면 Entity 가 변경이 되지 Version 을 증가 시킬수 있습니다.
트랜잭션이 필요함으로 Service 를 만들어서 테스트 해보겠습니다.

public interface TreatmentRepository extends JpaRepository<Treatment, Long> {

    @Query("SELECT t FROM Treatment t WHERE t.id = :id")
    @Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
    Treatment getByIdWithOptimisticForceIncrement(@Param("id") Long id);
    
}

@Transactional
@Service
@RequiredArgsConstructor
public class TreatmentService {
    private final TreatmentRepository treatmentRepository;

    public Treatment getByIdWithOptimisticForceIncrement(Long id) {
        return treatmentRepository.getByIdWithOptimisticForceIncrement(id);
    }
}


@SpringBootTest
class TreatmentServiceTest {

    @Autowired
    TreatmentService treatmentService;
    @Autowired
    TreatmentRepository treatmentRepository;

    @BeforeEach
    void setUp() {
        treatmentRepository.deleteAllInBatch();
    }

    @Test
    void getByIdWithOptimisticForceIncrement() {

        final Treatment treatment = treatmentRepository.save(new Treatment("오전진료"));
        final Treatment findTreatment = treatmentService.getByIdWithOptimisticForceIncrement(treatment.getId());

        assertThat(treatment.getVersion().intValue()).isZero();
        assertThat(findTreatment.getVersion().intValue()).isEqualTo(1);
    }
}

/* 로그중 일부

...

Hibernate: 
    update
        treatment 
    set
        version=? 
    where
        id=? 
        and version=?
*/

 

 

트랜잭션이 끝날 때 version 을 update 하는 query 를 로그에서 확인할 수 있습니다.  이제 이를 활용해서 동시성 문제를 해결해보겠습니다.

 

진료 예약 로직 순서

  1. 진료 아이디를 통해 해당 진료의 최대 예약 인원 수를 구한다.
  2. 해당 진료에 예약한 인원수를 구한다.
  3. 조회한 인원수가 최대 예약인원 보다 같거나 많지 않다면 예약 정보를 등록한다.

해당 진료의 최대 예약 인원 수를 조회를 하는 과정에서 OptimisticForceIncrement LockType 으로 조회하는것이 중요합니다.

거의 동시에 여러명이 오전진료 예약 로직을 수행했을 경우, 그중에서도 가장 빠른 사람이 마지막의 Version 의 정보를 증가 시킬 것입니다.

## 예시
update treatment set version=1 where id=1 and version=0


treatment 의 버전이 0에서 1이 되었으니 2번째 이후 부터는 version 0 으로는 treatment 가 되지 않습니다. 이렇게 조회가 되지 않을 경우 JPA 에서는 ObjectOptimisticLockingFailureException 이 발생하여 해당 트랜잭션이 Rollback 이 됩니다.

 

@Slf4j
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ReservationLifeCycleServiceConcurrencyTest {

    @Autowired
    ReservationLifeCycleService service;

    @Autowired
    ReservationRepository reservationRepository;

    @Autowired
    TreatmentRepository treatmentRepository;

    Long treatmentId;

    @BeforeEach
    void setUp() {
        treatmentRepository.deleteAllInBatch();
        this.treatmentId = treatmentRepository.save(new Treatment("감기진료")).getId();
    }

    @AfterEach
    public void afterEach() {
        reservationRepository.deleteAllInBatch();
    }

    @DisplayName("여러명이 동시에 예약을 할 때 최초 한명만 예약된다.")
    @Test
    void reserveConcurrency() throws InterruptedException {
        // given
        final int N_THREAD_COUNT = 5;
        final ExecutorService executorService = Executors.newFixedThreadPool(N_THREAD_COUNT);

        // when
        CountDownLatch latch = new CountDownLatch(N_THREAD_COUNT);
        for (int index = 0; index < N_THREAD_COUNT; index++) {
            final int finalIndex = index;
            executorService.execute(() -> {
                log.info("[BEFORE] reserve");
                try {
                    service.saveReservation(new ReservationDtos.Request(treatmentId, "신규예약자" + finalIndex));
                } catch (Exception e) {
                    log.info("[ERROR] {} {}", e.getClass(), e.getMessage());
                }
                log.info("[AFTER] reserve");
                latch.countDown();
            });

        }
        latch.await(10, TimeUnit.SECONDS);

        // then
        assertEquals(getReservationCountByToday(), 1);
    }

    private int getReservationCountByToday() {
        final LocalDateTime startDateTime = LocalDate.now().atTime(0, 0);
        final LocalDateTime endDateTime = startDateTime.plusDays(1L);
        return reservationRepository.countByTreatmentIdAndToday(treatmentId, startDateTime, endDateTime);
    }
}

 

 

동시성 문제를 거의 해결하였습니다. 지금은 남은자리가 5자리여도 동시에 5명이 접근하면 딱 1명만 예약되는 것이 문제입니다.
위에서 발생한 ObjectOptimisticLockingFailureException 를 핸들링 하여 해결할 수 있습니다.

 

ObjectOptimisticLockingFailureException 핸들링

@Slf4j
@Service
@RequiredArgsConstructor
public class ReservationSyncService {
    private final ReservationLifeCycleService reservationLifeCycleService;

    public ReservationDtos.Response reserveWithSync(ReservationDtos.Request request) {
        int retryCount = Treatment.DEFAULT_CAPACITY - 1;
        for (int index = 0; index < retryCount; index++) {
            try {
                return reservationLifeCycleService.saveReservation(request);
            } catch (OptimisticLockingFailureException exception) {
                log.info("[reserveWithSync] {} : ConcurrencyFailureException 오류 {} 번 발생", Thread.currentThread().getName(), (index + 1));
            }
        }
        throw new IllegalThreadStateException("동시성을 처리할 수 없는 상태입니다.");
    }
}

 

 

동시성 예외가 발생할 경우, 1위 경쟁에서 실패한 스레드들이 다시 예약을 시도하도록 retryCount 를 활용하여 핸들링 했습니다. 
이럴 경우, 5자리여도 동시에 5명이 접근하면 딱 1명만 예약되는 것이 문제를 해결할 수 있습니다.

 

동시성 인수테스트로 마무리 하겠습니다.

@Slf4j
class ReservationControllerTest extends AcceptanceTest {

    public static final String RESERVE_URL = "/reserve";
    public static final String RESERVE_COUNT_URL = RESERVE_URL + "/count";

    Long 오전진료_아이디;

    @Override
    @BeforeEach
    public void setUp() {
        super.setUp();
        오전진료_아이디 = 진료가_등록_되어_있음("오전진료");
    }

    @Test
    @DisplayName("여러명이 동시에 예약한다.")
    void multiReserve() throws InterruptedException {
        final int N_THREAD_COUNT = 20;
        final ExecutorService executorService = Executors.newFixedThreadPool(N_THREAD_COUNT);

        // when
        final CyclicBarrier cyclicBarrier = new CyclicBarrier(N_THREAD_COUNT);
        CountDownLatch latch = new CountDownLatch(N_THREAD_COUNT);
        for (int index = 0; index < N_THREAD_COUNT; index++) {
            final int finalIndex = index;

            executorService.submit(() -> {
                cyclicBarrier.await();
                예약하기(예약요청정보가져오기(오전진료_아이디, finalIndex + "번 신규예약자"));
                latch.countDown();
                return null;
            });
        }

        latch.await(10, TimeUnit.SECONDS);

        final Integer count = 예약자_수_확인하기(오전진료_아이디).as(Integer.class);
        assertEquals(10, count);
    }

    @Test
    @DisplayName("예약한다.")
    void reserve() {
        // given
        Map<String, String> params = 예약요청정보가져오기(오전진료_아이디, "신규예약자");
        // when
        예약하기(params);
        // then
        final Integer count = 예약자_수_확인하기(오전진료_아이디).as(Integer.class);
        assertEquals(1, count);
    }

    private Map<String, String> 예약요청정보가져오기(Long treatmentId, String name) {
        Map<String, String> params = new HashMap<>();
        params.put("name", name);
        params.put("treatmentId", String.valueOf(treatmentId));
        return params;
    }

    private ExtractableResponse<Response> 예약하기(Map<String, String> params) {
        final ExtractableResponse<Response> response = RestAssured
                .given().log().all()
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .body(params)
                .when()
                .post(RESERVE_URL)
                .then().log().all()
                .extract();
        return response;
    }

    private ExtractableResponse<Response> 예약자_수_확인하기(Long treatmentId) {
        final ExtractableResponse<Response> response = RestAssured
                .given().log().all()
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .params("treatmentId", treatmentId)
                .when()
                .get(RESERVE_COUNT_URL)
                .then().log().all()
                .extract();
        return response;
    }

}

 

 

더 자세한 정보는 아래의 저장소와 참고 및 출처를 확인부탁드립니다. 감사합니다.

 

저장소

개발 환경: Spring Boot, JPA, H2
URL:  https://github.com/doyoung0205/jpa-concurrency

참고 및 출처

728x90

댓글