🗄️ TypeORM4. QueryBuilder06-pagination-and-counting

06 · 페이지네이션 & 카운팅

이 문서가 답하는 질문: skip/takeoffset/limit왜 별도로 존재하는가, 그리고 getManyAndCount는 어떻게 N+1 가능성을 안고 있는가? 한 줄 답: skip/takejoin이 있을 때 별도 SELECT id 쿼리를 발사해 정확성을 지키고, offset/limit현재 SQL에 LIMIT/OFFSET을 그대로 박는다 — 둘은 결과가 다르다.”


Why — 왜 페이지네이션이 별도 문서인가

페이지네이션은 가장 단순해 보이지만 가장 자주 틀리는 영역이다. 이유:

  1. skip/takeoffset/limit둘 다 존재하고 — 무엇이 다른지 문서에 분명히 안 적혀 있다.
  2. join이 있을 때 LIMIT 10user 10명이 아니라 row 10개다 — Cartesian explosion에 먹힌다.
  3. getManyAndCount2번의 round-trip이고, 그 중 count 쿼리N+1을 부르는 자리가 있다.

이 세 가지를 모르면 — “왜 page 2가 page 1과 겹치지?”, “왜 count가 user 수와 안 맞지?” 같은 버그를 영원히 디버깅한다.

핵심 주장:

  • skip/take엔티티 단위로 페이지를 자르고, offset/limitraw row 단위다.
  • skip/take는 join이 있을 때 내부적으로 SELECT id 서브쿼리 + IN으로 변환된다.
  • getManyAndCountcount = 별도 쿼리를 발사하고, 잘못 짜면 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 20

join이 없으면 완전히 동일한 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-triprow 전송량정확성
.offset(0).limit(10).getMany()110 row (mixed)❌ user 수 부족
.skip(0).take(10).getMany()210 + 50 row✅ user 10명 + 글
.skip(0).take(10).getManyAndCount()310 + 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비용한계
OFFSETLIMIT 20 OFFSET 100000O(offset)큰 페이지 느림
CursorWHERE id > :last LIMIT 20O(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.reltuplesEXPLAIN 추정치를 쓰면 카운트 쿼리 비용을 0으로 만들 수 있다. infinite scrollcount조차 필요 없다.


Insight — 한 단락 이야기

skip/take는 ORM이 Cartesian explosion을 미리 인지한 결과다”

1세대 ORM(Hibernate, ActiveRecord)에는 skip/take의 자동 분리가 없었다LIMIT 10user 10명이 아니라 row 10개가 되는 버그가 지금도 GitHub 이슈에 올라온다. TypeORM은 그 역사를 학습하고 skip/take별도 메서드로 만들어 — 암묵적으로 2단계 SQL을 발사한다. 그 대가가 2 round-trip의 비용이다 — 작은 데이터에선 과잉 비용이고, 큰 데이터에선 정확성의 비용이다. 그래서 TypeORM은 둘 다 제공하고 — 선택은 개발자에게 맡긴다. API 설계의 묘수는 “옳은 것 하나만 제공”이 아니라 “트레이드오프를 시각화하는 것”skip vs offset이라는 이름 차이동작 차이의 신호다.


요약 + Mermaid

항목skip/takeoffset/limit
단위엔티티raw row
join 있을 때2 round-trip + 정확1 round-trip + 부정확
join 없을 때동일 SQL동일 SQL
사용 시점기본값join 없거나 row 단위 의도
큰 OFFSET둘 다 느림 — cursor로 대체동상
메서드round-trip의미
getMany()1 (또는 skip 시 2)엔티티 배열
getCount()1COUNT만
getManyAndCount()2 (또는 3)[entities, total]

한 줄 결론skip/take정확성, offset/limit제어, getManyAndCount비용을 안긴다. 큰 OFFSET이면 cursor로 갈 시점이다. 챕터 끝 — 다음 챕터(05-transaction-datasource)는 이 모든 쿼리들을 한 트랜잭션으로 묶는 길을 다룬다.