ArgumentResolver
ArgumentResolver란
이전의 글에서도 작성했지만, Spring MVC는 사용자 요청이 들어오면 DispatcherServlet에서 해당 요청의 URI를 HandlerMapping에서 검색 후 RequestMappingHandlerAdaptor를 통해 우리가 만든 컨트롤러에 전달한다.
Mapping을 찾은 후 컨트롤러에 넘겨주기 전에, ArgumentResolver를 통해 데이터를 변환하여 파라미터에 바인딩을 수행한다.
public interface HandlerMethodArgumentResolver {
boolean supportsParameter(MethodParameter parameter);
@Nullable
Object resolveArgument(MethodParameter parameter,
@Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
@Nullable WebDataBinderFactory binderFactory) throws Exception;
}
Java
복사
ArgumentResolver는 위와 같이 파라미터 바인딩을 할 수 있는지 여부를 확인하는 메서드와, 바인딩을 수행하는 메서드로 구성되어 있다. 스프링에서는 30개가 넘는 HandlerMethodArgumentResolver 구현체를 가지고 있어, supportsParameter 메서드를 통해 특정 파라미터에 값을 변환해서 넣을 수 있는지 확인한다. 그 중 변환이 되는 구현체를 선택하여, resolveArgument 메서드로 변환하고 값을 바인딩한다. 이를 통해 @ModelAttribute, @RequestBody, @ModelAndView 등 다양한 파라미터에 값을 자동으로 변환하여 넣어주는 것이다.
우리는 이러한 HandlerMethodArgumentResolver 인터페이스를 구현하는 새로운 객체를 만들고, 이를 등록해 스프링에서 해당 타입에 대해 별다른 절차 없이 자동으로 값을 넣어주도록 만들 수 있다.
리팩터링 전 코드
@GetMapping("/me")
@AuthGuard(level = AuthLevel.USER_ONLY)
public MyProfileResponseDto getMyProfile(@UserSession UserSessionDto userSessionDto) {
return userFacadeService.getProfile(userSessionDto);
}
Java
복사
@Component
@Aspect
@RequiredArgsConstructor
@Log4j2
public class UserAspect {
//private final UserMapper ...
//private final UserService ...
//컨트롤러가 아니므로 Facade를 주입받지는 않지만, 서비스와 매퍼를 주입받아서 UserSessionDto를 생성해 줌.
private final CookieManager cookieManager;
private final TokenValidator tokenValidator;
private final JwtProperties jwtProperties;
private final UserQueryService userQueryService;
@Around("execution(* *(.., @UserSession (*), ..))")
public Object setUserSessionDto(ProceedingJoinPoint joinPoint) throws Throwable {
HttpServletRequest request =
((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
//@User를 쓰려면 반드시 첫 매개변수에 UserSessionDto로 설정해주어야 함.
Object[] args = joinPoint.getArgs();
if (!args[0].getClass().equals(UserSessionDto.class)) {
log.error("User not found");
throw ExceptionStatus.UNAUTHORIZED.asControllerException();
}
args[0] = getUserSessionDtoByRequest(request);
return joinPoint.proceed(args);
}
// ToDo: 수정 필요
public UserSessionDto getUserSessionDtoByRequest(HttpServletRequest req) throws JsonProcessingException {
String name = tokenValidator.getPayloadJson(
cookieManager.getCookieValue(req, jwtProperties.getMainTokenName()))
.get("name").asText();
User user = userQueryService.findUserByName(name)
.orElseThrow(ExceptionStatus.NOT_FOUND_USER::asServiceException);
//ToDo: name을 기준으로 service에게 정보를 받고, 매핑한다.
// name과 email은 우선 구현했으나 수정이 필요함.
return new UserSessionDto(user.getId(), name, user.getEmail(), 1, 1, LocalDateTime.now(), true);
}
}
Java
복사
ArgumentResolver 적용 전에는 위와 같이 @UserSession 애노테이션과 UserSessionDto를 AOP를 통해서 값을 넣어주도록 되어 있었다. 코드를 보면 알겠지만, UserSessionDto를 받기 위해서는 반드시 첫 번째 파라미터로 선언해야 했고 AOP로 선언되었기 때문에 매번 pointcut에 대한 검증이 수행되는 오버헤드가 있었을 것이다.
ArgumentResolver 적용
@Slf4j
@Component
@RequiredArgsConstructor
public class UserSessionArgumentResolver implements HandlerMethodArgumentResolver {
private final TokenValidator tokenValidator;
private final UserQueryService userQueryService;
private final CookieManager cookieManager;
private final JwtProperties jwtProperties;
@Override
public boolean supportsParameter(MethodParameter parameter) {
boolean hasUserSessionAnnotation = parameter.hasParameterAnnotation(UserSession.class);
boolean hasUserSessionType =
UserSessionDto.class.isAssignableFrom(parameter.getParameterType());
return hasUserSessionAnnotation && hasUserSessionType;
}
@Override
public Object resolveArgument(@NotNull MethodParameter parameter, ModelAndViewContainer mavContainer,
@NotNull NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
String token = cookieManager.getCookieValue(request, jwtProperties.getMainTokenName());
if (StringUtil.isNullOrEmpty(token)) {
throw ExceptionStatus.INVALID_JWT_TOKEN.asControllerException();
}
String name = tokenValidator.getPayloadJson(token).get("name").asText();
if (StringUtil.isNullOrEmpty(name)) {
throw ExceptionStatus.INVALID_JWT_TOKEN.asControllerException();
}
User user = userQueryService.findUserByName(name)
.orElseThrow(ExceptionStatus.NOT_FOUND_USER::asServiceException);
return new UserSessionDto(user.getId(), name, user.getEmail(), 1, 1,
user.getBlackholedAt(), false);
}
}
Java
복사
이와 같이 ArgumentResolver를 선언했다.
supportsParameter 메서드에서는 @UserSession 애노테이션이 적용되어 있고, UserSessionDto 클래스와 그 자식 클래스인 경우에 적용 가능하도록 판단한다.
resolveArgument 메서드에서는 쿠키에 저장되어 있는 JWT 토큰을 파싱하여 유저의 이름 정보를 얻은 후, DB에서 유저 정보를 확인하여 UserSessionDto로 변환하여 반환한다.
@Configuration
@RequiredArgsConstructor
public class ArgumentResolverConfig implements WebMvcConfigurer {
private final UserSessionArgumentResolver userSessionArgumentResolver;
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(userSessionArgumentResolver);
}
}
Java
복사
만든 ArgumentResolver를 위와 같이 등록하여 스프링에서 자동으로 적용되도록 만들었다.
이와 같이 별다른 작업 없이 잘 변환되어 바인딩된 것을 볼 수 있다.
요청 데이터 검증하기
문제 상황
Cabi에서 작업을 하던 중, 아이템 사용 요청에 대한 처리에서 아이템의 종류에 따라 받아야하는 데이터가 달라지는 상황이 있었다.
@Getter
@AllArgsConstructor
public class ItemUseRequestDto {
private Long newCabinetId; // 이사권 사용 시
private Long cabinetPlaceId; // 알림권 사용 시
private SectionAlarmType sectionAlarmType; // 알림권 사용 시
}
Java
복사
이와 같이 이사권의 경우에는 newCabinetId 필드만, 알림권 사용 시에는 cabinetPlaceId와 sectionAlarmType 필드에 값이 필요하다. 이에 대해 데이터를 검증하고 싶은데, 문제는 아이템의 종류인 Sku 값이 path variable로 전달된다는 것이다.
ArgumentResolver를 통한 해결 시도
HttpServletRequest 객체를 Path variable을 가져오는게 가능하므로, 처음에는 이 문제도 ArgumentResolver를 통해 해결 가능할 것이라 생각하고 코드를 작성하였다.
@Slf4j
@Component
@RequiredArgsConstructor
public class ItemUseArgumentResolver implements HandlerMethodArgumentResolver {
private final ObjectMapper objectMapper;
@Override
public boolean supportsParameter(MethodParameter parameter) {
return ItemUseRequestDto.class.isAssignableFrom(parameter.getParameterType());
}
@Override
public Object resolveArgument(@NotNull MethodParameter parameter, ModelAndViewContainer mavContainer,
@NotNull NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
Map<String, String> pathVariables = (Map<String, String>) request
.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
Sku sku = Sku.valueOf(pathVariables.get("sku"));
ItemUseRequestDto itemUseRequestDto = objectMapper.readValue(para
if (sku.equals(Sku.ALARM) && (itemUseRequestDto.getSectionAlarmType() == null
|| itemUseRequestDto.getCabinetPlaceId() == null)) {
throw ExceptionStatus.ITEM_NOT_FOUND.asControllerException();
}
if (sku.equals(Sku.SWAP) && (itemUseRequestDto.getNewCabinetId() == null)) {
throw ExceptionStatus.ITEM_NOT_FOUND.asControllerException();
}
String object = request.getParameter("itemUseRequestDto");
return objectMapper.readValue(object, ItemUseRequest.class);
}
}
Java
복사
하지만 동작을 시켜보면, 해당 메서드는 전혀 호출이 되지 않았다.
스프링에서는 아래의 애노테이션에 대해 모두 ArgumentResolver로 동작한다.
•
@RequestParam : 쿼리 파라미터 값 바인딩
•
@ModelAttribute : 쿼리 파라미터 및 폼 데이터 바인딩
•
@CookieValue : 쿠키값 바인딩
•
@RequestHeader : 헤더값 바인딩
•
@RequestBody : 바디값 바인딩
하지만 @RequestBody의 경우 ArgumentResolver를 추가하여도 적용되지 않는데, 그 이유는 @RequestBody 애노테이션은 ArgumentResolver의 구현체인 RequestResponseBodyMethodProcessor에 의해 처리된다. 여기서 JSON 형태의 데이터가 객체로 변환되는데, 여기에 ArgumentResolver를 추가하더라도 이미 JSON의 데이터를 객체로 변환한 후라서 추가한 ArgumentResolver는 동작하지 않는다.
이러한 이유로 @RequestBody 애노테이션 이후 변환된 객체에 대해 부가 작업을 하고 싶다면, RequestBodyAdvice 인터페이스를 사용해야 한다.
RequestBodyAdvice 적용하기
@RestControllerAdvice
public class ItemUseControllerAdvice implements RequestBodyAdvice {
@Override
public boolean supports(final MethodParameter methodParameter, final Type targetType, final Class<? extends HttpMessageConverter<?>> converterType) {
return targetType.getTypeName().getContainingClass().assignableFrom(ItemUseRequestDto.class);
}
@Override
public HttpInputMessage beforeBodyRead(final HttpInputMessage inputMessage, final MethodParameter parameter, final Type targetType, final Class<? extends HttpMessageConverter<?>> converterType) {
return inputMessage;
}
@Override
public Object afterBodyRead(final Object body, final HttpInputMessage inputMessage, final MethodParameter parameter, final Type targetType, final Class<? extends HttpMessageConverter<?>> converterType) {
final ItemUseRequestDto itemUseRequestDto = (ItemUseRequestDto) body;
...
return null;
}
@Override
public Object handleEmptyBody(final Object body, final HttpInputMessage inputMessage, final MethodParameter parameter, final Type targetType, final Class<? extends HttpMessageConverter<?>> converterType) {
return body;
}
}
Java
복사
하지만 RequestBodyAdvice를 통해 해당 파라미터의 검증 로직을 수행한다는 계획은 실패했다. 변환 이후 데이터 처리하는 afterBodyRead 메서드에서 검증 로직을 수행해야하는데, 검증에 필요한 Sku 정보가 Path variable에 있고 그를 얻어올 방법을 찾지 못했다.
ConstraintValidator
해결 방법을 한참 찾던 중, 다른 프로젝트에서 동일한 문제를 해결해본 경험을 가지신 분의 조언을 받아 ConstraintValidator를 찾아보게 되었다.
ConstraintValidator란
스프링에서는 spring-boot-starter-validation 라이브러리를 통해 많이 사용되는 유효성 검사들을 제공하는데, 해당 검사들로도 해결이 안되는 부분에 대해 추가적으로 검증 로직을 작성할 수 있도록 ConstraintValidator 인터페이스를 제공한다.
public interface ConstraintValidator<A extends Annotation, T> {
default void initialize(A constraintAnnotation) {
}
boolean isValid(T value, ConstraintValidatorContext context);
}
Java
복사
ConstraintValidator는 jakarta(javax)에서 제공하는 유효성 검증 인터페이스로, 특정 애노테이션이 적용된 객체에 대해 작성된 isValid의 검증 로직을 수행할 수 있다. 기존의 기능으로 검증이 부족한 경우 외에도, 여러 동일한 검증을 수행하는데 DTO가 달라서 여러 번 반복해야하는 경우에도 사용될 수 있다.
ConstraintValidator는 Contoller 전 Interceptor에서 동작한다. ConstraintValidator에서 발생하는 예외를 @ControllerAdvice와 @ExceptionHandler를 통해 처리하는 것도 가능하다.
DTO 검증에 적용하기
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ItemUseValidator.class)
public @interface ItemUseValidation {
String message() default "아이템 사용에 필요한 값이 올바르지 않습니다.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
Java
복사
이와 같이 Validation을 적용하기 위한 애노테이션을 추가하고,
@Component
@Slf4j
public class ItemUseValidator implements ConstraintValidator<ItemUseValidation, ItemUseRequestDto> {
@Override
public void initialize(ItemUseValidation constraintAnnotation) {
ConstraintValidator.super.initialize(constraintAnnotation);
}
@Override
public boolean isValid(ItemUseRequestDto itemUseRequestDto,
ConstraintValidatorContext context) {
log.info("validation called : {}", itemUseRequestDto);
ServletRequestAttributes attrs =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs == null) {
return false;
}
HttpServletRequest request = attrs.getRequest();
Map<String, String> pathVariables = (Map<String, String>) request
.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
Sku sku = Sku.valueOf(pathVariables.get("sku"));
if (sku.equals(Sku.ALARM)) {
return itemUseRequestDto.getBuilding() != null && itemUseRequestDto.getFloor() != null
&& itemUseRequestDto.getSection() != null;
} else if (sku.equals(Sku.SWAP)) {
return itemUseRequestDto.getNewCabinetId() != null;
}
return true;
}
}
Java
복사
HttpServletRequest에서 Path Variable을 가져와 검증 로직을 작성했다.
@Getter
@AllArgsConstructor
@ItemUseValidation
public class ItemUseRequestDto {
private Long newCabinetId; // 이사권 사용 시
private Long cabinetPlaceId; // 알림권 사용 시
private SectionAlarmType sectionAlarmType; // 알림권 사용 시
}
Java
복사
적용할 Dto에 해당 애노테이션을 추가해주고,
@PostMapping("{sku}/use")
@AuthGuard(level = AuthLevel.USER_ONLY)
public void useItem(@UserSession UserSessionDto user,
@PathVariable("sku") Sku sku,
@Valid @RequestBody ItemUseRequestDto data) {
itemFacadeService.useItem(user.getUserId(), sku, data);
}
Java
복사
파라미터에 @Valid 애노테이션을 추가하였다.
값을 올바르지 않게 넣으면 이와 같이 400 Bad Request의 응답이 발생한다.