테스트 픽스처와 격리 — @Sql, @DirtiesContext, 데이터 초기화 전략
어제 통과한 테스트가 오늘 갑자기 실패한다면, 테스트 코드를 믿을 수 있을까요?
테스트가 실행 순서에 따라 성공하기도, 실패하기도 한다면 그건 테스트가 서로 격리되지 않았기 때문 입니다. A 테스트가 DB에 데이터를 넣고 안 지웠는데 B 테스트가 그 데이터 때문에 실패하는 거죠. 공부하다 보니 테스트 격리를 제대로 안 하면 디버깅에 배보다 배꼽이 더 큰 시간을 쓰게 된다는 걸 뼈저리게 느꼈습니다.
테스트 격리란
테스트 격리는 각 테스트가 독립적으로 실행되어 서로 영향을 주지 않는 것 을 의미합니다.
격리가 깨지는 대표적인 상황:
- A 테스트가 DB에 레코드를 삽입 → B 테스트에서 "레코드가 1개"라고 가정했는데 2개
- A 테스트가 캐시에 값을 저장 → B 테스트에서 캐시 히트가 나서 예상과 다른 결과
- A 테스트가 static 변수를 변경 → B 테스트에서 초기값이 아닌 변경된 값을 읽음
Spring에서 테스트 격리를 보장하는 방법은 크게 4가지입니다:
@Transactional롤백@Sql로 데이터 준비/정리- DatabaseCleaner 패턴
@DirtiesContext(최후의 수단)
@Transactional — 가장 일반적인 격리 전략
테스트에 @Transactional을 붙이면, 테스트 완료 후 자동으로 롤백 됩니다.
@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 롤백의 함정
편리하지만 주의할 점이 있습니다:
@SpringBootTest
@Transactional
class OrderServiceTest {
@Test
void 주문과_결제가_하나의_트랜잭션에서_동작한다() {
// 테스트에서는 하나의 트랜잭션으로 묶임
orderService.createOrder(request);
// 하지만 프로덕션에서는 서비스 내부에서 별도 트랜잭션이 열릴 수 있음!
}
}
테스트의 @Transactional이 프로덕션 코드의 트랜잭션 경계를 덮어씌울 수 있습니다:
- 테스트에서는 하나의 트랜잭션으로 성공 → 프로덕션에서는 분리된 트랜잭션으로 실패
REQUIRES_NEW같은 전파 속성이 테스트에서 제대로 검증되지 않음- 지연 로딩(Lazy Loading)이 테스트에서는 같은 트랜잭션이라 성공 → 프로덕션에서
LazyInitializationException
이런 경우에는 @Transactional 없이 DatabaseCleaner를 사용하는 것이 더 정확합니다.
의도적으로 커밋하고 싶을 때
@Test
@Commit // 또는 @Rollback(false)
void 데이터_마이그레이션을_검증하고_결과를_유지한다() {
migrationService.migrate();
// 이 테스트는 롤백하지 않고 커밋
}
@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');
@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);
}
}
실행 시점 제어
// 테스트 전에 데이터 준비, 테스트 후에 정리
@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로 세부 설정
@Sql(
scripts = "/sql/test-data.sql",
config = @SqlConfig(
encoding = "UTF-8", // 인코딩 지정
separator = ";;", // SQL 구분자 변경 (프로시저 등에 유용)
errorMode = SqlConfig.ErrorMode.CONTINUE_ON_ERROR // 에러 무시하고 계속 실행
)
)
@Test
void SQL_설정을_커스터마이즈한다() {
// ...
}
@SqlGroup으로 여러 SQL 묶기
@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를 폐기하고 새로 생성 합니다.
@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**합니다.
@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();
}
}
@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할 수 있습니다.
@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
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);
}
}
}
@Test
void VIP_주문은_우선_처리된다() {
// 필요한 필드만 설정하고 나머지는 기본값 사용
Order vipOrder = OrderFixture.anOrder()
.withStatus(OrderStatus.VIP_PRIORITY)
.build();
Order normalOrder = OrderFixture.anOrder()
.build();
// 테스트 의도가 명확하게 드러남
assertThat(vipOrder.getPriority()).isGreaterThan(normalOrder.getPriority());
}
ObjectMother 패턴
자주 쓰는 객체 조합을 메서드로 미리 정의해두는 방식입니다.
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()
);
}
}
격리 전략 선택 기준
어떤 격리 전략을 사용할까?
│
├─ @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 패턴으로 테스트 데이터 생성 코드를 깔끔하게 관리하면 유지보수가 훨씬 편해집니다