본문 바로가기
Web

Query DSL을 통한 커서기반 페이징

by 수짱수짱 2024. 3. 7.

[먼저 페이징에 대한 개념이 부족하시다면 아래의 글을 참고해주면 감사하겠습니다]

 

[Paging] 페이지네이션을 알고 사용하자

해당 게시글은 "프로그래머스 데브코스 4기"의 팀 내 프로젝트 기록용으로 TECH BLOG에 직접 작성한 글입니다. 페이징(Paging, Pagination)이란? 프로젝트를 진행하면서 “베스트 상품 페이징”처리가

bestsu.tistory.com

 

위의 포스팅은 페이징에 대한 이론을 설명하고 있고 이제 작성 하는 글은 페이징을 실전에 사용하는 방법에 대해 쭉 정리한다

 

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);
	}
}

 

 

Spring Boot에 QueryDSL을 사용해보자

1. QueryDSL PostRepository.java Spring Data JPA가 기본적으로 제공해주는 CRUD 메서드 및 쿼리 메서드 기능을 사용하더라도, 원하는 조건의 데이터를 수집하기 위해서는 필연적으로 JPQL…

tecoble.techcourse.co.kr

 

 

Spring Boot Data Jpa 프로젝트에 Querydsl 적용하기

안녕하세요? 이번 시간에는 Spring Boot Data Jpa 프로젝트에 Querydsl을 적용하는 방법을 소개 드리겠습니다. 모든 코드는 Github에 있습니다. Spring Data Jpa를 써보신 분들은 아시겠지만, 기본으로 제공해

jojoldu.tistory.com

 

2. query dsl 테스트

그 다음 성공적으로 query dsl이 실행되는지 확인했다

그런데.. List로 잘 받아와지는지 테스트 하려고 하는데 아래와 같이 계속 test 코드에서 오류가 뜨는것이다 ㅠㅠ

com.querydsl.core.types.ExpressionException

 

해당 오류는 dto와 entity 사이 간극이 발생했을 때 dto를 생성하지 못해 발생하는 문제라고 한다.

 

참고로 dto로 결과를 받는 방법은 아래 블로그글과 같이 3가지가 있다

 

querydsl에서 Tuple 혹은 DTO로 데이터 받기

querydsl에서 보통 entity로 선언된 클래스의 변환 클래스인 QClass로 데이터를 받게 되나, DTO로 받을 수 있다. 기본적으로 Tuple로 받아서 개발자가 알아서 꺼내 쓸 수도 있겠으나, 좋은 방법이 아님에

louet.tistory.com

 

그리고 나는 이때까지 위 블로그에서 설명한 1번과 2번을 통해 진행하고 있었다

1번은 Property 접근(setter, getter), 2번은 field 접근

 

문제 해결은 3번 방법인 생성자 사용을 이용하니 해결이 되었다

참고로 BaseEntity에 createAt이라고 오타났다 ㅋㅋ 그래서 as로 별칭 설정해줌

 

 

 

생성자로만 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번 사진 참고, 아래 링크를 참고했다)

 

Spring Data JPA를 이용해 커서 페이징 구현하기

프로젝트를 진행하면서 회원의 포인트 적립 및 사용 상세 이력을 조회하는 요구사항을 개발하기 위해서 Spring Data JPA를 이용한 페이징 처리를 구현했다. 화면에서 커서 스크롤시 데이터를 호출

zayson.tistory.com

만약, pk id가 아닌 중복이 일어날 수 있는 필드로 조회한다면 데이터가 누락되는 문제가 발생할 수 있으므로 항상 유의해야 한다.

 

6️⃣ 무한 커서 페이징은 항상 cursorId 이후로만 조회하므로 첫번째(최초) 페이지 정보만 받으면 된다

그래서 Pageable를 of로 생성할 때 첫번째 인자인 page에 항상 0을 넣어주는 이유가 바로 이것이다.

더보기

PageRequest pageRequest = PageRequest.of(0, size + 1)

PageRequest 객체의 of 메소드는 인자로 조회할 page와 한 페이지당 조회할 데이터의 개수 size 를 받습니다.

커서 기반 페이지네이션이기 때문에 항상 lastFeedId 이후의 id 로만 조회하므로 첫번째 페이지의 정보를 받으면 됩니다.

 

7️⃣ slice를 사용하는 이유

출처:&nbsp;https://giron.tistory.com/131

즉, 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

 

Spring Boot Data Jpa 프로젝트에서 Querydsl 적용하기

Querydsl 사용하기 전, 설정을 진행해봅시다. `build.gradle`은 준비되었다는 전제하에 진행하도록 하겠습니다. QueryDSL Configuration 설정 프로젝트 어느 곳에서나 JPAQueryFactory를 주입받아 Querydsl을 사용할

chilling.tistory.com

 

 

[Spring Boot] QueryDSL 커서 기반 페이지네이션 구현해보기

페이지네이션의 구현 방법에는 크게 두 가지가 있는데 바로 offset과 cursor 방식입니다.먼저 offset 기반과 cursor 기반 페이지네이션의 차이점을 알아보겠습니다.아래와 같은 게시물이 5개이고, 한

velog.io

 

 

Cursor based Pagination(커서 기반 페이지네이션)이란? - Querydsl로 무한스크롤 구현하기

Cursor based Pagination, 커서 기반 페이징, 무한스크롤

velog.io

 

작성 일자 - 2023년 11월 16일