[Spring] MockMvc 사용 시 Page 인터페이스의 직렬화 문제

2024. 12. 31. 02:46·Spring

환경

  • Spring Boot 3.3.4
  • Amazon Corretto 17.0.11

문제 발생

@BeforeEach
void setUp() {
    mockMvc = MockMvcBuilders.standaloneSetup(replyController)
            .setCustomArgumentResolvers(new PageableHandlerMethodArgumentResolver())
            .build();
            
    // 생략 ..
}

@DisplayName("published 값에 따른 편지와 1:1로 대응하는 답장을 페이징으로 응답할 수 있다")
@ParameterizedTest
@ValueSource(booleans = {true, false})
void success_paging_response_when_published_is_existed(boolean published) throws Exception {
    // given
    // 기타 Arrange 생략 ..
    int year = 2024;
    PageRequest pageable = PageRequest.of(0, 15);

    List<ReplyResponseDto> content = List.of(ReplyResponseDto.of(mockReply));
    PageImpl<ReplyResponseDto> replies = new PageImpl<>(content, Pageable.unpaged(), 1); // 문제가 되는 부분
    doReturn(replies)
            .when(replyService)
            .findMyLetterAndReply(any(UUID.class), eq(year), eq(published), any(Pageable.class));

    // when
    ResultActions resultActions = mockMvc.perform(
            MockMvcRequestBuilders.get("/api/v1/replies/users/{userId}", mockUser.getId())
                    .queryParam("year", String.valueOf(year))
                    .queryParam("published", String.valueOf(published))
                    .queryParam("page", String.valueOf(pageable.getPageNumber()))
                    .queryParam("size", String.valueOf(pageable.getPageSize()))
                    .contentType(MediaType.APPLICATION_JSON_VALUE)
    );

    // then
    ReplyResponseDto firstContent = replies.getContent().get(0);

    resultActions.andExpect(status().isOk())
            .andExpect(jsonPath("$..[?(@.userId == '%s')]", firstContent.getUserId()).exists())
            .andExpect(jsonPath("$..[?(@.replyId == '%s')]", firstContent.getReplyId()).exists())
            .andExpect(jsonPath("$..[?(@.published == %s)]", firstContent.isPublished()).exists())
            .andExpect(jsonPath("$..[?(@.pageNumber == %d)]", replies.getNumber()).exists())
            .andExpect(jsonPath("$..[?(@.pageSize == %d)]", replies.getSize()).exists())
            .andDo(MockMvcResultHandlers.print());
}
  • 초기 설정: `StandaloneMockMvcBuilder`를 이용해 초기 설정에서 Controller에 Pageable 인스턴스를 주입해 줍니다.
  • 테스트 내용: 200 응답 코드와 응답값으로 `Page<DTO>`의 JSON 응답 값을 확인하는 테스트입니다.

테스트 결과 서버 내부에서 예외가 발생해 Status Code가 `200`가 아닌 `500`이 발생했습니다.

왜 500 에러가?

 

JSON으로 직렬화에 실패했다는 메시지도 확인했습니다.

Could not write JSON: (was java.lang.UnsupportedOperationException)]

원인

원인을 잡기 위해 디버깅을 해보겠습니다.

먼저 mockMvc가 만들어낸 결과를 보기 위해 `resultActions` 객체 내부를 봐야 합니다.

내부에서 `mockRequest`와 `mockResponse`, 예외 등을 볼 수 있습니다.

mockMvc의 resultActions에 답이 있다
resolvedException

`resolvedException` 필드에서 위에서 봤던 예외 메시지(Could not write JSON: (was java.lang.UnsupportedOperationException)])를 바로 찾았습니다.

 

자세한 메시지는 아래와 같습니다.

com.fasterxml.jackson.databind.JsonMappingException: \
(was java.lang.UnsupportedOperationException) \
(through reference chain: org.springframework.data.domain.PageImpl["pageable"]->org.springframework.data.domain.Unpaged["offset"])

JsonMappingException은 왜 발생했을까?

위의 예외는 Jackson 라이브러리가 `org.springframework.data.domain.Page` 객체를 직렬화할 때 발생했습니다.

reference chain을 보면 `PageImpl`의 `pageble` 필드가 `org.springframework.data.domain.Unpaged` 클래스를 참조했고, `Unpaged`의 `offset`을 읽다가 `UnsupportedOperationException` 예외를 만나게 된 것으로 해석할 수 있습니다.

 

여기서 잠깐 `Page`와 `PageImpl`의 관계, 그리고 `Pageable`과 `Unpaged`의 관계를 짚고 넘어가겠습니다.

인터페이스와 구현체

`PageImpl` 클래스는 `Page` 인터페이스의 구현체이며, `Unpaged` 클래스는 `Pageable` 인터페이스의 구현체입니다.

이제부터 헷갈릴 수 있으니 이 부분을 잘 기억해 주세요!

Page의 구현체 PageImpl

아래는 `PageImpl` 코드의 일부입니다.

public class PageImpl<T> extends Chunk<T> implements Page<T> {
    private static final long serialVersionUID = 867755909294344406L;
    private final long total;

    public PageImpl(List<T> content, Pageable pageable, long total) {
        super(content, pageable);
        this.total = (Long)pageable.toOptional().filter((it) -> !content.isEmpty()).filter((it) -> it.getOffset() + (long)it.getPageSize() > total).map((it) -> it.getOffset() + (long)content.size()).orElse(total);
    }
    
    // ...
}

생성자를 보면 `Pageable` 객체를 매개변수로 받고, 페이징하는 요소들의 전체 개수를 구하기 위해 `Pageable` 내부의 `getOffset()`을 호출합니다.

PageImpl 생성자에서 pageable.getOffset()을 호출한다

 

Pageable의 구현체 Unpaged

Pageable 인터페이스는 아래처럼 되어있습니다.

Pageable 인터페이스의 getOffset()

테스트에선 `getOffset()`을 호출하면 구현체인 `Unpaged` 객체의 `getOffset()`이 호출됩니다.

 

아래는 `Pageable`의 구현체인 `Unpaged` 클래스의 코드입니다.

구현체 Unpaged

`Unpaged` 객체의 `getOffset()`을 호출할 때 `UnsupportedOperationException` 예외를 던지는 것을 확인할 수 있습니다.

해결

`Unpaged` 객체가 아닌 `PageRequest` 객체를 전달하면 이 문제를 해결할 수 있습니다.

`PageRequest` 클래스는 `Pageable` 인터페이스의 또다른 구현체인데, 추상 클래스 `AbstractPageRequest`를 확장하는 클래스입니다.

`AbstractPageRequest` 클래스의 `getOffset()`은 예외를 던지지 않습니다.

AbstractPageRequest

정리

PageImpl<ReplyResponseDto> replies = new PageImpl<>(content, Pageable.unpaged(), 1);

테스트 코드에서 문제가 되는 부분은 `new PageImpl<>(...);` 부분에서 매개 변수로 `Pageable.unpaged()`를 넘길 때 발생합니다.

 

전체 흐름을 정리하면

 

  1. Jackson 라이브러리가 `PageImpl` 객체를 직렬화합니다.
  2. 이때 `PageImpl` 객체가 참조하고 있는 `Unpaged` 객체의 `getOffset()`을 읽습니다.
  3. `Unpaged` 객체의 `getOffset()`은 `UnsupportedOperationException`를 던졌고, 직렬화에 실패했습니다.
  4. 해결 방법으로는 `PageImpl`를 생성할 때 매개 변수 `pageable`은 `Unpaged`가 아닌 `PageRequest` 객체를 전달합니다.

도움이 되셨길 바라며, 긴 글 읽어주셔서 감사합니다.

'Spring' 카테고리의 다른 글

[Mybatis] Generic 기반 TypeHandler를 자동 등록하기  (0) 2025.07.20
Spring Event Deep Dive  (2) 2025.01.16
[Spring] 회원탈퇴 시 Kakao OAuth2 연결끊기: REST API로 연결끊기 (OpenFeign)  (2) 2024.10.30
[Spring] 직렬화/역직렬화 시 'is' prefix 가 안붙는 이유  (2) 2024.10.30
[Spring Batch] ItemWriter 가 List<T> 를 전달받으려면? (Spring Batch 5)  (0) 2024.07.01
'Spring' 카테고리의 다른 글
  • [Mybatis] Generic 기반 TypeHandler를 자동 등록하기
  • Spring Event Deep Dive
  • [Spring] 회원탈퇴 시 Kakao OAuth2 연결끊기: REST API로 연결끊기 (OpenFeign)
  • [Spring] 직렬화/역직렬화 시 'is' prefix 가 안붙는 이유
옐리yelly
옐리yelly
  • 옐리yelly
    개발 갤러리
    옐리yelly
  • 전체
    오늘
    어제
    • 모든 글 보기 (84)
      • Project (22)
      • Java (4)
      • Spring (8)
      • Kubernetes (6)
      • Docker (2)
      • JPA (2)
      • Querydsl (2)
      • MySQL (9)
      • ElasticSearch (7)
      • DevOps (4)
      • Message Broker (3)
      • Git & GitHub (2)
      • Svelte (1)
      • Python (8)
        • Python Distilled (4)
        • Anaconda (1)
        • Django (0)
        • pandas (3)
      • Algorithm (1)
      • Computer Science (0)
      • 내 생각 (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    RabbitMQ
    mybatis
    svelte
    데드락
    gitops
    ncloud
    포텐데이
    Python
    devops
    querydsl
    Message Broker
    성능 테스트
    Project
    nks
    프로젝트
    리팩토링
    비사이드
    Spring
    MySQL
    pandas
    k8s
    blue-green 배포
    pymysql
    커넥션 풀
    docker
    예약 시스템
    JPA
    elasticsearch
    OOP
    argocd
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
옐리yelly
[Spring] MockMvc 사용 시 Page 인터페이스의 직렬화 문제
상단으로

티스토리툴바