어제 통과한 테스트가 오늘 갑자기 실패한다면, 테스트 코드를 믿을 수 있을까요?

테스트가 실행 순서에 따라 성공하기도, 실패하기도 한다면 그건 테스트가 서로 격리되지 않았기 때문 입니다. A 테스트가 DB에 데이터를 넣고 안 지웠는데 B 테스트가 그 데이터 때문에 실패하는 거죠. 공부하다 보니 테스트 격리를 제대로 안 하면 디버깅에 배보다 배꼽이 더 큰 시간을 쓰게 된다는 걸 뼈저리게 느꼈습니다.

테스트 격리란

테스트 격리는 각 테스트가 독립적으로 실행되어 서로 영향을 주지 않는 것 을 의미합니다.

격리가 깨지는 대표적인 상황:

  • A 테스트가 DB에 레코드를 삽입 → B 테스트에서 "레코드가 1개"라고 가정했는데 2개
  • A 테스트가 캐시에 값을 저장 → B 테스트에서 캐시 히트가 나서 예상과 다른 결과
  • A 테스트가 static 변수를 변경 → B 테스트에서 초기값이 아닌 변경된 값을 읽음

Spring에서 테스트 격리를 보장하는 방법은 크게 4가지입니다:

  1. @Transactional 롤백
  2. @Sql로 데이터 준비/정리
  3. DatabaseCleaner 패턴
  4. @DirtiesContext (최후의 수단)

@Transactional — 가장 일반적인 격리 전략

테스트에 @Transactional을 붙이면, 테스트 완료 후 자동으로 롤백 됩니다.

JAVA
@SpringBootTest
@Transactional  // 테스트 후 자동 롤백
class OrderServiceTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void 주문을_생성하면_DB에_저장된다() {
        Order order = orderService.createOrder(new OrderRequest("상품A", 2));

        assertThat(orderRepository.findById(order.getId())).isPresent();
        // 테스트 끝나면 자동 롤백 — DB에 데이터가 남지 않음
    }

    @Test
    void 다른_테스트와_격리된다() {
        // 위 테스트가 롤백되었으므로 DB는 깨끗한 상태
        List<Order> orders = orderRepository.findAll();
        assertThat(orders).isEmpty();
    }
}

@DataJpaTest는 기본적으로 @Transactional이 포함되어 있어서 별도로 붙이지 않아도 됩니다.

@Transactional 롤백의 함정

편리하지만 주의할 점이 있습니다:

JAVA
@SpringBootTest
@Transactional
class OrderServiceTest {

    @Test
    void 주문과_결제가_하나의_트랜잭션에서_동작한다() {
        // 테스트에서는 하나의 트랜잭션으로 묶임
        orderService.createOrder(request);
        // 하지만 프로덕션에서는 서비스 내부에서 별도 트랜잭션이 열릴 수 있음!
    }
}

테스트의 @Transactional이 프로덕션 코드의 트랜잭션 경계를 덮어씌울 수 있습니다:

  • 테스트에서는 하나의 트랜잭션으로 성공 → 프로덕션에서는 분리된 트랜잭션으로 실패
  • REQUIRES_NEW 같은 전파 속성이 테스트에서 제대로 검증되지 않음
  • 지연 로딩(Lazy Loading)이 테스트에서는 같은 트랜잭션이라 성공 → 프로덕션에서 LazyInitializationException

이런 경우에는 @Transactional 없이 DatabaseCleaner를 사용하는 것이 더 정확합니다.

의도적으로 커밋하고 싶을 때

JAVA
@Test
@Commit  // 또는 @Rollback(false)
void 데이터_마이그레이션을_검증하고_결과를_유지한다() {
    migrationService.migrate();
    // 이 테스트는 롤백하지 않고 커밋
}

@Sql — SQL 파일로 테스트 데이터 관리

@Sql지정한 SQL 파일을 테스트 전후에 실행 하는 어노테이션입니다.

SQL
-- src/test/resources/sql/order-test-data.sql
INSERT INTO orders (id, product_name, quantity, status)
VALUES (1, '상품A', 2, 'PENDING');
INSERT INTO orders (id, product_name, quantity, status)
VALUES (2, '상품B', 5, 'COMPLETED');
JAVA
@SpringBootTest
@Sql("/sql/order-test-data.sql")  // 각 테스트 메서드 전에 실행
class OrderQueryTest {

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void PENDING_상태의_주문을_조회한다() {
        List<Order> pending = orderRepository.findByStatus(OrderStatus.PENDING);
        assertThat(pending).hasSize(1);
    }
}

실행 시점 제어

JAVA
// 테스트 전에 데이터 준비, 테스트 후에 정리
@Sql(
    scripts = "/sql/insert-test-data.sql",
    executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD
)
@Sql(
    scripts = "/sql/cleanup.sql",
    executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD
)
@Test
void 주문_데이터를_조회한다() {
    // ...
}

@SqlConfig로 세부 설정

JAVA
@Sql(
    scripts = "/sql/test-data.sql",
    config = @SqlConfig(
        encoding = "UTF-8",           // 인코딩 지정
        separator = ";;",              // SQL 구분자 변경 (프로시저 등에 유용)
        errorMode = SqlConfig.ErrorMode.CONTINUE_ON_ERROR  // 에러 무시하고 계속 실행
    )
)
@Test
void SQL_설정을_커스터마이즈한다() {
    // ...
}

@SqlGroup으로 여러 SQL 묶기

JAVA
@SqlGroup({
    @Sql(scripts = "/sql/schema.sql", executionPhase = BEFORE_TEST_METHOD),
    @Sql(scripts = "/sql/test-data.sql", executionPhase = BEFORE_TEST_METHOD),
    @Sql(scripts = "/sql/cleanup.sql", executionPhase = AFTER_TEST_METHOD)
})
@Test
void 여러_SQL_파일을_순서대로_실행한다() {
    // ...
}

@DirtiesContext — 최후의 수단

@DirtiesContext테스트 후 ApplicationContext를 폐기하고 새로 생성 합니다.

JAVA
@SpringBootTest
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class StatefulServiceTest {

    @Autowired
    private CacheManager cacheManager;

    @Test
    void 캐시에_값을_저장한다() {
        cacheManager.getCache("orders").put("key1", "value1");
        // 테스트 후 컨텍스트가 폐기되므로 캐시도 초기화됨
    }
}

classMode 옵션:

옵션동작
AFTER_CLASS (기본)클래스의 모든 테스트 후 컨텍스트 폐기
AFTER_EACH_TEST_METHOD각 테스트 메서드 후 컨텍스트 폐기
BEFORE_CLASS클래스 실행 전 컨텍스트 폐기
BEFORE_EACH_TEST_METHOD각 테스트 메서드 전 컨텍스트 폐기

왜 최후의 수단인가? 컨텍스트를 새로 생성하는 비용이 크기 때문입니다. 테스트 10개에 @DirtiesContext를 달면 컨텍스트 로드가 10번 일어나서 50~100초가 추가됩니다.

@DirtiesContext를 쓰고 싶은 상황 대부분은 다른 방법으로 해결할 수 있습니다:

  • 캐시 초기화 → @AfterEach에서 cacheManager.getCache("name").clear()
  • DB 상태 변경 → @Transactional 롤백 또는 DatabaseCleaner
  • static 변수 변경 → @AfterEach에서 직접 초기화

DatabaseCleaner 패턴 — 통합 테스트의 권장 전략

@Transactional 롤백의 함정을 피하면서 테스트 격리를 보장하는 방법입니다. 각 테스트 전에 ** 모든 테이블을 TRUNCATE**합니다.

JAVA
@Component
public class DatabaseCleaner {

    @PersistenceContext
    private EntityManager entityManager;

    private List<String> tableNames;

    @PostConstruct
    public void init() {
        // 엔티티 매핑된 테이블 이름 수집
        tableNames = entityManager.getMetamodel()
            .getEntities()
            .stream()
            .filter(e -> e.getJavaType().getAnnotation(Entity.class) != null)
            .map(e -> {
                Table table = e.getJavaType().getAnnotation(Table.class);
                return table != null ? table.name()
                    : camelToSnake(e.getName());
            })
            .collect(Collectors.toList());
    }

    @Transactional
    public void clear() {
        entityManager.flush();
        // 외래 키 제약 조건 비활성화
        entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE")
            .executeUpdate();

        for (String tableName : tableNames) {
            // TRUNCATE로 데이터 제거 + ID 시퀀스 초기화
            entityManager.createNativeQuery("TRUNCATE TABLE " + tableName)
                .executeUpdate();
            entityManager.createNativeQuery(
                "ALTER TABLE " + tableName + " ALTER COLUMN id RESTART WITH 1"
            ).executeUpdate();
        }

        // 외래 키 제약 조건 다시 활성화
        entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE")
            .executeUpdate();
    }

    private String camelToSnake(String camel) {
        return camel.replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase();
    }
}
JAVA
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderAcceptanceTest {

    @Autowired
    private DatabaseCleaner databaseCleaner;

    @Autowired
    private TestRestTemplate restTemplate;

    @BeforeEach
    void setUp() {
        databaseCleaner.clear();  // 각 테스트 전에 DB 초기화
    }

    @Test
    void 주문_생성_API를_호출한다() {
        // @Transactional 없이도 격리 보장
        ResponseEntity<OrderResponse> response = restTemplate.postForEntity(
            "/api/orders",
            new OrderRequest("상품A", 2),
            OrderResponse.class
        );

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
    }
}

@Transactional 롤백 대비 DatabaseCleaner의 장점:

  • 프로덕션과 동일한 트랜잭션 경계로 테스트
  • RANDOM_PORT 환경에서도 사용 가능 (실제 HTTP 요청은 다른 스레드이므로 @Transactional 롤백이 안 됨)
  • 지연 로딩 문제를 정확하게 발견 가능

TestEntityManager — @DataJpaTest에서의 데이터 준비

@DataJpaTest에서는 TestEntityManager로 테스트 데이터를 직접 persist할 수 있습니다.

JAVA
@DataJpaTest
class OrderRepositoryTest {

    @Autowired
    private TestEntityManager entityManager;

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void 최근_7일_이내_주문을_조회한다() {
        // 테스트 데이터 준비
        Order recent = new Order("상품A", LocalDateTime.now().minusDays(3));
        Order old = new Order("상품B", LocalDateTime.now().minusDays(10));

        entityManager.persist(recent);
        entityManager.persist(old);
        entityManager.flush();
        entityManager.clear();  // 영속성 컨텍스트 초기화 — 실제 DB 조회를 강제

        // 검증
        List<Order> orders = orderRepository.findRecentOrders(7);
        assertThat(orders).hasSize(1);
        assertThat(orders.get(0).getProductName()).isEqualTo("상품A");
    }
}

entityManager.clear()가 중요합니다. 이걸 안 하면 영속성 컨텍스트의 1차 캐시에서 가져오기 때문에 실제 쿼리가 제대로 동작하는지 검증할 수 없습니다.

테스트 데이터 빌더 패턴

테스트마다 엔티티를 new로 만들면 코드가 장황해지고, 필드가 추가될 때마다 모든 테스트를 수정해야 합니다.

Test Data Builder

JAVA
public class OrderFixture {

    // 기본값이 설정된 빌더
    public static OrderBuilder anOrder() {
        return new OrderBuilder()
            .withProductName("기본상품")
            .withQuantity(1)
            .withStatus(OrderStatus.PENDING)
            .withCreatedAt(LocalDateTime.now());
    }

    public static class OrderBuilder {
        private String productName;
        private int quantity;
        private OrderStatus status;
        private LocalDateTime createdAt;

        public OrderBuilder withProductName(String productName) {
            this.productName = productName;
            return this;
        }

        public OrderBuilder withQuantity(int quantity) {
            this.quantity = quantity;
            return this;
        }

        public OrderBuilder withStatus(OrderStatus status) {
            this.status = status;
            return this;
        }

        public OrderBuilder withCreatedAt(LocalDateTime createdAt) {
            this.createdAt = createdAt;
            return this;
        }

        public Order build() {
            return new Order(productName, quantity, status, createdAt);
        }
    }
}
JAVA
@Test
void VIP_주문은_우선_처리된다() {
    // 필요한 필드만 설정하고 나머지는 기본값 사용
    Order vipOrder = OrderFixture.anOrder()
        .withStatus(OrderStatus.VIP_PRIORITY)
        .build();

    Order normalOrder = OrderFixture.anOrder()
        .build();

    // 테스트 의도가 명확하게 드러남
    assertThat(vipOrder.getPriority()).isGreaterThan(normalOrder.getPriority());
}

ObjectMother 패턴

자주 쓰는 객체 조합을 메서드로 미리 정의해두는 방식입니다.

JAVA
public class OrderMother {

    public static Order completedOrder() {
        return OrderFixture.anOrder()
            .withStatus(OrderStatus.COMPLETED)
            .withCreatedAt(LocalDateTime.now().minusDays(1))
            .build();
    }

    public static Order pendingVipOrder() {
        return OrderFixture.anOrder()
            .withStatus(OrderStatus.VIP_PRIORITY)
            .withQuantity(10)
            .build();
    }

    public static List<Order> mixedStatusOrders() {
        return List.of(
            completedOrder(),
            pendingVipOrder(),
            OrderFixture.anOrder().withStatus(OrderStatus.CANCELLED).build()
        );
    }
}

격리 전략 선택 기준

PLAINTEXT
어떤 격리 전략을 사용할까?

├─ @DataJpaTest → @Transactional 기본 포함, 추가 작업 불필요

├─ @SpringBootTest(webEnvironment = MOCK)
│   └─ @Transactional 롤백이 가장 간단

├─ @SpringBootTest(webEnvironment = RANDOM_PORT)
│   └─ DatabaseCleaner 사용 (다른 스레드라 @Transactional 롤백 안 됨)

├─ 트랜잭션 경계를 정확히 검증해야 하는 경우
│   └─ DatabaseCleaner 사용

└─ 컨텍스트 자체가 오염되는 경우 (static 상태, 싱글톤 변경)
    └─ @DirtiesContext (최후의 수단)

정리

  • 테스트 격리는 ** 재현 가능한 테스트 **의 기본 전제입니다
  • @Transactional 롤백이 가장 간단하지만, 트랜잭션 경계 차이에 주의하세요
  • @Sql은 SQL 파일로 데이터를 선언적으로 관리할 때 유용합니다
  • DatabaseCleaner는 RANDOM_PORT 환경이나 트랜잭션 경계를 정확히 검증할 때 추천합니다
  • @DirtiesContext는 컨텍스트 재생성 비용이 크므로, 다른 방법이 없을 때만 사용하세요
  • Test Data Builder 패턴으로 테스트 데이터 생성 코드를 깔끔하게 관리하면 유지보수가 훨씬 편해집니다
댓글 로딩 중...