Drizzle | SQL Limit/Offset 페이지네이션
PostgreSQL
MySQL
SQLite

이 가이드는 Drizzle에서 limit/offset 페이지네이션을 구현하는 방법을 설명합니다:

index.ts
schema.ts
import { asc } from 'drizzle-orm';
import { users } from './schema';

const db = drizzle(...);

await db
  .select()
  .from(users)
  .orderBy(asc(users.id)) // order by is mandatory
  .limit(4) // the number of rows to return
  .offset(4); // the number of rows to skip
select * from users order by id asc limit 4 offset 4;
// 5-8 rows returned
[
  {
    id: 5,
    firstName: 'Beth',
    lastName: 'Davis',
    createdAt: 2024-03-11T20:51:46.787Z
  },
  {
    id: 6,
    firstName: 'Charlie',
    lastName: 'Miller',
    createdAt: 2024-03-11T21:15:46.787Z
  },
  {
    id: 7,
    firstName: 'Clara',
    lastName: 'Wilson',
    createdAt: 2024-03-11T21:33:46.787Z
  },
  {
    id: 8,
    firstName: 'David',
    lastName: 'Moore',
    createdAt: 2024-03-11T21:45:46.787Z
  }
]

Limit은 반환할 행의 개수(페이지 크기)이고, offset은 건너뛸 행의 개수((페이지 번호 - 1) * 페이지 크기)입니다. 일관된 페이지네이션을 위해서는 고유한 컬럼으로 정렬해야 합니다. 그렇지 않으면 결과가 일관성 없게 나올 수 있습니다.

고유하지 않은 컬럼으로 정렬해야 하는 경우, 정렬에 고유 컬럼을 추가해야 합니다.

다음은 2개의 컬럼으로 limit/offset 페이지네이션을 구현하는 방법입니다:

const getUsers = async (page = 1, pageSize = 3) => {
  await db
    .select()
    .from(users)
    .orderBy(asc(users.firstName), asc(users.id)) // order by first_name (non-unique), id (pk)
    .limit(pageSize) 
    .offset((page - 1) * pageSize);
}

await getUsers();

Drizzle은 limit/offset 페이지네이션을 쉽게 구현할 수 있는 유용한 관계형 쿼리 API를 제공합니다:

import * as schema from './db/schema';

const db = drizzle({ schema });

const getUsers = async (page = 1, pageSize = 3) => {
  await db.query.users.findMany({
    orderBy: (users, { asc }) => asc(users.id),
    limit: pageSize,
    offset: (page - 1) * pageSize,
  });
};

await getUsers();

Drizzle은 간단하고 유연한 API를 제공하여 쉽게 커스텀 솔루션을 만들 수 있습니다. 다음은 .$dynamic() 함수를 사용하여 페이지네이션용 커스텀 함수를 만드는 방법입니다:

import { SQL, asc } from 'drizzle-orm';
import { PgColumn, PgSelect } from 'drizzle-orm/pg-core';

function withPagination<T extends PgSelect>(
  qb: T,
  orderByColumn: PgColumn | SQL | SQL.Aliased,
  page = 1,
  pageSize = 3,
) {
  return qb
    .orderBy(orderByColumn)
    .limit(pageSize)
    .offset((page - 1) * pageSize);
}

const query = db.select().from(users); // query that you want to execute with pagination

await withPagination(query.$dynamic(), asc(users.id));

deferred join 기법을 사용하여 limit/offset 페이지네이션의 성능을 향상시킬 수 있습니다. 이 방법은 전체 테이블 대신 데이터의 하위 집합에서 페이지네이션을 수행합니다.

다음과 같이 구현할 수 있습니다:

const getUsers = async (page = 1, pageSize = 10) => {
   const sq = db
    .select({ id: users.id })
    .from(users)
    .orderBy(users.id)
    .limit(pageSize)
    .offset((page - 1) * pageSize)
    .as('subquery');

   await db.select().from(users).innerJoin(sq, eq(users.id, sq.id)).orderBy(users.id);
};

limit/offset 페이지네이션의 장점: 구현이 간단하고 페이지에 쉽게 접근할 수 있습니다. 이전 페이지의 상태를 저장하지 않고도 모든 페이지로 이동할 수 있습니다.

limit/offset 페이지네이션의 단점: offset이 증가하면 쿼리 성능이 저하됩니다. 데이터베이스가 offset 이전의 모든 행을 스캔한 후 건너뛰어야 하기 때문입니다. 또한 데이터 변경으로 인한 불일치가 발생할 수 있으며, 이로 인해 동일한 행이 다른 페이지에 반환되거나 행이 건너뛰어질 수 있습니다.

다음은 작동 방식입니다:

const getUsers = async (page = 1, pageSize = 3) => {
  await db
    .select()
    .from(users)
    .orderBy(asc(users.id))
    .limit(pageSize)
    .offset((page - 1) * pageSize);
};

// user is browsing the first page
await getUsers();
// results for the first page
[
  {
    id: 1,
    firstName: 'Alice',
    lastName: 'Johnson',
    createdAt: 2024-03-10T17:17:06.148Z
  },
  {
    id: 2,
    firstName: 'Alex',
    lastName: 'Smith',
    createdAt: 2024-03-10T17:19:06.147Z
  },
  {
    id: 3,
    firstName: 'Aaron',
    lastName: 'Williams',
    createdAt: 2024-03-10T17:22:06.147Z
  }
]
// while user is browsing the first page, a row with id 2 is deleted
await db.delete(users).where(eq(users.id, 2));

// user navigates to the second page
await getUsers(2);
// second page, row with id 3 was skipped
[
  {
    id: 5,
    firstName: 'Beth',
    lastName: 'Davis',
    createdAt: 2024-03-10T17:34:06.147Z
  },
  {
    id: 6,
    firstName: 'Charlie',
    lastName: 'Miller',
    createdAt: 2024-03-10T17:58:06.147Z
  },
  {
    id: 7,
    firstName: 'Clara',
    lastName: 'Wilson',
    createdAt: 2024-03-10T18:16:06.147Z
  }
]

따라서 데이터베이스에서 실시간으로 삽입 및 삭제 작업이 빈번하게 발생하거나 대용량 테이블을 페이지네이션할 때 높은 성능이 필요한 경우, 커서 기반 페이지네이션을 사용하는 것을 고려해야 합니다.

deferred join 기법에 대해 자세히 알아보려면 다음 가이드를 참고하세요: Planetscale Pagination Guide, Efficient Pagination Guide by Aaron Francis.