대량의 데이터를 한 번에 보여주는 건 사용자 경험에도, 서버 성능에도 좋지 않다. 237개의 게시물이 있다면 한 페이지에 10개씩 나눠서 24페이지로 보여주는 게 훨씬 낫다. 이것이 페이지네이션(Pagination) 의 핵심이다.
이 글에서는 기본적인 오프셋 페이지네이션부터 시작해서, 그 한계를 극복하는 커서 기반 방식, 그리고 유튜브나 인스타그램에서 볼 수 있는 계층형 댓글 페이지네이션까지 알아보고자 한다.
가장 전통적이고 직관적인 방식이다. SQL Server 기준으로 OFFSET ... FETCH NEXT 구문을 사용한다.
SELECT *
FROM TblPost
ORDER BY _registerDate DESC
OFFSET @offset ROWS -- 건너뛸 행 수
FETCH NEXT @pageSize ROWS ONLY -- 가져올 행 수
DECLARE @offset INT = (@pageNo - 1) * @pageSize
-- 예시 (pageSize = 10)
-- pageNo=1 → OFFSET 0 → 1~10번 조회
-- pageNo=2 → OFFSET 10 → 11~20번 조회
-- pageNo=3 → OFFSET 20 → 21~30번 조회
계산 자체는 단순하다. (페이지 번호 - 1) × 페이지 크기 가 건너뛸 행 수가 된다.
페이지네이션 UI를 만들려면 전체 데이터 개수가 필요하다.
| 용도 | 계산 | 예시 |
|---|---|---|
| 총 페이지 수 | totalCount / pageSize | 237 / 10 = 24페이지 |
| 페이지 버튼 생성 | 1, 2, 3 ... N | [1] [2] [3] ... [24] |
| 정보 표시 | "N개 중 M개" | "237개 중 1~10개" |
| 마지막 페이지 판단 | 더보기 버튼 숨김 | if (currentPage >= totalPages) |
CREATE PROCEDURE spGetPostList
@pageNo INT = 1,
@pageSize INT = 10
AS
BEGIN
DECLARE @offset INT = (@pageNo - 1) * @pageSize
DECLARE @totalCount INT = 0
-- 1. TotalCount 조회
SELECT @totalCount = COUNT(*)
FROM TblPost
WHERE _isDeleted = 0
-- 2. 리스트 조회 (TotalCount 포함)
SELECT
_postId,
_title,
_content,
_registerDate,
@totalCount AS _totalCount -- 모든 행에 동일한 값
FROM TblPost
WHERE _isDeleted = 0
ORDER BY _registerDate DESC
OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY
END
TotalCount를 SELECT에 포함시키면 모든 행에 중복 데이터가 들어가지만, 코드가 간단해지는 장점이 있다. OUTPUT 파라미터로 분리하는 방식도 있으니 상황에 맞게 선택하면 된다.
{: .prompt-tip }
public class GetPostListResult
{
public int _postId { get; set; }
public string _title { get; set; }
public string _content { get; set; }
public DateTime _registerDate { get; set; }
public int _totalCount { get; set; }
}
// 조회
List<GetPostListResult> result = await QueryAsync<GetPostListResult>(...);
int totalCount = result.FirstOrDefault()?._totalCount ?? 0;
int totalPages = (int)Math.Ceiling((double)totalCount / pageSize);
[1] [2] [3] [4] [5] [다음] 같은 페이지 블록을 만드는 로직이다.
int blockSize = 5; // 한 번에 보여줄 페이지 수
int currentBlock = (currentPage - 1) / blockSize;
int startPage = currentBlock * blockSize + 1;
int endPage = Math.Min(startPage + blockSize - 1, totalPages);
// currentPage = 7, totalPages = 24일 때
// currentBlock = 1
// startPage = 6, endPage = 10
// 결과: [6] [7] [8] [9] [10]
오프셋 방식은 직관적이지만, 치명적인 단점 이 있다.
-- 10000페이지 조회 시
OFFSET 99990 ROWS FETCH NEXT 10 ROWS ONLY
이 쿼리가 실행되면 DB는 99,990개 행을 먼저 읽고 버린 다음 10개만 반환한다. 페이지가 뒤로 갈수록 점점 느려지는 구조다.
1페이지: 10개 읽음 → 빠름
100페이지: 1000개 읽음 → 조금 느림
10000페이지: 100000개 읽음 → 매우 느림
사용자가 1페이지를 보는 동안 새 글이 올라오면?
[상황]
1. 유저가 1페이지 조회 (1~10번 글)
2. 새 글이 추가됨 (새 글이 1번이 됨)
3. 유저가 2페이지 조회 (OFFSET 10)
[결과]
원래 10번이었던 글이 11번으로 밀림
→ 2페이지에서 다시 나타남 (중복!)
반대로 글이 삭제되면 특정 글을 아예 못 보고 넘어갈 수도 있다.
데이터 변경이 빈번한 시스템에서는 오프셋 페이지네이션이 적합하지 않을 수 있다.
{: .prompt-warning }
대용량 테이블에서 COUNT(*)는 상당히 무거운 연산이다. 실무에서는 다음과 같은 대안을 사용한다.
sys.dm_db_partition_stats 활용오프셋의 한계를 극복하는 방식이다. 마지막으로 본 데이터의 기준값 을 커서로 사용한다.
-- 첫 페이지
SELECT TOP 10 *
FROM TblPost
ORDER BY _registerDate DESC
-- 다음 페이지 (마지막으로 본 날짜 이전 데이터)
SELECT TOP 10 *
FROM TblPost
WHERE _registerDate < @lastSeenDate -- 커서 조건
ORDER BY _registerDate DESC
OFFSET 없이 WHERE 조건으로 시작점을 지정 하는 방식이다.
오프셋 방식: 앞의 N개를 읽고 버림 → O(N)
커서 방식: 인덱스로 바로 해당 위치 접근 → O(1)
_registerDate에 인덱스가 있다면, DB는 해당 날짜 위치로 바로 점프할 수 있다.
| 구분 | 오프셋 방식 | 커서 방식 |
|---|---|---|
| 특정 페이지 이동 | 가능 (3페이지로 바로) | 불가능 |
| 뒤 페이지 성능 | 느림 | 일정함 |
| 실시간 데이터 추가 | 중복/누락 가능 | 안정적 |
| 적합한 UI | 페이지 번호 버튼 | 무한 스크롤, 더보기 |
| 구현 복잡도 | 단순 | 상대적으로 복잡 |
대부분의 사용자는 1~3페이지만 본다는 통계가 있다. 뒤 페이지 성능이 크게 문제되지 않는다면 오프셋 방식도 충분히 실용적이다.
{: .prompt-info }
유튜브나 인스타그램 댓글 시스템을 생각해보자.
댓글 A (부모)
├─ 답글 A-1
├─ 답글 A-2
└─ "답글 3개 더 보기" ← 클릭하면 추가 로드
댓글 B (부모)
├─ 답글 B-1
└─ "답글 5개 더 보기"
이건 2단계 페이지네이션 이 동시에 일어나는 구조다.
테이블은 1개 다. 부모/자식 구분은 _parentId 컬럼으로 한다.
CREATE TABLE TblComment (
_commentId INT PRIMARY KEY IDENTITY(1,1),
_parentId INT NULL, -- NULL이면 부모, 값 있으면 자식
_postId INT NOT NULL,
_content NVARCHAR(1000),
_registerDate DATETIME DEFAULT GETDATE()
)
-- 인덱스
CREATE INDEX IX_Comment_PostId_ParentId ON TblComment(_postId, _parentId)
CREATE INDEX IX_Comment_ParentId ON TblComment(_parentId)
_commentId | _parentId | _postId | _content
-----------|-----------|---------|------------------
1 | NULL | 100 | "부모 댓글 1" ← 부모
2 | NULL | 100 | "부모 댓글 2" ← 부모
3 | 1 | 100 | "자식 댓글 1" ← 1번의 답글
4 | 1 | 100 | "자식 댓글 2" ← 1번의 답글
5 | 2 | 100 | "자식 댓글 3" ← 2번의 답글
시각화하면:
[1] 부모 댓글 1 (부모)
├─ [3] 자식 댓글 1
└─ [4] 자식 댓글 2
[2] 부모 댓글 2 (부모)
└─ [5] 자식 댓글 3
실무에서는 SP를 2개로 분리 하는 게 일반적이다.
GET /posts/123/comments → 부모 댓글 조회GET /comments/456/replies → 특정 댓글의 답글 조회호출 시점이 다르다. 부모 댓글은 페이지 로드 시, 답글은 "더 보기" 클릭 시에만 호출한다.
[SP 분리 시]
[SP 합쳤을 때]
부모 댓글: 2페이지 보는 중
1번 댓글 답글: 3페이지까지 펼침
5번 댓글 답글: 1페이지만 봄
각각 페이징 상태가 다르다. SP 1개로 이걸 처리하려면 복잡해진다.
CREATE PROCEDURE spGetComments
@postId INT,
@pageNo INT = 1,
@pageSize INT = 10
AS
BEGIN
DECLARE @offset INT = (@pageNo - 1) * @pageSize
DECLARE @totalCount INT = 0
-- TotalCount
SELECT @totalCount = COUNT(*)
FROM TblComment
WHERE _postId = @postId AND _parentId IS NULL
-- 부모 댓글 조회
SELECT
_commentId,
_content,
_registerDate,
@totalCount AS _totalCount,
(SELECT COUNT(*)
FROM TblComment
WHERE _parentId = c._commentId) AS _replyCount -- 답글 개수
FROM TblComment c
WHERE _postId = @postId
AND _parentId IS NULL -- 부모만
ORDER BY _registerDate DESC
OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY
END
결과:
_commentId | _content | _totalCount | _replyCount
-----------|---------------|-------------|------------
1 | 부모 댓글 1 | 2 | 2
2 | 부모 댓글 2 | 2 | 1
CREATE PROCEDURE spGetReplies
@parentId INT,
@pageNo INT = 1,
@pageSize INT = 5
AS
BEGIN
DECLARE @offset INT = (@pageNo - 1) * @pageSize
DECLARE @totalCount INT = 0
-- 해당 부모의 답글 수
SELECT @totalCount = COUNT(*)
FROM TblComment
WHERE _parentId = @parentId
-- 답글 조회
SELECT
_commentId,
_parentId,
_content,
_registerDate,
@totalCount AS _totalCount
FROM TblComment
WHERE _parentId = @parentId
ORDER BY _registerDate ASC -- 답글은 오래된 순
OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY
END
[1] 처음 페이지 로드
→ spGetComments(@postId = 100, @pageNo = 1) 호출
→ 부모 댓글 목록 + 각각의 replyCount 받음
[2] 유저가 "답글 2개 보기" 클릭
→ spGetReplies(@parentId = 1, @pageNo = 1) 호출
→ 1번 댓글의 답글들 받음
[3] 유저가 스크롤 내려서 부모 댓글 더 보기
→ spGetComments(@postId = 100, @pageNo = 2) 호출
[4] 유저가 "답글 더 보기" 클릭
→ spGetReplies(@parentId = 1, @pageNo = 2) 호출
// 부모 댓글 모델
public class CommentDto
{
public int CommentId { get; set; }
public string Content { get; set; }
public DateTime RegisterDate { get; set; }
public int ReplyCount { get; set; }
public int TotalCount { get; set; }
// 프론트에서 관리
public List<ReplyDto> LoadedReplies { get; set; } = new();
public int ReplyPage { get; set; } = 0;
public bool HasMoreReplies => LoadedReplies.Count < ReplyCount;
}
// 답글 모델
public class ReplyDto
{
public int CommentId { get; set; }
public int ParentId { get; set; }
public string Content { get; set; }
public DateTime RegisterDate { get; set; }
}
// 답글 더 보기
public async Task LoadMoreReplies(CommentDto parentComment)
{
var replies = await _commentService.GetReplies(
parentComment.CommentId,
parentComment.ReplyPage + 1
);
parentComment.LoadedReplies.AddRange(replies);
parentComment.ReplyPage++;
}
| 상황 | 권장 방식 |
|---|---|
| 관리자 페이지, 일반 게시판 | 오프셋 페이지네이션 |
| SNS 피드, 무한 스크롤 | 커서 기반 페이지네이션 |
| 댓글-답글 구조 | SP 분리 (부모/자식 각각) |
| 데이터 변경이 빈번함 | 커서 기반 권장 |
| 특정 페이지 바로 이동 필요 | 오프셋만 가능 |
페이지네이션은 단순해 보이지만, 실제로는 성능, 사용자 경험, 데이터 정합성 을 모두 고려해야 하는 복잡한 주제다.
핵심 원칙을 정리하면:
상황에 맞는 방식을 선택하고, 필요하다면 두 방식을 조합해서 사용하는 것도 좋은 전략이다.