[Spring] x-www-form-urlencoded 요청 JSON 으로 변환하여 받기
들어가며
스프링을 사용한 프로젝트에서 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 를 사용하고 확장하는 방식에 대해 알 수 있었다.
이를 응용하면 더 다양한 상황에서의 요청 필드 처리가 가능할 것 같다.
끝