Query DSL을 통한 커서기반 페이징
[먼저 페이징에 대한 개념이 부족하시다면 아래의 글을 참고해주면 감사하겠습니다]
위의 포스팅은 페이징에 대한 이론을 설명하고 있고 이제 작성 하는 글은 페이징을 실전에 사용하는 방법에 대해 쭉 정리한다
1. query dsl 설정
처음 query dsl을 사용해보면서.. 기본부터 테스트를 진행해야겠다고 하면서 아래 블로그 글을 따라하고 있었다
일단 query dsl 설정 파일을 생성해 주었다.
package com.devcourse.ReviewRanger.common.config;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.querydsl.jpa.impl.JPAQueryFactory;
@Configuration
public class QueryDslConfig {
@PersistenceContext
private EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
2. query dsl 테스트
그 다음 성공적으로 query dsl이 실행되는지 확인했다
그런데.. List로 잘 받아와지는지 테스트 하려고 하는데 아래와 같이 계속 test 코드에서 오류가 뜨는것이다 ㅠㅠ
해당 오류는 dto와 entity 사이 간극이 발생했을 때 dto를 생성하지 못해 발생하는 문제라고 한다.
참고로 dto로 결과를 받는 방법은 아래 블로그글과 같이 3가지가 있다
그리고 나는 이때까지 위 블로그에서 설명한 1번과 2번을 통해 진행하고 있었다
1번은 Property 접근(setter, getter), 2번은 field 접근
문제 해결은 3번 방법인 생성자 사용을 이용하니 해결이 되었다
생성자로만 dto가 생성되는 이유가 무엇일까 생각해 보았다
아마 원인은 record 타입의 DTO에는 기본 생성자가 자동으로 생성되지 않으며(=인자가 없는 생성자) (다만 필드가 들어있는 생성자는 알아서 생성됨) 내가 만들어 주지도 않았다
또한 record는 불변 객체라 setter가 적용되지 않아 1번은 적합하지 않아보였다.
결론은 1, 2번 다 기본 생성자가 필요한 방법이었고 3번을 택하게 되었다.
3. cursor 기반의 페이징 구현
offset기반이 아닌 cursor기반의 페이징을 진행하려면 Pageable 인터페이스가 아니라 Slice 인터페이스를 사용해야 한다고 한다
[구현 코드]
FinalReviewResultController.java
@ResponseStatus(OK)
public RangerResponse<Slice<FinalReviewResultListResponse>> getAllFinalReviewResultsOfCursorPaging(
@AuthenticationPrincipal UserPrincipal user,
@RequestBody @Valid PagingFinalReviewRequest pagingFinalReviewRequest
) {
Slice<FinalReviewResultListResponse> finalReviewResultsOfCursorPaging
= finalReviewResultService.getAllFinalReviewResultsOfCursorPaging(pagingFinalReviewRequest.cursorId(),
user.getId(), pagingFinalReviewRequest.size());
return RangerResponse.ok(finalReviewResultsOfCursorPaging);
}
FinalReviewResultService.java
public Slice<FinalReviewResultListResponse> getAllFinalReviewResultsOfCursorPaging(Long cursorId, Long userId,
Integer size) {
return finalReviewResultRepository.findAllFinalReviewResults(cursorId, userId, size);
}
FinalReviewResultCustomRepositoryImpl.java
public class FinalReviewResultCustomRepositoryImpl implements FinalReviewResultCustomRepository {
private final JPAQueryFactory jpaQueryFactory;
public FinalReviewResultCustomRepositoryImpl(JPAQueryFactory jpaQueryFactory) {
this.jpaQueryFactory = jpaQueryFactory;
}
@Override
public Slice<FinalReviewResultListResponse> findAllFinalReviewResults(Long cursorId, Long userId, Integer size) {
QFinalReviewResult finalReviewResult = QFinalReviewResult.finalReviewResult;
List<FinalReviewResultListResponse> results = jpaQueryFactory
.select(
Projections.constructor(
FinalReviewResultListResponse.class,
finalReviewResult.id,
finalReviewResult.title,
finalReviewResult.createAt.as("createdAt"))
)
.from(finalReviewResult)
.where(
finalReviewResult.userId.eq(userId) // userId에 해당하는 최종 리뷰만 가져오고
.and(eqCursorId(cursorId)) // cursorId가 null이라면 null, 아니면 해당 cursorId보다 큰 값을 가져온다 (오름차순)
.and(finalReviewResult.status.eq(SENT)) // 전송된 최종 리뷰만 가져온다
)
.orderBy(finalReviewResult.id.asc()) // pk값인 id가 기준으로 잡혀있으므로 id를 기준으로 정렬해준다
.limit(size) // 아래에서 수정
.fetch();
boolean hasNext = false;
if (results.size() > size) {
results.remove(size);
hasNext = true;
}
return new SliceImpl<>(results, PageRequest.of(0, size), hasNext);
}
private BooleanExpression eqCursorId(Long cursorId) {
if (cursorId != null) {
return finalReviewResult.id.gt(cursorId);
}
return null;
}
4. 응답 결과(JSON)
// "content" 배열은 리뷰나 콘텐츠와 관련된 정보를 담고 있습니다.
"content": [
{
// 각 요소는 고유한 ID, 제목, 작성일 등을 포함한 리뷰 정보를 나타냅니다.
"id": 4, // 리뷰의 고유 식별자
"title": "첫번째 피어리뷰 입니다.", // 리뷰의 제목
"createdAt": "2023-11-15T13:49:28.234207" // 리뷰가 작성된 날짜와 시간
},
{
"id": 5,
"title": "첫번째 피어리뷰 입니다.",
"createdAt": "2023-11-15T15:05:55.285887"
}
],
// "pageable" 객체는 페이징과 관련된 정보를 포함합니다.
"pageable": {
// "sort" 객체는 현재 정렬 상태를 나타냅니다.
"sort": {
"empty": true, // 정렬되지 않았음을 나타냄
"sorted": false, // 정렬되어 있지 않음을 나타냄
"unsorted": true // 정렬되지 않음을 나타냄
},
// "offset"은 현재 페이지의 시작 인덱스를 나타냅니다.
"offset": 0,
// "pageSize"는 페이지당 요소 수를 나타냅니다.
"pageSize": 12,
// "pageNumber"는 현재 페이지 번호를 나타냅니다.
"pageNumber": 0,
// "unpaged"는 페이징되지 않았음을 나타냅니다.
"unpaged": false,
// "paged"는 페이징되었음을 나타냅니다.
"paged": true
},
// "size"는 전체 요소의 개수를 나타냅니다.
"size": 12,
// "number"는 현재 페이지 번호를 나타냅니다.
"number": 0,
// "sort" 객체는 현재 정렬 상태를 나타냅니다.
"sort": {
"empty": true, // 정렬되지 않았음을 나타냄
"sorted": false, // 정렬되어 있지 않음을 나타냄
"unsorted": true // 정렬되지 않음을 나타냄
},
// "numberOfElements"는 현재 페이지에 포함된 요소의 수를 나타냅니다.
"numberOfElements": 2,
// "first"는 현재 페이지가 첫 번째 페이지인지 여부를 나타냅니다.
"first": true,
// "last"는 현재 페이지가 마지막 페이지인지 여부를 나타냅니다.
"last": true,
// "empty"는 데이터가 비어있는지 여부를 나타냅니다.
"empty": false
각 요소에 대한 설명은 주석으로 표시하였다
5. 위 코드들에 대한 문제점 수정
그리고 추후에 위 코드들에 대한 문제점들을 또 확인할 수 있었다
1️⃣ controller에서 get 요청일 땐 request body를 불러올 수 없다
get 요청은 @RequestBody를 사용할 수 없다 (get 메소드는 메시지 바디를 사용하지 않기 때문이다)
따라서 requestParam이 페이징에 많이 사용되므로 방법을 위와 같이 수정했다
2️⃣ 페이징이 처음 요청될 땐 cursorId가 null일 수 있다
지금은 requestParam으로 대체되어 필요없지만 이전의 Request body 요청 DTO의 cursorId에 bean 유효성 검사로 붙어있던 @NotNull을 제거해 주었다.
(바꿔주고 삭제 한 파일이지만 그래도 @NotNull을 붙이면 안 된다는 걸 작성하고 싶었다)
3️⃣ 다음 페이지가 있는지 없는지 확인하기 위해 limit에 +1을 걸어줘야 한다
위에 코드에선 limit에 그냥 size만 있었는데 아래 results.size를 비교하는 구문을 위해 size+1이 필요하다
왜냐하면 size보다 1개더 많이 받아올 수 있다면 다음 페이지가 있다는 것이기 때문이다.
따라서, hasNext를 true로 하여 다음 페이지가 있음을 알려주고 list에서 마지막에 1개 더 받아온 데이터를 삭제하면 된다 (49~52번째 줄)
4️⃣ controller에서 PageRequest를 만들어서 인자로 넘겨주자
page 조건이 바뀌거나 할 때 controller에서 바로 컨트롤이 가능할 것 같고 컨트롤러에서 Pageable을 알아도 크게 문제가 되지 않는다고 생각하여 controller에서 Pageable 객체를 생성해 주었다
5️⃣ page 값 없이 유니크한 pk id를 통해 쿼리 내용을 변경하기 때문에 반드시 정렬이 필요하다
page값이 없다는 말은 controller에서 생성하는 Pageable 객체의 of 함수의 첫번째 인자인 page가 0으로 고정되어 있다는 의미이다
(4번 사진 참고, 아래 링크를 참고했다)
만약, pk id가 아닌 중복이 일어날 수 있는 필드로 조회한다면 데이터가 누락되는 문제가 발생할 수 있으므로 항상 유의해야 한다.
6️⃣ 무한 커서 페이징은 항상 cursorId 이후로만 조회하므로 첫번째(최초) 페이지 정보만 받으면 된다
그래서 Pageable를 of로 생성할 때 첫번째 인자인 page에 항상 0을 넣어주는 이유가 바로 이것이다.
PageRequest pageRequest = PageRequest.of(0, size + 1)
PageRequest 객체의 of 메소드는 인자로 조회할 page와 한 페이지당 조회할 데이터의 개수 size 를 받습니다.
커서 기반 페이지네이션이기 때문에 항상 lastFeedId 이후의 id 로만 조회하므로 첫번째 페이지의 정보를 받으면 됩니다.
7️⃣ slice를 사용하는 이유
즉, hasNext를 인자에 넣어서 넘겨줄 수 있기 때문이다.
6. query dsl 테스트 코드 작성
테스트 코드는 해당 글을 보고 작성해 보았다 (Spring Data JPA를 이용해 커서 페이징 구현하기)
추후엔 mock을 이용한 단위 테스트로 작성해 볼 필요가 있을 것 같다
@SpringBootTest
@ExtendWith(SpringExtension.class)
class FinalReviewResultPagingTest {
@Autowired
private FinalReviewResultRepository finalReviewResultRepository;
@Test
void 커서페이징_성공() {
// given
PageRequest pageRequest = PageRequest.of(0, 12);
// when
Slice<FinalReviewResultListResponse> finalReviewResultResponses
= finalReviewResultRepository.findAllFinalReviewResults(0L, 3L, pageRequest);
// then
assertEquals(12, finalReviewResultResponses.getSize()); // page size가 12인지 확인
assertEquals(4, finalReviewResultResponses.getNumberOfElements()); // page content 내용 갯수가 4개인지 확인
assertEquals(4, finalReviewResultResponses.getContent().get(0).id()); // 각 content의 id가 일치하는지 확인
assertEquals(5, finalReviewResultResponses.getContent().get(1).id());
assertFalse(finalReviewResultResponses.hasNext()); // hasNext가 false인지 확인
}
}
Reference
작성 일자 - 2023년 11월 16일