컨트롤러 메서드에 @RequestParam String name이라고 적으면 스프링이 쿼리 스트링에서 값을 꺼내 넣어주고,@RequestBody UserRequest body라고 적으면 JSON을 객체로 바꿔서 넣어줍니다. 이걸 당연하게 써 왔지만, 누가 어떻게 해주는 걸까요? 그 역할을 하는 것이 Argument Resolver입니다.
이번 글에서는 Argument Resolver가 무엇인지, 스프링이 기본으로 제공하는 것들은 어떤 게 있는지, 그리고 직접 만들어서 로그인 사용자 정보를 컨트롤러에 깔끔하게 넘기는 방법까지 정리하겠습니다.
Argument Resolver란?
먼저 컨트롤러 메서드가 호출되려면 파라미터에 들어갈 값이 준비되어야 합니다. 그런데 파라미터의 종류는 다양합니다. 쿼리 스트링에서 꺼낼 수도 있고, URL 경로에서 꺼낼 수도 있고, 요청 본문을 파싱해야 할 수도 있고, 세션이나 헤더에서 가져와야 할 수도 있죠. 여기서 스프링은 이 "파라미터 하나하나에 넣을 값을 어디서 어떻게 가져올지"를 Argument Resolver에게 맡깁니다. 참고로 정식 이름은 HandlerMethodArgumentResolver이고, 인터페이스는 메서드 두 개뿐입니다.

public interface HandlerMethodArgumentResolver {
// 이 파라미터를 내가 처리할 수 있는가?
boolean supportsParameter(MethodParameter parameter);
// 처리할 수 있다면, 실제 값을 만들어서 돌려준다
Object resolveArgument(MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) throws Exception;
}
supportsParameter()는 "이 파라미터 내 담당인가?"를 판단하고, resolveArgument()는 "그렇다면 값은 이거다"를 만들어 돌려줍니다. 스프링은 컨트롤러를 호출하기 직전에 파라미터를 하나씩 보면서, 등록된 Resolver들에게 차례로 supportsParameter()를 물어보고 처음으로 true를 돌려준 Resolver의 resolveArgument()를 호출합니다.
스프링이 기본으로 제공하는 Resolver
평소에 쓰는 어노테이션 대부분이 각자의 Resolver를 가지고 있습니다. 몇 가지만 보면 아래와 같습니다.

즉 우리가 아무 어노테이션 없이 HttpServletRequest request를 파라미터에 적어도 값이 들어오는 이유는, 그 타입을 담당하는 Resolver가 이미 등록되어 있기 때문입니다. 스프링 부트 기준으로 기본 Resolver만 30개 가까이 됩니다.
왜 직접 만들어야 할까요?
인터셉터 글에서 로그인 사용자 ID를 컨트롤러에 넘길 때 이렇게 했습니다.
// 인터셉터에서
request.setAttribute("userId", userId);
// 컨트롤러에서
@GetMapping("/cart")
public List<CartItem> getCart(@RequestAttribute Long userId) {
return cartService.findByUserId(userId);
}
동작은 잘 됩니다. 그런데 몇 가지 아쉬운 점이 있습니다.
- 문자열 키에 의존합니다. 인터셉터에서 "userId"로 넣고 컨트롤러에서 userId라는 파라미터 이름으로 받아야 합니다. 어느 한쪽에서 오타가 나면 컴파일은 통과하고 실행 시점에야 터집니다.
- ID만 넘길 수 있습니다. 컨트롤러에서 사용자 이름이나 권한도 필요하면 또 attribute를 추가하거나, 컨트롤러마다 userRepository.findById(userId)를 호출해야 합니다.
- 의도가 드러나지 않습니다. @RequestAttribute Long userId만 보고는 이게 로그인한 사용자인지, 어디서 온 값인지 알기 어렵습니다.
Argument Resolver를 직접 만들면 이렇게 바꿀 수 있습니다.
@GetMapping("/cart")
public List<CartItem> getCart(@LoginUser User user) {
return cartService.findByUserId(user.getId());
}
@LoginUser라는 어노테이션 하나로 "로그인한 사용자 객체"가 통째로 들어옵니다. 문자열 키도 없고, 컨트롤러는 사용자를 어떻게 찾는지 몰라도 되며, 코드만 봐도 의도가 분명하게 보입니다.
직접 만들어 보기
직접 만드는 방법은 아래 세 단계를 거치면 되는데요. 먼저 어노테이션을 만들고, Resolver를 구현하고, 마지막으로 등록하면 됩니다.
어노테이션 정의하기
@Target(ElementType.PARAMETER) // 파라미터에만 붙일 수 있음
@Retention(RetentionPolicy.RUNTIME) // 실행 중에 읽을 수 있어야 함
public @interface LoginUser {
}
@Target을 PARAMETER로 제한해서 메서드 파라미터 외에는 못 붙이게 하고, @Retention을 RUNTIME으로 해야 스프링이 실행 시점에 어노테이션을 읽을 수 있습니다. 이 두 줄을 빠뜨리면 Resolver가 어노테이션을 인식하지 못합니다.
Resolver 구현하기
@Component
@RequiredArgsConstructor
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {
private final UserRepository userRepository; // 스프링 빈 주입 가능
@Override
public boolean supportsParameter(MethodParameter parameter) {
// @LoginUser가 붙어 있고, 타입이 User인 파라미터만 담당
return parameter.hasParameterAnnotation(LoginUser.class)
&& parameter.getParameterType().equals(User.class);
}
@Override
public Object resolveArgument(MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) {
// 인터셉터가 넣어둔 userId를 꺼냄
Long userId = (Long) webRequest.getAttribute("userId", RequestAttributes.SCOPE_REQUEST);
if (userId == null) {
throw new UnauthorizedException();
}
// DB에서 사용자 조회 후 반환 → 이 값이 컨트롤러 파라미터에 들어감
return userRepository.findById(userId)
.orElseThrow(UnauthorizedException::new);
}
}
supportsParameter()에서는 두 가지를 확인합니다. @LoginUser 어노테이션이 붙어 있는지, 그리고 파라미터 타입이 User인지입니다. 타입까지 확인하는 이유는 누군가 @LoginUser String name처럼 잘못 쓰면 이 Resolver가 담당하지 않도록 하기 위해서입니다.
resolveArgument()에서는 인터셉터가 미리 넣어둔 userId를 꺼내 DB에서 사용자를 조회하고 반환합니다. 반환한 객체가 그대로 컨트롤러 파라미터에 들어갑니다. Resolver도 스프링 빈이기 때문에 UserRepository 같은 빈을 그냥 주입받아 쓸 수 있습니다.
webRequest는 NativeWebRequest 타입인데, 서블릿에 종속되지 않은 추상화된 요청 객체입니다. 헤더나 파라미터는 이 객체로 바로 읽을 수 있고, 진짜 HttpServletRequest가 필요하면 webRequest.getNativeRequest(HttpServletRequest.class)로 꺼낼 수 있습니다.
등록하기
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final LoginUserArgumentResolver loginUserArgumentResolver;
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(loginUserArgumentResolver);
}
}
인터셉터를 등록할 때 썼던 WebMvcConfigurer에서 addArgumentResolvers()를 오버라이드하면 됩니다. 인터셉터와 같은 설정 클래스에 함께 두는 경우가 많습니다.
사용하기
@RestController
@RequiredArgsConstructor
public class CartController {
private final CartService cartService;
@GetMapping("/api/cart")
public List<CartItem> getCart(@LoginUser User user) {
return cartService.findByUserId(user.getId());
}
@PostMapping("/api/cart")
public void addCart(@LoginUser User user, @RequestBody CartRequest body) {
cartService.add(user.getId(), body);
}
}
이제 로그인 사용자가 필요한 모든 컨트롤러에서 @LoginUser User user 한 줄이면 끝입니다. @RequestBody 같은 다른 파라미터와 섞어 써도 각자의 Resolver가 알아서 처리합니다.
내부에서는 어떻게 동작할까요?
컨트롤러를 실제로 호출하는 것은 RequestMappingHandlerAdapter이고, 그 안에서 InvocableHandlerMethod가 메서드 호출을 담당합니다. 이 클래스가 메서드의 파라미터를 하나씩 돌면서 HandlerMethodArgumentResolverComposite에게 값을 요청합니다.
Composite는 이름 그대로 Resolver들을 모아둔 묶음인데요. 등록된 Resolver 목록을 순서대로 돌면서 supportsParameter()를 물어보고, 처음 true를 돌려준 Resolver를 선택합니다. 그리고 한 번 찾은 결과는 파라미터별로 캐싱해 두기 때문에, 같은 컨트롤러 메서드가 두 번째 호출될 때부터는 supportsParameter()를 다시 돌지 않습니다. 그래서 supportsParameter()는 가볍고 결정적이어야 합니다. 요청 내용에 따라 결과가 달라지는 로직을 여기에 넣으면 첫 요청의 결과가 캐싱되어 이후 요청에 그대로 적용되는 문제가 생깁니다. 요청마다 달라져야 하는 판단은 resolveArgument() 안에서 해야 합니다.
등록 순서는 기본 Resolver가 먼저, 직접 등록한 Resolver가 나중입니다. 정확히는 기본 Resolver 중에서도 어노테이션 기반 Resolver들이 앞에 오고, 그 다음 커스텀 Resolver, 마지막으로 "어노테이션 없는 일반 객체"를 처리하는 범용 Resolver가 옵니다. 그래서 @LoginUser User user처럼 어노테이션을 붙이면 커스텀 Resolver가 범용 Resolver보다 먼저 선택됩니다.
자주 하는 실수
등록을 깜빡하는 경우
@Component를 붙였으니 알아서 등록되겠지 하고 addArgumentResolvers()를 빠뜨리는 경우입니다. Resolver는 빈으로 등록된다고 자동으로 적용되지 않습니다. 반드시 WebMvcConfigurer에서 명시적으로 추가해야 합니다. 등록이 안 되면 @LoginUser User user는 범용 Resolver가 처리하게 되고, 빈 User 객체가 들어오거나 바인딩 에러가 납니다.
supportsParameter를 너무 넓게 잡는 경우
어노테이션 확인 없이 타입만으로 parameter.getParameterType().equals(User.class)만 체크하면, @RequestBody User user처럼 다른 의도로 쓴 파라미터까지 가로채 버립니다. 어노테이션과 타입을 함께 확인하는 것이 안전합니다.
어노테이션의 Retention을 빠뜨리는 경우
@Retention(RetentionPolicy.RUNTIME)이 없으면 어노테이션 정보가 컴파일 후 사라져서 hasParameterAnnotation()이 항상 false를 돌려줍니다. 등록도 했고 코드도 맞는데 Resolver가 호출되지 않는다면 이것부터 확인해 보시기 바랍니다.
Resolver에서 인증 실패를 처리하려는 경우
Resolver는 @LoginUser를 쓴 컨트롤러에서만 동작합니다. 토큰 검증 같은 인증 자체를 Resolver에 두면 @LoginUser를 안 쓴 API는 인증 없이 통과하게 됩니다. 차단은 인터셉터에서, 주입은 Resolver에서 하는 역할 분담을 지켜야 합니다.
정리하면
- Argument Resolver는 컨트롤러 메서드의 파라미터에 넣을 값을 만들어주는 컴포넌트입니다.
@RequestParam,@PathVariable,@RequestBody모두 각자의 Resolver가 처리합니다. HandlerMethodArgumentResolver를 구현하고,supportsParameter()로 담당 여부를,resolveArgument()로 실제 값을 정합니다.WebMvcConfigurer.addArgumentResolvers()에 반드시 등록해야 적용됩니다.- 커스텀 어노테이션에는
@Target(PARAMETER)와@Retention(RUNTIME)이 필요합니다. supportsParameter()의 결과는 캐싱되므로 요청에 따라 달라지는 로직은resolveArgument()에 둡니다.- 인터셉터는 차단, Argument Resolver는 주입을 담당합니다. 인터셉터가 먼저 실행됩니다.
'ETC. > Spring' 카테고리의 다른 글
| [Spring] 스프링 전역 예외 처리 - @ExceptionHandler와 @RestControllerAdvice 사용법 (0) | 2026.10.07 |
|---|---|
| [Spring] 스프링 필터(Filter) vs 인터셉터(Interceptor) 차이점과 선택 기준 총정리 (0) | 2026.10.04 |
| [Spring] 스프링 인터셉터(Interceptor)란? 개념과 사용법, 등록 방법 총정리 (1) | 2026.10.03 |
| [Spring] 스프링 필터(Filter)란? 개념과 사용법, 등록 방법 총정리 (2) | 2026.10.01 |