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

안녕하세요, 하마연구소입니다.

현시점 최신 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 implementsjava.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을 이용하면 어떤 결과가 나올지도 확인해볼 필요가 있습니다.

감사합니다.

댓글

Popular Posts

AI 시대, SEO가 아닌 GEO에 포커싱해야 하는 이유

AI 메모리 HBM 외에 HBF도 주목

네이버 쇼핑 잘 나가네요, 구팡이 절대 강자인줄~