환경
- 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`이 발생했습니다.
JSON으로 직렬화에 실패했다는 메시지도 확인했습니다.
원인
원인을 잡기 위해 디버깅을 해보겠습니다.
먼저 mockMvc가 만들어낸 결과를 보기 위해 `resultActions` 객체 내부를 봐야 합니다.
내부에서 `mockRequest`와 `mockResponse`, 예외 등을 볼 수 있습니다.
`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()`을 호출합니다.
Pageable의 구현체 Unpaged
Pageable 인터페이스는 아래처럼 되어있습니다.
테스트에선 `getOffset()`을 호출하면 구현체인 `Unpaged` 객체의 `getOffset()`이 호출됩니다.
아래는 `Pageable`의 구현체인 `Unpaged` 클래스의 코드입니다.
`Unpaged` 객체의 `getOffset()`을 호출할 때 `UnsupportedOperationException` 예외를 던지는 것을 확인할 수 있습니다.
해결
`Unpaged` 객체가 아닌 `PageRequest` 객체를 전달하면 이 문제를 해결할 수 있습니다.
`PageRequest` 클래스는 `Pageable` 인터페이스의 또다른 구현체인데, 추상 클래스 `AbstractPageRequest`를 확장하는 클래스입니다.
`AbstractPageRequest` 클래스의 `getOffset()`은 예외를 던지지 않습니다.
정리
PageImpl<ReplyResponseDto> replies = new PageImpl<>(content, Pageable.unpaged(), 1);
테스트 코드에서 문제가 되는 부분은 `new PageImpl<>(...);` 부분에서 매개 변수로 `Pageable.unpaged()`를 넘길 때 발생합니다.
전체 흐름을 정리하면
- Jackson 라이브러리가 `PageImpl` 객체를 직렬화합니다.
- 이때 `PageImpl` 객체가 참조하고 있는 `Unpaged` 객체의 `getOffset()`을 읽습니다.
- `Unpaged` 객체의 `getOffset()`은 `UnsupportedOperationException`를 던졌고, 직렬화에 실패했습니다.
- 해결 방법으로는 `PageImpl`를 생성할 때 매개 변수 `pageable`은 `Unpaged`가 아닌 `PageRequest` 객체를 전달합니다.
도움이 되셨길 바라며, 긴 글 읽어주셔서 감사합니다.
'Spring' 카테고리의 다른 글
Spring Event Deep Dive (0) | 2025.01.16 |
---|---|
[Spring] 회원탈퇴 시 Kakao OAuth2 연결끊기: REST API로 연결끊기 (OpenFeign) (2) | 2024.10.30 |
[Spring] 직렬화/역직렬화 시 'is' prefix 가 안붙는 이유 (1) | 2024.10.30 |
[Spring Batch] ItemWriter 가 List<T> 를 전달받으려면? (Spring Batch 5) (0) | 2024.07.01 |
[Spring] Response DTO 직렬화 문제 (0) | 2024.06.13 |