CS/Spring

Spring MVC HandlerMethodArgumentResolver / ReturnValueHandler — 파라미터와 반환값은 어떻게 결정되나

dding-shark 2026. 4. 13. 20:01
728x90

Spring MVC HandlerMethodArgumentResolver / ReturnValueHandler — 파라미터와 반환값은 어떻게 결정되나


들어가며

스프링 MVC의 컨트롤러 메서드 시그니처는 자유롭다. @RequestParam String name으로 쿼리 하나를 받고, @RequestBody OrderCommand cmd로 JSON을 받고, HttpServletRequest req로 raw 요청을 받고, Principal user로 인증 주체를 받는다. 같은 메서드의 반환도 마찬가지다. String이면 뷰 이름, ResponseEntity<T>면 상태코드 포함 응답 바디, Mono<T>면 리액티브 응답, void면 해석은 또 달라진다. 이 자유로움은 리플렉션 한 번으로 뚝딱 만들어지는 게 아니라 파라미터 하나당 하나의 HandlerMethodArgumentResolver, **반환값 하나당 하나의 HandlerMethodReturnValueHandler**가 책임지는 구조의 결과다.

겉보기엔 @RequestParam이 알아서 값을 꽂아주는 것 같지만 실제로는 RequestParamMethodArgumentResolver라는 한 클래스의 resolveArgument()가 호출된 것뿐이다. 이 구조를 모르면 커스텀 어노테이션을 만들어도 "왜 내 resolver는 안 불리는가"에서 한 시간을 날린다. 다음 다섯 개는 실무에서 반복해서 밟는다.

  • @RequestParam은 마법이 아니라 RequestParamMethodArgumentResolver 한 클래스의 일이다 — resolver 체인의 순서가 모든 것을 결정한다
  • 커스텀 resolver를 등록했는데 built-in이 먼저 잡아버린다addArgumentResolvers로는 built-in 앞에 못 끼어든다
  • @RequestParam(required=true)로 감쌌는데 빈 문자열이 통과한다required는 "키 존재 여부"만 본다
  • int 파라미터가 NPE를 던진다 — primitive는 null 언박싱에서 터진다
  • Map<String,String> 파라미터 하나가 모든 쿼리를 삼킨다 — catch-all resolver의 선점 문제

이 글은 왜 → resolver 체인 구조 → 등록 경로 → Body 계열 겸용 → 타입 변환 계층 → 실무 커스텀 순으로, Spring Framework 6.x / Spring Boot 3.x / Java 17+ 기준으로 정리한다.


목차


1) 왜 resolver인가: DispatcherServlet의 책임 분해

DispatcherServlet이 요청을 받으면 HandlerMapping이 어느 @Controller의 어느 메서드로 보낼지 정한다. 그 다음 실행을 담당하는 게 RequestMappingHandlerAdapter다. 이 어댑터가 메서드를 어떻게 호출할 것인가를 푼다. 문제는 컨트롤러 메서드의 시그니처가 제각각이라는 점이다. 어댑터가 매번 "이 파라미터는 @RequestParam이네, 저 파라미터는 HttpServletRequest네"를 if/else로 분기하면 코드는 금세 수천 줄짜리 괴물이 된다.

스프링은 이 문제를 전략 패턴으로 푼다. 각 파라미터 타입·어노테이션 조합을 처리하는 작은 전략 클래스가 HandlerMethodArgumentResolver다. 어댑터는 resolver들의 리스트를 들고 있고, "이 파라미터를 처리할 수 있는 resolver를 찾아서 그에게 맡긴다". 단순하지만 이 구조 하나가 MVC의 확장성을 전부 설명한다. @RequestParam이 동작하는 이유는 마법이 아니라 RequestParamMethodArgumentResolver가 리스트에 있어서다. 커스텀 어노테이션이 동작하는 이유도 내가 만든 resolver가 그 리스트에 들어가서다.

인터페이스 자체는 두 메서드뿐이다.

public interface HandlerMethodArgumentResolver {
    boolean supportsParameter(MethodParameter parameter);
    Object resolveArgument(MethodParameter parameter,
                           ModelAndViewContainer mavContainer,
                           NativeWebRequest webRequest,
                           WebDataBinderFactory binderFactory) throws Exception;
}

supportsParameter는 "이 파라미터를 내가 처리할 수 있는가"를 boolean으로 답하고, resolveArgument는 실제 값을 만들어 반환한다. 반환값은 그대로 Method.invoke(...)의 인자 배열에 들어간다. 이게 전부다. 이 두 메서드가 스프링 MVC 파라미터 바인딩의 바닥이다.


2) HandlerMethodArgumentResolverComposite: 체인과 캐시

어댑터는 resolver 리스트를 직접 다루지 않고 HandlerMethodArgumentResolverComposite라는 래퍼를 통한다. 이름 그대로 여러 resolver를 모은 합성 객체다. 동작은 "리스트를 순회하면서 첫 번째로 supportsParameter == true인 놈에게 맡긴다". 이 선착순이 체인 전체의 성격을 결정한다.

// HandlerMethodArgumentResolverComposite 핵심 로직 (요약)
public Object resolveArgument(MethodParameter parameter, ...) throws Exception {
    HandlerMethodArgumentResolver resolver = getArgumentResolver(parameter);
    if (resolver == null) {
        throw new IllegalArgumentException("Unsupported parameter type ...");
    }
    return resolver.resolveArgument(parameter, mavContainer, webRequest, binderFactory);
}

private HandlerMethodArgumentResolver getArgumentResolver(MethodParameter parameter) {
    HandlerMethodArgumentResolver result = this.argumentResolverCache.get(parameter);
    if (result == null) {
        for (HandlerMethodArgumentResolver r : this.argumentResolvers) {
            if (r.supportsParameter(parameter)) {
                result = r;
                this.argumentResolverCache.put(parameter, result);
                break;
            }
        }
    }
    return result;
}

여기서 눈여겨볼 게 두 가지다. 첫째, 첫 매치에서 바로 break한다. 뒤에 더 적절한 resolver가 있어도 앞에서 true를 반환한 순간 끝이다. 둘째, 한 번 매치된 결과는 argumentResolverCache에 저장된다. 파라미터(MethodParameter)를 키로 resolver를 캐싱하므로 같은 메서드의 같은 파라미터는 두 번째 호출부터는 리스트 순회를 건너뛴다. 이 캐시 덕분에 체인이 길어져도 요청당 비용은 거의 일정하다.

캐시와 순서가 같이 주는 의미는 뚜렷하다. 체인의 순서는 영원히 고정이다. 런타임에 새 resolver를 끼워 넣어도 이미 캐시된 파라미터는 원래 resolver를 계속 쓴다. 그래서 등록 시점에 순서를 제대로 잡는 게 설계의 전부다.


3) resolver 3그룹: annotation-based / type-based / catch-all

RequestMappingHandlerAdapter가 기본으로 등록하는 resolver는 30개가 넘는다. 성격별로 보면 크게 세 묶음으로 나뉜다.

3-1) annotation-based: 어노테이션 있을 때만 반응

파라미터에 특정 어노테이션이 붙어 있을 때만 supportsParametertrue를 반환하는 부류다. @RequestParam, @RequestHeader, @PathVariable, @CookieValue, @RequestBody, @ModelAttribute, @RequestPart, @SessionAttribute, @RequestAttribute 등 대부분의 바인딩 어노테이션이 여기 속한다. 이 그룹은 어노테이션이 없으면 조용히 통과한다. 즉 다른 resolver에 기회를 넘긴다.

3-2) type-based: 특정 타입이면 반응

파라미터의 타입 자체를 보고 매치하는 부류다. HttpServletRequest, HttpServletResponse, HttpSession, Principal, Locale, TimeZone, InputStream, Reader, WebRequest, UriComponentsBuilder 같은 서블릿/스프링 고정 타입들이다. 이 그룹은 어노테이션 유무와 상관없이 "타입이 맞으면 나"라고 주장한다. HttpServletRequest req 파라미터에 @RequestParam을 붙여봐야 type-based가 먼저 잡아버린다(정확히는 등록 순서에 달려 있지만 기본 등록 순서상 어노테이션 계열이 먼저다 — 뒤에서 다시 본다).

3-3) catch-all: 어노테이션 없는 나머지

가장 위험한 그룹이다. 대표는 RequestParamMethodArgumentResolver의 두 번째 등록본(useDefaultResolution = true)과 ModelAttributeMethodProcessor의 기본 모드다. 어노테이션이 아예 없는 파라미터에 대해 "내가 처리할게"를 주장한다. 단순 타입(String, primitive, wrapper, Date 등)은 RequestParam처럼 처리되고, 그 외 객체 타입은 ModelAttribute처럼 처리된다. 개발자가 아무 어노테이션도 안 붙였을 때 스프링이 "알아서 하는" 편의의 정체가 이것이다.

그룹 매치 기준 대표 클래스 함정
annotation-based 특정 어노테이션 존재 RequestParamMethodArgumentResolver(annotation), RequestResponseBodyMethodProcessor, PathVariableMethodArgumentResolver 커스텀 어노테이션 만들 땐 이 그룹에 들어가야 안전
type-based 타입 정확 매치 ServletRequestMethodArgumentResolver, PrincipalMethodArgumentResolver 프레임워크 고정 타입은 어노테이션보다 type-based가 잡기 쉬움
catch-all 어노테이션 없음 + 단순/객체 타입 분기 RequestParamMethodArgumentResolver(default), ModelAttributeMethodProcessor(default) Map 같은 타입이 선점해서 쿼리 전체를 삼킴

이 3그룹 구분을 머리에 넣고 나면 "왜 특정 resolver가 먼저 잡는가"에 대한 설명이 바로 붙는다. 커스텀 resolver를 만들 때는 annotation-based 그룹에 합류하는 게 사고가 가장 적다. 어노테이션 없이 타입만으로 매치하면 나중에 반드시 다른 경로와 부딪힌다.


4) 등록 경로의 함정: addArgumentResolvers vs setArgumentResolvers

커스텀 resolver 등록은 WebMvcConfigureraddArgumentResolvers(List)에서 한다. 이게 가장 일반적인 경로다. 그런데 이름이 "add"라는 사실 때문에 사람들이 자주 착각한다. "기본 resolver 앞에 추가되는 거 아닌가?" 아니다. 뒤에 붙는다.

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new CurrentUserArgumentResolver()); // 맨 뒤에 붙음
    }
}

RequestMappingHandlerAdapter가 최종 체인을 조립하는 순서는 이렇다. ① built-in annotation-based → ② built-in type-based → ③ 사용자가 addArgumentResolvers로 추가한 resolver → ④ built-in catch-all. 즉 built-in 앞에는 못 끼어들고, built-in catch-all 앞에는 끼어든다. 이 배치가 의미하는 건 단순하다.

  • 어노테이션 기반 커스텀 resolver: 기본 어노테이션(@RequestParam, @RequestBody 등)과 겹치지만 않으면 addArgumentResolvers로 충분하다. 내가 만든 @CurrentUser는 built-in annotation-based가 @CurrentUser를 모르니까 통과시키고, 내 resolver가 잡는다.
  • 타입 기반 커스텀 resolver: 위험하다. Principal이나 HttpServletRequest 같은 타입을 재정의하려 하면 built-in type-based가 먼저 잡아버린다. 이 경우 setArgumentResolvers(List)로 체인 전체를 직접 지정해야 한다.

4-1) setArgumentResolvers를 쓰는 순간

setArgumentResolversRequestMappingHandlerAdapter에서 직접 호출해야 하는 API다. 이걸 쓰면 built-in 조립 과정이 통째로 날아가고 내가 넘긴 리스트가 전부가 된다. 거의 쓸 일이 없다. 쓴다면 built-in 앞에 커스텀을 강제로 넣기 위한 아래 패턴이다.

@Configuration
public class AdapterCustomizer {
    @Autowired private RequestMappingHandlerAdapter adapter;

    @PostConstruct
    public void init() {
        List<HandlerMethodArgumentResolver> resolvers = new ArrayList<>();
        resolvers.add(new MyOverridingResolver()); // 맨 앞
        resolvers.addAll(adapter.getArgumentResolvers()); // 기존 전부 뒤에 붙임
        adapter.setArgumentResolvers(resolvers);
    }
}

여기서 틀리기 쉽다. setArgumentResolvers는 한 번 호출되면 내부 HandlerMethodArgumentResolverComposite를 재구성하고, 캐시도 초기화된다. 부트 기동 중에만 쓰는 게 맞다. 런타임에 부르면 요청 처리 중에 resolver 리스트가 바뀌면서 경합이 생길 수 있다.

API 효과 사용 시점
addArgumentResolvers(List) (Configurer) built-in catch-all 앞에 추가 99%는 이걸 쓴다
setArgumentResolvers(List) (Adapter 직접) 체인 전체 교체 built-in 앞에 강제 삽입해야 할 때만
setCustomArgumentResolvers(List) addArgumentResolvers와 동일 효과 Configurer 대신 어댑터 직접 호출할 때

5) RequestResponseBodyMethodProcessor: 파라미터와 반환을 같이 처리하는 클래스

여기서 이야기의 축이 옮겨간다. @RequestBody로 JSON을 받는 resolver와 @ResponseBody로 JSON을 반환하는 handler는 같은 클래스가 구현한다. 이름이 RequestResponseBodyMethodProcessor다. 이 클래스는 HandlerMethodArgumentResolverHandlerMethodReturnValueHandler동시에 구현한다.

왜 한 클래스에 묶였는가. 둘 다 HttpMessageConverter 리스트를 공유하기 때문이다. JSON 역직렬화(@RequestBody)든 직렬화(@ResponseBody)든 같은 MappingJackson2HttpMessageConverter를 쓴다. 같은 자원을 쓰는 두 책임을 묶어둔 설계다.

// RequestResponseBodyMethodProcessor의 이중 구현 (요약)
public class RequestResponseBodyMethodProcessor
        extends AbstractMessageConverterMethodProcessor
        implements HandlerMethodArgumentResolver, HandlerMethodReturnValueHandler {

    @Override public boolean supportsParameter(MethodParameter p) {
        return p.hasParameterAnnotation(RequestBody.class);
    }
    @Override public boolean supportsReturnType(MethodParameter r) {
        return AnnotatedElementUtils.hasAnnotation(r.getContainingClass(), ResponseBody.class)
            || r.hasMethodAnnotation(ResponseBody.class);
    }
    // resolveArgument / handleReturnValue는 HttpMessageConverter로 읽고 쓴다
}

이 구조가 실무에서 주는 함의는 이렇다. JSON 직렬화 커스터마이징은 converter 층에서, 바디 바인딩 규칙 자체의 수정은 processor 확장에서 풀어야 한다. 둘을 뒤섞으면 한쪽만 고쳐지는 경우가 생긴다. 예를 들어 날짜 포맷을 바꾸려면 Jackson ObjectMapper 설정을 건드리면 되고, @RequestBody가 빈 바디를 어떻게 다룰지 바꾸려면 processor 자체를 확장해야 한다.


6) HttpEntityMethodProcessor: ResponseEntity의 뒷면

ResponseEntity<T>의 뒷면도 같은 패턴이다. HttpEntityMethodProcessor는 파라미터 타입이 HttpEntity 혹은 RequestEntity일 때(파라미터) 바인딩을 하고, 반환 타입이 HttpEntity 혹은 ResponseEntity일 때(반환) 응답을 만든다. 즉 이 클래스도 HandlerMethodArgumentResolver + HandlerMethodReturnValueHandler이중 구현한다.

@PostMapping("/orders")
public ResponseEntity<OrderResponse> create(@RequestBody OrderCommand cmd) {
    Order order = orderService.place(cmd);
    return ResponseEntity.status(HttpStatus.CREATED)
                         .header("Location", "/orders/" + order.getId())
                         .body(OrderResponse.of(order));
}

이 메서드의 반환을 풀어내는 건 HttpEntityMethodProcessor다. ResponseEntity의 status·headers·body를 분해해서, body는 내부적으로 HttpMessageConverter(즉 RequestResponseBodyMethodProcessor와 같은 converter 리스트)에 넘기고, status와 headers는 HttpServletResponse에 직접 쓴다. 여기서 혼동하기 쉬운 지점은 @ResponseBody가 없어도 ResponseEntity는 JSON으로 직렬화된다는 점이다. 이유는 간단하다. ResponseEntity를 처리하는 handler는 RequestResponseBodyMethodProcessor가 아니라 HttpEntityMethodProcessor이고, 이 handler는 @ResponseBody를 보지 않는다. 반환 타입만으로 매치한다.

반환 타입 처리 handler @ResponseBody 필요?
String + @ResponseBody RequestResponseBodyMethodProcessor 필요
OrderResponse + @ResponseBody (또는 @RestController) RequestResponseBodyMethodProcessor 필요 (또는 @RestController가 대신)
ResponseEntity<OrderResponse> HttpEntityMethodProcessor 불필요
Callable<T> / DeferredResult<T> CallableMethodReturnValueHandler / DeferredResultMethodReturnValueHandler 불필요

7) WebDataBinder와 ConversionService: 타입 변환 계층

@RequestParam Long orderId처럼 쿼리 문자열 "42"Long으로 바꾸는 일은 resolver가 직접 하지 않는다. 이 변환 책임은 **WebDataBinder**와 그 내부의 **ConversionService**에게 있다. resolver는 값을 어디서 꺼낼지를 결정하고, binder는 그 raw 값(대부분 String)을 메서드 파라미터의 타입으로 변환한다.

계층을 그리면 이렇다.

17_spring-mvc-argument-resolver-01

ConversionService는 스프링 코어의 타입 변환 허브다. String → Long, String → LocalDate, String → enum, 심지어 String → 커스텀 VO까지 등록된 Converter<S,T>에 따라 처리한다. 스프링 부트는 기본으로 ApplicationConversionService를 빈으로 올린다. 커스텀 변환을 추가하려면 Converter<String, MyType>을 빈으로 등록하면 된다.

@Component
public class StringToOrderStatusConverter implements Converter<String, OrderStatus> {
    @Override
    public OrderStatus convert(String source) {
        return OrderStatus.valueOf(source.trim().toUpperCase());
    }
}

이 빈이 등록되면 @RequestParam OrderStatus status로 바로 받을 수 있다. resolver 코드는 한 줄도 고치지 않는다. 변환은 binder 계층, 바인딩은 resolver 계층이라는 분리가 이 확장성을 만든다.


8) @InitBinder의 범위와 mass assignment

@InitBinder는 컨트롤러별로 WebDataBinder를 커스터마이즈하는 훅이다. 필드 변환기를 추가하거나, 특정 필드만 바인딩 허용하거나 차단할 때 쓴다.

@Controller
public class OrderController {

    @InitBinder
    public void init(WebDataBinder binder) {
        binder.registerCustomEditor(LocalDate.class,
            new CustomDateEditor(new SimpleDateFormat("yyyy-MM-dd"), false));
        binder.setAllowedFields("name", "email"); // allowlist
        binder.setDisallowedFields("role", "admin"); // denylist
    }
}

@InitBinder선언한 컨트롤러에만 적용된다. 전역으로 걸고 싶으면 @ControllerAdvice 안에서 선언한다. value 속성에 바인더 이름을 지정하면 특정 파라미터에만 적용할 수 있다.

8-1) mass assignment의 위험

@ModelAttribute로 객체 전체를 받을 때 가장 주의해야 할 게 mass assignment다. 클라이언트가 폼에 role=ADMIN 같은 숨겨진 필드를 넣어 보내면, User DTO에 role 필드가 있는 순간 그대로 바인딩된다. 서버는 "UI에 그 필드 안 넣었으니 괜찮겠지"로 방심한다. 공격자는 HTML을 직접 쓰지 UI를 쓰지 않는다.

피하기 / 선호 패턴은 이렇다.

// 피하기: 도메인 엔티티를 그대로 폼 바인딩 대상으로 씀
@PostMapping("/users")
public String create(@ModelAttribute User user) { // role, isAdmin 전부 노출
    userService.save(user);
    return "redirect:/users";
}

// 선호: 폼 전용 DTO + allowlist
@PostMapping("/users")
public String create(@ModelAttribute UserForm form) {
    userService.save(form.toUser());
    return "redirect:/users";
}

@InitBinder("userForm")
public void userFormBinder(WebDataBinder b) {
    b.setAllowedFields("name", "email", "password");
}

UserForm에 애초에 role 필드가 없으면 바인딩 자체가 불가능하다. 여기에 setAllowedFields를 한 번 더 거는 게 "방어가 두 겹"이다. setDisallowedFields만 쓰는 건 누락 가능성이 있으므로 allowlist가 기본이다.


9) required·defaultValue·primitive의 3각 함정

@RequestParam의 세 속성은 같이 놓고 봐야 한다. 하나씩 보면 자연스러운데 조합하면 꼬인다.

9-1) required=true는 "키 존재"만 본다

@GetMapping("/search")
public String search(@RequestParam(required = true) String q) { ... }

q=처럼 키는 있고 값은 빈 문자열이면 통과한다. required=true는 "파라미터가 요청에 존재하는가"만 본다. "비어 있지 않은가"는 보지 않는다. "검색어 필수"라는 의도라면 StringUtils.hasText(q)를 메서드 안에서 검사하거나 @NotBlank@Validated와 같이 쓴다.

@GetMapping("/search")
public String search(
    @RequestParam @NotBlank(message = "검색어는 필수입니다") String q
) { ... }
// 메서드가 붙은 컨트롤러에 @Validated가 필요

9-2) primitive는 null 언박싱에서 NPE

@GetMapping("/items")
public String items(@RequestParam(required = false) int page) { ... }
// 요청에 page 없으면 NPE. int는 null 언박싱 불가.

required = false는 resolver가 null을 반환하도록 만든다. 파라미터 타입이 Integerpage = null로 잘 들어가지만, int그 null을 언박싱하는 순간 NPE다. 해결은 두 가지. 첫째, wrapper 타입을 쓴다(Integer). 둘째, defaultValue를 지정한다.

// 선호: defaultValue로 primitive 유지
@GetMapping("/items")
public String items(@RequestParam(defaultValue = "1") int page) { ... }

defaultValue가 지정되면 required는 자동으로 false처럼 동작하고, 값이 없을 때 문자열 "1"ConversionService를 거쳐 int 1이 된다.

9-3) Map 파라미터의 catch-all 선점

@GetMapping("/search")
public String search(@RequestParam Map<String, String> params) { ... }

이 시그니처는 모든 쿼리 파라미터를 한 Map에 몰아넣는다. 편한 것 같지만 위험하다. 같은 컨트롤러의 다른 메서드들에서도 이 패턴을 쓰면 개별 파라미터 타입 검증이 사라진다. MultiValueMap<String, String>도 같은 부류다. @RequestParam Map은 "어떤 쿼리가 올지 모를 때"만 쓰고, 알고 있으면 개별 파라미터로 받는 게 원칙이다.

조합 결과 권장
required=true, String 키 없으면 400. 빈 문자열 통과 비어 있으면 안 되는 경우 @NotBlank 추가
required=false, int 값 없으면 NPE Integer 또는 defaultValue
defaultValue="1", int 안전 기본 권장
@RequestParam Map<String,String> 전체 쿼리 흡수 특수 경우에만

10) 실무 시나리오: @AuthenticationPrincipal 커스텀 확장

실무에서 가장 자주 쓰는 커스텀 resolver 패턴이 "현재 로그인 사용자 꺼내기"다. 스프링 시큐리티가 기본으로 @AuthenticationPrincipal과 그에 대응하는 AuthenticationPrincipalArgumentResolver를 제공하지만, 프로젝트 도메인의 User VO로 바로 변환하고 싶을 때가 많다. 이 시나리오를 end-to-end로 본다.

10-1) 커스텀 어노테이션 정의

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface CurrentUser {
    boolean required() default true;
}

타입 기반이 아니라 어노테이션 기반으로 만드는 이유는 §3-3 catch-all 위험 때문이다. User라는 타입만으로 매치하면 나중에 다른 User 파라미터와 충돌한다.

10-2) Resolver 구현

@Component
@RequiredArgsConstructor
public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver {

    private final UserRepository userRepository;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(CurrentUser.class)
            && parameter.getParameterType().equals(User.class);
    }

    @Override
    public User resolveArgument(MethodParameter parameter,
                                ModelAndViewContainer mav,
                                NativeWebRequest webRequest,
                                WebDataBinderFactory binder) {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) {
            CurrentUser ann = parameter.getParameterAnnotation(CurrentUser.class);
            if (ann != null && ann.required()) {
                throw new AuthenticationCredentialsNotFoundException("인증 필요");
            }
            return null;
        }
        String username = auth.getName();
        return userRepository.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException(username));
    }
}

supportsParameter에서 어노테이션 존재와 **타입이 정확히 User**임을 같이 본다. 어노테이션만 보면 다른 타입에서도 매치되고, 타입만 보면 @CurrentUser 없이 우연히 User를 받는 파라미터까지 삼킨다. 둘을 and로 거는 게 안전한 매치 조건이다.

10-3) 등록

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
    private final CurrentUserArgumentResolver currentUserResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(currentUserResolver);
    }
}

@CurrentUser는 built-in annotation-based가 "내 소관 아님"으로 통과시키고, built-in type-based도 User 타입을 모르니 통과시킨다. 결국 사용자가 추가한 currentUserResolver까지 내려와서 매치된다. §4 등록 경로의 체인 순서가 이 패턴이 안전한 이유를 설명한다.

10-4) 사용

@RestController
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;

    @PostMapping("/orders")
    public OrderResponse create(@CurrentUser User user,
                                @RequestBody OrderCommand cmd) {
        return OrderResponse.of(orderService.place(user, cmd));
    }
}

여기서 틀리기 쉬운 지점이 하나 더 있다. resolver 안에서 userRepository.findByUsername(...)를 매 요청마다 부르고 있다는 점이다. 컨트롤러가 이미 @Transactional에 들어가 있다면 괜찮지만, 그렇지 않으면 resolver 호출 시점에는 트랜잭션이 아직 안 열린 경우도 있다. JPA lazy 필드를 resolver 안에서 건드리면 LazyInitializationException이 난다. 이 계층에서는 식별자만 꺼내고 실제 엔티티 로딩은 서비스 계층으로 미루는 설계가 더 안전하다.

// 대안: 가벼운 principal DTO를 resolver에서 반환하고 서비스에서 load
public record CurrentUserPrincipal(Long id, String username, Set<String> roles) { }

11) HandlerMethodReturnValueHandler: 반환값의 대칭 구조

파라미터 쪽에 HandlerMethodArgumentResolver가 있듯 반환 쪽에는 HandlerMethodReturnValueHandler가 있다. 인터페이스 구조도 대칭이다.

public interface HandlerMethodReturnValueHandler {
    boolean supportsReturnType(MethodParameter returnType);
    void handleReturnValue(Object returnValue, MethodParameter returnType,
                           ModelAndViewContainer mavContainer,
                           NativeWebRequest webRequest) throws Exception;
}

기본 handler들은 크게 세 묶음이다. 뷰 기반(ModelAndViewMethodReturnValueHandler, ViewNameMethodReturnValueHandler, ViewMethodReturnValueHandler), 바디 기반(RequestResponseBodyMethodProcessor, HttpEntityMethodProcessor), 비동기 기반(CallableMethodReturnValueHandler, DeferredResultMethodReturnValueHandler, AsyncTaskMethodReturnValueHandler, 리액티브의 ReactiveTypeHandler).

체인 구조도 파라미터와 동일하다. HandlerMethodReturnValueHandlerComposite가 순회하면서 첫 매치에게 넘긴다. 등록도 WebMvcConfigurer.addReturnValueHandlers로 한다. 이 대칭성 때문에 파라미터 쪽에서 배운 규칙은 그대로 옮겨진다. built-in 앞에 끼어들려면 setReturnValueHandlers로 체인 전체 교체, 그 외에는 addReturnValueHandlers로 뒤에 붙이면 된다.

커스텀 return handler는 실무에서 파라미터보다 훨씬 드물게 쓰인다. 대부분은 ResponseEntity@ResponseBody로 해결된다. 커스텀을 만드는 경우는 주로 "특정 반환 타입을 특정 포맷으로 직렬화" 같은 좁은 요구일 때다. 그런 경우라도 먼저 HttpMessageConverter 커스터마이징으로 풀 수 있는지 살펴보는 게 순서다. converter 층에서 풀리면 handler를 건드릴 일이 없다.


12) 실무에서 이렇게 읽고 쓴다

  • 커스텀 resolver는 반드시 어노테이션 기반으로 만든다. supportsParameter에 어노테이션 체크를 넣지 않으면 built-in이 놓친 파라미터를 무차별로 삼킨다. 타입만으로 매치하는 resolver는 팀 전체가 그 타입의 존재 규칙을 공유해야 유지된다.
  • addArgumentResolvers로 built-in을 오버라이드할 수 있다고 착각하지 말 것. built-in annotation-based/type-based 뒤에 붙는다. 진짜로 앞에 넣어야 한다면 setArgumentResolvers로 체인 전체를 재조립한다. 그리고 그건 프레임워크와 싸우는 신호이므로, 웬만하면 다른 설계로 빠져나간다.
  • @RequestParam(required=true)는 빈 문자열을 막지 않는다. "비어 있으면 안 된다"는 의도면 @NotBlank + @Validated를 같이 쓴다. required만 믿으면 운영에서 빈 검색이 그대로 DB로 간다.
  • primitive 파라미터에 required=false를 쓰면 NPE다. Integer 같은 wrapper를 쓰거나 defaultValue를 넣는다. 이건 @RequestHeader, @PathVariable도 동일하다.
  • @RequestParam Map은 "모든 쿼리 흡수" 안티패턴이다. API 스펙이 모호해지고 검증이 전부 메서드 안으로 내려온다. 특수 케이스가 아니면 개별 파라미터로 쪼갠다.
  • @ModelAttribute에 도메인 엔티티를 바로 매핑하지 않는다. mass assignment의 입구다. 폼 전용 DTO + @InitBindersetAllowedFields 조합이 기본이다.
  • ResponseEntity@ResponseBody 없이도 JSON이 된다. 이유는 HttpEntityMethodProcessor가 잡기 때문이다. @ResponseBody를 같이 붙이면 중복이고, 붙여도 동작이 바뀌진 않는다.
  • 디버깅 체크 순서: ① 파라미터 로그에 어노테이션이 실제로 붙어 있는가(런타임 리플렉션 기준) ② RequestMappingHandlerAdapter.getArgumentResolvers()에 내 resolver가 들어 있는가 ③ 들어 있다면 몇 번째인가(built-in 뒤인가) ④ supportsParameter가 기대한 파라미터에서 true를 반환하는가 ⑤ ConversionService에 타깃 타입 변환기가 등록돼 있는가. 이 다섯 개로 90% 잡힌다.

13) 한 줄 정리

스프링 MVC의 파라미터 바인딩은 HandlerMethodArgumentResolverComposite 한 체인의 선착순 매칭이고, 반환 처리는 그 대칭으로 동일하게 동작한다. addArgumentResolvers는 built-in 뒤에 붙는다는 사실과 catch-all resolver의 선점 위험을 모르면 커스텀은 조용히 실패한다. resolver는 값을 어디서 꺼낼지를, WebDataBinder/ConversionService는 그 값을 어떻게 변환할지를 나눠 가진다.


태그: Spring MVC, HandlerMethodArgumentResolver, HandlerMethodReturnValueHandler, RequestMappingHandlerAdapter, WebDataBinder, ConversionService, @RequestParam, @RequestBody, @AuthenticationPrincipal, Spring Boot 3

728x90