IT/PROGRAMMING

[Spring] spring-boot 버전 1.X와 2.X에서 Page 객체를 ObjectMapper(Jackson) 사용시 차이점

하마연구소장 2019. 2. 19. 12:09
728x90
반응형

최신 spring-boot 릴리즈 버전은 2.1.3 이다.
특별한 사유가 없는한 새로운 프로젝트를 시작한다면 이 버전으로 셋팅할 것이다.
하지만 2~3년전에 만들어진 프로젝트는 spring-boot 버전 1.X를 사용했을 것이다.

필자도 수년전에 개발하고 운영중인 시스템은 spring-boot 1.4.X가 적용되어 있으며, spring-data의 JPA로 DB에서 Pageable을 이용한 페이징과 정렬 처리를 하는 기능이 많다.
이 기능의 반환타입은 Page이며 실제 구현체는 PageImpl로 되어있다.
이 Page 객체를 @RestController의 응답값으로 곧바로 넘기고, ObjectMapper(Jackson)를 통하여 JSON 문자열로 변환된다.

최근 spring-boot 1.4.X를 2.X로 버전업하였고, 컴파일 오류도 잡고, 로컬환경 테스트도 하고, 실제 서버에서도 테스트하여 이상없이 동작하는 것을 확인하였다.
하지만 조금 지나서 모니터링 시스템에서 알람이 울리기 시작하였다.
spring-boot 버전업한 어플리케이션이 아니고, 이 어플리케이션에 REST API로 호출하는 다른 어플리케이션의 알람이었다.
확인해보니 2.X로 올리고 나서 REST API의 응답 포멧이 변경되었고, 이를 파싱하던 중 오류가 발생한 것이다.

뭐가 바뀐것이고? 왜 바뀌었을까?


아래는 "request -> Page 생성 -> response"의 간단한 샘플이다.
문자열 리스트를 Page 객체로 만들어서 반환하는 컨트롤러이며, 입력 Pageable은 기본값으로 id 프로퍼티(컬럼) 오름차순 정렬했다고 설정되었다.

@RestController
@RequestMapping("/page")
@Slf4j
public class PageController {
@GetMapping("/string")
public Page<String> getStringPage(@PageableDefault(size = 5, page = 0, sort = "id", direction = Sort.Direction.ASC) Pageable pageable) {
log.info("Pageable: {0}", pageable.toString());

// content list
List<String> stringList = new LinkedList<>();
stringList.add("STRING-1");
stringList.add("STRING-2");
stringList.add("STRING-3");
stringList.add("STRING-4");
stringList.add("STRING-5");

// 전체 개수
long totalCount = 100;

// Page 객체 생성
Page<String> page = new PageImpl<>(stringList, pageable, totalCount);

log.info("Page: {}", page.toString());

return page;
}
}


동일한 코드를 spring-boot 버전을 변경하고, http://127.0.0.1:8080/page/string 을 호출해보았다.


아래는 spring-boot 1.X 에서 결과이다.

  • spring-boot 1.5.19
  • spring-data-commons 1.13.18
{
"content": [
"STRING-1",
"STRING-2",
"STRING-3",
"STRING-4",
"STRING-5"
],
"totalElements": 100,
"last": false,
"totalPages": 20,
"size": 5,
"number": 0,
"numberOfElements": 5,
"sort": [
{
"direction": "ASC",
"property": "id",
"ignoreCase": false,
"nullHandling": "NATIVE",
"ascending": true,
"descending": false
}
],
"first": true
}


아래는 spring-boot 2.X 에서 결과이다.
  • spring-boot 2.1.3
  • spring-data-commons 2.1.5
{
"content": [
"STRING-1",
"STRING-2",
"STRING-3",
"STRING-4",
"STRING-5"
],
"pageable": {
"sort": {
"sorted": true,
"unsorted": false,
"empty": false
},
"offset": 0,
"pageSize": 5,
"pageNumber": 0,
"paged": true,
"unpaged": false
},
"totalPages": 20,
"totalElements": 100,
"last": false,
"size": 5,
"number": 0,
"numberOfElements": 5,
"first": true,
"sort": {
"sorted": true,
"unsorted": false,
"empty": false
},
"empty": false
}


다르다.
spring-boot 2.X의 응답값에 뭔가 더 많다.
자세히 살펴보면 pageable, empty 필드가 추가되었고 sort 필드의 값이 완전히 다르다.
대부분 이 응답값을 받은 클라이언트는 content 필드를 사용하 것이고, 페이징을 위하여 totalPages, totalElements, number, first, last 필드 정도를 사용하기 때문에 오류는 없을 것이다.
하지만 sort 객체를 사용했거나 다시 객체로 만드는 경우라면 오류가 발생할 것이다.

우선 뭐가 바뀌었는지 살펴보겠다.

아래는 spring-boot 1.X의 Sort 클래스 소스 일부이다.
Iterable 인퍼테이스를 구현하였다.

public class Sort implements Iterable<org.springframework.data.domain.Sort.Order>, Serializable {

.
.
.

}


아래는 spring-boot 2.X의 Sort 클래스의 소스 일부이다.
Streamable 인터페이스를 구현하였다.
또한, 기존과는 다르게 isSorted()와 isUnsorted() 메서드가 있다.
이 메서드가 이번 글의 핵심이다.
아래쪽에서 다시 언급하겠다.

public class Sort implements Streamable<org.springframework.data.domain.Sort.Order>, Serializable {

.

.

.

public boolean isSorted() {
return !orders.isEmpty();
}

public boolean isUnsorted() {
return !isSorted();
}

.

.

.


}


Streamable 인터페이스는 spring-data-commons에 포함되었으며, 스트림 처리를 위한 것으로 보인다.

/*
* Copyright 2016-2019 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.util;

import java.util.Arrays;
import java.util.Collections;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;

import org.springframework.util.Assert;

/**
* Simple interface to ease streamability of {@link Iterable}s.
*
* @author Oliver Gierke
* @author Christoph Strobl
* @since 2.0
*/
@FunctionalInterface
public interface Streamable<T> extends Iterable<T>, Supplier<Stream<T>> {

.

.

.


}


"아~~~ ObjectMapper(Jackson)이 이 Streamable 구현체를 다르게 처리하는구나!" 또는 "Iterable 처리가 바뀌었나?" 생각이 들었다.
하지만 아무리 Jackson 쪽 소스를 까봐도 Streamable 처리를 하는 곳은 못찾겠다.
Iterable을 변환하는 IterableSerializer을 사용하는 것은 기존과 동일하였다.
Jackson의 데이터바인딩 부분의 BasicSerializerFactory.findSerializerByAddonType() 와 BasicSerializerFactory.buildIteratorSerializer() 소스에서 처리해주고 있다.


package com.fasterxml.jackson.databind.ser;

.

.

.

/**
* Factory class that can provide serializers for standard JDK classes,
* as well as custom classes that extend standard classes or implement
* one of "well-known" interfaces (such as {@link java.util.Collection}).
*<p>
* Since all the serializers are eagerly instantiated, and there is
* no additional introspection or customizability of these types,
* this factory is essentially stateless.
*/
@SuppressWarnings("serial")
public abstract class BasicSerializerFactory
extends SerializerFactory
implements java.io.Serializable
{

.

.

.


/**
* Reflection-based serialized find method, which checks if
* given class implements one of recognized "add-on" interfaces.
* Add-on here means a role that is usually or can be a secondary
* trait: for example,
* bean classes may implement {@link Iterable}, but their main
* function is usually something else. The reason for
*/
protected final JsonSerializer<?> findSerializerByAddonType(SerializationConfig config,
JavaType javaType, BeanDescription beanDesc, boolean staticTyping) throws JsonMappingException
{
Class<?> rawType = javaType.getRawClass();

if (Iterator.class.isAssignableFrom(rawType)) {
JavaType[] params = config.getTypeFactory().findTypeParameters(javaType, Iterator.class);
JavaType vt = (params == null || params.length != 1) ?
TypeFactory.unknownType() : params[0];
return buildIteratorSerializer(config, javaType, beanDesc, staticTyping, vt);
}
if (Iterable.class.isAssignableFrom(rawType)) {
JavaType[] params = config.getTypeFactory().findTypeParameters(javaType, Iterable.class);
JavaType vt = (params == null || params.length != 1) ?
TypeFactory.unknownType() : params[0];
return buildIterableSerializer(config, javaType, beanDesc, staticTyping, vt);
}
if (CharSequence.class.isAssignableFrom(rawType)) {
return ToStringSerializer.instance;
}
return null;
}

.

.

.


/**
* @since 2.5
*/
protected JsonSerializer<?> buildIteratorSerializer(SerializationConfig config,
JavaType type, BeanDescription beanDesc, boolean staticTyping,
JavaType valueType)
throws JsonMappingException
{
return new IteratorSerializer(valueType, staticTyping, createTypeSerializer(config, valueType));
}
.

.

.


}


여러번 디버깅을 통하여 소스의 흐름을 계속 파악하다 보니, 눈에 걸리는 것이 있었다.
BasicSerializerFactory.findSerializerByAddonType()을 호출하는 _createSerializer2() 메서드가 있다
여기에서 순서대로 타입에 맞는 JsonSerializer를 찾아주는 것이며, 이도저도 아니면 마지막으로 findSerializerByAddonType()을 호출한다.
spring-boot 1.X에서는 Sort 객체는 findSerializerByAddonType()에서 IterableSerializer로 바인딩되는 것이다.

protected JsonSerializer<?> _createSerializer2(SerializerProvider prov,
JavaType type, BeanDescription beanDesc, boolean staticTyping)
throws JsonMappingException
{
JsonSerializer<?> ser = null;
final SerializationConfig config = prov.getConfig();

// Container types differ from non-container types
// (note: called method checks for module-provided serializers)
if (type.isContainerType()) {
if (!staticTyping) {
staticTyping = usesStaticTyping(config, beanDesc, null);
}
// 03-Aug-2012, tatu: As per [databind#40], may require POJO serializer...
ser = buildContainerSerializer(prov, type, beanDesc, staticTyping);
// Will return right away, since called method does post-processing:
if (ser != null) {
return ser;
}
} else {
if (type.isReferenceType()) {
ser = findReferenceSerializer(prov, (ReferenceType) type, beanDesc, staticTyping);
} else {
// Modules may provide serializers of POJO types:
for (Serializers serializers : customSerializers()) {
ser = serializers.findSerializer(config, type, beanDesc);
if (ser != null) {
break;
}
}
}
// 25-Jun-2015, tatu: Then JsonSerializable, @JsonValue etc. NOTE! Prior to 2.6,
// this call was BEFORE custom serializer lookup, which was wrong.
if (ser == null) {
ser = findSerializerByAnnotations(prov, type, beanDesc);
}
}

if (ser == null) {
// Otherwise, we will check "primary types"; both marker types that
// indicate specific handling (JsonSerializable), or main types that have
// precedence over container types
ser = findSerializerByLookup(type, config, beanDesc, staticTyping);
if (ser == null) {
ser = findSerializerByPrimaryType(prov, type, beanDesc, staticTyping);
if (ser == null) {
// And this is where this class comes in: if type is not a
// known "primary JDK type", perhaps it's a bean? We can still
// get a null, if we can't find a single suitable bean property.
ser = findBeanSerializer(prov, type, beanDesc);
// Finally: maybe we can still deal with it as an implementation of some basic JDK interface?
if (ser == null) {
ser = findSerializerByAddonType(config, type, beanDesc, staticTyping);
// 18-Sep-2014, tatu: Actually, as per [jackson-databind#539], need to get
// 'unknown' serializer assigned earlier, here, so that it gets properly
// post-processed
if (ser == null) {
ser = prov.getUnknownTypeSerializer(beanDesc.getBeanClass());
}
}
}
}
}
if (ser != null) {
// [databind#120]: Allow post-processing
if (_factoryConfig.hasSerializerModifiers()) {
for (BeanSerializerModifier mod : _factoryConfig.serializerModifiers()) {
ser = mod.modifySerializer(config, beanDesc, ser);
}
}
}
return ser;
}
하지만 spring-boot 2.X는 달랐다.
findSerializerByAddonType()을 호출하기 전에 findBeanSerializer()에서 BeanSerializer로 바인딩되었다.
따라서 이 BeanSerializer을 통하여 JSON 문자열로 변환되는 것이었다.
BeanSerializer는 getter나 isXxx 메서드를 호출하여 그 값으로 JSON 문자열을 만든다.

그럼 왜 BeanSerializer가 선택되었을까?
바로 Sort 객체의 isSorted()와 isUnsorted() 메서드 때문이다.
BeanSerializer를 바인딩할 것인가 하는 로직에 getter와 isXxx 메서드가 하나라도 있으면 선택된다.
또한, Streamable 인터페이스에는 isEmpty() 메서드도 default로 구현되어있다.
즉, 이 메서드들 때문에 IterableSerializer이 바인딩 안되었던 것이다.

  • 스프링 잘못일까? Jackson 잘못일까?
  • 아니면 Page 객체를 response로 사용한 내가 잘못일까?
  • spring-boot 버전업한 내가 잘못일까?


Page 객체, 정확하게는 Sort 객체를 spring-boot 버전 1.X와 동일하게 JSON 문자열로 반환하려면 어떻게 해야할까?
두 가지 방법이 생각난다.

1. "Sort 객체는 IterableSerializer를 사용하세요"라고 ObjectMapper에 알려준다.
아래와 같이 커스텀하게 생성한 ObjectMapper를 사용하면된다.
이때 IterableSerializer 생성자에 이것저것 넣어주어야 하는데, 솔직히 각 파라미터의 정확한 의미를 파악하지는 못했다.
정확한 의미를 파악하기 위해서는 Jackson 코어 부분을 살펴볼 필요가 있다.
코딩으로 생성하려니 너무 억지로 생성자를 호출하는 것 같다.

import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ser.std.IterableSerializer;
import com.fasterxml.jackson.databind.type.TypeFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.Sort;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;

@Configuration
public class ObjectMapperConfig {
@Bean
public ObjectMapper objectMapper() {
JavaType javaType = TypeFactory.defaultInstance().constructSimpleType(Sort.class, null);
IterableSerializer iterableSerializer = new IterableSerializer(javaType, false, null);

return Jackson2ObjectMapperBuilder
.json()
.serializerByType(Sort.class, iterableSerializer)
.build();
}
}


2. Sort 객체용 JsonSerializer를 만들어 등록한다.
※ 원문: https://wimdeblauwe.wordpress.com/2018/06/10/pageimpl-json-serialization-with-spring-boot-2/

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import org.springframework.boot.jackson.JsonComponent;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Sort;
 
import java.io.IOException;
 
@JsonComponent
public class PageImplJacksonSerializer extends JsonSerializer {
    @Override
    public void serialize(PageImpl page, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
        jsonGenerator.writeStartObject();
        jsonGenerator.writeObjectField("content", page.getContent());
        jsonGenerator.writeBooleanField("first", page.isFirst());
        jsonGenerator.writeBooleanField("last", page.isLast());
        jsonGenerator.writeNumberField("totalPages", page.getTotalPages());
        jsonGenerator.writeNumberField("totalElements", page.getTotalElements());
        jsonGenerator.writeNumberField("numberOfElements", page.getNumberOfElements());
        jsonGenerator.writeNumberField("size", page.getSize());
        jsonGenerator.writeNumberField("number", page.getNumber());
        Sort sort = page.getSort();
        jsonGenerator.writeArrayFieldStart("sort");
        for (Sort.Order order : sort) {
            jsonGenerator.writeStartObject();
            jsonGenerator.writeStringField("property", order.getProperty());
            jsonGenerator.writeStringField("direction", order.getDirection().name());
            jsonGenerator.writeBooleanField("ignoreCase", order.isIgnoreCase());
            jsonGenerator.writeStringField("nullHandling", order.getNullHandling().name());
            jsonGenerator.writeEndObject();
        }
        jsonGenerator.writeEndArray();
        jsonGenerator.writeEndObject();
    }

}


개인적으로 위 두 가지 방법 모두 마음에 들지 않는다.
1번과 2번 모두 스프링의 기능을 무시한다고 해야할까?
그래서 그냥 spring-boot 2.X가 기본으로 반환하는 JSON 타입을 적용했고, REST API 호출하여 오류나던 어플리케이션에서 sort 필드를 무시하도록 하였다.
소스를 살펴보니 sort 필드 값만 가져오지 실제로 그 값을 이용하지는 않았다.

다음에 Page 객체나 Sort 객체를 직접 JSON 문자열로 변환할 일이 있으면, 그때 다시 고민해보기로 했다.
Gson을 이용하면 어떤 결과가 나올지도 확인해볼 필요가 있다.



반응형