이번 포스팅에는 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를 하는 쿼리임에도 조인 등 불필요한 처리를 하기 때문에
- 위의 이유로 Count 쿼리를 따로 작성해주는 것이 좋다. 카운트를 하여 Long 값만 나오므로 countQuery::fetchOne을 해줬다.
- PageableExcutionUtils.getPage() 를 사용하면 페이지가 한 페이지 밖에 없거나 마지막 페이지인 경우 굳이 Count 쿼리를 날리지 않는다. 따라서, 사용했다.
- QueryDSL에서 fetchResult()를 통해 카운트 정보까지 가져와 페이징 처리를 한 번에 할 수 있다. 하지만, 이 부분을 효율적이지 못하다.
- 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 쿼리가 생성되어도 상관 없다.
- 앞서, 말한 것처럼 Spring Data JPA는 JPQL에 맞춰서 count query를 작성한다.
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
'Spring > JPA' 카테고리의 다른 글
[JPA] getReferenceById() 리마인드 (0) | 2024.12.23 |
---|---|
[QueryDSL] QueryDSL이란? (0) | 2024.03.30 |
[SpringBoot] OSIV와 성능 최적화 (0) | 2024.02.01 |
[SpringBoot] JPA Collection 페이징 처리 (0) | 2024.01.29 |
[SpringBoot] JPA Collection 페치 조인 최적화 (0) | 2024.01.29 |