Spring Data JPA 동시성 테스트 에러: expected: X but was: Y

🐛 에러 상황

스프링 부트 프로젝트에서 트랜잭션 서비스 통합 테스트를 작성하는 중, 동시성 테스트에서 아래와 같은 에러가 발생했다.

org.opentest4j.AssertionFailedError:
expected: 35L
 but was: 1L
Expected :35L
Actual   :1L

 

 

관련 테스트 코드

@Slf4j
@SpringBootTest
@ExtendWith(SpringExtension.class)
@ActiveProfiles("payment-test") // application-payment-test.yml 설정 사용
public class TransactionServiceIntgTest {
    // ===== 의존성 주입 =====
    // SUT
    @Autowired TransactionService transactionService;       // 실제 트랜잭션 서비스
    // 의존성 주입
    @Autowired TransactionRepository transactionRepository; // 실제 트랜잭션 리포지토리
    @Autowired WalletService walletService;                 // 실제 지갑 서비스
    @Autowired WalletRepository walletRepository;           // 실제 리포지토리

    // 각 테스트 격리
    @AfterEach
    void tearDown() {
        transactionRepository.deleteAll();                  // 트랜잭션 먼저 삭제
        walletRepository.deleteAll();                       // 지갑 삭제
    }

    @Test
    @DisplayName("동시에 같은 orderId로 충전 요청 시, 트랜잭션은 1건만 생성되고 잔액은 정확히 1회만 반영된다")
    void charge_concurrent_sameOrderId_isIdempotent_andUnique() throws InterruptedException {
        // given
        // 사용자 지갑 생성
        CreatedWalletResponse wallet = walletService.createWallet(new CreateWalletRequest(1L));
        // 생성된 지갑 ID
        Long walletId = wallet.id();

        // 동일 orderId 준비
        // 충전 금액 1000원
        String orderId = "order-123";
        BigDecimal amount = BigDecimal.valueOf(1000);

        // 동시성 환경 준비
        int threadCount = 20;                                                 // 동시에 20개의 스레드에서 충전 시도
        ExecutorService executor = Executors.newFixedThreadPool(threadCount); // 스레드풀 생성
        CountDownLatch latch = new CountDownLatch(threadCount);               // 모든 스레드 완료 대기용

        List<ChargeTransactionResponse> results = new ArrayList<>();          // 결과 수집용 (동기화 필요)

        // when
        // 모든 스레드에서 동시에 요청 시작
        for (int i = 0; i < threadCount; i++) {
            // 각 스레드에서 충전 시도
            executor.submit(() -> {
                try {
                    log.debug("[{}] charge() 호출 시작", Thread.currentThread().getName());

                    // 실제 서비스 호출
                    ChargeTransactionResponse response = transactionService.charge(
                            new ChargeTransactionRequest(walletId, orderId, amount)
                    );

                    log.debug("[{}] charge() 성공 → walletId={}, balance={}",
                            Thread.currentThread().getName(), response.walletId(), response.balance());

                    // 결과 수집 (동기화 필요)
                    synchronized (results) {
                        results.add(response);
                    }
                } catch (DataIntegrityViolationException e) {
                    // 중복 예외 무시 (멱등성 테스트이므로 무시)

                    log.warn("[{}] DataIntegrityViolationException 발생: {}",
                            Thread.currentThread().getName(), e.getMessage());

                } finally {
                    latch.countDown(); // 완료 표시
                }
            });
        }

        // 모든 스레드 완료 대기
        latch.await();

        // then
        // 디버깅 출력
        log.info("최종 results.size() = {}", results.size());
        results.forEach(r -> log.info("→ result walletId={}, balance={}", r.walletId(), r.balance()));

        // 최종적으로 DB에 트랜잭션은 1건만 존재하는지 확인
        assertThat(results).hasSizeGreaterThanOrEqualTo(1);

        // 멱등성 보장: 모든 응답의 walletId는 동일해야 한다
        assertThat(results.get(0).walletId()).isEqualTo(walletId);
        // 멱등성 보장: 모든 응답의 잔액은 동일해야 한다
        assertThat(results.get(0).balance()).isEqualByComparingTo(BigDecimal.valueOf(1000));

        // 디버깅 출력
        System.out.printf("✅ concurrent charge test: orderId=%s, txCount=%d, finalBalance=%s%n",
                orderId, results.size(), results.get(0).balance());
    }
}

 

 

테스트 시나리오는 다음과 같다:

  • 여러 개의 스레드가 동시에 같은 donationId / orderId로 결제·충전 요청을 보낸다.
  • 멱등성이 보장되어야 하므로 트랜잭션은 1건만 생성되고, 지갑 잔액은 정확히 한 번만 반영되어야 한다.
  • 그러나 실제 결과에서 walletId 값이 엉뚱하게 반환되거나, 아예 트랜잭션이 생성되지 않는 문제가 반복적으로 발생했다.

🔎 원인 분석

테스트에서 반복적으로 로그를 찍어본 결과, 일부 스레드에서 지갑이 존재하지 않는다 (WalletNotFoundException) 는 에러가 발생하거나, DB 반영이 꼬이는 현상이 있었다.

 

즉, 문제의 핵심은:

  • transactionService.charge() or payment() 호출 과정에서
  • DB에 insert된 Wallet 엔티티가 아직 flush되지 않아, 다른 스레드에서 해당 지갑을 찾을 수 없는 경우가 발생한 것.

JPA는 트랜잭션 커밋 시점에만 flush를 보장한다.

따라서 테스트 코드에서 walletService.createWallet() 호출 직후, 다른 스레드가 동시에 findWalletByWalletId() 를 호출하면 아직 DB에 반영되지 않은 상태일 수 있다.


✅ 해결 방법

해결책은 간단했다.

// 지갑 생성 직후 강제로 flush 수행
CreatedWalletResponse wallet = walletService.createWallet(new CreateWalletRequest(1L));
Long walletId = wallet.id();

walletRepository.flush();  // 💡 DB에 즉시 반영

walletRepository.flush() 를 호출하여 지갑 엔티티가 DB에 즉시 반영되도록 보장했다.

 

그 결과:

  • 다른 스레드에서 findWalletByWalletId() 호출 시 반드시 DB에서 조회 가능
  • 멱등성 테스트에서도 더 이상 WalletNotFoundException 이 발생하지 않음
  • 기대했던 대로 트랜잭션은 정확히 1건만 생성되고, 잔액도 올바르게 반영됨

✅ 요약

에러 상황

  • 동시성 테스트(charge_concurrent_sameOrderId_isIdempotent_andUnique)에서
    WalletNotFoundException 혹은 AssertionFailedError (expected: X but was: Y) 발생.
  • 원인: 메인 스레드에서 만든 Wallet 엔티티가 아직 DB에 제대로 반영되지 않은 상태에서,
    다른 스레드들이 동시에 findWalletByWalletId()를 호출 → DB에서 못 찾음.

해결 방법

  1. flush() 추가 
    • Wallet을 생성한 직후 DB에 강제로 반영(commit 아님).
    • 따라서 이후 다른 스레드에서 트랜잭션을 열어도 해당 Wallet row를 조회할 수 있게 됨.
    • → expected != actual 에러 해결됨.
walletRepository.flush();

❌ 왜 @Transactional을 쓰지 않았는가?

  1. Spring Test 기본 동작
    • @Transactional을 테스트에 붙이면 테스트 메서드 전체를 하나의 트랜잭션으로 감쌈.
    • 테스트 끝나면 무조건 롤백.
    • 즉, 테스트 본문에서 insert된 Wallet은 커밋되지 않은 상태로만 존재.
  2. 동시성 테스트와 충돌
    • 멀티스레드로 실행된 서비스 호출은 각각 자기 트랜잭션에서 실행됨.
    • 하지만 메인 트랜잭션은 아직 커밋되지 않았으므로, 다른 스레드에서는 Wallet을 볼 수 없음.
    • 결과적으로 WalletNotFoundException이 발생.
  3. 정리
    • 동시성 테스트에서는 각 스레드가 DB에서 커밋된 데이터를 주고받아야 함.
    • 따라서 @Transactional을 테스트 클래스/메서드에 붙이지 않고,
      대신 Wallet 생성 후 flush()로 강제 반영해줌.

📝 교훈

  1. JPA의 flush 타이밍은 커밋 시점까지 지연될 수 있다. 멀티스레드/동시성 테스트에서는 flush 타이밍이 중요하다.
  2. 테스트 환경에서 동시에 여러 스레드가 같은 데이터를 접근한다면, flush()로 DB 반영을 명시적으로 강제하는 것이 안전하다.
  3. 처음에는 통과했는데 반복 실행에서 실패
    → 동시성 테스트는 한 번의 성공으로 넘어가면 안 되고, 여러 번 연속 실행해서 안정성을 확인해야 함.

 

이 테스트에 대한 문제를 뒤늦게 발견한 이유는, 첫 테스트 땐 문제 없이 통과했었기 때문이다.

첫 테스트 이후 재차 테스트를 시도했어야 했는데, 그러지 않았고 결국 뒤늦게 문제를 발견하게 되었다.

 

🔄 왜 처음엔 통과했을까?

  1. 첫 실행 시에는 통과
    • 테스트 클래스에서 Wallet 생성 직후 곧바로 동시성 테스트가 실행됨.
    • MySQL(InnoDB)나 H2 같은 DB는 트랜잭션 내에서 insert한 데이터가 세션 캐시에 남아있을 수 있고,
      때로는 다른 스레드가 DB에 반영된 것처럼 접근 가능해지는 운 좋은 케이스가 발생.
    • 그래서 단발성 실행에서는 테스트가 우연히 통과할 수 있음.
  2. 연속 실행 시에는 실패
    • 두 번째 테스트 실행부터는 트랜잭션 롤백/커밋 타이밍, DB 세션 정리, 캐시 초기화 등이 달라짐.
    • 즉, Wallet 엔티티가 아직 flush/commit 되지 않은 상태에서 다른 스레드들이 조회 → WalletNotFoundException 발생.
    • 테스트는 항상 DB 레벨에서 일관된 상태를 가정해야 하는데, flush 없이는 그게 깨짐.

🚀 결론

  • 에러 메시지 expected: X but was: Y 는 단순히 Assertion 실패가 아니라,
  • DB 반영 타이밍 불일치로 인한 동시성 문제였다.
  • 동시성 테스트에서는 테스트 메서드에 @Transactional 금지.
  • 필요한 데이터는 saveAndFlush() 또는 flush()로 DB에 즉시 반영.
  • 그래야 멀티스레드 환경에서 모든 스레드가 동일한 데이터를 볼 수 있고, 멱등성 검증이 가능하다.

+ Recent posts