백엔드/SpringBoot

[SpringBoot] 통합 테스트(Integeration Test) 알아보기

연유뿌린빙수 2025. 11. 10. 09:38

통합테스트란?

애플리케이션의 여러 모듈(레이어)를 실제 환경에 가깝게 "통합" 한 상태에서 동작을 검증하는 테스트이다.

Unit Test가 "메서드 하나" 또는 "클래스 하나"에 집중했다면,
통합테스트는 Controller → Service → Repository → Database와 같은 여러 레이어 간의 협력과 데이터 흐름과 애플리케이션의 조합을 테스트하는 것을 의미한다.

즉, 단위테스트가 최소한의 기능 조각들을 검증하는 작은 단위들에 대한 것이라면,
통합테스트란 여러 컴포넌트가 함께 동작할 때 문제없음을 검증하는 것이다.

 

 

과거에는?

과거에는 통합테스트라고 하면
"시스템 전체를 한 번에 돌려보는 형태"에 가까웠다.

  • SQL 스크립트를 직접 수동으로 돌리고 화면에서 클릭해보며 테스트
  • 화면을 클릭하면서 테스트해보기
  • 테스트 "자동화"가 미비했고 "환경을 완전히 구성"해야만 테스트가 가능

즉, 모든걸 실제처럼 돌려보려고하다보니 비용이나 복잡도 등이 수반되며 느렸다



현대의 통합 테스트

현대의 통합 테스틑 다음처럼 정교하게 분리된 계층을 실제 환경처럼 구성해 테스트한다.

  • TestContainers 등으로 실제 DB 환경을 재현 가능
testImplementation 'org.testcontainers:testcontainers:1.19.0'
testImplementation 'org.testcontainers:junit-jupiter:1.19.0'
  • 프로파일 기반 테스트 환경 분리
@SpringBootTest
@ActiveProfiles("test")
class OrderServiceIntegrationTest {
    // 테스트 코드
}
  • MockMVC를 통해 실제 HTTP 요청처럼 테스트가 가능(실제 HTTP 요청/응답 흐름을 서블릿 컨테이너 없이 검증이 가능하다)
  • 스프링 컨텍스트를 로드해 전체 Bean 조합 및 의존성을 검증
@SpringBootTest
@ActiveProfiles("test")
class MemberIntegrationTest {
	// 생략
}

즉, “실제로 돌아가는 환경에서 검증하면서도, 자동화로 반복 가능하게 만들자!”



통합 테스트가 왜 좋아? 왜 필요해?

단위 테스트는 “로직”을 보장하지만, 통합 테스트는 “실제 동작”을 보장한다.

만약 통합 테스트가 없이, Unit 테스트만 존재할 때의 문제점에 대하여 알아보자.

 

  1. 레이어 간의 계약(Contract) 불일치 문제
    단위 테스트 안에서는 각각 따로따로 정상 동작하지만, 통합 테스트에서는 어긋나는 경우에 대하여 발견할 수 있다.
    예를 들어 레이어 간 연결 문제 조기 발견(Controller ↔ Service, Service ↔ Repository 등)이 가능하다.
// Service에서는 Member 객체를 반환하는데
public Member findMember(Long id) { ... }

// Controller에서는 MemberDTO를 기대하는 경우
public ResponseEntity<MemberDTO> getUser(@PathVariable Long id) {
    Member member = memberService.findMember(id);
    // 타입 불일치 문제 발생!
}

 

 

 

2. JPA 엔티티 매핑 오류, JPQL 오류

@Entity
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    // 잘못된 매핑: 컬럼명 오타
    @Column(name = "user_id")  // 실제 DB 컬럼은 "customer_id"
    private Long userId;
}

위와 같이 Orderd에 대한 JPA의 매핑을 단위 테스트로는 문제를 발견하지 못할 수 있으나, 실제 DB 쿼리 시에는 오류가 발생한다.

 

 

3. 트랜잭션 전파 문제

@Transactional(propagation = Propagation.REQUIRES_NEW)

위에 대하여 별도의 트랜잭션으로 전체 서비스가 제대로 작동하는지에 대한 확인 및 흐름 통합 테스트를 통해 레이어 단에서 확인 가능

 

 

4. Spring Security 인증/인가
단위 테스트에서는 Security Context가 없어서 검증이 불가능하지만,
통합 테스트에서는 가능하다.

 

 

5. 그 외의 조기 버그 발견 및 리팩토링 안전성

 

그 외에도, 통합 테스트는 배포 전의 실제 시나리오에서의 문제를 탐지할 수 있다.
그리고 코드 변경 시 전체 흐름의 정상 동작이 보장된다. 리팩토링 시 자동화를 해보면서 이에 대하여 정상 동작을 확보할 수 있다.
또한 Bean의 구성이나 AOP 등의 동작이 올바른지에 대하여 확인할 수 있다.

또한 이를 기반으로 문서화를 가능하다. 실제 API 사용 예제로도 가능하다.

 

 

결론적으로,
통합 테스트는 배포 전 가장 확실한 안전망(Safety net) 이다.



통합 테스트를 잘 하려면?

  1. 테스트 환경을 실제와 가깝게 구성하기

@SpringBootTest 전체로 로드한 것이 잘 돌아가도록 구성해줘야한다.
TestContainers로 실제 MySQL/PostgreSQL 재현한다든가, 테스트 전용의 DB를 별도로 사용해준다. 또한 ActiveProfiles에 대한 설정을 통하여 테스트에 대한 환경을 구성해야한다.

MockMvc / RestAssured 활용, Spring Security와 Filter, Intercepptor 등의 내용에 대하여도 포함하면서 테스트 환경을 구축한다.

 

2. 애플리케이션 레이어의 자연스러운 동작을 검증하기

 

HTTP 요청/응답 흐름
Spring Filter, Interceptor, AOP, Security
트랜잭션 전파 및 롤백

 

3. 하지만 불필요한 구성은 최소화하기

 

서비스 테스트 할 때 Controller까지 로드 하지 않음
웹 계층만 테스트할 때 DB까지 로드 하지 않음

 

즉,
“필요한 만큼만 통합하고 나머지는 생략한다”
이 원칙을 지키면 빠르고 깔끔한 테스트가 가능하다.



Spring에서의 통합 테스트 도구

 

 

1. @SpringBootTest

모든 Bean을 로드하는 가장 포괄적인 테스트 방식 스프링 컨텍스트를 전부 로드하는 가장 강력한 통합 테스트 방식이다.

실제 서비스와 거의 동일한 환경의 컨텍스트이며, SpringBootTestApplication 실행을 통하여 실제 애플리케이션 구동처럼 DB 연결, 설정 파일 등 전체에 대한 Bean을 로드하여 테스트해볼 수 있다.
(Configuration, Bean, AOP, Filter등에 대하여 적용)

또한 RestTemplate/MockMvc를 통한 실제 HTTP 환경에서의 요청을 해봄으로써 요청/응답에 대한 처리가 가능하다.

테스트 속도는 가장 느리지만, 가장 신뢰도가 높다.

예시

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class MemberIntegrationTest {

    @Autowired
    TestRestTemplate rest;

    @Test
    void 회원가입_요청_성공() {
        var req = new SignUpRequest("test","1234","test@test.com");

        var response = mockMvc.postForEntity("/api/users", req, String.class);

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

 

추가로 작성을해보자면,
환경설정에 대하여 다음과같이 정리할 수 있다.

// 1. MOCK (기본값): MockMvc 사용, 실제 서버 없음
@SpringBootTest(webEnvironment = WebEnvironment.MOCK)
// 2. RANDOM_PORT: 랜덤 포트로 실제 서버 시작 (TestRestTemplate 사용)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
// 3. DEFINED_PORT: application.yml의 포트 사용
@SpringBootTest(webEnvironment = WebEnvironment.DEFINED_PORT)
// 4. NONE: 웹 환경 없이 ApplicationContext만 로드
@SpringBootTest(webEnvironment = WebEnvironment.NONE)

 

2. @WebMvcTest (컨트롤러 차원에서의 통합 테스트)

WebMvcTest는 Controller 계층만 로드하고 Service/Repository는 로드하지 않는다.

Controller의 RequestMapping/Validation/ExceptionHandler 등을 검증할 때 사용하며, 웹 계층만 슬라이스 테스트하는 가장 효율적인 방법이다.

 

SpringBootTest에 대해서는 전반적인 너무 많은 Bean이 요구되기 때문에 WebMvcTest를 통한 테스트 차원에 대하여 문서화 작업을 더 많이 자주한다.

@WebMvcTest 를 사용한다면 로드되는 컴포넌트와 로드되지 않는 컴포넌트에 대하여 정리할 수 있다.

  • 로드되는 컴포넌트:
    • @Controller, @RestController
    • @ControllerAdvice, @RestControllerAdvice
    • @JsonComponent : json 형식 응답에 대한 직렬화 및 역직렬화 처리
    • Filter, WebMvcConfigurer
    • Spring Security 설정
  • 로드되지 않는 컴포넌트:
    • @Service, @Repository : Controller 차원이 아니라면 로드되지 않음
    • @Component, @Configuration (웹 관련 제외)

웹 계층(컨트롤러)의 동작을 검증하는 데에 최적화되어있으며,
모든 Bean을 다 로드하여 속도를 느리게하는 것보다 더 테스트 속도가 빠르게 유지되기 때문이다.

필요한 서비스만 @MockBean으로 주입해주면 되고, 굳이 필요하지 않은 것은 로드하지 않아도 된다.

class MemberControllerTest {
	
    @MockBean
    MemberService memberService;
    
    // 생략...
}

위와 같이 MemberService를 호출해야하는 경우 MockBean으로 객체를 생성해 불러주면 된다.

보다 더 구체적인 예시로는 다음과 같다. HTTP 요청을 MockMvc로 테스트한 코드이다.

@WebMvcTest(UserController.class)
class MemberControllerTest {

    @Autowired
    MockMvc mvc;

    @MockBean
    MemberService memberService;

    @Test
    void 회원조회_성공() throws Exception {
        when(memberService.findUser(1L))
            .thenReturn(new UserResponse("test"));

        mvc.perform(get("/users/1"))
           .andExpect(status().isOk())
           .andExpect(jsonPath("$.name").value("test"));
    }
}

하나의 Controller에 대하여 테스트하자면 위와 같이 설정할 수 있다. 이 때 필요한 주입들에 대해서는 MockBean으로 주입을 해준다. 그리고 실제 HTTP 요청/응답을 Mock을 통하여 구현할 수 있으며, 실제 응답과 동일한 양식으로 구성됨을 확인할 수 있다.

이에 대하여 바로 테스트를하려고하면 분명 시큐리티 부분에서 오류가 날 수 있다.
그럼 필터를 제외하거나, 혹은 커스텀 시큐리티 설정 테스트에 대하여 생성하는 것도 하나의 방법이다.

 

 

 

 

3. @DataJpaTest (JPA 통합 테스트)

Repository, Entity 매핑, JPA 쿼리를 검증할 때 사용하는 테스트로, 영속성 레이어 테스트를 한다.

그래서 JPA 관련 Bean들에 대해서만 로드하기 때문에 기본적으로 빠르다.(Repository/Entity에 대해서만 알아서 로드해줌)
기본적으로 테스트 관련해서는 H2 내장 DB 사용하며, Hibernate 설정도 실제처럼 동작한다.

 

또한 DataJpaTest를 통해 생성된 @Transactional에 대하여 각 테스트마다 트랜잭션을 롤백해주기 때문에 격리가 보장되어있으며, TestEntityManager를 제공해준다.

@DataJpaTest
class MemberRepositoryTest {

    @Autowired
    MemberRepository memberRepository;

    @Test
    void 회원_저장_조회() {
        User saved = repo.save(new User("test", 20));

        User found = memberRepository.findById(saved.getId()).get();

        assertThat(found.getName()).isEqualTo("test");
    }
}

위와 같이 Repository 테스트를 위하여 @DataJpaTest 빈을 등록을하고,
Repository를 Autowired를 통하여 등록하고
이에 대하여 메서드를 테스트하면 된다.



통합테스트는 그럼 어떻게?

다시 정리해보자면,
메서드/클래스 하나에 대하여 검증하는 단위테스트와 다르게
통합 테스트는 여러 모듈이 함께 동작하는지를 검증한다.

이를 자동화함으로써 애플리케이션 전체에 대한 구동을 상시로 할 수 있으며, 리팩토링에 대해서도 안정성을 보장할 수 있다.

 

완전한 통합 테스트를 구현하기는 어렵다.

@SprintBootTest를 사용하는 방식이 그나마 전체 애플리케이션을 아우르는 통합 테스트에 가장 가까운 구현이다.
반면, 컨트롤러의 테스틀 위하여 @WebMvcTest를 적용하는 것은 실질적으로 컨트롤러 단위에 한정된 테스트이므로, 통합 테스트라기보다는 컨트롤러에 대한 유닛 테스트 범주에 가깝다.

그래서 다음과 같이 단계를 나눠서 진행한다.

우선은 슬라이스 테스트 (부분 통합)만 만족하는 테스트를 작성한다.

  • Controller 로직만 검증: @WebMvcTest
  • Repository 쿼리만 검증: @DataJpaTest
    빠른 실행 속도와 레이어 분리가 제대로 되어있기 때문에 좋다. 그러나 이것이 완전한 통합 테스트인 것은 아니다.
    레이어 간의 연결 검증이 불가능하기 때문이다.


그다음, 완전한 통합 테스트를 하도록 한다. 실제 운영 환경과 유사하게 전체 플로우를 검증한다. 기본 구성으로 `@SpringBootTest` + `@AutoConfigureMockMvc` 를 사용해야한다.

SprintBootTest를 사용할 때 MockMvc는 의존성을 주입하지 않기 때문에
@AutoConfigureMockMvc를 같이 사용해야한다.

@SpringBootTest
@AutoConfigureMockMvc  // MockMvc 자동 구성
@Transactional  // 각 테스트 후 롤백
class CompleteIntegrationTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @Autowired
    private UserRepository userRepository;
    
    @Autowired
    private PasswordEncoder passwordEncoder;
    
    @Test
    @DisplayName("회원가입 → 로그인 → 프로필 조회 플로우")
    void fullUserJourneyTest() throws Exception {
        // 1. 회원가입
        String signUpRequest = """
            {
                "username": "newuser",
                "password": "password123!",
                "email": "new@example.com"
            }
            """;
        
        MvcResult signUpResult = mockMvc.perform(post("/api/auth/signup")
                .contentType(MediaType.APPLICATION_JSON)
                .content(signUpRequest))
            .andExpect(status().isCreated())
            .andReturn();
        
        // 2. 로그인하여 토큰 획득
        String loginRequest = """
            {
                "username": "newuser",
                "password": "password123!"
            }
            """;
        
        MvcResult loginResult = mockMvc.perform(post("/api/auth/login")
                .contentType(MediaType.APPLICATION_JSON)
                .content(loginRequest))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.accessToken").exists())
            .andReturn();
        
        String token = JsonPath.read(loginResult.getResponse().getContentAsString(), "$.accessToken");
        
        // 3. 인증된 상태로 프로필 조회
        mockMvc.perform(get("/api/users/me")
                .header("Authorization", "Bearer " + token))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.username").value("newuser"))
            .andExpect(jsonPath("$.email").value("new@example.com"));
        
        // 4. DB 검증
        User savedUser = userRepository.findByUsername("newuser").orElseThrow();
        assertThat(passwordEncoder.matches("password123!", savedUser.getPassword())).isTrue();
    }
    

    @Test
    @Transactional  // 테스트 후 자동 롤백
    @DisplayName("주문 생성 시 재고 감소 트랜잭션 테스트")
    void orderCreation_WithStockDeduction() throws Exception {
        // Given
        Long productId = 1L;
        int initialStock = 10;
        
        // When
        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content(String.format("""
                    {
                        "productId": %d,
                        "quantity": 3
                    }
                    """, productId)))
            .andExpect(status().isCreated());
        
        // Then
        // 트랜잭션 내에서 변경사항 즉시 확인 가능
        // 테스트 종료 후 자동 롤백됨
    }
}

위와 같이 전반적인 통합 테스트를 위한 코드를 따로 작성하여 구현할 수 있다.



그럼 언제 어떤 테스트를 사용할까?

  • 서비스 로직만 검증하고 싶다 → Mockito 단위 테스트
  • HTTP 요청/응답, Validation 확인 → @WebMvcTest
  • JPA 쿼리/엔티티 검증 → @DataJpaTest
  • 운영과 동일한 환경 전체 검증 → @SpringBootTest (통합 테스트)

위와 같이 상황에 따라 정리할 수 있다.

 

이번에는 UnitTest와는 다른, 통합 테스트(Integration Test)에 대하여 알아보았다.


이제 다음에는 WebMVC 테스트를 기반으로 자동화된 API를 문서 스니펫을 생성하는 방법에 대하여 알아보도록 하자!