Spring, JPA 페이징 처리와 성능 최적화 알아보기

#JPA#JpaSpecification#QueryDSL

2023-01-26 14:40

대표 이미지

JPA에서 페이징 처리할 때 page 객체로 리턴 타입을 구성하면 select 쿼리와 count 쿼리를 실행할 수 있습니다. 대용량 데이터를 다룰 때 페이징 처리가 성능 이슈를 일으킬 수 있는데 이에 대한 대응 방법을 알아봅니다.

개요


JPA에서는 Pageable 클래스를 이용하면 간단하게 페이징 처리를 할 수 있다. 그러나, 간단하게 구현할 수 있는 만큼 데이터 사이즈가 커지거나 사용하는 아키텍처에 따라 성능 이슈를 일으킬 수 있다. 이번 글에서는 JPA 페이징 처리에서 발생할 수 있는 성능 이슈와 이를 어떤 방법으로 우회할 수 있을지 알아보자.

JPA에서 페이징 처리하기


JPA에서는 Pageable 클래스를 사용해서 쉽게 페이징 처리를 구현할 수 있다. JpaRepository가 상속하고 있는 PagingAndSortingRepository를 살펴보면 아래와 같은 메소드를 제공한다.

Page<T> findAll(Pageable pageable);

이 메소드를 이용해서 데이터 쿼리를 수행하면 목록 데이터를 조회하기 위한 select 쿼리와 페이징 정보를 생성하기 위한 count 쿼리가 각각 한번씩 실행된다. 아래 예시 코드를 통해서 실제 실행 결과를 확인해보자.

@EqualsAndHashCode
@Setter
@Getter
@NoArgsConstructor
@Table(name = "user")
@Entity
public class UserEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column
    private String email;

    @Column
    private String password;

    @Column(name = "created_at")
    private LocalDateTime createdAt;

    @Column(name = "updated_at")
    private LocalDateTime updatedAt;
}

public interface UserEntityRepository extends JpaRepository<UserEntity, Long> {
}

Page<UserEntity> userPage = userEntityRepository.findAll(PageRequest.of(5, 1000));

실행 결과

실제로 select 쿼리와 count 쿼리가 순차적으로 한번씩 실행되는 것을 확인할 수 있다.

Hibernate: select userentity0_.id as id1_0_, userentity0_.created_at as created_2_0_, userentity0_.email as email3_0_, userentity0_.password as password4_0_, userentity0_.updated_at as updated_5_0_ from user userentity0_ limit ?, ?
Hibernate: select count(userentity0_.id) as col_0_0_ from user userentity0_

JPA에서 페이징 없이 데이터만 필요한 경우


JPA를 사용해서 목록 기능을 개발하다보면 대부분 페이징을 구현하지만, 때로는 Page 객체 없이 데이터만 반환하고 싶은 경우가 있다. 특별한 조건 없이 목록 데이터 값만 반환하고 싶을 때, 아래와 같은 방법으로 ListSlice를 반환하는 응답을 만들 수 있다. 이 경우 Page 객체를 사용하지 않기 때문에 페이징 정보 없이 특정 페이지 범위만 조회한 데이터 값을 응답할 수 있다.

1. List 사용하기

public interface UserEntityRepository extends JpaRepository<UserEntity, Long> {
    List<UserEntity> findAllBy(Pageable pageable);
}

List<UserEntity> userPage = userEntityRepository.findAllBy(PageRequest.of(5, 1000));

2. Slice 사용하기

public interface UserEntityRepository extends JpaRepository<UserEntity, Long> {
    Slice<UserEntity> findAllBy(Pageable pageable);
}

Slice<UserEntity> userPage = userEntityRepository.findAllBy(PageRequest.of(5, 1000));

실행 결과

위 예시에 작성된 메소드를 사용하면 실제 쿼리는 어떻게 실행될까? Page 객체를 사용하지 않기 때문에 우리가 예상하는 것처럼 목록 데이터를 조회하는 select 쿼리만 한번 실행되는 것을 볼 수 있다.

Hibernate: select userentity0_.id as id1_0_, userentity0_.created_at as created_2_0_, userentity0_.email as email3_0_, userentity0_.password as password4_0_, userentity0_.updated_at as updated_5_0_ from user userentity0_ limit ?

성능비교


count 쿼리 실행 여부에 따른 성능 차이는 얼마나 될까? 이미 익히 알고 있듯, count 쿼리는 full-scan이 필요한 쿼리이기 때문에 데이터 셋의 크기가 커질 수록 성능 저하가 발생한다. 따라서, 데이터가 작은 경우 그 차이가 굉장히 미미하지만 데이터가 커지면 커질 수록 응답 속도에 큰 영향을 미친다. 앞서 살펴본 예시에서 사용한 User 테이블에 100만건의 데이터를 저장하고 성능을 측정해보자.

테스트 설정

테스트는 Jmeter를 사용했고 단순 응답시간만 비교하기 위해서 Thread 조건을 아래와 같이 세팅했다.

  • Thread - 1개
  • Ramp-up - 1초
  • Loop count - 100
  • Same user on each iteration

Page 객체를 사용하는 테스트 코드

@GetMapping("/page")
public String pageTest() {
  Page<UserEntity> userPage = userEntityRepository.findAll(PageRequest.of(100, 1000));
  return "success";
}

실행 결과

# Sample Average Min Max
100 119 92 250

SQL 로그

Hibernate: select userentity0_.id as id1_0_, userentity0_.created_at as created_2_0_, userentity0_.email as email3_0_, userentity0_.grade as grade4_0_, userentity0_.password as password5_0_, userentity0_.updated_at as updated_6_0_ from user userentity0_ limit ?, ?
Hibernate: select count(userentity0_.id) as col_0_0_ from user userentity0_

Select 쿼리만 호출한 테스트 코드

@GetMapping("/list")
public String listTest() {
  List<UserEntity> userList = userEntityRepository.findAllBy(PageRequest.of(100, 1000));
  return "success";
}

실행 결과

# Sample Average Min Max
100 44 31 123

SQL 로그

Hibernate: select userentity0_.id as id1_0_, userentity0_.created_at as created_2_0_, userentity0_.email as email3_0_, userentity0_.grade as grade4_0_, userentity0_.password as password5_0_, userentity0_.updated_at as updated_6_0_ from user userentity0_ limit ?, ?

Count 쿼리만 호출한 테스트 코드

@GetMapping("/count")
public String countTest() {
  userEntityRepository.count();
  return "success";
}

실행 결과

# Sample Average Min Max
100 71 57 138

SQL 조건

Hibernate: select count(*) as col_0_0_ from user userentity0_

테스트 결과

Page 객체를 사용하는 쿼리 평균값이 select 쿼리와 count 쿼리의 합과 유사한 것을 알 수 있다. User 테이블은 컬럼이 몇개 되지 않고 각 컬럼의 데이터의 사이즈도 작지만, 데이터 개수가 늘어났을 때 count 쿼리의 응답 속도가 성능에 영향을 주는 것을 알 수 있다. 실제 운영 중인 서비스에서는 환경에 따라 크게 1-2초(1000~2000 밀리초) 이상도 차이가 날 수 있다.

JpaSpecification과 페이징


만약 JpaSpecification를 사용중인데 select 쿼리만 필요하다면 어떻게 해야할까? JpaSpecificationPage 리턴 타입만 지원하며 Slice는 지원하지 않는다. 즉, select와 count 쿼리를 모두 호출하는 방식만 지원한다. 이 문제를 해결할 수 있는 쉬운 방법 중 하나는 JpaSpecification 포기하고 위에서 알아본 것처럼 ListSlice를 이용해서 select 쿼리와 count 쿼리를 각각 실행하는 것이다.

또하나의 해결 방법 - QueryDSL

JpaSpecification 사용 시 페이징 성능 이슈를 해결하는 방법 중 하나는 QueryDSL로 교체하는 것이다. QueryDSL은 JpaSpecificaiton처럼 조건절을 재사용할 수 있게 BooleanExpression을 제공한다. JpaSpecificaiton을 사용할 때처럼 동일한 구조로 개발할 수 있기 때문에 가장 좋은 대안이라고 할 수 있다.

public interface JpaSpecificationExecutor<T> {
    Optional<T> findOne(@Nullable Specification<T> spec);
    List<T> findAll(@Nullable Specification<T> spec);
    Page<T> findAll(@Nullable Specification<T> spec, Pageable pageable);
    List<T> findAll(@Nullable Specification<T> spec, Sort sort);
    long count(@Nullable Specification<T> spec);
    boolean exists(Specification<T> spec);
}

맺음말


JPA를 사용하면 Page 객체를 통해 쉽게 페이징 처리를 할 수 있다. 다만, 다루는 데이터 규모가 크다면 언제든 성능 이슈가 발생할 수 있다. 그럴 때는 요구 사항에 맞춰서 앞서 살펴본 해결 방법 중 하나를 취사 선택하면 도움이 될 것이다. Page 객체 대신 select 쿼리와 count 쿼리를 별도로 호출하는 방식은 분명 성능 최적화에 도움이 되겠지만, 직접 페이징 결과를 만들어야 하는만큼 번거로움과 버그 발생의 가능성이 있다. 각 방식의 장단점을 이해하고 현명하게 활용하면 좋겠다.