[Project] #3 <커서 기반 페이지네이션>과 함께하는 무한스크롤(feat. NestJS)

DatQueue·2024년 1월 27일
5
post-thumbnail

🧃 무한스크롤을 구현해야 한다.

어떤 정보에 대한 목록을 유저에게 보여주는데 있어서 도메인의 특성상 특정 페이지로 이동이 가능한 페이징 형태보단 무한스크롤이 대부분 요구되었다.

무한스크롤을 구현하려할 때 "서버"에서 고려해야할 사항은 무엇일까? 물론, 클라이언트와 어떤 형식의 요청 쿼리로 소통할 것인가도 포함이다.

물론 개발자마다 중요하게 생각하는 부분이 다를 수 있겠지만, 내가 여태 페이지네이션들을 구현해보며 중점을 두었던 부분은 첫째는 무조건 "성능(조회 속도)" 이고, 둘째는 구현하고자 하는 페이지네이션에 "정말 필요한 기법"을 사용하는 것이라 본다.

무한 스크롤은 정말 빠르게 변동할 수 있는 데이터 목록인 만큼 UX에 민감해야하고 결국 이는 1차적으로 조회 API의 성능이 우선시 된다.

이 부분에 대해 이번 글에서 깊게 설명하고 싶지만, 사실 해당 개념적 내용은 일전에 따로 다룬 적이 있었다.

해당 기능을 처음 구현해 볼 당시 관련 글이 많이 없었고, 그래서 그런지 꽤 많으신 분들이 페이지네이션 관련 글을 봐주신 것 같다.



🥊🥊🥊🥊🥊

위의 이미지와 같은 검색 키워드로 일전에 제가 작성한 글을 찾으실 수 있습니다.

왜 "무한스크롤"을 구현하는데 "Cursor-Based-Pagination(커서 기반 페이지네이션)" 기법을 사용했는가에 대해 설명합니다.

더불어 아래에 첨부되는 링크 중 2번째에선 만약 커서 값이 "Unique"하지 않을 경우 발생할 수 있는 문제를 다루고, 이를 통해 어떠한 커서 값을 생성하는 지를 제시합니다.

이번 포스팅에선 해당 내용의 자세한 부분에 대해 생략하고 코드적 접근에 포인트를 두고자 하므로 사전에 꼭 아래 두 링크를 보시고 오시면 감사하겠습니다.

#1 Cursor-Based Pagination _Query 성능 관점 접근, NestJS 구현 접근

#2 Cursor-Based Pagination _Custom한 커서값 생성 배경과 구현

🥊🥊🥊🥊🥊



사실, 지금에서야 말하지만 위 링크에서 NestJS를 사용한 구현 코드 부분은 내가 작성하였지만 굉장히 좋지 않은 코드이다.

그 당시 코드 레벨에서의 수준이 너무 좋지 않았고, 성능에 이슈를 불러일으킬 만한 (전체 조회 등등...) 불필요한 연산들을 많이 수행하였다. 페이지네이션 조회 시간이 굉장히 오래 걸리는 경우가 발생하였고 하지만 기능 구현에 기뻐 그 이외의 것들을 생각해보지 못하였다.

이미 작성된 포스팅에 대한 수정작업은 아마 이 글이 끝나고 진행될 것 같고, 이번 글에선 저 당시 보다 훨씬 더 고도의 커서 기반 페이지네이션을 작업을 서술해보고자 한다.


들어가기에 앞서 잠시 무한스크롤을 구현하는데 있어 "왜" "Offset-Based(오프셋)" 방식을 쓰지 않았는가에 대해 말하고 싶다.

오프셋 기법을 사용하였을 때 발생할 수 있는 기능적 문제(중복 아이템 호출), 데이터베이스 조회 성능과 관련된 문제는 (위 링크 확인) 예전 포스팅에서 전부 다루어서 따로는 언급안하겠지만 그냥 단순히 생각해서 "무한스크롤""오프셋"이란 개념이 맞을까? 란 생각을 한 번 해볼 수 있을거 같다.

spring 환경의 orm이든 Node orm이든 페이지네이션을 할 수 있는 orm 수준의 기능을 지원해주고, 이는 대부분 데이터베이스의 "limit-offset", orm 수준의 "take-skip"으로 활용할 수 있는 "오프셋" 기반의 페이지네이션 api 함수이다. 아주 간편하게 페이징 처리를 할 수 있는 api 함수를 제공해주기에 종종 무한스크롤을 구현한 블로그들에서도 이렇게 오프셋을 활용한 코드들을 많이 볼 수 있었다.

하지만 그림만 보아도 "오프셋"이란 개념이 무한스크롤과는 맞지 않다는 생각이 들지 않을 수가 없었다.

"쉽게 사용할 수 있다" 는 사실 "서버 측"에 해당하는 말이다.

물론 오프셋으로 구현을 하더라도 클라이언트에게 "take와 skip" 그대로가 아닌(그대로인 일부 사례들도 보았다....) skip을 통해 서버측에서 로직으로 구현한 "page(페이지 수)"를 날려줄 것을 요구하겠지만 애초에 "무한 스크롤"에 "페이지"를 요구한다... 이것 또한 모순이 있다고 본다.

오프셋 기법은 말 그대로 특정 페이지로써 바로 이동이 가능하게끔 offset(skip), limit(take) 를 이용해 페이지 호출을 한다는 것인데 이는 무한스크롤의 의미와는 너무 다르다 생각이 든다. 우리가 무한 스크롤링을 하면서 "페이지"란 개념을 떠올리진 않기 때문이다.

결국 서버 측에서 무한스크롤 구현에 오프셋 기반의 페이징 처리를 할 경우, 이는 클라이언트와의 협업에 있어 의심할 여지가 있는 API 소통을 하고 있다 생각이 들 수 밖에 없었다.

물론, 여러 관점들이 존재하겠지만 성능적 측면은 물론이거니와 앞으로 설명할 "Custom Cursor-Based Pagination"을 도입하게 된 충분한 이유가 되었다.


> 요구되는 기능

프로젝트에서 무한 스크롤을 요구하는 화면 정의는 여러 군데 존재하였지만 그 중 가장 구현이 복잡하였던 "가게 리스트 조회"를 바탕으로 이번 글을 설명하고자 한다.

(출처: 미리, -- 가게 명 노출 x )

요구되는 기능은 첫 째로 키워드에 따른 정렬이다. 이미지에 (흐릿하게) 보이듯이 메인이 되는 "유저 등록 위치에 따른 매장과의 거리 순", "매장 찜 등록에 따른 찜 많은 순", "등록된 리뷰가 많은 순", "별점 순"에 따른 정렬이 구현되어야 한다.

두 번째는 모든 키워드에 따른 정렬은 기본적으로 유저가 설정한 위치에 따른 서비스에서 정한 거리 범위 내에 있는 가게여야 한다. 유저와 멀리 떨어져있는 가게를 굳이 보여줄 필요는 없도록 기획이 되었기 때문에 (직접 픽업이 필요한 o2o 서비스이다) 설정한 거리 범위(limitDistance) 내부의 가게들만 리스트에 포함시킨다.

마지막으로는 불러올 정보들이다. 매장 썸네일 사진, 매장 태그, 별점, 찜 수, 예상 수령 시간 등등 뷰에서 처리하기 위한 다양한 정보들을 데이터베이스 내부에서 불러와야한다. 어떤 정렬 기준이던 불러올 정보의 데이터 셋은 전부 동일하다는 기획이다.



🧃 요청/응답 모델(dto) 설계

이번 챕터 역시 자세한 설명은 생략하는것이 좋을 것 같다. 위에 링크로 걸어둔 포스팅에서 해당 내용을 다루었고, 이는 커서 기반에만 국한되지 않고 오프셋 기반 등 여러 방식의 페이지네이션 API를 설계하는데 있어 전반적으로 사용한 요청/응답 모델 들이다.


한 번더 링크를 남겨두도록 하겠습니다. 아래 포스팅의 내용을 통해 커서 기반의 페이지네이션을 비롯해 여러 다른 방식의 페이지네이션 API에 사용될 공통적인 객체 모델을 확인할 수 있습니다.

NestJS Pagination with Typeorm (Offset-Based) __ 관심사 분리 파트 확인 바람

NestJS Pagination with Typeorm (Cursor-Based) __ 구상 및 모델 생성 파트 확인 바람


해당 객체를 정의한 파일들은 모든 도메인에 공용적으로 쓰이므로 common 디렉토리 내부에 관리토록 하였고, 폴더 구조는 아래와 같다.

> 요청부(request)

// cursor=page-option.dto.ts
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsOptional, ValidationArguments, ValidationOptions, registerDecorator } from "class-validator";

export class CursorPageOptionsDto {

  @ApiProperty({
    example: 5,
    description: '하나의 페이지당 가져올 데이터의 개수 (필수 x)',
    required: false,
  })
  @Type(() => Number)
  @IsOptional()
  // custom decorator
  @IsPositiveNumber()
  take?: number = 5;

  @ApiProperty({
    example: '0000000000000000',
    description: '16자리의 스트링으로된 커스텀 커서 값 (첫 페이징 시작시엔 서버에서 지정한 디폴트 값 발동)',
    required: false,
  })
  @Type(() => String)
  @IsOptional()
  // custom decorator
  @IsSixteenDigitString()
  customCursor?: string;
}

function IsSixteenDigitString(validationOptions?: ValidationOptions) {
  return function (object: Object, propertyName: string) {
    registerDecorator({
      name: 'isFourteenDigitString',
      target: object.constructor,
      propertyName: propertyName,
      options: validationOptions,
      validator: {
        validate(value: any, args: ValidationArguments) {
          if (typeof value !== 'string') {
            return false;
          }
          return /^[0-9]{16}$/.test(value);
        },
        defaultMessage(validationArguments?: ValidationArguments) {
          return `${validationArguments.property}는 16자리 숫자의 스트링값 형태이어야 합니다.`;
        },
      },
    });
  };
}

function IsPositiveNumber(validationOptions?: ValidationOptions) {
  return function (object: Object, propertyName: string) {
    registerDecorator({
      name: 'isPositiveNumber',
      target: object.constructor,
      propertyName: propertyName,
      options: validationOptions,
      validator: {
        validate(value: any, args: ValidationArguments) {
          return typeof value === 'number' && value > 0;
        },
        defaultMessage(validationArguments?: ValidationArguments) {
          return `${validationArguments.property}는 숫자타입임과 동시에 양수여야 합니다.`;
        },
      },
    });
  };
}

물론 클라이언트측에서 올바른 형식의 쿼리 필드값을 넘겨주겠지만, 유효성 체킹을 하는 건 항상 동반이 되어야한다 생각한다. 그러므로 우린 직접 커스텀 데코레이터를 생성해 가져올 갯수(take)와 커서 값(customCursor)에 대한 유효성 검증을 진행한다.

  • take: 양수일 것
  • customCursor: 16자리 숫자의 스트링값 형태여야 할 것

만약 형식에 어긋날 경우 아래와 같은 에러를 응답 받을 수 있다.


> 응답부 (request)


CursorPageResModel (최종 응답)

// cursor-page-res.dto.ts
import { IsArray } from "class-validator";
import { CursorPageMetaRes } from "./cursor-page-meta.dto";
import { ApiProperty } from "@nestjs/swagger";

export class CursorPageResModel<T> {
  @ApiProperty({
    type: 'array',
    items: {
      type: 'item'
    }
  })
  @IsArray()
  readonly data: T[];

  @ApiProperty({
    type: CursorPageMetaRes
  })
  readonly meta: CursorPageMetaRes;

  constructor(data: T[], meta: CursorPageMetaRes) {
    this.data = data;
    this.meta = meta;
  }
}

CursorPageMetaRes (메타 데이터)

// custom-cursor-page.meta.dto.ts
import { ApiProperty } from "@nestjs/swagger";
import { CursorPageMetaDtoParameters } from "./cursor-page-meta-param.interface";

export class CursorPageMetaRes {
  
  @ApiProperty({
    example: '10',
    description: 'take',
    required: true,
  })
  readonly take: number;

  @ApiProperty({
    example: 'true',
    description: 'hasNextData',
    required: true,
  })
  readonly hasNextData: boolean;

  @ApiProperty({
    example: '0000000000000000',
    description: 'customCursor',
    required: true,
  })
  readonly customCursor: string;

  constructor({cursorPageOptionsCommand, hasNextData, customCursor}: CursorPageMetaDtoParameters) {
    this.take = cursorPageOptionsCommand.take;
    this.hasNextData = hasNextData;
    this.customCursor = customCursor;
  }
}

기획에 전체 아이템 갯수를 표시해줄 필요는 없었기 때문에, 클라이언트에게 제공해주는 메타 데이터는 take, hasNextData(다음 데이터가 존재하는가), customCursor를 포함하도록 하였다.

데이터를 응답하는데 있어 객체(엔티티)의 ID 식별자, 그리고 정렬 속성값을 제공하기 때문에 커스텀 커서(customCursor)값은 클라이언트 측에서 만들어도 무방하다. 하지만 커서 값을 정의하는 책임 및 로직을 클라이언트로 가져갈 필요는 없다고 판단하였고, 스크롤 구현에만 집중케끔 하기로 하였다.


CursorPageMetaDtoParameters

// cursor-page-meta-param.interface.ts
export interface CursorPageMetaDtoParameters {
  cursorPageOptionsCommand: CursorPageOptionsCommand;
  hasNextData: boolean;
  customCursor: string;
}


🧃 로직 설계

이전의 경험을 토대로 커서 페이지네이션 로직을 설계하면서 1차적으로 중요하게 생각하였던 것은 조회 성능이었다. 부드럽게 리스트가 조회되어야 할 무한 스크롤 특성 상, API를 빈번하게 호출하는데 있어 최대한 속도를 줄여나가야 했다.

객체지향적 측면에서의 "클린한 코드", "좋은 코드"를 만드는 것 또한 물론 중요하지만 사실 상 orm을 통한 "쿼리문" 작성이 핵심이었고 성능에 지장을 줄 수 있는 쿼리 함수및 쿼리 식을 최대한 지양하는 것을 목표로 하였다.


모든 정렬에 대한 로직을 설명하기 전, 먼저 "거리 순"에 따른 페이지네이션 처리 로직을 알아보자.

(다시 한 번 언급하지만 커서값을 이루는 요소 및 생성 이유에 대한 설명은 생략하니 꼭 먼저 포스팅 상단의 링크를 보고 오시기 바랍니다)

> 단계 1 (base)

먼저 로직을 바로 확인해보자.

거의 대부분의 로직은 "Adaptor(어댑터)" 영역에서 작성된다. 페이징 필터링을 하는데 있어 실질적으로 Raw Query와 직/간접적으로 연결된 로직이 많으므로, 굳이 서비스 레이어까지 들고가는 것은 불필요하지 않을까 생각했다.

페이지네이션에 필요한 option dto는 그대로 불러오는 것이 아닌, 서비스 영역부턴 command 객체로써 받아온다.

// cursor-page-option.command.ts
export class CursorPageOptionsCommand {
  constructor(
    public take?: number,
    public customCursor?: string,
  ) {}
}
// store_driven.adpator.ts
export class StoreMySqlAdaptor implements StoreDrivenPort {
  constructor(
    @Inject(TypeOrmStoreRepositorySymbol)
    private readonly storeRepository: TypeOrmStoreRepository,
  ) {}

  async paginateStoresByDistance(userId: number, cursorPageOptionsCommand: CursorPageOptionsCommand): Promise<CursorPageResModel<StoreResponseModel>> {
    const limitDistance = +process.env.AROUND_STORE_LIMIT_DISTANCE; // 거리 제한(미터)
    const takePerPage = cursorPageOptionsCommand.take;
    // queryBuilder 식을 통한 customCursor 정의
    // MySql의 공간함수를 사용하여 커스텀 커서 조건 식 생성 (아래에서 추가 설명)
    const queryByCustomCursor = 
      `(CONCAT(LPAD(FLOOR(ST_DISTANCE_SPHERE(store.latlng, ul.latlng)), 8, '0'), LPAD(store.storeId, 8, '0')) > :customCursor)`

    const storeQBuilder = this.storeRepository
      .createQueryBuilder('store')
      .select(
        'ST_DISTANCE_SPHERE(ul.latlng, store.latlng)', 'dist'
      )
      .addSelect([
        'store.storeId AS storeId',
        'store.storeName AS storeName',
        'store.rating AS rating',
        'store.createdAt AS createdAt',
        'store.storeThumbImgUrl AS storeThumbImgUrl',
        'store.curPickupTime AS curPickupTime',
        'store.likeCount AS likeCount',
        'store.reviewCount AS reviewCount',
        // leftJoin을 통해 불러온 store_tags 테이블의 tagName을 GROUP_CONCAT으로 불러온다.
        'GROUP_CONCAT(storeTags.tagName) AS tags'
      ])
      .innerJoin('user_locations', 'ul')
      .leftJoin('store_tags', 'storeTags', 'storeTags.storeId = store.storeId')
      
      // 클라이언트에서 요청을 보낸 take(불러올 갯수)값보다 하나를 더 불러오도록 한다. (아래에서 추가 설명)
      .limit(takePerPage + 1)
      .where('ul.userId = :userId', {userId})
      .andWhere(`ul.isActivated = 1`)
    
      // 앞서 생성한 커서값을 통한 비교식을 조건으로 달아준다.
      .andWhere(queryByCustomCursor, {
        customCursor: cursorPageOptionsCommand.customCursor,
      })
    
      // 집계함수를 사용하였기 때문에(거리 계산) 이를 select 하기 위해 groupBy에 등록해준다.
      .groupBy(`dist, store.storeId`)
      // 모든 정렬은 항상 오름차순 혹은 내림차순이다. "거리순"이므로 작은 거리 순부터 정렬될 것이다.
      .orderBy({
        "dist": "ASC",
        "store.storeId": "ASC",
      })
      // 모든 불러올 가게 데이터 리스트는 지정해 준(ex _ 4,000m) 유저와 매장 사이의 거리 이내의 값들만 유효하게끔 한다.
      .having(`dist < ${limitDistance}`);

    const stores: StoreResponseModel[]; = await storeQBuilder.getRawMany();
    
    // 실제로 클라이언트에게 응답할 데이터는 `take+1`이 아니라 `take` 이어야 한다. 
    // 즉, 아래와 같은 작업으로 마지막 응답 데이터를 뺀 나머지 리스트를 얻는다.
    const responseStores = stores.slice(0, takePerPage);
    
    // 다음 데이터의 존재 유무
    let hasNextData: boolean = true;
    
    // 응답 데이터들 중 마지막 데이터 불러옴.
    const lastDataPerPage = responseStores[responseStores.length - 1];
    
    // 앞서 구한 `lastDataPerPage`를 사용하여 다음 커서 조회를 위한 customCursor 생성
    let customCursor = this.createCustomCursor(parseInt(lastDataPerPage['dist']), lastDataPerPage['storeId']);
    
    /* 주의 */
    // 만약 커서값에 따라 요청한 페이지 다음 페이지에 어떠한 데이터도 존재하지 않을 경우 
    // 아래와 같이 hasNextData=false, customCursor=null을 가지게끔 한다. (아래에서 추가 설명)
    if (stores.length <= takePerPage) {
      customCursor = null;
      hasNextData = false;
    } 

    const cursorPageMeta = new CursorPageMetaRes({ cursorPageOptionsCommand, hasNextData, customCursor });

    return new CursorPageResModel<StoreResponseModel>(responseStores, cursorPageMeta);
  }
  
  // custom-cursor 값 생성 (ex. "0000355500000010" -> storeId=10이며 거리(dist)=3555m)
  private createCustomCursor(property: number, id: number): string {
    return String(property).padStart(8, '0') + String(id).padStart(8, '0');
  }

다음 단계로 넘어가기 전 몇 가지 중요한 사항을 체크해보자.


> 중요한 포인트 체크 (커서 페이지네이션을 이루는 핵심)

  • queryByCustomCursor

    const queryByCustomCursor = 
          `(CONCAT(LPAD(FLOOR(ST_DISTANCE_SPHERE(store.latlng, ul.latlng)), 8, '0'), LPAD(store.storeId, 8, '0')) > :customCursor)`
    
    // ... 
    
     .andWhere(queryByCustomCursor, {
       customCursor: cursorPageOptionsCommand.customCursor,
     })

    물론 다른 조건식들도 중요하지만 "Cursor-Pagitnation"을 구현하는데 있어 가장 "핵심"이 되는 조건절이지 않을까 싶다.

    만약 클라이언트가 서버로 부터 아래의 customCursor값을 받았다고 해보자.

    "meta": {
      "take": 5,
      "hasNextData": true,
      "customCursor": "0000897000000189"
    }

    해당 커서 값이 의미하는 바는 응답 데이터 리스트 들 중 마지막 아이템의 storeIddist값을 조합해서 만든 결과이다. 참고로 storeId는 모두 고유하므로 두 번째 8자리의 숫자에 배치해두면 dist의 소수점을 버리더라도 항상 고유한 커서 값을 유지할 수 있게 된다. (아래 이미지 참조)

    그리고 해당 커서 값을 통해서 다음 불러올 데이터들을 서버로 요청할 수 있게 된다. 그 데이터 리스트는 당연히 "거리 순" 정렬이므로 8970m보다 큰 거리 간격의 가게 데이터가 요청 될 것이다.

    이를 가능케 하는 작업이 바로 위의 조건절 식이라 할 수 있다. 클라이언트로부터 요청 받은 커스텀 커서값을 통해 항상 정렬 기준에 부합하는 데이터를 불러올 수 있다.


  • takePerPage

    페이지 당 불러올 데이터 갯수에 해당하는 takePerPage 역시 상당히 중요하다.

    예전 포스팅 당시엔 이 부분을 실수하였다. 지금에서야 바로 잡고 수정해보도록 한다.

    // 클라이언트에서 요청을 보낸 take(불러올 갯수)값보다 하나를 더 불러오도록 한다. (아래에서 추가 설명)
    .limit(takePerPage + 1)
    
    // ...
    
    const stores: StoreResponseModel[]; = await storeQBuilder.getRawMany();
    
    if (stores.length <= takePerPage) {
       customCursor = null;
       hasNextData = false;
     } 

    "왜 클라이언트로부터 요청받은 갯수(takePerPage)를 그대로 불러오는게 아니라, "하나"를 추가로 불러오는 것일까?"

    그 이유는 바로 응답으로 보여질 "메타데이터"의 정확성 때문이다.

    메타데이터로써 클라이언트에게 커스텀 커서 값, 그리고 다음 페이지 데이터의 존재 유무를 응답해준다. 오프셋 기반의 페이지네이션과 달리 무한 스크롤에서 클라이언트에게 전달될 정보는 그리 많지도 않고 그리 많이 필요하지도 않다. (물론 요구사항에 따라 달라진다. 현재는 한 방향으로만 스크롤되는 무한스크롤의 예시이다)

    즉, 제공해주는 hasNextData이 모든 케이스에서 정확하다면 클라이언트는 별도의 작업없이 마지막 스크롤에대한 처리를 쉽게 할 수 있을 것이다.

    보통의 케이스에서 다음 페이지의 데이터 존재 유무를 알아내야 한다면 어떤식으로 진행될까?

    곰곰히 생각해보면 여러 글들에서, 그리고 이전의 나조차도 아래와 같은 방식을 사용하였다.


    "takePerPage(페이지 당 불러올 갯수) => total(전체 데이터 카운트)"


    위의 방식이 틀린 것은 절대 아니지만 나의 경우 "전체 데이터 카운팅"이 필요하지 않았으므로 굳이 사용해 성능에 지장을 주고 싶지 않았다.

    미미할 수도 있겠지만 전체 데이터를 카운트하는 작업은 어찌됐건 불필요한 비용이기 때문이다.

    즉, 다른 방법을 생각해야 했다.

    그 방법은 "페이지당 불러올 갯수 + 1"을 통해 다음 페이지의 데이터중 첫 번째 데이터까지 미리 구하는 것이다. 그리고 해당 데이터 리스트의 길이와 페이지당 불러올 갯수 값을 비교한다.

    store.lengthlimit(takePerPage+1) 이므로 마지막 커서를 제외한 모든 상황에선 무조건 store.length > takePerPage가 성립된다. 즉, 두 값이 같게 되는 순간부터, 부등호가 역전이 되는 순간 부터는 "다음 데이터가 존재하지 않음"을 설명할 수 있게 된다.

    이에 따라 아래와 같은 비교 식을 정의할 수 있다.

      if (stores.length <= takePerPage) {
        customCursor = null;
        hasNextData = false;
      } 

> 단계 2) 성능 개선과 부하 테스트 (Having절 은 옳은 선택일까)


앞선 쿼리 식을 다시 살펴보자.

const storeQBuilder = this.storeRepository
  .createQueryBuilder('store')
  .select(
    'ST_DISTANCE_SPHERE(ul.latlng, store.latlng)', 'dist'
  )
  .addSelect([
    'store.storeId AS storeId',
    'store.storeName AS storeName',
    'store.rating AS rating',
    'store.createdAt AS createdAt',
    'store.storeThumbImgUrl AS storeThumbImgUrl',
    'store.curPickupTime AS curPickupTime',
    'store.likeCount AS likeCount',
    'store.reviewCount AS reviewCount',
    // leftJoin을 통해 불러온 store_tags 테이블의 tagName을 GROUP_CONCAT으로 불러온다.
    'GROUP_CONCAT(storeTags.tagName) AS tags'
  ])
  .innerJoin('user_locations', 'ul')
  .leftJoin('store_tags', 'storeTags', 'storeTags.storeId = store.storeId')
  // 클라이언트에서 요청을 보낸 take(불러올 갯수)값보다 하나를 더 불러오도록 한다. (아래에서 추가 설명)
  .limit(takePerPage + 1)
  .where('ul.userId = :userId', {userId})
  .andWhere(`ul.isActivated = 1`)
  // 앞서 생성한 커서값을 통한 비교식을 조건으로 달아준다.
  .andWhere(queryByCustomCursor, {
    customCursor: cursorPageOptionsCommand.customCursor,
  })
  // 집계함수를 사용하였기 때문에(거리 계산) 이를 select 하기 위해 groupBy에 등록해준다.
  .groupBy(`dist, store.storeId`)
  // 모든 정렬은 항상 오름차순 혹은 내림차순이다. "거리순"이므로 작은 거리 순부터 정렬될 것이다.
  .orderBy({
    "dist": "ASC",
    "store.storeId": "ASC",
  })
  // 모든 불러올 가게 데이터 리스트는 지정해 준(ex _ 4,000m) 유저와 매장 사이의 거리 이내의 값들만 유효하게끔 한다.
  .having(`dist < ${limitDistance}`);

마지막에 사용한 having() 즉, HAVING 절에 주목해보자.

처음 코드를 작성할 땐 아무생각이 HAVING 절 내부에서 거리 계산을 해주었지만, 이는 곧 테스트 시 생각보다..? 느린 성능으로 다가왔다.

HAVING 절은 알다시피 위의 코드와 같이, GROUPBY 이후에 실행이 된다. 즉, 모든 결과 행을 전체 정렬한 뒤 HAVING 절이 실행되기 때문에 이는 굉장히 비효율적이라 할 수 있다.

만약 하나의 API 호출당 내뱉어야 하는 쿼리 결과의 데이터 셋이 더 "클" 경우 이는 더 눈에띄는 성능 저하로 다가왔을 것이다.


어떻게 수정해 줄 수 있을까? 아주 간단하다.

HAVING절을 제거하고, 유저의 위치 테이블과 조인하는 부분에서 바로 조건을 걸어주면 된다.

.innerJoin('user_locations', 'ul', `ST_DISTANCE_SPHERE(ul.latlng, store.latlng) <= ${limitDistance}`)

inner join 내부에서 이를 적용하면 필터링이 조인 조건에 포함되어 쿼리 실행 계획을 더 효율적으로 만들어 줄 수 있다. 필요한 데이터만 걸러서 GROUPBY로 이동하기 때문에 성능에 더 유리하게 된 것이다.

실제로 두 케이스를 비교해보았을 때 HAVING절을 사용하지 않은 경우가 단순 한 번의 API 요청 테스트에서도 3배 정도의 성능 개선을 보였다.


간단히 apache benchmark 부하 테스트를 통해 동시 요청 건 수를 늘려보자.

사실 더 유의미한 테스트는 불러올 데이터 셋의 크기를 키우는 것이다. 하지만 불러올 데이터 셋의 크기를 키우는 것은 몇 가지 제약사항이 있기 때문에 (이는 당연히 성능 차이가 확실히 생길 것임에 의심할 여지가 없다) 동시 요청 건 수를 늘려 테스트를 해보았다.


아래는 1,000건의 동시 요청에 따른 비교이다.

ab -n 1000 -c 1000 "http://localhost:3030/stores/paginateStoreByDistance?customCursor=0000626700000200"

✔ without having

✔ with having

Tps(Time per request) 뿐만 아니라 median(중앙값), max(최댓값) 등에서도 HAVING을 사용할 경우가 눈에 띄게 비효율적인 성능을 낸 다는 것을 확인할 수 있다.


쿼리 성능이 우선적으로 생각되야 하므로 API 호출 시간이 예상외로 늦을 시 이러한 점을 항상 의심해 볼 필요가 있다.

🧃 Refactoring (최종 코드 입니다)

앞서 우린 "거리 순"에 따른 커서 페이지네이션 정렬에 대해 알아보았다. 하지만 기획에 따라 "찜 순", "리뷰 많은 순", "별점 순"에 따른 정렬또한 진행해야 한다.

물론, 거리 순에서 수행하였던 것과 동일하게 해주면 된다. 어짜피 리턴하는 응답 값은 동일한 모델 객체를 바라보므로 정렬커서값 생성(커서 값을 생성하는 데 있어 각 정렬 기준 속성 값이 포함되어야 한다)만 달리 해주면 된다.

하지만 그렇다고 일일히 paginateStoresByDistance()를 생성한 것 처럼 동일한 크기의 로직으로 paginateStoresByLikeCount(), paginateStoresRating(), paginateStoresReviewCount()를 만들 것인가?

보기에 좋지 않을 뿐더러, 추후 정렬 기준의 변동 및 추가가 일어날 경우 또 하나의 뚱뚱한 함수를 생성해야 하는 일이 생긴다.

즉, 이런 점을 고려해 db에 접근해 쿼리 데이터를 호출하는 함수, 각 속성(정렬 기준)으로 전달 될 메인로직 및 공통 객체 응답 부를 담은 함수, 각 정렬마다의 커스텀 커서와 정렬 차순을 정의한 함수로 나누어 진행하기로 했다.

전체 어댑터 부의 로직은 아래와 같다. 거의 모든 로직이 정의된다 보면 된다.

// store_driven.adpator.ts

export class StoreMySqlAdaptor implements StoreDrivenPort {
  constructor(
    @Inject(TypeOrmStoreRepositorySymbol)
    private readonly storeRepository: TypeOrmStoreRepository,
  ) {}

  private async getStores(
    userId: number,
    cursorPageOptionsCommand: CursorPageOptionsCommand,
    queryByCustomCursor: string,
    orderBy: {},
  ): Promise<{ stores: any[] }> {
    const limitDistance = +process.env.AROUND_STORE_LIMIT_DISTANCE; // 거리 제한(미터)

    const storeQBuilder = this.storeRepository
      .createQueryBuilder('store')
      .select(
        'ST_DISTANCE_SPHERE(ul.latlng, store.latlng)', 'dist'
      )
      .addSelect([
        'store.storeId AS storeId',
        'store.storeName AS storeName',
        'store.rating AS rating',
        'store.createdAt AS createdAt',
        'store.storeThumbImgUrl AS storeThumbImgUrl',
        'store.curPickupTime AS curPickupTime',
        'store.status AS status',
        'store.likeCount AS likeCount',
        'store.reviewCount AS reviewCount',
        'IFNULL(GROUP_CONCAT(storeTags.tagName), "") AS tags'
      ])
      .innerJoin('user_locations', 'ul', `ST_DISTANCE_SPHERE(ul.latlng, store.latlng) <= 10000`)
      .leftJoin('store_tags', 'storeTags', 'storeTags.storeId = store.storeId')
      .limit(cursorPageOptionsCommand.take + 1)
      .where('ul.userId = :userId', { userId })
      .andWhere(`ul.isActivated = 1`)
      .andWhere(queryByCustomCursor, {
        customCursor: cursorPageOptionsCommand.customCursor,
      })
      .groupBy(`dist, store.storeId`)
      .orderBy(orderBy);

    const stores: StoreResponseModel[] = await storeQBuilder.getRawMany();

    return {
      stores,
    };
  } 

  private createCustomCursor(property: number, id: number): string {
    return String(property).padStart(8, '0') + String(id).padStart(8, '0');
  }
  
  private async paginateStoresByAttribute(
    userId: number,
    cursorPageOptionsCommand: CursorPageOptionsCommand,
    queryByCustomCursor: string,
    orderBy: {},
    customCursorAccessor: (store: any) => number,
  ): Promise<CursorPageResModel<StoreResponseModel>> {

    const takePerPage = cursorPageOptionsCommand.take;
    // cursorPageMeta를 미리 초기화합니다.
    let cursorPageMeta = new CursorPageMetaRes({ cursorPageOptionsCommand, hasNextData: false, customCursor: null });

    const { stores } = await this.getStores(
      userId,
      cursorPageOptionsCommand,
      queryByCustomCursor,
      orderBy
    );

    const responseStores = stores.slice(0, takePerPage);

    if (stores.length === 0) {
      return new CursorPageResModel<StoreResponseModel>([], cursorPageMeta);
    }
  
    const transformedStoresData: StoreResponseModel[] = responseStores.map(store => ({
      ...store,
      rating: parseFloat(store.rating),
    }));

    let hasNextData: boolean = true;

    const lastDataPerPage = transformedStoresData[transformedStoresData.length - 1];
    let customCursor = this.createCustomCursor(customCursorAccessor(lastDataPerPage), lastDataPerPage['storeId']);

    if (stores.length <= takePerPage) {
      customCursor = null;
      hasNextData = false;
    } 
    
    cursorPageMeta = new CursorPageMetaRes({ cursorPageOptionsCommand, hasNextData, customCursor });
    return new CursorPageResModel<StoreResponseModel>(transformedStoresData, cursorPageMeta);
  }
  
  /* 거리 순 */
  public async paginateStoresByDistance(userId: number, cursorPageOptionsCommand: CursorPageOptionsCommand): Promise<CursorPageResModel<StoreResponseModel>> {
    const queryByCustomCursor = 
      `(CONCAT(LPAD(FLOOR(ST_DISTANCE_SPHERE(store.latlng, ul.latlng)), 8, '0'), LPAD(store.storeId, 8, '0')) > :customCursor)`;
    
    const orderBy = {
      "dist": "ASC",
      "store.storeId": "ASC",
    };
    
    return this.paginateStoresByAttribute(
      userId,
      cursorPageOptionsCommand,
      queryByCustomCursor,
      orderBy,
      store => parseInt(store['dist']),
    );
  }
  
  /* 좋아요(찜) 순 */
  public async paginateStoresByLikeCount(userId: number, cursorPageOptionsCommand: CursorPageOptionsCommand): Promise<CursorPageResModel<StoreResponseModel>> {
    const queryByCustomCursor = 
      `(CONCAT(LPAD(store.likeCount, 8, '0'), LPAD(store.storeId, 8, '0')) < :customCursor)`;

    const orderBy = {
      "store.likeCount": "DESC",
      "store.storeId": "DESC",
    };
    
    return this.paginateStoresByAttribute(
      userId,
      cursorPageOptionsCommand,
      queryByCustomCursor,
      orderBy,
      store => store['likeCount'],
    );
  }
  
  /* 별점 순 */
  public async paginateStoresRating(userId: number, cursorPageOptionsCommand: CursorPageOptionsCommand): Promise<CursorPageResModel<StoreResponseModel>> {
    const queryByCustomCursor = 
      `(CONCAT(LPAD(CONVERT(rating, CHAR) * 10, 8, '0'), LPAD(store.storeId, 8, '0')) < :customCursor)`;

    const orderBy = {
      "store.rating": "DESC",
      "store.storeId": "DESC",
    };
    
    return this.paginateStoresByAttribute(
      userId,
      cursorPageOptionsCommand,
      queryByCustomCursor,
      orderBy,
      store => parseFloat(store['rating']) * 10,
    );
  }
  
  /* 리뷰 카운트 순 */
  public async paginateStoresReviewCount(userId: number, cursorPageOptionsCommand: CursorPageOptionsCommand): Promise<CursorPageResModel<StoreResponseModel>> {
    const queryByCustomCursor = 
      `(CONCAT(LPAD(store.reviewCount, 8, '0'), LPAD(store.storeId, 8, '0')) < :customCursor)`;

    const orderBy = {
      "store.reviewCount": "DESC",
      "store.storeId": "DESC",
    };
    
    return this.paginateStoresByAttribute(
      userId,
      cursorPageOptionsCommand,
      queryByCustomCursor,
      orderBy,
      store => store['reviewCount'],
    );
  }
}


🧃 Service & Controller

> Service & UseCase

// store.usecase.ts
export const StoreUseCaseSymbol = Symbol('StoreUseCase_Token');

export interface StoreUseCase {
  paginateStoresByAttribute(userId: number, cursorPageOptionsCommand: CursorPageOptionsCommand, attributeName: string): Promise<CursorPageResModel<StoreResponseModel>>;
}


// store.service.ts
export class StoreService implements StoreUseCase {
  constructor(
    private readonly storeRepository: StoreDrivenPort,
  ) {}

  private async findStoresByDistance(userId: number, cursorPageOptionsCommand: CursorPageOptionsCommand): Promise<CursorPageResModel<StoreResponseModel>> {
    return await this.storeRepository.paginateStoresByDistance(userId, cursorPageOptionsCommand);
  }

  private async findStoresByLikeCount(userId: number, cursorPageOptionsCommand: CursorPageOptionsCommand): Promise<CursorPageResModel<StoreResponseModel>> {
    return await this.storeRepository.paginateStoresByLikeCount(userId, cursorPageOptionsCommand);
  }

  private async findStoresByRating(userId: number, cursorPageOptionsCommand: CursorPageOptionsCommand): Promise<CursorPageResModel<StoreResponseModel>> {
    return await this.storeRepository.paginateStoresRating(userId, cursorPageOptionsCommand);
  }

  private async findStoresByReviewCount(userId: number, cursorPageOptionsCommand: CursorPageOptionsCommand): Promise<CursorPageResModel<StoreResponseModel>> {
    return await this.storeRepository.paginateStoresReviewCount(userId, cursorPageOptionsCommand);
  }
  
  // 해당 함수만이 컨트롤러에서 호출되는 유스케이스 메서드의 구현체이다.
  public async paginateStoresByAttribute(userId: number, cursorPageOptionsCommand: CursorPageOptionsCommand, attributeName: string): Promise<CursorPageResModel<StoreResponseModel>> {
    switch (attributeName) {
      case 'distance':
        return this.findStoresByDistance(userId, cursorPageOptionsCommand);
      case 'likeCount':
        return this.findStoresByLikeCount(userId, cursorPageOptionsCommand);
      case 'rating':
        return this.findStoresByRating(userId, cursorPageOptionsCommand);
      case 'reviewCount':
        return this.findStoresByReviewCount(userId, cursorPageOptionsCommand);
      default:
        return null;
    }
  }
}

정렬 기준에 따른 각 함수를 유스 케이스로 둘 수도 있지만 정렬 속성에 따른 "동일한" 페이지네이션을 수행한다고 판단하였고 이에 따라 프리젠테이션으로 전달해 줄 유스케이스는 "하나"paginateStoresByAttribute()로 두었다.


> QueryKey Validation

일전 응답 객체에서 각 쿼리 키의 벨류에 대한 검증을 직접 데코레이터를 생성해 취해준 것을 알 것이다. (customCursor의 value는 16자리의 숫자로 된 스트링 값인지, take의 value는 number인지)

이번에는 커스텀 파이프를 생성해 쿼리 키 자체의 유효성 검증을 취해준다. 만약 허용된 키 이름 이외의 값이 요청으로 들어오면 정해준 에러를 내뱉게끔 한다.

// custom-cursor-queryKey.pipe.ts
import { ArgumentMetadata, Injectable, PipeTransform } from "@nestjs/common";
import { PaginationQueryKeyInvalidException } from "../exception-handle/exception-classes/bad_request_exception/custom_cursor.exception";

@Injectable()
export class CursorPagingReqQueryKeyPipe implements PipeTransform {
  private readonly allowedKeys = ['customCursor', 'take']; // 허용된 키 이름 배열

  transform(value: any, metadata: ArgumentMetadata) {
    if (metadata.type === 'query') {
      const actualKeys = Object.keys(value);

      // 요청의 키 이름이 허용된 키 이름 배열에 포함되는지 검사
      const invalidKeys = actualKeys.filter(key => !this.allowedKeys.includes(key));
      if (invalidKeys.length > 0) {
        throw new PaginationQueryKeyInvalidException();
      }
      
      return value;
    }

    return value;
  }
}

> Controller

// store.controller.ts

@SkipThrottle()
@ApiTags('Stores')
@ApiBearerAuth('access-token')
@UseGuards(JwtAccessAuthGuard)
@UsePipes(new CursorPagingReqQueryKeyPipe())
@Controller('stores/paginate')
export class StoreController {
  constructor(
    @Inject(StoreUseCaseSymbol)
    private readonly storeUseCase: StoreUseCase,
  ) {}

  private async getStoresByAttribute(
    attributeName: string,
    cursorPageOptionsDto: CursorPageOptionsDto,
    request: ExtendedRequest,
  ): Promise<CursorPageResModel<StoreResponseModel>> {
    const userId: number = request.userId;
    const cursorPageOptionsCommand: CursorPageOptionsCommand = PaginateOptionsDtoToCommandMapper.mapToCommand(cursorPageOptionsDto);

    if (!cursorPageOptionsCommand.customCursor) {
      if (request.path === '/stores/paginateStoreByDistance') {
        cursorPageOptionsCommand.customCursor = DefaultCustomCursorValueBuilder.toDefaultValue(8, 8, "0");
      } else {
        cursorPageOptionsCommand.customCursor = DefaultCustomCursorValueBuilder.toDefaultValue(8, 8, "9");
      }
    }

    return this.storeUseCase.paginateStoresByAttribute(userId, cursorPageOptionsCommand, attributeName);
  }
  
  @Get('storeByDistance')
  @ApiOperation({
    summary: '거리 순 가게 페이지네이션',
    description: '유저 위치 4000m 내외 가게 중 거리 순에 따른 가게 목록 조회'
  })
  @ApiCursorPaginatedResponse(StoreResponseModel)
  async paginateStoreByDistance(
    @Query() cursorPageOptionsDto: CursorPageOptionsDto,
    @Req() request: ExtendedRequest
  ): Promise<CursorPageResModel<StoreResponseModel>> {
    return this.getStoresByAttribute('distance', cursorPageOptionsDto, request);
  }

  @Get('storeByLikeCount')
  @ApiOperation({
    summary: '찜 순 가게 페이지네이션',
    description: '유저 위치 4000m 내외 가게 중 찜 순에 따른 가게 목록 조회'
  })
  @ApiCursorPaginatedResponse(StoreResponseModel)
  async paginateStoreByLikeCount(
    @Query() cursorPageOptionsDto: CursorPageOptionsDto,
    @Req() request: ExtendedRequest
  ): Promise<CursorPageResModel<StoreResponseModel>> {
    return this.getStoresByAttribute('likeCount', cursorPageOptionsDto, request);
  }

  @Get('storeByRating')
  @ApiOperation({
    summary: '별점 순 가게 페이지네이션',
    description: '유저 위치 4000m 내외 가게 중 별점 순에 따른 가게 목록 조회'
  })
  @ApiCursorPaginatedResponse(StoreResponseModel)
  async paginateStoreByRating(
    @Query() cursorPageOptionsDto: CursorPageOptionsDto,
    @Req() request: ExtendedRequest
  ): Promise<CursorPageResModel<StoreResponseModel>> {
    return this.getStoresByAttribute('rating', cursorPageOptionsDto, request);
  }

  @Get('storeByReviewCount')
  @ApiOperation({
    summary: '리뷰 순 가게 페이지네이션',
    description: '유저 위치 4000m 내외 가게 중 리뷰 순에 따른 가게 목록 조회'
  })
  @ApiCursorPaginatedResponse(StoreResponseModel)
  async paginateStoreByReviewCount(
    @Query() cursorPageOptionsDto: CursorPageOptionsDto,
    @Req() request: ExtendedRequest
  ): Promise<CursorPageResModel<StoreResponseModel>> {
    return this.getStoresByAttribute('reviewCount', cursorPageOptionsDto, request);
  }
}

아래 부분을 주목해 보자.

    if (!cursorPageOptionsCommand.customCursor) {
      if (request.path === '/stores/paginate/storeByDistance') {
        cursorPageOptionsCommand.customCursor = DefaultCustomCursorValueBuilder.toDefaultValue(8, 8, "0");
      } else {
        cursorPageOptionsCommand.customCursor = DefaultCustomCursorValueBuilder.toDefaultValue(8, 8, "9");
      }
    }

request.path를 통해 접근한 요청 api가 만약 "거리 순" api일 경우 / 혹은 그 나머지(별점 순, 좋아요 순, 리뷰 카운트 순) api일 경우, 고정 디폴트로 가지게 되는 커스텀 커서 값이 다르게 생성되도록 설정하였다.

참고로 고정(default) 커스텀 커서 값의 생성은 아래의 빌더 클래스에서 생성 된다.

export class DefaultCustomCursorValueBuilder {
  static toDefaultValue(digitByTargetColumn: number, digitById: number, initialValue: string) {
    const defaultCustomCursor: string =  String().padStart(digitByTargetColumn, `${initialValue}`) + String().padStart(digitById, `${initialValue}`);
    return defaultCustomCursor;
  }
}

"거리 순"의 경우는 "가까운 순서대로"이다. 반대로 "나머지""많은 혹은 큰 순서대로"이다.

클라이언트 입장에선 가장 처음의 요청 상황에 (물론 협의를 통해 클라이언트도 지정해줘도 무방하다) 커스텀 커서 값을 알 수 없기 때문에 서버에선 고정 디폴트 커서 값을 자동 적용케끔 하였다.

이에 따라 "거리 순"의 경우 16자리의 숫자 값으로 된 스트링 중 가장 작은 값"0000000000000000"을, "나머지"의 경우 가장 큰 값"9999999999999999"를 적용해주도록 하였다.


🥤 동작 확인 (with Postman)

거리 순 (dist)


좋아요 순 (likecount)


별점 순 (rating)


리뷰 카운트 순 (reviewCount)


🧃 생각정리

어쩌다 보니 긴 글이 된 점에 읽으실 분들께 심심치않은 사과를 먼저 드려본다.

(두 포스팅으로 나눌 수도 있었지만 글의 통일성이 깨질거 같아 하나로 통합하였습니다. 긴 글 읽어주심에 감사드립니다)

이번 글은 NestJS를 사용했지만 NestJS가 중심이 아닌 오로지 "무한 스크롤을 구현하기 위해 서버에선 어떤 제스처를 취해야 하는가, 어떤 설계가 필요한가"에 초점을 맞추었던 것 같다.

아쉬운 점도 참 많았다. 클라이언트 측에서 테스트 해 본 결과 성능 측면에선 만족을 하였지만, 갠 적으로 코드레벨에서의 아쉬움이 없지 않아 있다. 대부분의 로직이 어댑터 클래스에서 수행이 되었고 일부 데이터 액세스와 비교적 거리가 있는 로직을 서비스 레이어로 옮기는 것이 생각보다 쉽지 않았다. 오히려 레이어간 책임을 명확하게 하려고 시도 하다 보니 불필요한 과정이 수반되게 되었고 더욱 가독성을 저해할 뿐이었다.

이로 인해 서비스 레이어에선 싱크홀 안티 패턴이 발생하였지만, 사실 상 쿼리 로직이 전부인 해당 기능에선 꼭 이상하지만은 않을지도 모른다는 생각이 든다.

로 쿼리 레벨을 사용하는 만큼, 또한 커서값을 형성하는데 있어 모두 제 각기의 쿼리식을 수행하는 만큼 정말로 "공통 페이지네이션 모듈"을 생성하는 건 아직 나에겐 큰 어려움이었다. 어쩌면 굳이 공통 로직 클래스를 생성하는 행위가 오버헤드일지도 모르겠다.

profile
You better cool it off before you burn it out / 티스토리(Kotlin, Android): https://nemoo-dev.tistory.com

0개의 댓글