본문 바로가기
Spring/JPA

Spring Pagination 연습

by 진꿈청 2024. 3. 8.

이번 포스팅에는 Spring Pagination에 대해 다뤄 볼 것이다.

 

구글, 네이버 카페, 커뮤니티 등 어떤 웹에서도 페이징 처리가 된 웹 형태를 자주 찾아볼 수 있다.

 

그렇다면 이렇게 페이징 처리하는 이유가 뭘까? 

 

수많은 데이터가 DB에 존재한다고 했을 때, 페이징 처리를 하지 않고 모든 데이터들을 한 번에 뿌려준다면

해당 데이터들을 DB로부터 가져오는데 엄청난 시간이 소요될 것이다.

 

또한, 해당 데이터 자체를 들고 있어야 하기에 메모리에도 부담이 클 것이다.

하물며 사용자들이 그 수많은 데이터들을 다 보게 하는 것도 용이하지 못하다.

 

그래서, 모든 데이터들의 개수에 대한 기준을 만들어 해당 개수만큼 가져오는 페이징 형태를 사용하는 것이다.

 

페이징 처리를 적절히 활용하여 정렬 기준과 정렬 방식(오름차순, 내림차순) 등 다양한 조회가 가능하다.

 

페이징 처리를 한번 구현해보자

 

먼저, Page의 구현체인 PageImpl 클래스에는 어떤 내용이 있을까?

 

PageImpl

// PageImpl
public PageImpl(List<T> content, Pageable pageable, long total) {

    super(content, pageable);

    this.total = pageable.toOptional().filter(it -> !content.isEmpty())//
        .filter(it -> it.getOffset() + it.getPageSize() > total)//
        .map(it -> it.getOffset() + content.size())//
        .orElse(total);
}

// Pageable
static Pageable ofSize(int pageSize) {
    return PageRequest.of(0, pageSize);
}

// PageRequest
public static PageRequest of(int page, int size) {
    return of(page, size, Sort.unsorted());
}

 

세 가지의 인자를 전달받는다.

 

담길 객체/정보인 Content와 Page의 정보(PageOffset, PageLimit, Sort)를 담고 있는 Pageable, 총 개수를 담고 있는 total로 이루어져있다.

 

Pageable은 Pageable를 구현한 구현체인 PageRequest로 생성 가능하다.


Pageable 인터페이스

  • Spring에서는 Pagination을 지원하는 Pageable 인터페이스를 제공한다.
    • getPageNumber(): 현재 페이지 번호를 반환(0부터 시작함에 유의)
    • getPageSize(): 한 페이지당 최대 항목 수를 반환
    • getOffset(): 현재 페이지의 시작 위치를 반환
    • getSort(): 정렬 정보를 반환
    • next(): 다음 페이지 정보를 반환
    • previous(): 이전 페이지 정보를 반환
  • Pageable를 잘 활용하면 페이지 번호, 페이지당 항목 수, 필요에 따른 정렬 정보도 추가로 지정 가능하다.
  • Pageable로 지정한 정보를 토대로 Page 객체 반환이 가능하며, Page 객체는 조회된 데이터와 페이지 정보를 갖는다.

PageRequest 클래스

  • Spring Data JPA에서 제공하는 Pageable 구현체 중 하나로, 페이지 정보를 생성한다.
  • 페이지 번호, 페이지당 항목 수, 정렬 정보를 토대로 Pageable 인터페이스를 구현한다.
    • page: 조회할 페이지 번호(마찬가지로 0부터 시작)
    • size: 한 페이지당 최대 항목 수
    • sort: 정렬 정보(선택)
    • direction: 정렬 방향(ASC, DESC)
    • properties: 정렬 대상 속성명
  • PageRequest 객체를 생성한 뒤 JpaRepository 메서드 파라미터로 전달하면, Page 객체를 반환하기에 Pagination 구현이 가능하다.

 

PageRequest

// 생성자
PageRequest(int page, int size)
PageRequest(int page, int size, Sort sort)
PageRequest(int page, int size, Sort.Direction direction, String... properties)

// 객체 생성
PageRequest pageRequest = PageRequest.of(0, 10, Sort.by("id").ascending());

 

Pagination 연습 전 DB에 데이터 넣기

편의를 위해 Posts와 Comments Entity를 생성하여 미리 데이터를 집어 넣었다.

쿼리가 너무 많이 나가서 처음 한번만 사용하고 DDL 속성을 none으로 바꾸었다.

 

Posts

package com.example.springpagingpractice.entity;

import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@Entity
public class Posts {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotNull
    private String title;

    @NotNull
    @Column(length = 1000)
    private String content;

    @Column
    private String author;

    @Builder
    public Posts(String title, String content, String author) {
        this.title = title;
        this.content = content;
        this.author = author;
    }
}

 

Comments

package com.example.springpagingpractice.entity;

import jakarta.persistence.*;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;

import java.time.LocalDateTime;

@Entity
@NoArgsConstructor
@Getter
public class Comments {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "posts_id")
    private Posts posts;

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @Builder
    public Comments(String content, Posts posts) {
        this.content = content;
        this.posts = posts;
    }
}

 

다대일 연관관계에서 관계의 주인인 Comments로 부터 Post를 읽어올 때 Pagination 처리를 해보기 위해. @ManyToOne을 지정하였다.

 

InitDb

package com.example.springpagingpractice.config;

import com.example.springpagingpractice.entity.Comments;
import com.example.springpagingpractice.entity.Posts;
import jakarta.annotation.PostConstruct;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Component
@RequiredArgsConstructor
public class InitDb {

    private final InitService initService;

    @PostConstruct
    public void init(){
        initService.dbInit1();
    }

    @Component
    @Transactional
    @RequiredArgsConstructor
    static class InitService{

        private final EntityManager em;
        public void dbInit1(){
            for(int i = 0; i < 100; i++){
                Posts posts = Posts.builder()
                        .title("글" + i)
                        .content("" + (100 - i))
                        .author("" + i)
                        .build();
                em.persist(posts);

                for(int j = 0; j < 1000; j++){
                    em.persist(Comments.builder()
                            .posts(posts)
                            .content("Comments" + j)
                            .build());
                }
            }

        }
    }
}

 

Posts는 100개, Comments는 Posts당 1000개씩 데이터를 넣었다.

 

정렬 테스트를 위해 숫자를 추가하였다.

 

PostsController

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/posts")
public class PostsController {

    public final PostsService postsService;

    @GetMapping
    public ResponseEntity findPostsWithPaging(@RequestParam(required = false, defaultValue = "0", value = "page") int pageNo,
                                                 @ModelAttribute("postSearch") PostsSearch postsSearch){
        PageResponseDto response = postsService.findPostsWithPaging(pageNo, postsSearch);
        return new ResponseEntity<>(new SingleResponseDto(response), HttpStatus.OK);
    }
}

 

pageNo: page번호를 받는다. 만약, 값이 없다면 기본값으로 0을 넣는다.
PostsSearch: 특정 Posts 및 정렬 조건/정렬 대상을 선택하기 위해 PostsSearch를 인자로 받는다.

 

PostsSearch

@Getter @Setter
@AllArgsConstructor
public class PostsSearch {
    private String title;
    private String content;
    private String author;

    @NotEmpty
    private String orderBy;

    @NotEmpty
    private String sort;

    public PostsSearch(){
        this.orderBy = "DESC";
        this.sort = "id";
    }
}

 

여기서 기본 생성자는 컨트롤러에서 PostsSearch가 들어오지 않았을 경우 기본으로 사용할 값이다.

 

PostsService

@Service
@RequiredArgsConstructor
public class PostsService {

    private final PostsRepository postsRepository;


    public PageResponseDto findPostsWithPaging(int pageNo, PostsSearch postsSearch) {
        int page = pageNo == 0 ? 0 : pageNo - 1;
        int pageLimit = 10;
        
        Pageable pageable = PageRequest.of(page, pageLimit,
                Sort.by(Sort.Direction.fromString(postsSearch.getOrderBy()),
                        postsSearch.getSort()));

        Page<Posts> posts = postsRepository.findPostsWithPaging(pageable, postsSearch);

        return PageResponseDto.create(posts, PostsResponseDto::create);
    }
}

 

페이징에서 Page 번호는 0번 부터 시작하기 때문에 -1을 해준다.

 

전달받은 PostsSearch 값을 토대로 페이지 정보를 생성한다.

 

페이징 처리가 되는 여러 엔티티를 위해 통일된 Page 응답 반환 객체인 PageResponseDto를 사용했다.

 

PageResponseDto

@Data
@NoArgsConstructor
@AllArgsConstructor
public class PageResponseDto<D> {
    private int numberOfElements;
    private int totalPages;
    private boolean hasNext;
    private List<D> data;

    public static <E, D> PageResponseDto create(Page<E> entity, Function<E, D> makeDto) {
        List<D> dto = convertToDto(entity, makeDto);
        return new PageResponseDto(entity.getNumberOfElements(), entity.getTotalPages(), entity.hasNext(), dto);
    }

    private static <E, D> List<D> convertToDto(Page<E> entity, Function<E, D> makeDto) {
        return entity.getContent().stream()
                .map(e -> makeDto.apply(e))
                .collect(Collectors.toList());
    }
}

 

 

PostsResponseDto

@Data
@NoArgsConstructor
public class PostsResponseDto {
    private Long id;
    private String title;
    private String content;
    private String author;

    public static PostsResponseDto create(Posts entity) {
        PostsResponseDto postsResponseDto = new PostsResponseDto();
        postsResponseDto.setId(entity.getId());
        postsResponseDto.setTitle(entity.getTitle());
        postsResponseDto.setContent(entity.getContent());
        postsResponseDto.setAuthor(entity.getAuthor());

        return postsResponseDto;
    }
}

 

 

PostsCustomImpl

@Repository
@RequiredArgsConstructor
public class PostsCustomImpl implements PostsCustom{
    private final JPAQueryFactory jpaQueryFactory;

    @Override
    public Page<Posts> findPostsWithPaging(Pageable pageable, PostsSearch postsSearch) {
        // Load Data
        List<Posts> postsList = jpaQueryFactory
                .selectFrom(QPosts.posts)
                .where(searchEq(postsSearch))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .orderBy(postsSort(pageable))
                .fetch();

        // Count data
        JPAQuery<Long> countQuery = jpaQueryFactory
                .select(posts.count())
                .from(posts)
                .where(searchEq(postsSearch));

        return PageableExecutionUtils.getPage(postsList, pageable, countQuery::fetchOne);
    }

    private OrderSpecifier<?> postsSort(Pageable pageable) {
        //서비스에서 보내준 Pageable 객체에 정렬조건 null 값 체크
        if (!pageable.getSort().isEmpty()) {
            //정렬값이 들어 있으면 for 사용하여 값을 가져온다
            for (Sort.Order order : pageable.getSort()) {
                // 서비스에서 넣어준 DESC or ASC 를 가져온다.
                Order direction = order.getDirection().isAscending() ? Order.ASC : Order.DESC;
                // 서비스에서 넣어준 정렬 조건을 스위치 케이스 문을 활용하여 셋팅하여 준다.
                switch (order.getProperty()){
                    case "title":
                        return new OrderSpecifier(direction, posts.title);
                    case "content":
                        return new OrderSpecifier(direction, posts.content);
                    case "author":
                        return new OrderSpecifier(direction, posts.author);
                    default:
                        return new OrderSpecifier(direction, posts.id);
                }
            }
        }
        return null;
    }

    private BooleanBuilder searchEq(PostsSearch postsSearch) {
        BooleanBuilder builder = new BooleanBuilder();

        if(StringUtils.hasText(postsSearch.getTitle())){
            builder.and(posts.title.like("%" + postsSearch.getTitle() + "%"));
        }

        if(StringUtils.hasText(postsSearch.getContent())){
            builder.and(posts.content.like("%"+postsSearch.getContent() +"%"));
        }

        if(StringUtils.hasText(postsSearch.getAuthor())){
            builder.and(posts.author.like("%" + postsSearch.getAuthor() + "%"));
        }

        return builder;
    }
}

 

Paging 처리를 할 때 PostsSearch를 사용하기에 동적 쿼리 생성이 필요하여 QueryDSL를 사용하였다.

 

  • findPostsWithPaging(): 
    • QueryDSL에서 fetchResult()를 통해 카운트 정보까지 가져와 페이징 처리를 한 번에 할 수 있다. 하지만, 이 부분을 효율적이지 못하다.
      • 단순 Count를 하는 쿼리임에도 조인 등 불필요한 처리를 하기 때문에
        더욱 시간이 오래 걸리는 비효율적인 Count 쿼리가 되기 때문이다.
        • (Spring Data JPA가 생성해주는 count 쿼리는 JPQL을 그대로 사용하여 count 쿼리를 생성하기 때문에 불필요한 조인이 생긴다.)
    • 위의 이유로 Count 쿼리를 따로 작성해주는 것이 좋다. 카운트를 하여 Long 값만 나오므로 countQuery::fetchOne을 해줬다.
    • PageableExcutionUtils.getPage() 를 사용하면 페이지가 한 페이지 밖에 없거나 마지막 페이지인 경우 굳이 Count 쿼리를 날리지 않는다. 따라서, 사용했다.
  • postsSort():
    • postsSearch를 토대로 동적 쿼리 사용 및 정렬 처리를 위한 메소드이다.
    • QueryDSL에서 사용하는 OrderSpecifer 객체에 정렬 조건을 담아서 쿼리에 추가해 주었다.
  • searchEq():
    • 마찬가지로 PostsSearch를 토대로 내용이 담긴 경우를 찾는다.

 

예시1)

 

실제로 content 기준으로 내림차순 정렬하고 title에 1이 포함된 것에 대한 Paging을 진행한 것을 확인할 수 있다.

 

예시2)

 

PostsSearch에 아무런 값도 없다면 기본 생성자의 작성대로 ID 기준으로 내림차순으로 출력된다.

 

 

그렇다면 연관관계에 있는 경우 Paging 처리는 어떻게 하는 것이 좋을까?

 

 

CommentsController

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/comments")
public class CommentsController {

    public final CommentsService commentsService;

    @GetMapping
    public ResponseEntity findCommentsWithPaging(@RequestParam(required = false, defaultValue = "0", value = "page") int pageNo,
                                              @RequestParam("content") String content){
        PageResponseDto response = commentsService.findCommentsWithPaging(content, pageNo);
        return new ResponseEntity<>(new SingleResponseDto(response), HttpStatus.OK);
    }

    @GetMapping("/queryDSL")
    public ResponseEntity findCommentsWithPagingWithQueryDSL(@RequestParam(required = false, defaultValue = "0", value = "page") int pageNo){
        PageResponseDto response = commentsService.findCommentsWithPagingWithQueryDSL(pageNo);
        return new ResponseEntity<>(new SingleResponseDto(response), HttpStatus.OK);
    }

}

 

일반적인 @Query를 사용 방식과 QueryDSL 둘다 사용해보기 위해 두 가지의 메소드를 작성했다.

 

CommentsService

@Service
@RequiredArgsConstructor
public class CommentsService {

    private final CommentsRepository commentsRepository;

    public PageResponseDto findCommentsWithPaging(String content, int pageNo){
        int page = pageNo == 0 ? 0 : pageNo - 1;
        int pageLimit = 20;

        Pageable pageable = PageRequest.of(page, pageLimit, Sort.by(DESC, "id"));

        Page<Comments> commentsList = commentsRepository.findCommentsWithPaging(content, pageable);

        return PageResponseDto.create(commentsList, CommentsResponseDto::create);
    }

    public PageResponseDto findCommentsWithPagingWithQueryDSL(int pageNo){
        int page = pageNo == 0 ? 0 : pageNo - 1;
        int pageLimit = 20;

        Pageable pageable = PageRequest.of(page, pageLimit, Sort.by(DESC, "id"));

        Page<Comments> commentsList = commentsRepository.findCommentsWithPagingWithQueryDSL(pageable);

        return PageResponseDto.create(commentsList, CommentsResponseDto::create);
    }
}

 

CommentsRepository

public interface CommentsRepository extends JpaRepository<Comments, Long>, CommentsCustom{

    @EntityGraph(attributePaths = {"posts"})
    @Query("select c from Comments c where c.content = :content")
    Page<Comments> findCommentsWithPaging(String content, Pageable pageable);
}

 

  • @EntityGraph:
    • 앞서, 말한 것처럼 Spring Data JPA는 JPQL에 맞춰서 count query를 작성한다.
      그렇기에 fetch join을 사용하면 java.lang.IllegalStateException: Failed to lad ApplicationContext 오류가 발생한다.
    • 따라서, 페치 조인의 역할을 하는 @EntityGraph를 사용한다.
    • @EntityGraph로 객체 그래프를 조회할 경우 left outer join이 발생하게 되어 페치 조인 쿼리가 빠진 JPQL로 count 쿼리가 생성되어도 상관 없다.

CommentsCustomImpl

@Repository
@RequiredArgsConstructor
public class CommentsCustomImpl implements CommentsCustom{

    private final JPAQueryFactory jpaQueryFactory;

    @Override
    public Page<Comments> findCommentsWithPagingWithQueryDSL(Pageable pageable) {
        // Load Data
        List<Comments> commentsList = jpaQueryFactory
                .selectFrom(comments)
                .leftJoin(comments.posts, posts).fetchJoin()
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .orderBy(commentsSort(pageable))
                .fetch();


        // Count data
        JPAQuery<Long> countQuery = jpaQueryFactory
                .select(comments.count())
                .from(comments);

        return PageableExecutionUtils.getPage(commentsList, pageable, countQuery::fetchOne);
    }

    private OrderSpecifier<?> commentsSort(Pageable pageable) {
        //서비스에서 보내준 Pageable 객체에 정렬조건 null 값 체크
        if (!pageable.getSort().isEmpty()) {
            //정렬값이 들어 있으면 for 사용하여 값을 가져온다
            for (Sort.Order order : pageable.getSort()) {
                // 서비스에서 넣어준 DESC or ASC 를 가져온다.
                Order direction = order.getDirection().isAscending() ? Order.ASC : Order.DESC;
                return new OrderSpecifier<>(direction, comments.id);
            }
        }
        return null;
    }
}

 

예시1) @Query

 

content로 "Comments3"을 갖는 Comment에 대해 검색한다.

 

예시2) QueryDSL

 

마찬가지로, 잘 나오는 것을 확인할 수 있다.

 

 

 

 

마무리하며

가물가물한 Pagination에 대한 연습을 하였다.
하지만, 다시하면서 새로운 사실도 많이 알게 되고 다른 지식이 생긴 상태에서 복습하니
더욱 깊이 알게된 것 같다.

 

무한 스크롤 방식에 사용되는 Slice도 사용해보고 원하는 데이터를 읽기 위해 쓸데없는 데이터를 읽어야하는
Offset 문제에 대해서도 포스팅으로 한번 다뤄봐야겠다.

 

그리고 QueryDSL에 대해서도 다른 글로 한번 다뤄야겠다.

 

 

참고 블로그

https://gilssang97.tistory.com/72

 

Page, Slice에 대해

Page, Slice 오늘 다룰 포스트는 Page와 Slice이다. 우리는 흔히 어떤 웹을 돌아다녀도 페이징을 처리한 부분을 자주 찾아볼 수 있다. 우리가 개발을 하면서 구글링을 진행할 때 하단 부분을 바라보면

gilssang97.tistory.com

https://velog.io/@kimdy0915/JPA-Spring-Data-Jpa-Pageable%EB%A1%9C-Pagination-%EC%89%BD%EA%B2%8C-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0

 

[JPA] Spring Data Jpa + Pageable로 Pagination 쉽게 구현하기

이번주는 독서리뷰를 남길 수 있는 미니 프로젝트를 진행하고 있다. 리뷰 글을 특정 조건으로 뽑아서 정렬 기준을 선택하여 조회하는 기능을 구현하고자 한다.전체 리뷰글을 조회하거나, 내가

velog.io