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)의 수
일단 컨트롤러에서 pageNum
과 limit
을 매개변수로 받았다. 그리고 아래와 같이 PageRequest
, Sort
인스턴스를 생성해서 서비스로 넘겨주었다.
PageRequest.of(pageNum, limit, Sort.by("create_dtm").descending()
PageRequest
의 of
메서드는 매개변수를 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();
}
limit
와 offset
을 바이딩하는 구문을 조금 바꿨다. 두 구문의 차이는 바인딩 순서와 관련이 있다. 수정 전에는 행수, 수정위치 순으로 바인딩 되는 것과 다르게 limit ?, ?
에서는 시작위치, 행수 로 바인딩 된다. 아래 두 쿼리는 동일하다.
SELECT 컬럼명, ... FROM 테이블명 LIMIT 행수 OFFSET 시작위치;
SELECT 컬럼명, ... FROM 테이블명 LIMIT 시작위치, 행수;
아마도 SQLDialect.java
에서 바인딩 하는 순서가 반대로 된 것이 원인으로 추정된다.