Pagination

언버스의 페이지네이션을 설명 합니다.

1. 개요

1.1. 기존 페이징 방식

언버스의 페이지네이션을 설명하기 이전에, 기존 대부분의 프로젝트에서 사용하는 방식들을 알아보고, 언버스의 페이지네이션을 살펴보도록 합니다.

1.1.1. 페이징 정보를 파라미터로 받아서 처리

@RequestMapping(value="/shop/list")
public @ResponseBody ModelMap itemListIndex(ModelMap model, 
                                            @RequestParam int firstIndex,
                                            @RequestParam int recordCountPerPage) {
    /** ---------------------------------------------- paging 로직 **/
    Map param = new HashMap();
    param.put("firstIndex", firstIndex);
    param.put("firstIndex", recordCountPerPage);
    int totalItemCnt = searchService.searchProductItemCount(param);
    
    model.addAttribute("totalItemCount", totalItemCnt);
    /** --------------------------------------------// paging 로직 **/
    
    /** 실제 business logic **/
    List<ProductVO> list = searchService.searchProductItem(param);
    model.addAttribute("list", list);
    
    return model;
}

실제 business logic 보다 페이징을 위한 코드가 더 많습니다.

1.1.2. 파라미터 모델을 만들어 사용

@RequestMapping(value="/shop/list")
public @ResponseBody ModelMap itemListIndex(ModelMap model, Product product) {
  /** ---------------------------------------------- paging 로직 **/
  int totalItemCnt = searchService.searchProductItemCount(product);

  model.addAttribute("totalItemCount", totalItemCnt);
  /** --------------------------------------------// paging 로직 **/


  /** 실제 business logic **/
  List<ProductVO> list = searchService.searchProductItem(product);
  model.addAttribute("list", list);

  return model;
}
// Product.java
@Data
public class Product {
  private Long id;
  private String name;
  //.....

  /** ---------------------------------------------- paging 파라미터 **/
  private int firstIndex = 1;
  private int recordCountPerPage = 10;
  /** --------------------------------------------// paging 파라미터 **/
}

여전히 페이징을 위한 부분이 들어가며, Product 도메인 클래스는 자신의 고유 속성과 관련 없는 페이징 속성을 가져야 합니다.

Unvus Pagination

  • Resource
@GetMapping(value = "/user", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<List<User>> listUser(@RequestParam(value="q", required = false) Map<String, Object> q) throws URISyntaxException {
    if(q == null) q = new HashMap();

    List<User> userList = userService.listUser(q);

    /** 페이징을 위한, 전체 수, 현재 페이지 등의 정보를 자동으로 담아줍니다. **/
    HttpHeaders headers = PaginationHeaderUtil.generatePaginationHttpHeaders();

    return new ResponseEntity<>(userList, headers, HttpStatus.OK);
}
  • Service
public List<User> listUser(Map<String, Object> params) {
    // 검색 조건 세팅. 이 부분은 `검색 조건` 섹션에서 자세히 다루도록 합니다.
    QueryBuilder qb = QueryBuilder.of(params, UserDsl.defaultAlias());
    
    // with pagination
    List<User> lists = InPagination.withRequest((p) -> userRepository.listUser(qb.build()));

    return lists;
}

Unvus Pagination 은

  • 도메인객체, 파라미터 등에 페이징 관련 부분을 모두 없애고, 비즈니스 로직에 집중
  • 페이징을 위한 count 쿼리 또한 자동으로 실행 (count 쿼리 id 는 list 쿼리명 뒤에 ‘Cnt’ suffix - 변경 가능)

4. 예제 코드 에서 더 복잡한 예제를 볼 수 있습니다.

페이징이 필요 없는 경우 요청을 보낼 때, USE_PAGING 값을 false로 설정하면 됩니다.

pagination 은 크게 아래 두가지로 사용법으로 구분됩니다.

구분설명챕터
InPagination.page()request, response 와 관계 없이, 비즈니스로직 내에서 페이징이 필요한 경우 사용합니다.1. 일반 페이징 & 정렬
InPagination.sort()request, response 와 관계 없이, 비즈니스로직 내에서 소트가 필요한 경우 사용합니다.1. 일반 페이징 & 정렬
InPagination.withRequest()request 로 부터 페이지 정보를 얻고, 결과 값을 response 에 담습니다.2. Primary 페이징 & 정렬

1. 일반 페이징 & 정렬

비즈니스 로직상에서 페이징, 정렬이 필요한 경우 Pagination 을 사용 하는 방법을 설명합니다.

1.1. 페이징

java 비즈니스 로직에서 특정 쿼리에 페이징이 필요한 경우 다음과 같이 사용 할 수 있습니다.

// 처음 5개의 항목 가져오기
List<User> lists = InPagination.page((p) -> userRepository.listUser(param), 5);

다음처럼 offset 구현을 할 수 있습니다.

// 3번째 페이지의 5개 항목 가져오기
List<User> lists = InPagination.page((p) -> userRepository.listUser(param), 3, 5);

1.2. 정렬

java 비즈니스 로직에서 특정 쿼리에 정렬이 필요한 경우 다음과 같이 사용 할 수 있습니다.

// user_id 오름차순으로 데이터를 가져옵니다. 페이징 안함
List<User> lists = InPagination.sort((p) -> userRepository.listUser(param), new SortBy("u.user_id"));

다음과 같이, 여러가지 정렬 기준을 설정 할 수 있습니다.

// 정렬 조건은 여러개가 될 수 있습니다
// user_id 오름차순, user_name 내림차순 으로 데이터를 가져옵니다. 페이징 안함
List<User> lists = InPagination.sort(
  (p) -> userRepository.listUser(param), 
  new SortBy("u.user_id"),
  new SortBy("u.user_name", SortBy.SortDirection.DESC));

정렬에 대한 옵션을 지정 할 수 있습니다.
InPagination.SortByType.REPLACE 는 기존 정렬이 있을 경우, 이를 무시하고 대체하도록 합니다.

// 정렬 조건은 여러개가 될 수 있습니다
// user_id 오름차순, user_name 내림차순 으로 데이터를 가져옵니다. 페이징 안함
List<User> lists = InPagination.sort(
  (p) -> userRepository.listUser(param),
  InPagination.SortByType.REPLACE,
  new SortBy("u.user_id"));

정렬 옵션은 REPLACE_IF_NOT_EXISTS, REPLACE, APPEND 가 있으며, 차이점은 다음과 같습니다.
이 정렬 옵션은 1. 일반 페이징 & 정렬 에서 보다는, 주로 2. Primary 페이징 & 정렬 에서 기본정렬값을 설정하기 위해 사용합니다.

메소드설명
APPEND정렬 순서를 추가합니다.
이전에 설정된 정렬 순서가 있다면 그 뒤로 추가합니다.
REPLACE정렬 순서를 설정합니다.
이전에 설정된 정렬 순서가 있다면 무시하고 덮어 씁니다.
REPLACE_IF_NOT_EXISTS이전에 설정된 정렬 순서가 없는 경우에만, 정렬 순서를 설정합니다.

1.3. 복합 (페이징 + 정렬)

다음과 같이 페이징과 정렬을 구성 할 수 있습니다.

// user_id 오름차순, 3번째 페이지의 5개의 데이터만 가져옵니다
InPagination.pageSort((p) -> userRepository.listUser(param), 3, 5, new SortBy("u.user_id"));

2. Http Request 로부터 페이징 & 정렬

InPagination.withRequest()HttpServletRequest 파라미터에서 pagination 정보를 가져와서 설정하고, 그 실행 결과를 HttpServletResponse header 에 설정합니다.

기본 사용법은 다음과 같습니다.

List<User> lists = InPagination.withRequest((p) -> userRepository.listUser(qb.build()));

기본 소팅을 추가해주고 싶으면 다음과 같이 합니다.

List<Member> lists = InPagination.withRequest(
  (p) -> {
    return memberRepository.listMember(qb.build());
  },
  new SortBy(u.renderColumn(u.id), SortBy.SortDirection.DESC)
);

프레임워크에서 페이징을 위해 HttpServletRequest 파라미터에서 획득하는 값은 다음과 같습니다.

파라미터설명기본값
currentPage요청 하는 페이지 번호1currentPage=8
dataPerPage한 페이지당 데이터 수10dataPerPage=50
linkPerPageUI 상 화면에 보여질 페이지 링크 수10linkPerPage=5
sortBy쿼리 정렬 기준(콤마 구분)sortBy=u.user_id:desc,u.user_name:asc
sortByList쿼리 정렬 기준(파라미터 배열 구분)sortByList=u.user_id:desc&sortByList=u.user_name:asc

페이징 결과 값은 HttpServletResponse 의 header 에 다음과 같은 키값으로 설정됩니다.

파라미터설명
x-nv-total-count전체 데이터 수
x-nv-data-count한 페이지당 데이터 수
x-nv-page현재 페이지 번호

이로써, 응답받은 페이지(jsp), 또는 ajax client 에서 결과에 따른 페이지 UI 화면을 구성 할 수 있게 합니다.

RestAPI 경우 **Resource 에서 값을 반환하기 전에, 다음과 같이 PaginationHeaderUtil.generatePaginationHttpHeaders() 를 호출 함으로서, header 에 결과값을 세팅 할 수 있습니다.

public ResponseEntity<> some() {
  /** some logic here **/
  
  HttpHeaders headers = PaginationHeaderUtil.generatePaginationHttpHeaders();
  return new ResponseEntity<>(userList, headers, HttpStatus.OK);
}

3. 전제조건

TIP

여기에서 설명하는 페이징(및 소팅)을 위한, 기본 구조 및 설정은 code genie 에 의해 자동으로 생성됩니다.

페이징(및 소팅)을 위해서는 다음과 같은 조건이 성립되어야 합니다.

3.1. count 쿼리 제공

프레임워크에서는 기본적으로, list query 의 mapper idCnt 를 붙인 mapper id 를 count 쿼리로 판단합니다.

즉, 페이징 설정을 한 뒤 userRepository.listUser(param) 를 호출하면,
프레임워크 내부에서는 userRepository.listUserCnt(param) 를 함께 호출 하게 됩니다.

그러므로, 프레임워크에게 count 쿼리를 함께 제공해주어야 합니다.

code genie 를 통해 code 를 생성하면, list 쿼리와 함께 다음과 같이 count 쿼리도 함께 생성해줍니다.

TIP

count 맵퍼 아이디로 mapper id + Cnt 외에, 다른 아이디를 사용하고 싶다면,
아래 3.2. @Pageable 어노테이션 의 countMapperId 설명을 참고해주세요

<select id="listUser" resultMap="userResult">
    SELECT *
    FROM user u
    <!-- 검색 조건 sql include -->
    <include refid="userCondition">
        <property name="_alias" value="u."/>
    </include>

    <!-- 정렬 logic -->
    <include refid="Common.sort"/>

    <!-- 페이징 query logic -->
    <include refid="Common.pagingFooter"/>
</select>

<select id="listUserCnt" resultType="int">
    SELECT COUNT(*)
    FROM user u
    <!-- 검색 조건 sql include -->
    <include refid="userCondition">
        <property name="_alias" value="u."/>
    </include>

</select>

3.2. @Pageable 어노테이션

페이징(소팅)이 작동되기 원하는 mapper method 에는 다음과 같이 @Pageable 어노테이션이 있어야 합니다.
해당 어노테이션이 존재 하지 않으면 InPagination.** 을 통해 페이징 설정을 하더라도, 작동되지 않습니다.

@Pageable
List<Member> listMember(Map<String, Object> params);

int listMemberCnt(Map<String, Object> params);
파라미터설명기본값
countMapperIdlist mapper id + Cnt 가 아닌 별도의 count mapper id 를 제공
usePagingKey페이징 사용 여부 키값UnvusConstants.USE_PAGING
useMergeQuerymerge query 를 사용할지 여부false
mergeMapperIdmerge query 사용시 merge query mapper id
mergeParamIdmerge query 사용시 id 리스트의 param 명ids

4. merge query

쿼리 결과가 1:N 형태를 가질때, 페이징을 하면, 기대했던 수량 보다 더 작은 데이터를 가져오게 됩니다. 다음 예로 살펴보겠습니다.

User (사용자) 클래스가 있습니다. User 는 여러개의 Role (권한) 을 가질 수 있습니다.

public class User {
    private String acntId;
    private String name;

    private List<Role> roleList; 
}

Role (권한) 클래스는 다음과 같습니다.

public class Role {
  private String code;
  private String name;
}

이와 같이 user 와 role 는 1:N 관계로 구성되어 있습니다.

쿼리를 실행해보겠습니다.

SELECT u.user_acnt_id, u.user_name, r.role_code 
  FROM nv_user u 
  JOIN nv_role r ON u.user_id = r.user_key

결과는 다음과 같습니다.

user_acnt_iduser_namerole_code
guavatak구아바ROLE_ADMIN
guavatak구아바ROLE_CMS
admin관리자ROLE_ADMIN
admin관리자ROLE_AGENT
user사용자ROLE_USER

위 결과는 mybatis 에 의해 3개의 User 객체(guavatak, admin, user) 를 반환하게 됩니다.
그리고, 각 객체는 자신의 roleList 를 보유한 상태가 됩니다.

이런 경우에 3개만 가져오도록 paging 을 하면 다음과 같은 결과가 나옵니다.

SELECT u.user_acnt_id, u.user_name, r.role_code 
  FROM nv_user u 
  JOIN nv_role r ON u.user_id = r.user_key
LIMIT 3

결과는 다음과 같습니다.

user_acnt_iduser_namerole_code
guavatak구아바ROLE_ADMIN
guavatak구아바ROLE_CMS
admin관리자ROLE_ADMIN

이는 mybatis 에 의해 2개의 User 객체(guavatak, admin) 를 반환하게 되며,
admin 은 ROLE_AGENT 가 없는 상태가 되어버립니다.

이런 경우에는 merge query 를 사용하도록 합니다.

merge query 를 사용하지 않을때는
다음과 같은 순서로 쿼리를 실행하게 됩니다.
(리스트 mapper id 가 listUser 라고 가정)

  1. listUser
  2. listUserCnt

merge query 를 사용하면, 앞에 한 단계가 추가됩니다.

  1. listUserIds
  2. listUser
  3. listUserCnt

이 경우 Ids 쿼리는 다음과 같은 형태가 됩니다.

-- listUserIds
SELECT DISTINCT u.user_acnt_id
  FROM nv_user u 
  JOIN nv_role r ON u.user_id = r.user_key
LIMIT 3

결과는 다음과 같습니다.

user_acnt_id
guavatak
admin
user

이 결과값은 리스트쿼리의 파라미터(ids) 로 전달 되며,
리스트 쿼리는 ids 파라미터가 있는 경우 다른 조건을 생략하고 해당 조건만 실행하게 됩니다.

-- listUser
SELECT u.user_acnt_id, u.user_name, r.role_code 
  FROM nv_user u 
  JOIN nv_role r ON u.user_id = r.user_key
 WHERE u.user_acnt_id IN ('guavatak', 'admin', 'user')

결과는 다음과 같습니다.

user_acnt_iduser_namerole_code
guavatak구아바ROLE_ADMIN
guavatak구아바ROLE_CMS
admin관리자ROLE_ADMIN
admin관리자ROLE_AGENT
user사용자ROLE_USER

이렇게 해서 가져오고 싶은 수 만큼 정확히 가져 올 수 있게됩니다.

TIP

merge query 에서 사용하는 Ids mapper sql 또한 code genie 에 의해 자동으로 생성되어 집니다.

5. 예제 코드

다음은 상품 목록 화면에 Top banner 3개와, 최근 본 상품 5개 를 함께 보여주는 Controller class 예제 코드 입니다.


@GetMapping("/product")
public String listProduct(Map<String, Object> q, Model model) {

    // "Top banner" 를 위해, 5개의 데이터만 가져옴 
    List<Banner> bannerList = InPagination.pageSort((p) -> bannerService.listTopBanner(q), 5);
    model.addAttribute("bannerList", bannerList);
    
    // "최근 본 상품" 을 위해, 5개의 데이터만 가져옴 
    List<Product> recentList = InPagination.pageSort((p) -> productService.listRecentProduct(q), 5);
    model.addAttribute("recentList", recentList);

    // 이 메소드의 Main 인 "상품 목록" 을 위해, http request parameter 부터 페이징(소팅) 설정
    List<Product> productList = InPagination.withRequest((p) -> productService.listProduct(q));
    model.addAttribute("productList", productList);
    
    return "product-list";
}