티스토리 뷰

728x90

nestjs, prisma를 이용해 cursor 페이지네이션을 구현한다.

서버 부하를 줄이는 많은 태스크가 있지만, 오늘은 페이지네이션 방식인 cursor를 통해 어떻게 부하를 줄이는지 알아보자.

참고로 Prisma를 적용한 포스팅 글이 없어 prisma로 적용한 포스팅이다. 

cursor 도입 후 실서버에서 테스트 결과는 맨 아래에 있다.

 

 

🌠 목차
✅ 들어가기에 앞서
✅ offset과 cursor란? 속도 차이가 나는 이유
✅ cursor의 적용 예제
prisma 사용시 cursor의 주의사항
✅ cursor 결과
✅ 성능 최적화 방법 및 비용 줄이기

들어가기에 앞서


▶️ BE: nestJS, Prisma, TypeScript, AWS AuroraDB | FE: flutter 를 사용했다.

    - Prisma+cursor로 포스팅 된 블로그가 없어서 작성하게 된 포스팅이다.

▶️ offset과 cursor방식의 차이에 대해서는 다른 블로거들이 잘 작성한 글들이 있다.

    - 대략 적으로 offset과 cursor방식에 대해 설명을 하지만, 딥하게 설명하진 않는다.

▶️ 성능 최적화에 대해 다룬다.

 

 

 

 

offset방식과 cursor란? 속도차이가 나는 이유(Index)


 

참고자료: https://ramzialqrainy.medium.com/faster-pagination-in-mysql-you-are-probably-doing-it-wrong-d9c9202bbfd8

 

Offset 페이지네이션 방식

Offset 페이지네이션은 전통적으로 많이 사용되는 페이지네이션 방식.
페이지 하단에 페이지 번호를 표시하여 현재 위치를 알기 쉽게 해준다.
그러나 전체 페이지 수를 알아야 하기 때문에 처음 위치와 내 위치를 계산하고 반환해야 한다.
이로 인해 초기 페이징 속도는 빠르지만, 뒤로 갈수록 내 위치와 SKIP 해야 할 페이지를 연산해야 하므로 속도가 느려진다.

 

Cursor 페이지네이션 방식

Cursor 페이지네이션은 인덱스를 기반으로 페이지를 나누는 방식.
각 페이지는 이전 페이지의 마지막 항목의 커서를 사용하여 가져온다.
보통은 ID 값이나 생성일을 사용하는데, 이를 기준으로 페이지를 조회하고 해당 커서를 사용해 다음 페이지를 가져온다.

 

페이지네이션 방식에는 장단점이 있으며, 상황에 맞게 선택해야 한다.
이번 포스팅에서는 cursor 방식의 적용과 성능 향상의 결과를 다룬다.

cursor의 적용 예제


적용 예제에서는 그대로 복붙 하면 되는 코드가 아닌, 참고용 코드임을 밝힙니다.

레이어 단 마다 코드를 보면서 왜 이렇게 구현했는지에 대한 설명을 첨부합니다. 그래도 모르겠는 부분이 있다면 댓글 부탁해요~

 

✋Flow

프런트에서 첫 조회 시 cursor값이 없다.

백엔드에서 첫 조회 시 string Type인 unixTimeStamp 밀리세컨드 초를 cursor 값으로 전달한다.

프런트에서 n번째 조회 시 string Type인 unixTimeStamp를 전달한다.
백엔드에서 unixTimeStamp를 Date객체로 반환하여 pageSize만큼 피드를 조회한다. 

반복 

 

 

Controller

@Get('')
async getFeed(
    @Query('userId', ParseIntPipe) userId: number | undefined,
    @Query('cursor') cursor: string | undefined,
    @Query('pageSize', ParseIntPipe) pageSize: number,
  ) {
    return this.feedUsecase.getFeedsByCursor({
      userId,
      cursor,
      pageSize,
    });
  }

controller에서 쿼리 파라미터는

피드를 조회하는 userId,

페이지네이션의 인덱스가 될 cursor,

가져올 데이터 개수가 될 pageSize를 선언했다.

그리고 맨 처음 페이지를 가져올 때는 cursor값이 없으므로 undefined를 선언했다. 

 

Service

@Injectable()
export class FeedService {
  async getFeedByCursor({
    userId,
    cursor,
    pageSize
  }: {
    userId?: string;
    cursor?: string;
    pageSize: number;
  }) {
    if (userId) {
      const feeds = await feedReadRepository.withFriend.findPublicUserFeeds({
        userId,
        cursor,
        pageSize,
      });

      const nextCursor =
        feeds.length > 0
          ? feeds[feeds.length - 1].createdAt.getTime().toString()
          : null;

      return makeResponsePaginationCursorWithOutCount(
        feeds,
        nextCursor,
        pageSize,
      );
    }
  }
}

1. userId값이 있을 때만 feed를 조회한다.

2. nextCursor를 계산한다.

    - nextCursor란 unixtTimeStamp로 구성된 string type이고, API가 2번째 호출 시부터 nextCursor값이 생성된다.

    - 프런트 단에 string Type인 unixTimeStamp를 전달한다.

       (프런트에서 날짜 계산하는 것은 좋은 방법이 아님, 모든 연산은 백엔드에서 하도록 하자) 

    - feeds의 길이가 있을 때, feeds의 맨 마지막 feed의 createdAt 날짜를 unixTimestamp로 반환한다. 

      이때 반드시 밀리초로 반환해야 한다.  https://www.epochconverter.com/   <- UnitTimeStamp 밀리초 테스트 시 계산기

 

3. makeResponsePaginationCursorWithOutCount함수를 이용해서 마지막 페이지를 계산하고, 반환값을 정의한다.

 

 

makeResponsePaginationCursorWithOutCount

export function makeResponsePaginationCursorWithOutCount<T>(
  data: T | T[],
  cursor: string | null,
  pageSize: number,
) {
  let isLastPage = false;

  // data가 배열인 경우에만 길이를 확인합니다.
  if (Array.isArray(data)) {
    isLastPage = data.length === 0 || data.length < pageSize;
  }

  if (
    data === undefined ||
    data === null ||
    (Array.isArray(data) && data.length === 0)
  ) {
    return {
      data: [],
      pagination: {
        cursor: cursor,
        pageSize: pageSize,
        isLastPage: true,
      },
    };
  }

  return {
    data,
    pagination: {
      cursor: cursor,
      pageSize: pageSize,
      isLastPage: isLastPage,
    },
  };
}

데이터와, 페이지네이션 값을 전달한다. 

페이지네이션에는 cursor, pageSize, isLastPage가 들어간다.

- 맨 마지막 페이지 조회 시 isLastPage: true 값으로 판별

 

Repository

export const findPublicUserFeeds = async ({
  userId,
  cursor,
  pageSize,
  tx,
}: {
  userId: string;
  cursor?: string;
  pageSize: number;
  tx: any; 
}) => {
  let cursorOption = {};
  if (cursor) {
    cursorOption = {
      createdAt: {
        lt: new Date(Number(cursor)),
      },
    };
  }

  const result = await getPrismaDB(tx).feed.findMany({
    take: pageSize,
    where: {
      deletedAt: null,
      isPublic: true,
      ...cursorOption,
      user: {
        deletedAt: null,
      },
    },
    orderBy: {
      createdAt: 'desc',
    },
  });
  return result;
};

 

이 함수는 주어진 사용자의 ID(userId), 커서(cursor), 페이지 크기(pageSize), 그리고 데이터베이스 트랜잭션(tx)을 기반으로

사용자 피드를 조회한다.

여기서 커서는 페이징 기능을 지원하기 위해 createdAt을 기준으로 설정한다.

  • cursorOption 객체를 선언한다.
  • cursor 값이 존재한다면, 해당 값을 Date 객체로 변환하여 createdAt이 해당 날짜보다 이전인 데이터만 조회한다.
  • 조회된 피드는 삭제되지 않았으며, 공개된 것으로 설정된 것만을 대상으로 한다.
  • 결과는 createdAt을 기준으로 내림차순으로 정렬되어 반환한다.

함수는 Prisma에서 제공하는 기본적인 커서를 활용하지 않고, 직접 createdAt 활용하여 커서를 구현하여 중복 조회를 방지한다.

 

lt(Less Than), lte(Less Than Equal to)

 

prisma 사용 시 cursor 주의사항


https://www.prisma.io/docs/orm/prisma-client/queries/pagination#cursor-based-pagination

 

Pagination (Reference)

Prisma Client supports both offset pagination and cursor-based pagination. Learn more about the pros and cons of different pagination approaches and how to implement them.

www.prisma.io

prisma.io 공식문서를 보면 cursor를 사용할 수 있다. 

 

아래 내용을 보고 prisma에서 두 가지 포인트를 짚고 넘어가도록 하자.

위 내용은 프리즈마 공식 문서를 보다가 혼동이 왔던 부분이라 첨부해 보았다.

이 글을 보고 createdAt으로 cursor의 id 값으로 적용해보려고 했는데 적용이 되지 않았다.

 

 

1.  prisma에서는 id 값만을 cursor로 사용하는 것 같았다.(정확히 아는 사람이 있다면 댓글 부탁합니다)

내가 적용하려고 했던 id 값은 uuid로 되어 있어서 인덱스를 적용할 수 없었고, createdAt을 이용했어야 했다.

 

2. 그래서 createdAt을 이용했는데, 공식문서에 나와 있는 skip은 제거해야 한다.

skip은 말 그래도 내 값을 제외하고 넘어간다는 것인데 

우리는 prisma의 lt를 이용해서 조회할 것이기 때문에 나 자신이 생략된다. 

따라서 skip을 제거하면 된다.

 

결과 


DB 부하가 가장 심한 부분을 개선해보자.

RDS 모니터링 api 툴에서 보게 된 부하가 가장 큰 피드 조회 쿼리이다. 

 

 

피드 20,000번째 조회

offset 방식은 1063ms - 약 1.06초

cursor는 17ms 약 0.017초 

의 조회성능을 보였다.

 

만약 20,000번이 아닌 200,000번째 피드를 조회한다면 

offset 방식의 조회 시간은 더 늘어날 것이고,

cursor방식은 동일한 조회 시간을 유지한다. 

 

 

 

 

성능 최적화 및 비용 줄이기


요즘 최대 관심사는 성능 최적화에 대한 부분이다.

이는 비용을 절감하면서도 서비스 품질을 향상하는 데 도움이 된다.

특히 스타트업에서는 수익을 창출하는 것뿐만 아니라 비용을 줄이는 것도 매우 중요하다고 느낀다.

 

인프라가 다 깔려있고, 비용을 생각하지 않고, 최고의 품질을 요구하는 대기업은 다른 이야기일 수도 있겠다.

나는 앞으로도 스타트업 씬에 있을 거고, 또 다른 곳에서 어떻게 하면 비용을 절감할 수 있을지에 대해 내용을 정리해 보았다. 

이 글을 통해서 스타트업에서 일하고 있는 많은 개발자들에게 도움이 되면 좋겠다. 

 

우리가 할 수 있는 성능 최적화 방법을 마지막 포스팅으로 글을 마무리한다.

 

  • 데이터 역정규화: count 쿼리를 사용하는 대신 데이터 역정규화를 통해 필요한 숫자를 조회하는 방법을 고려하자. 
    이는 데이터베이스 부하를 줄이고 속도를 향상할 수 있다.
  • 페이지네이션 방식: 상황에 따라 적절한 페이지네이션 방식을 선택. 특히, 대량의 데이터를 처리하거나, 무한 스크롤 페이지에서는 cursor 기반의 페이지네이션 방식이 성능상 더 유리할 수 있다.
  • 인덱스 최적화: 데이터베이스에서 인덱스를 잘 활용하여 쿼리의 실행 속도 향상하기.
    적절한 인덱스를 설정하면 쿼리의 성능을 향상할 수 있다.

또한, AWS의 서버와 데이터베이스를 사용할 때 비용을 줄일 수 있는 몇 가지 방법이 있다. 예를 들면:

  • 리소스 최적화: 서버 및 데이터베이스 인스턴스의 크기를 실제 필요에 맞게 조정하여 비용을 절감하자.
  • 예약 인스턴스 및 예약 용량: AWS에서는 예약 인스턴스 및 예약 용량을 통해 장기적으로 인스턴스를 예약하여 저렴한 가격에 이용할 수 있다.
  • 스팟 인스턴스: 가격이 가장 낮은 스팟 인스턴스를 활용하자
  • 서버리스 아키텍처: 서버리스 아키텍처를 활용하여 인프라 관리 비용을 절감하고 자원 사용량에 따라 요금을 지불하자.
  • AWS 1년치 비용 약정걸기: 예상되는 n년치 사용량에 대해 약정을 걸면 정확한 비용은 생각나지 않지만 30프로? 이상의 비용을 절감할 수 있다. 서비스의 확장성을 고려하여 약정을 걸도록 하자.

 

 

 

 

2024.04.14-서버 비용 3분의1로 줄이기: cursor, index, 역정규화, softDelete 적용

 

서버 비용 3분의1로 줄이기: cursor, index, 역정규화, softDelete 적용

"성능 최적화를 했던 경험을 말씀해 주세요."한창 취업 시장의 문을 두드렸을 때 면접에서 들었던 내용이다. 주니어 개발자에게,한 번 실수 하면 큰 일 날 대규모 트래픽 분산 작업이나, 성능 최

study-easy-coding.tistory.com

 

 

 

 

글 잼나게 보셨으면 좋아요 눌러주고 가세요

관심 받는거 좋아합니다

 

728x90
댓글
250x250
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday