결제 서비스를 만드는 도중 과연 동시에 여러 결제가 발생할 경우 잔액이 정확하게 업데이트가 될지 고민이 되었습니다. 만약 동시에 결제가 이루어져 잔액이 부족한데 결제가 될 경우 서비스에 큰 오차라고 생각이 들었습니다. 그로인해 데이터의 정합성과 동시성을 테스트가 필요하다고 느껴 비관적 락과 낙관적 락을 도입해보았습니다
동시성을 확인하기 위한 ch-bank의 시스템의 베이스 코드 입니다
@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();
}
}
@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);
}
100개의 요청이 동시에 들어왔을 때 정확하게 100개의 결제를 안전하게 할 수 있는지 확인을 해봐야합니다
@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)이 발생했기 때문입니다.
레이스 컨디션이란, 둘 이상의 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이 발생하여 재고가 정상적으로 줄지 않은 것입니다.
비관적 락은 실제로 DB 단에 X-Lock을 설정해서 동시성을 제어하는 방법입니다.
DB단에서 해당 자원의 점유는 트랜잭션 단위로 수행됩니다.
A 트랜잭션에서 데이터에 X-Lock을 설정하면, 해당 트랜잭션이 종료되기 전까지는 다른 트랜잭션에서 해당 데이터를 수정할 수 없습니다.
이렇게 데이터에 직접 DB단의 X-Lock을 걸도록 애플리케이션 코드에서 지정하여 동시성을 처리하는 방법입니다.
@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();
}
}
@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);
}

낙관적 락은 DB 단에 실제 Lock을 설정하지 않고,
Version을 관리하는 컬럼을 테이블에 추가해서 데이터 수정 시마다 맞는 버전의 데이터를 수정하는지를 판단하는 방식입니다.

여기서 1~3번 과정은 스프링에서 어노테이션을 선언하면 자동으로 동작합니다.
4번 과정은 애플리케이션에서 예외를 잡아서 다시 로직을 수행하도록 수동으로 코드를 구현해야 합니다.
@Version
private Long version;
@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);
}
}
}
}
@Override
public ChalletBank findByIdWithOptimisticLock(Long accountId) {
QChalletBank challetBank = QChalletBank.challetBank;
return query
.selectFrom(challetBank)
.where(challetBank.id.eq(accountId))
.setLockMode(LockModeType.OPTIMISTIC)
.fetchOne();
}
private ChalletBank getChalletBank(Long accountId) {
return challetBankRepository.findByIdWithOptimisticLock(accountId);
}

show engine innodb status 명령어로 데드락 History를 확인해야 하지만 DB의 권한이 없어서 확인을 못 했습니다
하지만 블로그들을 보니 아래와 같은 오류라고 생각이 들었습니다

⇒ 비관적 락으로 해결하자