Technology/JPA

PageImpl 의 역할 제대로 이해하기

ikjo 2024. 1. 8. 02:08

발생 이슈

입사한지 얼마 안되 배정받았던 소소한(?) 이슈가 있었다. FE 측에서 BE 측에 페이지네이션 기능을 포함한 목록 조회 API 를 호출했는데, 분명히 페이지네이션 관련 파라미터를 제대로 할당했음에도 불구하고 페이지네이션된 데이터가 아니라 전체 데이터가 조회된다는 것이었다. 👀

 

 

코드 분석

당시 나는 우선적으로 해당 API 의 로직 중 데이터베이스에 접근하는 계층의 로직을 우선적으로 살펴보았다. 해당 로직을 살펴보니 엔티티 매니저 의존성을 직접 주입받고 이를 활용하여 데이터베이스에 접근하고있었으며, 네이티브 쿼리를 이용하고있었다. 이때 동적 검색 기능을 구현하기 위해 일일이 조건절을 만들어 문자열 쿼리를 만들어주고 있었다.

 

근데, 쿼리를 생성하는 로직을 아무리 살펴봐도 페이지네이션을 설정하는 부분이 보이지 않았다. 👀 그대로 주욱 로직을 살펴보니, 가장 맨 아래에서 데이터베이스로부터 조회한 엔티티 데이터와 Pageable 객체를 인자로 하여 new 연산을 통해 PageImple 객체를 생성하여 서비스 계층에 전달해고있는 것이었다. 🙃 대략적인 로직의 상황은 아래와 같았다.

 

public class XXXRepositoryImpl implements XXXRepository {

  @PersistenceContext
  private EntityManager entityManager;

  @Override
  public Page<XXX> findAllXXXWithFilter(Pageable pageable) {
 
      // ...
 
      final Query query = this.entityManager.createNativeQuery(listQueryString.toString(), XXX.class);
      List<XXX> entities = query.getResultList();

      final BigInteger totalCount = (BigInteger) this.entityManager.createNativeQuery(countQueryString).getSingleResult();

      return new PageImpl<>(entities, pageable, totalCount.intValue());
}

 

 

당시 나는 PageImpl 클래스를 처음봤었고, 이 PageImpl 객체가 어떤 역할을 하는지 궁금해졌다. 혹시 이 PageImpl 이 무슨 페이징 처리라도 해주는 역할을 하는걸까 궁금해졌다. 👀

 

 

PageImpl 이란?

PageImpl 클래스는 org.springframework.data.domain 패키지 내에 있는 클래스인데, 우리 서비스의 경우 spirng-data-jpa 를 사용했었기에, 기본적으로 포함된 spring-data-commons jar 내에 포함되어 제공되는 것이었다. 이때, PageImpl 클래스는 동일 패키지 내에 포함된 Page 인터페이스를 구현하고있었다. 클래스의 코드는 대략 아래와 같으며, Page 인터페이스의 추상메서드들을 각각 오버라이딩(아래 코드에서는 생략)하고있었다.

 

package org.springframework.data.domain;

import java.util.List;
import java.util.function.Function;

import org.springframework.lang.Nullable;

/**
 * Basic {@code Page} implementation.
 *
 * @param <T> the type of which the page consists.
 * @author Oliver Gierke
 * @author Mark Paluch
 */
public class PageImpl<T> extends Chunk<T> implements Page<T> {

	private static final long serialVersionUID = 867755909294344406L;

	private final long total;

	/**
	 * Constructor of {@code PageImpl}.
	 *
	 * @param content the content of this page, must not be {@literal null}.
	 * @param pageable the paging information, must not be {@literal null}.
	 * @param total the total amount of items available. The total might be adapted considering the length of the content
	 *          given, if it is going to be the content of the last page. This is in place to mitigate inconsistencies.
	 */
	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);
	}
    
    // ...
    
 }

 

 

기존 우리 코드에서 new 연산자를 통해 PageImpl 객체를 생성할 때에는 위 코드에 정의된 PageImpl 생성자를 호출하게 된다. 근데, 생성자 내 로직을 살펴보면, 할당받은 엔티티 데이터(List<T> content)를 Wrapping 해주는 역할만 할뿐 별도로 페이징 처리를 해주는 로직은 보이지 않았다.

 

 

PageImpl 에 대한 오해 풀기

이제 본격적인 디버깅을 위해 해당 API 를 로컬 환경에서 호출하여, 데이터베이스에 전송되는 쿼리를 살펴보았다. 아까, 쿼리를 생성하는 로직을 살펴봤듯이 실제 데이터베이스에 전송되는 쿼리에는 페이징 처리를 위한 clause 가 어디에도 보이지 않았다.

 

그럼 이전에 이 코드를 작성했었던 담당자는 왜 이 PageImple 을 사용했을까 의문이 들었다. 일단, PageImpl 과 not paging 키워드로 구글링을 해보았다. 근데 웬걸 GitHub 의 spring-project 저장소 상에 우리와 동일한 문제로 이슈를 제기한 개발자가 있었다. 🙏 PageImpl 객체를 생성할 때, 페이징 처리가 되지 않는다는 것이었다. 이에, spring 측에서는 아래와 같이 답변을 주었다.

 

I think you're misunderstanding the purpose of PageImpl. It doesn't exist to hand it a list and split that up into individual pages. As the JavaDoc of the PageImpl constructor taking a List suggests, the List you hand is the content of that very page. Pagination exists to avoid having to load all the data in the first place to then end up using only a few of the elements. In the example you give on your StackOverflow post you should've handed the Pageable object down to the repository so that it can already optimize the query to only retrieve the elements you're actually interested in

 

대략적으로, PageImpl 의 역할은 페이징 처리를 해주기 위함이 아니며, 페이징 처리는 데이터베이스에 쿼리를 전송 시에 해야한다는 것이다. 😅 이때, PageImpl 가 어떻게 사용되는지 궁금해졌다. 일례로, spring-data-jpa 에서 제공하는 SimpleJpaRepository 에서는 아래와 같은 방식으로 사용되고있었다.

 

	@Override
	public Page<T> findAll(@Nullable Specification<T> spec, Pageable pageable) {

		TypedQuery<T> query = getQuery(spec, pageable);
		return isUnpaged(pageable) ? new PageImpl<>(query.getResultList())
				: readPage(query, getDomainClass(), pageable, spec);
	}

 

위 코드를 보면, Pageable 객체 내 페이징 정보를 지니고 있지 않다면, 반환된 엔티티 데이터를 그대로 PageImpl 에 할당해주고있었다. 반면, 페이징 정보를 지니고 있다면 readPage 메서드가 호출되는데, readPage 메서드의 코드는 아래와 같다. 

 

	protected <S extends T> Page<S> readPage(TypedQuery<S> query, final Class<S> domainClass, Pageable pageable,
			@Nullable Specification<S> spec) {

		if (pageable.isPaged()) {
			query.setFirstResult((int) pageable.getOffset());
			query.setMaxResults(pageable.getPageSize());
		}

		return PageableExecutionUtils.getPage(query.getResultList(), pageable,
				() -> executeCountQuery(getCountQuery(spec, domainClass)));
	}

 

여기서는 최종적으로 PageableExecutionUtils 클래스의 getPage 메서드를 호출하는데, 이에 대한 코드는 아래와 같다.

 

	public static <T> Page<T> getPage(List<T> content, Pageable pageable, LongSupplier totalSupplier) {

		Assert.notNull(content, "Content must not be null");
		Assert.notNull(pageable, "Pageable must not be null");
		Assert.notNull(totalSupplier, "TotalSupplier must not be null");

		if (pageable.isUnpaged() || pageable.getOffset() == 0) {

			if (pageable.isUnpaged() || pageable.getPageSize() > content.size()) {
				return new PageImpl<>(content, pageable, content.size());
			}

			return new PageImpl<>(content, pageable, totalSupplier.getAsLong());
		}

		if (content.size() != 0 && pageable.getPageSize() > content.size()) {
			return new PageImpl<>(content, pageable, pageable.getOffset() + content.size());
		}

		return new PageImpl<>(content, pageable, totalSupplier.getAsLong());
	}

 

여기까지 보면, PageImpl 의 역할은 페이징이 됐든 안됐든 Pageable 객체를 넘겨받은 findAll 같은 메서드의 반환 타입을 인터페이스인 Page 타입으로 반환할 때 구현체로 사용될 뿐이었다. 위에서 코드로 남기진 않았지만 PageImpl 은 Page 인터페이스의 추상메서드를 구현하고 Chunk 클래스를 상속받음으로써, 데이터베이스로부터 조회된 엔티티 객체에 대한 부가 기능들을 사용자에게 제공하고있는 것이었다.

 

예를 들면, Slice 인터페이스의 map 이라는 추상메서드를 구현함으로써, 현재 Wrapping 하고 있는 엔티티 객체를 다른 타입의 데이터로 바꿔주는 컨버터 기능을 제공하기도 한다. ☕ 이외에도, 전체 페이지의 수, 데이터 전체 크기 등의 정보를 제공하는 역할도 한다.

 

	@Override
	public <U> Page<U> map(Function<? super T, ? extends U> converter) {
		return new PageImpl<>(getConvertedContent(converter), getPageable(), total);
	}

 

 

결론

다시 우리 서비스의 코드로 되돌아와서, 이전 담당자가 왜 이런 실수를 했을까 추측(?)해보면, PageImpl 객체를 생성할 때 페이징 처리가 되리라 기대하지 않았을까 싶다. 😅 (앞서 동일한 이슈로 이슈 레이징을 했었던 사례가 있었듯이...🛒) 이제 페이지네이션이 안된는 문제에 대한 해결 방법은 매우 단순하다. 앞서 쿼리를 생성해줄 때 페이징 처리를 위한 clause 를 추가해주기만 하면 된다. 🤣 이번 기회로 PageImpl 에 대해 조금(?) 이해해봤으니, 다음에는 PageImpl 의 부가 기능을 알차게(?) 활용해보거나 기본으로 제공되는 Page 구현체인 PageImpl 이 아닌 커스터마이징된 Page 구현체를 만들어봐도 좋을 것 같다. ✨