4 분 소요

개요

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”을 더블클릭한다.
스크린샷 2022-08-25 오후 12 27 08

(또는 ./gradlew clean compileQuerydsl 명령으로 직접 생성한다.)

생성 전

스크린샷 2022-08-25 오후 12 25 40

생성 후

아래 사진처럼 Q-type 객체가 생성되었음을 확인할 수 있다.
스크린샷 2022-08-25 오후 12 34 48

주의 🚨 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을 추가했다. (BriefPostInfoDtoPostReadCondition은 뒤에서 다룬다.)

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 방식으로 구현했기 때문에, 조회를 할 때 isDeletefalse인 게시글만 조회한다.
만약 검색어(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.



💛 개인 공부 기록용 블로그입니다. 👻

맨 위로 이동하기

태그:

카테고리:

업데이트: