@JsonNaming이 Query Parameter 또는 Form Data 요청에서 동작하지 않는 이유

Introduction

현재 일부 API는 요청으로 들어오는 request data와 응답으로 반환되는 json의 네이밍 컨벤션으로 snake case를 채택하고 있다. 최근의 가장 대표적인 사례로는 oauth2 스펙을 준수한 API들의 지원이 있었는데, 이러한 snake case 변수명들이 Java 코드에까지 침투하도록 두고 싶지는 않았다.

이를 해결하기 위해 지금까지는 jackson의 ObjectMapper의 직렬화/역직렬화 단계에서 카멜 케이스와 스네이크 케이스의 자동 변환을 수행할 수 있게 해주는 @JsonNaming 어노테이션을 이용해왔는데, 최근 유사한 이슈를 같은 방법으로 해결하려 했지만 동작하지 않았던 경험이 있어 이를 정리해보고자 한다.

 

 

@RequestBody의 Snake Case 매핑

@Getter
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class MyRequestDto {

    private long myId;

    private String myName;

}
@Getter
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@AllArgsConstructor
public class MyResponseDto {

    private long yourId;

    private String yourName;

    public static MyResponseDto of(MyRequestDto requestDto) {
        return new MyResponseDto(requestDto.getMyId(), requestDto.getMyName());
    }

}
@RestController
@RequestMapping("/my-info")
public class SnakeController {

    @PostMapping(value = "", produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<MyResponseDto> api(@RequestBody MyRequestDto requestDto) {
        return ResponseEntity.ok(MyResponseDto.of(requestDto));
    }

}

위와 같이 @JsonNaming 어노테이션의 SnakeCaseStrategy를 명시한 dto를 @RequestBody로 받게 되면, 클라이언트 측에서 snake case로 보내도 정상적으로 필드에 매핑된다. 이는 object mapper를 통해 역직렬화하는 단계에서 연결부 대문자가 있으면 이를 전부 언더바로 치환하는 동작을 수행해주기 때문이다.

아래와 같은 테스트를 돌려보자. 결과는 pass로, camel case 변수가 직렬화/역직렬화시 성공적으로 snake case로 변환되었음을 확인할 수 있다.

@WebMvcTest(controllers = SnakeController.class)
public class SnakeControllerTests {

    @Autowired
    MockMvc mvc;

    @Test
    void snakeRequestBody_mapping() throws Exception {
        // given
        long myId = 1;
        String myName = "panpan";

        // when
        ResultActions resultActions = mvc.perform(MockMvcRequestBuilders.post("/my-info")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\\"my_id\\": \\"%s\\", \\"my_name\\": \\"%s\\"}".formatted(myId, myName)));

        // then
        resultActions.andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(jsonPath("$.your_id").hasJsonPath())
                .andExpect(jsonPath("$.your_id").value(myId))
                .andExpect(jsonPath("$.your_name").hasJsonPath())
                .andExpect(jsonPath("$.your_name").value(myName));
    }

}

 

 

@ModelAttribute의 Snake Case 매핑

이번에는 같은 dto를 @ModelAttribute로 받는다고 하자. @ModelAttribute는 주로 query parameter나 form data를 받기 위해 사용된다.

@RestController
@RequestMapping("/snake-to-camel")
public class SnakeController {

    @PostMapping(value = "", produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<MyResponseDto> api(@ModelAttribute MyRequestDto requestDto) {
        return ResponseEntity.ok(MyResponseDto.of(requestDto));
    }

}

이제 테스트의 요청 빌더를 아래와 같이 수정하고 다시 테스트를 수행해보자.

주의할 점은, 이번에는 Request Dto에 Setter가 있어야 한다. 그 이유는 아래에서 설명하고 일단 setter를 추가하고 테스트를 돌려보자.

ResultActions resultActions = mvc.perform(MockMvcRequestBuilders.post("/my-info")
        .contentType(MediaType.MULTIPART_FORM_DATA)
        .param("my_id", String.valueOf(myId))
        .param("my_name", myName));

이번에는 테스트가 실패한다. 응답 바디를 보면 제대로 바인딩이 이루어지지 않았기 때문에 테스트가 실패했음을 알 수 있다.

MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"application/json"]
     Content type = application/json
             Body = {"your_id":0,"your_name":null}
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

왜 실패했을까?

원인은 정말 간단하다. @ModelAttribute는 요청 데이터 바인딩 과정에서 ObjectMapper를 사용하지 않기 때문이다.

Setter가 필요한 이유 또한 이와 관련이 있다. 바인딩 과정에서 ObjectMapper에 의해 데이터가 바인딩되는 @RequestBody는 리플렉션 기반이므로 Setter가 필요 없지만, @ModelAttribute는생성자를 통해 객체를 생성한 뒤 Setter로 데이터를 바인딩하기 때문에 Setter가 있어야 동작하는 것이다.

어떻게 해결할 수 있을까?

그렇다면 쿼리 파라미터 또는 폼 데이터로 snake case 값들을 받아 camel case에 매핑하고 싶을 때에는 어떻게 해야 할까?

크게 아래 2가지 방법을 채택할 수 있다.

  1. snake case → camel case 변환을 수행하는 Filter 만들기
    첫 번째 방법은, 요청이 매핑되기 전에 @ModelAttribute에 바인딩할 데이터를 담고 있는 HttpRequestServlet의 parameter map을 조작하는 것이다. 직접 해당 map에 담긴 key값들의 네이밍을 카멜 케이스로 수정해주면 정상적으로 매핑이 된다.
  2. snake case를 처리할 수 있는 All Args Constructor 또는 Setter 선언하기
    @ModelAttribute의 바인딩 과정을 이용하여 해결하는 약간 속임수같은 방법이다.
    아래와 같이 dto에 전체 인자를 받는 생성자 또는 Setter를 선언하면 된다.
@Getter
public class MyRequestDto {

    private long myId;

    private String myName;

    // 1. 전체 생성자로 매핑
    public MyRequestDto(long my_id, String my_name) {
        this.myId = my_id;
        this.myName = my_name;
    }

    // 2. setter로 매핑
    public void setMy_id(long myId) {
        this.myId = myId;
    }

    public void setMy_name(String myName) {
        this.myName = myName;
    }

}

@ModelAttribute의 바인딩은 우선 전체 필드를 초기화할 수 있는 생성자의 존재 여부를 확인한 뒤, 없다면 my_id라는 필드에 대한 java bean 표준 setter 메서드명을 가진 메서드가 있다면 이를 호출하는 방식으로 이루어진다. 그렇기 때문에, 이와 같은 트릭이 먹히는 것이라고 볼 수 있다.


정말 극히 일부의 dto에 대응하는 것이 목적이라면 2번을 사용해도 무방할 것 같지만, 영문 모를 생성자 또는 메서드들이 생기는 것이니 주석은 반드시 달아두어야 할 것이다. 또한, 이러한 변환을 수행할 API가 여러 개라면 (번거롭게 느껴지더라도) 가급적 1번 방식을 선택하는 것이 좋다고 보인다.