[Naem] QueryDSL을 이용해 cursor-based-pagination 구현하기 + condition을 이용한 filtering
개요
Cursor-Based vs Offset-Based
pagination을 구현하기 위한 방식에는 offset-based와 cursor-based가 존재한다.
- 오프셋 기반 방식
- 1억번~1억+10번 데이터 주세요. 라고 한다면 → 1억+10번개의 데이터를 읽음 - 커서 기반 방식
- 마지막으로 읽은 데이터(1억번)의 다음 데이터(1억+1번) 부터 10개의 데이터 주세요 → 10개의 데이터만 읽음
이 글에서는 cursor-based-pagination을 구현한다.
Cursor 기반 pagination이란?
Cursor란 사용자에게 응답해준 마지막의 데이터의 식별자 값이 cursor
가 된다.
해당 cursor
를 기준으로 다음 n개의 데이터를 응답해주는 방식이다.
- 첫 페이지에 진입했을 때의 쿼리는 그냥
limit
으로 원하는 개수만큼 짤라서 주면 된다. - 이후 페이지에 대한 요청은, 사용자에게 응답한 데이터 중 마지막 게시글의 ID가
cursor
가 된다. - 데이터 중복이 발생하지도 않고 딱 필요한 데이터만 가져올 수 있다.
condition
을 통해 필터링도 가능하다.
그러므로 어떤 페이지를 조회하든 항상 원하는 데이터 개수만큼만 읽기 때문에 성능상 이점이 존재한다는 것이다.
cursor 기반 pagination을 구현하기 위해 QueryDSL을 사용할 것이다.
QueryDSL 설정
1. build.gradle
아래 표시한 부분의 코드를 plugins 부분에 추가한다.
plugins {
id 'org.springframework.boot' version '2.4.7'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'java'
// 🌟 QueryDSL 추가
id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
}
아래 표시한 부분의 코드를 dependencies 부분에 추가한다.
dependencies {
...
// 🌟 QueryDSL 추가
implementation 'com.querydsl:querydsl-jpa:5.0.0'
implementation "com.querydsl:querydsl-apt:5.0.0"
implementation "com.querydsl:querydsl-core:5.0.0"
}
아래 코드는 build.gradle
파일 맨 아래에 붙여넣는다.
// 🌟 QueryDSL 추가 시작
def querydslDir = "$buildDir/generated/querydsl"
querydsl {
jpa = true
querydslSourcesDir = querydslDir
}
sourceSets {
main.java.srcDir querydslDir
}
compileQuerydsl {
options.annotationProcessorPath = configurations.querydsl
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
querydsl.extendsFrom compileClasspath
}
// 🌟 QueryDSL 추가 끝
2. compileQuerydsl로 Q-type 객체 생성
[Gradle > Tasks > other]에서 “compileQuerydsl”을 더블클릭한다.
(또는 ./gradlew clean compileQuerydsl
명령으로 직접 생성한다.)
생성 전
생성 후
아래 사진처럼 Q-type 객체가 생성되었음을 확인할 수 있다.
주의 🚨 Q-type 객체는 git에 등록되면 안된다. .gitignore에 등록되어야 한다. 디폴트로 등록되어 있을 것!
앞서 설정에서 생성 위치를 gradle build 폴더 아래 생성되도록 했기 때문에 이 부분도 자연스럽게 해결된다. (대부분 gradle build 폴더를 git에 포함하지 않는다.)
3. application.yml 설정
spring:
data:
web:
pageable:
default-page-size: 20 # 페이징 할 때 기본값, 20개씩 조회
코드 작성
1. QuerydslConfiguration 코드 작성
config/QuerydslConfiguration.java
@Configuration
public class QuerydslConfiguration {
@PersistenceContext
private EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
2. CustomPostRepository 인터페이스 정의
repository/CustomPostRepository.java
public interface CustomPostRepository {
Slice<BriefPostInfoDto> getBriefPostInfoScroll(Long cursorId, PostReadCondition condition, Pageable pageable);
}
조회할 때, where 절에 필터링을 넣기 위해 PostReadCondition
을 추가했다. (BriefPostInfoDto
와 PostReadCondition
은 뒤에서 다룬다.)
3. Custom 인터페이스 상속
repository/PostRepository.java
public interface PostRepository extends JpaRepository<Post, Long>, CustomPostRepository {
}
4. CustomPostRepositoryImpl 클래스 구현
repository/CustomPostRepositoryImpl.java
import static naem.server.domain.post.QPost.*; // Q-type 객체 import
...
@Repository
@RequiredArgsConstructor
public class CustomPostRepositoryImpl implements CustomPostRepository {
private final JPAQueryFactory queryFactory;
@Override
public Slice<BriefPostInfoDto> getBriefPostInfoScroll(Long cursorId, PostReadCondition condition,
Pageable pageable) {
List<Post> postList = queryFactory
.select(post)
.from(post)
.where(
eqIsDeleted(condition.getIsDeleted()), // 삭제되지 않은 게시글만 조회
eqTitle(condition.getKeyword()),
eqContent(condition.getKeyword()),
eqCursorId(cursorId)
)
.limit(pageable.getPageSize() + 1) // limit 보다 데이터를 1개 더 들고와서, 해당 데이터가 있다면 hasNext 변수에 true 를 넣어 알림
.fetch();
List<BriefPostInfoDto> briefPostInfos = new ArrayList<>();
for (Post post : postList) {
briefPostInfos.add(new BriefPostInfoDto(post));
}
boolean hasNext = false;
if (briefPostInfos.size() > pageable.getPageSize()) {
briefPostInfos.remove(pageable.getPageSize());
hasNext = true;
}
return new SliceImpl<>(briefPostInfos, pageable, hasNext);
}
/*
* 동적 쿼리를 위한 BooleanExpression
*/
private BooleanExpression eqCursorId(Long cursorId) {
return (cursorId == null) ? null : post.id.gt(cursorId);
}
// 삭제된 게시글인지 필터링
private BooleanExpression eqIsDeleted(Boolean isDeleted) {
return (isDeleted == null) ? null : post.isDeleted.eq(isDeleted);
}
// 제목에 keyword 포함되어있는지 필터링
private BooleanExpression eqTitle(String keyword) {
return (keyword == null) ? null : post.title.contains(keyword);
}
// 내용에 keyword 포함되어있는지 필터링
private BooleanExpression eqContent(String keyword) {
return (keyword == null) ? null : post.content.contains(keyword);
}
}
5. Dto 작성
1) BriefPostReadDto 클래스
domain/post/dto/BriefPostReadDto.java
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BriefPostInfoDto {
private Long postId;
private String title;
private String content;
private String writerName;
private String createdDate;
public BriefPostInfoDto(Post post) {
this.postId = post.getId();
this.title = post.getTitle();
this.content = post.getContent();
this.writerName = post.getMember().getNickname();
this.createdDate = post.getCreateAt().toString();
}
}
게시글 리스트 조회 시 나타낼 간단한 게시글 정보로
게시글 제목, 내용, 작성자, 생성일자로 구성해보았다.
2) PostReadCondition 클래스
domain/post/dto/PostReadCondition.java
@Data
public class PostReadCondition {
private String keyword; // 검색 키워드
private Boolean isDeleted;
public PostReadCondition() {
this.isDeleted = false;
}
public PostReadCondition(String keyword) {
this.keyword = keyword;
this.isDeleted = false;
}
}
PostReadCondition
은 필터링하기 위해 작성한 클래스이다.
나는 게시글 삭제 기능을 soft delete 방식으로 구현했기 때문에, 조회를 할 때 isDelete
가 false
인 게시글만 조회한다.
만약 검색어(keyword
)가 있다면 검색어가 포함된 게시글들만 조회한다.
6. PostService 인터페이스
service/PostService.java
public interface PostService {
Slice<BriefPostInfoDto> getPostList(Long cursor, PostReadCondition condition, Pageable pageRequest);
}
7. PostServiceImpl 클래스 구현
service/PostServiceImpl.java
@Service
@RequiredArgsConstructor
@Slf4j
public class PostServiceImpl implements PostService {
private final PostRepository postRepository;
...
@Override
@Transactional
public Slice<BriefPostInfoDto> getPostList(Long cursor, PostReadCondition condition, Pageable pageRequest) {
return postRepository.getBriefPostInfoScroll(cursor, condition, pageRequest);
}
}
8. BoardController
web/BoardController.java
@RequiredArgsConstructor
@RestController
@RequestMapping
("/board")
@Slf4j
public class BoardController {
private final PostService postService;
...
@ApiOperation(value = "게시글 리스트 조회 (무한 스크롤)", notes = "게시글 리스트 조회 (무한 스크롤)")
@GetMapping("/list")
public Slice<BriefPostInfoDto> list(Long cursor, String keyword, @PageableDefault(size = 5, sort = "createAt") Pageable pageRequest) {
if (StringUtils.hasText(keyword)) {
return postService.getPostList(cursor, new PostReadCondition(keyword), pageRequest); // keyword가 포함된 게시글 조회
}
return postService.getPostList(cursor, new PostReadCondition(), pageRequest); // 삭제되지 않은 모든 게시글 조회
}
}
포스트맨 테스트
Ref.
- QueryDSL이란? 설정방법 - compileQuerydsl로 Q객체 생성
- [Spring] querydsl로 커서 기반 페이지네이션 구현해보기
- Cursor based Pagination(커서 기반 페이지네이션)이란? - Querydsl로 무한스크롤 구현하기
💛 개인 공부 기록용 블로그입니다. 👻