Spring ArgumentResolver 심화

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 심화와 결합하면 인증·인가·바인딩을 한 번에 처리하는 깔끔한 컨트롤러를 만들 수 있습니다. 핵심은 리졸버는 데이터 추출만 담당하고, 비즈니스 로직은 서비스 레이어에 두는 것입니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux