@RequestMapping만 붙인 타입은 Spring 6부터 컨트롤러가 아니다

2021년 강의의 v1·v2 컨트롤러를 Spring Boot 4.1로 옮기면 서버는 정상으로 뜨는데 요청마다 404를 돌려준다. v1은 인터페이스와 구현 클래스로, v2는 인터페이스 없는 클래스로 만들었고, 둘 다 @Controller 대신 @RequestMapping만 붙어 있다. 컴포넌트 스캔을 피하고 설정 클래스에서 직접 빈으로 등록하려는 선택이다. 등록을 설정 클래스가 쥐고 있어야 뒤에서 실제 객체 대신 프록시(실제 객체와 같은 타입으로 호출을 대신 받아 앞뒤에 기능을 붙이는 객체)를 빈으로 바꿔 끼울 수 있다

Spring Framework 6.0부터는 타입에 붙은 @RequestMapping만으로는 컨트롤러로 인식하지 않는다. Boot 4.1에서 강의 코드를 돌릴 때는 v1 인터페이스와 v2 클래스에 @Controller를 붙이고 앱의 스캔 범위를 @RestController로 만든 v3 패키지로 좁히는 쪽이 짧다. 스캔 범위를 그대로 두면 빈 이름이 어긋날 때나 v1만 등록하는 설정을 불러올 때 서버가 뜨지 않는데, 어느 쪽이든 시작 실패로 바로 드러난다. 컨트롤러 코드를 강의 그대로 두려면 컨트롤러로 인정하는 조건을 넓히는 길도 있지만, 그 설정 빈을 @Primary 없이 둘 두면 경고 없이 404가 난다. 인터페이스의 @RequestParam에 이름을 적으라는 강의의 조언은 지금도 유효하고, 이유는 바뀌었다

강의 모양 그대로면 서버는 뜨지만 경고 없이 404를 돌려준다

v1은 매핑을 인터페이스에 둔다

@RequestMapping
@ResponseBody
public interface OrderControllerV1 {

    @GetMapping("/proxy/v1/request")
    String request(@RequestParam("itemId") String itemId);

    @GetMapping("/proxy/v1/no-log")
    String noLog();
}

구현 클래스 OrderControllerV1Impl에는 애노테이션이 없고, 설정 클래스가 @Bean으로 등록한다. v2도 클래스에 @RequestMapping과 @ResponseBody만 두고 설정 클래스가 등록한다. 강의는 @Controller와 @RequestMapping 중 하나만 있으면 스프링이 컨트롤러로 인식한다고 설명한다. @Controller에는 @Component가 들어 있어 컴포넌트 스캔 대상이 되므로, 직접 등록할 컨트롤러에는 스캔과 관계없는 @RequestMapping을 붙였다는 것이다

스프링 MVC는 등록된 빈마다 RequestMappingHandlerMapping.isHandler()로 컨트롤러인지 묻고, 그렇다고 답한 빈에서만 @GetMapping 같은 매핑을 찾는다. 지금 이 메서드는 타입 수준의 @Controller가 있는 빈을 컨트롤러로 본다(RequestMappingHandlerMapping — Spring Framework 7 API). Spring Framework 6.0 릴리스 노트는 타입 수준 @RequestMapping만으로 컨트롤러를 찾던 동작을 없앴다고 적었다(Spring Framework 6.0 Release Notes). 강의가 나온 2021년의 스프링에서는 둘 중 하나면 충분했지만, 6.0부터는 @Controller가 있어야 한다

v1·v2·v3 컨트롤러를 모두 등록한 강의 모양 그대로 띄워, v1·v2와 대조군으로 @RestController를 붙인 v3를 호출한 결과다. 각 줄은 실험 조건 이름, 호출한 경로, 상태 코드, 응답 본문 앞부분이다

lecture-shape GET /proxy/v1/request?itemId=hello -> 404 {"timestamp":"2026-11-08T21:55:04.303Z","status":404,"error"
lecture-shape GET /proxy/v2/request?itemId=hello -> 404 {"timestamp":"2026-11-08T21:55:04.354Z","status":404,"error"
lecture-shape GET /proxy/v3/request?itemId=hello -> 200 ok

v1·v2는 404이고 @RestController인 v3만 200이다. 서버는 정상으로 떴고 시작 로그에 경고도 없어서, 기본 로그만으로는 요청을 보내 보기 전에 컨트롤러가 빠진 것을 알기 어렵다

@Controller를 붙이면 인터페이스는 그대로 고쳐지고 클래스는 스캔 범위에서 빼야 한다

인터페이스에 @Controller를 붙이는 것은 수동 등록과 부딪치지 않는다. 컴포넌트 스캔은 인터페이스를 후보에서 빼고(ClassPathScanningCandidateComponentProvider — Spring Framework 7 API), 구현 클래스에는 애노테이션이 없어서 스캔에 걸리지 않는다. 6.0 릴리스 노트는 인터페이스 기반 AOP 프록시를 쓰는 컨트롤러라면 클래스 기반 프록시로 바꾸거나 인터페이스에도 @Controller를 붙이라고 안내한다. isHandler()가 구현 객체의 인터페이스에서도 @Controller를 찾는다는 뜻으로 읽히고, 아래 실행에서 v1이 200인 것도 이와 맞는다

v2는 인터페이스가 없어 클래스에 @Controller를 붙여야 하고, 그러면 그 클래스가 스캔 대상이 되어 설정 클래스가 @Bean으로 등록한 컨트롤러와 겹친다. 같은 강의 모양에서 v1 인터페이스와 v2 클래스에 @Controller를 붙여 두 번 띄웠다. 처음은 그대로 두어 스캔이 붙이는 빈 이름 orderControllerV2(클래스 이름의 첫 글자를 소문자로 바꾼 이름)와 설정 클래스의 @Bean 메서드 이름이 같고, 두 번째는 @Bean 메서드 이름만 orderControllerV2Manual로 바꿨다

controller-same-name GET /proxy/v1/request?itemId=hello -> 200 ok
controller-same-name GET /proxy/v2/request?itemId=hello -> 200 ok
controller-other-name STARTUP FAIL

이름이 같으면 v1·v2 모두 200이고, 이름을 바꾸면 서버가 뜨지 않았다. 이름을 바꾼 쪽의 시작 로그에 남은 원인이다

Caused by: java.lang.IllegalStateException: Ambiguous mapping. Cannot map 'orderControllerV2Manual' method 
hello.advanced.app.proxy.v2.OrderControllerV2#noLog()
to {GET [/proxy/v2/no-log]}: There is already 'orderControllerV2' bean method
hello.advanced.app.proxy.v2.OrderControllerV2#noLog() mapped.

orderControllerV2Manual과 orderControllerV2 두 빈이 같은 GET /proxy/v2/no-log 매핑을 가져서 Ambiguous mapping으로 멈췄다. 이름이 같을 때 하나로 합쳐지는 것은 Spring Framework 6.1에 들어간 규칙 덕분이다

Boot는 같은 이름의 빈 정의(어떤 객체를 어떤 이름의 빈으로 만들지 적은 설정)를 나중 것으로 덮어쓰는 일을 기본으로 막지만(spring.main.allow-bean-definition-overriding, 기본값 false, Spring Boot Application Properties), 6.1부터는 스캔한 클래스와 반환 타입이 같은 @Bean 메서드가 같은 이름의 스캔 빈을 대신하는 경우는 막지 않는다(spring-framework#31052). 이 이슈는 @Bean 메서드가 돌려주는 클래스에 스테레오타입(@Component·@Controller처럼 클래스의 역할을 표시하는 애노테이션)을 붙여도 스캔과 충돌하지 않게 하려는 변경이라고 밝히고, 반환 타입이 같으면 의도가 분명해 실수로 덮어쓸 위험이 없다고 본다

프록시를 끼운 설정에서는 v2 클래스가 스캔에 걸린다

(2026년 11월 10일 보완) 처음 발행할 때는 여기서 v2의 @Bean 메서드 이름만 맞추면 된다고 적었고, 뒤에서 프록시를 끼운 상태로는 띄워 보지 않았다. 뒤 편의 실험을 준비하며 AI(Claude)가 그 상태에서 서버가 뜨지 않는 경우를 찾았고, 같은 조건을 내가 다시 띄워 확인했다

v2 클래스에 붙인 @Controller는 스캔 대상을 만들기 때문에 이름 말고도 한 곳에서 더 걸린다. 뒤에서 프록시를 끼우는 두 설정은 한쪽 버전만 빈으로 만든다. 인터페이스 프록시를 끼우는 InterfaceProxyConfig는 v1 컨트롤러만, 구체 클래스 프록시를 끼우는 ConcreteProxyConfig는 v2 컨트롤러만 등록한다. 앱은 v1·v2·v3 컨트롤러가 든 패키지를 통째로 스캔하므로, InterfaceProxyConfig만 불러온 상태에서도 v2 클래스는 스캔에 걸린다. 이 실행의 컨트롤러는 hello.advanced.proxy.app 패키지에 있어, 앞 로그의 hello.advanced.app.proxy와 클래스 이름 앞부분이 다르다

InterfaceProxyConfig에는 v1을 컨트롤러로 잡는 isHandler 확장 빈도 들어 있다. 아래 첫 줄은 손대지 않은 상태, 둘째·셋째 줄은 v1 인터페이스와 v2 클래스에 @Controller를 붙이고 그 빈을 남긴 경우와 뺀 경우, 마지막 줄은 빈을 빼고 인터페이스에만 붙인 경우다

baseline startup=OK GET /proxy/v1/request -> 200
controller-both-registrations-kept STARTUP FAIL | Parameter 0 of constructor in hello.advanced.proxy.app.v2.OrderControllerV2 required a bean of type 'hello.advanced.proxy.app.v2.OrderServiceV2' that could not be found.
controller-both-no-registrations STARTUP FAIL | Parameter 0 of constructor in hello.advanced.proxy.app.v2.OrderControllerV2 required a bean of type 'hello.advanced.proxy.app.v2.OrderServiceV2' that could not be found.
controller-interface-only-no-registrations startup=OK GET /proxy/v1/request -> 200

@Controller를 둘 다 붙인 가운데 두 줄은 서버가 뜨지 않았다. 스캔이 v2 클래스를 빈으로 만들려다 생성자에 넣을 OrderServiceV2 빈을 찾지 못했고, isHandler 확장 빈을 남기든 빼든 같다. 인터페이스에만 붙인 마지막 줄은 그 빈 없이도 200이다

스캔 범위를 @RestController인 v3 패키지로 좁히면 v1·v2 컨트롤러는 설정 클래스가 등록할 때만 빈이 된다

@Import(InterfaceProxyConfig.class)
@SpringBootApplication(scanBasePackages = "hello.advanced.proxy.app.v3")
public class AdvancedApplication {
    // ...
}

v1만 등록하는 InterfaceProxyConfig(줄 이름 v1-config)와 v2만 등록하는 ConcreteProxyConfig(v2-config)를 각각 불러와, v1 인터페이스와 v2 클래스에 @Controller를 붙이고 isHandler 확장 빈을 뺀 채 띄웠다. 가운데 줄만 스캔 범위를 그대로 두었다

v1-config-controller-both-scan-v3 startup=OK v1=200 v2=404 v3=200
v2-config-controller-both startup=OK v1=404 v2=200 v3=200
v2-config-controller-both-scan-v3 startup=OK v1=404 v2=200 v3=200

세 경우 모두 서버가 떴고, 불러온 설정이 등록한 컨트롤러는 200이다. 404는 그 설정이 다른 버전을 등록하지 않아서 나온 값이다. 스캔 범위를 그대로 둔 가운데 줄에서는 ConcreteProxyConfig가 반환 타입이 OrderControllerV2인 @Bean 메서드 orderControllerV2로 등록한 프록시와, 스캔한 v2 클래스가 같은 이름으로 만난다. 그 실행에서 v2에 보낸 요청의 로그다

18:24:08.585 [http-nio-18083-exec-2] ThreadLocalLogTrace : [ec847c9d] OrderControllerV2.request()
18:24:08.585 [http-nio-18083-exec-2] ThreadLocalLogTrace : [ec847c9d] |-->OrderServiceV2.orderItem()
18:24:08.585 [http-nio-18083-exec-2] ThreadLocalLogTrace : [ec847c9d] |    |-->OrderRepositoryV2.request()
18:24:09.590 [http-nio-18083-exec-2] ThreadLocalLogTrace : [ec847c9d] |    |<--OrderRepositoryV2.request(), time=1005ms
18:24:09.591 [http-nio-18083-exec-2] ThreadLocalLogTrace : [ec847c9d] |<--OrderServiceV2.orderItem(), time=1005ms
18:24:09.591 [http-nio-18083-exec-2] ThreadLocalLogTrace : [ec847c9d] OrderControllerV2.request(), time=1006ms

첫 줄과 마지막 줄의 OrderControllerV2.request()는 컨트롤러 프록시만 남기는 추적이라 요청을 받은 쪽은 프록시다. 앞의 이름 규칙대로 프록시가 스캔한 빈을 대신한 것으로 본다

Boot 4.1에서 강의 코드를 돌린다면 이 길이 짧다. v1 인터페이스와 v2 클래스에 @Controller를 붙이고 앱의 스캔 범위를 v3 패키지로 좁히면, InterfaceProxyConfig와 ConcreteProxyConfig 어느 쪽을 불러와도 v1·v2 컨트롤러는 설정 클래스가 등록한 빈으로만 생긴다. 앞에서 v1·v2를 함께 등록한 설정에서 스캔을 좁힌 경우는 띄워 보지 않았다. 스캔 범위를 그대로 둔다면 v2의 @Bean 메서드 이름을 스캔이 붙이는 이름과 맞춰야 하고, InterfaceProxyConfig처럼 v1만 등록하는 설정을 불러오면 서버가 뜨지 않는다

isHandler를 넓히는 길은 설정 빈이 @Primary 없이 둘이면 경고 없이 404를 낸다

컨트롤러 코드에 @Controller를 넣지 않고 강의 그대로 두려면, 컨트롤러로 인정하는 조건 쪽을 넓힌다. Spring Boot의 WebMvcRegistrations 빈으로 RequestMappingHandlerMapping을 바꿔 끼우고, isHandler()가 v1·v2 타입에도 true를 돌려주게 한다

@Bean
public WebMvcRegistrations webMvcRegistrationsV1() {
    return new WebMvcRegistrations() {
        @Override
        public RequestMappingHandlerMapping getRequestMappingHandlerMapping() {
            return new RequestMappingHandlerMapping() {
                @Override
                protected boolean isHandler(Class<?> beanType) {
                    return super.isHandler(beanType) || OrderControllerV1.class.isAssignableFrom(beanType)
                        || OrderControllerV2.class.isAssignableFrom(beanType);
                }
            };
        }
    };
}

isAssignableFrom으로 검사하므로 v1 구현 클래스, 그리고 v1 인터페이스를 구현하거나 v2 클래스를 상속하는 프록시도 컨트롤러로 잡힌다. 걸리는 것은 이 빈이 여럿일 때다. Boot 4.1.1의 자동 구성은 WebMvcRegistrations 빈을 ObjectProvider.getIfUnique()로 꺼내는데(WebMvcAutoConfiguration — Spring Boot 4.1.1 소스), 이 메서드는 후보가 여럿이고 그중 @Primary가 없으면 null을 돌려준다(ObjectProvider — Spring Framework 7 API). 같은 조건의 빈을 v1·v2 설정 클래스에 하나씩 두고 한쪽에 @Primary를 붙인 경우, 그 @Primary만 뺀 경우, 빈을 하나만 두고 @Primary도 없는 경우를 차례로 띄웠다

registrations-two-primary GET /proxy/v1/request?itemId=hello -> 200 ok
registrations-two-primary GET /proxy/v2/request?itemId=hello -> 200 ok
registrations-two-no-primary GET /proxy/v1/request?itemId=hello -> 404 {"timestamp":"2026-11-08T21:55:24.220Z","status":404,"error"
registrations-two-no-primary GET /proxy/v2/request?itemId=hello -> 404 {"timestamp":"2026-11-08T21:55:24.268Z","status":404,"error"
registrations-one GET /proxy/v1/request?itemId=hello -> 200 ok
registrations-one GET /proxy/v2/request?itemId=hello -> 200 ok

둘에 @Primary가 있으면 200, @Primary를 빼면 v1·v2 모두 404, 하나만 두면 @Primary 없이도 200이다. @Primary를 뺀 경우는 하나를 고르지 못해 둘 다 쓰이지 않고 기본 RequestMappingHandlerMapping이 들어간 결과로 보이며, 이때도 시작 로그에 경고가 없다

두 길은 실수했을 때의 모습이 다르다. @Controller 쪽은 빈 이름이 어긋나거나 스캔에 걸린 v2 클래스가 받을 빈이 없으면 서버가 뜨지 않고, isHandler 쪽은 설정 빈이 @Primary 없이 하나 더 생기면 서버는 뜨는데 컨트롤러가 조용히 빠진다. 설정 클래스마다 이 빈을 하나씩 두는 식으로 늘어나기 쉬우니, 이 길을 고른다면 늘 함께 불러오는 설정 한 곳에 하나만 둔다. @Primary로 하나를 고를 수도 있지만 고른 쪽의 조건만 쓰이므로, 다른 빈에만 넣은 컨트롤러는 빠진다

@RequestParam 이름은 -parameters 없이 빌드하면 인터페이스든 클래스든 필요하다

인터페이스의 @RequestParam에는 이름을 꼭 적으라는 것이 강의의 조언이다. 이름을 빼면 인식하지 못할 때가 있고, 클래스에서는 크게 문제가 없다는 이유를 든다. 이름을 적어 두는 편이 지금도 안전하지만 이유가 바뀌었다

Spring Framework 6.1은 바이트코드의 지역 변수 표에서 파라미터 이름을 읽던 LocalVariableTableParameterNameDiscoverer를 없앴고, 이제 컴파일러의 -parameters 옵션이 클래스 파일에 남긴 이름에 기댄다(Spring Framework 6.1 Release Notes — Parameter Name Retention). 지역 변수 표는 메서드 본문이 있어야 생기므로, -parameters 없이 컴파일하면 인터페이스는 이름을 못 읽을 때가 있다던 강의 시절의 차이는 여기서 온 것으로 보인다. Spring Boot Gradle 플러그인은 java 플러그인과 함께 쓰면 모든 JavaCompile 작업에 -parameters를 넣는다(Spring Boot Gradle Plugin — Reacting to the Java Plugin)

v1 인터페이스의 @RequestParam("itemId")에서 이름을 지우고, Boot 기본 빌드와 -parameters만 뺀 빌드에서 각각 v1과 v3를 호출했다. v3는 클래스이고 request(String itemId)에 애노테이션이 없다. MethodParameters 속성 수는 컴파일된 v1 인터페이스에 파라미터 이름 정보(MethodParameters 속성)가 몇 개 붙었는지 javap로 센 값이다. 파라미터를 받는 메서드가 request() 하나라 1이면 이름이 남은 것이다

name-removed-parameters-on MethodParameters 속성 수: 1
name-removed-parameters-on GET /proxy/v1/request?itemId=hello -> 200 ok
name-removed-parameters-on GET /proxy/v3/request?itemId=hello -> 200 ok
name-removed-parameters-off MethodParameters 속성 수: 0
name-removed-parameters-off GET /proxy/v1/request?itemId=hello -> 500 {"timestamp":"2026-11-08T21:55:40.039Z","status":500,"error"
name-removed-parameters-off GET /proxy/v3/request?itemId=hello -> 500 {"timestamp":"2026-11-08T21:55:40.100Z","status":500,"error"

기본 빌드에서는 이름을 지워도 v1·v3 모두 200이다. -parameters를 빼면 인터페이스에 이름이 남지 않고(0) v1뿐 아니라 클래스인 v3도 500이다. 그 빌드의 서버 로그에 남은 원인이고, 끝에 -parameters 플래그를 쓰라는 안내가 붙는다

java.lang.IllegalArgumentException: Name for argument of type [java.lang.String] not specified, and parameter name information not available via reflection. Ensure that the compiler uses the '-parameters' flag.

6.1부터는 인터페이스와 클래스의 차이가 사라지고 빌드 옵션 하나에 달렸다(Spring Framework 6.1 Release Notes). 이름을 그대로 둔 v1을 -parameters 없이 빌드한 결과는 다음과 같다

name-kept-parameters-off MethodParameters 속성 수: 0
name-kept-parameters-off GET /proxy/v1/request?itemId=hello -> 200 ok
name-kept-parameters-off GET /proxy/v3/request?itemId=hello -> 500 {"timestamp":"2026-11-08T21:55:44.753Z","status":500,"error"

파라미터 이름이 클래스 파일에 남지 않았는데도(0) 이름을 적어 둔 v1은 200이고, 이름이 없는 v3만 500이다. Boot Gradle 플러그인으로 빌드하면 이름을 생략해도 동작하지만, 같은 인터페이스를 -parameters 없이 컴파일하는 모듈에서 쓰면 500이 난다. 인터페이스에 @RequestParam("itemId")처럼 이름을 적어 두면 빌드 옵션과 상관없이 동작한다

인터페이스가 없는 프록시는 상속으로 만들어 부모 생성자와 final에 묶인다

등록을 설정 클래스가 쥐고 있으면 실제 객체 대신 프록시를 빈으로 돌려줄 수 있다. 그러면 스프링 MVC가 요청을 넘기는 컨트롤러 빈 자체가 프록시가 된다

@Bean
public OrderControllerV1 orderController(LogTrace logTrace) {
    OrderControllerV1Impl orderControllerImpl = new OrderControllerV1Impl(orderService(logTrace));
    return new OrderControllerInterfaceProxy(orderControllerImpl, logTrace);
}

OrderControllerInterfaceProxy는 실제 객체와 같은 OrderControllerV1 인터페이스를 구현해 호출을 대신 받고, 실제 객체(target) 호출 앞뒤에 로그 추적을 붙인다. 모양이 같은 프록시라도 캐시가 있으면 실제 객체를 부르지 않는 식으로 접근을 제어하려고 끼우면 프록시 패턴, 기능을 덧붙이려고 끼우면 데코레이터 패턴으로 의도에 따라 구분한다는 것이 강의의 설명이다. 로그 추적은 호출을 막지 않고 앞뒤에 기록만 더하므로 데코레이터 쪽이다

인터페이스가 없는 v2는 클래스를 상속해 프록시를 만든다

public class OrderControllerConcreteProxy extends OrderControllerV2 {

    private final OrderControllerV2 target;
    private final LogTrace logTrace;

    public OrderControllerConcreteProxy(OrderControllerV2 target, LogTrace logTrace) {
        super(null);
        this.target = target;
        this.logTrace = logTrace;
    }

    @Override
    public String request(String itemId) {
        // ... begin → target.request(itemId) → end, 예외면 exception 후 다시 던진다
    }
    // ...
}

자식 클래스는 부모 생성자를 반드시 불러야 하는데, OrderControllerV2에는 서비스를 받는 생성자뿐이라 프록시는 super(null)을 넘긴다. 프록시는 부모의 필드를 쓰지 않고 넘겨받은 target만 부르므로 null이어도 동작한다. 메서드가 final이면 재정의할 수 없어서, 그 메서드는 프록시 객체에서 물려받은 부모 코드가 그대로 돌고 target에는 닿지 않는다. 그 코드가 부모의 필드를 쓰면 super(null)로 비워 둔 필드라 NullPointerException이 날 것으로 보인다. 클래스가 final이면 상속 자체가 안 된다. 인터페이스 기반 프록시에는 이런 제약이 없는 대신 인터페이스가 있어야 한다

어느 쪽이든 대상 클래스마다 프록시 클래스를 하나씩 만들어야 하고, 프록시마다 로그 추적 코드가 똑같다. 대상이 100개면 프록시도 100개다. 다음 편에서 프록시 클래스를 실행 중에 만들어 이 중복을 줄인다


출처와 범위

인프런 김영한님의 「스프링 핵심 원리 – 고급편」 강의를 듣고, 강의를 따라 만든 예제를 Spring Boot 4.1.1(Spring Framework 7.0.9, Tomcat 11.0.24)과 Java 25로 옮겨 실행하며 썼다. 실험은 조건마다 코드를 바꿔 서버를 새로 띄우고 요청을 한 번씩 보냈다. 2026년 11월 9일 기준(스캔 범위 확인은 11월 10일) Boot 4.1에서 막힌 지점의 원인 확인과 공식 문서 대조, 비교 실험 스크립트 작성, 원고 정리에 AI(Claude)를 썼다

참고 자료