페이지네이션

김현재·2026년 2월 1일

공부

목록 보기
58/58
post-thumbnail

들어가며

대량의 데이터를 한 번에 보여주는 건 사용자 경험에도, 서버 성능에도 좋지 않다. 237개의 게시물이 있다면 한 페이지에 10개씩 나눠서 24페이지로 보여주는 게 훨씬 낫다. 이것이 페이지네이션(Pagination) 의 핵심이다.

이 글에서는 기본적인 오프셋 페이지네이션부터 시작해서, 그 한계를 극복하는 커서 기반 방식, 그리고 유튜브나 인스타그램에서 볼 수 있는 계층형 댓글 페이지네이션까지 알아보고자 한다.


1. 오프셋 페이지네이션 (Offset 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) × 페이지 크기 가 건너뛸 행 수가 된다.

TotalCount가 필요한 이유

페이지네이션 UI를 만들려면 전체 데이터 개수가 필요하다.

용도계산예시
총 페이지 수totalCount / pageSize237 / 10 = 24페이지
페이지 버튼 생성1, 2, 3 ... N[1] [2] [3] ... [24]
정보 표시"N개 중 M개""237개 중 1~10개"
마지막 페이지 판단더보기 버튼 숨김if (currentPage >= totalPages)

SP 예시: TotalCount 포함 조회

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 }

C#에서 결과 처리

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]

2. 오프셋 페이지네이션의 한계

오프셋 방식은 직관적이지만, 치명적인 단점 이 있다.

성능 문제: 뒤 페이지로 갈수록 느려진다

-- 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(*) 성능 이슈

대용량 테이블에서 COUNT(*)는 상당히 무거운 연산이다. 실무에서는 다음과 같은 대안을 사용한다.

  • 캐싱: 일정 주기로만 갱신
  • 근사치 사용: sys.dm_db_partition_stats 활용
  • 표시 안 함: 구글 검색처럼 "약 1,000,000개 결과" 식으로 표현

3. 커서 기반 페이지네이션 (Keyset Pagination)

오프셋의 한계를 극복하는 방식이다. 마지막으로 본 데이터의 기준값 을 커서로 사용한다.

기본 개념

-- 첫 페이지
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페이지 번호 버튼무한 스크롤, 더보기
구현 복잡도단순상대적으로 복잡

언제 어떤 방식을 쓸까?

  • 오프셋 방식: 관리자 페이지, 게시판처럼 특정 페이지로 바로 이동해야 할 때
  • 커서 방식: 인스타그램, 트위터처럼 무한 스크롤 UI일 때

대부분의 사용자는 1~3페이지만 본다는 통계가 있다. 뒤 페이지 성능이 크게 문제되지 않는다면 오프셋 방식도 충분히 실용적이다.
{: .prompt-info }


4. 계층형 댓글 페이지네이션

유튜브나 인스타그램 댓글 시스템을 생각해보자.

댓글 A (부모)
  ├─ 답글 A-1
  ├─ 답글 A-2
  └─ "답글 3개 더 보기"  ← 클릭하면 추가 로드
  
댓글 B (부모)
  ├─ 답글 B-1
  └─ "답글 5개 더 보기"

이건 2단계 페이지네이션 이 동시에 일어나는 구조다.

  1. 부모 댓글 페이지네이션 (스크롤하면 더 로드)
  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개로 분리하는가?

실무에서는 SP를 2개로 분리 하는 게 일반적이다.

이유 1: API 엔드포인트가 다르다

  • GET /posts/123/comments → 부모 댓글 조회
  • GET /comments/456/replies → 특정 댓글의 답글 조회

호출 시점이 다르다. 부모 댓글은 페이지 로드 시, 답글은 "더 보기" 클릭 시에만 호출한다.

이유 2: 불필요한 데이터 로드 방지

  • [SP 분리 시]

    • 부모 댓글 10개만 조회 → 가벼움
    • 유저가 클릭한 댓글의 답글만 조회 → 필요한 것만
  • [SP 합쳤을 때]

    • 부모 댓글 + 모든 답글 조회 → 무거움
    • 안 볼 답글까지 다 가져옴 → 낭비

이유 3: 페이징 독립성

부모 댓글: 2페이지 보는 중
1번 댓글 답글: 3페이지까지 펼침
5번 댓글 답글: 1페이지만 봄

각각 페이징 상태가 다르다. SP 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

SP 2: 특정 부모의 답글 조회

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) 호출

C# 모델 및 처리

// 부모 댓글 모델
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++;
}

5. 정리: 상황별 선택 가이드

상황권장 방식
관리자 페이지, 일반 게시판오프셋 페이지네이션
SNS 피드, 무한 스크롤커서 기반 페이지네이션
댓글-답글 구조SP 분리 (부모/자식 각각)
데이터 변경이 빈번함커서 기반 권장
특정 페이지 바로 이동 필요오프셋만 가능

마치며

페이지네이션은 단순해 보이지만, 실제로는 성능, 사용자 경험, 데이터 정합성 을 모두 고려해야 하는 복잡한 주제다.

핵심 원칙을 정리하면:

  1. 오프셋 방식 은 구현이 간단하지만, 대용량/실시간 데이터에 취약하다
  2. 커서 방식 은 성능이 일정하지만, 특정 페이지 이동이 불가능하다
  3. 계층형 데이터 는 SP를 분리해서 독립적으로 페이징하는 게 실무에서 일반적이다
  4. 어떤 방식이든 TotalCount 조회 비용 을 고려해야 한다

상황에 맞는 방식을 선택하고, 필요하다면 두 방식을 조합해서 사용하는 것도 좋은 전략이다.


References

  • 실무 경험 기반 정리 with Claude
profile
Studying Everyday

0개의 댓글