📡 백엔드/🌱 Spring Boot

[Spring] x-www-form-urlencoded 요청 JSON 으로 변환하여 받기

gengminy 2024. 4. 1. 15:48

들어가며

스프링을 사용한 프로젝트에서 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 를 사용하고 확장하는 방식에 대해 알 수 있었다.

이를 응용하면 더 다양한 상황에서의 요청 필드 처리가 가능할 것 같다.