항목 두 개를 불러온 뒤 ‘다음’을 누르자 이전 페이지의 마지막 항목이 다시 나타납니다. 다른 때는 페이지를 넘기는 사이 항목 하나가 사라집니다. 탐색 중에도 바뀌는 작은 작업 목록을 생각해 봅시다. 행의 위치가 바뀌었을 때 ‘다음’이 무엇인지 코드가 어떻게 정하는지 살펴봐야 합니다.

필터, 정렬, 페이지 추출을 함께 읽기

목록은 createdAt 내림차순, 그다음 고유한 숫자 id 내림차순으로 정렬합니다. 예제에서는 작은 정수를 시간으로 사용합니다. open 항목 여섯 개의 순서는 6, 5, 4, 3, 2, 1이며, 5와 4의 시간은 모두 50입니다. 별도로 시간이 70인 done 항목 0이 있습니다.

읽을 부분 찾을 규칙
selected 한 페이지를 추출하기 전에 상태로 필터링
compare 최근 시간부터 정렬하고, 시간이 같으면 큰 ID부터 정렬
offsetPage / cursorPage 어떤 행이 다음 페이지에 포함되는지
호출 코드 nextCursor 저장, 유효한 결과 추가, 쿼리 변경 시 초기화

‘다음’ 동작에서 시작해 인수를 선택 함수까지 따라갑니다. 마지막 배열 자르기만 보면 그 위치를 결정한 필터와 정렬을 놓칩니다.

같은 offset이 다른 행을 가리키는 과정 보기

이 구현은 현재 조건에 맞는 행을 지정한 개수만큼 건너뜁니다.

const compare = (a, b) => b.createdAt - a.createdAt || b.id - a.id;
const selected = (rows, status) =>
  rows.filter((row) => row.status === status).sort(compare);

function offsetPage(rows, offset, take = 2, status = 'open') {
  return selected(rows, status).slice(offset, offset + take);
}

첫 페이지에는 6과 5가 들어 있습니다. 그 뒤 데이터를 한 번 바꾸고 offset = 2로 두 번째 페이지를 요청하면 결과가 달라집니다.

첫 페이지 이후 변경 현재 조건에 맞는 순서 두 번째 페이지 결과
없음 6, 5, 4, 3, 2, 1 4, 3 예상한 대로 이어짐
시간이 70인 7 삽입 7, 6, 5, 4, 3, 2, 1 5, 4 5 중복
6 삭제 5, 4, 3, 2, 1 3, 2 4 누락

표의 각 경우는 원래 데이터에서 다시 시작합니다. 건너뛸 개수는 그대로인데 행의 위치가 바뀐 것입니다. 화면에서 중복 ID를 제거하면 중복 표시를 숨길 수는 있지만 누락된 항목을 되찾을 수는 없습니다.

정렬 키 전체를 비교하기

커서는 마지막으로 반환한 항목의 키를 담습니다. createdAt < cursor.createdAt처럼 시간만 비교하면 5 다음의 4가 빠집니다. 두 항목 모두 시간이 50이기 때문입니다. 이를 <=로 바꾸면 이전 항목이 다시 포함됩니다.

(createdAt, id)의 두 값을 저장하고 정렬에 쓴 비교를 재사용합니다.

function cursorPage(rows, after = null, take = 2, status = 'open') {
  const items = selected(rows, status)
    .filter((row) => after === null || compare(row, after) > 0)
    .slice(0, take);
  const last = items.at(-1);
  return {
    items,
    nextCursor: last ? { createdAt: last.createdAt, id: last.id } : null,
  };
}

이 내림차순에서 compare(row, after) > 0은 행이 저장한 위치보다 엄밀히 뒤에 온다는 뜻입니다. 시간이 같으면 id로 순서를 정합니다. 앞서 본 삽입이나 삭제가 일어나도 다음 페이지는 4, 3입니다.

nextCursor 객체는 이전 행을 다시 조회하지 않고 키 값을 복사합니다. 따라서 5를 삭제해도 저장한 키부터 계속 읽을 수 있습니다. 빈 페이지는 null을 반환합니다. 이 예제는 목록이 끝났음을 확인하려고 요청을 한 번 더 보낼 수 있습니다.

변경 상황을 별도 테스트로 실행하기

pagination-reading.mjs를 다운로드하고 Node.js 22 이상에서 실행합니다.

node --test pagination-reading.mjs

테스트 10개가 모두 통과합니다. 이름이 reproduce:로 시작하는 세 개는 offset 중복, offset 누락, 시간만 비교하는 커서의 누락이라는 잘못된 결과를 의도적으로 확인합니다. 나머지 일곱 개는 앞쪽 삽입, 커서 앞의 행 삭제, 커서 원본 행 삭제, 같은 시간과 마지막 빈 페이지를 포함한 전체 순회, 뒤쪽에 삽입한 새 행, 개수 제한 전 필터링, 필터 초기화를 확인합니다.

각 테스트는 새 배열을 사용하며 호출 사이에 동기적으로 데이터를 바꿉니다. 서비스, 계정, 타이머, 패키지 설치가 필요하지 않습니다.

수정이 끝났다고 판단하기 전에 호출 코드 읽기

기존 커서를 유지한 채 필터를 done으로 바꾸면 예제에서는 빈 페이지가 나옵니다. 항목 0이 그 커서 앞에 정렬되기 때문입니다. 변경한 쿼리를 after = null로 시작하면 항목 0이 반환됩니다. 필터나 정렬이 바뀔 때 호출 코드는 쌓아 둔 행과 커서를 함께 초기화해야 합니다.

nextCursor를 어디에 저장하고 언제 다시 쓰는지도 확인하세요. 여기서 빈 응답 뒤의 null은 중단을 뜻합니다. 이를 그대로 함수에 전달하면 첫 페이지부터 다시 시작합니다. 반복 요청, 뒤바뀐 응답 순서, 같은 응답의 중복 추가도 살펴봅니다. 이런 화면 동작은 이 동기 예제의 범위 밖입니다. 오래된 응답은 비동기 선택 변경을 추적하는 예제에서 별도로 다룹니다.

데이터 변경을 사용자에게 어떻게 보여 줄지 정하기

커서는 스냅샷을 제공하지 않습니다. 첫 페이지를 읽은 뒤 시간이 49인 항목 8을 삽입하면 다음 페이지는 4, 8이 됩니다. 새 행이 저장한 위치 뒤에 정렬되므로 이미 진행 중인 순회에 나타납니다. 커서 앞에 삽입한 새 행은 새로고침을 해야 나타납니다.

여기서 연속 읽기를 확인한 전제는 ID가 고유하고 남아 있는 항목의 정렬 키가 바뀌지 않는다는 것입니다. 키 편집, 상태 변경, 아직 읽지 않은 항목 삭제에는 별도의 제품 규칙이 필요합니다. 내용을 고정한 내보내기는 별도의 스냅샷 방식이 필요할 수 있습니다. 이 배열 예제는 데이터베이스 격리, 인덱스, 전송, 커서 인코딩을 검증하지 않습니다. 매번 전체 필터링과 정렬을 하므로 대규모 데이터의 성능 설계로도 적합하지 않습니다.

자신의 구현을 읽을 때는 필터, 비교, 저장한 키, 호출 코드의 초기화를 나란히 놓고 확인하세요. Yenpo에서는 관련 파일을 열고 참조를 따라가며 각 단계의 코드를 확인할 수 있습니다.