[NestJS] Cursor-Based-Pagination에 다가가기 #2 (feat. 유니크키가 아닌 컬럼을 커서로 둔다면 ?)

DatQueue·2023년 2월 28일
2

NestJS _TIL

목록 보기
10/12
post-thumbnail

시작하기에 앞서


이전 포스팅 💨 여기 클릭!!!


"Cursor-Based-Pagination에 다가가기" 중 두 번째 파트이다.

이전 포스팅에서 우린 "커서 기반의 페이지네이션"은 무엇이고, 오프셋 기반의 페이지네이션과 어떤 차이가 있고, 어떻게 구현할 수 있는가에 대해 알아보았다.

세부적으론 "인덱스"에 따른 쿼리 성능에 관한 이슈, 클라이언트단과 어떻게 소통하는지, 또한 무한스크롤을 구현시엔 기존과 데이터를 응답해주는데에있어 어떠한 차이를 둘 수 있는지를 직접 구현해보며 진행하였다.

지난 포스팅을 보았다면 우린 "PK"이자 "유니크 키"로 지정한 id값에 대한 커서 기반 페이지네이션을 구현했다는 것을 알 것이다.

하지만 만약, 커서값으로 지닐 컬럼이 "유니크 키"가 아니라면??

해당 문제에 대해 이번 포스팅에서 진행해보고자 한다.


💥 Cursor-Based에서 발생하는 문제점과 Custom-Cursor 생성


> Cursor로 쓰일 컬럼이 Unique하지 않을 경우

우리가 여태껏 구현한 예시에선 커서값으로 "pk"로 지정한 id값을 사용하였다. 클러스터형 인덱스를 타는 iddesc 정렬을 통해 커서 기반의 페이지네이션을 구현하였다. (생성된 최신순의 데이터를 나타내기 위해 desc정렬을 하였다. 일반적으로는 createdAt과 같은 컬럼을 따로 생성하여 정렬하는것이 바람직하다.)

하지만, 클라이언트 측해서 요구하는게 항상 "최신순" 정렬이지는 않을 것이다. 만약, price(가격) 을 커서값으로 하여 페이지네이션 응답을 요구할 경우 어떻게 될까?

포스팅에서 따로 보여주진 않았지만 39999건의 데이터 중 price값은 10~110까지의 임의의 랜덤 숫자로 구성되어있다.
즉, 전체 데이터 중 중복되는 price값을 가진 데이터는 수십 혹은 수백개가 될 수도 있다.

이처럼 중복을 허용하는 컬럼 (price)은 Unique 키가 아니다. 앞서 id 정렬의 경우엔 id값은 Primary 키로써 Unique 키였다.

Unique 키가 아닌 컬럼의 경우 인덱스를 타고 있지 않기 때문에 인덱스를 타게끔 Unique 하게 만들어줄 수도 있다. 하지만 price와 같이 중복된 경우의 컬럼에 유니크를 준다는 것은 모순이다.

일단 위와 같이 price를 통해 정렬해 커서-기반 페이지네이션을 구현하면 어떤 문제가 발생하는지 알아보자.


✔ 정확하지 않은 커서값으로 인한 데이터 누락

-- cursor
select * from admin.products
where products.price > 10
order by products.price ASC
limit 5;

위와 같은 쿼리문을 던진다고 하자. 한 페이지당 5개의 데이터를 불러오게 되며 10 보다 큰 price값을 지닌 데이터를 불러오는 방식이다.

위와 같이 10보다 큰 price값인 11을 가지는 5개의 데이터가 불러와졌다. 그러면 이제 커서값을 통해 다음 페이지를 불러와야한다. 커서 값은 "현재 페이지의 마지막 데이터의 price값" 일 테니까 11을 주면 될 것이다.

where products.price > 11  (where 절 수정)

결과를 확인해보면 당연히 예상치 못한 응답을 받을 것이다.

바로 price=12를 가진 데이터로 넘어간다. price11을 가진 데이터가 처음 요청했던 5개 전부였을까?

당연히 아닐것이다. 보다 시피 count를 굳이 샐 필요도 없다.

즉, 이런 경우에 커서값 = 현재 페이지의 마지막 데이터의 특정 값이 오류를 발생시킨다. 처음 limit으로 불러온 데이터를 제외하고 나머지 모든 값을 누락시켜버리는 것이다.


> OR 연산자를 통해 문제를 해결해보자.

쿼리문에 OR 연산자를 추가하여 위 문제를 해결해보자. id 정렬, price 정렬 모두 ASC(오름차순) 정렬로 가정하고 진행한다.

select * from admin.products
where products.price > 10
order by products.price ASC, products.id ASC
limit 5;

위와 같이 현재 페이지의 마지막 데이터 커서값은 11임은 변함이 없다. 하지만 이때 우린 해당 마지막 데이터의 id 값을 가져옴으로써 기대하는 다음 데이터를 불러올 수 있다.

✔ 수정된 쿼리문 ( OR 추가 )

select * from admin.products
where 
	(products.price > 11 OR (price = 11 AND id > 247))
order by products.price ASC, products.id ASC
limit 5;

위의 테이블에서 조회한 데이터 중 마지막 데이터의 id247을 받아와 price=11이며 동시에 id > 247인 경우를 OR구문을 통해 Where절에 추가해주었다.

idDESC 정렬이고 priceASC정렬인 경우도 동일하게 적용하면 된다.


> OR 사용은 과연 좋은가?

크게 두 가지의 관점에서 해당 문제를 얘기할 수 있다.

번째는 "인덱스"에 관한 문제이다.

잠깐 우리가 앞서 id 값으로만 비교했을때의 쿼리를 분석해보자.

-- cursor
explain
select * from admin.products
where products.id < 100
order by products.id DESC
limit 5;

typekey를 보면된다. PRIMARY KEYid 비교만으로 정렬했을 경우에, type = range, possible_keys = PRIMARY, key = PRIMARY란 것을 알아볼 수 있다. 또한, FULL SCAN이 아닌 desc 설정으로 인한 Backward index scan인 것 또한 알 수 있다. ==> 인덱스를 타고 있다.


하지만, OR절을 추가한 다음 구문은 어떨까?

-- cursor
explain
select * from admin.products
where 
	(products.price > 11 OR (price = 11 AND id < 39402))
order by products.price ASC, products.id DESC
limit 5;

보다 시피 type = ALL 인 것을 확인할 수 있고 이것은 곧 Full-scan을 의미한다. 그에 따라, rows 의 수 또한 인덱싱을 전혀 타지 못하는 것으로 확인된다.
또한, possible_keysPRIMARY KEY 가 확인되지만 막상 key = null로 사용하지 못한단 것을 알려준다.

만약 OR절에 추가될 모든 필드(키)들이 "복합키"로 설정되어 있다면 얘기가 달라지지만 그렇지 않는 위의 경우엔 인덱스를 타지 않는 것이다.


번째는 "늘어가는 필드"에 관한 문제이다.

위의 경우엔 OR절에 쓰일 필드가 하나 추가된것이므로 크게 불편함을 못 느끼겠지만, 만약 페이지네이션을 구현하는데 있어 고려해야할 컬럼들이 점점 추가된다면 어떨까?

stackoverflow에서 재밌는 쿼리문을 찾아볼 수 있었다.

특정 패턴을 지키며 규칙적으로 작성되는 것 같지만, 얼핏 보기에도 지저분 해 보인다.

또한, 클라이언트 측에서 쿼리문으로 요청을 날릴 때, 요구되는 필드도 점점 늘어날 것이다. 만약, id, 와 price 두 가지에 따른 정렬만 하더라도, 메인 커서값으로 쓰일 price와 현재 페이지(스크롤)의 마지막 데이터의 id 값 이 두 개를 모두 보내줘야 한다. 서버단에서도 마찬가지로 모든 응답 시 마다 이 두 개를 항상 응답시켜야 한다.


> Custom - Cursor를 통한 해결

만약, Cursor-based 페이지네이션에서, 구현하기 위해 요구되는 필드가 몇 개가 추가되던 간에, 그 모든 걸 충족하는 하나의 컬럼이 정의된다면 어떨까?

위의 참고 자료로 걸어둔 좋은 글을 통해 "커스텀한 커서값"을 생성하는 방법에 대해 알게 되었다.

구현하고자 하는 상황은 동일하게 PRIMARY KEYid는 내림차순 정렬, price는 오름차순 정렬이다. id는 클러스터형 인덱스를 타는 키로써 고유한 값을 가지고 있다. 절대 중복될 일이 없다. 반면, price값은 유니크 키가 아니므로 얼마든지 중복이 허용된 값을 가진다. 이 상황에서 커스텀하게 유니크한 키를 만들려면 어떻게 하면 좋을까?

바로 두 컬럼의 값을 "결합"하면 된다.

MySQL 내장함수를 사용해보자. (CONCAT, LPAD)

select * ,
	concat(lpad(products.price, 7, '0'), lpad(products.id, 7, '0')) as 'cursor'
	from admin.products
    order by products.price ASC, products.id DESC
	limit 5;

concat은 사용하고 있는 자바스크립트에서도 제공하는 내장 메서드이기에 익숙하였지만 lpad는 생소하였다.

lpad 안의 첫 번째 인자를 두 번째 인자에서 지정해준 문자열 길이 만큼 받아주는데 가장 오른쪽으로 채운다. 동시에 세 번째 인자에서 지정해준 숫자가 나머지 빈 열을 채우게 되는 것이다. 한 번 결과를 확인해 보자.

concat을 통해 두 문자열을 결합하게 되는데 예를 들어 첫 번째 행을 각각을 분리시켜보면 "0000010(price=10)" + "0039999(id=39999)" 인 것을 확인할 수 있다. (숫자 => 문자열 자동 변환)

문자열의 길이를 7로 설정한 이유는 딱히 없다. 그냥 기댓값에 응하는 만큼 지정해주면 된다. 시행중인 더미데이터의 price 값은 10 ~ 110 범위이므로 3으로 설정하여도 상관은 없다.)

여기서 핵심은 id 값이 PK이자 UNIQUE이므로 유니크 하지 않은 price와 결합해도 UNIQUE를 띈다는 것이다. 절대 중복될 수 없는 고유의 문자열이 만들어진 것이다.


✔ 결과 테스트

select * ,
	concat(lpad(products.price, 7, '0'), lpad(products.id, 7, '0')) as "cursor"
	from admin.products
    where concat(lpad(products.price, 7, '0'), lpad(products.id, 7, '0')) < '00000100039480'
    order by products.price ASC, products.id DESC
	limit 5;

where절을 추가해주었다. 이 때, HAVING 절을 써 앞서 as로 나타낸 cursor를 사용하여 비교해줄 경우, 간결한 구문을 작성할 수는 있지만 인덱스를 타지 않게 된다... 이에 따라 where절에 컬럼을 생성하는데 사용하였던 concat() 함수 구문을 한번 더 추가해준다. 식을 한번 더 반복하여 지저분할순 있지만, 어짜피 우린 Typeorm을 이용하여 코드로써 표현할것이므로 상관없다.

where concat(lpad(products.price, 7, '0'), lpad(products.id, 7, '0')) < '00000100039480'

concat 문자열 구문과 비교할 값은 현재 페이지의 마지막 데이터의 커스텀 커서이다. 해당 값을 비교 구문에 커서값으로 날려준다. 결과를 확인해보자.

"커스텀 커서" 값 비교를 통한 정렬만으로도 우리가 원하는 데이터를 불러올 수 있게 되었다. 여기서 끝이 아니다. 제대로 인덱스를 탔는지 확인해봐야 한다.

그런데 왠일인지 인덱스를 타지 않는 것을 확인할 수 있었다.

사실, 예상치 못한 상황이었지만 where절에concat과 같은 함수를 사용하여 컬럼을 생성 시 해당 컬럼은 인덱스를 타지 못한다는 것을 알게 되었다.

이 문제에 관해선 사실 제대로 해결하지 못했다. 너무 파고들긴 지식 밖인거 같고 구현에 만족해야할지 쿼리의 성능까지 고려를 당장이라도 해야할지 고민을 가지게 되었다. 일단 추후에 해결하기로 하고, 다음으로 넘어가기로 하였다.

비록 확실한 방법과 확실한 성능을 도출해내진 못하였지만, 아직은 그것이 크게 중요한 것은 아니라 생각한다.

(어짜피 코드로써 생성할 것이므로 서버단에서 해결하는 방법을 모색하기로 하였다.)


price 값이 변하게 된다면?

위 쿼리문에서도 문제를 또 발견하였다. 계속해서 문제다... 문제... 문제... ㅠㅠ

price값을 오름차순 정렬, id값을 내림차순 정렬인 상황에서 위와 같은 결합식으로 구현 시 price값이 변할 시 문제가 일어난다. 한번 확인해 보자.


(캡쳐 사진이 좀 이상하긴 한데) 데이터가 3개만 불러져온 상황이다. 우리는 limit=5를 지정함으로써 다음 두 데이터는 price=11을 지닌 데이터중 id값이 가장 큰 두 데이터를 불러오길 기대했지만 그러지 못한 상황이다.

그럴 수 밖에 없다.

where concat(lpad(products.price, 7, '0'), lpad(products.id, 7, '0')) < 커서값

이런 식으로 커서값 보다 작은 값을 지닌 데이터를 조회하는데 아무리 id값이 작다하더라도 price가 단 1이라도 더 크면 해당 쿼리문에 부합하지 않기 때문이다. 그럼 이런 상황에는 어떻게 해야하는 걸까?

이 경우에 관해선 고민을 해보았지만, 일단 위처럼 id는 내림차순인 경우에서, price의 오름차순 정렬을 원할 시 기대하는 페이지네이션을 구현하긴 힘들다는 것을 깨달았다. 물론, sql차원에선 여러 서브쿼리를, 혹은 코드에선 여러 if문을 추가해줌으로써 price가 변하는 구간에서 특정 로직을 취해줄 수는 있을 것이다.

하지만, 위와 같이 코드를 짠다는 것은 실로 엄청난 고역일뿐더러 코드의 효율이 떨어지게 된다. 굳이 그렇게 해 줄 필요는 없다는 것이다. 항상 모든 경우에 "전천후"하게 대응할 수 있는 코드가 존재할 것이라고 생각했던 나로썬 "만능"의 코드란 꼭 존재하는게 아니란 것을 깨닫게 되는 시간이었다.

이에 채택한 방법은 price오름차순으로 구현하고 싶다면, id도 마찬가지로 오름차순으로 정렬시킨 후 커스텀 커서값을 구하는 것이다.

select * ,
	concat(lpad(products.price, 7, '0'), lpad(products.id, 7, '0')) as "cursor"
	from admin.products
    where concat(lpad(products.price, 7, '0'), lpad(products.id, 7, '0')) > '00000100039789'
    order by products.price ASC, products.id ASC  // 동일 정렬
	limit 5;

불러온 페이지의 데이터를 확인해 보면 아래와 같이 다음 price값을 자연스래 불러올 수 있다.

마찬가지로, price내림차순 하고 싶다하면, 즉 비싼 순으로 정렬시키고자 할 땐, id값을(혹은 다른 중복되지 않는 유니크 값을) 마찬가지로 내림차순 정렬해 그에 부합하는 커서값을 구하면 될 것이다.


💥 Nest + Typeorm으로 커스텀 커서를 구현해보자 !!

Cursor-Base-Pagination을 어떻게 구현하는지, 또 구현하고자 하는 필드가 유니크 키가 아닐 경우 어떤 문제가 생기는지, 그 문제를 해결하는데 있어 더불어 또 어떤 문제가 생기는지... 우린 연쇄적으로 많은 상황에 대해 알아보았다.

그 중 마지막에 알아본 Custom-Cursor를 통한 커서 기반의 페이지네이션을 코드로써 구현해보고자 한다.

상황은 위에서 진행한대로 "price의 정렬"로 한다.


> 구현해보기 (nest+typeorm)

✔ 커스텀 커서를 어떤 식으로 응답해주어야 할까?

처음엔 클라이언트에게 넘겨줄 커스텀 커서값을 엔터티 필드에 정의해야하나 생각했었다. 단순히 생각하여 앞서 sql로 나타내었을 때, 커스텀 커서값에 해당하는 필드를 컬럼으로 추가해주었기 때문이다.

하지만, 이 방법은 좋지 못하다 생각하였다. 일단 해당 필드가 꼭 "필수적인" 필드는 아닐 수 있기 때문이다. 엔터티에 정의되었단 것은 디비에 넘겨줄 꼭 필요한 항목이어야 할 것인데, 만약 클라이언트 측에서 해당 정보를 필요로 하지 않는다면 불 필요한 작업일 것이기 때문이다.
또한, price값을 오름차순 또는 내림차순으로 바꿀 때마다 그에 부합하는 커스텀 값 또한 바뀔 것이기 때문에 엔터티에 두는 것은 좋지 못하다 판단하였다.

일전과 마찬가지로 응답으로 던져줄 메타데이터에 담아 보내주는 방법을 사용하기로 하였다.


custom-cursor를 만들어보자

커스텀 커서를 만드는 로직과 페이징을 하는 로직, 이 두 구현부가 하나의 함수안에 정의되는 것은 재사용성, 효율적 측면에서 고려하였을때 좋지 않다고 판단하였다. 두 로직을 완전히 분리시킬 수 있을지는 모르겠지만 최대한 그러한 방향으로 구현하기로 하였다.

  async createCustomCursor(cursorIndex: number): Promise<string> {
    const products = await this.all();

    const customCursor = products.map((product) => {
      const id = product.id;
      const price = product.price;
      const customCursor: string = String(price).padStart(7,"0") + String(id).padStart(7,"0");
      return customCursor;
    });

    return customCursor[cursorIndex];
  }

우린 앞서 concat을 사용하여 products.priceproducts.id 값을 문자열로써 결합하였다. 이는 단지 숫자를 문자열로 변환 후 합쳐주기만 하면 되므로 String(products.price) + String(products.id)의 수식으로써 표현해주었다.

또한, 빈 공백을 특정 값으로 채워주게 되는 lpad의 경우엔 JS의 padStart()란 내장함수를 사용함으로써 구현해줄 수 있었다.

map()함수를 사용하여 불러온 전체 데이터 products에 대해 각 데이터 값의 커스텀 커서값(customCursor)을 구할 수 있고, 최종 createCustomCursor()함수가 리턴하는 값은 수 많은 데이터 건의 커스텀 커서값을 가진 배열 중 특정 인덱스(cursorIndex)를 가진 요소이다.

해당 인덱스(cursorIndex)는 파라미터로써 페이징 구현부의 "현재 페이지의 마지막 데이터 인덱스"를 받아올 것이다.


Find-options를 사용할 수 없다. ==> QueryBuilder 패턴으로써 진행

처음에 언급했다시피, 커스텀 커서로 구한 값을 엔터티 필드에 넣어주진 않기로 하였다. 하지만, 이럴 시 문제가 생겼다. 사실 문제라기보단 구현하는 방법에 있어 변화를 주어야하는 상황이 일어났다. 잠시 아래 코드를 보자.

    const [products, total] = await this.productRepository.findAndCount({
      take: cursorPageOptionsDto.take,
      where: cursorPageOptionsDto.cursorId ? {
        id: LessThan(cursorPageOptionsDto.cursorId),
      }: null,
      order: {
        price: cursorPageOptionsDto.sort.toUpperCase() as any,
      },
    });

typeorm의 find-options에서 위와 같이 where절, order절과 같은 FindOptions를 사용할 때, 프로퍼티로 쓸 수 있는 값은 오로지 "엔터티에 정의된" 값이어야 한다.

하지만, 우리의 커스텀 커서값은 응답으로만 보내줄 뿐 엔터티에 필드로써 선언되어있지 않다. 그렇기 때문에 본인 스스로 고수하였던 find-options를 사용할 수 없다고 판단하였다. 결국 QueryBuilder 패턴을 사용하기로 한다.


QueryBuilder를 통해 페이징 구현하기

async paginateByCustomCursor(customCursorPageOptionsDto: CustomCursorPageOptionsDto): Promise<CustomCursorPageDto<Product>> {
    const queryBuilder = this.productRepository.createQueryBuilder("products");

    const queryByPriceSort = (customCursorPageOptionsDto.sort === Order.ASC) 
      ? "CONCAT(LPAD(products.price, 7, '0'), LPAD(products.id, 7, '0')) > :customCursor" 
      : "CONCAT(LPAD(products.price, 7, '0'), LPAD(products.id, 7, '0')) < :customCursor"
    
    queryBuilder
      .take(customCursorPageOptionsDto.take)
      .where(queryByPriceSort, { customCursor: customCursorPageOptionsDto.customCursor })
      .orderBy({
        "products.price": customCursorPageOptionsDto.sort.toUpperCase() as any,
        "products.id": customCursorPageOptionsDto.sort.toUpperCase() as any,
      })
    
    const allProducts: Product[] = await this.all();
    const products: Product[] = await queryBuilder.getMany();
    const total: number = await queryBuilder.getCount();
    
    let hasNextData: boolean = true;
    let idByLastDataPerPage: number;
    let customCursor: string;

    const takePerPage = customCursorPageOptionsDto.take;
    const isLastPage = total <= takePerPage;
    const lastDataPerPage = products[products.length - 1];

    if (isLastPage) {
      hasNextData = false;
      idByLastDataPerPage = null;
      customCursor = null;
    } else {
      idByLastDataPerPage = lastDataPerPage.id;
      const lastDataPerPageIndexOf = allProducts.findIndex(data => data.id === idByLastDataPerPage);
      customCursor = await this.createCustomCursor(lastDataPerPageIndexOf);
    }
    
    const customCursorPageMetaDto = new CustomCursorPageMetaDto({ customCursorPageOptionsDto, total, hasNextData, customCursor });

    return new CustomCursorPageDto(products, customCursorPageMetaDto)
  }

더 좋은 리팩토링을 구현할 수 있지만 일단 구현의 목적으로 페이징을 구현하는 함수를 작성하였다.

그 전에 앞서 작성하였던 id정렬에 따른 커서-기반의 페이지네이션과 큰 틀에선 바뀜이없다. 단지, 값을 반환해주는데에 있어 createCustomCursor에서 생성한 커스텀 커서를 반환 후 메타데이터에 넘겨주게 된다.


const queryByPriceSort = (customCursorPageOptionsDto.sort === Order.ASC) 
	? "CONCAT(LPAD(products.price, 7, '0'), LPAD(products.id, 7, '0')) > :customCursor" 
	: "CONCAT(LPAD(products.price, 7, '0'), LPAD(products.id, 7, '0')) < :customCursor"

  queryBuilder
    .take(customCursorPageOptionsDto.take)
    .where(queryByPriceSort, { customCursor: customCursorPageOptionsDto.customCursor })
    .orderBy({
    "products.price": customCursorPageOptionsDto.sort.toUpperCase() as any,
    "products.id": customCursorPageOptionsDto.sort.toUpperCase() as any,
  })

여태껏 해왔던 것과는 다르게 이번엔 오름차순 정렬(sort=ASC), 내림차순 정렬(DESC) 이 두 가지 경우에 대해 모두 대응하는 페이징을 구현하고자 한다.
이에 따라, queryBuilder내의 where 절 내부 Raw Query에 하나의 경우에 대한 쿼리문을 작성해주는 것이 아닌, 오름차순일 경우와 내림차순일 경우의 쿼리문을 나누어 변수로 받아주도록 한다.

let customCursor: string;

if (isLastPage) {
  hasNextData = false;
  idByLastDataPerPage = null;
  customCursor = null;
} else {
  idByLastDataPerPage = lastDataPerPage.id;
  const lastDataPerPageIndexOf = allProducts.findIndex(data => data.id === idByLastDataPerPage);
  customCursor = await this.createCustomCursor(lastDataPerPageIndexOf);
}

또한, 위와 같이 customCursor를 선언한 뒤, 마지막 페이지일 경우 null을 반환하고, 마지막 페이지가 아닐 경우 앞서 만든 createCustomCursor()에서 생성한 커스텀 커서값에 현재 페이지 마지막 데이터의 인덱스를 의미하는 lastDataPerPageIndexOf값을 인자로 넣어준다.


✔ 커스텀 커서 모델 생성

// page.dto.ts

import { IsArray } from "class-validator";
import { CustomCursorPageMetaDto } from "./c-c-page-meta.dto";

export class CustomCursorPageDto<T> {
  @IsArray()
  readonly data: T[];
  readonly meta: CustomCursorPageMetaDto;

  constructor(data: T[], meta: CustomCursorPageMetaDto) {
    this.data = data;
    this.meta = meta;
  }
}
// page.meta.dto.ts

import { CustomCursorPageMetaDtoParameters } from "./c-c_meta-dto-parameter.interfafce";

export class CustomCursorPageMetaDto {
  readonly total: number;
  readonly take: number;
  readonly hasNextData: boolean;
  readonly customCursor: string;

  constructor({customCursorPageOptionsDto, total, hasNextData, customCursor}: CustomCursorPageMetaDtoParameters) {
    this.take = customCursorPageOptionsDto.take;
    this.total = total;
    this.hasNextData = hasNextData;
    this.customCursor = customCursor;
  }
}
// page-option.dto.ts

import { Type } from "class-transformer";
import { IsEnum, IsOptional } from "class-validator";
import { Order } from "./c_page-order.enum";

export class CustomCursorPageOptionsDto {

  @Type(() => String)
  @IsEnum(Order)
  @IsOptional()
  readonly sort?: Order = Order.ASC;

  @Type(() => Number)
  @IsOptional()
  readonly take?: number = 5;

  @Type(() => String)
  @IsOptional()
  customCursor?: string;
}
// page-meta-dto-parameter.interface.ts

import { CustomCursorPageOptionsDto } from "./c-c-page-options.dto";

export interface CustomCursorPageMetaDtoParameters {
  customCursorPageOptionsDto: CustomCursorPageOptionsDto;
  total: number;
  hasNextData: boolean;
  customCursor: string;
}
// page-order.enum.ts

export enum Order {
  ASC = "asc",
  DESC = "desc"
}

✔ 컨트롤러 구현

컨트롤러에서 직접적으로 쿼리의 파라미터에 접근할 수 있으므로, 페이지 정렬(sort=ASC or sort=DESC)에 따른 디폴트 값 정의에 관한 로직은 컨트롤러단에서 구현해주기로 하였다.

처음에 page-option.dto모델 단에서 구현할 수 있지 않을까 싶었지만 쿼리 인자로의 접근성 및 가독성 측면에서 컨트롤러에 구현하는 것이 바람직하다고 인지하게 되었다.

  @Get('customCursorPaginate')
  async paginateByCustomCursor(
    @Query() customCursorPageOptionsDto: CustomCursorPageOptionsDto
  ) {
    if(!customCursorPageOptionsDto.customCursor && customCursorPageOptionsDto.sort === Order.ASC) {
      customCursorPageOptionsDto.customCursor = "00000000000000";
    } else if (!customCursorPageOptionsDto.customCursor && customCursorPageOptionsDto.sort === Order.DESC) {
      customCursorPageOptionsDto.customCursor = "99999999999999";
    }
    return this.productService.paginateByCustomCursor(customCursorPageOptionsDto)
  }

만약 쿼리문에 특정 커스텀 커서값이 없으면서 동시에 오름차순 정렬일 경우, 디폴트 값을 "00000000000000", 동일하게 커스텀 커서값을 날려주지 않은 상태이며 내림차순 정렬일 경우 "99999999999999"를 받게 한다.

우리가 설정해준 7(price부분) + 7(id부분) 문자열이 가질 수 있는 가장 큰 값과 가장 작은 값이 생각하면 된다.

마찬가지로 이와 같은 접근 시, price가 30이상인 제품을 보여달라는 요청을 하게 되면 쿼리문에 "00000300000000"이란 값을 넘겨주게 될 것이고 서버에선 이에 맞는 데이터를 응답해 줄 것이다.

하지만, 컨트롤러단에 저렇게 직접적으로 특정 문자를 명시하는 것은 좋지 못하다.

조금 더 "재사용적 측면" 에서 고려해볼 필요가 있다. 현재는 7자리로 설정한 id부분과 price부분의 자릿 수는 언제든 필요에 따라 늘어날 수 있고, 설정한 초기값 문자(현재는 0)또한 필요에 따라 변경될 수 있다. 즉, 커스텀 커서의 규칙이 바뀌더라도 해당 부분을 인자로써 받아 편하게 설정할 수 있도록 함수로 구현하는 것이 바람직하다.

  • 인자로 사용할 매개변수 : id 자릿수, 대상 컬럼 자릿수, 초기 값 문자
// 서비스단에서 함수로써 구현

  createDefaultCustomCursorValue(digitById: number, digitByTargetColumn: number, initialValue: string) {
    const defaultCustomCursor: string =  String().padStart(digitByTargetColumn, `${initialValue}`) + String().padStart(digitById, `${initialValue}`);
    return defaultCustomCursor;
  }

// createDefaultCustomCursorValue 함수를 원하는 인자값을 받아 호출

  @Get('customCursorPaginate')
  async paginateByCustomCursor(
    @Query() customCursorPageOptionsDto: CustomCursorPageOptionsDto
  ): Promise<CustomCursorPageDto<Product>> {
    if(!customCursorPageOptionsDto.customCursor && customCursorPageOptionsDto.sort === Order.ASC) {
      customCursorPageOptionsDto.customCursor = this.productService.createDefaultCustomCursorValue(7, 7, "0");
    } else if (!customCursorPageOptionsDto.customCursor && customCursorPageOptionsDto.sort === Order.DESC) {
      customCursorPageOptionsDto.customCursor = this.productService.createDefaultCustomCursorValue(7, 7, "9");
    }
    return this.productService.paginateByCustomCursor(customCursorPageOptionsDto)
  }


마무리 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!



생각정리

아마 여태껏 써왔던 포스팅중에 가장 길게 작성한 글이 아닌가 싶다. 처음엔 하나의 포스팅에 전부 녹여내었지만, 여러 선생님들의 피드백을 얻어 가독성 있게 두 파트로 나누어 포스팅하게 되었다.

이전 포스팅에선 커서 기반의 페이지네이션의 정의 및 특징, 그리고 코드로써 어떻게 구현하는지 알아보았다면 이번 포스팅에선 "커서로 쓰일 컬럼이 유니크 키가 아니라면 어떻게 할까?" 에 대한 이슈를 알아봄과 동시에 해결해보았다 (사실 해결?은 아니다..).

그러한 과정에서 OR절, Custom-Cursor등과 같은 방법을 알아보았고 개선해 나갈 수 있었다. 더 좋은 로직을 짤 수 있었겠지만 솔직히 아직 능력 부족인 것은 과감히 받아들였다. 작성한 코드는 효율성 측면에서 부족하겠지만, 추후 로직을 작성하고 리펙토링을 하는데 있어 도움이 되는 시간이 아니었나 싶다.

오프셋 기반의 페이지네이션에 비해 더 좋은 페이징 방법이다, 혹은 무조건 성능적으로 좋다, 이런 취지로 쓴 글은 아니다. 인덱스를 타게되어 성능향상에 조금 더 다가갈 순 있겠지만, 커서-기반의 페이지네이션을 구현하는데에 있어 오프셋에 비해 생각해야할 이슈들이 더 많지 않았나 싶다.

뭐든지 "전천후"한 최고의 방법은 존재하지 않고, 최선을 만들어내는게 개발자의 몫인것 같고, 동시에 "trade-off"란 항상 존재할 수 있음또한 느끼게 되는 시간이었다.


💨 긴 글 읽어주셔서 감사합니다. (혹시나 계신다면...)



참고 자료 : 커서-기반 페이지네이션 구현하기 __minsangk.log

도움을 주신 분: 허재(Alen)님 (kakao mobility developer)

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

0개의 댓글