ArgumentResolver란?
HandlerMethodArgumentResolver는 Spring MVC 컨트롤러 메서드의 파라미터를 커스텀 로직으로 바인딩하는 확장 포인트입니다. @RequestBody, @PathVariable처럼 동작하는 커스텀 어노테이션을 만들어, 인증된 사용자 주입, 페이지네이션 파싱, 멀티테넌트 컨텍스트 등을 선언적으로 처리할 수 있습니다.
이 글에서는 기본 구현 패턴, 어노테이션 기반 파라미터 바인딩, 복합 객체 해석, WebFlux 대응, 테스트 전략까지 실무 패턴을 다룹니다.
기본 구조: supportsParameter + resolveArgument
public interface HandlerMethodArgumentResolver {
// 이 리졸버가 해당 파라미터를 처리할 수 있는지 판단
boolean supportsParameter(MethodParameter parameter);
// 실제 파라미터 값을 해석하여 반환
@Nullable
Object resolveArgument(
MethodParameter parameter,
@Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
@Nullable WebDataBinderFactory binderFactory
) throws Exception;
}
@CurrentUser: 인증된 사용자 주입
// 1. 어노테이션 정의
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface CurrentUser {
boolean required() default true;
}
// 2. ArgumentResolver 구현
@Component
@RequiredArgsConstructor
public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver {
private final UserRepository userRepository;
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(CurrentUser.class)
&& UserDto.class.isAssignableFrom(parameter.getParameterType());
}
@Override
public Object resolveArgument(
MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()
|| auth instanceof AnonymousAuthenticationToken) {
CurrentUser annotation = parameter.getParameterAnnotation(CurrentUser.class);
if (annotation != null && annotation.required()) {
throw new UnauthorizedException("인증이 필요합니다");
}
return null;
}
// JWT 클레임에서 사용자 ID 추출
if (auth.getPrincipal() instanceof Jwt jwt) {
String userId = jwt.getSubject();
return userRepository.findById(Long.parseLong(userId))
.map(this::toDto)
.orElseThrow(() -> new UserNotFoundException(userId));
}
throw new UnauthorizedException("지원하지 않는 인증 타입");
}
private UserDto toDto(User user) {
return new UserDto(user.getId(), user.getEmail(),
user.getName(), user.getRoles());
}
}
// 3. 컨트롤러 사용
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@GetMapping
public List<OrderResponse> myOrders(@CurrentUser UserDto user) {
return orderService.findByUserId(user.id());
}
@GetMapping("/public")
public List<OrderResponse> publicOrders(
@CurrentUser(required = false) UserDto user) {
// 비인증 사용자도 접근 가능, user가 null일 수 있음
return orderService.findPublicOrders(user?.id());
}
}
@PageableRequest: 커스텀 페이지네이션
Spring Data의 Pageable보다 프로젝트 요구사항에 맞는 커스텀 페이지네이션을 구현할 수 있습니다.
// 어노테이션: 기본값 설정 가능
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface PageableRequest {
int defaultSize() default 20;
int maxSize() default 100;
String defaultSort() default "createdAt";
String defaultDirection() default "DESC";
String[] allowedSortFields() default {};
}
// DTO
public record PageRequest(
int page,
int size,
String sort,
Sort.Direction direction,
long offset
) {}
// Resolver
@Component
public class PageableRequestResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(PageableRequest.class)
&& PageRequest.class.isAssignableFrom(parameter.getParameterType());
}
@Override
public Object resolveArgument(
MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) {
PageableRequest annotation =
parameter.getParameterAnnotation(PageableRequest.class);
int page = parseIntOrDefault(webRequest.getParameter("page"), 1);
int size = parseIntOrDefault(webRequest.getParameter("size"),
annotation.defaultSize());
String sort = webRequest.getParameter("sort");
String direction = webRequest.getParameter("direction");
// 검증: 페이지 최소 1
page = Math.max(1, page);
// 검증: 사이즈 범위
size = Math.min(annotation.maxSize(), Math.max(1, size));
// 검증: 허용된 정렬 필드만
if (sort == null || sort.isBlank()) {
sort = annotation.defaultSort();
} else if (annotation.allowedSortFields().length > 0) {
String finalSort = sort;
boolean allowed = Arrays.stream(annotation.allowedSortFields())
.anyMatch(f -> f.equalsIgnoreCase(finalSort));
if (!allowed) {
throw new InvalidSortFieldException(sort,
annotation.allowedSortFields());
}
}
Sort.Direction dir = parseDirection(direction,
annotation.defaultDirection());
return new PageRequest(page, size, sort, dir, (long)(page - 1) * size);
}
}
// 사용
@GetMapping("/products")
public PageResponse<ProductDto> listProducts(
@PageableRequest(
defaultSize = 10,
maxSize = 50,
allowedSortFields = {"name", "price", "createdAt"}
) PageRequest page) {
return productService.findAll(page);
}
@TenantContext: 멀티테넌시
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface TenantContext {}
public record Tenant(String id, String name, TenantPlan plan) {}
@Component
@RequiredArgsConstructor
public class TenantContextResolver implements HandlerMethodArgumentResolver {
private final TenantRepository tenantRepository;
private final CacheManager cacheManager;
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(TenantContext.class);
}
@Override
public Object resolveArgument(
MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) {
// 헤더 → 서브도메인 → JWT 클레임 순으로 테넌트 ID 추출
String tenantId = webRequest.getHeader("X-Tenant-ID");
if (tenantId == null) {
HttpServletRequest request =
webRequest.getNativeRequest(HttpServletRequest.class);
String host = request.getServerName();
// subdomain.example.com → subdomain
tenantId = host.split("\.")[0];
}
if (tenantId == null) {
Authentication auth =
SecurityContextHolder.getContext().getAuthentication();
if (auth.getPrincipal() instanceof Jwt jwt) {
tenantId = jwt.getClaimAsString("tenant_id");
}
}
if (tenantId == null) {
throw new TenantNotFoundException("테넌트를 식별할 수 없습니다");
}
// 캐시에서 테넌트 정보 조회
Cache cache = cacheManager.getCache("tenants");
String finalTenantId = tenantId;
return cache.get(tenantId, () ->
tenantRepository.findById(finalTenantId)
.orElseThrow(() -> new TenantNotFoundException(finalTenantId))
);
}
}
// 사용
@PostMapping("/api/invoices")
public InvoiceResponse createInvoice(
@TenantContext Tenant tenant,
@CurrentUser UserDto user,
@RequestBody CreateInvoiceRequest request) {
// tenant.plan()으로 요금제별 제한 체크
return invoiceService.create(tenant, user, request);
}
리졸버 등록과 우선순위
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final CurrentUserArgumentResolver currentUserResolver;
private final PageableRequestResolver pageableRequestResolver;
private final TenantContextResolver tenantContextResolver;
@Override
public void addArgumentResolvers(
List<HandlerMethodArgumentResolver> resolvers) {
// 등록 순서 = 우선순위 (먼저 등록된 것이 먼저 매칭)
resolvers.add(currentUserResolver);
resolvers.add(tenantContextResolver);
resolvers.add(pageableRequestResolver);
}
}
// 주의: 커스텀 리졸버는 Spring 내장 리졸버보다 나중에 평가됨
// @RequestBody, @PathVariable 등이 먼저 매칭됨
// 내장 리졸버보다 먼저 실행하려면:
@Configuration
public class PriorityResolverConfig extends RequestMappingHandlerAdapter {
@PostConstruct
public void prioritize() {
List<HandlerMethodArgumentResolver> resolvers = new ArrayList<>();
resolvers.add(currentUserResolver); // 최우선
resolvers.addAll(getArgumentResolvers());
setArgumentResolvers(resolvers);
}
}
테스트 전략
Spring @Async 비동기 처리 심화에서 다뤘듯이, 횡단 관심사는 독립적인 단위 테스트가 중요합니다.
// 단위 테스트
@ExtendWith(MockitoExtension.class)
class CurrentUserArgumentResolverTest {
@Mock private UserRepository userRepository;
@InjectMocks private CurrentUserArgumentResolver resolver;
@Test
void shouldResolveAuthenticatedUser() throws Exception {
// JWT 인증 설정
Jwt jwt = Jwt.withTokenValue("token")
.header("alg", "RS256")
.subject("123")
.build();
SecurityContextHolder.getContext().setAuthentication(
new JwtAuthenticationToken(jwt));
User user = new User(123L, "test@test.com", "Test User");
when(userRepository.findById(123L)).thenReturn(Optional.of(user));
NativeWebRequest webRequest = mock(NativeWebRequest.class);
MethodParameter parameter = mockParameter(CurrentUser.class, UserDto.class);
Object result = resolver.resolveArgument(
parameter, null, webRequest, null);
assertInstanceOf(UserDto.class, result);
assertEquals("test@test.com", ((UserDto) result).email());
}
@Test
void shouldThrowWhenRequiredAndNotAuthenticated() {
SecurityContextHolder.clearContext();
MethodParameter parameter = mockParameter(CurrentUser.class, UserDto.class);
assertThrows(UnauthorizedException.class, () ->
resolver.resolveArgument(parameter, null, mock(), null));
}
}
// 통합 테스트 (@WebMvcTest)
@WebMvcTest(OrderController.class)
@Import({CurrentUserArgumentResolver.class})
class OrderControllerTest {
@Autowired private MockMvc mockMvc;
@MockBean private UserRepository userRepository;
@MockBean private OrderService orderService;
@Test
@WithMockJwtUser(subject = "123")
void shouldInjectCurrentUser() throws Exception {
when(userRepository.findById(123L))
.thenReturn(Optional.of(testUser()));
mockMvc.perform(get("/api/orders"))
.andExpect(status().isOk());
verify(orderService).findByUserId(123L);
}
}
주의사항과 안티패턴
| 안티패턴 | 문제 | 해결 |
|---|---|---|
| 리졸버에서 비즈니스 로직 | 테스트 어려움, 책임 혼재 | 데이터 추출만, 로직은 서비스에 |
| N+1 DB 조회 | 매 요청마다 DB 호출 | 캐시 적용 또는 SecurityContext 활용 |
| supportsParameter 범위 과다 | 의도치 않은 파라미터 매칭 | 어노테이션 + 타입 동시 검사 |
| 예외 무시 | null 반환 시 NPE 발생 | required 플래그로 명시적 처리 |
마무리
Spring ArgumentResolver는 인증 사용자 주입, 커스텀 페이지네이션, 멀티테넌트 컨텍스트 등 반복되는 파라미터 바인딩을 선언적 어노테이션으로 추상화합니다. Spring Method Security 심화와 결합하면 인증·인가·바인딩을 한 번에 처리하는 깔끔한 컨트롤러를 만들 수 있습니다. 핵심은 리졸버는 데이터 추출만 담당하고, 비즈니스 로직은 서비스 레이어에 두는 것입니다.