본문 바로가기
Spring

[Spring] RequestBodyAdvice로 @RequestBody 커스텀하기

by doodoom 2023. 5. 8.

1. RequestBodyAdvice란?

@RequestBody가 붙은 파라미터 객체를 커스텀하고 싶다고 가정하자. 우리는 자연스럽게 ArgumentResolver를 통해 커스텀 하려고 할 것이다. 하지만 ArgumentResolver는 1개의 파라미터 객체에 1개의 ArgumentResolver만을 허락한다. 우선 순위를 커스텀하지 않는 이상 @RequestBody가 우선순위를 갖고, 우리가 설정한 ArgumentResolver는 동작하지 않을 것이다.

그렇다면 @RequestBody를 커스텀할 방법은 없을까? 당연히 아니다. 이미 스프링은 이런 요구를 반영해서 RequestBodyAdvice를 만들어놨다.
RequestBodyAdvice는 RequestBody를 커스텀 할 수 있는 기능을 제공한다.

이는 @RequestBody와 HttpEntity reqeust의 body가 객체로 변환되기 전과 전환되고 Controller로 들어가기 전에 해당 데이터를 커스텀할 수 있는 기능을 제공한다.

1.1 RequestBodyAdvice

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 : 이 구현체가 적용될지 확인하기 위한 메소드. 제일 먼저 호출됨.
beforeBodyRead : reqeust body가 읽히고 객체로 변환되기 전 실행 됨. 두번째로 호출
afterBodyRead : reqeust body가 객체로 변환된 후 실행됨. 세번째(마지막)으로 호출
handleEmptyBody : body가 비어있을 때 실행 됨. 이게 실행되면 beforeBodyRead, afterBodyRead가 실행되지 않는듯

해당 인터페이스를 구현한 객체를 RequestMappingHandlerAdapter로 바로 등록 해주거나, @ControllerAdvice 어노테이션을 붙혀 등록할 수 있다.

2. 예시

클라이언트가 회원가입을 위해 json으로 이메일과 비밀번호를 보내준다. 이때 controller에서는 encode된 값을 받으려고한다고 가정하자.

MemberCreateRequest

@Getter
@NoArgumentConstructor
@AllArgumentConstructor
public class MemberCreateRequest {

    private String email;
    private String password;
}

요청 객체이다.

MemberController

@RestController
@RequestMapping("/test")
public class TestController {

    @PostMapping("/member")
    public ResponseEntity<MemberCreateRequest> createMember(
        @RequestBody @EncodePassword final MemberCreateRequest request) {
        System.out.println("password = " + request.getPassword());
        return ResponseEntity.status(HttpStatus.CREATED).body(request);
    }
}

MemberCreateRequest를 인자로 받는데 앞에 @RequestBody @EncodePassword가 붙어있다.
제대로 작동한다면 password가 encoding 되어 출력되어야한다.

EncodePasswordRequestBodyAdvice

@ControllerAdvice
public class EncodePasswordRequestBodyAdvice implements RequestBodyAdvice {

    private final PasswordEncoder passwordEncoder = new PasswordEncoder();

    @Override
    public boolean supports(final MethodParameter methodParameter, final Type targetType, final Class<? extends HttpMessageConverter<?>> converterType) {
        return methodParameter.hasParameterAnnotation(EncodePassword.class)
            && targetType.equals(MemberCreateRequest.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 MemberCreateRequest request = (MemberCreateRequest) body;
        final String encodePassword = passwordEncoder.encode(request.getPassword());
        return new MemberCreateRequest(request.getEmail(), encodePassword);
    }

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

support 코드를 설명하자면 파라미터가 @EncodePassword어노테이션을 가지고있고, 타입이 MemberCreateRequest라면 작동한다는 뜻이다.
afterBodyRead코드는 이러한 객체를 받아서 비밀번호를 encode한 새로운 객체로 바꿔서 보내준다는 뜻이다.

@ControllerAdvice를 꼭 붙혀야 위 코드가 작동한다.

@SpringBootTest
@AutoConfigureMockMvc
class TestControllerTest {

    @Autowired
    private MockMvc mockMvc;
    @Autowired
    private ObjectMapper objectMapper;

    @Test
    public void test() throws Exception {
        // given
        final MemberCreateRequest request = new MemberCreateRequest("test@test.com", "password");
        final String jsonRequest = objectMapper.writeValueAsString(request);

        // when
        final ResultActions result = mockMvc.perform(post("test/member")
            .content(jsonRequest)
            .contentType(MediaType.APPLICATION_JSON)
        );

        // then
        result.andExpect(MockMvcResultMatchers.status().isCreated());
    }
}

실행결과

password = cGFzc3dvcmQ=

인코딩 되는 것을 볼 수 있다.

이 기능을 활용하면 @RequestBody가 붙은 객체를 마음대로 커스터마이징 하고, 부가기능을 구현할 수 있을 것 같다.
참고로 위 기능을 Response에 적용할 수 있는 ResponseBodyAdvice도 있다.