Spring Boot + JPA + SQLite Pagination 처리

2022. 11. 6. 22:02[개발] 지식/JPA

Repository

JPA에서 Pagination을 사용하기 위해서는 Pagable 객체를 Repository의 쿼리 함수의 파라미터로 넘겨주어야 한다.

@Repository
public interface TestRepository extends JpaRepository<TestData, Integer> {
  public Page<TestData> findByUserId(String userId, Pageable pageable);
}

Pagable 인터페이스를 구현체인 AbstactPageRequest 클래스를 상속한 **PageRequest**를 사용해서 페이징 정보를 전달한다. 아래는 userId로 테스트 데이터들을 조회하는 컨트롤러 소스이다.

Controller

package io.spring.api;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import io.spring.application.test.PaginationTestDataService;
import lombok.AllArgsConstructor;

@RestController
@RequestMapping(path = "/test")
@AllArgsConstructor
public class TestApi {

  private PaginationTestDataService paginationTestDataService;

  @GetMapping(path = "pagination")
  public ResponseEntity getPaginationTestDatas(
      @RequestParam(value = "page", defaultValue = "0") int pageNum,
      @RequestParam(value = "limit", defaultValue = "5") int limit,
            User user) {
    return ResponseEntity.ok(
        paginationTestDataService.findPaginationTestDatas(PageRequest.of(pageNum, limit, Sort.by("create_dtm").descending()), user));
  }
}
  • pageNum : 조회할 페이지 번호 (0부터 시작)
  • limit : 하나의 페이지에서 보여주는 행(Row)의 수

일단 컨트롤러에서 pageNumlimit을 매개변수로 받았다. 그리고 아래와 같이 PageRequest, Sort 인스턴스를 생성해서 서비스로 넘겨주었다.

PageRequest.of(pageNum, limit, Sort.by("create_dtm").descending() 

PageRequestof 메서드는 매개변수를 2개 받는 것과 3개 받는 것으로 오버로딩 되어 있다. 따라서 3번째 인자인 Sort 객체가 필수는 아니다. 생성일자를 기준으로 내림차순하여 보여주고 싶었기 때문에 이렇게 작성했다.

Service

import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import io.spring.application.data.TestDataList;
import lombok.AllArgsConstructor;

@Service
@Validated
@AllArgsConstructor
public class PaginationTestDataService {

  @Autowired private TestRepository testRepository;

  public TestDataList findPaginationTestDatas(PageRequest pageRequest, User currentUser) {

    Page<TestData> page = testRepository.findByUserId(currentUser.getId(), pageRequest);    

    List<TestData> testDataList = page.getContent();

    return new TestDataList(testDataList, (int)page.getTotalElements());
  }
}

처음에 작성한 JPA Repository를 통해 findByUserId를 호출한다. 이 때 PageRequest 객체를 2번째 인자로 넘겨준다. 그럼 Page 인터페이스 구현체를 리턴받을 수 있다. 아래는 Page 인터페이스 소스이다.

/*
 * Copyright 2008-2021 the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.springframework.data.domain;

import java.util.Collections;
import java.util.function.Function;

/**
 * A page is a sublist of a list of objects. It allows gain information about the position of it in the containing
 * entire list.
 *
 * @param <T>
 * @author Oliver Gierke
 */
public interface Page<T> extends Slice<T> {

    /**
     * Creates a new empty {@link Page}.
     *
     * @return
     * @since 2.0
     */
    static <T> Page<T> empty() {
        return empty(Pageable.unpaged());
    }

    /**
     * Creates a new empty {@link Page} for the given {@link Pageable}.
     *
     * @param pageable must not be {@literal null}.
     * @return
     * @since 2.0
     */
    static <T> Page<T> empty(Pageable pageable) {
        return new PageImpl<>(Collections.emptyList(), pageable, 0);
    }

    /**
     * Returns the number of total pages.
     *
     * @return the number of total pages
     */
    int getTotalPages();

    /**
     * Returns the total amount of elements.
     *
     * @return the total amount of elements
     */
    long getTotalElements();

    /**
     * Returns a new {@link Page} with the content of the current one mapped by the given {@link Function}.
     *
     * @param converter must not be {@literal null}.
     * @return a new {@link Page} with the content of the current one mapped by the given {@link Function}.
     * @since 1.10
     */
    <U> Page<U> map(Function<? super T, ? extends U> converter);
}

Page 인터페이스는 Slice 인터페이스를 상속한다. 따라서 Repository 리턴 값을 Slice 구현체로도 받을 수 있다. 아래는 Page 구현체를 통해 호출 가능한 페이징 관련 메서드이다.

  • getTotalPages() : 총 페이지 수
  • getTotalElements() : 총 데이터 수
  • getNumber() : 페이지 번호
  • getSize() : 페이지 별 데이터 수
  • hasnext() : 다음 페이지 존재 여부
  • isFirst() : 시작페이지 여부
  • getContent(), get() : 실제 컨텐츠를 가지고 오는 메서드. getContext는 List 반환, get()은 Stream 반환

다시 위의 서비스 내용을 보면:

List<TestData> testDataList = page.getContent();

리턴 받은 Page 구현체로부터 실제 데이터 리스트를 가져온다.

return new TestDataList(testDataList, (int)page.getTotalElements());

실제 조회한 데이터 리스트와 토탈 데이터 수를 담아 리턴했다. 이 부분은 페이징 처리 구현 방식에 따라 다를 수 있으므로 참고만 하자.

[참고] SQLite Bugfix

SQLite DB를 사용했는데, 페이지네이션 처리가 제대로 되지 않는 현상이 있었다. 1번과 2번 페이지는 정상 조회되었으나 3번 페이지부터 동일한 데이터만 보이는 버그가 있었다. 이는 아래 설명하는 바와 같이 SQLDialect를 수정해서 해결했다.

JPA Hibernate에서는 SQLite에 대한 SQLDialect를 지원하지 않기 때문에 개발자가 직접 클래스를 작성해서 사용해야 한다. 일반적으로 구글링했을 때 구할 수 있는 소스는 아래와 같다.

package io.spring;

import java.sql.Types;
import org.hibernate.dialect.Dialect;
import org.hibernate.dialect.function.SQLFunctionTemplate;
import org.hibernate.dialect.function.StandardSQLFunction;
import org.hibernate.dialect.function.VarArgsSQLFunction;
import org.hibernate.type.StringType;

public class SQLDialect extends Dialect {
  public SQLDialect() {
    registerColumnType(Types.BIT, "integer");
    registerColumnType(Types.TINYINT, "tinyint");
    registerColumnType(Types.SMALLINT, "smallint");
    registerColumnType(Types.INTEGER, "integer");
    registerColumnType(Types.BIGINT, "bigint");
    registerColumnType(Types.FLOAT, "float");
    registerColumnType(Types.REAL, "real");
    registerColumnType(Types.DOUBLE, "double");
    registerColumnType(Types.NUMERIC, "numeric");
    registerColumnType(Types.DECIMAL, "decimal");
    registerColumnType(Types.CHAR, "char");
    registerColumnType(Types.VARCHAR, "varchar");
    registerColumnType(Types.LONGVARCHAR, "longvarchar");
    registerColumnType(Types.DATE, "date");
    registerColumnType(Types.TIME, "time");
    registerColumnType(Types.TIMESTAMP, "timestamp");
    registerColumnType(Types.BINARY, "blob");
    registerColumnType(Types.VARBINARY, "blob");
    registerColumnType(Types.LONGVARBINARY, "blob");
    // registerColumnType(Types.NULL, "null");
    registerColumnType(Types.BLOB, "blob");
    registerColumnType(Types.CLOB, "clob");
    registerColumnType(Types.BOOLEAN, "integer");

    registerFunction("concat", new VarArgsSQLFunction(StringType.INSTANCE, "", "||", ""));
    registerFunction("mod", new SQLFunctionTemplate(StringType.INSTANCE, "?1 % ?2"));
    registerFunction("substr", new StandardSQLFunction("substr", StringType.INSTANCE));
    registerFunction("substring", new StandardSQLFunction("substr", StringType.INSTANCE));
  }

  public boolean supportsIdentityColumns() {
    return true;
  }

  public boolean hasDataTypeInIdentityColumn() {
    return false; // As specify in NHibernate dialect
  }

  public String getIdentityColumnString() {
    // return "integer primary key autoincrement";
    return "integer";
  }

  public String getIdentitySelectString() {
    return "select last_insert_rowid()";
  }

  public boolean supportsLimit() {
    return true;
  }

  protected String getLimitString(String query, boolean hasOffset) {
    return new StringBuffer(query.length() + 20)
        .append(query)
        .append(hasOffset ? " limit ? offset ?" : " limit ?")
        .toString();
  }

  public boolean supportsTemporaryTables() {
    return true;
  }

  public String getCreateTemporaryTableString() {
    return "create temporary table if not exists";
  }

  public boolean dropTemporaryTableAfterUse() {
    return false;
  }

  public boolean supportsCurrentTimestampSelection() {
    return true;
  }

  public boolean isCurrentTimestampSelectStringCallable() {
    return false;
  }

  public String getCurrentTimestampSelectString() {
    return "select current_timestamp";
  }

  public boolean supportsUnionAll() {
    return true;
  }

  public boolean hasAlterTable() {
    return false; // As specify in NHibernate dialect
  }

  public boolean dropConstraints() {
    return false;
  }

  public String getAddColumnString() {
    return "add column";
  }

  public String getForUpdateString() {
    return "";
  }

  public boolean supportsOuterJoinForUpdate() {
    return false;
  }

  public String getDropForeignKeyString() {
    throw new UnsupportedOperationException(
        "No drop foreign key syntax supported by SQLiteDialect");
  }

  public String getAddForeignKeyConstraintString(
      String constraintName,
      String[] foreignKey,
      String referencedTable,
      String[] primaryKey,
      boolean referencesPrimaryKey) {
    throw new UnsupportedOperationException("No add foreign key syntax supported by SQLiteDialect");
  }

  public String getAddPrimaryKeyConstraintString(String constraintName) {
    throw new UnsupportedOperationException("No add primary key syntax supported by SQLiteDialect");
  }

  public boolean supportsIfExistsBeforeTableName() {
    return true;
  }

  public boolean supportsCascadeDelete() {
    return false;
  }
}

아무 생각 없이 SQLDialect.java 를 그대로 사용했더니 Pagination을 구현해도 정상적으로 동작하지 않았다. 1페이지와 2페이지는 정상적으로 조회되지만, 3페이지부터는 2페이지 내용이 반복되었다.

결론적으로 SQLDialect.java의 아래 코드를 수정하면서 해결했다.

protected String getLimitString(String query, boolean hasOffset) {
    return new StringBuffer(query.length() + 20)
        .append(query)
        .append(hasOffset ? " limit ? offset ?" : " limit ?")
        .toString();
  }
protected String getLimitString(String query, boolean hasOffset) {
    return new StringBuffer(query.length() + 20)
        .append(query)
        .append(hasOffset ? " limit ?, ?" : " limit ?")
        .toString();
  }

limitoffset을 바이딩하는 구문을 조금 바꿨다. 두 구문의 차이는 바인딩 순서와 관련이 있다. 수정 전에는 행수, 수정위치 순으로 바인딩 되는 것과 다르게 limit ?, ? 에서는 시작위치, 행수 로 바인딩 된다. 아래 두 쿼리는 동일하다.

SELECT 컬럼명, ... FROM 테이블명 LIMIT 행수 OFFSET 시작위치;
SELECT 컬럼명, ... FROM 테이블명 LIMIT 시작위치, 행수;

아마도 SQLDialect.java에서 바인딩 하는 순서가 반대로 된 것이 원인으로 추정된다.

<