동시성 테스트

gh·2022년 5월 21일
4

계기

스프링 부트는 멀티 쓰레드 환경이며, 신경써서 코딩하지 않으면 의도치 않은 결과를 마주칠 때가 있습니다.
예를 들어, 미리 충전된 돈으로 물건을 구매하는 기능이 있다고 합시다. 물건을 구매하기전 충분하 금액이 있는지 DB에서 조회를 하여 돈이 충분하다면 지불을 할것입니다. 코드로 표현하면 이렇게 될것입니다.

위에 코드는 얼핏보기에는 문제가 없어보입니다.
멀티 쓰레딩 환경에서 유저가 pay를 각각 다른 방법으로 동시에 진행했다고 가정하겠습니다.

테스트

	@Test
    void 동시성테스트() throws InterruptedException {
        Account account = new Account();
        account.plusBalance(2000);
        accountRepository.save(account);
        
        ExecutorService executorService = Executors.newFixedThreadPool(2);
        CountDownLatch latch = new CountDownLatch (2);
        
        AtomicBoolean result1 = new AtomicBoolean(true);
        AtomicBoolean result2 = new AtomicBoolean(true);

        executorService.execute(() -> {
            result1.set(paymentService.pay(Long.valueOf(1), 2000));
            latch.countDown();
        });
        executorService.execute(() -> {
            result2.set(paymentService.pay(Long.valueOf(1), 1000));
            latch.countDown();
        });
        latch.await();
        Account account2 = accountRepository.findById(Long.valueOf(1)).get();

        SoftAssertions assertions =new SoftAssertions();
        assertions.assertThat(account2.getBalance()).as("첫번째 결제 결과").isEqualTo(result1.get()?0:1000);
        assertions.assertThat(account2.getBalance()).as("두번째 결제 결과").isEqualTo(result2.get()?1000:0);
        assertions.assertThat(result1.get()).as("두결과는 항상 달라야함")
                .isNotEqualTo(result2.get());
        assertions.assertAll();
    }

초기에 유저는 account에 2000원이 들어가있고 한번은 2000원짜리를 지불하고 한번은 1000원짜를 지불합니다. 비동기로 진행 되기 때문에 결과는 2가지 케이스 입니다.
1. 2000원 결제 성공, 1000원 결제 실패, 잔금 2000-2000 = 0
2. 1000원 결제 성공, 2000원 결제 실패, 잔금 2000-1000 = 1000


테스트코드를 돌려보면 위와 같은 결과가 나옵니다.
result1 : true
result2 : true
잔금 : 1000원

결과를 살펴보면 둘다 처리는 되었는데 2000원 결제의 자산처리가 정상적으로 되지 않았습니다.
어떻게 보면 result1이 false가 되지 않은게 문제일수도 있습니다.

원인

위 상황을 시간의 흐름 대로 나타 내면 이렇습니다.

시간2000원 결제1000원 결제
account select (2000)account selelct (2000)
account.canPay (true)account.canPay (true)
account.minusBalance(2000)account.minusBalance(2000)
account 0account 1000
account.save 0
acccount 0
account.save 1000
acccount 1000

쇼핑몰이나 일반적인 경우에는 거의 발생하지 않지만 가상화폐거래소나 증권사 같은 금융권의 경우 api로 호출 하기 때문에 자주발생 할수 있습니다.

해결방법

해결방법으로는 db의 isolation level을 조정하거나 db에 lock을 걸어 동기처리 등등이 있겠습니다.

@Repository
public interface AccountRepository extends JpaRepository<Account, Long> {
   @Lock(LockModeType.PESSIMISTIC_WRITE)
   @Query("select a from Account a where a.id = :id")
   Account findByIdForUpdate(@Param("id") Long id);
}

위 방법은 비관적 락으로, select 할 때 lock을 걸어 다른 쓰레드에서 같은 모드로 select나 변경을 할려고 할 때 대기 상태가 되고 lock 풀리면 진행 될수 있게됩니다. 다른 메소드에서 락을 걸 경우 deadlock이 발생할수 있으니 유의해서 작성해야합니다. 또한 경우에 따라서는 병목현상이 일어 날수 있으니, 낙관적 락과 비교하여 선택하면됩니다.

낙관적 락은 버전관리를 통해 select 이후 업데이트를 할때 그사이에 다른 업데이트가 존재하면 Exception을 뿜어내는 방식입니다.낙관적이란 말그대로 리소스에 대한 경합상황이 잘 일어나지 않는 낙관적이 상황일 때 쓴다고 생각하면됩니다.

결론

스프링의 경우 멀티쓰레드 입니다. 멀티쓰레드 환경에서는 특정 값의 조회하고 그것을 바꾸는 로직이 있다면 항상 유의해야 할것입니다.

코드는 아래 깃헙에 올려두었습니다.
https://github.com/gh90/example-dblock

작성하다가 알게 된것

  1. 부트 @Transactionl 은 일반적으로 단일쓰레드에서만 지원
    https://dulajra.medium.com/spring-transaction-management-over-multiple-threads-dzone-java-b36a5bc342e5
  2. H2 DB의 경우 아래와 같은 설정후 비관적락 사용시 정상동작하지 않습니다.(제가 잘못 알고 있는건지 모르겠습니다.)
@Transactional(isolation = Isolation.REPEATABLE_READ)

[참고]
https://dev-monkey-dugi.tistory.com/152
https://steady-coding.tistory.com/351
https://stackoverflow.com/questions/69324749/why-do-isolation-levels-repeatable-read-and-serializable-in-springboot-transacti

profile
개발자~

0개의 댓글