강의

멘토링

로드맵

Inflearn brand logo image

인프런 커뮤니티 질문&답변

이용준님의 프로필 이미지
이용준

작성한 질문수

송금 테스트 코드 작성중인데 비동기 처리 시 Transactional이 적용이 안되서 지갑이 null로 발생하는 문제

작성

·

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을 사용하여 별도의 트랜잭션을 생성하려고 했는데, 비동기 환경에서는 트랜잭션 전파가 예상대로 동작하지 않을 수 있습니다. 주어진 에러 메세지에서 “보내는 지갑이 존재하지 않습니다” 라는 문제가 있는 듯 하네요.

이럴 경우 몇 가지 확인할 점과 시도해볼 수 있는 방법을 안내드립니다:

  1. 메서드 호출 시점: 비동기 호출 시 메서드가 호출되기 전에 관련 데이터가 제대로 준비되었는지 확실하지 않습니다. 이는 특히 데이터베이스에 write(기록) 연산이 끝나기 전에 read(조회) 연산이 시작될 수 있는 비동기 처리에서 중요한 부분입니다.

  2. 테스트 데이터의 충돌: 테스트 코드가 동시에 여러 스레드에서 실행되면서 동일한 데이터를 읽고 쓰기 때문에 충돌이 발생할 수 있습니다. 각 테스트 케이스에서 고유한 사용자와 지갑을 생성하여 이러한 충돌을 피하는 것이 좋습니다.

  3. 트랜잭션 관리 주의: 비동기 메서드의 @Transactional 애노테이션 사용 시 특히 주의해야 합니다. 비동기 컨텍스트에서는 트랜잭션이 기대대로 공유되지 않을 수 있습니다. 비동기 호출을 관리하는 별도의 트랜잭션 메니저를 설정하거나, 다음과 같은 방법을 고려해 볼 수 있습니다:

    • 비동기 수행 전 데이터를 명확하게 확인
    • 메서드 전/후로 로그를 추가하여 각 호출의 흐름을 추적해서 어떤 시점에 문제가 발생하는지 파악.
  4. Spring의 트랜잭션 관리 강화: @EnableTransactionManagement(mode = AdviceMode.ASPECTJ)와 같은 설정으로 트랜잭션 관리를 강화하고, 비동기와 트랜잭션이 올바르게 설정되어 있는지 확인해야 합니다.

관련 문서에 대한 더 깊은 이해를 원하시면, Spring Data JPA 문서, Spring Boot JTA와 같은 참고 자료들을 살펴보시는 것도 도움이 될 수 있습니다:

  • [Spring Data JPA - Transaction알] reference
  • [Spring Boot - Distributed Transactions With JTA] reference

이 방법들로 문제를 해결할 수 있기를 바랍니다. 추가적인 도움이 필요하시면 언제든지 말씀해 주세요.

좋은 하루 되세요!

이용준님의 프로필 이미지
이용준

작성한 질문수

질문하기