테스트를 실행할 때마다 애플리케이션 전체가 뜨는 데 10초씩 걸린다면, 그 테스트를 자주 실행하게 될까요?

테스트가 느리면 안 돌리게 되고, 안 돌리면 의미가 없어집니다. @SpringBootTest는 강력하지만, 모든 테스트에 사용하면 전체 테스트 실행 시간이 기하급수적으로 늘어납니다. 공부하다 보니 "이 테스트에 정말 전체 컨텍스트가 필요한가?"를 먼저 묻는 습관이 가장 중요하다는 걸 깨달았습니다.

@SpringBootTest의 동작 원리

@SpringBootTest전체 ApplicationContext를 로드 하는 통합 테스트 어노테이션입니다.

내부적으로 일어나는 일:

  • @SpringBootApplication이 붙은 클래스를 찾아 컴포넌트 스캔 실행
  • 모든 @Bean, @Configuration, Auto-Configuration 로드
  • 내장 서버 설정 (webEnvironment에 따라)
  • 테스트 프로퍼티 적용
JAVA
@SpringBootTest
class OrderServiceIntegrationTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void 주문_생성부터_저장까지_전체_흐름을_검증한다() {
        // 서비스 → 리포지토리 → DB까지 실제로 동작
        OrderRequest request = new OrderRequest("상품A", 2);
        Order order = orderService.createOrder(request);

        assertThat(order.getId()).isNotNull();
        assertThat(orderRepository.findById(order.getId())).isPresent();
    }
}

이 테스트는 전체 빈을 띄우기 때문에 보통 5~10초 가 걸립니다. 빈이 수백 개인 프로젝트에서는 더 오래 걸리기도 합니다.

webEnvironment 옵션 4가지

@SpringBootTestwebEnvironment 속성은 서버를 어떻게 띄울지 결정합니다.

옵션동작테스트 도구
MOCK (기본)실제 서버 없이 MockServletContext 사용MockMvc
RANDOM_PORT랜덤 포트로 실제 서버 기동TestRestTemplate, WebTestClient
DEFINED_PORTserver.port 설정값으로 서버 기동TestRestTemplate, WebTestClient
NONE웹 환경 없이 ApplicationContext만 로드직접 빈 호출
JAVA
// 실제 서버를 띄워서 테스트 — HTTP 통신까지 검증하고 싶을 때
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderApiIntegrationTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void 주문_API를_호출하면_201을_반환한다() {
        OrderRequest request = new OrderRequest("상품A", 2);

        ResponseEntity<Order> response = restTemplate.postForEntity(
            "/api/orders", request, Order.class
        );

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
    }
}
JAVA
// 웹 환경이 필요 없는 서비스 계층 통합 테스트
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
class PaymentServiceIntegrationTest {

    @Autowired
    private PaymentService paymentService;

    @Test
    void 결제_처리_흐름을_검증한다() {
        PaymentResult result = paymentService.process(new PaymentRequest(10000));
        assertThat(result.isSuccess()).isTrue();
    }
}

실무에서는 MOCK이 가장 많이 쓰이고, 실제 HTTP 레벨까지 검증해야 할 때만 RANDOM_PORT를 사용합니다. DEFINED_PORT는 포트 충돌 위험이 있어서 CI 환경에서는 피하는 편입니다.

슬라이스 테스트 — 필요한 계층만 잘라서 테스트

슬라이스 테스트는 특정 계층에 필요한 빈만 로드 하는 테스트입니다. 전체 컨텍스트를 띄우지 않으니 훨씬 빠릅니다.

어노테이션용도로드되는 빈대략적 속도
@WebMvcTest컨트롤러 테스트Controller, ControllerAdvice, Filter1~2초
@DataJpaTestRepository 테스트JPA 관련 빈, 내장 DB1~2초
@JsonTestJSON 직렬화/역직렬화JacksonTester, JsonbTester1초 미만
@RestClientTest외부 API 클라이언트RestTemplateBuilder, MockRestServiceServer1초 미만
JAVA
// 컨트롤러만 테스트 — Service는 Mock으로 대체
@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private OrderService orderService;

    @Test
    void 주문_조회_요청이_200을_반환한다() throws Exception {
        given(orderService.findById(1L))
            .willReturn(new OrderResponse(1L, "상품A", 2));

        mockMvc.perform(get("/api/orders/1"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.productName").value("상품A"));
    }
}
JAVA
// Repository만 테스트 — 내장 H2 DB 사용
@DataJpaTest
class OrderRepositoryTest {

    @Autowired
    private TestEntityManager entityManager;

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void 상태별_주문을_조회한다() {
        // TestEntityManager로 테스트 데이터 직접 삽입
        entityManager.persist(new Order("상품A", OrderStatus.COMPLETED));
        entityManager.persist(new Order("상품B", OrderStatus.PENDING));
        entityManager.flush();

        List<Order> completed = orderRepository.findByStatus(OrderStatus.COMPLETED);

        assertThat(completed).hasSize(1);
        assertThat(completed.get(0).getProductName()).isEqualTo("상품A");
    }
}
JAVA
// JSON 직렬화만 테스트
@JsonTest
class OrderResponseJsonTest {

    @Autowired
    private JacksonTester<OrderResponse> json;

    @Test
    void 주문_응답이_올바르게_직렬화된다() throws Exception {
        OrderResponse response = new OrderResponse(1L, "상품A", 2);

        assertThat(json.write(response))
            .extractingJsonPathStringValue("$.productName")
            .isEqualTo("상품A");
    }
}

ApplicationContext 캐시 — 모르면 테스트가 느려지는 이유

Spring Test Framework는 같은 설정의 테스트끼리 ApplicationContext를 캐시해서 공유 합니다. 이 메커니즘을 이해하지 못하면, 테스트를 추가할수록 점점 느려지는 현상을 겪게 됩니다.

컨텍스트 캐시 키를 구성하는 요소:

  • @ContextConfiguration (또는 @SpringBootTest의 classes)
  • @ActiveProfiles
  • @TestPropertySource
  • @MockBean / @SpyBean 조합
  • @DirtiesContext 여부
JAVA
// 이 두 테스트는 같은 컨텍스트를 공유한다 (설정이 동일)
@SpringBootTest
class OrderServiceTest { /* ... */ }

@SpringBootTest
class PaymentServiceTest { /* ... */ }
JAVA
// 이 테스트는 새 컨텍스트를 생성한다 (프로파일이 다름)
@SpringBootTest
@ActiveProfiles("test")
class OrderServiceTest { /* ... */ }

@SpringBootTest
@ActiveProfiles("integration")  // 다른 프로파일 → 새 컨텍스트!
class PaymentServiceTest { /* ... */ }

프로파일 함정

공부하다 보니 이 부분에서 많이 헷갈렸습니다. 테스트마다 다른 프로파일을 쓰면 컨텍스트가 매번 새로 생성 됩니다. 테스트가 100개인데 프로파일 조합이 5가지라면, 컨텍스트가 5번 로드되는 겁니다.

컨텍스트 캐시를 최대한 활용하는 방법:

  • 테스트 프로파일을 하나로 통일 (@ActiveProfiles("test"))
  • @MockBean 조합을 ** 일관되게** 유지 (A 테스트에서 ServiceA를 Mock하고, B 테스트에서 ServiceB를 Mock하면 별도 컨텍스트)
  • @DirtiesContext는 정말 필요한 경우에만 사용
  • @TestPropertySource로 개별 속성을 바꾸는 대신 application-test.yml에 통합
JAVA
// 나쁜 예: MockBean 조합이 달라서 컨텍스트가 각각 생성됨
@SpringBootTest
class Test1 {
    @MockBean private ServiceA serviceA;  // 컨텍스트 #1
}

@SpringBootTest
class Test2 {
    @MockBean private ServiceB serviceB;  // 컨텍스트 #2
}

// 좋은 예: 공통 베이스 클래스로 MockBean 조합 통일
@SpringBootTest
abstract class BaseIntegrationTest {
    @MockBean protected ServiceA serviceA;
    @MockBean protected ServiceB serviceB;
}

class Test1 extends BaseIntegrationTest { /* ... */ }  // 같은 컨텍스트
class Test2 extends BaseIntegrationTest { /* ... */ }  // 같은 컨텍스트

테스트 피라미드와 선택 기준

테스트 피라미드는 테스트를 세 계층으로 나누고, 아래로 갈수록 많이, 위로 갈수록 적게 작성하는 전략입니다.

PLAINTEXT
        /  E2E  \          ← 가장 적게 (Selenium, Playwright)
       /----------\
      / 통합 테스트  \       ← 적당히 (@SpringBootTest)
     /--------------\
    / 슬라이스 테스트  \     ← 많이 (@WebMvcTest, @DataJpaTest)
   /------------------\
  /    단위 테스트      \    ← 가장 많이 (Mockito, JUnit)
 /----------------------\

각 계층의 특성:

** 단위 테스트 (Mockito + JUnit)**

  • Spring 없이 순수 자바로 테스트
  • 가장 빠름 (밀리초 단위)
  • 비즈니스 로직 검증에 집중

** 슬라이스 테스트 (@WebMvcTest, @DataJpaTest 등)**

  • 특정 계층만 로드
  • 빠름 (1~2초)
  • 계층 간 통합 검증

** 통합 테스트 (@SpringBootTest)**

  • 전체 컨텍스트 로드
  • 느림 (5~10초)
  • 전체 흐름 검증

실무 선택 기준 — "내가 테스트하려는 것이 뭔가?"

테스트를 작성하기 전에 "지금 검증하려는 것이 정확히 뭔가?" 를 먼저 물어보는 게 핵심입니다.

PLAINTEXT
내가 테스트하려는 것이 뭔가?

├─ 순수 비즈니스 로직 → 단위 테스트 (Mockito)
│   예: 할인 계산, 유효성 검증, 상태 변환

├─ 컨트롤러의 요청/응답 매핑 → @WebMvcTest
│   예: URL 매핑, 유효성 검증 응답, JSON 변환

├─ JPA 쿼리가 의도대로 동작하는지 → @DataJpaTest
│   예: 커스텀 쿼리, @Query 메서드, 페이징

├─ JSON 직렬화/역직렬화 → @JsonTest
│   예: 날짜 포맷, @JsonIgnore, 커스텀 직렬화기

├─ 외부 API 클라이언트 → @RestClientTest
│   예: RestTemplate/WebClient 호출, 에러 처리

└─ 여러 계층이 함께 동작하는 흐름 → @SpringBootTest
    예: 주문 생성 → 재고 차감 → 알림 발송
JAVA
// 비즈니스 로직은 단위 테스트로 충분하다
class DiscountCalculatorTest {

    private DiscountCalculator calculator = new DiscountCalculator();

    @Test
    void VIP_회원은_10퍼센트_할인을_받는다() {
        // Spring 컨텍스트 없이 순수 자바로 테스트
        Money discounted = calculator.calculate(
            Money.of(10000), MemberGrade.VIP
        );
        assertThat(discounted).isEqualTo(Money.of(9000));
    }
}
JAVA
// 여러 계층의 흐름을 검증할 때만 @SpringBootTest
@SpringBootTest
@Transactional
class OrderFlowIntegrationTest {

    @Autowired private OrderService orderService;
    @Autowired private StockRepository stockRepository;

    @Test
    void 주문_생성_시_재고가_차감된다() {
        // 서비스 → 리포지토리 → DB 전체 흐름 검증
        stockRepository.save(new Stock("상품A", 10));

        orderService.createOrder(new OrderRequest("상품A", 3));

        Stock stock = stockRepository.findByProductName("상품A");
        assertThat(stock.getQuantity()).isEqualTo(7);
    }
}

@SpringBootTest에서 자주 쓰는 옵션들

JAVA
// 특정 설정 클래스만 로드 — 전체보다 빠름
@SpringBootTest(classes = {OrderConfig.class, PaymentConfig.class})
class PartialContextTest { /* ... */ }

// 프로퍼티 오버라이드
@SpringBootTest(properties = {
    "spring.datasource.url=jdbc:h2:mem:testdb",
    "external.api.url=http://localhost:8089"
})
class PropertyOverrideTest { /* ... */ }

// 특정 Auto-Configuration 제외
@SpringBootTest
@EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class)
class WithoutSecurityTest { /* ... */ }

테스트 조합 실무 예시

실제 프로젝트에서 주문 도메인을 테스트한다면 이런 조합이 됩니다:

PLAINTEXT
주문 도메인 테스트 구성:
├── 단위 테스트 (70%)
│   ├── OrderTest.java            — 주문 엔티티 비즈니스 로직
│   ├── DiscountPolicyTest.java   — 할인 정책 계산
│   └── OrderValidatorTest.java   — 주문 유효성 검증

├── 슬라이스 테스트 (20%)
│   ├── OrderControllerTest.java  — @WebMvcTest
│   ├── OrderRepositoryTest.java  — @DataJpaTest
│   └── OrderResponseTest.java    — @JsonTest

└── 통합 테스트 (10%)
    └── OrderFlowTest.java        — @SpringBootTest

정리

  • @SpringBootTest는 전체 컨텍스트를 로드하므로 정말 여러 계층의 통합이 필요한 경우에만 사용합니다
  • 슬라이스 테스트(@WebMvcTest, @DataJpaTest 등)는 ** 특정 계층만 빠르게 테스트 **할 수 있는 방법입니다
  • ApplicationContext 캐시를 잘 활용하려면 ** 프로파일과 MockBean 조합을 통일 **해야 합니다
  • 테스트를 작성하기 전에 "내가 검증하려는 것이 뭔가?" 를 먼저 물어보세요 — 대부분은 @SpringBootTest 없이도 검증할 수 있습니다
댓글 로딩 중...