본문 바로가기
Testing

테스트 코드 뜯어보기 - @WebMvcTest만 붙였더니 500이 났다

by Ramos 2026. 7. 5.

컨트롤러 슬라이스 테스트를 짜다가 만난 황당한 상황. @WebMvcTest를 정석대로 붙였는데 404도 아니고 500이 돌아왔다. 범인은 우리 앱의 scanBasePackages 설정이었고, 해결책은 @Import였다. 이게 왜 우리 레포에선 사실상 필수가 되는지 백그라운드까지 파봤다.

Intro

  • @WebMvcTest는 전체 컨텍스트 대신 MVC 계층만 가볍게 띄우는 테스트 슬라이스다.
  • 슬라이스는 명시적 설정이 없으면 위로 올라가며 @SpringBootApplication 클래스를 찾아 "주 설정"으로 재사용한다.
  • 우리 앱은 @SpringBootApplication(scanBasePackages = "com.acme.app.config")로 스캔 범위를 config 패키지로 좁혀놨다.
  • 그래서 슬라이스의 컴포넌트 스캔도 config에 갇히고, 실제 컨트롤러(console.domain.**)까지 도달하지 못한다 → 컨트롤러 빈 없음 → 요청이 리소스 핸들러로 빠짐 → 500.
  • @Import({Controller.class, GlobalExceptionHandler.class})는 컴포넌트 스캔을 우회해 클래스를 직접 등록한다. 이게 우리 레포 컨트롤러 슬라이스의 표준.

무엇을 하려던 거였나

@WebMvcTest는 Spring Boot가 제공하는 "테스트 슬라이스(test slice)" 애노테이션이다. 전체 ApplicationContext를 띄우는 대신 MVC 계층(컨트롤러, 컨버터, 필터, @ControllerAdvice 등)만 골라서 가볍게 올린다. 컨트롤러 하나를 빠르게 검증하려고 붙였는데, 요청을 날려보니 대상 컨트롤러가 404도 아니고 500으로 응답했다. 뭔가 근본적으로 어긋나 있었다.

@WebMvcTest가 스캔하는 범위 (공식 동작)

Spring Boot 공식 문서에 따르면 @WebMvcTest는 스캔 대상을 이렇게 제한한다.

  • @Controller, @ControllerAdvice, @JsonComponent, Converter, GenericConverter, Filter, HandlerInterceptor, WebMvcConfigurer, HandlerMethodArgumentResolver 등 MVC 관련 빈만.
  • 일반 @Component, @ConfigurationProperties 빈은 스캔하지 않는다.

그리고 핵심은 이거다. @WebMvcTest는 다른 @*Test 슬라이스와 마찬가지로, 명시적으로 설정 클래스를 지정하지 않으면 테스트 클래스가 속한 패키지에서 위로 올라가며 @SpringBootApplication(또는 @SpringBootConfiguration)이 붙은 클래스를 찾아 그것을 "주 설정(primary configuration)"으로 사용한다.

참고: https://docs.spring.io/spring-boot/reference/testing/spring-boot-applications.html

 

Testing Spring Boot Applications :: Spring Boot

To test whether Spring MVC controllers are working as expected, use the @WebMvcTest annotation from the spring-boot-webmvc-test module. @WebMvcTest auto-configures the Spring MVC infrastructure and limits scanned beans to @Controller, @ControllerAdvice, @J

docs.spring.io

우리 레포에서 정확히 무엇이 깨졌나

우리 앱의 진입점은 스캔 범위를 명시적으로 좁혀놨다.

// ApiApplication.java
@SpringBootApplication(scanBasePackages = PackageName.APPLICATION_PACKAGE_SCAN_CONFIG)

// PackageName.java
public static final String APPLICATION_PACKAGE_SCAN_CONFIG = "com.acme.app.config";

@SpringBootApplication은 내부적으로 @ComponentScan(basePackages = ...)을 포함하는 메타 애노테이션이다. @WebMvcTest가 슬라이스 컨텍스트를 구성할 때 "주 설정 클래스"로 이 ApiApplication을 찾아 재사용하는데, 그 클래스가 이미 scanBasePackages = "com.acme.app.config"로 스캔을 제한해놨기 때문에, 슬라이스 테스트의 컴포넌트 스캔도 config 패키지로 한정된다.

문제는 컨트롤러들이 실제로는 com.acme.app.console.domain.** 같은 하위 패키지에 산다는 점이다. @WebMvcTest가 "MVC 관련 빈만 스캔"하려고 훑는 범위 자체가 애초에 그 패키지에 도달하지 못한다. 그 결과,

  1. 대상 컨트롤러 빈이 컨텍스트에 없다 → 요청 URL에 매핑되는 @RequestMapping 핸들러가 없다.
  2. Spring MVC는 매핑되지 않은 요청을 정적 리소스 핸들러(ResourceHttpRequestHandler)로 넘긴다 → 리소스도 없으니 결국 예외가 난다.
  3. 그런데 GlobalExceptionHandler(@ControllerAdvice)도 스캔되지 않아 그 예외를 잡아 우리 공통 오류 포맷으로 바꿔주지 못한다 → 500.

"요청이 ResourceHttpRequestHandler로 빠져 404에서 500으로" 떨어지는 흐름이 정확히 이거였다.

@Import가 이걸 어떻게 우회하는가

Spring 공식 레퍼런스(Core Technologies, "Composing Java-based Configurations")는 @Import를 이렇게 설명한다.

As of Spring Framework 4.2, @Import also supports references to regular component classes, analogous to the AnnotationConfigApplicationContext.register method. This is particularly useful if you want to avoid component scanning, by using a few configuration classes as entry points to explicitly define all your components.

@Import컴포넌트 스캔(패키지 경로 기반 탐색)을 아예 우회하고, 지정한 클래스를 컨텍스트에 직접, 명시적으로 등록한다. @Configuration 클래스뿐 아니라 일반 @Controller, @ControllerAdvice 클래스도 이 방식으로 등록할 수 있다(Spring Framework 4.2+).

@WebMvcTest
@AutoConfigureMockMvc(addFilters = false)
@Import({UserConsoleController.class, GlobalExceptionHandler.class})
@ExtendWith(UserConsoleContextExtension.class)
class UserConsoleControllerTest {

@Import({UserConsoleController.class, GlobalExceptionHandler.class})는 "스캔해서 찾아라"가 아니라 "이 두 클래스는 무조건 빈으로 등록해라"는 명령이다. 컴포넌트 스캔 경로가 어디로 제한돼 있든 상관없이, @Import로 지정된 클래스는 항상 등록된다. 스캔 경계에 갇혀 있던 컨트롤러와 어드바이스를 손으로 끌어올리는 셈이다.

컴포넌트 스캔 vs @Import

구분 컴포넌트 스캔 @Import
발견 방식 패키지 경로를 순회하며 @Component 계열을 찾음 (암묵적) 지정한 클래스를 그대로 등록 (명시적)
트리거 @ComponentScan(@SpringBootApplication에 내장) @Import 애노테이션
패키지 제약 scanBasePackages로 제한되면 그 밖은 못 찾음 패키지 무관, 아무 클래스나 지정 가능
우리 레포에서의 함의 config 패키지 밖은 자동으로는 못 찾음 하위 컨트롤러/어드바이스를 강제로 등록 가능

Mermaid로 보는 전체 흐름

흔한 함정

  • GlobalExceptionHandler를 빼먹는 실수. 컨트롤러만 @Import하고 어드바이스를 빼면, 컨트롤러 자체는 로딩되지만 예외가 발생했을 때 우리 공통 JSON 오류 포맷(header.isSuccessful=false, header.resultCode)으로 변환되지 않고 Spring MVC 기본 오류 응답이 나가서 테스트가 의도와 다르게 실패한다.
  • AOP 애스펙트 걱정. GlobalExceptionHandler에 커스텀 감사(audit) 애노테이션이 붙어 있어도, 그 애스펙트 빈이 슬라이스에 없으면 무해하다. Spring AOP는 애스펙트 빈이 없으면 프록시를 만들지 않고 그냥 원본 메서드를 실행할 뿐이다.
  • "@WebMvcTest(controllers = X.class) 문법도 되지 않냐"는 오해. 이 필터도 결국 내부적으로 컴포넌트 스캔에 기반한 타입 필터링이라, 애초에 스캔 범위 밖이면 필터링할 대상 자체가 없다. 그래서 우리 레포는 @WebMvcTest(대상 미지정) + @Import(명시적 등록) 조합을 표준으로 쓴다.

언제 쓰고 언제 안 쓰나

  • 우리 레포의 모든 Controller 슬라이스 테스트: 사실상 필수. scanBasePackages가 좁혀져 있는 한 @Import 없이는 슬라이스 테스트 자체가 성립하지 않는다.
  • scanBasePackages 제약이 없는 일반적인 Spring Boot 프로젝트: @WebMvcTest(controllers = X.class)만으로 충분한 경우가 많다.

여기서 얻은 교훈 하나. @Import가 강제되는 건 우리 레포 고유의 구조적 특징이지 Spring Boot의 일반 관행이 아니다. 다른 프로젝트에서 본 슬라이스 테스트 코드를 그대로 가져다 쓰면 안 되는 이유이기도 하다. "왜 이 애노테이션이 여기 있는가"를 모른 채 복붙하면, 우리 환경에선 500이 나고 남의 환경에선 불필요한 잉여가 된다.

다음 글에서는 이 슬라이스 테스트에 등장한 @MockBean과, 서비스 테스트의 @Mock/@InjectMocks가 "같은 목인데 완전히 다른 세계"인 이유를 뜯어본다.