1. 개요

JSON 역직렬화에 대한 Spring의 기본 지원을 사용할 때 들어오는 JSON을 단일 요청 핸들러 매개변수에 매핑해야 합니다. 그러나 경우에 따라 보다 세분화된 메서드 서명을 선호합니다.

이 사용방법(예제)에서는 사용자 지정 HandlerMethodArgumentResolver 를 사용하여 JSON POST를 여러 강력한 형식의 매개 변수로 역직렬화하는 방법을 배웁니다.

2. 문제

먼저 JSON 역직렬화에 대한 Spring MVC의 기본 접근 방식의 제한 사항을 살펴보겠습니다.

2.1. 기본  @RequestBody 동작

예제 JSON 본문부터 시작하겠습니다.

{
   "firstName" : "John",
   "lastName"  :"Smith",
   "age" : 10,
   "address" : {
      "streetName" : "Example Street",
      "streetNumber" : "10A",
      "postalCode" : "1QW34",
      "city" : "Timisoara",
      "country" : "Romania"
   }
}

다음으로 JSON 입력과 일치하는 DTO 를 생성해 보겠습니다.

public class UserDto {
    private String firstName;
    private String lastName;
    private String age;
    private AddressDto address;

    // getters and setters
}
public class AddressDto {

    private String streetName;
    private String streetNumber;
    private String postalCode;
    private String city;
    private String country;

    // getters and setters
}

마지막으로 @RequestBody 어노테이션  을 사용하여 JSON 요청을 UserDto 로 역직렬화 하는 표준 접근 방식 을 사용합니다.

@Controller
@RequestMapping("/user")
public class UserController {

    @PostMapping("/process")
    public ResponseEntity process(@RequestBody UserDto user) {
        /* business processing */
        return ResponseEntity.ok()
            .body(user.toString());
    }
}

2.2. 제한 사항

위의 표준 솔루션의 주요 이점은 JSON POST를 수동으로 UserDto 개체로 역직렬화할 필요가 없다는 것입니다.

그러나 전체 JSON POST는 단일 요청 매개변수에 매핑되어야 합니다. 즉 , 예상되는 각 JSON 구조에 대해 별도의 POJO를 생성해야 하며 이 목적으로만 사용되는 클래스로 코드 기반을 오염시킵니다.

그 결과는 JSON 속성의 하위 집합만 필요할 때 특히 분명합니다. 위의 요청 핸들러에서는 사용자의 firstName 및  city 속성만 필요하지만 전체 UserDto 를 역직렬화해야 합니다 .

Spring을 사용 하면 자체 개발 DTO가 아닌 매개변수로 Map 또는  ObjectNode 를 사용할 수 있지만 둘 다 단일 매개변수 옵션입니다. DTO와 마찬가지로 모든 것이 함께 패키지됩니다. MapObjectNode 콘텐츠는 문자열 값 이므로 직접 객체로 마샬링해야 합니다. 이러한 옵션을 사용하면 일회용 DTO를 선언하지 않아도 되지만 훨씬 더 복잡해집니다.

3. 사용자 정의 HandlerMethodArgumentResolver

위의 제한 사항에 대한 해결책을 살펴보겠습니다. Spring MVC의  HandlerMethodArgumentResolver 를 사용하여 요청 핸들러에서 원하는 JSON 속성을 매개변수로 선언할 수 있습니다.

3.1. 컨트롤러 만들기

먼저 요청 처리기 매개변수를 JSON 경로에 매핑하는 데 사용할 수 있는 사용자 지정 어노테이션을 생성해 보겠습니다.

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface JsonArg {
    String value() default "";
}

다음으로 어노테이션을 사용하여 JSON POST 본문의 속성과 상관 관계가 있는 별도의 매개 변수로 firstNamecity 를 매핑하는 요청 처리기를 만듭니다.

@Controller
@RequestMapping("/user")
public class UserController {
    @PostMapping("/process/custom")
    public ResponseEntity process(@JsonArg("firstName") String firstName,
      @JsonArg("address.city") String city) {
        /* business processing */
        return ResponseEntity.ok()
            .body(String.format("{\"firstName\": %s, \"city\" : %s}", firstName, city));
    }
}

3.2. 사용자 지정  HandlerMethodArgumentResolver 만들기

Spring MVC가 들어오는 요청을 처리해야 하는 요청 핸들러를 결정한 후 매개변수를 자동으로 해결하려고 시도합니다. 여기에는 Spring MVC가 자동으로 수행할 수 없는 매개변수를 해결할 수 있는 경우 HandlerMethodArgumentResolver 인터페이스 를 구현하는 Spring 컨텍스트의 모든 빈을 통한 반복이 포함 됩니다.

@JsonArg 어노테이션이 달린 모든 요청 처리기 매개 변수를 처리할 HandlerMethodArgumentResolver 구현을 정의해 보겠습니다 .

public class JsonArgumentResolver implements HandlerMethodArgumentResolver {

    private static final String JSON_BODY_ATTRIBUTE = "JSON_REQUEST_BODY";

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

    @Override
    public Object resolveArgument(
      MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest,
      WebDataBinderFactory binderFactory) 
      throws Exception {
        String body = getRequestBody(webRequest);
        String jsonPath = Objects.requireNonNull(
          Objects.requireNonNull(parameter.getParameterAnnotation(JsonArg.class)).value());
        Class<?> parameterType = parameter.getParameterType();
        return JsonPath.parse(body).read(jsonPath, parameterType);
    }

    private String getRequestBody(NativeWebRequest webRequest) {
        HttpServletRequest servletRequest = Objects.requireNonNull(
          webRequest.getNativeRequest(HttpServletRequest.class));
        String jsonBody = (String) servletRequest.getAttribute(JSON_BODY_ATTRIBUTE);
        if (jsonBody == null) {
            try {
                jsonBody = IOUtils.toString(servletRequest.getInputStream());
                servletRequest.setAttribute(JSON_BODY_ATTRIBUTE, jsonBody);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
        return jsonBody;
    }
}

Spring은 이 클래스가 주어진 매개변수를 해결할 수 있는지 여부를 확인하기 위해 supportsParameter() 메소드를 사용합니다. 핸들러가 @JsonArg 어노테이션이 달린 모든 매개변수를 처리하기를 원하기 때문에 주어진 매개변수에 해당 어노테이션이 있으면 true 를 반환 합니다.

다음으로 resolveArgument() 메서드에서 JSON 본문을 추출한 다음 후속 호출을 위해 직접 액세스할 수 있도록 요청에 속성으로 첨부합니다. 그런 다음 @JsonArg 어노테이션에서 JSON 경로를 가져오고 리플렉션을 사용하여 매개변수 유형을 가져옵니다. JSON 경로 및 매개변수 유형 정보를 사용하여 JSON 본문의 개별 부분을 풍부한 개체로 역직렬화할 수 있습니다.

3.3. 사용자 지정 HandlerMethodArgumentResolver 등록

Spring MVC에서 JsonArgumentResolver 를 사용하려면 다음과 같이 등록해야 합니다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        JsonArgumentResolver jsonArgumentResolver = new JsonArgumentResolver();
        argumentResolvers.add(jsonArgumentResolver);
    }
}

JsonArgumentResolver 는 이제 @JsonArgs 어노테이션이 달린 모든 요청 핸들러 매개변수를 처리 합니다 . @JsonArgs 값이 유효한 JSON 경로 인지 확인해야 하지만 이는 모든 JSON 구조에 대해 별도의 POJO가 필요한 @RequestBody 접근 방식 보다 가벼운 프로세스 입니다.

3.4. 사용자 정의 유형과 함께 매개변수 사용

이것이 사용자 정의 Java 클래스에서도 작동함을 보여주기 위해 강력한 유형의 POJO 매개변수를 사용하여 요청 핸들러를 정의해 보겠습니다.

@PostMapping("/process/custompojo")
public ResponseEntity process(
  @JsonArg("firstName") String firstName, @JsonArg("lastName") String lastName,
  @JsonArg("address") AddressDto address) {
    /* business processing */
    return ResponseEntity.ok()
      .body(String.format("{\"firstName\": %s, \"lastName\": %s, \"address\" : %s}",
        firstName, lastName, address));
}

이제 AddressDto 를 별도의 매개변수로 매핑할 수 있습니다.

3.5. 사용자 지정 JsonArgumentResolver 테스트

JsonArgumentResolver 가 예상대로 작동하는지 증명하는 테스트 사례를 작성해 보겠습니다  .

@Test
void whenSendingAPostJSON_thenReturnFirstNameAndCity() throws Exception {

    String jsonString = "{\"firstName\":\"John\",\"lastName\":\"Smith\",\"age\":10,\"address\":{\"streetName\":\"Example Street\",\"streetNumber\":\"10A\",\"postalCode\":\"1QW34\",\"city\":\"Timisoara\",\"country\":\"Romania\"}}";
    
    mockMvc.perform(post("/user/process/custom").content(jsonString)
      .contentType(MediaType.APPLICATION_JSON)
      .accept(MediaType.APPLICATION_JSON))
      .andExpect(status().isOk())
      .andExpect(MockMvcResultMatchers.jsonPath("$.firstName").value("John"))
      .andExpect(MockMvcResultMatchers.jsonPath("$.city").value("Timisoara"));
}

다음으로 JSON을 POJO로 직접 구문 분석하는 두 번째 Endpoints을 호출하는 테스트를 작성해 보겠습니다.

@Test
void whenSendingAPostJSON_thenReturnUserAndAddress() throws Exception {
    String jsonString = "{\"firstName\":\"John\",\"lastName\":\"Smith\",\"address\":{\"streetName\":\"Example Street\",\"streetNumber\":\"10A\",\"postalCode\":\"1QW34\",\"city\":\"Timisoara\",\"country\":\"Romania\"}}";
    ObjectMapper mapper = new ObjectMapper();
    UserDto user = mapper.readValue(jsonString, UserDto.class);
    AddressDto address = user.getAddress();

    String mvcResult = mockMvc.perform(post("/user/process/custompojo").content(jsonString)
      .contentType(MediaType.APPLICATION_JSON)
      .accept(MediaType.APPLICATION_JSON))
      .andExpect(status().isOk())
      .andReturn()
      .getResponse()
      .getContentAsString();

    assertEquals(String.format("{\"firstName\": %s, \"lastName\": %s, \"address\" : %s}",
      user.getFirstName(), user.getLastName(), address), mvcResult);
}

4. 결론

이 기사에서는 Spring MVC의 기본 역직렬화 동작의 몇 가지 제한 사항을 살펴보고 이를 극복하기 위해 사용자 지정 HandlerMethodArgumentResolver 를 사용하는 방법을 배웠습니다.

항상 그렇듯이 이러한 예제의 코드는  GitHub 에서 사용할 수 있습니다 .

Generic footer banner