개발

FeignClient에서 네이밍 전략 변환하기

autocat 2024. 1. 11. 17:20

개요

Feign은 HTTP 요청을 생성하는 과정을 단순화하는 선언적 웹 서비스 클라이언트입니다. 개인적으로 선호하는 클라이언트여서 SpringCloud의 의존성을 추가하는 번거로움을 감수하고라도 사용하고 있습니다. 하지만 종종 Response 의 필드가 애플리케이션에서 예상하는 케이스와 일치하지 않는 상황이 발생합니다. 이를 처리하기 위한 과정을 기록합니다.
GitHub Repository

문제상황

Drawing 2024-01-11 10 47 57 excalidraw

포스트 내에서 사용하는 Application Server는 SpringBoot 프로젝트를 의미하고,
API Server는 외부에서 정보를 받아오는 서버로 용어를 사용할 예정이다.

API Server의 필드는 snake_case를 사용하고 아래와 같은 JSON을 반환한다.

{
  "date": "2023-12-21T00:00+09:00",
  "character_class": "미하일",
  "use_preset_no": "1",
  "use_available_hyper_stat": 0,
  "hyper_stat_preset_1": [{...}]
 ...
}

그리고 모두가 알다싶이 Java진영은 camelCase를 암묵적인 네이밍컨벤션으로 사용하므로 Application Server의 DTO는 아래와 같이 작성한다.

public class Character{
    private OffsetDateTime date;
    private String characterClass;
    private String usePresetNo;
    private Integer useAvailableHyperStat;
    private List<HyperStatPreset> hyperStatPreset1;
    ...
}

서로 다른 네이밍전략을 가진 데이터를 핸들링할때의 불편함을 먼저 얘기하려고 한다.

CustomDecoder 없이 처리해보는 과정

1. @JsonProperty 사용

@JsonProperty 어노테이션은 JSON 필드명과 DTO 필드명을 맵핑한다.

public class Character {

    @JsonProperty("character_class")
    private String characterClass;
    ...
}

번거롭게 필드에 어노테이션을 달아주고 JSON 필드명을 적어줘야한다.

public class CharacterService{
    ...
    // OUTPUT : CharacterClass is 아크
    log.info("CharacterClass is {}", character.getCharacterClass());
    ...
}

데이터 핸들링에 어려움을 갖지 않고 데이터 맵핑된것은 확인 할 수있다.
하지만 위 DTO를 ApplicationServer API의 Response로 사용하면 JSON으로 직렬화를 하면서 아래와 같은 결과를 리턴한다.

{
  "date": "2024-01-08T15:00:00Z",
  "character_class": "아크",
  "use_preset_no": "1",
  "use_available_hyper_stat": 1500,
  "hyper_stat_preset_1_remain_point": 0,
  ...
}

DTO가 JSON으로 직렬화 되는 과정에서 JSON 필드 name은 역시나 @JsonProperty를 기본으로 하여 맵핑한다.
즉 어노테이션에 의해 직렬화와 역직렬화가 수행되면서 데이터 핸들링에는 문제가 없다.
하지만 이 상태로 사용하는 것이 과연 옳을까?

  • API Response에 snake_case를 표현할거라면 DTO를 카멜로 생성할 필요가 있었을까?
  • 진영의 규약으로 인해 내부에서 핸들링을 하기위해 컨버팅을 했는데 Application Server의 return 또한 동일해야하지 않을까?

    위와 같은 의문이 생기며 위 방식은 옳지 않다고 생각한다.

2. @JsonNaming 사용

@JsonNaming("SomeNamingStrategy.class") 어노테이션이 선언된 DTO는 기존의 글로벌 네이밍 전략을 선언한 클래스로 덮어쓴다.

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class Character {
    ...
}

Jackson은 relection을 사용해 맵핑할 필드를 찾아오는데 직렬화에는 Java Object의 선언된 Getter method로, 역직렬화에는 Setter method로 필드명을 찾아서 맵핑한다.

{
  "date": "2024-01-08T15:00:00Z",
  "character_class": "아크",
  "use_preset_no": "1",
  "use_available_hyper_stat": 1500,
  "hyper_stat_preset1_remain_point": null,
  "hyper_stat_preset1": null,
  "hyper_stat_preset2_remain_point": null,
  "hyper_stat_preset2": null,
  "hyper_stat_preset3_remain_point": null,
  "hyper_stat_preset3": null
}

위와 같은 ApplicationServer의 Response JSON 이 반환되었는데 hyper_stat_preset1_remain_point 필드를 보면 기존 JSON형태와 다른걸 확인 할 수 있다. getHyperStatPreset1RemainPoint 에서 필드를 유추하는 과정에서 의도와 다르게 컨버팅이 되어서 결과적으로 필드명도 다르고 맵핑도 안된것이다.

3. PropertyNamingStrategy 사용

오직 FeignClient의 Decoder 내 obejectMapper 전략에 snake_case 전략을 주입하는 방법을 사용하여 역직렬화에만 사용 할 수 있다. Decoder를 사용하기위해 Feign용 configuration class를 생성해준다.

// @Configuration을 사용하지 않는다. 이유는 추후에 설명
public class FeignConfig{
    @Bean  
    Decoder feignDecoder() {  
        ObjectFactory<HttpMessageConverters> objectFactory = () -> new HttpMessageConverters(  
                new MappingJackson2HttpMessageConverter(customObjectMapper()));  
        return new OptionalDecoder(new ResponseEntityDecoder(new SpringDecoder(objectFactory))); // SpringDecoder(ObjectFactory) 생성자가 Deprecated되었다가, v4.1.0에 reverted 됬다(inopenFeign github issues)

    }  

    private ObjectMapper customObjectMapper() {  
        ObjectMapper objectMapper = new ObjectMapper();  
        objectMapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);  
        objectMapper.registerModule(new JavaTimeModule());  
        return objectMapper;  

    };
}

이렇게 해서 테스트를 하면 아래와 같은 오류가 발생한다.

com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException: Unrecognized field "hyper_stat_preset_1" (class com.autocat.playground.feign_with_decoder.dto.CharacterHyperStat), not marked as ignorable (10 known properties: "hyper_stat_preset3_remain_point", "hyper_stat_preset2_remain_point", "character_class", "use_available_hyper_stat", "use_preset_no", "hyper_stat_preset1", "hyper_stat_preset2", "hyper_stat_preset3", "date", "hyper_stat_preset1_remain_point"])
 at [Source: (org.springframework.util.StreamUtils$NonClosingInputStream); line: 1, column: 136] (through reference chain: com.autocat.playground.feign_with_decoder.dto.CharacterHyperStat["hyper_stat_preset_1"])

2번 3번 방식은 Jackson에 의해 실행되어 크게 다르지 않다. objectMapper에
DTO 필드d인 hyperStatPrest1hyper_stat_preset_1 로 변환되길 기대했지만, hyper_stat_preset1 와 같이 변환되면서 컨버팅 할 필드를 찾지 못해 에러가 발생하였다.

objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

위 코드를 추가했다면 오류없이 2번과 똑같은 결과를 반환할것이다.

위와 같은 제한들 때문에 Decoder를 사용하지 않으면 내가 원하는 형식의 표현에 제한이 생김을 알 수 있다.

왜 위에서 @Configuration 을 뺐는가?

openFeign.Overriding Feign Defaults
레퍼런스를 보고 의역한 내용이니 의도와 다르게 해석 될 가능성이 있습니다. 잘못된 부분에 대해서 지적해주시면 감사히 받아들이겠습니다.

Spring Cloud의 Feign이 지원하는 핵심개념은 명명된 클라이언트(named client)이다. 각각의 클라이언트들은 서버와 통신할때 함께 작동하는 구성요소(ensemble)들의 컴포넌트(component)이며
개발자가 정의한 @FeignClient을 통해 명명된다. Spring cloud는 FeignClientsConfiguration클래스를 통해 각각 명명된 클라이언트의 요구에 따라 새로운 ApplicationContext로서 컴포넌트를 생성합니다. 이 컴포넌트에는 feign.Decoder, feign.Encoder, feign.Contract 등이 포함되어 있습니다.

위 단락에서 FeignClient의 configuration 속성에 사용되는 클래스에 @Configuration 어노테이션이 필요 없는 이유가 나타난다.

// org.springframework.cloud.openfeign.FeignClientsConfiguration
@Configuration(proxyBeanMethods = false)
public class FeignClientConfiguration{

    // Some Beans
    ...
}

해당 클래스파일을 들어가서 보면 proxyBeanMethods = false 를 볼 수 있다. 즉 Spring에게 이 설정클래스는 프록시를 생성하여 Context에서 Bean을 가져올 필요 없이 내가 관리 하겠다고 하는 말이다.
만약에 여러개의 설정이 서로 다른 클라이언트를 사용할때. @Configuration을 통해 다른 설정들을 오버라이딩 하는것을 방지하기위해 위와 같이 설정한것으로 예상된다.

Decoder 사용

이제 제대로 네이밍 전략을 바꾸기 위해 CustomPropertyNamingStrategy 클래스를 생성하고 PropertyNamingStrategies.SnakeCaseStrategy를 상속받습니다.
위에서 hyperStatPrest1hyper_stat_preset1 로 변경했으니 내가 원하는 형식으로 나올수 있도록 역직렬화에 사용되는 메서드를 override하고 수정한다.

@Slf4j
public class CustomPropertyNamingStrategy extends PropertyNamingStrategies.SnakeCaseStrategy {
    

    @Override
    public String nameForSetterMethod(MapperConfig<?> config, AnnotatedMethod method, String defaultName) {
        log.info("nameForSetterMethod: {}", defaultName);
        return convertSnakeCaseToCamelCaseName(defaultName);
    }

    private String convertSnakeCaseToCamelCaseName(String input) {
        StringBuilder stringBuilder = new StringBuilder();

        boolean lastCharWasUpper = false;

        for (int i = 0; i < input.length(); i++) {
            char currentChar = input.charAt(i);

            if (Character.isUpperCase(currentChar)) {

                if (i > 0 && ((Character.isLowerCase(input.charAt(i - 1)) || Character.isDigit(input.charAt(i - 1))))) {
                    stringBuilder.append('_');
                }
                if (i > 0 && lastCharWasUpper && (i < input.length() - 1 && Character.isLowerCase(input.charAt(i + 1)))) {
                    stringBuilder.append('_');
                }
                stringBuilder.append(Character.toLowerCase(currentChar));
                lastCharWasUpper = true;
            } else if (Character.isLowerCase(currentChar)) {
                if (i > 0 && Character.isDigit(input.charAt(i - 1))) {
                    stringBuilder.append('_');
                }
                stringBuilder.append(currentChar);
                lastCharWasUpper = false;
            } else if (Character.isDigit(currentChar)) {
                if (i > 0 && (Character.isLetter(input.charAt(i - 1)))) {
                    stringBuilder.append('_');
                }
                stringBuilder.append(currentChar);
                lastCharWasUpper = false;
            }
        }
        log.info("converted name: {}", stringBuilder.toString());
        return stringBuilder.toString();
    }
}

아까 사용했던 FeignConfiguration 클래스의 .setNamingStrategy() 를 수정한다.

public class FeignConfig{
    ...
    private ObjectMapper customObjectMapper() {  
        ObjectMapper objectMapper = new ObjectMapper();  
        objectMapper.setPropertyNamingStrategy(new CustomPropertyNamingStrategy());  
        objectMapper.registerModule(new JavaTimeModule());  
        return objectMapper;  

    };
}

그리고 Application Server의 Response를 확인해본다.

2024-01-11T16:33:42.056+09:00  INFO 19856 --- [nio-8080-exec-1] c.a.p.f.c.CustomPropertyNamingStrategy   : nameForSetterMethod: hyperStatPreset1RemainPoint
2024-01-11T16:33:42.056+09:00  INFO 19856 --- [nio-8080-exec-1] c.a.p.f.c.CustomPropertyNamingStrategy   : converted name: hyper_stat_preset_1_remain_point

로그에도 원하는대로 변하는것을 확인 하고

{
  "useAvailableHyperStat": 1500,
  "hyperStatPreset1RemainPoint": 0,
  ...
}

응답도 원하는 형태로 나왔다.

마무리

Feign을 사용하여 서로 다른 네이밍 전략을 가진 데이터를 효율적으로 핸들링하는 방법을 살펴보았다. 처음에는 @JsonProperty@JsonNaming을 사용하여 문제를 해결하려 했지만, 이들은 원하는 결과를 얻는데 제한적이었고, 결국 CustomPropertyNamingStrategy를 구현하여 FeignDecoder를 커스터마이징함으로써, JSON 데이터와 Java 객체 간의 매핑을 우리가 원하는 방식으로 세밀하게 조정할 수 있었다.

Feign을 사용하는 데 있어서 다양한 네이밍 전략을 다루는 데 도움이 되길 바랍니다.