티스토리 뷰

무한 스크롤이란? 

무한 스크롤(Infinite Scroll)은 웹사이트나 앱에서 사용되는 스크롤링 기술로, 사용자가 웹페이지를 스크롤 하면 새로운 콘텐츠가 자동으로 동적으로 로드되는 방식이다. 기존의 페이지를 새로 고침 하거나 페이지 이동 버튼을 누르지 않고도 사용자들이 끊임없이 콘텐츠를 스크롤 하여 볼 수 있게 해주는 기술로 사용자가 페이지에 머무르는 시간을 증가시킨다.

 

 

나는 이번 nest.js 프로젝트에서 무한 스크롤 구현을 담당했다.

처음에는 무한 스크롤이라고 하니 무척 낯설고 어려워서 막막했는데 원리를 공부하고 직접 구현해 보니 생각보다 어렵지 않았다. 

아래는 내가 구현한 페이지에서 무한스크롤이 작동하고 있는 모습이다.

 

 

메이트 탐색 페이지에서 랜덤 메이트 탐색 스크롤 부분에서 무한 스크롤을 구현했다. 

무한 스크롤을 구현하기에 앞서서 Cursor-Based-Pagination과 Offset-Based-Pagination 두 가지 페이지네이션의 특징과 차이점을 짚고 넘어가자.

 

우선 오프셋 기반 페이지네이션은 아랫단에 네비게이션 UI를 통해 유저가 쉽게 원하는 위치의 데이터를 조회할 수 있다. 구글과 네이버 등 여러 포털 사이트의 검색 결과 조회에서 이러한 오프셋 기반 페이지네이션을 확인할 수 있다. 

오프셋 기반 페이지네이션을 사용하는 구글의 검색 조회 결과

하지만 요즘은 이러한 오프셋 기반 페이지네이션 대부분이 커서 기반 페이지네이션으로 대체되고 있는 추세이다. 실제 조사에 따르면 유저들이 오프셋 기반 페이지네이션에서 데이터를 조회할 경우, 상위 몇 페이지를 제외하곤 거의 조회하지 않는다고 한다. 

 

그에 반해 커서 기반 페이지네이션 즉 무한 스크롤은 유저가 어떤 페이지에서 몇 번째 데이터를 읽고 있는지 알 수 없다. 즉 유저는 끝없는 스크롤을 통해 자신이 원하는 데이터를 찾을 때까지 부담 없이 데이터를 조회할 수 있는 것이다. 

 

또한 커서 기반 페이지네이션은 데이터를 불러오는 방식에서 부담도 적다. 오프셋 기반으로 1만 건의 데이터 중 마지막 10개 데이터를 얻고자 한다면(다시 말해서 9991~10000번째 데이터), '9990 + 10'개의 데이터를 모두 읽어와야 한다.

반면에 커서 기반 방식은 요청받은 해당 데이터만 읽어오면 된다. 즉 10개만 읽어올 수 있는 것이다. 이는 분명한 성능의 이점으로, 대용량 데이터를 처리하는 데 있어서 분명한 성능 향상을 보일 것이다. 

 

그럼 이렇게 좋은 커서 기반 페이지네이션 어떻게 구현할 수 있을까?

 


 

NestJs, TypeORM에서 무한 스크롤 구현하기

 

1. 커서 기반 페이지네이션에 필요한 DTO

CursorPageDto.ts: 무한 스크롤 리턴값 (데이터는 보내줄 데이터, 메타는 커서 정보를 담는다.)

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

  readonly meta: CursorPageMetaDto;

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

 

 

CursorPageMetaDto.ts: CursorPageDto의 meta에 들어가는 커서 정보

export class CursorPageMetaDto {
  readonly total: number;
  readonly take: number;
  readonly hasNextData: boolean;
  readonly cursor: number;

  constructor({
    cursorPageOptionsDto,
    total,
    hasNextData,
    cursor,
  }: CursorPageMetaDtoParameters) {
    this.take = cursorPageOptionsDto.take;
    this.total = total;
    this.hasNextData = hasNextData;
    this.cursor = cursor;
  }
}

 

 

CursorPageOptionDto.ts: Request 파라미터로 받는 커서 데이터 정보 

export class CursorPageOptionsDto {
  @Type(() => String)
  @IsEnum(Order)
  @IsOptional()
  readonly sort?: Order = Order.DESC;

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

  @Type(() => Number)
  @IsOptional()
  readonly cursorId?: number = '' as any;
}

 

CursorPageMetaDtoParameters.ts: CursorPageMetaDto에서 implement하는 인터페이스

export interface CursorPageMetaDtoParameters {
  cursorPageOptionsDto: CursorPageOptionsDto;
  total: number;
  hasNextData: boolean;
  cursor: number;
}

 

Order 커서 페이지네이션 정렬 방식 지정

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

 


 

이제 커서 기반 페이지네이션 구현을 위한 모든 준비가 완료되었으니 구현을 해보자.

 

MateContoller.ts

@Controller('/mate')
export class MateController {
  constructor(private readonly mateService: MateService) {}

  @Get('/random') // 메이트 탐색 첫째 줄: 랜덤으로 메이트 추천
  @UseGuards(UserGuard)
  async getRandomMateProfileWithInfiniteCursor(
    @Req() req: Request,
    @Query() cursorPageOptionDto: CursorPageOptionsDto,
  ) {
    try {
      const result =
        await this.mateService.recommendRandomMateWithInfiniteScroll(
          cursorPageOptionDto,
          req.user.id,
        );

      return new ResponseDto(
        ResponseCode.GET_RANDOM_MATE_PROFILE_SUCCESS,
        true,
        '랜덤 메이트 추천 데이터 생성 성공',
        result,
      );
    } catch (e) {
      console.log(e);
      return new ResponseDto(
        ResponseCode.GET_RANDOM_MATE_PROFILE_FAIL,
        false,
        '랜덤 메이트 추천 데이터 생성 실패',
        null,
      );
    }
  }
}

 

API url:

[GET] {{SERVER_ADDRESS}}/api/v1/mate/random?take=3&cursorId=0

MateController에 있는 getRandomMateProfileWithInfiniteCursor 매서드는 위 url로 들어오는 api 요청을 처리한다. 여기서 파라미터로 받는 것이 CursorPageOptionDto이다. 여기서 커서 페이지네이션을 위해 필요한 정보를 파라미터로 보내줘야 한다. 우선 take는 한 번에 보낼 데이터의 개수이다. 즉 여기서는 3으로 지정했으니까 서버에서는 데이터를 세 개를 골라서 보내준다. 

중요한 것은 cursorId이다. 커서 기반 페이지네이션의 기준이 되는 것은 cursorId이다.

 

만약 ASC 오름차순으로 가져온다면 DB에서 기본키가 cursorID보다 큰 것 세 개를 가져오는 것이고, DESC 내림차순이면 DB에서 기본키가 cursorID보다 작은 것 세 개를 가져온다. (그러므로 기본키를 auto_increment, Long 타입으로 선언한 테이블에 대해서만 적용할 수 있다)

 

이 내용은 서비스 코드를 보면 더욱 확실히 이해할 수 있다. 

 

MateService.ts

@Injectable()
export class MateService {
  constructor(
    private readonly userService: UserService,
    private readonly s3Service: S3UtilService,
    private readonly signatureService: SignatureService,
  ) {}

  async recommendRandomMateWithInfiniteScroll(
    cursorPageOptionsDto: CursorPageOptionsDto,
    userId: number,
  ) {
    let cursorId = 0;

    // [0] 맨 처음 요청일 경우 랜덤 숫자 생성해서 cursorId에 할당
    if (cursorPageOptionsDto.cursorId == 0) {
      const newUser = await UserEntity.find({
        where: { isQuit: false }, // 탈퇴 필터링
        order: {
          id: 'DESC', // id를 내림차순으로 정렬해서 가장 최근에 가입한 유저 가져오기
        },
        take: 1,
      });
      const max = newUser[0].id + 1; // 랜덤 숫자 생성의 max 값
      console.log('max id: ', max);

      const min = 5; // 랜덤 숫자 생성의 min 값
      // TODO 사용자 늘어나면 min 값 늘리기
      cursorId = Math.floor(Math.random() * (max - min + 1)) + min;
      console.log('random cursor: ', cursorId);
    } else {
      cursorId = cursorPageOptionsDto.cursorId;
    }

    // [1] 무한 스크롤: take만큼 cursorId보다 id값이 작은 유저들 불러오기
    const [mates, total] = await UserEntity.findAndCount({
      take: cursorPageOptionsDto.take,
      where: {
        id: LessThan(cursorId),
        isQuit: false,
      },
      order: {
        id: 'DESC' as any,
      },
    });

    console.log('mates: ', mates);

    // [2] 가져온 메이트들 프로필 커버 만들기
    const mateProfiles: MateRecommendProfileDto[] = [];

    for (const mate of mates) {
      if (userId == mate.id) continue; // 본인은 제외
      const mateProfile = await this.generateMateProfile(mate, userId, null);
      mateProfiles.push(mateProfile);
    }

    // [3] 스크롤 설정
    let hasNextData = true;
    let cursor: number;

    const takePerScroll = cursorPageOptionsDto.take;
    const isLastScroll = total <= takePerScroll;
    const lastDataPerScroll = mates[mates.length - 1];

    if (isLastScroll) {
      hasNextData = false;
      cursor = null;
    } else {
      cursor = lastDataPerScroll.id;
    }

    const cursorPageMetaDto = new CursorPageMetaDto({
      cursorPageOptionsDto,
      total,
      hasNextData,
      cursor,
    });

    return new CursorPageDto(mateProfiles, cursorPageMetaDto);
  }
  
 }

 

메이트 랜덤 탐색의 로직은 다음과 같다.

[1] 맨 처음 요청의 경우 즉, 커서 아이디가 0인 경우, 가장 최신 유저의 기본키값이 최댓값 ~ 최솟값 5 사이의 랜덤 숫자를 생성한다. 

[2] 생성한 랜덤 숫자를 기본키로 갖는 유저부터 순서대로 '내림차순'으로 나열했을 때 유저 세 명을 뽑아서 보내준다.

[3] 다음 요청부터는 마지막으로 보내준 유저의 기본키를 커서 아이디로 설정해서 다음 요청을 처리한다. 

 

무한 스크롤로 유저를 가져오는 코드는 다음과 같다. 

// [1] 무한 스크롤: take만큼 cursorId보다 id값이 작은 유저들 불러오기
    const [mates, total] = await UserEntity.findAndCount({
      take: cursorPageOptionsDto.take,
      where: {
        id: LessThan(cursorId),
        isQuit: false,
      },
      order: {
        id: 'DESC' as any,
      },
    });

cursorPageOptionDto.take만큼 데이터를 가져온다. 가져오는 기준은 id 즉 기본키가 cursorId보다 작은 유저들을 가져오는데 isQuit 즉 탈퇴한 유저는 제외한다. 이때, 내림차순으로 가져오도록 한다.

 

마지막으로 리턴할 커서 데이터를 갱신한다. 마지막 스크롤이라면 hasNextData = false, cursor = null 값이 담긴다. 다음 데이터가 남아있다면 cursor에 마지막 데이터의 아이디 값을 담아줘서 다음 커서 요청에서 사용할 수 있도록 갱신해서 보내준다. 

// [3] 스크롤 설정
    let hasNextData = true;
    let cursor: number;

    const takePerScroll = cursorPageOptionsDto.take;
    const isLastScroll = total <= takePerScroll;
    const lastDataPerScroll = mates[mates.length - 1];

    if (isLastScroll) {
      hasNextData = false;
      cursor = null;
    } else {
      cursor = lastDataPerScroll.id;
    }

    const cursorPageMetaDto = new CursorPageMetaDto({
      cursorPageOptionsDto,
      total,
      hasNextData,
      cursor,
    });

 

리턴값 예시

다음 요청이 있는 경우:

{
    "timestamp": "2024-02-09T12:13:45.477Z",
    "code": "OK",
    "success": true,
    "message": "랜덤 메이트 추천 데이터 생성 성공",
    "data": {
        "data": [
            {
                "_id": 4,
                "userName": "옌",
                "introduction": "안녕하세요 옌입니다.",
                "is_followed": false,
                "userImage": null,
                "signatures": []
            },
            {
                "_id": 3,
                "userName": "고구마",
                "introduction": "안녕하세요 따뜻한 호박 고구마입니다.",
                "is_followed": false,
                "userImage": null,
                "signatures": [
                    {
                        "_id": 22,
                        "title": "강남역근처",
                        "image": "https://hereyou-cdn.kaaang.dev/signature/07f8c65c-5966-4b2e-9d96-08d5d084d013.png"
                    }
                ]
            },
            {
                "_id": 2,
                "userName": "콜라",
                "introduction": "안녕하세요 여행하는 콜라입니다.",
                "is_followed": false,
                "userImage": null,
                "signatures": []
            }
        ],
        "meta": {
            "take": "3",
            "total": 4,
            "hasNextData": true,
            "cursor": 2
        }
    }
}

요청이 있는 경우는 맨 아래에 meta 부분에서 hasNextData를 확인할 수 있고, 다음 요청에서는 meta.cursor 값을 다음 cursorId로 갱신해서 보내줘야한다.

 

 

마지막 요청인 경우:

{
    "timestamp": "2024-02-09T12:24:34.388Z",
    "code": "OK",
    "success": true,
    "message": "랜덤 메이트 추천 데이터 생성 성공",
    "data": {
        "data": [
            {
                "_id": 1,
                "userName": "써니",
                "introduction": "인생을 여행하고 있습니다.",
                "is_followed": false,
                "userImage": null,
                "signatures": [
                    {
                        "_id": 21,
                        "title": "신사역 근처 카페 모음",
                        "image": "https://hereyou-cdn.kaaang.dev/signature/4ae1e7c7-b01b-42aa-94ca-d9a84146f4af.png"
                    },
                    {
                        "_id": 20,
                        "title": "신사역 근처 카페 모음",
                        "image": "https://hereyou-cdn.kaaang.dev/signature/979d6cdb-605f-4abd-904b-a4a414262f8d.png"
                    }
                ]
            }
        ],
        "meta": {
            "take": "3",
            "total": 1,
            "hasNextData": false,
            "cursor": null
        }
    }
}

 

 

 

예제의 전체 코드는 아래 깃허브 레파지토리에서 확인할 수 있습니다.

 

GitHub - Here-You/here-you-backend: 여행의 이유: Here You - backend server

여행의 이유: Here You - backend server. Contribute to Here-You/here-you-backend development by creating an account on GitHub.

github.com

 

참고 문헌:

 

[NestJS] Cursor-Based-Pagination에 다가가기 #1 (feat. 커서 기반 페이징의 특징과 Nest에서 구현해보기)

시작하기에 앞서 지난번에 "Typeorm을 통해 nest에서 페이지네이션을 어떻게 구현하는가"에 관해 글을 작성해보았다. (해당 글 아래 링크 참조) Pagination with offset-based (벨로그 포스팅) 위 글에서 소

velog.io

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
글 보관함