LIMIT으로 결과 행 수 제한하기
LIMIT은 SQL에서 가장 단순한 옵션입니다. SQLite에게 "딱 이 개수까지만 가져와"라고 지정하는 역할을 하죠. SELECT 문 끝에 붙이기만 하면 그 숫자만큼만 결과가 돌아옵니다. 테이블에 행이 부족하면 더 적게 나올 수도 있지만, 지정한 개수보다 많이 나오는 일은 절대 없습니다.
처음 세 행이 돌아옵니다. 그런데 정확히 어떤 세 행일까요? 함정은 바로 여기에 있습니다. ORDER BY가 없으면 SQLite는 자기 편한 대로 순서를 정해버립니다. 오늘은 입력한 순서대로 나올 수도 있지만, 내일 업데이트나 인덱스 변경이 한 번 일어나고 나면 전혀 다른 결과가 나올 수 있죠. "그냥 데이터 몇 개만 훑어보자" 정도라면 LIMIT만 써도 충분합니다. 하지만 순서가 중요해지는 순간부터는 반드시 명시적으로 정렬을 지정해야 합니다.
OFFSET으로 앞쪽 행 건너뛰기
LIMIT과 OFFSET을 함께 쓰면 결과 집합의 중간 부분만 잘라낼 수 있습니다. OFFSET k는 앞에서부터 k개의 행을 버리고, LIMIT n은 그 뒤에 남은 행 중에서 최대 n개를 반환합니다.
"앞의 두 행은 건너뛰고, 그다음 두 행을 가져온다" — 즉, 정렬된 결과에서 3번째와 4번째 행이 나옵니다. 머릿속으로 이렇게 정리하면 편합니다. WHERE로 거르고, ORDER BY로 정렬하고, OFFSET으로 건너뛰고, LIMIT으로 잘라낸다. 실행 순서도 이 순서이며, 어느 하나도 빼놓을 수 없습니다.
SQLite 페이지네이션에는 반드시 ORDER BY가 필요합니다
LIMIT과 OFFSET을 가장 많이 쓰는 곳이 바로 페이지네이션입니다. 긴 목록을 한 페이지당 20행씩 잘라서 보여주는 식이죠. 1페이지는 LIMIT 20 OFFSET 0, 2페이지는 LIMIT 20 OFFSET 20, 이런 식으로 이어집니다.
여기서 두 가지를 짚고 넘어가야 합니다. 첫째, ORDER BY는 절대 빼면 안 됩니다. 정렬 기준이 없으면 "2페이지"라는 개념 자체가 성립하지 않고, 페이지를 다시 불러올 때마다 행 순서가 뒤바뀔 수 있기 때문입니다. 둘째, 정렬 키에 id를 타이브레이커로 함께 넣었다는 점입니다. 두 게시글의 created_at이 같다면 순서를 확정해 줄 고유한 컬럼이 하나 더 있어야 하고, 그렇지 않으면 행끼리 자리가 뒤바뀌면서 같은 행이 페이지 경계를 넘나들 수 있습니다.
기억해 두면 좋은 원칙: 고유한 컬럼으로 정렬하거나, 정렬 컬럼에 고유한 타이브레이커를 함께 붙여라.
짧게 쓰는 법: LIMIT n, m
SQLite는 MySQL과의 호환성을 위해 옛날식 콤마 문법인 LIMIT offset, count도 지원합니다. 의미는 LIMIT count OFFSET offset과 똑같지만, 순서가 뒤집혀 있어서 헷갈리기 쉽습니다.
-- これら2つは同等です:
SELECT * FROM books LIMIT 10 OFFSET 20;
SELECT * FROM books LIMIT 20, 10; -- 最初にオフセット、次に件数
두 번째 형식은 간결하긴 하지만, 첫 번째 숫자를 개수라고 착각하기 쉬워서 자주 실수하게 됩니다. 그냥 LIMIT n OFFSET k로 쓰세요. 명시적이고 왼쪽에서 오른쪽으로 자연스럽게 읽힙니다.
LIMIT 없이 OFFSET만 쓰기: LIMIT -1 트릭
SQLite에서 OFFSET은 단독으로 쓸 수 없습니다. 문법상 반드시 LIMIT 뒤에 붙어야 하거든요. 그렇다면 "앞의 10행은 건너뛰고 나머지는 전부 가져오기"는 어떻게 표현할까요? 관용적으로 LIMIT -1을 사용합니다. SQLite가 이 값을 "상한 없음"으로 해석하기 때문이죠.
음수 값을 넣으면 어떤 값이든 동일하게 동작하지만, 관례적으로 -1을 씁니다. 결과를 페이지 단위로 훑어 내려가다가 마지막 배치에서 "남은 거 다 줘"라는 쿼리가 필요할 때 스크립트에서 자주 보게 되는 패턴이죠.
SQLite OFFSET 성능, 이게 함정입니다
직접 부딪혀 보기 전엔 아무도 말해주지 않는 사실이 하나 있습니다. OFFSET은 SQLite가 작업을 건너뛰게 만드는 게 아니라, 출력 만 건너뛰게 합니다. 10,001번째부터 10,020번째 행을 돌려받으려면, 엔진은 내부적으로 앞쪽 1만 개의 행을 일일이 훑고 지나간 다음에야 결과를 내보내기 시작합니다. 오프셋이 작을 땐 부담이 거의 없지만, 수만에서 수십만 단위로 커지면 눈에 띄게 느려집니다.
깊은 페이지네이션을 다룰 때 흔히 쓰는 해법은 키셋 페이지네이션(keyset pagination) 입니다. "N개 행을 건너뛰어라"가 아니라, 마지막 행의 정렬 키를 기억해 두고 "이 값 이후의 행을 줘"라고 요청하는 방식이죠.
페이지마다 앞쪽 데이터를 전부 훑고 지나가는 대신, 인덱스를 타고 바로 원하는 위치를 찾아갑니다. 대신 한 가지 제약이 있는데, "47페이지로 점프"하는 건 안 되고 오로지 앞으로만 나아갈 수 있다는 점이죠. 무한 스크롤 피드나 API 커서를 만들 땐 오히려 이런 동작이 딱 맞습니다.
OFFSET 기반 페이지네이션은 관리자 페이지나 결과 건수가 적은 화면에서는 충분히 쓸 만합니다. 하지만 데이터가 계속 늘어나는 상황이라면 키셋(keyset) 페이지네이션을 선택하세요.
실전 예제로 정리하기
지금까지 다룬 내용을 한 번에 적용해 봅시다. 필터링, 정렬, 그리고 동률을 깔끔하게 처리하는 보조 정렬 키까지 포함된 페이지네이션 쿼리입니다.
사무용품만 골라낸 다음 가격 오름차순으로 정렬하고, 같은 값이면 이름순으로 다시 정렬한 뒤 앞에서 두 개만 가져옵니다. 2페이지로 넘어가려면 OFFSET 0을 OFFSET 2로 바꾸기만 하면 됩니다. 쿼리는 짧지만 모든 절이 제 몫을 톡톡히 합니다.
다음 주제: DISTINCT
LIMIT이 몇 개의 행을 가져올지를 정한다면, DISTINCT는 중복된 행을 아예 가져올지 말지를 결정합니다. SELECT 도구 상자에서 그다음으로 만나게 될 절인데, 쉬워 보이지만 의외로 잘못 쓰기 쉽습니다 — 이 부분은 다음 페이지에서 자세히 살펴보겠습니다.
자주 묻는 질문
SQLite에서 LIMIT은 어떤 역할을 하나요?
LIMIT n은 SELECT가 반환하는 행 수를 최대 n개로 제한합니다. WHERE, GROUP BY, ORDER BY가 모두 처리된 다음에 적용되기 때문에, 실제로 스캔하는 행이 아니라 최종 결과 집합의 크기를 자르는 거라고 보면 됩니다. 예를 들어 SELECT * FROM users LIMIT 10은 최대 10개 행을 돌려줍니다.
OFFSET은 LIMIT과 어떻게 같이 동작하나요?
OFFSET k는 결과에서 앞쪽 k개 행을 건너뛴 뒤부터 LIMIT이 카운트를 시작합니다. 그래서 LIMIT 10 OFFSET 20이라고 쓰면 21번째부터 30번째 행이 반환되죠. 다만 SQLite 내부적으로는 건너뛰는 행도 결국 한 번씩 훑어야 하기 때문에, OFFSET 값이 커질수록 쿼리가 느려집니다.
SQLite에서 LIMIT 없이 OFFSET만 쓸 수 있나요?
직접적으로는 안 됩니다. OFFSET은 반드시 LIMIT 절의 일부로만 쓸 수 있거든요. 대신 LIMIT -1 OFFSET k 형태로 우회하는데, -1은 "상한 없음"을 의미하기 때문에 앞의 k개를 건너뛰고 나머지를 전부 반환합니다. 알아두면 유용한 SQLite 특유의 문법입니다.
페이지네이션 쿼리에 ORDER BY가 꼭 필요한 이유는 뭔가요?
ORDER BY가 없으면 SQLite는 어떤 순서로든 행을 반환할 수 있고, 그 순서가 쿼리마다 바뀔 수도 있습니다. 그러면 페이지네이션이 망가져요. 같은 행이 1페이지와 3페이지에 동시에 나오거나, 아예 사라져 버릴 수도 있죠. 그래서 LIMIT/OFFSET을 쓸 때는 항상 고유하고 안정적인 컬럼 기준으로 ORDER BY를 같이 걸어줘야 합니다.