네로개발일기

개발자 네로의 개발 일기, 자바를 좋아합니다 !

반응형

 

페이징은 웹에서 흔히 볼 수 있는 기능이다. 하지만, 기초적인 페이징 구현 방식은 서비스가 커지면서 큰 장애를 유발할 수 있다. 적재된 데이터가 많아지면서 페이징 기능이 수십초~ 수분까지 조회가 느려질 수 있다.

 

특히 1억건이 넘는 테이블에서의 페이징은 단순히 인덱스를 추가한다고 해서 성능 문제가 해결되진 않는다.

 

그래서 일반적인 페이징 기능에서 성능을 개선하는 방법을 알아보자.

물론, 인덱스를 이용한 쿼리 튜닝이 되어있다는 가정이고 조회 쿼리에서 인덱스 사용은 필수이다.

 

 [ 참고 ] 효율적인 데이터베이스 인덱스 설정 방법 

https://frogand.tistory.com/144

 

[SQL] 효율적인 DB INDEX(인덱스) 설정

인덱스(INDEX) 검색 속도를 높이기 위한 색인 기술이다. 보통 인덱스는 일반적으로 SELECT 쿼리의 WHERE에 사용할 컬럼에 대해 효율적인 검색을 위해 사용하거나, 다른 테이블과의 JOIN에 사용된다. (

frogand.tistory.com

 

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

 

1. 페이징 성능 개선하기 - No Offset 사용하기

일반적인 웹 서비스에서 페이징은 아주 흔하게 사용되는 기능입니다. 그래서 웹 백엔드 개발자분들은 기본적인 구현 방법을 다들 필수로 익히시는데요. 다만, 그렇게 기초적인 페이징 구현 방

jojoldu.tistory.com

 

728x90
반응형
blog image

Written by ner.o

개발자 네로의 개발 일기, 자바를 좋아합니다 !