들어가며
스프링을 사용한 프로젝트에서 NHN KCP 결제 시스템을 연동하는 요구사항이 있었다.
우리 서버에서는 객체 필드명에 카멜 케이스를 사용하는데, 결제 서버에서는 스네이크 케이스로 보내주는 상황.
이를 @JsonProperty 로 매핑시키려고 했으나
해당 결제 서버에서 보내는 콜백 요청은 x-www-form-urlencded 를 사용 중이었다
따라서 Jackson 이 아닌 다른 컨버터가 사용되어 @JsonProperty 가 먹히지 않았다.
이를 커스텀 컨버터를 등록하여 JSON 으로 매핑시키도록 하여 해결해보자.
인코딩
- application/x-www-form-urlencoded
HTML 의 form 태그에서 서버에 전송할 때 주로 사용되는 방식이다.
말 그대로 데이터를 서버에 전송하기 전에 URL 인코딩하여 전송한다.
공백은 '+', 특수문자는 '%x' 형태로 치환되고 키 값 쌍이 '&' 으로 구분되는 형태이다.
(name=John Doe, age=23)
=> name=John+Doe&age=23
- application/json
JSON 형식으로 된 데이터를 전송하는 방법이다.
프론트엔드와 백엔드를 나누어 API 로 통신할 때 가장 익숙한 형태일 것이다.
{
"name": "John Doe",
"age": 23
}
Spring 의 요청 파싱 방법
Spring 은 여러 요청 방법을 Java 코드로 올바르게 파싱할 수 있도록 표준적인 컨버터를 제공한다.
public interface HttpMessageConverter<T> {
boolean canRead(Class<?> clazz, @Nullable MediaType mediaType);
boolean canWrite(Class<?> clazz, @Nullable MediaType mediaType);
List<MediaType> getSupportedMediaTypes();
default List<MediaType> getSupportedMediaTypes(Class<?> clazz) {
return !this.canRead(clazz, (MediaType)null) && !this.canWrite(clazz, (MediaType)null) ? Collections.emptyList() : this.getSupportedMediaTypes();
}
T read(Class<? extends T> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException;
void write(T t, @Nullable MediaType contentType, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException;
}
Spring MVC 는 HttpMessageConverter 인터페이스를 제공한다.
canRead, canWrite 메서드를 통해 해당 객체에 적용 가능한지 여부를 파악하는데
여기에서 HTTP Header 의 Content-Type, Accept 를 기반으로 적절한 구현체를 선택하게 된다.
또한 인터페이스이기 때문에 WebMvcConfigurer 를 구현하여 얼마든지 확장 가능하다.
public class FormHttpMessageConverter implements HttpMessageConverter<MultiValueMap<String, ?>> {
...
public FormHttpMessageConverter() {
this.charset = DEFAULT_CHARSET;
this.supportedMediaTypes.add(MediaType.APPLICATION_FORM_URLENCODED);
this.supportedMediaTypes.add(MediaType.MULTIPART_FORM_DATA);
this.supportedMediaTypes.add(MediaType.MULTIPART_MIXED);
this.supportedMediaTypes.add(MediaType.MULTIPART_RELATED);
this.partConverters.add(new ByteArrayHttpMessageConverter());
this.partConverters.add(new StringHttpMessageConverter());
this.partConverters.add(new ResourceHttpMessageConverter());
this.applyDefaultCharset();
}
public MultiValueMap<String, String> read(@Nullable Class<? extends MultiValueMap<String, ?>> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
MediaType contentType = inputMessage.getHeaders().getContentType();
Charset charset = contentType != null && contentType.getCharset() != null ? contentType.getCharset() : this.charset;
String body = StreamUtils.copyToString(inputMessage.getBody(), charset);
String[] pairs = StringUtils.tokenizeToStringArray(body, "&");
MultiValueMap<String, String> result = new LinkedMultiValueMap(pairs.length);
...
return result;
}
...
}
application/x-www-form-urlencoded 요청은 FormHttpMessageConverter 를 사용하여 파싱된다.
HTTP 요청의 데이터는 MultiValueMap 형태로 반환된다.
Jackson 관련 코드를 전혀 사용하지 않기 때문에 @JsonProperty 가 동작하지 않았던 것이다.
public class MappingJackson2HttpMessageConverter extends AbstractJackson2HttpMessageConverter {
...
public MappingJackson2HttpMessageConverter(ObjectMapper objectMapper) {
super(objectMapper, new MediaType[]{MediaType.APPLICATION_JSON, new MediaType("application", "*+json")});
}
}
public abstract class AbstractJackson2HttpMessageConverter extends AbstractGenericHttpMessageConverter<Object> {
...
protected AbstractJackson2HttpMessageConverter(ObjectMapper objectMapper, MediaType supportedMediaType) {
this(objectMapper);
this.setSupportedMediaTypes(Collections.singletonList(supportedMediaType));
}
public Object read(Type type, @Nullable Class<?> contextClass, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
JavaType javaType = this.getJavaType(type, contextClass);
return this.readJavaType(javaType, inputMessage);
}
private Object readJavaType(JavaType javaType, HttpInputMessage inputMessage) throws IOException {
MediaType contentType = inputMessage.getHeaders().getContentType();
Charset charset = this.getCharset(contentType);
ObjectMapper objectMapper = this.selectObjectMapper(javaType.getRawClass(), contentType);
...
}
}
application/json 은 MappingJackson2HttpMessageConverter 을 통해 처리된다.
내부적으로 ObjectMapper 를 사용하기 때문에 Jackson 관련 어노테이션을 모두 사용할 수 있다.
결론적으로 form 요청에 Jackson 관련 어노테이션을 사용하려면 Jackson Converter 를 사용하도록 해야 한다.
Custom FormHttpMessageConverter
이제 스프링의 Http 요청 파싱 방법을 알았으니 이를 응용하여 커스텀 컨버터를 구현해보자.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface JsonFormData {}
우선 모든 form 요청에 대해 적용하는 것이 아닌 특정 요청에 대해서만 적용할 것이기 때문에
이를 지정하기 위한 커스텀 어노테이션을 구현한다.
@SuppressWarnings("all")
public class JsonFormUrlEncodedConverter<T> extends AbstractHttpMessageConverter<T> {
/** JSON 매핑 안된 속성 오류 무시 */
private static final ObjectMapper objectMapper =
new ObjectMapper().configure(FAIL_ON_UNKNOWN_PROPERTIES, false);
private static final FormHttpMessageConverter converter = new FormHttpMessageConverter();
@Override
protected boolean supports(Class<?> clazz) {
return clazz.isAnnotationPresent(JsonFormData.class);
}
@Override
protected T readInternal(Class<? extends T> clazz, HttpInputMessage inputMessage)
throws IOException, HttpMessageNotReadableException {
Map<String, String> vals = converter.read(null, inputMessage).toSingleValueMap();
return objectMapper.convertValue(vals, clazz);
}
@Override
protected void writeInternal(T clazz, HttpOutputMessage outputMessage)
throws HttpMessageNotWritableException {}
}
이제 이를 사용하는 커스텀 컨버터를 구현한다.
AbstractHttpMessageConverter 를 상속하고 FormHttpMessageConverter 를 내부적으로 주입한다.
supports 메소드를 통해 convert 대상을 JsonFormData 를 메타 어노테이션으로 가지고 있는 클래스로 한정한다.
그리고 Map 으로 변환된 폼 요청 데이터를 objectMapper 를 통해 json 으로 변환하면 된다.
@Configuration
public class JsonFormUrlEncodedConverterConfiguration implements WebMvcConfigurer {
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
JsonFormUrlEncodedConverter<?> converter = new JsonFormUrlEncodedConverter<>();
MediaType mediaType = new MediaType(APPLICATION_FORM_URLENCODED, UTF_8);
converter.setSupportedMediaTypes(List.of(mediaType));
converters.add(converter);
}
}
마지막으로 Spring MVC 에 해당 컨버터를 등록해주면 끝이다.
WebMvcConfigurer 구현체를 만들어 MessageConverter 를 등록한다.
@Getter
@NoArgsConstructor
@JsonFormData
public class UserRequest {
@JsonProperty("user_name")
@NotNull
private String userName;
@JsonProperty("user_phone")
private String userPhone;
}
@JsonFormData 를 적용하고자 하는 클래스의 메타 어노테이션으로 지정해준다.
@RestController
@RequestMapping("/user")
public class UserController {
@PostMapping(value = "/callback", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public ResponseEntity<?> callback(@Valid @RequestBody UserRequest userRequest) {
return ResponseEntity.ok(UserResponse.builder()
.userName(userRequest.getUserName())
.userPhone(userRequest.getUserPhone())
.build());
}
}
컨트롤러 단에서는 consumes 와 @RequestBody 를 지정해준다.
이런 식으로 구현할 경우 java validation 의 @Valid 동작도 올바르게 수행되어 요청 검증이 쉬워진다.
application/x-www-form-urlencoded + @RequestBody 를 사용했음에도 요청이 올바르게 파싱된 모습
Validation 의 @NotNull 도 올바르게 동작한다.
마치며
코드 컨벤션이 다른 외부 API 를 연동하며 내부적인 컨벤션을 지키기 위해 고민했다.
그 과정에서 Spring 이 HttpMessageConverter 를 사용하고 확장하는 방식에 대해 알 수 있었다.
이를 응용하면 더 다양한 상황에서의 요청 필드 처리가 가능할 것 같다.
끝
'📡 백엔드 > 🌱 Spring Boot' 카테고리의 다른 글
[Spring] Jackson Module 을 이용한 Jackson 확장 (1) | 2024.03.25 |
---|---|
[Spring] Swagger 공통 응답 예시 커스터마이징 (0) | 2024.03.17 |
[Spring] spring-data-envers 를 이용한 엔티티 변경 이력 관리 (0) | 2024.03.10 |
[Spring] Redisson 분산락 AOP로 동시성 문제 해결하기 (트랜잭션 전파속성 NEVER 사용) (0) | 2023.07.06 |
[Spring] 스프링 소셜 로그인 OIDC 방식으로 구현하기 (OAuth with OpenID Connect) (1) | 2023.03.25 |