무한 스크롤 적용 계기
현재 한 달 동안의 2차 프로젝트를 진행 중이다. 우리 팀이 선정한 주제는 Bmart
이다.
실제 프로덕트를 분석/설계하고, 개발하는 과정에서 사용해보고 싶은 기술을 적용하며 최적화하는 재미가 있다.
Bmart에서 카테고리별 상품을 조회하는 과정에서, 각 정렬기준별로 조회하는 기능이 있다.
무작정 Pagination부터 구현을 시작하여 점진적으로 성능을 개선해 보고자 이 글을 작성하게 되었다.
페이지네이션 사용
우선, 해당 글에서는 두 가지 방식으로 무한 스크롤을 적용해 볼 것이다.
- JPA Pagination을 사용하여 page number와 page size를 이용하기
- JPA Pagination을 이용하되 약간의 성능개선을 위해 page number는 0으로 고정하기
두 방식 모두 Pagination을 사용하는 방식인데 그 차이는 무엇일까?
1. JPA Pagination을 사용하여 page number와 page size를 이용하기
1번 방식은 페이징 방식과 동일하다. 클라이언트로부터 스크롤 시 조회할 paging관련 정보를 받아와서 페이징 조회를 하면 된다.
그렇다면 이 페이징 조회의 원리는 무엇일까?
위 문장에서 언급한 paging관련 정보란 페이지 번호와 페이지 크기를 말한다.
클라이언트로부터 전달받은 페이지번호와 페이지 크기를 이용해서 offset
과 limit
을 구하게 된다.
offset: 실제 데이터를 검색하기 위한 기준이 되는 데이터 개수를 의미하기도 한다.
limit: offset으로부터 몇 개의 데이터를 조회할 것인지 그 최대 개수를 의미한다.(limit보다 적은 데이터가 있다면, 그만큼 조회하게 된다.)
e.g) 1번 데이터부터 조회한다는 가정 하, 페이지 크기가 5이면서, offset이 10이라면?
==> 10번째 데이터부터 5개의 데이터를 읽는다.
위 방식의 큰 문제는 offset으로 인한 불필요한 탐색이다.
만약 offset이 백만이라고 가정하면 100만 개의 데이터를 탐색한 후에서야 그다음 유의미한 데이터들을 조회할 수 있는 것이다.
여러 정렬기준에 대한 조회 쿼리가 있지만, 여기서는 카테고리별 가격이 비싼 순으로 조회하는 로직을 가져왔다.
로직은 아래와 같다.
1. PageRequest.of()를 통해 페이징 정보를 가져온다.
2. 특정 mainCategory를 조회하는데, 가격 내림차순으로 조회한다.
가격이 동일하다면 최신 상품이 먼저 조회되도록 상품 id 내림차순으로 조회한다.
1.1 구현 코드
- Item
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Item {
@Id
private Long itemId;
private String name;
private int price;
private String description;
private int quantity;
}
- Repository
List<Item> findByMainCategoryOrderByPriceDescItemIdDesc(
MainCategory mainCategory,
Pageable pageable
);
- Jpa 쿼리 메서드를 사용하여 위에서 작성한 로직대로 쿼리문이 발생하도록 하였다.
- 특정 mainCategory를 조회하는데, 가격 내림차순으로 조회한다.
가격이 동일하다면 최신 상품이 먼저 조회되도록 상품 id 내림차순으로 조회한다.
- 특정 mainCategory를 조회하는데, 가격 내림차순으로 조회한다.
- 조회
@Test
@DisplayName("offset: 처음으로 노출되는 상품 세 가지 조회")
public void offset() {
// Given
MainCategory mainCategory = mainCategoryRepository.findById(1L).get();
PageRequest pageRequest = PageRequest.of(0, 3);
// When
List<Item> items = itemRepository.findByMainCategoryOrderByPriceDescItemIdDesc(
mainCategory, pageRequest);
// Then
assertThat(items.size()).isEqualTo(3);
for (Item item : items) {
log.info("item ID={}", item.getItemId());
}
}
- 첫 페이지에서 노출되는 세 개의 아이템을 조회하도록 페이징 요청을 했다.
- 결과는 아래 이미지와 같이 잘 나온다.
2. JPA Pagination을 이용하되 약간의 성능개선을 위해 page number는 0으로 고정하기
이 방법은 1번 방법과 동일하게 Pagination을 사용하지만, offset을 설정하지 않는 방식이다.
앞서 1번 방법에서 정리했듯이 offset은 불필요한 데이터까지 순차적으로 조회하는 문제가 있다.
그래서 "애초에 마지막으로 조회한 아이템의 정보를 이용해서 다음 아이템을 조회하면 어떨까?"에서 나온 방식이다.
다른 사람의 글을 보고 좋은 방식이란 생각이 들어 비슷하게 적용해보기로 했다. (링크: 페이지 하단)
1번에서 들었던 예시와 같은 데이터를 조회할 것이다. 로직은 아래와 같다.
1. PageRequest.of()를 통해 페이징 정보를 가져오는데, offset값은 0으로 고정하고 limit값만 설정한다.
즉, PageRequest.of(0, 3) 이라고 하면 offset값은 0으로 하고, 최대 3개의 데이터를 가져오는 것을 의미한다.
2. 특정 mainCategory를 조회하는데, 가격 내림차순으로 조회한다.
가격이 동일하다면 최신 상품이 먼저 조회되도록 상품 id 내림차순으로 조회한다.
3. (중요)
이전에 마지막으로 조회한 아이템의 아이디와 가격정보를 가지고 와서, 아래 조건을 추가한다.
이전 아이템의 가격보다 싸야한다 OR (이전 아이템의 가격과 같고, 이전 아이템의 아이디보다 작아야 한다.)
3번이 가장 중요한데, offset이란 기준점을 없앤 대신에 where절에 조건을 추가하여 offset과 동일한 데이터를 가져오게끔 하는 것이다.
아래 예시를 통해 어떤 원리인지 살펴보자.
id(아이디) | price(가격) | category(카테고리) |
---|---|---|
1 | 200 | 생활용품 |
2 | 200 | 생활용품 |
3 | 6000 | 생활용품 |
4 | 300 | 생활용품 |
5 | 100 | 생활용품 |
6 | 90000 | 반찬 |
- 위 테이블을 조건에 맞게 정렬하면 아래와 같다.
id(아이디) | price(가격) | category(카테고리) |
---|---|---|
3 | 6000 | 생활용품 |
4 | 300 | 생활용품 |
2 | 200 | 생활용품 |
1 | 200 | 생활용품 |
5 | 100 | 생활용품 |
- 카테고리가 "생활용품"이고, 가격을 내림차순 / 아이디를 내림차순 정렬한 결과이다.
- 위 상태에서 페이징을 이용한 조회를 해보자.
- 처음 세 개의 데이터로는 3, 4, 2번 아이템이 조회된다.
- 그리고, 다음 조회를 위해 아래와 같은 정보들을 Repository 조회 메서드에 넘겨준다.
1. pageRequest.of(0,3)이라는 페이징 정보
2. 마지막으로 조회된 아이템의 아이디: 2
3. 마지막으로 조회된 아이템의 가격: 200 - 위 정보들로 하여금 아래 조건에 맞게 조회한다.
이전 아이템의 가격보다 싸야 한다 OR (이전 아이템의 가격과 같고, 이전 아이템의 아이디보다 작아야 한다.)
-> 2번 아이템의 가격인 200원과 가격이 같고, 아이디가 작은 1번이 조회.
-> 2번 아이템보다 가격이 싼 5번 조회
- 처음 세 개의 데이터로는 3, 4, 2번 아이템이 조회된다.
2.1 구현 코드
- Item은 1번에서 작성한 코드와 동일.
- Repository
@Query("SELECT i "
+ "FROM Item i "
+ "WHERE i.mainCategory = :mainCategory "
+ "AND (i.price < :price OR (i.price = :price AND i.itemId < :itemId)) "
+ "ORDER BY i.price DESC, i.itemId DESC")
List<Item> findByMainCategoryAndPriceDesc(
@Param("itemId") Long itemId,
@Param("price") int price,
@Param("mainCategory") MainCategory mainCategory,
Pageable pageable);
- 직접 조건문을 명시하기 위해 JPQL로 작성
- "이전 아이템의 가격보다 싸야한다 OR (이전 아이템의 가격과 같고, 이전 아이템의 아이디보다 작아야 한다.) "라는 조건을 where절에 명시하였다.
- 이렇게 함으로써 offset을 통한 불필요한 탐색을 없애고, 인덱스를 활용한 조건문으로 탐색이 가능합니다.
- 조회
실행 결과 및 차이
100만 건의 데이터를 이용하여 테스트를 진행
조건에 사용되는 price, main_category_id, item_id는 인덱스를 통해 성능개선을 해놓은 상태이다.
처음 세 상품을 조회
- 처음에 세 상품을 조회하는데, 별 차이가 없는 것을 확인할 수 있다.
그렇다면, 극단적으로 100만 번째 상품을 조회할 때 어떤 속도 차이가 나는지 살펴보자.
100만번째 상품 조회
- 100만 번째 조회를 하기 위해 페이징 조회 정보와 price 조건문을 변경했다.
- 1번 방식의 경우 offset을 사용하기 때문에 속도 저하가 눈에 띄게 발생했다.
- 2번 방식의 경우 offset은 0으로 고정하고, 인덱스 컬럼을 통한 where 조건문을 사용함으로써 속도변화는 크게 없었다.
결론
- offset을 통한 페이징 조회는 뒤 이은 상품들을 조회할수록 속도가 저하된다.
- offset을 0으로 고정하고, where절을 통한 조건으로 다음 상품을 조회함으로써 페이징 성능을 개선할 수 있었다.
- querydsl을 적용하여 코드를 통한 최적화 방법을 모색할 예정이다.
'backend > Spring' 카테고리의 다른 글
빈번한 북마크 update요청을 방지하기 위한 사용자별 스레드 관리 (0) | 2023.09.13 |
---|---|
Fetch join과 Paging (0) | 2023.08.14 |
[Spring] 스프링의 DI를 이용한 전략패턴 도입 (0) | 2023.06.08 |
[JPA] 동일 트랜잭션 내 OneToMany 필드 객체의 데이터와 DB 데이터의 불일치 (0) | 2023.04.21 |
[spring] @Configuration의 프록시 객체 (0) | 2023.03.10 |