작성
·
22
0
@Test
void 동시에_같은_계좌에_송금이_발생한다() throws InterruptedException {
// Given: 초기 충전
User sender = createUser("sender@example.com", "passwordA123!", "sen", "S1234", "010-1111-2222", 30, Sex.MALE);
User receiver = createUser("receiver@example.com", "passwordA123!", "rec", "R5678", "010-3333-4444", 25, Sex.FEMALE);
Wallet senderWallet = createWalletForUser(sender);
Wallet receiverWallet = createWalletForUser(receiver);
WalletChargeRequest chargeRequest = new WalletChargeRequest(
sender.getUserId(), CHARGE_AMOUNT.multiply(BigDecimal.valueOf(100)), CURRENCY, CURRENCY, LocalDateTime.now()
);
walletService.charge(chargeRequest);
walletBalanceRepository.flush();
walletRepository.flush();
int concurrentUsers = 100;
ExecutorService executorService = Executors.newFixedThreadPool(100);
CountDownLatch countDownLatch = new CountDownLatch(concurrentUsers);
CompletableFuture<?>[] futures = new CompletableFuture[concurrentUsers];
for (int i = 0; i < concurrentUsers; i++) {
futures[i] = CompletableFuture.runAsync(() -> {
try {
WalletTransferRequest transferRequest = new WalletTransferRequest(
sender.getUserId(), receiver.getUserId(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now()
);
walletService.asyncTransfer(transferRequest);
} finally {
countDownLatch.countDown();
}
}, executorService);
}
// 모든 송금 요청이 완료될 때까지 기다림
CompletableFuture.allOf(futures).join();
countDownLatch.await();
executorService.shutdown();
// Then: 잔액 검증
WalletBalance senderBalance = balanceService.findBalance(senderWallet.getWalletId(), CURRENCY);
WalletBalance receiverBalance = balanceService.findBalance(receiverWallet.getWalletId(), CURRENCY);
System.out.println("receiverBalance = " + receiverBalance.getBalance());
System.out.println("senderBalance = " + senderBalance.getBalance());
assertThat(senderBalance.getBalance()).isEqualByComparingTo(
CHARGE_AMOUNT.multiply(BigDecimal.valueOf(concurrentUsers)).subtract(TRANSFER_AMOUNT.multiply(BigDecimal.valueOf(concurrentUsers)))
);
assertThat(receiverBalance.getBalance()).isEqualByComparingTo(
TRANSFER_AMOUNT.multiply(BigDecimal.valueOf(concurrentUsers))
);
}
/**
// * 💡 송금 도중 출금 실패 테스트
// */
@Test
void 송금_도중_발생한_출금은_실패한다() throws InterruptedException {
User sender = createUser("sender@example.com", "passwordA123!", "sen", "S1234", "010-1111-2222", 30, Sex.MALE);
User receiver = createUser("receiver@example.com", "passwordA123!", "rec", "R5678", "010-3333-4444", 25, Sex.FEMALE);
Wallet senderWallet = createWalletForUser(sender);
Wallet receiverWallet = createWalletForUser(receiver);
walletRepository.flush();
WalletChargeRequest chargeRequest = new WalletChargeRequest(
sender.getUserId(), CHARGE_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now()
);
walletService.charge(chargeRequest);
AtomicReference<BigDecimal> withdrawAmount = new AtomicReference<>(BigDecimal.ZERO);
AtomicBoolean isWithdrawFirst = new AtomicBoolean(false);
AtomicBoolean isTransferFirst = new AtomicBoolean(false);
int concurrentTasks = 2;
ExecutorService executorService = Executors.newFixedThreadPool(concurrentTasks);
CountDownLatch countDownLatch = new CountDownLatch(concurrentTasks);
CompletableFuture<?>[] futures = new CompletableFuture[concurrentTasks];
// 출금 실행 (비동기)
futures[0] = CompletableFuture.runAsync(() -> {
try {
System.out.println("[출금 시작] - Thread: " + Thread.currentThread().getName());
WalletChargeRequest withdrawRequest = new WalletChargeRequest(
sender.getUserId(), CHARGE_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now()
);
withdrawAmount.set(walletService.asyncWithdrawal(withdrawRequest));
if(!isTransferFirst.get())isWithdrawFirst.set(true);
} catch (Exception e) {
System.err.println("[출금 중 예외 발생]: " + e.getMessage());
} finally {
countDownLatch.countDown();
}
}, executorService);
// 송금 실행 (비동기)
futures[1] = CompletableFuture.runAsync(() -> {
try {
System.out.println("[송금 시작] - Thread: " + Thread.currentThread().getName());
WalletTransferRequest transferRequest = new WalletTransferRequest(
sender.getUserId(), receiver.getUserId(), TRANSFER_AMOUNT, CURRENCY, CURRENCY, LocalDateTime.now()
);
walletService.asyncTransfer(transferRequest);
if(!isWithdrawFirst.get())isTransferFirst.set(true);
} catch (Exception e) {
System.err.println("[송금 중 예외 발생]: " + e.getMessage());
} finally {
countDownLatch.countDown();
}
}, executorService);
// 모든 작업이 완료될 때까지 대기
CompletableFuture.allOf(futures).join();
countDownLatch.await();
executorService.shutdown();
// 잔액 확인
WalletBalance balanceA = balanceService.findBalance(senderWallet.getWalletId(), CURRENCY);
WalletBalance balanceB = balanceService.findBalance(receiverWallet.getWalletId(), CURRENCY);
System.out.println("[최종 Sender 잔액] = " + balanceA.getBalance());
System.out.println("[최종 Receiver 잔액] = " + balanceB.getBalance());
System.out.println("[출금된 금액] = " + withdrawAmount.get());
System.out.println("[출금이 먼저 실행되었는가?] " + isWithdrawFirst.get());
System.out.println("[송금이 먼저 실행되었는가?] " + isTransferFirst.get());
// 테스트 검증
if (isWithdrawFirst.get()) {
// 출금이 먼저 실행되었으면 송금이 실패해야 함
assertThat(withdrawAmount.get()).isEqualByComparingTo(CHARGE_AMOUNT); // 출금 성공
assertThat(balanceA.getBalance()).isEqualByComparingTo(BigDecimal.ZERO); // 잔액 없음 (출금 완료)
} else if (isTransferFirst.get()) {
// 송금이 먼저 실행되었으면 출금이 실패해야 함
assertThat(balanceA.getBalance()).isEqualByComparingTo(TRANSFER_AMOUNT); // 송금 성공
assertThat(withdrawAmount.get()).isEqualTo(BigDecimal.ZERO); // 출금 실패
} else {
throw new IllegalStateException("출금과 송금이 모두 실행되지 않음");
}
}
}
Service 코드:
public class WalletService {
private final WalletRepository walletRepository;
private final UserRepository userRepository;
private final WalletBalanceService balanceService;
@Async // ✅ 비동기 실행
@Transactional(propagation = Propagation.REQUIRES_NEW) // ✅ 새 트랜잭션 적용
public void asyncTransfer(WalletTransferRequest request) {
transfer(request);
}
@Async // ✅ 비동기 실행
@Transactional(propagation = Propagation.REQUIRES_NEW) // ✅ 새 트랜잭션 적용
public BigDecimal asyncWithdrawal(WalletChargeRequest request) {
return withdrawal(request);
}
@Transactional
public void charge(WalletChargeRequest request) {
if (!request.toCurrency().equals(request.fromCurrency())) {
//환전
}
Wallet wallet = walletRepository.findByUserId(request.userId());
if (!balanceService.checkBalance(wallet.getWalletId(), request.toCurrency())) {
Wallet findWallet = walletRepository.findById(wallet.getWalletId())
.orElseThrow(ErrorCode.WALLET_NOT_FOUND::commonException);
balanceService.createBalance(findWallet, request.toCurrency());
}
WalletBalance balance = balanceService.findBalance(wallet.getWalletId(), request.toCurrency());
balanceService.chargeBalance(balance, request.chargeAmount());
}
@Transactional
public BigDecimal withdrawal(WalletChargeRequest request) {
if (!request.toCurrency().equals(request.fromCurrency())) {
//환전
}
Wallet wallet = walletRepository.findByUserId(request.userId());
WalletBalance balance = balanceService.findBalance(wallet.getWalletId(), request.toCurrency());
balanceService.withdrawBalance(balance, request.chargeAmount());
return balance.getBalance();
}
// @Transactional(propagation = Propagation.REQUIRES_NEW)
@Transactional
public void transfer(WalletTransferRequest request) {
Wallet fromWallet = walletRepository.findByUserId(request.senderId());
Wallet toWallet = walletRepository.findByUserId(request.receiverId());
if (fromWallet == null) {
throw new IllegalStateException("보내는 지갑이 존재하지 않습니다: " + request.senderId());
}
if (toWallet == null) {
throw new IllegalStateException("받는 지갑이 존재하지 않습니다: " + request.receiverId());
}
WalletBalance fromBalance =balanceService.findBalance(fromWallet.getWalletId(), request.fromCurrency());
WalletBalance toBalance = balanceService.findBalance(toWallet.getWalletId(), request.toCurrency());
BigDecimal transferAmount = request.transferAmount();
if (transferAmount.compareTo(fromBalance.getBalance()) > 0) {
throw ErrorCode.BALANCE_NOT_AVAILABLE.commonException();
}
balanceService.transferBalance(fromBalance, toBalance, transferAmount);
}
}
ErrorCode:
java.lang.IllegalStateException: 보내는 지갑이 존재하지 않습니다: 4
java.util.concurrent.CompletionException: java.lang.IllegalStateException: 보내는 지갑이 존재하지 않습니다: 4
어떻게 해결할 수 있을까요?ㅜㅜ
답변 1
0
안녕하세요, 인프런 AI 인턴입니다.
이용준님, 비동기 송금 테스트 과정에서 발생한 문제 해결에 대해 몇 가지 생각해보겠습니다. 코드를 보면 비동기 메서드에서 @Transactional
을 사용하여 별도의 트랜잭션을 생성하려고 했는데, 비동기 환경에서는 트랜잭션 전파가 예상대로 동작하지 않을 수 있습니다. 주어진 에러 메세지에서 “보내는 지갑이 존재하지 않습니다” 라는 문제가 있는 듯 하네요.
이럴 경우 몇 가지 확인할 점과 시도해볼 수 있는 방법을 안내드립니다:
메서드 호출 시점: 비동기 호출 시 메서드가 호출되기 전에 관련 데이터가 제대로 준비되었는지 확실하지 않습니다. 이는 특히 데이터베이스에 write(기록) 연산이 끝나기 전에 read(조회) 연산이 시작될 수 있는 비동기 처리에서 중요한 부분입니다.
테스트 데이터의 충돌: 테스트 코드가 동시에 여러 스레드에서 실행되면서 동일한 데이터를 읽고 쓰기 때문에 충돌이 발생할 수 있습니다. 각 테스트 케이스에서 고유한 사용자와 지갑을 생성하여 이러한 충돌을 피하는 것이 좋습니다.
트랜잭션 관리 주의: 비동기 메서드의 @Transactional
애노테이션 사용 시 특히 주의해야 합니다. 비동기 컨텍스트에서는 트랜잭션이 기대대로 공유되지 않을 수 있습니다. 비동기 호출을 관리하는 별도의 트랜잭션 메니저를 설정하거나, 다음과 같은 방법을 고려해 볼 수 있습니다:
Spring의 트랜잭션 관리 강화: @EnableTransactionManagement(mode = AdviceMode.ASPECTJ)
와 같은 설정으로 트랜잭션 관리를 강화하고, 비동기와 트랜잭션이 올바르게 설정되어 있는지 확인해야 합니다.
관련 문서에 대한 더 깊은 이해를 원하시면, Spring Data JPA 문서, Spring Boot JTA와 같은 참고 자료들을 살펴보시는 것도 도움이 될 수 있습니다:
이 방법들로 문제를 해결할 수 있기를 바랍니다. 추가적인 도움이 필요하시면 언제든지 말씀해 주세요.
좋은 하루 되세요!