현재 Java의 내장된 Pageable 객체는 0페이지부터 시작하는 구조를 가지고 있습니다.
특히, 많은 웹 애플리케이션에서 페이지 번호는 1부터 시작하는 경향이 있어, 이로 인해 혼란이 발생할 수 있습니다.
이를 해결하기 위해, 우리는 Pageable을 상속받아 커스텀 클래스를 구현하였습니다. 이 과정에서 실제 프론트엔드 페이지 번호와 일치하도록 페이지 계산 로직을 수정하였습니다.
아래는 커스텀 페이지 구현의 핵심 요소입니다:
class CustomPageImpl<T>(
content: List<T>, pageable: Pageable, total: Long
) : PageImpl<T>(content, pageable, total) {
private val elements = total
override fun getTotalPages() = ceil(elements.div(super.getSize().toDouble())).toInt()
}
object CustomPageableExecutionUtils {
fun <T> getPage(content: List<T>, pageable: Pageable, totalSupplier: LongSupplier): Page<T> =
CustomPageImpl(content, pageable, totalSupplier.asLong)
}
abstract class CustomQuerydslRepositorySupport(domainClass: Class<*>) : QuerydslRepositorySupport(domainClass) {
protected fun <T> applyPagination(pageable: Pageable, query: JPQLQuery<T>): JPQLQuery<T> {
require(pageable.pageNumber > 0) { "Page number must be greater than 0." }
val zeroBasedPageable = pageable.withPage(pageable.pageNumber.minus(1))
return query.offset(zeroBasedPageable.offset).limit(zeroBasedPageable.pageSize.toLong())
}
}
import com.querydsl.core.types.OrderSpecifier
import com.querydsl.core.types.dsl.ComparableExpressionBase
import com.querydsl.core.types.dsl.EntityPathBase
import com.querydsl.jpa.JPQLQuery
import jakarta.persistence.EntityManager
import jakarta.persistence.PersistenceContext
import org.springframework.data.domain.*
import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport
import kotlin.math.ceil
class CustomPageRequest(
page: Int, size: Int, sort: Sort
) : PageRequest(page, size, sort) {
// 0부터 시작하는 페이지 번호를 1부터 시작하는 페이지 번호로 변환
override fun getOffset(): Long = maxOf(0L, (pageNumber.minus(1L))).times(pageSize)
}
/**
* 커스텀 페이지 구현 클래스.
*
* @param content 페이지에 포함될 데이터 리스트
* @param pageable 페이지 요청 정보
* @param total 전체 데이터 수
*
* 이 클래스는 Spring Data의 PageImpl을 확장하여, 페이지네이션이 1페이지부터 시작하도록 수정합니다.
*/
class CustomPageImpl<T>(
content: List<T>, pageable: Pageable, private val total: Long
) : PageImpl<T>(content, pageable, total) {
/**
* 전체 데이터 수를 반환합니다.
* @return 전체 데이터 수
*/
override fun getTotalElements() = total
/**
* 전체 페이지 수를 계산하여 반환합니다.
* @return 전체 페이지 수
*/
override fun getTotalPages() = (this.size.takeIf { it > 0 }?.let { ceil(total.toDouble().div(it)).toInt() } ?: 1)
/**
* 1페이지부터 시작하는 페이지 번호를 반환합니다.
* @return 첫 페이지 여부
*/
override fun isFirst() = pageable.pageNumber == 1
/**
* 마지막 페이지 여부를 반환합니다.
* @return 마지막 페이지 여부
*/
override fun isLast() = pageable.pageNumber >= this.totalPages
}
/**
* 페이지네이션을 위한 유틸리티 객체.
*/
object CustomPageableExecutionUtils {
/**
* 페이지 객체를 생성합니다.
*
* @param content 페이지에 포함될 데이터 리스트
* @param pageable 페이지 요청 정보
* @param totalSupplier 전체 데이터 수를 제공하는 LongSupplier
* @return 생성된 Page<T> 객체
*/
fun <T> getPage(content: List<T>, pageable: Pageable, total: () -> Long): Page<T> =
CustomPageImpl(content, CustomPageRequest(pageable.pageNumber, pageable.pageSize, pageable.sort), total())
}
/**
* 커스텀 Querydsl 리포지토리 지원 클래스.
*
* @param domainClass 도메인 클래스
*
* 이 클래스는 QuerydslRepositorySupport를 확장하여, 커스텀 페이지네이션 기능을 제공합니다.
*/
abstract class CustomQuerydslRepositorySupport(domainClass: Class<*>) : QuerydslRepositorySupport(domainClass) {
/**
* 페이지네이션을 적용하여 JPQLQuery를 수정합니다.
*
* @param pageable 페이지 요청 정보
* @param query JPQLQuery 객체
* @return 페이지네이션이 적용된 JPQLQuery 객체
* @throws IllegalArgumentException 페이지 번호가 0 이하일 경우 예외 발생
*/
protected fun <T> applyPagination(
pageable: Pageable,
query: JPQLQuery<T>,
vararg path: EntityPathBase<*>
): JPQLQuery<T> {
require(pageable.pageNumber > 0) { "Page number must be greater than 0." }
// frontend에서 1부터 시작하는 페이지 번호를 0부터 시작하는 페이지 번호로 변환
val zeroBasedPageable = pageable.withPage(pageable.pageNumber.minus(1))
return query
.offset(zeroBasedPageable.offset)
.limit(zeroBasedPageable.pageSize.toLong())
.orderBy(*makeOrder(pageable, path).toTypedArray())
}
/**
* 페이지를 가져오는 메서드.
*
* @param content 페이지에 포함될 데이터 리스트
* @param pageable 페이지 요청 정보
* @param total 전체 데이터 수를 제공하는 LongSupplier
* @return 생성된 Page<T> 객체
*/
protected fun <T> fetchPage(content: List<T>, pageable: Pageable, total: () -> Long): Page<T> =
CustomPageableExecutionUtils.getPage(content, pageable, total)
// pageable 에 있는 sort 를 사용하여, 자동 정렬을 합니다.
private fun makeOrder(pageable: Pageable, entityPath: Array<out EntityPathBase<*>>): List<OrderSpecifier<*>> =
pageable.sort.mapNotNull { order ->
propertyMappingByReflection(entityPath, order.property)?.let {
when (order.direction) {
Sort.Direction.DESC -> it.desc()
else -> it.asc()
}
}
}
// 엔티티에 있는 항목과 비교하여, 항목이 있다면 해당 항목에 매핑을 시켜주는 작업을 진행합니다.
private fun propertyMappingByReflection(
entityPath: Array<out EntityPathBase<*>>,
propertyName: String
): ComparableExpressionBase<*>? {
// 각 엔티티를 순회하며 propertyName 필드를 찾습니다.
entityPath.forEach { entity ->
runCatching {
entity::class.java.getDeclaredField(propertyName).apply { isAccessible = true }
}.getOrNull()?.let { field ->
// 필드가 존재하고, 해당 필드가 ComparableExpressionBase로 캐스팅 가능할 경우 반환
return field.get(entity) as? ComparableExpressionBase<*>
}
}
return null
}
}
@Component
class HospitalNoteRepositoryAdaptor(private val queryFactory: JPAQueryFactory) :
CustomQuerydslRepositorySupport(HospitalNote::class.java), HospitalNoteQueryRepository {
override fun getNotes(hospitalId: String, page: Pageable): Page<HospitalNote> {
val query = queryFactory
.from(hospitalNote)
.where(hospital.id.eq(hospitalId))
.where(hospitalNote.useYn.eq("Y"))
.orderBy(hospitalNote.createdAt.desc())
val listQuery = query.select(hospitalNote)
val list = applyPagination(page, listQuery).fetch()
val count = query.select(hospitalNote.id.count()).fetchFirst() ?: 0L
return fetchPage(list, page) { count }
}
}