결제 동시성 테스트

·2024년 9월 24일

🚩프로젝트

목록 보기
3/5

의도

결제 서비스를 만드는 도중 과연 동시에 여러 결제가 발생할 경우 잔액이 정확하게 업데이트가 될지 고민이 되었습니다. 만약 동시에 결제가 이루어져 잔액이 부족한데 결제가 될 경우 서비스에 큰 오차라고 생각이 들었습니다. 그로인해 데이터의 정합성과 동시성을 테스트가 필요하다고 느껴 비관적 락과 낙관적 락을 도입해보았습니다

1. Challet Bank의 Base code

동시성을 확인하기 위한 ch-bank의 시스템의 베이스 코드 입니다

1-1 Entity

@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "ch_bank")
public class ChalletBank {

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

    @Column(name = "account_number", nullable = false, unique = true)
    private String accountNumber;

    @Column(name = "account_balance", nullable = false)
    private Long accountBalance;

    @Column(name = "create_date_time", nullable = false, columnDefinition = "DATETIME")
    @CreationTimestamp
    private LocalDateTime createDateTime;

    @Column(name = "phone_number", nullable = false)
    private String phoneNumber;

    // 거래 발생시 처리
    @Builder.Default
    @OneToMany(mappedBy = "challetBank", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<ChalletBankTransaction> challetBankTransactions = new ArrayList<>();
    
    public void addTransaction(ChalletBankTransaction challetBankTransaction) {
        this.accountBalance -= challetBankTransaction.getTransactionAmount();
        this.challetBankTransactions.add(challetBankTransaction);
        challetBankTransaction.assignTransactionChAccount(this);
    }

    public static ChalletBank createAccount(String phoneNumber, String accountNumber) {
        return ChalletBank.builder()
            .phoneNumber(phoneNumber)
            .accountNumber(accountNumber)
            .accountBalance(0L)
            .build();
    }
  
    public static ChalletBank createAccountTest(String phoneNumber, long accountBalance, String accountNumber) {
	    return ChalletBank.builder()
        .phoneNumber(phoneNumber)
        .accountNumber(accountNumber)
        .accountBalance(accountBalance)
        .build();
    }
}
  • 해당 계좌의 결제 시 잔액이 감소 됩니다.

Challet Bank Service

@Transactional
@Override
public PaymentResponseDTO qrPayment(Long accountId, PaymentRequestDTO paymentRequestDTO) {
    //1. 연결 계좌를 찾습니다
    ChalletBank challetBank = getChalletBank(accountId);

		//2. 계좌의 금액으로 결제를 시도합니다
    long transactionBalance = calculateTransactionBalance(challetBank,
        paymentRequestDTO.transactionAmount());
		
		//3. 결제 성공 시 상세결제 내역 entity 생성
    ChalletBankTransaction paymentTransaction = createTransaction(challetBank,
        paymentRequestDTO, transactionBalance);
		
		//4. 결제 내역 저장
    challetBank.addTransaction(paymentTransaction);

		//5. 결제 내역 반환
    return createPaymentResponse(paymentRequestDTO);
}
  1. 선택한 계좌의 ID를 통해 해당 계좌의 정보를 가져옵니다
  2. 계좌의 남은 금액과 결제 내역을 비교하여 결제를 시도합니다
  3. 결제 성공 시 상세 결과 내역을 entity에 담습니다
  4. 결제 내역을 저장하며 해당 계좌의 잔액도 수정합니다

2. base code의 문제점

100개의 요청이 동시에 들어왔을 때 정확하게 100개의 결제를 안전하게 할 수 있는지 확인을 해봐야합니다

2-1 test

@SpringBootTest
class ChalletBankServiceImplTest {

    @Autowired
    private ChalletBankService challetBankService;

    @Autowired
    private ChalletBankRepository challetBankRepository;

    @Test
    @DisplayName("동시에 100개의 결제로 잔액을 감소시킨다.")
    void qrPayment() throws InterruptedException {
				//1. 결제 내용(결제 금액, 전화번호, 거래처, 카테고리)
        PaymentRequestDTO paymentRequestDTO = new PaymentRequestDTO(1L, "01012345678", "test1", "DELIVERY");
				
				//2. 100번의 결제
        int threadCount = 100;
        CountDownLatch countDownLatch = new CountDownLatch(threadCount);

        // 동시에 실행할 스레드 개수
        ExecutorService executorService = Executors.newFixedThreadPool(32);
				
        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                try {
			              //3. 결제 계좌ID, 결제내역
                    challetBankService.qrPayment(1L, paymentRequestDTO);
                } finally {
                    countDownLatch.countDown();
                }
            });
        }

        // 모든 스레드가 완료될 때까지 대기
        countDownLatch.await();

				//4. 잔액을 확인하기 위해 1번째 계좌 정보를 가져옵니다
        ChalletBank updatedChalletBank = challetBankRepository.findById(1L).orElseThrow();
        Long updateAccountBalance = updatedChalletBank.getAccountBalance();

        //5. 예상 잔액으로 검증 (동시성 문제 해결 시 정상적으로 동작해야 함)
        Assertions.assertThat(updateAccountBalance).isEqualTo(0);
    }
}

이런 식으로 동작하므로 기대하는 동작이 되려면 100번 모든 요청 이후에 잔액를 조회했을 때 0이 반환되어야 합니다.

결과는, 0이 되지 않고 테스트가 실패하는 것을 알 수 있습니다.

왜 이러한 결과가 발생했을까요? 바로 레이스 컨디션(Race Condition)이 발생했기 때문입니다.

※ 레이스 컨디션(Race Condition)이란?

레이스 컨디션이란, 둘 이상의 Thread가 공유 자원에 접근해서 동시에 변경을 할 때 발생하는 문제입니다.

기존 상품의 재고가 100인 상태에서, 위와 같이 스레드 3개가 상품 하나에 접근했다고 생각해봅시다.

편의상 Thread 1 → Thread 2 → Thread 3 순으로 빠르게 작업이 수행된다고 했을 때,

Thread 1, 2, 3에서 모두 같은 상품에 접근합니다.

이때 Thread 1, 2, 3에서 재고 감소 로직을 처리할 때의 상품 재고는 모두 100일 것입니다.

그러므로 가장 마지막으로 실행되는 Thread 3의 작업이 끝날 때 재고가 최종적으로 99로 변하는 것입니다.

일반적인 예상으로는 작업 하나당 재고가 1개씩 줄어서 위의 상황이라면 재고가 97이 되기를 예상하지만, 위와 같은 Race Condition이 발생하여 재고가 정상적으로 줄지 않은 것입니다.


3. 비관적 락

비관적 락은 실제로 DB 단에 X-Lock을 설정해서 동시성을 제어하는 방법입니다.

DB단에서 해당 자원의 점유는 트랜잭션 단위로 수행됩니다.

A 트랜잭션에서 데이터에 X-Lock을 설정하면, 해당 트랜잭션이 종료되기 전까지는 다른 트랜잭션에서 해당 데이터를 수정할 수 없습니다.

이렇게 데이터에 직접 DB단의 X-Lock을 걸도록 애플리케이션 코드에서 지정하여 동시성을 처리하는 방법입니다.

3-1 Repository

@Repository
@RequiredArgsConstructor
public class ChalletBankRepositoryImpl implements ChalletBankRepositoryCustom {

  private final JPAQueryFactory query;
  
	@Override
	public ChalletBank findByIdWithLock(Long accountId) {
	    QChalletBank challetBank = QChalletBank.challetBank;
	
	    return query
	        .selectFrom(challetBank)
	        .where(challetBank.id.eq(accountId))
	        .setLockMode(LockModeType.PESSIMISTIC_WRITE)
	        .fetchOne();
	}
}
  • queryDsl는 query문 안에 직접 setLockMode를 넣어야 합니다
  • challetBank를 조회하면 다른 사림이 접근하지 못하도록 락을 걸어둡니다
 @BeforeEach
  public void before(){
      challetBankRepository.save(ChalletBank.createAccountTest("01012345678", 100, "123123123123"));
  }

  @Test
  @DisplayName("동시에 100개의 결제로 잔액을 감소시킨다.")
  void qrPayment() throws InterruptedException {
      ChalletBank challetBank = challetBankRepository.findById(1L).orElseThrow();
      System.out.println(challetBank.getAccountBalance());

      //1. 결제 내용(결제 금액, 전화번호, 거래처, 카테고리)
      PaymentRequestDTO paymentRequestDTO = new PaymentRequestDTO(1L, "01012345678", "test1", "DELIVERY");

      //2. 100번의 결제
      int threadCount = 100;
      CountDownLatch countDownLatch = new CountDownLatch(threadCount);

      // 동시에 실행할 스레드 개수
      ExecutorService executorService = Executors.newFixedThreadPool(32);

      for (int i = 0; i < threadCount; i++) {
          executorService.submit(() -> {
              try {
                  //3. 결제 계좌ID, 결제내역
                  challetBankService.qrPayment(1L, paymentRequestDTO);
              }catch (Exception e) {
                  // 잔액 부족 시 발생하는 예외를 처리
                  System.out.println("예외 발생: " + e.getMessage());
              } finally {
                  countDownLatch.countDown();
              }
          });
      }

      // 모든 스레드가 완료될 때까지 대기
      countDownLatch.await();

      //4. 잔액을 확인하기 위해 1번째 계좌 정보를 가져옵니다
      ChalletBank updatedChalletBank = challetBankRepository.findById(1L).orElseThrow();
      Long updateAccountBalance = updatedChalletBank.getAccountBalance();

      //5. 예상 잔액으로 검증 (동시성 문제 해결 시 정상적으로 동작해야 함)
      Assertions.assertThat(updateAccountBalance).isEqualTo(0);
  }
  • repository에서 락을 걸어두었으니 앞으로의 결제에서는 100까지는 문제 없이 결제가 되고 잔액이 0이 되어야 합니다


4. 낙관적 락

낙관적 락은 DB 단에 실제 Lock을 설정하지 않고,

Version을 관리하는 컬럼을 테이블에 추가해서 데이터 수정 시마다 맞는 버전의 데이터를 수정하는지를 판단하는 방식입니다.

  1. 2개의 스레드에서 동시에 DB에 접근하여 재고 100, Version이 1인 상품을 조회
  2. 스레드 1에서 먼저 조회한 상품에 대한 업데이트 (quantity -1, version + 1)
  3. 스레드 2에서 조회한 상품에 대해 업데이트 하려고 할 때 id가 1이고 version이 1인 상품은 존재하지 않으므로(이미 스레드 1에서 version 2로 업데이트) 예외 발생
  4. 예외를 잡아서 다시 DB에서 상품을 재조회하여 version 2인 상품을 업데이트 (quantity - 1, version + 1)

여기서 1~3번 과정은 스프링에서 어노테이션을 선언하면 자동으로 동작합니다.

4번 과정은 애플리케이션에서 예외를 잡아서 다시 로직을 수행하도록 수동으로 코드를 구현해야 합니다.

4-1 Challet Bank Entity

  @Version
  private Long version;
  • 낙관적 락을 위해 version을 추가해준다

4-2 OptimisticLockStockFacade

@Component
public class OptimisticLockStockFacade {

    private final ChalletBankService challetBankService;

    public OptimisticLockStockFacade(ChalletBankService challetBankService) {
        this.challetBankService = challetBankService;
    }

    public void decrease(Long id, PaymentRequestDTO dto) throws InterruptedException {
        while(true){
            try{
                challetBankService.qrPayment(id, dto);
                break;
            }catch (Exception e){
                Thread.sleep(50);
            }
        }
    }
}
  • 낙관적 락이 실패했을 경우 50ms 동안 sleep한 후에 다시 결제 로직을 실행

4-3 Challet Bank Repository

@Override
public ChalletBank findByIdWithOptimisticLock(Long accountId) {
    QChalletBank challetBank = QChalletBank.challetBank;

    return query
        .selectFrom(challetBank)
        .where(challetBank.id.eq(accountId))
        .setLockMode(LockModeType.OPTIMISTIC)
        .fetchOne();
}

4-4 Challet Bank Service

private ChalletBank getChalletBank(Long accountId) {
    return challetBankRepository.findByIdWithOptimisticLock(accountId);
}

4-5 낙관적 락의 데드락 발생

show engine innodb status 명령어로 데드락 History를 확인해야 하지만 DB의 권한이 없어서 확인을 못 했습니다

하지만 블로그들을 보니 아래와 같은 오류라고 생각이 들었습니다

  • 동일한 레코드(데이터)에 대해 s-Lock과 x-Lock을 시도하고 있었다.
  • s-Lock 끼리는 동시에 설정할 수 있지만, x-Lock은 한 리소스에 하나의 x-Lock만 설정가능하기 때문에 데드락이 발생한 것이다.
  • 결론적으로 FK 제약조건이 있는 테이블에는 낙관적 락을 사용할 수가 없습니다.이는 MySQL 데이터베이스에서 데이터의 일관성을 지키기 위해 개발자는 Lock을 걸고 싶지 않아도 Lock을 걸기 때문입니다.

https://0soo.tistory.com/214

⇒ 비관적 락으로 해결하자

0개의 댓글