[Spring] 페이징 성능 개선하기 1. No Offset 사용하기
페이징은 웹에서 흔히 볼 수 있는 기능이다. 하지만, 기초적인 페이징 구현 방식은 서비스가 커지면서 큰 장애를 유발할 수 있다. 적재된 데이터가 많아지면서 페이징 기능이 수십초~ 수분까지 조회가 느려질 수 있다.
특히 1억건이 넘는 테이블에서의 페이징은 단순히 인덱스를 추가한다고 해서 성능 문제가 해결되진 않는다.
그래서 일반적인 페이징 기능에서 성능을 개선하는 방법을 알아보자.
물론, 인덱스를 이용한 쿼리 튜닝이 되어있다는 가정이고 조회 쿼리에서 인덱스 사용은 필수이다.
[ 참고 ] 효율적인 데이터베이스 인덱스 설정 방법
https://frogand.tistory.com/144
1. No Offset으로 구조 변경하기
기존 페이징에서 No Offset으로 구조를 변경하는 것이다.
기존 페이징 방식은 페이지 번호(offset)와 페이지 사이즈(limit)를 기반으로 한다.
반면에 No Offset은 아래와 같이 페이지 번호(offset)이 없는 더보기(More) 방식을 이야기한다.
1-1. No Offset은 왜 빠른가?
기존에 사용하는 페이징 쿼리는 일반적으로 다음과 같다.
SELECT *
FROM items
WHERE [조건]
ORDER BY id DESC
OFFSET [페이지번호]
LIMIT [페이지사이즈]
이와같은 형태의 페이징 쿼리가 뒤로 갈수록 느린 이유는 결국 앞에서 읽었던 행을 다시 읽어야 하기 때문입니다.
예를 들어 offset 10,000이고 limit 20이라 하면 최종적으로 10,020개의 행을 읽어야 합니다.
그리고 이 중의 얖 10,000개 행은 버리게 됩니다.
뒤로 갈 수록 버리지만 읽어야 할 행의 개수가 많아 점점 뒤로 갈수록 느려집니다.
No Offset 방식은 바로 이 부분에서 조회 시작 부분을 인덱스로 빠르게 찾아 매번 첫 페이지만 읽도록 하는 방식입니다.
(클러스터 인덱스인 PK를 조회 시작 부분 조건문으로 사용했기 때문에 빠르게 조회됩니다.)
SELECT *
FROM items
WHERE [조건문]
AND id < [마지막 조회 ID] # 직전 조회 결과의 마지막 id
ORDER BY id DESC
LIMIT [페이지 사이즈]
이전에 조회된 결과를 한번에 건너뛸 수 있게 마지막 조회 결과의 ID를 조건문에 사용하는 것으로 매번 이전 페이지 전체를 건너 뛴다.
즉, 아무리 페이지가 뒤로 가더라도 처음 페이지를 읽은 것과 동일한 성능을 가지게 된다.
1-2. 구현 코드
기존의 페이징 코드 (사용되는 book 엔티티의 인덱스는 idx_boox_1 (name) 이다.)
public List<BookPaginationDto> paginationLegacy(String name, int pageNo, int pageSize) {
return queryFactory
.select(Projections.fields(BookPaginationDto.class,
book.id.as("bookId"),
book.name,
book.bookNo))
.from(book)
.where(book.name.like(name + "%")) // like 뒤에는 %가 있을 때만 인덱스가 적용된다.
.orderBy(book.id.desc()) // 최신순으로
.limit(pageSize) // 지정된 사이즈만큼
.offset(pageNo * pageSize) // 지정된 페이지 위치에서
.fetch(); // 조회
}
offset은 pageNo가 사용되는 것이 아니라 몇번째 row부터 시작할지를 나타낸다.
pageNo는 0부터 시작한다고 가정한 상태이다. 1부터 시작할 경우 (pageNo - 1) * pageSize를 사용해야 한다.
No Offset 코드 1
public List<BookPaginationDto> paginationNoOffsetBuilder(Long bookId, String name, int pageSize) {
BooleanBuilder dynamicLtId = new BooleanBuilder();
if (bookId != null) {
dynamicLtId.and(book.id.lt(bookId));
}
return queryFactory
.select(Projections.fields(BookPaginationDto.class,
book.id.as("bookId"),
book.name,
book.bookNo))
.from(book)
.where(dynamicLtId
.and(book.name.like(name + "%")))
.orderBy(book.id.desc())
.limit(pageSize)
.fetch();
}
첫 페이지 조회할 때와 두번째 페이지부터 조회할 때 사용되는 쿼리가 달라 동적 쿼리가 필요하다.
그래서 맨 위와 같은 코드가 사용되었다. (BooleanBuilder 사용)
No Offset 코드 2
public List<BookPaginationDto> paginationNoOffsetBuilder(Long bookId, String name, int pageSize) {
return queryFactory
.select(Projections.fields(BookPaginationDto.class,
book.id.as("bookId"),
book.name,
book.bookNo))
.from(book)
.where(ltBookId(bookId), book.name.like(name + "%"))
.orderBy(book.id.desc())
.limit(pageSize)
.fetch();
}
private BooleanExpression ltBookId(Long bookId) {
if (bookId == null) {
return null; // BooleanExpression 자리에 null이 반환되면 조건문에서 자동으로 삭제된다.
}
return book.id.lt(bookId);
}
1-3. 테스트 코드로 기능 검증
기존 페이징 기능의 테스트이다.
@Test
void 기존_페이징_방식() throws Exception {
// given
String prefixName = "a";
for (int i = 0; i <= 30; i++) {
Book book = Book.builder()
.name(prefixName + i)
.bookNo(i)
.build();
bookRepository.save(book);
}
// when
// 두번째 페이지 조회 (pageNo는 0부터 시작임)
List<BookPaginationDto> books = bookPaginationRepository.paginationLegacy(prefix, 1, 10);
// then
assertThat(books).hasSize(10);
assertThat(books.get(0).getName()).isEqualsTo("a20");
assertThat(books.get(9).getName()).isEqualsTo("a11");
}
NoOffset 테스트
@Test
void nooffset_첫페이지() throws Exception {
// given
String prefixName = "a";
for (int i = 0; i <= 30; i++) {
Book book = Book.builder()
.name(prefixName + i)
.bookNo(i)
.build();
bookRepository.save(book);
}
// when
List<BookPaginationDto> books = bookPaginationRepository.paginationNoOffset(null, prefixName, 10);
// then
assertThat(books).hasSize(10);
assertThat(books.get(0).getName()).isEqualsTo("a30");
assertThat(books.get(9).getName()).isEqualsTo("a21");
}
@Test
void nooffset_두번째페이지() throws Exception {
// given
String prefixName = "a";
for (int i = 0; i <= 30; i++) {
Book book = Book.builder()
.name(prefixName + i)
.bookNo(i)
.build();
bookRepository.save(book);
}
// when
List<BookPaginationDto> books = bookPaginationRepository.paginationNoOffset(21L, prefixName, 10);
// then
assertThat(books).hasSize(10);
assertThat(books.get(0).getName()).isEqualsTo("a20");
assertThat(books.get(9).getName()).isEqualsTo("a11");
}
1-4. 성능 비교
offset 위치 / 인덱스 유무 / DB 성능에 따라 테스트 결과가 다를 수 있지만 1억 row, 5개의 컬럼의 경우 수백배의 성능 차이가 난다.
1-5. 단점
- where에 사용되는 기준 Key가 중복이 가능할 경우
group by 등으로 기준을 잡을 key가 중복이 될 경우 정확한 결과를 반환할 수 없다.
- More 버튼이 아니라 페이징 버튼 형식으로 해야할 경우
출처
https://jojoldu.tistory.com/528
'web > Spring' 카테고리의 다른 글
[Spring] 페이징 성능 개선하기 3-1. 검색 버튼 사용시 페이지 건수 고정하기 (0) | 2022.07.20 |
---|---|
[Spring] 페이징 성능 개선하기 2. 커버링 인덱스 사용하기 (0) | 2022.07.19 |
[Spring] Assert (0) | 2022.07.15 |
[Spring] RedirectAttributes 인터페이스의 addFlashAttribute (0) | 2022.07.12 |
[Spring Security] @AuthenticationPrincipal (1) | 2022.07.11 |
댓글 개