본문 바로가기
Dev/[넘블] 2023 백엔드 챌린지

[Numble] Spring으로 타임딜 서버 구축 - 트러블 슈팅과 회고 3 (동시성 처리)

by javapp 자바앱 2023. 4. 17.
728x90

 

 

9. 동시성 구현


개요

스프링에서는 하나의 요청에 스레드 풀에서 스레드를 하나 꺼내 한 요청을 수행합니다.

만약 여러 요청이 올 경우 여러 스레드들이 동시에 요청을 수행을 할 것입니다.

 

 

이러한 멀티 스레드 환경에서 자원들이 공유되는 상황에서 프로그래밍을 할 때, 타이밍에 의한 Race Condition(경쟁상태 : 여러 개의 스레드 혹은 프로세스가 공유 데이터를 동시에 변경하려고 할 때 생기는 문제) 가 발생해 문제를 야기할 수도 있습니다.

 

동시성 문제는 변경되는 데이터에 의해 발생한다고 생각한다.(변경되기 전의 데이터에 대한 접근과 변경된 후에 데이터에 대한 접근에 대한 데이터 정합성 문제)
그래서 변경되는 부분을 focus 해서 처리하자.

 

동시성문제 처리에 있어서 동시성, 가시성, 원자성에 대한 용어 정리를 하고 넘어가면 좋을 것입니다.

  • 동시성: 한 코어 안에서 스레드가 여러 작업을 엄청 빠르게 번갈아 실행하는 성질, 이 속도가 빨라서 사람은 동시에 실행되는 것처럼 보임
  • 가시성: 스레드가 항상 최신 값을 받아볼 수 있게 하는 성질
  • 원자성: 어떤 작업이 프로그램(소스코드)안에서 가장 작은 단위라서 더 이상 다른 작업으로 나눌 수 없는 성질
    • int count = 100; (원자성 O), count++ (원자성 X)ㅇ

 

 

멀티 스레드 환경에서의 구매 테스트

  • 100개의 재고가 있을 때 50개의 요청(스레드)에 2개씩 구매하는 상황을 가정하여 테스트 진행
@Test
@Order(6)
@DisplayName("여러 유저가 구매")
void manyPplPurchase() throws InterruptedException{
   /**
    * 여러 유저 생성
    * 쓰레드 생성해서
    * 구매 러시
    * */
   // given
   int threadCount = 50;
   String userId = userService.findUserIdByNickname("client");
   ReqPurchase reqPurchase = new ReqPurchase();
   reqPurchase.setUser_id(userId);
   reqPurchase.setTimedeal_id(1L);
   reqPurchase.setCount(2);


   // 멀티스레드 이용 ExecutorService : 비동기를 단순하게 처리할 수 있또록 해주는 java api
   ExecutorService executorService = Executors.newFixedThreadPool(32);

   // 다른 스레드에서 수행이 완료될 때 까지 대기할 수 있도록
   CountDownLatch latch = new CountDownLatch(threadCount);

   // when
   for(int i = 0; i< threadCount; i++){
      executorService.submit(()->{
         try{
            // 로직
            purchaseService.purchaseTimedeal(reqPurchase);
         }finally {
            latch.countDown();
         }
      });
   }

   latch.await();

   // then
   Timedeal timedeal = timedealService.findById(1L);
   assertEquals(0,timedeal.getLimitedAmount());
}

50 개의 요청으로 2개씩 구매해 재고가 0개 있을 것으로 기대

 

재고량이 88

2개씩 50번 구매가 이루어진 것으로 보아 동시성 문제가 발생

 

동시성 처리

이러한 Race condition을 해결하는 여러 방법이 있습니다.

synchronized, MySql Lock, 메시지 브로커(Redis pub/sub, kafka)

 

synchronized는 서버가 여러 대 일때 동시성 처리가 불가하기 때문에 제외시킵니다.

지금 상황에서 가장 적합한 해결책을 제시하고 실행하는 것이 좋다고 생각하기 때문에

Redis pub/sub을 사용하여 여러 서버가 있다고 가정하여 처리를 하는 것도 좋다고 생각하지만 외부 시스템을 사용하진 않을 생각입니다.

 

그래서 현재 프로젝트에 적합하게 DB로 사용하는 MySQL에 Lock을 걸어 해결해 볼 것입니다.

 

Pessimistic Lock && Optimistic Lock

여러 서버에서 동시에 같은 레코드를 업데이트하려고 할 때 발생하는 동시성 문제를 해결하는 방법입니다.

 

Pessimistic Lock

Spring Data JPA에서 비관적 잠금을 사용하려면 JPA가 제공하는 비관적 잠금 모드 중 하나를 선택해야 합니다. JPA는 PESSIMISTIC_READ, PESSIMISTIC_WRITE 및 PESSIMISTIC_FORCE_INCREMENT 세 가지 비관적 잠금 모드를 정의합니다.

PESSIMISTIC_READ는 공유 잠금을 획득하여 데이터가 업데이트되거나 삭제되지 않도록 합니다. 반면 PESSIMISTIC_WRITE는 배타적 잠금을 획득하여 데이터가 읽히거나 업데이트되거나 삭제되지 않도록 합니다.

 

@Lock(value = LockModeType.PESSIMISTIC_WRITE)
@Query("select t from Timedeal t where t.timedealId = :timedealId")
Timedeal findByIdWithPessimisticLock(@Param("timedealId") Long timedealId);

 

결과

데이터 정합성에 맞춰 성공적으로 구매 완료

 

Pessimistic Lock 은 요청순으로 락을 걸어 순차적으로 처리를 하는 결과를 보인다.

 

 

Optimistic Lock

Spring Data JPA에서 낙관적 잠금을 사용하려면 엔티티에 @Version 주석이 있는 필드가 있어야 합니다. 이 필드는 엔티티의 버전을 추적하고 낙관적 잠금을 자동으로 활성화합니다

 

@Version
private Integer version;

JPA provided two locking modes in case of Optimistic locking: OPTIMISTIC and OPTIMISTIC_FORCE_INCREMENT. OPTIMISTIC obtains the read lock for the entities with @Version property. OPTIMISTIC_FORCE_INCREMENT obtains the read lock for the entities with @Version property and increments the value of the property.

 

해당 레코드의 버전 정보를 사용하여 업데이트가 제대로 수행됐는지 확인합니다. 만약 레코드가 이미 업데이트 되어 있을 경우 Optimistic Lock은 해당 업데이트를 롤백하고 새로운 버전 정보를 생성하여 다시 시도합니다.

 

OPTIMISTIC 일 때

@Lock(value = LockModeType.OPTIMISTIC)
@Query("select t from Timedeal t where t.timedealId = :timedealId")
Timedeal findByIdWithOptimisticLock(@Param("timedealId")Long timedealId);

 

100개의 재고를 2개씩 구매하는 데

418번째로 요청한 스레드가 마지막으로 성공되었고

select count(*) from timedeal_server.purchase ;

50 * 2 = 100개로 정확히 100개 재고를 소진하였다.

version 또한 성공한 요청수가 찍혀있다.

 

 

 

OPTIMISTIC_FORCE_INCREMENT

@Lock(value = LockModeType.OPTIMISTIC_FORCE_INCREMENT)
@Query("select t from Timedeal t where t.timedealId = :timedealId")
Timedeal findByIdWithOptimisticLock(@Param("timedealId")Long timedealId);

 

 

 

처음 활성된 스레드 10(default)개는 락에 의해 요청처리가 되지 않은 것으로 보인다.

트랜잭션 중에 엔터티 자체가 수정되지 않더라도 엔터티의 version 속성이 증가합니다.

Pessimistic 와는 달리 스레드 요청을 계속 받으면서 처리를 하는 것으로 보인다.

 

 

댓글