개요
Feign
은 HTTP 요청을 생성하는 과정을 단순화하는 선언적 웹 서비스 클라이언트입니다. 개인적으로 선호하는 클라이언트여서 SpringCloud의 의존성을 추가하는 번거로움을 감수하고라도 사용하고 있습니다. 하지만 종종 Response 의 필드가 애플리케이션에서 예상하는 케이스와 일치하지 않는 상황이 발생합니다. 이를 처리하기 위한 과정을 기록합니다.
GitHub Repository
문제상황
포스트 내에서 사용하는 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인 hyperStatPrest1
를 hyper_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
를 상속받습니다.
위에서 hyperStatPrest1
를 hyper_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
를 구현하여 Feign
의 Decoder
를 커스터마이징함으로써, JSON 데이터와 Java 객체 간의 매핑을 우리가 원하는 방식으로 세밀하게 조정할 수 있었다.
Feign
을 사용하는 데 있어서 다양한 네이밍 전략을 다루는 데 도움이 되길 바랍니다.
'개발' 카테고리의 다른 글
Java8 Lambda tutorial (4) | 2024.10.23 |
---|---|
ThreadPoolTaskExecutor와 CompletableFutre를 사용하여 비동기처리하기 (0) | 2024.09.30 |
분산환경은 로깅을 어떻게 할까 (1) | 2023.12.05 |
분산환경은 서비스 트레이싱을 어떻게 할까 (1) | 2023.11.30 |
@RequiredArgsConstructor와 @Qualifier를 함께 쓰기 (2) | 2023.11.28 |