API 설계에서 페이지네이션은 대량의 데이터를 효율적으로 전송하고 관리하기 위한 핵심 요소이다. 페이지네이션을 통해 서버는 데이터를 작은 “페이지” 단위로 나누어 전달하여 성능, 사용자 경험, 리소스 사용을 모두 최적화할 수 있다.
페이지네이션의 필요성과 중요성#
페이지네이션이 필요한 주요 이유는 다음과 같다:
성능 최적화
대규모 데이터셋을 한 번에 전송하면 여러 문제가 발생한다:
- 서버 부하 증가: 대량의 레코드를 검색하고 직렬화하는 과정은 서버 리소스를 많이 소모한다.
- 네트워크 부하: 대용량 응답은 네트워크 대역폭을 많이 사용하며, 특히 모바일 환경에서 문제가 된다.
- 응답 지연: 큰 데이터셋을 처리하는 데 시간이 오래 걸려 사용자 경험이 저하된다.
- 메모리 사용량: 클라이언트와 서버 모두 대량의 데이터를 메모리에 로드해야 한다.
사용자 경험 향상
페이지네이션은 사용자 인터페이스와 경험을 개선한다:
- 점진적 로딩: 사용자는 전체 데이터셋이 로드될 때까지 기다릴 필요 없이 즉시 데이터를 볼 수 있다.
- UI 관리 용이성: 적절한 크기의 데이터 청크는 UI 렌더링과 상호작용을 더 효율적으로 만든다.
- 무한 스크롤, 로드 더 보기: 현대적인 UX 패턴에 적합하다.
리소스 효율성
페이지네이션은 여러 방면에서 리소스를 절약한다:
- 필요한 데이터만 전송: 사용자가 실제로 보거나 사용할 데이터만 전송한다.
- 서버 자원 보호: 서버의 CPU, 메모리, I/O 사용을 최적화한다.
- 데이터베이스 효율성: 데이터베이스 쿼리가 더 효율적으로 실행되고 인덱스를 더 잘 활용한다.
페이지네이션 설계 방법론#
API 페이지네이션에는 여러 설계 방법이 있으며, 각각 장단점이 있다.
가장 단순하고 널리 사용되는 방식으로, 건너뛸 항목 수(오프셋)와 페이지 크기를 지정한다.
구현 예시#
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+)은 데이터베이스 성능에 심각한 영향을 미칠 수 있다.
커서(일종의 포인터)를 사용하여 결과셋의 특정 위치를 표시한다. 일반적으로 커서는 정렬된 필드의 값(주로 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" }))
|
- 확장성: 데이터셋 크기에 관계없이 일관된 성능을 제공한다.
- 일관성: 페이지 간 이동 중 데이터가 변경되어도 중복이나 누락 없이 일관된 결과를 제공한다.
- 효율성: 데이터베이스가 인덱스를 효과적으로 활용할 수 있다.
- 방향성: 앞으로/뒤로 이동을 자연스럽게 지원한다.
- 구현 복잡성: 오프셋 기반보다 구현이 더 복잡하다.
- 임의 접근 제약: 특정 페이지로 직접 이동하기 어렵다(첫 페이지부터 순차적으로 이동해야 함).
- 투명성 부족: 커서 값은 일반적으로 불투명하며, 클라이언트가 내부 구조를 이해하기 어렵다.
커서 기반 페이지네이션의 변형으로, 인코딩된 커서 대신 실제 필드 값을 직접 사용한다.
구현 예시#
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
}
}
|
- 확장성: 커서 기반과 유사한 성능 특성을 가진다.
- 투명성: 페이지네이션 매개변수가 명확하고 이해하기 쉽다.
- 복합 정렬 지원: 여러 필드를 기준으로 정렬된 결과를 쉽게 페이지네이션할 수 있다.
- 복잡성: 필드 타입과 정렬 방향에 따라 다른 연산자 사용이 필요하다.
- 유일성 보장 필요: 페이지네이션 키는 유일한 값이거나, 유일하지 않을 경우 추가 조건이 필요하다.
페이지 번호와 페이지 크기를 지정하는 방식으로, 오프셋 기반의 변형이다.
구현 예시#
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
2
3
| -- PostgreSQL에서 추정 카운트
EXPLAIN SELECT * FROM articles;
-- 이 쿼리는 실행 계획과 함께 행 수 추정치를 제공합니다
|
비동기 카운트
페이지네이션된 결과를 먼저 반환하고, 백그라운드에서 전체 카운트를 계산할 수 있다.
증분 카운트
캐시된 카운트 값을 유지하고 데이터 변경 시 증분적으로 업데이트한다.
하이브리드 접근법#
다양한 페이지네이션 방식을 상황에 따라 조합할 수 있다:
첫 페이지 최적화
초기 페이지에는 오프셋 기반을, 깊은 페이지에는 커서 기반 페이지네이션을 사용한다.
가변 페이지 크기
데이터 특성에 따라 페이지 크기를 동적으로 조정한다:
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
을 사용하여 다음 페이지를 요청한다.
페이지네이션 관련 모범 사례와 가이드라인#
- 일반적인 모범 사례
- 합리적인 기본값 설정:
- 적절한 기본 페이지 크기 설정(10-50 항목)
- 기본 정렬 순서 지정
- 한계 설정:
- 최대 페이지 크기 제한(예: 100 또는 1000)
- 요청당 최대 항목 수 제한(DoS 방지)
- 일관성 유지:
- API 전체에서 동일한 페이지네이션 규칙 적용
- 명확하고 일관된 매개변수 이름 사용
- 응답 형식:
- 데이터와 페이지네이션 메타데이터 명확히 구분
- 다음/이전 페이지 링크 또는 토큰 항상 포함
- 성능 관련 모범 사례
- 데이터베이스 최적화:
- 페이지네이션 쿼리에 사용되는 필드에 인덱스 생성
- 큰 오프셋 사용 시 커서 기반 방식 고려
- 캐싱 전략:
- 페이지네이션된 응답 캐싱
- 캐시 키에 페이지네이션 매개변수 포함
- 필요한 필드만 선택:
- 클라이언트가 필요한 필드만 지정할 수 있도록 허용
- 특히 대용량 데이터에서 중요
- 사용자 경험 개선 가이드라인
- 진행 상황 표시:
- 총 페이지 수 또는 항목 수 제공
- 현재 위치 표시(예: “573개 중 21-30 표시 중”)
- 유연한 페이지 크기:
- 클라이언트가 페이지 크기를 선택할 수 있도록 허용
- 모바일/데스크톱 환경에 맞게 최적화
- 정렬 옵션 제공:
- 다양한 필드로 정렬 지원
- 오름차순/내림차순 정렬 방향 지원
- 문서화 가이드라인
- 명확한 페이지네이션 설명:
- 사용된 페이지네이션 메커니즘 설명
- 매개변수 및 응답 형식 문서화
- 예제 포함:
- 요청 및 응답 예제 제공
- 일반적인 시나리오 설명
- 제한 사항 명시:
- 최대 페이지 크기
- 성능 관련 고려사항
- 깊은 페이지 탐색 시 권장사항
페이지네이션 구현 예시#
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)
|
페이지네이션의 도전과제와 해결책#
대규모 데이터셋 문제#
대규모 데이터셋에서 페이지네이션은 다양한 도전과제를 제시한다:
문제: 오프셋 기반 페이지네이션의 성능 저하#
깊은 페이지로 이동할수록 쿼리 성능이 저하된다.
해결책:
- 커서 기반 페이지네이션 채택: 데이터셋 크기에 관계없이 일관된 성능
- 복합 인덱스 활용: 정렬 필드에 대한 효율적인 인덱스 생성
- 클러스터링 인덱스 활용: 주요 정렬 필드를 기준으로 물리적 데이터 구성
코드 예시 (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;
|
일관성 문제#
페이지네이션 도중 데이터가 변경되면 항목이 중복되거나 누락될 수 있다.
해결책:
- 커서 기반 페이지네이션: 데이터 변경에도 일관된 결과 제공
- 유일한 정렬 키 사용: 정렬 필드가 유일하지 않은 경우 보조 필드(예: ID) 추가
- 스냅샷 격리 수준: 트랜잭션 격리 수준을 높여 쿼리 중 일관성 유지
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
4
5
| # 기존 API 계속 지원
GET /api/v1/articles?offset=20&limit=10
# 새로운 커서 기반 API 도입
GET /api/v2/articles?cursor=eyJpZCI6MjB9&limit=10
|
필터링 및 정렬과의 상호작용#
복잡한 필터링이나 정렬이 있는 경우 페이지네이션이 복잡해질 수 있다.
해결책:
- 복합 커서 설계: 모든 필터 및 정렬 매개변수를 커서에 포함
- 일관된 정렬 보장: 항상 기본 정렬 필드를 포함하여 일관성 유지
- 상태 보존: 필터 상태를 페이지네이션 매개변수와 함께 유지
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를 통한 실시간 업데이트와 페이지네이션의 통합이 증가하고 있다.
구현 접근법#
- 초기 페이지 로드: 일반적인 페이지네이션 API를 통해 초기 데이터 로드
- 실시간 업데이트: WebSocket을 통해 새 항목이나 변경사항 수신
- 클라이언트 측 병합: 현재 페이지에 영향을 미치는 업데이트 적용
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
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
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);
}
|
스트리밍 페이지네이션#
대용량 응답을 작은 청크로 스트리밍하여 페이지네이션 경험을 개선하는 접근법이 등장하고 있다.
구현 접근법#
- HTTP 스트리밍: Transfer-Encoding: chunked를 사용한 점진적 응답
- 서버-푸시 스트리밍: Server-Sent Events를 통한 실시간 스트리밍
- 부분 응답 처리: 클라이언트가 데이터가 도착하는 대로 처리
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();
});
});
|
용어 정리#
참고 및 출처#