페이지네이션 성능 개선하기(with BatchSize, No Offset)

이상훈·2023년 10월 31일
1

Project

목록 보기
4/6
post-thumbnail

이번에 개선하고자 하는 API는 "이력서 리스트 조회"다.

요구사항

  • 이력서에는 복수개의 자격증, 교육, 경력 사항을 기재할 수 있다.
  • 자신의 이력서를 제외한 모든 이력서를 조회한다.
  • 공개 여부가 True인 이력서만 조회한다.
  • 최신순으로 조회한다.
  • 무한스크롤 페이지네이션을 이용한다.

코드

@RequiredArgsConstructor
@Repository
public class ResumeRepositoryImpl implements ResumeRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public Page<ResumeDto.GetResumeByQueryDslRes> findResumeList(Pageable pageable, Long userId) {

        List<Resume> results = queryFactory
                .selectFrom(resume)
                .where(
                        userIdEq(userId),
                        resume.isOpened.eq(Boolean.TRUE)
                )
                .orderBy(resume.id.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        int total = queryFactory
                .selectFrom(resume)
                .where(
                        userIdEq(userId),
                        resume.isOpened.eq(Boolean.TRUE)
                )
                .fetch().size();

        List<ResumeDto.GetResumeByQueryDslRes> resultDtos = results.stream()
                .map(ResumeDto.GetResumeByQueryDslRes::new)
                .collect(Collectors.toList());

        return new PageImpl<>(resultDtos, pageable, total);
    }
    
    private BooleanExpression userIdEq(Long userId) {
        return resume.users.id.ne(userId);
    }
}

 기존에는 Offset 기반 페이징을 구현했다. where 절에 userId와 isOpened가 있는데 userId는 자동으로 인덱스가 걸려있지만 "==" 조건이 아닌 "!=" 조건이고 isOpened는 카디널리티가 너무 낮아서 인덱스 처리하기에 적합하지 않다.


ERD

성능 개선하기 전 Response Time을 측정해봤다.

구분1nd2nd3nd4nd5nd6nd7nd8nd9nd10ndavg
성능 개선 X281ms260ms270ms260ms264ms320ms260ms263ms272ms256ms270ms

Resume, Certificate, Education, Career : 10000 rows
페이지네이션 : size=7, page= 대략 700


N+1 문제

문제 원인

 Resume : Certificate = 1 : N, Resume : Education = 1 : N, Resume : Career = 1 : N 관계이다. 이 상황에서 Resume를 조회하면 지연 로딩으로 프록시 객체 Certificate, Education, Career가 조회되고 DTO를 생성할 때 Certificate, Education, Career를 참조하면서 Resume 수만큼 추가로 DB에 쿼리가 나가는 N+1 문제가 발생한다. 따라서 DB로 나가는 총 쿼리는 아래와 같다. 참고로 페이지의 size는 7이다.

  • Resume 조회 쿼리 : 1
  • Resume count 쿼리 : 1
  • Certificate 조회 쿼리 : 7
  • Education 조회 쿼리 : 7
  • Career 조회 쿼리 : 7

@BatchSize를 통해 해결하기

 N+1 문제를 해결하는 대표적인 방법으로는 fetch join이 있다. 하지만 fetch join은 1:N에서 페이지네이션과 함께 사용할 수 없다. 왜냐하면 데이터베이스는 N를 기준으로 조인을 하기 때문에 rows가 N 만큼 증가하기 때문이다. 따라서 대안으로 BatchSize를 사용했다.
  JPA에서는 @BatchSize와 글로벌 설정을 위한 hibernate.default_batch_fetch_size를 지원해서 연관된 컬렉션이나 프록시 객체를 한꺼번에 설정한 size만큼 IN 쿼리로 조회할 수 있다. 프로젝트에서 컬렉션 엔티티와 페이징을 같이 사용하는 경우가 한 군데밖에 없어서 hibernate를 통한 글로벌 설정 대신에 @BatchSize를 사용했다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Resume extends BaseTimeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    ...

    @BatchSize(size = 100) // size는 일반적으로 100~1000
    @OneToMany(mappedBy = "resume", orphanRemoval = true, cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private List<Certificate> certificates = new ArrayList<>();


    @BatchSize(size = 100)
    @OneToMany(mappedBy = "resume", orphanRemoval = true, cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private List<Career> careers = new ArrayList<>();

    @BatchSize(size = 100)
    @OneToMany(mappedBy = "resume", orphanRemoval = true, cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private List<Education> educations = new ArrayList<>();

	...
}

@BatchSize 적용 후 실행 쿼리는 아래와 같다.

  • Resume 조회 쿼리 : 1 -> 1
  • Resume count 쿼리 : 1 -> 1
  • Certificate 조회 쿼리 : 7 -> 1
  • Education 조회 쿼리 : 7 -> 1
  • Career 조회 쿼리 : 7 -> 1

즉, 1 + N 번 실행 되었던 쿼리가 1 + 1번 실행됨으로써 N+1 문제를 해결했다.

 @BatchSize 적용 후 Response Time을 측정해봤다. 직전 N+1 문제가 발생했을 당시 평균 Response Time이었던 270ms에 비해 264ms로 큰 변화가 없음을 확인할 수 있다. 그 이유는 페이지 size가 7로 작아서 성능 개선이 미미하기 때문이다.

구분1nd2nd3nd4nd5nd6nd7nd8nd9nd10ndavg
성능 개선 X281ms260ms270ms260ms264ms320ms260ms263ms272ms256ms270ms
BatchSize 적용263ms292ms267ms269ms249ms266ms255ms263ms268ms249ms264ms

Resume, Certificate, Education, Career : 10000 rows
페이지네이션 : size=7, page= 대략 700

 말이 안되지만, 만약 페이지 size를 100으로 늘리면 어떻게 될까? 평균 response time이 498ms에서 299ms로 크게 성능 개선이 되었음을 확인할 수 있다. 결론은 페이지네이션 쿼리에서 발생한 N+1 문제를 BatchSize를 통해 해결했다. 하지만 페이지 size가 워낙 작아 성능 개선은 미미했다.

  • Resume 조회 쿼리 : 1 -> 1
  • Resume count 쿼리 : 1 -> 1
  • Certificate 조회 쿼리 : 100 -> 1
  • Education 조회 쿼리 : 100 -> 1
  • Career 조회 쿼리 : 100 -> 1
구분avg
기존498ms
BatchSize 적용299ms

Resume, Certificate, Education, Career : 10000 rows
페이지네이션 : size=100, page= 대략 50


No Offset

No Offset이란?

 페이징은 대량의 데이터를 한꺼번에 조회할 때, 성능의 저하가 발생하므로 일정량의 개수로 데이터를 page 단위로 나누어 조회하는 방식으로 목록 조회 시 필수적으로 구현되는 기능 중 하나이다. 이러한 페이징은 주로 limit과 offset을 이용하며 아래 구조를 가진다. 흔히 Offset 페이징 기법이라 부른다.

SELECT * 
FROM 테이블 
WHERE 조건문 
ORDER BY id DESC 
LIMIT 컨텐츠개수
OFFSET 페이지 번호 * 컨텐츠 개수

 하지만 이러한 페이징 기법에는 2가지 문제점이 있다.

1. 뒤로 갈수록 심해지는 성능 저하

 매번 페이징 쿼리가 수행될 때 최신 값부터 순차적으로 scan 하므로 조회할 값이 과거의 값일수록 성능이 저하된다. 예를 들어 n번째부터 7개의 데이터를 조회하고 싶다면 기존의 Offset 페이지네이션은 바로 n번째 데이터로 건너뛰는 것이 아니라 n번째 데이터를 찾을 때까지 앞에서 읽었던 모든 행을 읽게 된다.

2. 데이터 중복 문제

 페이지를 이동하는 중간에 데이터가 삽입, 삭제, 업데이트 등의 작업이 발생하면 다른 페이지에 영향 주게 된다. 예를 들어 1 page를 조회하고 2 page로 넘어가는 중간에 만약 새로운 데이터 2개가 1 page에 추가된다고 가정하자. 그러면 Offset 페이징 기법은 처음부터 순차적으로 scan 하므로 2 page에는 1 page에 있던 데이터 2개가 밀려들어가 기존에 조회했던 1 page 데이터 2개가 중복 조회돼버린다. 이는 사용자에게 부정적인 경험을 줄 수 있어서 피해야 한다.


 이러한 Offset 페이징 기법의 대안으로 No Offset 페이징 기법이 있다. No Offset 방식은 이전 페이지의 마지막 데이터의 id 값을 기억하고, 다음 페이지를 요청할 때 이 id 값 이후의 데이터를 가져오는 방식이다. 이를 통해 매번 Offset 값을 계산하지 않아도 되기 때문에 데이터베이스에서 더욱 효율적으로 데이터를 조회할 수 있다.
 따라서 무한 스크롤은 페이지 버튼이 필요 없기 때문에 성능상 우수한 No Offset 방식으로 구현한다.

SELECT * 
FROM 테이블 
WHERE 조건문
AND id < lastId
ORDER BY id DESC 
LIMIT 컨텐츠개수

No Offset으로 전환하기

 프로젝트에서 이력서 피드 조회는 무한스크롤 형식으로 되어 있어서 No Offset 기반 페이징으로 전환하기로 하였다. 그렇다면 No Offset 방식으로 기존 코드를 수정해보자.

@RequiredArgsConstructor
@Repository
public class ResumeRepositoryImpl implements ResumeRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public Slice<ResumeDto.GetResumeByQueryDslRes> findResumeList(Pageable pageable, Long lastId, Long userId) {

        List<Resume> results = queryFactory
                .selectFrom(resume)
                .where(
                        userIdEq(userId),
                        ltResumeId(lastId)
                        resume.isOpened.eq(Boolean.TRUE)
                )
                .orderBy(resume.id.desc())
                .limit(pageable.getPageSize() + 1)
                .fetch();

        List<ResumeDto.GetResumeByQueryDslRes> resultDtos = results.stream()
                .map(ResumeDto.GetResumeByQueryDslRes::new)
                .collect(Collectors.toList());

        return checkLastPage(pageable, resultDtos);
    }
    // 1. no-offset 방식 처리하는 메서드
    private BooleanExpression ltResumeId(Long lastId) {
        if (lastId == null) {
            return null;
        }
        return resume.id.lt(lastId);
    }

    private BooleanExpression userIdEq(Long userId) {
        return resume.users.id.ne(userId);
    }

    // 2. 무한 스크롤 방식 처리하는 메서드
    private Slice<ResumeDto.GetResumeByQueryDslRes> checkLastPage(Pageable pageable, List<ResumeDto.GetResumeByQueryDslRes> resultDtos) {

        boolean hasNext = false;

        // 조회한 결과 개수가 요청한 페이지 사이즈보다 크면 뒤에 더 있음, next = true
        if (resultDtos.size() > pageable.getPageSize()) {
            hasNext = true;
            resultDtos.remove(pageable.getPageSize());
        }
        return new SliceImpl<>(resultDtos, pageable, hasNext);
    }

}

ltResumeId, checkLastPage 메서드가 눈에 띈다.

  • ltResumeId
     No Offset으로 처음 조회할 때는(1 page) 몇 번째 id부터 조회하는지 알 수 없어서 null 값을 넣어줘야 한다. 그다음부터는 클라이언트로부터 받은 데이터의 lastId를 기준으로 이보다 id 값이 낮은 데이터들을 page size만큼 조회하면 된다.

    // 1. no-offset 방식 처리하는 메서드
       private BooleanExpression ltResumeId(Long lastId) {
           if (lastId == null) {
               return null;
           }
           return resume.id.lt(lastId);
       }
    
       private BooleanExpression userIdEq(Long userId) {
           return resume.users.id.ne(userId);
       }
  • checkLastPage
     QueryDsl 코드에서 요청으로 들어온 pageable 객체의 pageSize에 +1을 해서 limit를 걸었다. 그 이유는 만약 지금 페이지가 마지막 페이지가 아니라면 조회한 결과 개수 results가 요청한 pageSize보다 더 클 것이다. 따라서 이 경우에는 다음 페이지가 있다는 것을 의미하므로 hasNext를 true로 반환하고 다음 페이지가 있는지 확인용으로 추가한 데이터 1개를 제거하면 된다. 반대의 경우에는 다음 페이지가 없다는 것을 의미하므로 hasNext를 false로 반환하면 된다.

    // 2. 무한 스크롤 방식 처리하는 메서드
       private Slice<ResumeDto.GetResumeByQueryDslRes> checkLastPage(Pageable pageable, List<ResumeDto.GetResumeByQueryDslRes> resultDtos) {
    
           boolean hasNext = false;
    
           // 조회한 결과 개수가 요청한 페이지 사이즈보다 크면 뒤에 더 있음, next = true
           if (resultDtos.size() > pageable.getPageSize()) {
               hasNext = true;
               resultDtos.remove(pageable.getPageSize());
           }
           return new SliceImpl<>(resultDtos, pageable, hasNext);
       }

No Offset으로 전환하고 다시 response time을 측정해봤다.

구분1nd2nd3nd4nd5nd6nd7nd8nd9nd10ndavg
성능 개선 X281ms260ms270ms260ms264ms320ms260ms263ms272ms256ms270ms
BatchSize 적용263ms292ms267ms269ms249ms266ms255ms263ms268ms249ms264ms
BatchSize + No Offset 적용42ms37ms59ms46ms41ms41ms47ms37ms43ms52ms44ms

Resume, Certificate, Education, Career : 10000 rows
Offset 페이징 : size=7, page = 대략 700
No Offset 페이징 : size=7, lastId = 대략 5000


결론

 프로젝트에서 이력서 피드 목록 페이징 api를 구현했다. 조회 성능을 개선하려고 인덱스, N+1 문제, No Offset 페이징 기법을 고려해봤다. 인덱스는 where 절에서 활용할 만한 컬럼이 없었고 N+1 문제는 해결했지만, page size가 워낙 작아서 유의미한 성능 개선은 없었다. 하지만 기존 Offset 기반 페이징을 No Offset 기반 페이징으로 전환하면서 대략 6배 정도의 성능 향상을 얻을 수 있었다. 참고로 여기서 6배의 결과값은 테이블의 row 수가 커질수록 뒤 페이지로 갈수록 성능 향상의 정도가 커진다.


참고

[프로젝트] No-offset 방식과 Spring Data JPA Slice를 사용해서 무한 스크롤 구현하기

컬렉션 엔티티와 페이징을 함께 사용하기 (feat. @BatchSize)

profile
Problem Solving과 기술적 의사결정을 중요시합니다.

0개의 댓글