REST API 페이지네이션 구현 방법 (단계별 가이드)

Mark Ponomarev

Mark Ponomarev

19 May 2025

REST API 페이지네이션 구현 방법 (단계별 가이드)

리소스 목록을 반환하는 REST API를 구축할 때, 대규모 데이터 세트를 처리하는 방법을 고려하는 것이 중요합니다. 단일 API 응답으로 수천 또는 수백만 개의 레코드를 반환하는 것은 비실용적이며, 서버와 클라이언트 모두에게 상당한 성능 문제, 높은 메모리 소비, 그리고 좋지 않은 사용자 경험을 초래할 수 있습니다. 페이지네이션(Pagination)은 이 문제에 대한 표준적인 해결책입니다. 이는 대규모 데이터 세트를 "페이지"라고 불리는 더 작고 관리 가능한 덩어리로 분할하여 순차적으로 제공하는 것을 포함합니다. 이 튜토리얼은 REST API에서 다양한 페이지네이션 전략을 구현하는 기술적인 단계를 안내합니다.

💡
아름다운 API 문서를 생성하는 훌륭한 API 테스트 도구를 원하시나요?

최대 생산성으로 개발 팀이 함께 작업할 수 있는 통합된 올인원 플랫폼을 원하시나요?

Apidog는 귀하의 모든 요구 사항을 충족하며, Postman을 훨씬 저렴한 가격으로 대체합니다!
button

페이지네이션이 필수적인 이유

구현 세부 사항에 들어가기 전에, 리소스 컬렉션을 다루는 API에서 페이지네이션이 왜 필수적인 기능인지 간략하게 살펴보겠습니다.

  1. 성능: 대량의 데이터를 요청하고 전송하는 것은 느릴 수 있습니다. 페이지네이션은 각 요청의 페이로드 크기를 줄여 응답 시간을 단축하고 서버 부하를 줄입니다.
  2. 리소스 소비: 작은 응답은 이를 생성하는 서버와 이를 파싱하는 클라이언트 모두에서 더 적은 메모리를 소비합니다. 이는 모바일 클라이언트나 리소스가 제한된 환경에서 특히 중요합니다.
  3. 속도 제한 및 할당량: 많은 API는 속도 제한을 적용합니다. 페이지네이션은 클라이언트가 한 번에 모든 데이터를 가져오려 하지 않고, 시간이 지남에 따라 더 작은 조각으로 데이터를 가져오도록 도와 이러한 제한 내에서 유지할 수 있게 합니다.
  4. 사용자 경험: API를 사용하는 UI의 경우, 데이터를 페이지로 제공하는 것이 거대한 목록이나 매우 긴 스크롤로 사용자를 압도하는 것보다 훨씬 사용자 친화적입니다.
  5. 데이터베이스 효율성: 전체 테이블을 검색하는 것에 비해 데이터의 하위 집합을 가져오는 것은 일반적으로 데이터베이스에 부담이 덜합니다. 특히 적절한 인덱싱이 적용된 경우 더욱 그렇습니다.

일반적인 페이지네이션 전략

페이지네이션을 구현하는 데에는 여러 가지 일반적인 전략이 있으며, 각기 장단점이 있습니다. 가장 인기 있는 전략인 오프셋/리밋(종종 페이지 기반이라고도 함)과 커서 기반(키셋 또는 시크 페이지네이션이라고도 함)을 살펴보겠습니다.

1. 오프셋/리밋 (또는 페이지 기반) 페이지네이션

이것은 가장 간단하고 널리 채택된 페이지네이션 방법이라고 할 수 있습니다. 클라이언트가 두 가지 주요 매개변수를 지정하도록 허용하여 작동합니다.

대안으로 클라이언트는 다음을 지정할 수 있습니다.

offsetpagepageSize를 사용하여 다음 공식으로 계산할 수 있습니다: offset = (page - 1) * pageSize.

기술 구현 단계:

항목 목록을 반환하는 API 엔드포인트 /items가 있다고 가정해 보겠습니다.

a. API 요청 매개변수:
클라이언트는 다음과 같이 요청할 수 있습니다:
GET /items?offset=20&limit=10 (처음 20개를 건너뛰고 10개의 항목 가져오기)
또는
GET /items?page=3&pageSize=10 (페이지당 10개 항목으로 3번째 페이지 가져오기, 이는 offset=20, limit=10과 동일합니다).

클라이언트가 이러한 매개변수를 제공하지 않는 경우 기본값을 설정하는 것이 좋습니다 (예: limit=20, offset=0 또는 page=1, pageSize=20). 또한 클라이언트가 과도하게 많은 레코드를 요청하여 서버에 부담을 주는 것을 방지하기 위해 최대 limit 또는 pageSize를 강제하는 것이 좋습니다.

b. 백엔드 로직 (개념):
서버가 이 요청을 받으면 이러한 매개변수를 데이터베이스 쿼리로 변환해야 합니다.

// Spring Boot를 사용한 Java 예제
@GetMapping("/items")
public ResponseEntity<PaginatedResponse<Item>> getItems(
    @RequestParam(defaultValue = "0") int offset,
    @RequestParam(defaultValue = "20") int limit
) {
    // 남용 방지를 위해 limit 유효성 검사
    if (limit > 100) {
        limit = 100; // 최대 limit 강제
    }

    List<Item> items = itemRepository.findItemsWithOffsetLimit(offset, limit);
    long totalItems = itemRepository.countTotalItems(); // 메타데이터용

    // 페이지네이션 응답 구성 및 반환
    // ...
}

c. 데이터베이스 쿼리 (SQL 예제):
대부분의 관계형 데이터베이스는 오프셋 및 리밋 절을 직접 지원합니다.

PostgreSQL 또는 MySQL의 경우:

SELECT *
FROM items
ORDER BY created_at DESC -- 일관된 순서는 안정적인 페이지네이션에 중요합니다
LIMIT 10 -- 이것이 'limit' 매개변수입니다
OFFSET 20; -- 이것이 'offset' 매개변수입니다

SQL Server의 경우 (이전 버전은 ROW_NUMBER()를 사용할 수 있습니다):

SELECT *
FROM items
ORDER BY created_at DESC
OFFSET 20 ROWS
FETCH NEXT 10 ROWS ONLY;

Oracle의 경우:

SELECT *
FROM (
    SELECT i.*, ROWNUM rnum
    FROM (
        SELECT *
        FROM items
        ORDER BY created_at DESC
    ) i
    WHERE ROWNUM <= 20 + 10 -- offset + limit
)
WHERE rnum > 20; -- offset

순서에 대한 중요 참고 사항: 오프셋/리밋 페이지네이션이 신뢰할 수 있으려면, 기본 데이터 세트는 일관되고 고유한 (또는 거의 고유한) 키 또는 키 조합으로 정렬되어야 합니다. 요청 간에 항목 순서가 변경될 수 있는 경우 (예: 새 항목이 삽입되거나 항목이 정렬 순서에 영향을 미치도록 업데이트되는 경우), 사용자는 페이지를 탐색할 때 중복 항목을 보거나 항목을 놓칠 수 있습니다. 일반적인 선택은 생성 타임스탬프 또는 기본 ID로 정렬하는 것입니다.

d. API 응답 구조:
좋은 페이지네이션 응답은 현재 페이지의 데이터뿐만 아니라 클라이언트가 탐색하는 데 도움이 되는 메타데이터도 포함해야 합니다.

{
  "data": [
    // 현재 페이지의 항목 배열
    { "id": "item_21", "name": "Item 21", ... },
    { "id": "item_22", "name": "Item 22", ... },
    // ... 'limit' 항목까지
    { "id": "item_30", "name": "Item 30", ... }
  ],
  "pagination": {
    "offset": 20,
    "limit": 10,
    "totalItems": 5000, // 사용 가능한 총 항목 수
    "totalPages": 500, // ceil(totalItems / limit)로 계산
    "currentPage": 3 // (offset / limit) + 1로 계산
  },
  "links": { // 탐색을 위한 HATEOAS 링크
    "self": "/items?offset=20&limit=10",
    "first": "/items?offset=0&limit=10",
    "prev": "/items?offset=10&limit=10", // 첫 페이지인 경우 null
    "next": "/items?offset=30&limit=10", // 마지막 페이지인 경우 null
    "last": "/items?offset=4990&limit=10"
  }
}

HATEOAS (Hypermedia as the Engine of Application State) 링크 (self, first, prev, next, last)를 제공하는 것은 REST 모범 사례입니다. 이를 통해 클라이언트는 URL을 직접 구성할 필요 없이 페이지를 탐색할 수 있습니다.

오프셋/리밋 페이지네이션의 장점:

오프셋/리밋 페이지네이션의 단점:

2. 커서 기반 (키셋/시크) 페이지네이션

커서 기반 페이지네이션은 오프셋/리밋의 일부 단점, 특히 대규모 데이터 세트에서의 성능 및 데이터 일관성 문제를 해결합니다. 절대 오프셋에 의존하는 대신, 데이터 세트의 특정 항목을 가리키는 "커서"를 사용합니다. 클라이언트는 이 커서 "이후" 또는 "이전" 항목을 요청합니다.

커서는 일반적으로 이전 페이지에서 검색된 마지막 항목의 정렬 키 값(들)을 인코딩하는 불투명한 문자열입니다.

기술 구현 단계:

a. API 요청 매개변수:
클라이언트는 다음과 같이 요청할 수 있습니다:
GET /items?limit=10 (첫 페이지의 경우)
그리고 이후 페이지의 경우:
GET /items?limit=10&after_cursor=opaquestringrepresentinglastitemid
또는 뒤로 페이지네이션하려면 (덜 일반적이지만 가능):
GET /items?limit=10&before_cursor=opaquestringrepresentingfirstitemid

limit 매개변수는 여전히 페이지 크기를 정의합니다.

b. 커서란 무엇인가?
커서는 다음과 같아야 합니다:

c. 백엔드 로직 (개념):

// Spring Boot를 사용한 Java 예제
@GetMapping("/items")
public ResponseEntity<CursorPaginatedResponse<Item>> getItems(
    @RequestParam(defaultValue = "20") int limit,
    @RequestParam(required = false) String afterCursor
) {
    // limit 유효성 검사
    if (limit > 100) {
        limit = 100;
    }

    // 커서를 디코딩하여 마지막으로 본 항목의 속성 가져오기
    // 예: LastSeenItemDetails lastSeen = decodeCursor(afterCursor);
    // afterCursor가 null이면 첫 페이지입니다.

    List<Item> items;
    if (afterCursor != null) {
        DecodedCursor decoded = decodeCursor(afterCursor); // 예: { lastId: "some_uuid", lastCreatedAt: "timestamp" }
        items = itemRepository.findItemsAfter(decoded.getLastCreatedAt(), decoded.getLastId(), limit);
    } else {
        items = itemRepository.findFirstPage(limit);
    }

    String nextCursor = null;
    if (!items.isEmpty() && items.size() == limit) {
        // 항목이 정렬되어 있다고 가정하면, 목록의 마지막 항목이 다음 커서를 생성하는 데 사용됩니다.
        Item lastItemOnPage = items.get(items.size() - 1);
        nextCursor = encodeCursor(lastItemOnPage.getCreatedAt(), lastItemOnPage.getId());
    }

    // 커서 페이지네이션 응답 구성 및 반환
    // ...
}

// 커서 인코딩/디코딩을 위한 헬퍼 메서드
// private DecodedCursor decodeCursor(String cursor) { ... }
// private String encodeCursor(Timestamp createdAt, String id) { ... }

d. 데이터베이스 쿼리 (SQL 예제):
핵심은 커서에서 가져온 정렬 키를 기반으로 레코드를 필터링하는 WHERE 절을 사용하는 것입니다. ORDER BY 절은 커서 구성과 일치해야 합니다.

created_at (내림차순)으로 정렬하고, created_at이 고유하지 않은 경우 안정적인 순서를 위해 id (내림차순)를 타이브레이커로 사용한다고 가정합니다.

첫 페이지의 경우:

SELECT *
FROM items
ORDER BY created_at DESC, id DESC
LIMIT 10;

커서가 last_created_at_from_cursorlast_id_from_cursor로 디코딩된 경우 이후 페이지의 경우:

SELECT *
FROM items
WHERE (created_at, id) < (CAST('last_created_at_from_cursor' AS TIMESTAMP), CAST('last_id_from_cursor' AS UUID)) -- 또는 적절한 타입
-- 오름차순의 경우 > 입니다
-- 튜플 비교 (created_at, id) < (val1, val2)는 다음을 간결하게 작성한 것입니다:
-- WHERE created_at < 'last_created_at_from_cursor'
--    OR (created_at = 'last_created_at_from_cursor' AND id < 'last_id_from_cursor')
ORDER BY created_at DESC, id DESC
LIMIT 10;

이러한 유형의 쿼리는 특히 (created_at, id)에 인덱스가 있는 경우 매우 효율적입니다. 데이터베이스는 관련 없는 행을 스캔하지 않고 시작점으로 직접 "시크"할 수 있습니다.

e. API 응답 구조:

{
  "data": [
    // 현재 페이지의 항목 배열
    { "id": "item_N", "createdAt": "2023-10-27T10:05:00Z", ... },
    // ... 'limit' 항목까지
    { "id": "item_M", "createdAt": "2023-10-27T10:00:00Z", ... }
  ],
  "pagination": {
    "limit": 10,
    "hasNextPage": true, // 더 많은 데이터가 있는지 여부를 나타내는 boolean
    "nextCursor": "base64encodedcursorstringforitem_M" // 불투명 문자열
    // 양방향 커서가 지원되는 경우 "prevCursor"도 포함될 수 있습니다.
  },
  "links": {
    "self": "/items?limit=10&after_cursor=current_request_cursor_if_any",
    "next": "/items?limit=10&after_cursor=base64encodedcursorstringforitem_M" // 다음 페이지가 없는 경우 null
  }
}

커서 기반 페이지네이션은 일반적으로 totalPages 또는 totalItems를 제공하지 않습니다. 왜냐하면 이들을 계산하려면 전체 테이블 스캔이 필요하여 일부 성능 이점을 상쇄하기 때문입니다. 이러한 정보가 반드시 필요한 경우, 별도의 엔드포인트나 추정치를 제공할 수 있습니다.

커서 기반 페이지네이션의 장점:

커서 기반 페이지네이션의 단점:

올바른 전략 선택

오프셋/리밋과 커서 기반 페이지네이션 중 어떤 것을 선택할지는 특정 요구 사항에 따라 달라집니다.

일부 시스템에서는 하이브리드 접근 방식을 사용하거나 다른 사용 사례 또는 엔드포인트에 대해 다른 전략을 제공하기도 합니다.

페이지네이션 구현을 위한 모범 사례

선택한 전략에 관계없이 다음 모범 사례를 준수하십시오.

  1. 일관된 매개변수 이름 지정: 페이지네이션 매개변수에 대해 명확하고 일관된 이름을 사용하십시오 (예: limit, offset, page, pageSize, after_cursor, before_cursor). API 전체에서 하나의 규칙 (예: camelCase 또는 snake_case)을 따르십시오.
  2. 탐색 링크 제공 (HATEOAS): 응답 예제에서 보여준 것처럼 self, next, prev, first, last (해당하는 경우) 링크를 포함하십시오. 이는 API를 더 쉽게 검색할 수 있게 하고 클라이언트와 URL 구성 로직을 분리합니다.
  3. 기본값 및 최대 제한:
  1. 명확한 API 문서: 페이지네이션 전략을 철저히 문서화하십시오:
  1. 일관된 정렬: 모든 페이지네이션 요청에 대해 기본 데이터가 일관되게 정렬되도록 하십시오. 오프셋/리밋의 경우 데이터 왜곡을 피하기 위해 필수적입니다. 커서 기반의 경우 정렬 순서가 커서 구성 및 해석 방식을 결정합니다. 기본 정렬 열에 중복 값이 있을 수 있는 경우 고유한 타이브레이커 열 (기본 ID 등)을 사용하십시오.
  2. 예외 상황 처리:
  1. 총 개수 고려 사항:
  1. 오류 처리: 오류에 대해 적절한 HTTP 상태 코드 (예: 잘못된 입력에는 400, 데이터 가져오는 중 서버 오류에는 500)를 반환하십시오.
  2. 보안: 페이지네이션 메커니즘 자체는 아니지만, 페이지네이션되는 데이터가 권한 규칙을 준수하는지 확인하십시오. 사용자는 자신이 볼 수 있도록 허가된 데이터만 페이지네이션할 수 있어야 합니다.
  3. 캐싱: 페이지네이션된 응답은 종종 캐시될 수 있습니다. 오프셋 기반 페이지네이션의 경우 GET /items?page=2&pageSize=10은 매우 캐시 가능합니다. 커서 기반의 경우 GET /items?limit=10&after_cursor=XYZ도 캐시 가능합니다. 페이지네이션 링크가 생성되고 사용되는 방식과 캐싱 전략이 잘 작동하는지 확인하십시오. 기본 데이터가 자주 변경되는 경우 무효화 전략을 고려해야 합니다.

고급 주제 (간략한 언급)

결론

페이지네이션을 올바르게 구현하는 것은 확장 가능하고 사용자 친화적인 REST API를 구축하는 데 필수적입니다. 오프셋/리밋 페이지네이션은 시작하기에 더 간단하지만, 커서 기반 페이지네이션은 크고 동적인 데이터 세트에 대해 우수한 성능과 일관성을 제공합니다. 각 전략의 기술적 세부 사항을 이해하고, 애플리케이션의 요구에 가장 적합한 전략을 선택하며, 구현 및 API 설계에 대한 모범 사례를 따르면, 규모에 관계없이 API가 클라이언트에게 데이터를 효율적으로 전달하도록 보장할 수 있습니다. API 소비자를 위한 원활한 경험을 제공하기 위해 항상 명확한 문서와 강력한 오류 처리를 우선시해야 함을 기억하십시오.


Apidog에서 API 설계-첫 번째 연습

API를 더 쉽게 구축하고 사용하는 방법을 발견하세요