컨트롤러 슬라이스 테스트를 짜다가 만난 황당한 상황. @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 관련 빈만 스캔"하려고 훑는 범위 자체가 애초에 그 패키지에 도달하지 못한다. 그 결과,
- 대상 컨트롤러 빈이 컨텍스트에 없다 → 요청 URL에 매핑되는
@RequestMapping핸들러가 없다. - Spring MVC는 매핑되지 않은 요청을 정적 리소스 핸들러(
ResourceHttpRequestHandler)로 넘긴다 → 리소스도 없으니 결국 예외가 난다. - 그런데
GlobalExceptionHandler(@ControllerAdvice)도 스캔되지 않아 그 예외를 잡아 우리 공통 오류 포맷으로 바꿔주지 못한다 → 500.
"요청이 ResourceHttpRequestHandler로 빠져 404에서 500으로" 떨어지는 흐름이 정확히 이거였다.
@Import가 이걸 어떻게 우회하는가
Spring 공식 레퍼런스(Core Technologies, "Composing Java-based Configurations")는 @Import를 이렇게 설명한다.
As of Spring Framework 4.2,
@Importalso supports references to regular component classes, analogous to theAnnotationConfigApplicationContext.registermethod. 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가 "같은 목인데 완전히 다른 세계"인 이유를 뜯어본다.
'Testing' 카테고리의 다른 글
| 테스트 코드 뜯어보기 - 조연들을 한자리에 (0) | 2026.07.07 |
|---|---|
| 테스트 코드 뜯어보기 - @ParameterizedTest는 for문의 문법 설탕이 아니다 (1) | 2026.07.07 |
| 테스트 코드 뜯어보기 - MockMvc는 서블릿 컨테이너를 흉내 내는가 (0) | 2026.07.05 |
| 테스트 코드 뜯어보기 - @Mock, @MockBean, @InjectMocks는 같은 목이 아니다 (0) | 2026.07.05 |
| 테스트 코드 뜯어보기 - LocalDateTime.now()는 왜 테스트를 괴롭히는가 (3) | 2026.07.05 |