-
[Project] Nest 페이지네이션 넘어져보기 (cursor, offset기반) Feat: TypeORMProject 2023. 7. 4. 09:32
시작하며
프로젝트를 진행하면서 2종류의 페이지네이션(Cursor, Offset 기반)을 사용해야 하는 상황이 있었습니다.
두 종류의 페이지네이션의 장단점과 "어떤 상황에서 적절한 사용이 될 수 있을까?"에 대한 고민, 그리고 성능면에서 위 두 종류의 페이지네이션 구현방법이 어떤 차이를 갖는가를 설명하고 기록하려 합니다.
마주친 문제
이전 프로젝트에서 페이지네이션을 구현한 경험이 있었고, 그 프로젝트에서 Cursor기반으로 구현했다고 생각했지만 Offset기반으로 구현을 하는 실수를 했습니다. 이유는 Offset, Cursor기반의 기본개념의 미숙지로 인해 실수를 했던 사실을 인지했습니다.
두 페이지네이션 구현방법과 개념을 다시 찾아보면서 실수를 깨닫고 제대로 된 페이지네이션을 구현 설명하려 합니다.
문제 해결 과정
우선 (Cursor, Offset기반) 페이지네이션의 개념을 인지하고 있다는 가정하에 설명을 시작하려 합니다.
(위 두 페이지네이션의 기본 개념을 알지 못하시면 숙지 후 읽어주세요.)
async getMyProducts(mallId: string, query: PaginationQueryDto) { const associationProduct = await this.dataSource .getRepository(Product) .createQueryBuilder('product') .where('product.user = :mallId', { mallId }) .take(+query.take) .skip(+query.skip) .getquery()
💩 위 코드는 문제가 있었던 이 부분 코드입니다. (전체 코드를 공개하지 못하는 점 양해 부탁드립니다.)
.getquery()를 사용하여 raw query를 얻고 실행계획법으로 확인해 보겠습니다.
간단하게 알아보겠습니다.
- type: 'ALL' => 풀 스캔(Full Scan) 방식으로 모든 레코드를 검색했습니다. (Full Scan은 성능상 좋지 못합니다.)
- rows: '53' => 검색 결과로 반환된 행의 수입니다.
- filltered: '1.89' => 쿼리의 실행 비용(Cost)입니다.
- Extra: 'Using where' => WHERE 절에서 필터링이 수행된다는 의미입니다.
Offset방식은 처음부터 끝까지 조회가 된다는 사실을 알 수 있습니다. 만약 10000개의 데이터 이후 10개의 데이터를 얻고 싶다면 10010개의 데이터를 조회하는 현상이 일어납니다.
출처: https://jojoldu.tistory.com/528 현재 시드데이터가 53이지만 데이터가 백만 개가 된다면 성능은 더욱 좋지 못할 겁니다.
추가설명 : TypeORM에서 페이지네이션을 구현할 때, limit, offset 함수를 사용하지 말고 take, skip함수를 사용해야 합니다.
더보기The resulting SQL query depends on the type of database (SQL, mySQL, Postgres, etc). Note: LIMIT may not work as you may expect if you are using complex queries with joins or subqueries. If you are using pagination, it's recommended to use take instead. 출처: https://typeorm.io/select-query-builder#adding-limit-expression
공식문서는 서브쿼리나 조인을 사용하는 복잡한 쿼리에서 예상대로 LIMIT이 동작하지 않을 수 있기 때문에. take()를 사용하라고 권장하고 있습니다.
그렇다면 cursor기반으로 구현해 보겠습니다. 여기서 cursor는 index로 해석해도 무방합니다. 즉 "몇 번째 인덱스의 데이터부터 몇 개의 데이터(limit)를 가져와"라는 SQL을 생성해야겠죠?
async getProductCursorQB( mallId: string, cursorPageOptionsDto: CursorPageOptionsQueryRsIdDto, ) { const productQB = this.productRepository.createQueryBuilder('product') const products = await productQB .where('product.mall_id = :mallId', { mallId }) .andWhere('product.reward_setting_id = :rsId', { rsId: cursorPageOptionsDto.rsId, }) .andWhere('product.id > :cursorId', { cursorId: cursorPageOptionsDto.cursorId, }) .take(cursorPageOptionsDto.limit) .getMany() return { products }
위 코드를 보면 offset을 쓰지 않고 limit만 사용한 것을 확인할 수 있습니다. 그리고 where절에 id값(pk => index)을 주면서 우리가 찾고자 하는 데이터의 기준을 정해주는 코드를 확인할 수 있습니다.
자, 그럼 실행계획으로 데이터를 어떻게 찾아가는지 확인해 보겠습니다.
- type: 'index_merge' => 인덱스 병합(Index Merge) 방식으로 인덱스를 사용한다는 의미입니다.
- row: '1' => LIMIT 절에 대한 정보입니다.
- filtered: '5.00' => 쿼리의 실행 비용(Cost)입니다. (filtered 값이 낮을수록 인덱스가 데이터를 필터링하는 데 효율적이지 않다는 것을 의미합니다. 이 경우 쿼리 실행 시 더 많은 데이터를 처리해야 하므로 성능이 저하될 수 있습니다.)
두 페이지네이션 장단점
그렇다면 위 페이지네이션 종류의 장단점은 무엇일까요? 간단하게 알아보겠습니다.
Offset 기반
장점 - 구현이 쉽다.
단점 - 성능이 매우 좋지 않을 수 있다.
Cursor 기반
장점 - index를 활용하기 때문에 성능상 유리하다.
단점 - 구현이 까다로울 수 있다.
흔히 우리가 생각하는 인스타그램, 트위터는 무한스크롤을 사용하고 있습니다. 이것은 Cursor기반으로 구현했다고 생각하시면 됩니다. 만약 무한스크롤을 Offse기반으로 구현을 한다면 피드나 글들을 내리면 내릴수록 데이터를 가져오는 시간이 길어지고 유저의 사용경험은 더욱 나빠질 것입니다. Offset기반으로 구현한 대표적인 예는 구글에서 검색을 했을 때 맨 하단에 1|2|3|4|5와 같이 페이지가 나와있는 경우를 생각해 볼 수 있습니다.
🧐 내 프로젝트에서는?
지금 진행 중인 프로젝트에서는 cafa24 API를 사용하고 있습니다. cafe24 API조회를 할 때 Offset기반으로 데이터를 조회합니다.
하지만 cafe24 API 플로우를 타지 않는 로직은 Cursor기반으로 구현했습니다.
마치며
프로그래밍을 하면 할수록 특정 상황에서 어떤 구현방법이 적절한가, 또는 트레이드오프(trade-off)를 고려하며 개발하는 상황들이 점점 생기기 시작했습니다. 어떤 기능을 구현하고자 할 때 그 이유와 정확한 개념을 알고 개발하는 개발자가 되고 싶습니다.
'Project' 카테고리의 다른 글
[Project] Nest Lifecycle 이해하기 (0) 2023.07.14 [Project] Nest seeding 해보기 (typeorm-extension사용) (0) 2023.06.28 [Project] Nest 넘어져보기 (Service, Repository 분리) (0) 2023.04.18