외부 API를 활용하다 보면 스네이크 케이스와 카멜케이스가 내부 프로젝트와 충돌하다 보니 관리에 힘든 부분이 발생합니다.
그렇다고 같은 키값을 객체를 나누어 내부에서 전달받을 경우에는 카멜케이스로 외부로 전달할때는 객체 매핑을 통해 스네이크 케이스로 나누는 방식은 유비보수 비용이 높게 책정 된다고 생각합니다.
물론 모든게 비동기 방식으로 처리 되는 경우에는 파라미터 형태를 JSON형식을 가정하고 ObjectMapper 혹은 Jackson 라이브러리의 어노테이션을 활용하면 됩니다.
하지만 페이지 이동을 포함하는 API 작동 시 FormData 형식으로 넘어오는 경우 매핑이 쉽지 않다는 점이 있습니다.
위의 문제에 대한 해결방안에 앞서 요청에 대한 간단한 원리를 설명드리겠습니다.
Http 통신에서 요청 되는 정보를 해당 객체에 매핑하여 서버로 전달하는 역할을 수행하게 됩니다.
Header에는 통신방식, 요청 주소, 브라우저 및 애플리케이션 정보 등이 들어있습니다.
해당 필드에서 주목해야 하는 부분은 Accept입니다.
개발자 도구를 통해 디버깅 할 경우 이와 같은 형태를 자주 보셨을 겁니다.
이때 application/x-www-form-urlencoded 타입으로 FormData가 넘어오게 된다면 ObjectMapper를 활용한 직렬화 의 경우 Map이든 객체로 받든 String 자료형을 기반으로 하는 JSON형태로 변환이 필요하며 어노테이션 방식의 경우 활용이 힘들게 됩니다.
물론 내부 통신의 경우 프론트에서 header를 변환시켜 넘기면 해결 가능한 부분이지만 외부 API의 경우는 제어가 어려운 상황에 빠지게 됩니다.
그럼 백엔드에서 해당 통신을 JSON으로 변환시켜야 하는데 이때 HttpMessageConverter 클래스를 활용하게 됩니다.
해당 클래스는 인터페이스 형태로 구성되어 있으며 헤더의 Accept부분과 Body를 종합하여 처리 방식을 선택하게 됩니다.
그렇기 때문에 외부 API 호출 결과 값이 application/x-www-form-urlencoded 해당 방식으로 넘어오게 된다면 @RequestBody, @ResponseBody 어노테이션을 활용한 객체 매핑에 어려움이 발생합니다.
이와 같은 문제 해결을 위해 해당 인터페이스를 상속받는 클래스와 커스텀 어노테이션을 생성하여 활용할 수 있습니다.
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface JsonFormConvert {
}
import com.certpia.attachuser.annotation.JsonFormConvert;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.HttpOutputMessage;
import org.springframework.http.converter.AbstractHttpMessageConverter;
import org.springframework.http.converter.FormHttpMessageConverter;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.http.converter.HttpMessageNotWritableException;
import java.io.IOException;
import java.util.Map;
@Slf4j
public class JsonFormMessageConverter<T> extends AbstractHttpMessageConverter<T> {
/* JSON 매핑 안된 속성 오류 무시 */
private static final ObjectMapper objectMapper =
new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
private static final FormHttpMessageConverter converter = new FormHttpMessageConverter();
@Override
protected boolean supports(Class<?> clazz) {
return clazz.isAnnotationPresent(JsonFormConvert.class);
}
@NotNull
@Override
protected T readInternal(@NotNull Class<? extends T> clazz, @NotNull HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
Map<String, String> param = converter.read(null, inputMessage).toSingleValueMap();
return objectMapper.convertValue(param, clazz);
}
@Override
protected void writeInternal(@NotNull T t, @NotNull HttpOutputMessage outputMessage) throws HttpMessageNotWritableException {
}
}
사실상 ObjectMapper가 여기서 생성되므로 해당 클래스에서 객체 매핑이 이루어져도 상관없다고 생각합니다. 다만 AbstractHttpMessageConverter 상속받아 JSON형태의 파싱역할만 수행하도록 하기 위하여 해당 과정은 구현하지 않았습니다.
import com.certpia.attachuser.util.JsonFormMessageConverter;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.nio.charset.StandardCharsets;
import java.util.List;
@Slf4j
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
JsonFormMessageConverter<?> converter = new JsonFormMessageConverter<>();
MediaType mediaType = new MediaType(MediaType.APPLICATION_FORM_URLENCODED, StandardCharsets.UTF_8);
converter.setSupportedMediaTypes(List.of(mediaType));
converters.add(converter);
}
import com.example.demo.annotation.JsonFormConvert;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@JsonFormConvert
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class Member {
private String name;
private int age;
private String userId;
private String userPassword;
}
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonProperty;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
public class Member {
private String name;
private int age;
@JsonProperty("user_id")
private String userId;
@JsonProperty("user_password")
private String userPassword;
}
import org.springframework.stereotype.Controller;
@Controller
public class JoinController {
@PostMappping("/join")
public String join(@RequestBody Member member) {
system.out.println("객체 매핑 결과 확인 : ", member.toString());
return "success"
}
}