Post

스프링 통합 테스트와 트랜잭션, 3번의 실패와 해결 과정

스프링 통합 테스트와 트랜잭션, 3번의 실패와 해결 과정

Spring Boot 환경에서 통합 테스트(Integration Test)를 작성하는 것은 애플리케이션의 여러 계층이 올바르게 상호작용하는지 검증하는 강력한 방법입니다. 하지만 데이터베이스 트랜잭션의 동작 방식을 정확히 이해하지 못하면 예상치 못한 문제에 직면하게 됩니다. 이 글에서는 좌석 예약 만료를 처리하는 스케줄링 서비스를 테스트하며 겪었던 세 번의 연속된 실패와 그 해결 과정을 기록합니다.

테스트 시나리오: 만료된 좌석 예약 해제

목표는 간단했습니다. 임시 배정된 좌석이 일정 시간이 지나면 자동으로 해제(AVAILABLE 상태로 변경)되는 SeatExpirationService의 기능을 검증하는 것입니다. @SpringBootTest@Transactional을 사용하여 통합 테스트 환경을 구축했습니다.

첫 번째 실패: IncorrectResultSizeDataAccessException

가장 먼저 만난 에러는 “결과가 하나여야 하는데 여러 개가 반환되었다”는 예외였습니다.

에러 로그:

1
org.springframework.dao.IncorrectResultSizeDataAccessException: Query did not return a unique result: 2 results were returned

원인 분석: 이 문제는 테스트 데이터 생성 방식 때문에 발생했습니다.

  1. @BeforeEach에서 setupInitialSeats()를 통해 1번부터 5번까지 AVAILABLE 상태의 좌석을 미리 생성했습니다.
  2. 각 테스트 케이스에서는 시나리오에 맞는 좌석을 SeatReservation.createTemporaryReservation(...)과 같은 팩토리 메서드로 새롭게 생성하고 save() 했습니다.

결과적으로 데이터베이스에는 동일한 콘서트와 좌석 번호를 가진 레코드가 AVAILABLE 상태인 것과 RESERVED 상태인 것, 두 개가 공존하게 되었습니다. 이후 해당 좌석을 단건 조회하는 findByCon...AndSeatNumber() 메서드가 여러 개의 결과를 반환하며 예외가 발생한 것입니다.

해결책: 테스트의 의도는 새로운 데이터를 ‘생성’하는 것이 아니라, 기존 데이터의 ‘상태를 전이’시키는 것입니다. 따라서 로직을 다음과 같이 변경했습니다.

  • Before: SeatReservation reservation = SeatReservation.create...(); seatRepository.save(reservation);
  • After: SeatReservation reservation = seatRepository.findBy...(); reservation.reserve(); seatRepository.save(reservation);

교훈: 통합 테스트에서 데이터는 새로 생성하기보다, 사전에 준비된 데이터의 상태를 변경(Find and Update)하는 방식으로 다루어야 합니다.


두 번째 실패: AssertionFailedError: expected: AVAILABLE but was: RESERVED

데이터 중복 문제를 해결하자, 이번에는 만료 로직이 전혀 동작하지 않는 문제가 발생했습니다. 테스트는 만료된 좌석의 상태가 AVAILABLE이 되길 기대했지만, 결과는 RESERVED 그대로였습니다.

원인 분석: 이 문제의 핵심은 트랜잭션의 격리(Isolation) 수준에 있었습니다.

  1. 테스트 메서드는 @Transactional에 의해 하나의 큰 트랜잭션(이하 Tx-A) 안에서 실행됩니다.
  2. Given 절에서 좌석을 RESERVED로 변경하고 만료 시간을 과거로 설정한 내용은 아직 Tx-A에만 반영된 채 커밋되지 않은 상태입니다.
  3. When 절에서 호출한 seatExpirationService.expireReservations() 메서드 역시 내부적으로 @Transactional이 선언되어 있어, 새로운 트랜잭션(이하 Tx-B)을 시작합니다.
  4. 기본 격리 수준(READ_COMMITTED)에서 Tx-B는 아직 커밋되지 않은 Tx-A의 변경 내용을 읽을 수 없습니다. 따라서 서비스는 만료된 좌석이 없다고 판단하고 아무 작업도 하지 않습니다.
  5. 테스트는 다시 Tx-A로 돌아와 결과를 검증하지만, 아무 일도 없었으므로 상태는 RESERVED 그대로였고, 단언(Assertion)은 실패했습니다.

해결책: Given 절에서 준비한 데이터가 When 절에서 실행되는 서비스 로직에 보이게 하려면, 데이터 준비 로직을 별도의 트랜잭션에서 실행하고 즉시 커밋해야 합니다. Spring의 트랜잭션 전파 속성을 이용해 이 문제를 해결했습니다.

1
2
3
4
5
6
7
8
9
10
@TestConfiguration
static class TestHelper {
    @Autowired
    private SeatReservationRepository seatReservationRepository;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public SeatReservation reserveAndForceExpireSeat(...) {
        // ... 좌석 조회 후 상태 변경 및 저장 로직 ...
    }
}

테스트 클래스 내부에 @TestConfiguration으로 헬퍼 빈을 등록하고, 데이터 준비 메서드에 @Transactional(propagation = Propagation.REQUIRES_NEW)를 붙였습니다. 이렇게 하면 이 메서드는 항상 새로운 트랜잭션에서 실행되고 즉시 커밋되므로, 이후에 실행되는 서비스 로직이 변경된 데이터를 볼 수 있게 됩니다.

교훈: @Transactional 테스트 내에서 다른 @Transactional 서비스를 호출할 때는 트랜잭션의 전파와 격리를 반드시 고려해야 합니다. 테스트 데이터 준비를 위해 Propagation.REQUIRES_NEW는 유용한 도구입니다.


세 번째 실패: AssertionError: 사전 좌석 데이터가 없습니다.

두 번째 문제를 해결하기 위해 도입한 TestHelper가 또 다른 문제를 낳았습니다. 이번에는 TestHelper 내부에서 AVAILABLE 상태의 좌석을 찾지 못해 테스트가 실패했습니다.

원인 분석: 이것은 두 번째 문제와 정확히 반대되는 상황이었습니다.

  1. @BeforeEach에서 초기 좌석 데이터(AVAILABLE 상태)를 생성하는 로직은 여전히 주 테스트 트랜잭션(Tx-A) 안에서 실행됩니다. 이 데이터는 아직 커밋되지 않았습니다.
  2. TestHelperreserveAndForceExpireSeat 메서드가 REQUIRES_NEW 속성에 따라 새로운 트랜잭션(Tx-C)을 시작합니다.
  3. Tx-C는 아직 커밋되지 않은 Tx-A의 데이터를 볼 수 없으므로, @BeforeEach에서 생성한 AVAILABLE 좌석을 찾지 못하고 예외를 던진 것입니다.

해결책: 결론은 모든 데이터 준비 과정을 일관된 트랜잭션 전략으로 관리하는 것이었습니다. @BeforeEach에서 수행하던 초기 데이터 생성 로직까지 TestHelper로 옮겨 REQUIRES_NEW 트랜잭션 내에서 실행하도록 수정했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
// TestHelper
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void setupInitialData() {
    // 콘서트, 사용자, AVAILABLE 상태의 좌석 등 모든 초기 데이터 생성
}

// Test Class
@BeforeEach
void setUp() {
    // DB 정리 후 TestHelper를 통해 초기 데이터 생성 및 커밋
    testHelper.setupInitialData();
}

이제 모든 테스트는 깨끗한 상태에서 시작하며, setupInitialData()를 통해 커밋된 초기 데이터를 기반으로 동작합니다. 이후 TestHelper의 다른 메서드나 서비스 로직 역시 이 커밋된 데이터를 안전하게 조회할 수 있습니다.

교훈: 복잡한 통합 테스트에서는 데이터 준비, 실행, 검증 단계의 트랜잭션 경계를 명확히 설계해야 한다. 모든 테스트 사전 조건은 본 테스트 로직이 실행되기 전에 안정적으로 커밋된 상태여야 한다.

정리하며

단순해 보였던 통합 테스트 하나를 완성하기까지 세 번의 실패를 거쳤습니다. 이 과정은 결국 스프링의 트랜잭션 관리와 테스트 환경의 상호작용에 대한 깊은 이해로 이어졌습니다. 버그처럼 보이는 테스트 실패 뒤에는 논리적인 원인이 숨어있었고, 이를 단계적으로 분석하고 해결하며 더 견고한 테스트 코드를 작성할 수 있었습니다.

This post is licensed under CC BY 4.0 by the author.