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에서 못 찾음.
해결 방법
- flush() 추가
- Wallet을 생성한 직후 DB에 강제로 반영(commit 아님).
- 따라서 이후 다른 스레드에서 트랜잭션을 열어도 해당 Wallet row를 조회할 수 있게 됨.
- → expected != actual 에러 해결됨.
walletRepository.flush();
❌ 왜 @Transactional을 쓰지 않았는가?
- Spring Test 기본 동작
- @Transactional을 테스트에 붙이면 테스트 메서드 전체를 하나의 트랜잭션으로 감쌈.
- 테스트 끝나면 무조건 롤백.
- 즉, 테스트 본문에서 insert된 Wallet은 커밋되지 않은 상태로만 존재.
- 동시성 테스트와 충돌
- 멀티스레드로 실행된 서비스 호출은 각각 자기 트랜잭션에서 실행됨.
- 하지만 메인 트랜잭션은 아직 커밋되지 않았으므로, 다른 스레드에서는 Wallet을 볼 수 없음.
- 결과적으로 WalletNotFoundException이 발생.
- 정리
- 동시성 테스트에서는 각 스레드가 DB에서 커밋된 데이터를 주고받아야 함.
- 따라서 @Transactional을 테스트 클래스/메서드에 붙이지 않고,
대신 Wallet 생성 후 flush()로 강제 반영해줌.
📝 교훈
- JPA의 flush 타이밍은 커밋 시점까지 지연될 수 있다. 멀티스레드/동시성 테스트에서는 flush 타이밍이 중요하다.
- 테스트 환경에서 동시에 여러 스레드가 같은 데이터를 접근한다면, flush()로 DB 반영을 명시적으로 강제하는 것이 안전하다.
- 처음에는 통과했는데 반복 실행에서 실패
→ 동시성 테스트는 한 번의 성공으로 넘어가면 안 되고, 여러 번 연속 실행해서 안정성을 확인해야 함.
이 테스트에 대한 문제를 뒤늦게 발견한 이유는, 첫 테스트 땐 문제 없이 통과했었기 때문이다.
첫 테스트 이후 재차 테스트를 시도했어야 했는데, 그러지 않았고 결국 뒤늦게 문제를 발견하게 되었다.
🔄 왜 처음엔 통과했을까?
- 첫 실행 시에는 통과
- 테스트 클래스에서 Wallet 생성 직후 곧바로 동시성 테스트가 실행됨.
- MySQL(InnoDB)나 H2 같은 DB는 트랜잭션 내에서 insert한 데이터가 세션 캐시에 남아있을 수 있고,
때로는 다른 스레드가 DB에 반영된 것처럼 접근 가능해지는 운 좋은 케이스가 발생. - 그래서 단발성 실행에서는 테스트가 우연히 통과할 수 있음.
- 연속 실행 시에는 실패
- 두 번째 테스트 실행부터는 트랜잭션 롤백/커밋 타이밍, DB 세션 정리, 캐시 초기화 등이 달라짐.
- 즉, Wallet 엔티티가 아직 flush/commit 되지 않은 상태에서 다른 스레드들이 조회 → WalletNotFoundException 발생.
- 테스트는 항상 DB 레벨에서 일관된 상태를 가정해야 하는데, flush 없이는 그게 깨짐.
🚀 결론
- 에러 메시지 expected: X but was: Y 는 단순히 Assertion 실패가 아니라,
- DB 반영 타이밍 불일치로 인한 동시성 문제였다.
- 동시성 테스트에서는 테스트 메서드에 @Transactional 금지.
- 필요한 데이터는 saveAndFlush() 또는 flush()로 DB에 즉시 반영.
- 그래야 멀티스레드 환경에서 모든 스레드가 동일한 데이터를 볼 수 있고, 멱등성 검증이 가능하다.