Pagination

API 설계에서 페이지네이션은 대량의 데이터를 효율적으로 전송하고 관리하기 위한 핵심 요소이다. 페이지네이션을 통해 서버는 데이터를 작은 “페이지” 단위로 나누어 전달하여 성능, 사용자 경험, 리소스 사용을 모두 최적화할 수 있다.

페이지네이션의 필요성과 중요성

페이지네이션이 필요한 주요 이유는 다음과 같다:

  1. 성능 최적화
    대규모 데이터셋을 한 번에 전송하면 여러 문제가 발생한다:

    • 서버 부하 증가: 대량의 레코드를 검색하고 직렬화하는 과정은 서버 리소스를 많이 소모한다.
    • 네트워크 부하: 대용량 응답은 네트워크 대역폭을 많이 사용하며, 특히 모바일 환경에서 문제가 된다.
    • 응답 지연: 큰 데이터셋을 처리하는 데 시간이 오래 걸려 사용자 경험이 저하된다.
    • 메모리 사용량: 클라이언트와 서버 모두 대량의 데이터를 메모리에 로드해야 한다.
  2. 사용자 경험 향상
    페이지네이션은 사용자 인터페이스와 경험을 개선한다:

    • 점진적 로딩: 사용자는 전체 데이터셋이 로드될 때까지 기다릴 필요 없이 즉시 데이터를 볼 수 있다.
    • UI 관리 용이성: 적절한 크기의 데이터 청크는 UI 렌더링과 상호작용을 더 효율적으로 만든다.
    • 무한 스크롤, 로드 더 보기: 현대적인 UX 패턴에 적합하다.
  3. 리소스 효율성
    페이지네이션은 여러 방면에서 리소스를 절약한다:

    • 필요한 데이터만 전송: 사용자가 실제로 보거나 사용할 데이터만 전송한다.
    • 서버 자원 보호: 서버의 CPU, 메모리, I/O 사용을 최적화한다.
    • 데이터베이스 효율성: 데이터베이스 쿼리가 더 효율적으로 실행되고 인덱스를 더 잘 활용한다.

페이지네이션 설계 방법론

API 페이지네이션에는 여러 설계 방법이 있으며, 각각 장단점이 있다.

오프셋 기반 페이지네이션(Offset-based Pagination)

가장 단순하고 널리 사용되는 방식으로, 건너뛸 항목 수(오프셋)와 페이지 크기를 지정한다.

구현 예시
1
GET /api/articles?offset=20&limit=10

여기서 offset=20은 처음 20개 항목을 건너뛰고, limit=10은 최대 10개 항목을 반환하라는 의미이다.

응답 예시
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
  "data": [
    { "id": 21, "title": "Article 21" },
    { "id": 22, "title": "Article 22" },
    ...
  ],
  "pagination": {
    "total": 573,
    "offset": 20,
    "limit": 10,
    "has_more": true
  }
}
장점
  • 구현 용이성: 대부분의 데이터베이스가 OFFSET/LIMIT을 지원한다.
  • 직관적 탐색: 특정 페이지로 직접 이동할 수 있다(예: 5페이지로 바로 이동).
  • 총 항목 수 제공: 전체 결과 수를 쉽게 계산하고 표시할 수 있다.
단점
  • 성능 저하: 데이터셋이 커질수록 성능이 저하된다. 오프셋이 클 경우, 데이터베이스는 여전히 오프셋 전의 모든 행을 스캔해야 한다.
  • 일관성 문제: 페이지 간 이동 중 데이터가 추가되거나 삭제되면 중복 또는 누락이 발생할 수 있다.
  • 대규모 오프셋 비효율성: 매우 큰 오프셋(예: 10,000+)은 데이터베이스 성능에 심각한 영향을 미칠 수 있다.

커서 기반 페이지네이션(Cursor-based Pagination)

커서(일종의 포인터)를 사용하여 결과셋의 특정 위치를 표시한다. 일반적으로 커서는 정렬된 필드의 값(주로 ID나 타임스탬프)을 기반으로 한다.

구현 예시
1
GET /api/articles?cursor=eyJpZCI6MjB9&limit=10

여기서 cursor는 이전 페이지의 마지막 항목을 가리키는 인코딩된 값으로, 다음 결과셋의 시작점을 나타낸다.

응답 예시
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
  "data": [
    { "id": 21, "title": "Article 21" },
    { "id": 22, "title": "Article 22" },
    ...
  ],
  "pagination": {
    "next_cursor": "eyJpZCI6MzB9",
    "prev_cursor": "eyJpZCI6MjB9",
    "limit": 10,
    "has_more": true
  }
}
커서 생성 방법

보통 커서는 다음과 같은 값의 Base64 인코딩이다:

1
2
3
4
5
// 단순 커서
cursor = base64_encode("30") // 마지막 항목의 ID

// 복합 커서
cursor = base64_encode(JSON.stringify({ id: 30, created_at: "2023-03-22T15:30:00Z" }))
장점
  • 확장성: 데이터셋 크기에 관계없이 일관된 성능을 제공한다.
  • 일관성: 페이지 간 이동 중 데이터가 변경되어도 중복이나 누락 없이 일관된 결과를 제공한다.
  • 효율성: 데이터베이스가 인덱스를 효과적으로 활용할 수 있다.
  • 방향성: 앞으로/뒤로 이동을 자연스럽게 지원한다.
단점
  • 구현 복잡성: 오프셋 기반보다 구현이 더 복잡하다.
  • 임의 접근 제약: 특정 페이지로 직접 이동하기 어렵다(첫 페이지부터 순차적으로 이동해야 함).
  • 투명성 부족: 커서 값은 일반적으로 불투명하며, 클라이언트가 내부 구조를 이해하기 어렵다.

키셋 기반 페이지네이션(Keyset-based Pagination)

커서 기반 페이지네이션의 변형으로, 인코딩된 커서 대신 실제 필드 값을 직접 사용한다.

구현 예시
1
GET /api/articles?created_at_lt=2023-03-22T15:30:00Z&limit=10

여기서 created_at_lt는 “생성 시간이 이 값보다 작은” 항목을 요청한다.

응답 예시
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
  "data": [
    { "id": 45, "title": "Article 45", "created_at": "2023-03-22T15:25:00Z" },
    { "id": 39, "title": "Article 39", "created_at": "2023-03-22T15:20:00Z" },
    ...
  ],
  "pagination": {
    "next_created_at": "2023-03-22T15:10:00Z",
    "limit": 10,
    "has_more": true
  }
}
장점
  • 확장성: 커서 기반과 유사한 성능 특성을 가진다.
  • 투명성: 페이지네이션 매개변수가 명확하고 이해하기 쉽다.
  • 복합 정렬 지원: 여러 필드를 기준으로 정렬된 결과를 쉽게 페이지네이션할 수 있다.
단점
  • 복잡성: 필드 타입과 정렬 방향에 따라 다른 연산자 사용이 필요하다.
  • 유일성 보장 필요: 페이지네이션 키는 유일한 값이거나, 유일하지 않을 경우 추가 조건이 필요하다.

페이지 번호 기반 페이지네이션(Page-based Pagination)

페이지 번호와 페이지 크기를 지정하는 방식으로, 오프셋 기반의 변형이다.

구현 예시
1
GET /api/articles?page=3&per_page=10

여기서 page=3은 3페이지를, per_page=10은 페이지당 10개 항목을 요청한다.

응답 예시
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
{
  "data": [
    { "id": 21, "title": "Article 21" },
    { "id": 22, "title": "Article 22" },
    ...
  ],
  "pagination": {
    "total_items": 573,
    "total_pages": 58,
    "current_page": 3,
    "per_page": 10,
    "has_next": true,
    "has_prev": true
  }
}
장점
  • 사용자 친화성: 전통적인 페이지 번호 UI와 자연스럽게 통합된다.
  • 구현 용이성: 오프셋 기반 페이지네이션을 약간 수정하여 구현할 수 있다.
  • 직관적인 탐색: 페이지 번호로 쉽게 탐색 가능하다.
단점
  • 오프셋과 동일한 성능 문제: 높은 페이지 번호에서 성능이 저하된다.
  • 일관성 문제: 데이터 변경 시 페이지 내용이 변할 수 있다.

고급 페이지네이션 기법 및 최적화

커서 기반 페이지네이션의 최적화

커서 기반 페이지네이션을 효과적으로 구현하는 방법:

복합 인덱스 활용
1
2
3
4
5
6
7
8
9
-- 복합 정렬을 위한 인덱스 예시
CREATE INDEX idx_articles_created_id ON articles (created_at DESC, id DESC);

-- 이 인덱스를 활용하는 쿼리
SELECT * FROM articles 
WHERE (created_at < :last_created_at) 
   OR (created_at = :last_created_at AND id < :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 10;

이 방식은 정렬 키가 중복될 경우에도 일관된 결과를 보장한다.

양방향 탐색 지원

다음 페이지와 이전 페이지 모두 지원하려면 방향에 따라 다른 비교 연산자를 사용해야 한다:

1
2
3
4
5
6
7
// 다음 페이지 (오름차순)
where = "created_at > :cursor_value";
order = "created_at ASC";

// 이전 페이지 (오름차순)
where = "created_at < :cursor_value";
order = "created_at DESC";

전체 항목 수 최적화

전체 항목 수를 계산하는 것은 대규모 데이터셋에서 비용이 많이 들 수 있다.

다음과 같은 최적화가 가능하다:

  1. 추정 카운트
    정확한 수 대신 대략적인 수를 제공하여 성능을 향상시킬 수 있다:

    1
    2
    3
    
    -- PostgreSQL에서 추정 카운트
    EXPLAIN SELECT * FROM articles;
    -- 이 쿼리는 실행 계획과 함께 행 수 추정치를 제공합니다
    
  2. 비동기 카운트
    페이지네이션된 결과를 먼저 반환하고, 백그라운드에서 전체 카운트를 계산할 수 있다.

  3. 증분 카운트
    캐시된 카운트 값을 유지하고 데이터 변경 시 증분적으로 업데이트한다.

하이브리드 접근법

다양한 페이지네이션 방식을 상황에 따라 조합할 수 있다:

  1. 첫 페이지 최적화
    초기 페이지에는 오프셋 기반을, 깊은 페이지에는 커서 기반 페이지네이션을 사용한다.

  2. 가변 페이지 크기
    데이터 특성에 따라 페이지 크기를 동적으로 조정한다:

    1
    
    GET /api/articles?limit=10&adaptive=true
    

    여기서 adaptive=true는 서버가 상황에 따라 반환하는 항목 수를 조정할 수 있음을 나타낸다.

다양한 API 형식에서의 페이지네이션 구현

REST API

REST API에서는 일반적으로 쿼리 파라미터를 통해 페이지네이션을 구현한다:

HTTP 헤더를 활용한 페이지네이션
1
2
3
4
5
6
HTTP/1.1 200 OK
Link: <https://api.example.com/articles?page=4>; rel="next",
      <https://api.example.com/articles?page=2>; rel="prev",
      <https://api.example.com/articles?page=1>; rel="first",
      <https://api.example.com/articles?page=10>; rel="last"
X-Total-Count: 573

이 방식은 GitHub API에서 사용하는 방식으로, 응답 헤더를 통해 페이지네이션 정보를 제공한다.

HAL(Hypertext Application Language) 형식
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
{
  "_embedded": {
    "articles": [
      { "id": 21, "title": "Article 21" },
      { "id": 22, "title": "Article 22" }
    ]
  },
  "_links": {
    "self": { "href": "/api/articles?page=3" },
    "next": { "href": "/api/articles?page=4" },
    "prev": { "href": "/api/articles?page=2" },
    "first": { "href": "/api/articles?page=1" },
    "last": { "href": "/api/articles?page=58" }
  },
  "page": {
    "size": 10,
    "totalElements": 573,
    "totalPages": 58,
    "number": 3
  }
}

이 방식은 HATEOAS 원칙을 따르며, 클라이언트가 링크를 통해 탐색할 수 있다.

GraphQL

GraphQL에서는 페이지네이션을 구현하기 위한 여러 패턴이 있다:

커넥션 패턴(Relay 스타일)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
{
  articlesConnection(first: 10, after: "cursor_here") {
    edges {
      node {
        id
        title
      }
      cursor
    }
    pageInfo {
      hasNextPage
      hasPreviousPage
      startCursor
      endCursor
    }
    totalCount
  }
}

이 패턴은 Facebook의 Relay 규격을 따르며, 커서 기반 페이지네이션에 최적화되어 있다.

오프셋 기반 접근법
1
2
3
4
5
6
7
{
  articles(offset: 20, limit: 10) {
    id
    title
  }
  articlesCount
}

이 방식은 간단하지만 대규모 데이터셋에서는 성능 문제가 있다.

gRPC

gRPC에서는 프로토콜 버퍼 정의를 통해 페이지네이션을 구현한다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
message ListArticlesRequest {
  string page_token = 1;
  int32 page_size = 2;
}

message ListArticlesResponse {
  repeated Article articles = 1;
  string next_page_token = 2;
  string prev_page_token = 3;
  int32 total_size = 4;
}

클라이언트는 page_token을 사용하여 다음 페이지를 요청한다.

페이지네이션 관련 모범 사례와 가이드라인

  • 일반적인 모범 사례
    1. 합리적인 기본값 설정:
      • 적절한 기본 페이지 크기 설정(10-50 항목)
      • 기본 정렬 순서 지정
    2. 한계 설정:
      • 최대 페이지 크기 제한(예: 100 또는 1000)
      • 요청당 최대 항목 수 제한(DoS 방지)
    3. 일관성 유지:
      • API 전체에서 동일한 페이지네이션 규칙 적용
      • 명확하고 일관된 매개변수 이름 사용
    4. 응답 형식:
      • 데이터와 페이지네이션 메타데이터 명확히 구분
      • 다음/이전 페이지 링크 또는 토큰 항상 포함
  • 성능 관련 모범 사례
    1. 데이터베이스 최적화:
      • 페이지네이션 쿼리에 사용되는 필드에 인덱스 생성
      • 큰 오프셋 사용 시 커서 기반 방식 고려
    2. 캐싱 전략:
      • 페이지네이션된 응답 캐싱
      • 캐시 키에 페이지네이션 매개변수 포함
    3. 필요한 필드만 선택:
      • 클라이언트가 필요한 필드만 지정할 수 있도록 허용
      • 특히 대용량 데이터에서 중요
  • 사용자 경험 개선 가이드라인
    1. 진행 상황 표시:
      • 총 페이지 수 또는 항목 수 제공
      • 현재 위치 표시(예: “573개 중 21-30 표시 중”)
    2. 유연한 페이지 크기:
      • 클라이언트가 페이지 크기를 선택할 수 있도록 허용
      • 모바일/데스크톱 환경에 맞게 최적화
    3. 정렬 옵션 제공:
      • 다양한 필드로 정렬 지원
      • 오름차순/내림차순 정렬 방향 지원
  • 문서화 가이드라인
    1. 명확한 페이지네이션 설명:
      • 사용된 페이지네이션 메커니즘 설명
      • 매개변수 및 응답 형식 문서화
    2. 예제 포함:
      • 요청 및 응답 예제 제공
      • 일반적인 시나리오 설명
    3. 제한 사항 명시:
      • 최대 페이지 크기
      • 성능 관련 고려사항
      • 깊은 페이지 탐색 시 권장사항

페이지네이션 구현 예시

Node.js/Express API(커서 기반)

 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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// 커서 기반 페이지네이션 구현 예시
const express = require('express');
const router = express.Router();

router.get('/articles', async (req, res) => {
  try {
    // 페이지네이션 매개변수 파싱
    const limit = Math.min(parseInt(req.query.limit) || 10, 100); // 최대 100 제한
    let cursor = null;
    
    if (req.query.cursor) {
      try {
        // Base64 디코딩 및 JSON 파싱
        const decodedCursor = Buffer.from(req.query.cursor, 'base64').toString('utf-8');
        cursor = JSON.parse(decodedCursor);
      } catch (e) {
        return res.status(400).json({ error: '잘못된 커서 형식' });
      }
    }
    
    // 쿼리 구성
    let query = db('articles')
      .select('*')
      .orderBy('created_at', 'desc')
      .orderBy('id', 'desc')
      .limit(limit + 1); // 다음 페이지 있는지 확인용 추가 항목
    
    // 커서가 있으면 조건 추가
    if (cursor) {
      query = query.where(function() {
        this.where('created_at', '<', cursor.created_at)
          .orWhere(function() {
            this.where('created_at', '=', cursor.created_at)
              .andWhere('id', '<', cursor.id);
          });
      });
    }
    
    // 쿼리 실행
    const articles = await query;
    
    // 다음 페이지 있는지 확인
    const hasMore = articles.length > limit;
    if (hasMore) {
      articles.pop(); // 추가 항목 제거
    }
    
    // 다음 커서 생성
    let nextCursor = null;
    if (hasMore && articles.length > 0) {
      const lastItem = articles[articles.length - 1];
      const cursorObj = {
        created_at: lastItem.created_at,
        id: lastItem.id
      };
      nextCursor = Buffer.from(JSON.stringify(cursorObj)).toString('base64');
    }
    
    // 응답 구성
    res.json({
      data: articles,
      pagination: {
        next_cursor: nextCursor,
        has_more: hasMore,
        limit
      }
    });
  } catch (error) {
    console.error('Error fetching articles:', error);
    res.status(500).json({ error: '서버 오류' });
  }
});

module.exports = router;

Spring Boot API(오프셋 기반)

 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
31
32
33
34
35
36
@RestController
@RequestMapping("/api/articles")
public class ArticleController {

    @Autowired
    private ArticleRepository articleRepository;
    
    @GetMapping
    public ResponseEntity<Map<String, Object>> getArticles(
            @RequestParam(defaultValue = "0") int offset,
            @RequestParam(defaultValue = "10") int limit) {
        
        // 최대 한계 적용
        limit = Math.min(limit, 100);
        
        // 페이지네이션된 결과 가져오기
        Page<Article> articlesPage = articleRepository.findAll(
                PageRequest.of(offset / limit, limit, Sort.by(Sort.Direction.DESC, "createdAt")));
        
        List<Article> articles = articlesPage.getContent();
        
        // 응답 구성
        Map<String, Object> response = new HashMap<>();
        response.put("data", articles);
        
        Map<String, Object> pagination = new HashMap<>();
        pagination.put("total", articlesPage.getTotalElements());
        pagination.put("offset", offset);
        pagination.put("limit", limit);
        pagination.put("has_more", offset + limit < articlesPage.getTotalElements());
        
        response.put("pagination", pagination);
        
        return ResponseEntity.ok(response);
    }
}

Django REST Framework API(커서 기반)

 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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
from rest_framework.decorators import api_view
from rest_framework.response import Response
from rest_framework import status
from django.core.paginator import Paginator
from .models import Article
from .serializers import ArticleSerializer
import base64
import json

@api_view(['GET'])
def article_list(request):
    try:
        # 페이지네이션 매개변수 파싱
        limit = min(int(request.query_params.get('limit', 10)), 100)
        cursor_param = request.query_params.get('cursor')
        
        # 기본 쿼리셋
        queryset = Article.objects.all().order_by('-created_at', '-id')
        
        # 커서가 있으면 디코딩 및 필터링
        if cursor_param:
            try:
                decoded = base64.b64decode(cursor_param).decode('utf-8')
                cursor = json.loads(decoded)
                
                # 복합 조건 (created_at, id)로 필터링
                created_at = cursor.get('created_at')
                id = cursor.get('id')
                
                queryset = queryset.filter(
                    models.Q(created_at__lt=created_at) |
                    models.Q(created_at=created_at, id__lt=id)
                )
            except Exception as e:
                return Response({'error': '잘못된 커서 형식'}, status=status.HTTP_400_BAD_REQUEST)
        
        # 쿼리 실행 (limit + 1로 다음 페이지 여부 확인)
        articles = list(queryset[:limit + 1])
        
        # 다음 페이지 있는지 확인
        has_more = len(articles) > limit
        if has_more:
            articles.pop()  # 추가 항목 제거
        
        # 다음 커서 생성
        next_cursor = None
        if has_more and articles:
            last_item = articles[-1]
            cursor_obj = {
                'created_at': last_item.created_at.isoformat(),
                'id': last_item.id
            }
            next_cursor = base64.b64encode(json.dumps(cursor_obj).encode('utf-8')).decode('utf-8')
        
        # 직렬화 및 응답 구성
        serializer = ArticleSerializer(articles, many=True)
        
        return Response({
            'data': serializer.data,
            'pagination': {
                'next_cursor': next_cursor,
                'has_more': has_more,
                'limit': limit
            }
        })
    
    except Exception as e:
        return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

페이지네이션의 도전과제와 해결책

대규모 데이터셋 문제

대규모 데이터셋에서 페이지네이션은 다양한 도전과제를 제시한다:

문제: 오프셋 기반 페이지네이션의 성능 저하

깊은 페이지로 이동할수록 쿼리 성능이 저하된다.

해결책:

  1. 커서 기반 페이지네이션 채택: 데이터셋 크기에 관계없이 일관된 성능
  2. 복합 인덱스 활용: 정렬 필드에 대한 효율적인 인덱스 생성
  3. 클러스터링 인덱스 활용: 주요 정렬 필드를 기준으로 물리적 데이터 구성
코드 예시 (MySQL 쿼리 최적화)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
-- 전통적인 오프셋 쿼리 (느림)
SELECT * FROM posts
ORDER BY created_at DESC
LIMIT 10 OFFSET 10000;

-- 커서 기반 접근법 (빠름)
SELECT * FROM posts
WHERE created_at < '2023-03-22 15:30:00'
ORDER BY created_at DESC
LIMIT 10;

일관성 문제

페이지네이션 도중 데이터가 변경되면 항목이 중복되거나 누락될 수 있다.

해결책:

  1. 커서 기반 페이지네이션: 데이터 변경에도 일관된 결과 제공
  2. 유일한 정렬 키 사용: 정렬 필드가 유일하지 않은 경우 보조 필드(예: ID) 추가
  3. 스냅샷 격리 수준: 트랜잭션 격리 수준을 높여 쿼리 중 일관성 유지
예시
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 복합 키로 일관성 유지
function getNextPage(lastCreatedAt, lastId, limit) {
  return db.posts
    .where((post) => {
      return (
        post.created_at < lastCreatedAt ||
        (post.created_at === lastCreatedAt && post.id < lastId)
      );
    })
    .orderBy(["created_at", "id"], ["desc", "desc"])
    .limit(limit);
}

백엔드 변경의 영향

API 업그레이드나 백엔드 변경이 페이지네이션에 미치는 영향을 관리해야 한다.

해결책:

  1. 불투명 커서 사용: 내부 구현 세부 사항을 숨겨 유연성 확보
  2. 버전 관리 적용: 페이지네이션 메커니즘 변경 시 명시적 버전 관리
  3. 하위 호환성 유지: 기존 페이지네이션 매개변수 지원 유지
버전 관리 예시
1
2
3
4
5
# 기존 API 계속 지원
GET /api/v1/articles?offset=20&limit=10

# 새로운 커서 기반 API 도입
GET /api/v2/articles?cursor=eyJpZCI6MjB9&limit=10

필터링 및 정렬과의 상호작용

복잡한 필터링이나 정렬이 있는 경우 페이지네이션이 복잡해질 수 있다.

해결책:

  1. 복합 커서 설계: 모든 필터 및 정렬 매개변수를 커서에 포함
  2. 일관된 정렬 보장: 항상 기본 정렬 필드를 포함하여 일관성 유지
  3. 상태 보존: 필터 상태를 페이지네이션 매개변수와 함께 유지
예시
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 필터와 정렬이 포함된 커서
const complexCursor = {
  lastValue: item.price,
  lastId: item.id,
  filters: {
    category: 'electronics',
    minPrice: 100
  },
  sort: {
    field: 'price',
    direction: 'desc'
  }
};

// Base64로 인코딩하여 클라이언트에 전달
const encodedCursor = btoa(JSON.stringify(complexCursor));

미래 트렌드와 발전 방향

실시간 페이지네이션

WebSocket이나 Server-Sent Events를 통한 실시간 업데이트와 페이지네이션의 통합이 증가하고 있다.

구현 접근법
  1. 초기 페이지 로드: 일반적인 페이지네이션 API를 통해 초기 데이터 로드
  2. 실시간 업데이트: WebSocket을 통해 새 항목이나 변경사항 수신
  3. 클라이언트 측 병합: 현재 페이지에 영향을 미치는 업데이트 적용
 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
// 초기 데이터 로드
async function loadInitialPage() {
  const response = await fetch('/api/messages?limit=20');
  const data = await response.json();
  renderMessages(data.messages);
  
  // 가장 최근 메시지 ID 저장
  latestMessageId = data.messages[0].id;
}

// WebSocket 연결 설정
const socket = new WebSocket('wss://example.com/messages');
socket.onmessage = (event) => {
  const newMessage = JSON.parse(event.data);
  
  // 새 메시지가 현재 페이지에 속하는지 확인
  if (newMessage.id > latestMessageId) {
    // 메시지 목록 맨 위에 추가
    prependMessage(newMessage);
    // 가장 오래된 메시지 제거하여 페이지 크기 유지
    removeOldestMessage();
    // 최신 ID 업데이트
    latestMessageId = newMessage.id;
  }
};

GraphQL과 선언적 페이지네이션

GraphQL은 클라이언트가 필요한 데이터와 페이지네이션 방식을 더 세밀하게 제어할 수 있게 해준다.

Relay 커넥션 스펙의 장점
  1. 표준화: 잘 정의된 페이지네이션 패턴 제공
  2. 효율성: 필요한 필드만 요청 가능
  3. 유연성: 클라이언트가 데이터 구조 정의
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# 양방향 페이지네이션 지원 쿼리
query GetArticles {
  articlesConnection(first: 10, after: "cursor1") {
    edges {
      node {
        id
        title
        # 클라이언트는 필요한 필드만 요청
        createdAt
      }
      cursor
    }
    pageInfo {
      hasNextPage
      hasPreviousPage
      startCursor
      endCursor
    }
  }
}

AI 기반 페이지네이션

머신러닝을 활용하여 사용자 패턴에 따라 페이지네이션을 최적화하는 방향으로 발전하고 있다.

가능한 구현
  1. 예측적 로딩: 사용자가 다음에 볼 가능성이 높은 데이터 미리 로드
  2. 개인화된 페이지 크기: 사용자 행동에 기반한 최적 페이지 크기 동적 조정
  3. 관심 기반 우선순위: 사용자 관심사에 따라 결과 순서 조정
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 사용자 행동 패턴에 기반한 적응형 페이지 크기
function getAdaptivePageSize(userId) {
  // 사용자의 과거 페이지 탐색 패턴 분석
  const userBehavior = getUserBehaviorMetrics(userId);
  
  // 스크롤 속도, 항목당 평균 체류 시간 등에 기반하여 페이지 크기 계산
  let optimalPageSize = BASE_PAGE_SIZE;
  
  if (userBehavior.scrollSpeed > THRESHOLD_FAST) {
    // 빠르게 스크롤하는 사용자에게는 더 많은 항목 제공
    optimalPageSize *= 1.5;
  } else if (userBehavior.averageDwellTime > THRESHOLD_ENGAGED) {
    // 각 항목을 오래 보는 사용자에게는 더 적은 항목 제공
    optimalPageSize *= 0.8;
  }
  
  return Math.min(Math.max(optimalPageSize, MIN_PAGE_SIZE), MAX_PAGE_SIZE);
}

스트리밍 페이지네이션

대용량 응답을 작은 청크로 스트리밍하여 페이지네이션 경험을 개선하는 접근법이 등장하고 있다.

구현 접근법
  1. HTTP 스트리밍: Transfer-Encoding: chunked를 사용한 점진적 응답
  2. 서버-푸시 스트리밍: Server-Sent Events를 통한 실시간 스트리밍
  3. 부분 응답 처리: 클라이언트가 데이터가 도착하는 대로 처리
 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
31
32
33
34
35
36
37
38
39
40
// 서버 측(Node.js) 스트리밍 구현
app.get('/api/stream-articles', (req, res) => {
  // 스트리밍 헤더 설정
  res.setHeader('Content-Type', 'application/json');
  res.setHeader('Transfer-Encoding', 'chunked');
  
  // 초기 응답 시작
  res.write('{"articles":[');
  
  let first = true;
  
  // 데이터베이스 쿼리 스트림 생성
  const stream = db.collection('articles')
    .find()
    .sort({created_at: -1})
    .stream();
  
  stream.on('data', (article) => {
    // 필요시 쉼표 추가
    if (!first) {
      res.write(',');
    } else {
      first = false;
    }
    
    // 항목 직렬화 및 전송
    res.write(JSON.stringify(article));
  });
  
  stream.on('end', () => {
    // 응답 완료
    res.write(']}');
    res.end();
  });
  
  // 클라이언트 연결 해제 처리
  req.on('close', () => {
    stream.destroy();
  });
});

용어 정리

용어설명

참고 및 출처