본문 바로가기
바이브코딩

바이브코딩 툴 입문 - 페이지네이션 만들기: LIMIT과 OFFSET으로 데이터 나눠보기

by 시도아 2026. 5. 26.
728x90
반응형

지난 글에서는 To-do 앱에 전체 / 완료 / 미완료 필터 기능을 추가했습니다.

이제 사용자는 원하는 조건에 맞는 할 일만 볼 수 있습니다.

전체 보기
완료된 항목만 보기
미완료 항목만 보기
검색어로 찾기
 

그런데 데이터가 더 많아지면 또 다른 문제가 생깁니다.

할 일이 10개일 때는 괜찮습니다.
하지만 100개, 1,000개, 10,000개가 되면 전체 데이터를 한 번에 불러오는 방식은 부담이 됩니다.

이때 필요한 기능이 바로 페이지네이션(Pagination)입니다.

이번 글에서는 SQLite와 Express를 기준으로 LIMIT과 OFFSET을 사용해 데이터를 페이지 단위로 나누는 방법을 정리해보겠습니다.

페이지네이션이란 무엇일까?

페이지네이션은 데이터를 한 번에 모두 보여주지 않고, 일정 개수씩 나누어 보여주는 방식입니다.

예를 들어 할 일이 100개 있다고 가정해보겠습니다.

한 화면에 100개를 모두 보여주는 대신, 이렇게 나눌 수 있습니다.

1페이지 → 10개
2페이지 → 다음 10개
3페이지 → 다음 10개
...
 

블로그 목록, 게시판, 쇼핑몰 상품 목록, 댓글 목록에서 흔히 보는 방식입니다.

페이지네이션을 사용하면 화면이 가벼워지고, 서버와 데이터베이스가 한 번에 처리해야 하는 데이터도 줄어듭니다.

 

왜 페이지네이션이 필요할까?

페이지네이션이 필요한 이유는 단순합니다.

데이터가 많아질수록 전체 목록을 한 번에 불러오는 방식은 비효율적이기 때문입니다.

예를 들어 아래 SQL은 모든 할 일을 가져옵니다.

SELECT *
FROM todos
ORDER BY id DESC;
 

데이터가 20개라면 문제없습니다.

하지만 데이터가 10만 개라면 어떨까요?

DB는 많은 데이터를 조회해야 하고
서버는 많은 데이터를 응답해야 하고
브라우저는 많은 항목을 화면에 그려야 합니다.
 

그래서 실제 서비스에서는 보통 필요한 만큼만 가져옵니다.

이번 페이지에 보여줄 10개만 가져오기
 

이때 사용하는 SQL 문법이 LIMIT과 OFFSET입니다.

 

LIMIT은 무엇일까?

LIMIT은 가져올 데이터 개수를 제한하는 문법입니다.

SQLite 공식 문서에 따르면 LIMIT 절은 SELECT 문이 반환하는 행의 개수에 상한을 두는 역할을 합니다. 즉, 전체 결과 중 몇 개만 가져올지 정할 때 사용합니다.

예를 들어 최신 할 일 10개만 가져오고 싶다면 이렇게 작성합니다.

SELECT *
FROM todos
ORDER BY id DESC
LIMIT 10;
 

이 문장은 이렇게 해석할 수 있습니다.

todos 테이블에서 최신순으로 정렬한 뒤 10개만 가져와줘.
 

 

OFFSET은 무엇일까?

OFFSET은 앞에서 몇 개를 건너뛸지 정하는 문법입니다.

SQLite 공식 문서는 OFFSET이 있으면 먼저 M개의 행을 생략하고, 그다음 N개의 행을 반환한다고 설명합니다. 즉, LIMIT은 가져올 개수이고, OFFSET은 건너뛸 개수입니다.

예를 들어 1페이지에 10개씩 보여준다고 생각해보겠습니다.

1페이지 → 0개 건너뛰고 10개 가져오기
2페이지 → 10개 건너뛰고 10개 가져오기
3페이지 → 20개 건너뛰고 10개 가져오기
 

SQL로 쓰면 이렇게 됩니다.

-- 1페이지
SELECT *
FROM todos
ORDER BY id DESC
LIMIT 10 OFFSET 0;

-- 2페이지
SELECT *
FROM todos
ORDER BY id DESC
LIMIT 10 OFFSET 10;

-- 3페이지
SELECT *
FROM todos
ORDER BY id DESC
LIMIT 10 OFFSET 20;
 

핵심 공식은 이것입니다.

OFFSET = (현재 페이지 - 1) × 한 페이지당 개수
 

예를 들어 3페이지이고 한 페이지에 10개씩 보여준다면,

OFFSET = (3 - 1) × 10 = 20
 

그래서 20개를 건너뛰고 다음 10개를 가져옵니다.

 

LIMIT과 OFFSET은 ORDER BY와 함께 쓰는 것이 좋다

페이지네이션에서는 정렬 기준이 중요합니다.

정렬 기준이 없으면 매번 데이터 순서가 일정하지 않을 수 있습니다. SQLite 공식 문서도 ORDER BY가 없으면 여러 행이 반환될 때 행의 순서가 정의되지 않는다고 설명합니다.

그래서 페이지네이션에서는 보통 이런 식으로 작성합니다.

SELECT *
FROM todos
ORDER BY id DESC
LIMIT 10 OFFSET 0;
 

여기서 ORDER BY id DESC는 최신 데이터가 먼저 나오게 하는 기준입니다.

정리하면 페이지네이션 SQL의 기본 구조는 아래와 같습니다.

SELECT *
FROM todos
ORDER BY id DESC
LIMIT ? OFFSET ?;
 

 

이번 글에서 만들 기능

이번 글에서는 기존 To-do 앱에 페이지네이션을 추가합니다.

기능은 다음과 같습니다.

페이지당 5개씩 보여주기
이전 버튼
다음 버튼
현재 페이지 표시
전체 페이지 수 표시
검색/필터와 페이지네이션 함께 사용하기
 

API 주소는 이렇게 설계합니다.

/api/todos?page=1&limit=5
/api/todos?page=2&limit=5
/api/todos?keyword=운동&status=active&page=1&limit=5
 

여기서 page는 현재 페이지이고, limit은 한 페이지에 보여줄 개수입니다.

Express 공식 API 문서에는 Request 객체의 속성으로 req.query가 포함되어 있습니다. 이번 예제에서는 req.query.page, req.query.limit, req.query.keyword, req.query.status를 사용해 URL 쿼리 값을 읽습니다.

 

서버 응답 구조 먼저 설계하기

페이지네이션에서는 목록 데이터만 응답하면 부족합니다.

프론트엔드가 이전/다음 버튼을 만들려면 아래 정보가 필요합니다.

현재 페이지
한 페이지당 개수
전체 데이터 개수
전체 페이지 수
현재 페이지 데이터
 

그래서 응답 구조를 이렇게 만들겠습니다.

{
  "items": [],
  "pagination": {
    "page": 1,
    "limit": 5,
    "totalCount": 23,
    "totalPages": 5
  }
}
 

이렇게 응답하면 프론트엔드는 현재 페이지가 몇 페이지인지, 다음 페이지가 있는지, 전체 페이지가 몇 개인지 알 수 있습니다.

 

1단계: page와 limit 값 받기

서버의 GET /api/todos 라우트에서 page와 limit을 받습니다.

const page = Number(req.query.page || 1);
const limit = Number(req.query.limit || 5);
 

하지만 여기서 주의해야 합니다.

사용자가 이상한 값을 보낼 수도 있습니다.

/api/todos?page=-1&limit=abc
/api/todos?page=0&limit=10000
 

그래서 기본적인 검사를 넣는 것이 좋습니다.

if (!Number.isInteger(page) || page < 1) {
  return res.status(400).json({
    message: "page 값은 1 이상의 정수여야 합니다."
  });
}

if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
  return res.status(400).json({
    message: "limit 값은 1 이상 50 이하의 정수여야 합니다."
  });
}
 

잘못된 요청값은 클라이언트 오류로 볼 수 있기 때문에 400 Bad Request를 사용할 수 있습니다. MDN 문서에 따르면 HTTP 400 Bad Request는 서버가 클라이언트 오류로 판단한 요청을 처리하지 않는다는 의미의 상태 코드입니다.

 

2단계: OFFSET 계산하기

page와 limit이 정상이면 offset을 계산합니다.

const offset = (page - 1) * limit;
 

예를 들어 다음과 같습니다.

page limit offset
1 5 0
2 5 5
3 5 10
4 5 15

즉, 페이지 번호가 올라갈수록 앞의 데이터를 더 많이 건너뜁니다.

 

3단계: 조건 배열 만들기

지난 글에서 검색과 필터를 함께 처리할 때 조건 배열을 만들었습니다.

이번에도 같은 방식을 사용합니다.

const keyword = req.query.keyword?.trim();
const status = req.query.status;

const conditions = [];
const values = [];
 

검색어가 있으면 LIKE 조건을 추가합니다.

if (keyword) {
  conditions.push("title LIKE ?");
  values.push(`%${keyword}%`);
}
 

상태 필터가 있으면 completed 조건을 추가합니다.

if (status === "completed") {
  conditions.push("completed = ?");
  values.push(1);
}

if (status === "active") {
  conditions.push("completed = ?");
  values.push(0);
}
 

조건이 있으면 WHERE 절을 만듭니다.

const whereClause =
  conditions.length > 0
    ? "WHERE " + conditions.join(" AND ")
    : "";
 

이렇게 하면 검색, 필터, 페이지네이션을 함께 사용할 수 있습니다.

 

4단계: 전체 개수 구하기

페이지네이션에서 중요한 것은 전체 데이터 개수입니다.

전체 개수를 알아야 전체 페이지 수를 계산할 수 있습니다.

SELECT COUNT(*) AS count
FROM todos
WHERE ...
 

서버 코드에서는 이렇게 작성할 수 있습니다.

const countResult = await db.get(
  `
  SELECT COUNT(*) AS count
  FROM todos
  ${whereClause}
  `,
  values
);

const totalCount = countResult.count;
const totalPages = Math.ceil(totalCount / limit);
 

예를 들어 전체 데이터가 23개이고 한 페이지에 5개씩 보여준다면,

totalPages = Math.ceil(23 / 5) = 5
 

마지막 페이지에는 3개만 표시됩니다.

 

5단계: 현재 페이지 데이터 가져오기

이제 실제 목록 데이터를 가져옵니다.

const todos = await db.all(
  `
  SELECT id, title, completed, created_at
  FROM todos
  ${whereClause}
  ORDER BY id DESC
  LIMIT ? OFFSET ?
  `,
  [...values, limit, offset]
);
 

여기서 중요한 점은 values 뒤에 limit과 offset을 추가한다는 것입니다.

검색어가 있다면 values에는 이미 %검색어%가 들어 있습니다.

상태 필터가 있다면 1 또는 0도 들어 있습니다.

마지막에 LIMIT ? OFFSET ? 값으로 limit, offset을 넘깁니다.

[...values, limit, offset]
 

이 구조를 사용하면 SQL 문자열에 사용자 입력값을 직접 붙이지 않고 안전하게 값을 전달할 수 있습니다.

 

6단계: 완성된 GET 라우트 코드

아래는 검색, 필터, 페이지네이션을 함께 지원하는 GET /api/todos 라우트입니다.

app.get("/api/todos", async (req, res) => {
  const keyword = req.query.keyword?.trim();
  const status = req.query.status;

  const page = Number(req.query.page || 1);
  const limit = Number(req.query.limit || 5);

  if (!Number.isInteger(page) || page < 1) {
    return res.status(400).json({
      message: "page 값은 1 이상의 정수여야 합니다."
    });
  }

  if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
    return res.status(400).json({
      message: "limit 값은 1 이상 50 이하의 정수여야 합니다."
    });
  }

  const offset = (page - 1) * limit;

  try {
    const conditions = [];
    const values = [];

    if (keyword) {
      conditions.push("title LIKE ?");
      values.push(`%${keyword}%`);
    }

    if (status === "completed") {
      conditions.push("completed = ?");
      values.push(1);
    }

    if (status === "active") {
      conditions.push("completed = ?");
      values.push(0);
    }

    const whereClause =
      conditions.length > 0
        ? "WHERE " + conditions.join(" AND ")
        : "";

    const countResult = await db.get(
      `
      SELECT COUNT(*) AS count
      FROM todos
      ${whereClause}
      `,
      values
    );

    const totalCount = countResult.count;
    const totalPages = Math.ceil(totalCount / limit);

    const todos = await db.all(
      `
      SELECT id, title, completed, created_at
      FROM todos
      ${whereClause}
      ORDER BY id DESC
      LIMIT ? OFFSET ?
      `,
      [...values, limit, offset]
    );

    res.json({
      items: todos.map(todo => ({
        ...todo,
        completed: Boolean(todo.completed)
      })),
      pagination: {
        page,
        limit,
        totalCount,
        totalPages
      }
    });
  } catch (error) {
    res.status(500).json({
      message: "목록을 불러오는 중 오류가 발생했습니다."
    });
  }
});
 

이제 서버는 단순히 배열을 응답하는 것이 아니라, 목록과 페이지 정보를 함께 응답합니다.

 

7단계: 프론트엔드 상태값 추가하기

프론트엔드에서는 현재 페이지 정보를 기억해야 합니다.

let currentPage = 1;
let currentLimit = 5;
let totalPages = 1;
 

필터나 검색어가 바뀌면 보통 1페이지로 돌아가는 것이 자연스럽습니다.

currentPage = 1;
 

 

8단계: API 주소 만들기

기존 buildTodoUrl() 함수에 page와 limit을 추가합니다.

function buildTodoUrl() {
  const keyword = searchInput.value.trim();

  const params = new URLSearchParams();

  if (keyword) {
    params.set("keyword", keyword);
  }

  if (currentFilter !== "all") {
    params.set("status", currentFilter);
  }

  params.set("page", currentPage);
  params.set("limit", currentLimit);

  return `/api/todos?${params.toString()}`;
}
 

URLSearchParams는 URL 쿼리 문자열을 다룰 수 있는 인터페이스이며, set()으로 특정 검색 파라미터 값을 설정하고 toString()으로 URL에 붙일 수 있는 쿼리 문자열을 만들 수 있습니다.

예를 들어 검색어가 “운동”, 필터가 미완료, 현재 페이지가 2페이지라면 아래 주소가 만들어집니다.

/api/todos?keyword=운동&status=active&page=2&limit=5
 

 

9단계: loadTodos 함수 수정하기

서버 응답 구조가 바뀌었으므로 프론트엔드 코드도 바꿔야 합니다.

기존에는 바로 배열을 받았습니다.

todos = await response.json();
 

이제는 items와 pagination을 함께 받습니다.

const data = await response.json();

todos = data.items;
totalPages = data.pagination.totalPages;
currentPage = data.pagination.page;
 

완성된 코드는 아래와 같습니다.

async function loadTodos() {
  try {
    setStatus("할 일 목록을 불러오는 중입니다.");

    const response = await fetch(buildTodoUrl());

    if (!response.ok) {
      throw new Error(`목록 불러오기 실패: ${response.status}`);
    }

    const data = await response.json();

    todos = data.items;
    totalPages = data.pagination.totalPages;
    currentPage = data.pagination.page;

    renderTodos();
    renderPagination();

    setStatus(
      `총 ${data.pagination.totalCount}개 중 ${currentPage}/${totalPages || 1}페이지`
    );
  } catch (error) {
    setStatus(error.message);
  }
}
 

 

10단계: 페이지 버튼 UI 만들기

HTML에 페이지네이션 영역을 추가합니다.

<div class="pagination-area">
  <button onclick="goToPreviousPage()">이전</button>
  <span id="pageInfo">1 / 1</span>
  <button onclick="goToNextPage()">다음</button>
</div>
 

CSS도 추가합니다.

.pagination-area {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 12px;
  margin-top: 20px;
}

.pagination-area button {
  background: #4A90E2;
}

#pageInfo {
  font-weight: bold;
  color: #334155;
}
 

 

11단계: 페이지 정보 표시하기

프론트엔드에 페이지 정보를 업데이트하는 함수를 만듭니다.

function renderPagination() {
  const pageInfo = document.getElementById("pageInfo");
  pageInfo.textContent = `${currentPage} / ${totalPages || 1}`;
}
 

totalPages가 0일 수도 있으므로 화면에는 최소 1로 표시해도 됩니다.

검색 결과가 없을 때는 이렇게 표시됩니다.

1 / 1
 

또는 실제 서비스에서는 “검색 결과 없음” UI를 따로 만들 수도 있습니다.

 

12단계: 이전/다음 버튼 기능 만들기

이전 버튼 함수입니다.

async function goToPreviousPage() {
  if (currentPage <= 1) {
    return;
  }

  currentPage -= 1;
  await loadTodos();
}
 

다음 버튼 함수입니다.

async function goToNextPage() {
  if (currentPage >= totalPages) {
    return;
  }

  currentPage += 1;
  await loadTodos();
}
 

이제 사용자는 이전/다음 버튼으로 페이지를 이동할 수 있습니다.

 

13단계: 검색과 필터 변경 시 1페이지로 돌리기

검색어나 필터가 바뀌면 현재 페이지를 1로 초기화해야 합니다.

예를 들어 5페이지에 있다가 “운동”을 검색했는데 검색 결과가 1페이지밖에 없다면 문제가 생길 수 있습니다.

그래서 검색 함수는 이렇게 수정합니다.

async function searchTodos() {
  currentPage = 1;
  await loadTodos();
}
 

필터 함수도 마찬가지입니다.

async function setFilter(filter) {
  currentFilter = filter;
  currentPage = 1;
  updateFilterButtons();
  await loadTodos();
}
 

전체 보기로 초기화할 때도 1페이지로 돌아갑니다.

async function resetSearch() {
  searchInput.value = "";
  currentFilter = "all";
  currentPage = 1;
  updateFilterButtons();
  await loadTodos();
}
 

 

프론트엔드 핵심 코드 정리

아래는 페이지네이션 관련 핵심 JavaScript 코드입니다.

let currentPage = 1;
let currentLimit = 5;
let totalPages = 1;

function buildTodoUrl() {
  const keyword = searchInput.value.trim();

  const params = new URLSearchParams();

  if (keyword) {
    params.set("keyword", keyword);
  }

  if (currentFilter !== "all") {
    params.set("status", currentFilter);
  }

  params.set("page", currentPage);
  params.set("limit", currentLimit);

  return `/api/todos?${params.toString()}`;
}

async function loadTodos() {
  try {
    setStatus("할 일 목록을 불러오는 중입니다.");

    const response = await fetch(buildTodoUrl());

    if (!response.ok) {
      throw new Error(`목록 불러오기 실패: ${response.status}`);
    }

    const data = await response.json();

    todos = data.items;
    totalPages = data.pagination.totalPages;
    currentPage = data.pagination.page;

    renderTodos();
    renderPagination();

    setStatus(
      `총 ${data.pagination.totalCount}개 중 ${currentPage}/${totalPages || 1}페이지`
    );
  } catch (error) {
    setStatus(error.message);
  }
}

function renderPagination() {
  const pageInfo = document.getElementById("pageInfo");
  pageInfo.textContent = `${currentPage} / ${totalPages || 1}`;
}

async function goToPreviousPage() {
  if (currentPage <= 1) {
    return;
  }

  currentPage -= 1;
  await loadTodos();
}

async function goToNextPage() {
  if (currentPage >= totalPages) {
    return;
  }

  currentPage += 1;
  await loadTodos();
}

async function searchTodos() {
  currentPage = 1;
  await loadTodos();
}

async function setFilter(filter) {
  currentFilter = filter;
  currentPage = 1;
  updateFilterButtons();
  await loadTodos();
}

async function resetSearch() {
  searchInput.value = "";
  currentFilter = "all";
  currentPage = 1;
  updateFilterButtons();
  await loadTodos();
}
 

 

전체 흐름 정리

페이지네이션이 들어간 To-do 앱의 흐름은 아래와 같습니다.

1. 사용자가 페이지 접속
2. currentPage = 1, currentLimit = 5
3. /api/todos?page=1&limit=5 요청
4. 서버가 LIMIT 5 OFFSET 0 실행
5. items와 pagination 응답
6. 화면에 5개 표시
7. 다음 버튼 클릭
8. currentPage = 2
9. /api/todos?page=2&limit=5 요청
10. 서버가 LIMIT 5 OFFSET 5 실행
 

검색과 필터까지 포함하면 이렇게 됩니다.

검색어 입력
→ currentPage = 1
→ /api/todos?keyword=운동&page=1&limit=5

완료 필터 클릭
→ currentPage = 1
→ /api/todos?status=completed&page=1&limit=5

미완료 + 검색
→ /api/todos?keyword=운동&status=active&page=1&limit=5
 

 

초보자가 자주 막히는 부분

1. OFFSET 계산을 잘못한다

가장 흔한 실수는 offset을 이렇게 계산하는 것입니다.

const offset = page * limit;
 

이렇게 하면 1페이지부터 이미 앞의 데이터를 건너뛰게 됩니다.

올바른 공식은 이것입니다.

const offset = (page - 1) * limit;
 

 

2. totalPages 계산을 빼먹는다

페이지 버튼을 만들려면 전체 페이지 수가 필요합니다.

const totalPages = Math.ceil(totalCount / limit);
 

Math.ceil()을 사용하는 이유는 남은 데이터가 조금 있어도 마지막 페이지가 필요하기 때문입니다.

예를 들어 23개를 5개씩 보여주면 4페이지가 아니라 5페이지가 필요합니다.

 

3. 검색 후 페이지를 1로 초기화하지 않는다

검색이나 필터를 바꾸면 반드시 1페이지로 돌아가는 것이 좋습니다.

currentPage = 1;
 

그렇지 않으면 검색 결과가 적을 때 빈 화면이 나올 수 있습니다.

 

4. limit 값을 너무 크게 허용한다

사용자가 이런 요청을 보낼 수도 있습니다.

/api/todos?limit=100000
 

이런 요청을 그대로 처리하면 서버와 DB에 부담이 됩니다.

그래서 서버에서 제한을 두는 것이 좋습니다.

if (limit < 1 || limit > 50) {
  return res.status(400).json({
    message: "limit 값은 1 이상 50 이하의 정수여야 합니다."
  });
}
 

 

바이브코딩에서 이렇게 요청하면 좋다

AI에게 페이지네이션 코드를 요청할 때는 단순히 “페이지네이션 만들어줘”라고 하기보다, 필요한 구조를 구체적으로 말하는 것이 좋습니다.

Express와 SQLite로 만든 To-do 앱에 페이지네이션을 추가해줘.
page와 limit은 req.query로 받고,
SQLite에서는 LIMIT ? OFFSET ?을 사용해줘.
응답은 items와 pagination 객체로 나눠서 보내줘.
 

검색과 필터까지 함께 연결하고 싶다면 이렇게 요청할 수 있습니다.

기존 GET /api/todos 라우트에 keyword 검색, status 필터, page, limit 페이지네이션을 모두 적용해줘.
WHERE 조건은 동적으로 구성하고,
COUNT(*)로 전체 개수를 구한 뒤 totalPages를 계산해줘.
 

프론트엔드까지 요청하려면 이렇게 말하면 됩니다.

프론트엔드에 이전/다음 버튼과 현재 페이지 표시를 추가해줘.
검색이나 필터가 바뀌면 currentPage를 1로 초기화하게 만들어줘.
 

 

이번 글의 핵심 정리

이번 글에서는 SQLite와 Express를 사용해 To-do 앱에 페이지네이션을 추가했습니다.

핵심은 다음과 같습니다.

LIMIT → 가져올 개수
OFFSET → 건너뛸 개수
OFFSET = (page - 1) × limit
COUNT(*) → 전체 데이터 개수
totalPages = Math.ceil(totalCount / limit)
 

API 구조는 이렇게 만들었습니다.

/api/todos?page=1&limit=5
/api/todos?keyword=운동&status=active&page=1&limit=5
 

서버는 LIMIT ? OFFSET ?으로 필요한 데이터만 가져오고, 프론트엔드는 items와 pagination 정보를 받아 화면을 업데이트합니다.

이제 To-do 앱은 데이터가 많아져도 한 번에 전체를 불러오지 않고, 페이지 단위로 안정적으로 보여줄 수 있게 되었습니다.

728x90
반응형