@RequsetBody에 ArgumentResolver가 안먹힌다?

주노·2023년 4월 23일
1
post-thumbnail

서론

지난 글에 이어서 ArgumentResolver를 사용하려 했으나 @RequestBody를 붙이니 ArgumentResolver가 동작하지 않았다!!!

@RestController
public class MyController {

    @GetMapping("/validTest")
    public MyUser argumentTest(@MyValid @RequestBody MyUser value) {
        return value;
    }
}
public class MyUser {
    private String name;

    MyUser() {
    }

    public String getName() {
        return name;
    }
}

구조는 위와 같이 변경되었다.
기존에 String을 인자로 받던 떄와는 다르게 MyUser라는 객체를 통해 바인딩을 한다.
이 때 Header에 participants라는 값이 없거나 participants == juno 라면 예외가 발생하는 것이 정상적인 시나리오여야한다.

하지만 결과는 위처럼 예외가 발생하지 않는 모습을 볼 수 있다.

어떠한 이유로 @RequestBody를 사용했을 때 ArgumentResolver가 동작하지 않는지 확인해보자.

@RequestBody

@RequestBody는 ArgumentResolver의 구현체인 RequestResponseBodyMethodProcessor에 의해 처리된다.

해당 구현체를 확인하면 다음과 같이 @RequestBody를 찾아서 사용한다는 것을 알 수 있다.

	@Override
	public boolean supportsParameter(MethodParameter parameter) {
		return parameter.hasParameterAnnotation(RequestBody.class);
	}

@MyValid, @RequestBody를 선언함으로써 CustomArgumentResolver,RequestResponseBodyMethodProcessor가 동작한다.

즉, 1개의 객체 생성을 위해 2개의 ArgumentResolver 처리 어노테이션이 붙어 있게 되는 꼴이다.

ArgumentResolver의 우선순위 상 @RequestBodty를 수행하는 ArgumentResolve의 우선순위가 더 높기 때문에 위와같은 현상이 발생한다.

다시말해 @RequestBody의 우선순위가 높고, RequestResponseBodyMethodProcessor를 거쳐 값이 이미 객체로 만들었기 때문에 기존의 ArgumentResolver로 동작하지 않는다.

객체를 만든 뒤의 처리방법?

우선순위 때문에 ArgumentResolver가 동작하지 않는다는 점을 알게 되었다.

그렇다면 어떻게 @RequestBody가 붙어있는 값에 대한 처리를 추가로 해줄 수 있을까?

RequestBodyAdvice

Spring에서는 RequestBodyAdvice라는 인터페이스를 제공하고있다.

해당 인터페이스를 상속받으면 @RequestBody 어노테이션이 선언된 부분에 대해서 추가적인 작업을 수행할 수 있다.

생성한 구현체를 사용하기 위해서는 RequestMappingHandlerAdapter에 구현체를 직접 등록하거나 @ControllerAdvice 어노테이션을 붙여주는 방법이 있다.

RequestBodyAdvice는 다음과 같은 메소드들을 제공한다. 해당 메소드들을 구현하면 @RequestBody의 동작을 커스터마이징할 수 있다.

public interface RequestBodyAdvice {

	boolean supports(MethodParameter methodParameter, Type targetType,
			Class<? extends HttpMessageConverter<?>> converterType);

	HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter,
			Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException;

	Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter,
			Type targetType, Class<? extends HttpMessageConverter<?>> converterType);

	@Nullable
	Object handleEmptyBody(@Nullable Object body, HttpInputMessage inputMessage, MethodParameter parameter,
			Type targetType, Class<? extends HttpMessageConverter<?>> converterType);
}
  • supports: 해당 RequestBodyAdvice를 적용할지 여부를 결정함
  • beforeBodyRead: body를 읽어 객체로 변환되기 전에 호출됨
  • afterBodyRead: body를 읽어 객체로 변환된 후에 호출됨
  • handleEmptyBody: body가 비어있을때 호출됨

구현하기

시나리오를 다시 구성해보자

@RestController
public class MyController {

    @GetMapping("/validTest")
    public MyUser argumentTest(@MyValid @RequestBody MyUser value) {
        return value;
    }
}

위와같은 요청이 있을 때

public class MyUser {
    private String name;
    private LocalDateTime time = null;

    MyUser() {
    }

    public String getName() {
        return name;
    }

    public LocalDateTime getTime() {
        return time;
    }

    public void setName(String name) {
        this.name = name;
    }

    public void setTime(LocalDateTime time) {
        this.time = time;
    }
}

MyUser 객체에 현재 시간을 넣어주는 동작을 추가해보자.

CustomRequestBodyAdvice

위에서 설명했던 RequestBodyAdvice를 상속받는 CustomRequestBodyAdvice를 생성해보자

supports()

@Override
public boolean supports(MethodParameter methodParameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
    return methodParameter.hasParameterAnnotation(MyValid.class) && targetType.getTypeName().equals(MyUser.class.getTypeName());
}

supports 메소드를 오버라이드했다.
@MyValid 어노테이션이 달려있고, 해당 객체의 타입이 MyUser인 경우 동작을 수행한다.

beforeBodyRead()

@Override
public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException {
    return inputMessage;
}

객체로 변환되기 전에 처리할 동작을 정의한다.
특별한 처리 없이 값을 반환한다.

handleEmptyBody

@Override
public Object handleEmptyBody(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
    return body;
}

body가 비어있을 때 동작을 정의한다.
특별한 처리 없이 값을 그대로 반환한다.

afterBodyRead()

@Override
public Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
    MyUser user = (MyUser) body;
    user.setTime(LocalDateTime.now());
    return user;
}

객체로 변환된 후의 동작을 정의한다.
이곳에서 객체에 현재 시간값을 넣어준다.

전체 코드

@RestControllerAdvice
public class CustomRequestBodyAdvice implements RequestBodyAdvice {

    @Override
    public boolean supports(MethodParameter methodParameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
        return methodParameter.hasParameterAnnotation(MyValid.class) && targetType.getTypeName().equals(MyUser.class.getTypeName());
    }

    @Override
    public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException {
        return inputMessage;
    }

    @Override
    public Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
        MyUser user = (MyUser) body;
        user.setTime(LocalDateTime.now());
        return user;
    }

    @Override
    public Object handleEmptyBody(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
        return body;
    }
}

따라서 CustomRequestBodyAdvice는 위와같이 구성된다.
Spring Bean 등록을 위해 클래스에 @RestControllerAdvice 어노테이션도 붙여준다.

실행하기

요청에 이름만 넣었지만 정상적으로 시간이 들어간 것을 확인할 수 있다.

다음과 같은 흐름으로 데이터가 처리되었음을 확인할 수 있다.

  1. @RequsetBody를 통해 요청값이 객체로 바인딩 되었다.
  2. CustomRequestBodyAdvice를 통해 @MyValid 어노테이션이 붙은 객체에 대해 다음 동작을 수행한다.
    a. 현재 시간을 생성된 MyUser에 넣는다.
  3. 결과적으로 파라미터값이 바인딩완료 된 순간에는 이름과 시간값이 모두 포함되어있다.

결론

RequestBodyAdvice를 상속받아 Custom하게 구현해봤다.

큰 흐름에서는 ArgumentResolver와 다를 것이 없지만 동일한 ArgumentResolver 동작 시 @RequestBody에 대한 ArgumentResolver가 우선순위를 가짐으로써 동작하지 않았기 때문에 위와 같은 흐름으로 학습을 진행해봤다.

AOP, Interceptor 등과 같은 방법을 이용할 수도 있겠지만 @RequestBody에 대한 처리라는 관심사로 본다면 RequestBodyAdvice를 사용하는것이 적절해보인다.

Reference

[Spring] @RequestBody에 ArgumentResolver(아규먼트 리졸버)가 동작하지 않는 이유, RequestBodyAdvice로 @RequestBody에 부가 기능 구현하기

Github / InvocableHandlerMethod

Docs : Interface RequestBodyAdvice

Stack Overflow : Spring MVC - Why not able to use @RequestBody and @RequestParam together

profile
안녕하세요 😆

0개의 댓글