06 · 페이지네이션 & 카운팅
이 문서가 답하는 질문:
skip/take와offset/limit은 왜 별도로 존재하는가, 그리고getManyAndCount는 어떻게 N+1 가능성을 안고 있는가? 한 줄 답: “skip/take는 join이 있을 때 별도 SELECT id 쿼리를 발사해 정확성을 지키고,offset/limit은 현재 SQL에 LIMIT/OFFSET을 그대로 박는다 — 둘은 결과가 다르다.”
Why — 왜 페이지네이션이 별도 문서인가
페이지네이션은 가장 단순해 보이지만 가장 자주 틀리는 영역이다. 이유:
skip/take와offset/limit이 둘 다 존재하고 — 무엇이 다른지 문서에 분명히 안 적혀 있다.- join이 있을 때
LIMIT 10은 user 10명이 아니라 row 10개다 — Cartesian explosion에 먹힌다. getManyAndCount는 2번의 round-trip이고, 그 중 count 쿼리가 N+1을 부르는 자리가 있다.
이 세 가지를 모르면 — “왜 page 2가 page 1과 겹치지?”, “왜 count가 user 수와 안 맞지?” 같은 버그를 영원히 디버깅한다.
핵심 주장:
skip/take는 엔티티 단위로 페이지를 자르고,offset/limit은 raw row 단위다.skip/take는 join이 있을 때 내부적으로 SELECT id 서브쿼리 + IN으로 변환된다.getManyAndCount는 count = 별도 쿼리를 발사하고, 잘못 짜면 count 쿼리 자체가 무거워진다.- 큰 테이블에서 OFFSET은 느리다 — cursor 기반 페이지네이션이 대안.
How — skip/take vs offset/limit의 정확한 SQL 차이
join이 없을 때 — 둘은 같다
// skip/take
await userRepo.createQueryBuilder('u')
.skip(20).take(10)
.getMany();
// → SELECT * FROM users u LIMIT 10 OFFSET 20
// offset/limit
await userRepo.createQueryBuilder('u')
.offset(20).limit(10)
.getMany();
// → SELECT * FROM users u LIMIT 10 OFFSET 20join이 없으면 완전히 동일한 SQL이 나간다. 별 차이가 없어 보인다.
join이 있을 때 — 완전히 다르다
// offset/limit + leftJoinAndSelect — 함정!
await userRepo.createQueryBuilder('u')
.leftJoinAndSelect('u.posts', 'p')
.offset(0).limit(10)
.getMany();
// → SELECT u.*, p.* FROM users u LEFT JOIN posts p ON ... LIMIT 10 OFFSET 0
// → row 10개 = user 3명 + 그 user의 글 10개 (예: user 1이 5글, user 2가 3글, user 3이 2글)
// !! 의도한 "user 10명"이 아니다// skip/take + leftJoinAndSelect — 정확
await userRepo.createQueryBuilder('u')
.leftJoinAndSelect('u.posts', 'p')
.skip(0).take(10)
.getMany();TypeORM이 내부적으로 발사하는 SQL — 2단계:
-- 1단계: user id 10개 추리기
SELECT DISTINCT u.id FROM users u LIMIT 10 OFFSET 0;
-- → [1, 2, 3, ..., 10]
-- 2단계: 그 user id에 대해서만 join
SELECT u.*, p.* FROM users u LEFT JOIN posts p ON p.user_id = u.id
WHERE u.id IN (1, 2, 3, ..., 10);2개의 round-trip을 발사하는 대신, 정확히 user 10명과 그 user의 모든 글을 받는다.
Mermaid — 둘의 분기
What — getManyAndCount와 N+1 가능성
getManyAndCount의 정체
const [users, total] = await userRepo.createQueryBuilder('u')
.leftJoinAndSelect('u.posts', 'p')
.skip(0).take(10)
.getManyAndCount();이 한 줄이 발사하는 SQL은 3개:
-- 1. id 추리기 (skip/take용)
SELECT DISTINCT u.id FROM users u LIMIT 10 OFFSET 0;
-- 2. join하면서 본 쿼리
SELECT u.*, p.* FROM users u LEFT JOIN posts p ON p.user_id = u.id
WHERE u.id IN (...);
-- 3. 전체 count
SELECT COUNT(DISTINCT u.id) FROM users u LEFT JOIN posts p ON p.user_id = u.id;문제 — 3번 count 쿼리는 전체 users 테이블 + 전체 posts join을 스캔한다. 데이터가 수백만 건이면 — count 쿼리가 수초 걸린다.
Cartesian 비교 표 (10 user, 평균 5 post)
| 메서드 | round-trip | row 전송량 | 정확성 |
|---|---|---|---|
.offset(0).limit(10).getMany() | 1 | 10 row (mixed) | ❌ user 수 부족 |
.skip(0).take(10).getMany() | 2 | 10 + 50 row | ✅ user 10명 + 글 |
.skip(0).take(10).getManyAndCount() | 3 | 10 + 50 + 1 | ✅ + 총 count |
.skip(0).take(10).getManyAndCount() (큰 테이블) | 3 | + count 스캔 비용 | ⚠️ count 쿼리 무거움 |
N+1 가능성 — count + relations
// 함정 — count 쿼리에 relation이 자동 포함되지 않는다
const [users, total] = await userRepo.createQueryBuilder('u')
.leftJoinAndSelect('u.posts', 'p')
.leftJoinAndSelect('u.profile', 'pr')
.where('u.active = :a', { a: true })
.skip(0).take(10)
.getManyAndCount();
// count 쿼리: SELECT COUNT(DISTINCT u.id) FROM users u
// LEFT JOIN posts p ON ... LEFT JOIN profile pr ON ...
// WHERE u.active = true
// → 모든 join을 *그대로* 수행 — 불필요한 join 비용해결책 — count는 별도로 짜기:
const qb = userRepo.createQueryBuilder('u')
.where('u.active = :a', { a: true });
// count는 join 없이
const total = await qb.getCount();
// 데이터는 join 있게
const users = await qb.clone() // 중요 — clone으로 분리
.leftJoinAndSelect('u.posts', 'p')
.leftJoinAndSelect('u.profile', 'pr')
.skip(0).take(10)
.getMany();getCount()는 자동으로 SELECT COUNT(...)로 변환되며, 불필요한 SELECT 컬럼과 ORDER BY가 제거된다.
큰 OFFSET은 느리다
-- OFFSET 100000 — DB는 *처음부터 10만 row를 읽고 버린다*
SELECT * FROM users ORDER BY created_at LIMIT 20 OFFSET 100000;
-- → 매우 느림대안 — cursor 기반:
// 마지막 본 id 이후 20개
await userRepo.createQueryBuilder('u')
.where('u.id > :lastId', { lastId: 12345 })
.orderBy('u.id')
.take(20)
.getMany();
// → 인덱스 기반 seek — 일정 시간| 방식 | SQL | 비용 | 한계 |
|---|---|---|---|
| OFFSET | LIMIT 20 OFFSET 100000 | O(offset) | 큰 페이지 느림 |
| Cursor | WHERE id > :last LIMIT 20 | O(log n) | 무작위 페이지 점프 불가 |
| Keyset | 위와 같음 + 복합 키 | O(log n) | 정렬 키가 unique여야 |
What-if — 잘못된 해석들
오해 1 — “skip/take와 offset/limit은 같다”
join 없으면 같다. join 있으면 다르다 — 그리고 실무의 90%는 join이 있다. 기본은 *skip/take*를 쓰는 게 안전하다.
오해 2 — “getManyAndCount는 1번 호출이니 1 round-trip이다”
최소 2 round-trip (count + many), join이 있으면 3 round-trip (count + id + join). API 레이턴시 분석 시 이 점을 빼먹으면 “왜 P99이 갑자기 튀지?”의 답을 못 찾는다.
오해 3 — “OFFSET이 작으면 빠르다”
OFFSET이 0이어도 DB가 row를 스캔하고 정렬하는 비용은 그대로다. 정확히 느려지는 자리는 OFFSET > 10000 정도부터지만, 모든 OFFSET이 인덱스 활용을 막는다. 큰 테이블이면 cursor가 답.
오해 4 — “count는 항상 정확해야 한다”
UI 페이지네이션에는 추정 count면 충분한 경우가 많다. PostgreSQL의 pg_class.reltuples나 EXPLAIN 추정치를 쓰면 카운트 쿼리 비용을 0으로 만들 수 있다. infinite scroll은 count조차 필요 없다.
Insight — 한 단락 이야기
“
skip/take는 ORM이 Cartesian explosion을 미리 인지한 결과다”1세대 ORM(Hibernate, ActiveRecord)에는 skip/take의 자동 분리가 없었다 —
LIMIT 10이 user 10명이 아니라 row 10개가 되는 버그가 지금도 GitHub 이슈에 올라온다. TypeORM은 그 역사를 학습하고skip/take를 별도 메서드로 만들어 — 암묵적으로 2단계 SQL을 발사한다. 그 대가가 2 round-trip의 비용이다 — 작은 데이터에선 과잉 비용이고, 큰 데이터에선 정확성의 비용이다. 그래서 TypeORM은 둘 다 제공하고 — 선택은 개발자에게 맡긴다. API 설계의 묘수는 “옳은 것 하나만 제공”이 아니라 “트레이드오프를 시각화하는 것” —skipvsoffset이라는 이름 차이가 동작 차이의 신호다.
요약 + Mermaid
| 항목 | skip/take | offset/limit |
|---|---|---|
| 단위 | 엔티티 | raw row |
| join 있을 때 | 2 round-trip + 정확 | 1 round-trip + 부정확 |
| join 없을 때 | 동일 SQL | 동일 SQL |
| 사용 시점 | 기본값 | join 없거나 row 단위 의도 |
| 큰 OFFSET | 둘 다 느림 — cursor로 대체 | 동상 |
| 메서드 | round-trip | 의미 |
|---|---|---|
getMany() | 1 (또는 skip 시 2) | 엔티티 배열 |
getCount() | 1 | COUNT만 |
getManyAndCount() | 2 (또는 3) | [entities, total] |
한 줄 결론 — skip/take는 정확성, offset/limit은 제어, getManyAndCount는 비용을 안긴다. 큰 OFFSET이면 cursor로 갈 시점이다.
챕터 끝 — 다음 챕터(05-transaction-datasource)는 이 모든 쿼리들을 한 트랜잭션으로 묶는 길을 다룬다.