Development/Spring & Spring Boot

[Spring] WebClient로 외부 API를 호출하여 받은 JSON을 객체로 역직렬화할 때 대문자 필드가 바인딩되지 않는 문제

개발하는 곰돌이 2023. 6. 29. 18:29

문제의 배경

기존에 WebClient로 외부 API를 호출하여 받은 JSON을 Gson 라이브러리를 통해 JsonObject로 받아서 사용하고 있던 것을 좀 더 객체지향스럽게(?) 변경하기 위해 자바 객체로 받아서 처리하도록 리팩토링하는 작업을 하고 있었다. 아래는 API의 응답으로 받는 JSON과 기존에 JSON을 JsonObject로 받아서 사용하던 코드의 일부이다.

{
    "status": "success",
    "error": null,
    "listData": [
        {
            "CLIENT_CODE": "123456",
            "DATE": "2023-06-01",
            "AMOUNT": 12345
        },
        ...
    ]
}
public Mono<JsonObject> apiResponse(Map<String, String> params) {
    MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
    formData.setAll(params);
    
    return WebClient.create()
        .get()
        .uri(url, (uriBuilder -> uriBuilder.queryParams(formData).build()))
        .header(HttpHeaders.AUTHORIZATION, "Bearer " + authkey)
        .exchangeToMono(response -> {
            if (response.statusCode() == HttpStatus.OK) {
                return response.bodyToMono(String.class);
            } else {
                log.warn("{} API Error: {} {}; params: {};", this, response.statusCode(), response.statusCode().getReasonPhrase(), formData);
                return Mono.empty();
            }
        })
        .map(JsonParser::parseString)
        .map(JsonElement::getAsJsonObject);
}

Gson의 JsonObject는 WebClient에서 바로 받을 수가 없어서 String으로 받고 JsonParser를 통해 JsonElement로 변환하고, 그걸 다시 JsonObject로 변환하는 과정을 거쳤다.

 

이런식으로 Gson에서 제공하는 JsonObject를 사용하다보니 String으로 받은 다음에 파싱을 해야하는 것도 마음에 들지 않았다. 그리고 JsonObject가 사실상 Map<String, Object>와 다를바가 없다보니 객체지향적으로 사용하는 것도 불가능해서 객체지향적으로 코드를 짜는 김에 Gson도 덜어내기 위해 리팩토링을 진행해봤다.

문제 상황

일단 위의 JSON을 객체로 받기 위한 클래스를 아래와 같이 만들었다.

import java.util.List;

@Getter
public class JsonResponse {
    private String status;
    private String error;
    private List<Data> listData;
    
    @Getter
    public static class Data {
        private String CLIENT_CODE;
        private String DATE;
        private long AMOUNT;
    }
}

그리고 아래와 같이 API를 호출하는 코드를 수정하여 응답해주는 JSON을 받아보았다.

public Mono<JsonResponse> apiResponse(Map<String, String> params) {
    MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
    formData.setAll(params);
    
    return WebClient.create()
        .get()
        .uri(url, (uriBuilder -> uriBuilder.queryParams(formData).build()))
        .header(HttpHeaders.AUTHORIZATION, "Bearer " + authkey)
        .exchangeToMono(response -> {
            if (response.statusCode() == HttpStatus.OK) {
                return response.bodyToMono(JsonResponse.class);
            } else {
                log.warn("{} API Error: {} {}; params: {};", this, response.statusCode(), response.statusCode().getReasonPhrase(), formData);
                return Mono.empty();
            }
        });
}

받고자 하는 JSON
위 JSON을 WebClient로 받았을 때

그런데 statuslistData의 개수는 잘 받아지는데 정작 listData에 담긴 객체들의 정보는 전혀 담기지 않았다. Data의 내용만 빼면 전부 잘 들어오고 있어서 방법이 틀리진 않은 것 같은데 정작 Data의 내용이 전혀 들어오지 않고 있어서 해결법을 찾아봤다.

문제 원인

이 문제는 WebClient는 API를 호출하여 받은 JSON 응답을 객체로 역직렬화 할 때 Jackson 라이브러리를 사용하는데, Jackson 라이브러리의 필드 명명 규칙 때문에 발생한 문제였다. 정확히는 Getter를 통해 역직렬화를 해서 발생한 문제이다.

 

Getter의 기본 명명 규칙인 getXxx()의 경우 각 필드의 첫 글자가 대문자로 들어가게 된다. 즉, 원래 필드의 첫 글자가 대문자인지 소문자인지 알 수가 없는 것이다.

 

DefaultAccessorNamingStrategy 클래스에 있는 legacyManglePropertyName()의 내용을 한번 보자.

151번째 줄에서 접두사(get, is, set) 뒤의 첫 문자를 따고 161~165번째 줄에서 이 문자가 소문자라면 프로퍼티 이름을 반환한다. 이 경우에는 대문자이기 때문에 다음으로 넘어간다.

 

그 뒤에는 접두사를 제외한 basename의 두번째 문자부터 소문자가 나올 때까지 대문자를 소문자로 변환하게 된다. 결국 대문자 그대로 들어가지 않는 것이다.

해결법

3가지 방법으로 해결할 수 있었다.

필드의 접근 지정자를 public으로 변경

필드를 private으로 설정하고 Getter/Setter를 사용해서 간접적으로 데이터를 바인딩하지 않고 public 필드를 사용하여 직접 데이터를 바인딩하면 정상적으로 값이 들어가는 것을 볼 수 있다.

@Getter
public class JsonResponse {
    private String status;
    private String error;
    private List<Data> listData;

    public static class Data {
        public String CLIENT_CODE;
        public String DATE;
        public long AMOUNT;
    }
}

하지만 이 방법은 필드가 public으로 열리기 때문에 변경에 매우 취약한 상태가 되기 때문에 좋은 방법은 아니다.

필드에 @JsonProperty 어노테이션 추가

데이터를 바인딩할 필드들에 @JsonProperty 어노테이션을 추가해주면 어노테이션의 value로 설정한 JSON의 프로퍼티의 이름과 일치하는 것을 찾아서 바인딩한다. value를 설정하지 않으면 필드명이 그대로 사용된다.

@Getter
public class JsonResponse {
    private String status;
    private String error;
    private List<Data> listData;

    @Getter
    public static class Data {
        @JsonProperty
        private String CLIENT_CODE;
        @JsonProperty
        private String DATE;
        @JsonProperty
        private long AMOUNT;
    }
}

다만 이 방법은 필드의 개수가 늘어날 때마다 어노테이션을 추가해야 하기 때문에 약간 번거롭다는 단점이 있다.

(Java 16 이상) Record 클래스 사용

Record 클래스를 사용하면 아무 문제 없이 JSON의 데이터가 바인딩 되는 것을 확인할 수 있다.

public record JsonResponse(
        String status,
        String error,
        List<Data> listData
) {
    public record Data(
            String CLIENT_CODE,
            String DATE,
            long AMOUNT
    ) {}
}

public 필드를 사용할 때의 문제점도 없고, @JsonProperty 어노테이션을 사용할 때의 번거로움도 없다. 다만 자바 버전을 올리지 못하는 상황이라면 사용할 수 없다는 것이 단점이다.

결론

개인적으로는 Record 클래스를 사용하는게 가장 깔끔하고 문제 없이 사용할 수 있다고 생각한다. 물론 프로젝트 환경에 따라 무작정 자바 버전을 올리게 되면 문제가 발생할 수 있으니 자바 버전을 올릴 수 없는 상황이라면 @JsonProperty 어노테이션을 사용하는 것이 좋다고 생각한다.

 

그 외에도 커스텀 명명 규칙을 작성하여 사용할 수도 있다고 한다.