[Spring Boot] Page 객체 반환 시 ‘PlainPageSerializationWarning’warning 발생

발생 상황

특정 원데이 클래스에 대해 페이징 된 리뷰 목록을 반환하기 위해 Response DTO 를 Page 객체로 한번 감싸서 반환하고 있습니다.

    @GetMapping("/{classId}/reviews")
    public ResponseEntity<SuccessResponse<Page<GetReviewResponse>>> getClassReviews(
            @PathVariable Long classId,
            @PageableDefault Pageable pageable
    ) {
        return ResponseEntity.ok().body(
                SuccessResponse.of(
                        ResponseMessage.REVIEW_GET_SUCCESS,
                        reviewService.getClassReviews(classId, pageable)
                )
        );
    }

이렇게 Page 객체를 그대로 반환할 경우 , Postman 등으로 테스트를 해보면 다음과 같은 경고 메시지가 발생합니다.

2024-06-03 16:55:33.448 [http-nio-8080-exec-2] WARN org.springframework.data.web.config.SpringDataJacksonConfiguration$PageModule$PlainPageSerializationWarning - Serializing PageImpl instances as-is is not supported, meaning that there is no guarantee about the stability of the resulting JSON structure! For a stable JSON structure, please use Spring Data's PagedModel (globally via @EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO)) or Spring HATEOAS and Spring Data's PagedResourcesAssembler as documented in
https://docs.spring.io/spring-data/commons/reference/repositories/core-extensions.html#core.web.pageables.

대략적인 뜻은, 다음과 같습니다.

PageImpl 인스턴스를 그대로 직렬화하는 것은 지원되지 않으므로 반환하는 JSON 구조에 대한 안정성을 보장하지 않으니. 안정적인 JSON 구조를 위해 Spring Data의 PagedModel이나 Spring HATEOAS , Spring Data의 PagedResourcesAssembler 를 사용해주세요.

즉, 저는 Page 객체를 그대로 반환하고 있는데 PageImpl 클래스는 Page 인터페이스의 기본 구현체입니다. 이러한 PageImpl 인스턴스를 그대로 반환하지 말고, 제시하는 두 가지 방법 중 하나를 사용하여 반환하라 라는 말입니다.

원인

그 이유에 대해서 위 링크의 공식 문서에서는 다음과 같이 설명하고 있습니다.

It’s common for Spring MVC controllers to try to ultimately render a representation of a Spring Data page to clients. While one could simply return Page instances from handler methods to let Jackson render them as is, we strongly recommend against this as the underlying implementation class PageImpl is a domain type. This means we might want or have to change its API for unrelated reasons, and such changes might alter the resulting JSON representation in a breaking way.

 

Spring Data Extensions :: Spring Data Commons

If you work with the Spring JDBC module, you are probably familiar with the support for populating a DataSource with SQL scripts. A similar abstraction is available on the repositories level, although it does not use SQL as the data definition language bec

docs.spring.io

 

Page 인스턴스를 그대로 반환하는 것이 간단하게 Spring MVC 컨트롤러가 스프링 데이터 Page의 표현(representation)을 렌더링 하는 방법이지만, 이것을 강력하게 권장하지 않는다고 합니다.

그 이유는 Page 인터페이스의 기본 구현체인 PageImpl 이 도메인(Domain) 타입이기 때문입니다. 도메인 타입이기 때문에 PageImpl의 API가 어떠한 이유로 변경될 수 있고, 이는 결과적으로 생성되는 JSON 형이 변경될 수 있습니다.

그렇기에 Spring Data 3.1에서 부터 해당 경고를 표시하기 시작했다고 합니다. 또한 궁극적으로는 Spring HATEOAS 로의 통합을 권장하지만, 3.3부터 편리하고 Spring HATEOAS 를 포함할 필요가 없는 페이지 렌더링 메커니즘을 제공한다고 합니다.

참고

HATEOAS란 ?

HATEOAS는 "Hypermedia as the Engine of Application State"의 약자로, RESTful API 설계 원칙 중 하나로, 클라이언트와 서버 간의 상호작용을 하이퍼미디어를 통해 안내하는 방법입니다. 이를 통해 클라이언트는 응답에서 제공되는 링크를 통해 서버에서 추가적인 리소스를 탐색하고 상호작용할 수 있습니다.

 

HATEOAS - Wikipedia

From Wikipedia, the free encyclopedia Distributed computing constraint Hypermedia as the engine of application state (HATEOAS) is a constraint of the REST application architecture that distinguishes it from other network application architectures. With HAT

en.wikipedia.org

 

예를 들면, 다음과 같은 응답이 HATEOAS가 적용된 응답입니다.

{
  "id": 1,
  "name": "Sample Item",
  "price": 100,
  "_links": {
    "self": {
      "href": "<http://api.example.com/items/1>"
    },
    "update": {
      "href": "<http://api.example.com/items/1>"
    },
    "delete": {
      "href": "<http://api.example.com/items/1>"
    },
    "related-items": {
      "href": "<http://api.example.com/items/1/related>"
    }
  }
}

_link 섹션에서 클라이언트가 사용할 수 있는 여러 링크를 제공하여, 이 링크를 따라가서 수정, 삭제 요청을 할 수 있습니다.

이러한 HATEOAS 원칙의 적용을 도와주는 라이브러리가 Spring HATEOAS 입니다.

해결방법

그리고 이에 대한 해결 방법으로 크게 2가지를 제공하고 있습니다.

첫 번째로, 다음과 같이 PagedModel 객체로 변환하여 반환하는 것입니다.

@Controller
class MyController {

  private final MyRepository repository;

  // Constructor ommitted

  @GetMapping("/page")
  PagedModel<?> page(Pageable pageable) {
    return new PagedModel<>(repository.findAll(pageable)); 
  }
}

PagedModel 클래스는 위와 같이 스프링 데이터 Page 의 DTO를 안정적인 JSON 표현으로 생성해 줍니다. 또한 내부의 getMetadata() 라는 메서드에서 페이지 정보의 메타데이터를 반환하는 것을 알 수 있습니다. 따라서 PagedModel 로 반환 할 경우 형식은 다음과 같습니다.

{
  "content" : [
     … // Page content rendered here
  ],
  "page" : {
    "size" : 20,
    "totalElements" : 30,
    "totalPages" : 2,
    "number" : 0
  }
}

content 에는 렌더링 할 데이터 (DTO)가 들어가게 되고, page 에 위의 getMedatadata() 메서드로 생성된 메타데이터가 들어가게 됩니다. 이를 통해 클라이언트에서 이 메타데이터를 이용해 페이징 처리를 할 수 있게 됩니다.

그러나 이 방법의 경우, 기존 컨트롤러의 반환 타입을 PagedModel로 모두 바꿔야 하는 번거로움이 있습니다. 이에 대해 공식 문서에서는 다음과 같은 방법을 제공해주고 있습니다.

If you don’t want to change all your existing controllers to add the mapping step to return PagedModel instead of Page you can enable the automatic translation of PageImpl instances into PagedModel by tweaking @EnableSpringDataWebSupport as follows:

@EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO)
class MyConfiguration { }

전역적으로 @EnableSpringDataWebSupport 어노테이션을 적용할 경우 자동으로 PageImpl 인스턴스를 PagedModel 로 바꿔 줄 수 있습니다. 기존 코드를 변경 할 필요도 없고, config 파일 하나만 추가해 해당 어노테이션을 적용시켜 주면 되니 편리하다고 생각하여 저는 최종적으로 이 방식을 적용 하였 습니다.

@EnableSpringDataWebSupport(pageSerializationMode = *VIA_DTO*) 에서 pageSerializationMode 는 기본값으로 DIRECT 가 적용되어 있고, VIA_DTO 는 페이징된 데이터를 JSON 형식으로 직렬화 할 때 PagedModel 구조로 변환하겠다는 뜻 입니다.

두번째, 방법으로는 PagedResourcesAssembler를 사용하여 Page 인스턴스를 변환하는 것입니다.

@Controller
class PersonController {

  private final PersonRepository repository;

  @GetMapping("/people")
  HttpEntity<PagedModel<Person>> people(Pageable pageable, PagedResourcesAssembler<Person> assembler) {
    Page<Person> people = repository.findAll(pageable);
    return ResponseEntity.ok(assembler.toModel(people));
  }
}

이 방법은 PagedResourcesAssembler 를 사용하여 Page 객체를 PagedModel 로 변환합니다.

PagedResourcesAssembler 클래스는 Page 객체를 받아 각 엔티티를 감싸는 모델로 변환하고, 이를 PagedModel 로 변환합니다. 또한 링크를 만들어 주는 메서드를 가지고 있습니다.

이 방법을 이용할 경우 아래와 같은 응답을 제공합니다. PagedModel 로 변환하는 것에 더해 다음 페이지의 링크를 제공하여, 클라이언트에서 이 링크를 통해 다음 페이지의 요청을 쉽게 할 수 있습니다.

PagedResourcesAssembler 는 Page 객체를 PagedModel 로 변환하는 것에 더해 HATEOAS 원칙의 적용을 구현하기 위한 클래스 임을 알 수 있습니다.

{
  "links" : [
    { "rel" : "next", "href" : "<http://localhost:8080/persons?page=1&size=20>" }
  ],
  "content" : [
     … // 20 Person instances rendered here
  ],
  "page" : {
    "size" : 20,
    "totalElements" : 30,
    "totalPages" : 2,
    "number" : 0
  }
}

해결

저는 최종적으로 , @EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO) 을 적용시켜 줄 WebConfig 파일을 추가하여 어노테이션을 적용시켜 주었습니다.

@Configuration
@EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO)
public class WebConfig {
}

이후로는 페이징된 리뷰 목록 반환 시 더 이상 경고(warn)가 발생하지 않았습니다.

해결은 간단하게 끝났지만, Page 객체가 Spring 에서는 도메인 타입이라는 점, 해결 하기 위한 PagedModel 객체, HATEOAS 원칙 등에 대해 알게 되었습니다.