05 · Batching & loadRelationCountAndMap — count-only의 자리
이 문서가 답하는 질문: 자식의 내용은 필요 없고 개수만 필요할 때 무엇을 쓰는가? IN 절 배칭은 언제 카르테시안보다 싼가? 한 줄 답: “
loadRelationCountAndMap은 별도의 IN 쿼리로 count만 가져와 부모에 매핑한다 — 자식 row를 0개도 안 가져오면서 답을 준다.”
Why — 왜 count만 가져오는 도구가 필요한가
UI의 흔한 요구:
- 게시글 카드에 댓글 수 (52) 표시
- 유저 프로필에 팔로워 수 (1.2k) 표시
- 채널 리스트에 읽지 않은 메시지 수 (3) 표시
이 모든 자리에서 댓글 본문/팔로워 정보/메시지 내용은 필요 없다. 그런데 leftJoinAndSelect나 relations를 쓰면 모든 자식 row를 가져온다 — 메모리 낭비, 네트워크 낭비, 그리고 카르테시안 폭발.
핵심 주장:
loadRelationCountAndMap은 count 전용 도구다.- 내부적으로 별도 IN 쿼리를 발사하여 부모 컬렉션을 안 부풀린다.
- split fetch 배칭의 전형이다 — round-trip 2번이지만 행 수가 정확.
How — loadRelationCountAndMap의 SQL
기본 사용법
const posts = await postRepo
.createQueryBuilder('post')
.loadRelationCountAndMap('post.commentsCount', 'post.comments')
.getMany();발사되는 SQL
-- 1번: 부모만
SELECT * FROM post;
-- 2번: count만 IN으로 한 방
SELECT post.id AS id, COUNT(comment.id) AS cnt
FROM comment
WHERE comment.post_id IN (1, 2, 3, ..., 100)
GROUP BY post.id;결과는 런타임에 매핑된다:
posts[0].commentsCount; // 52
posts[1].commentsCount; // 0
// ↑ commentsCount 필드는 엔티티 클래스에 *없어도* 동적으로 붙는다매핑된 필드는 string으로 들어온다 (PostgreSQL의
count(*)는 bigint, JS에서 string). 숫자가 필요하면Number(post.commentsCount)로 변환해야 한다.
조건 붙은 count
postRepo
.createQueryBuilder('post')
.loadRelationCountAndMap(
'post.publishedCommentsCount',
'post.comments',
'comment',
(qb) => qb.andWhere('comment.published = :p', { p: true }),
)
.getMany();-- count에 WHERE 절이 들어감
SELECT post.id, COUNT(comment.id)
FROM comment
WHERE comment.post_id IN (...) AND comment.published = true
GROUP BY post.id;Mermaid — count-only의 비용 비교
What — IN 절 배칭의 일반 원리
loadRelationIdAndMap — id만 필요할 때
postRepo
.createQueryBuilder('post')
.loadRelationIdAndMap('post.commentIds', 'post.comments')
.getMany();SELECT * FROM post;
SELECT post_id, id FROM comment WHERE post_id IN (1, 2, ..., 100);post.commentIds = [101, 102, 103, ...]로 id 배열만 채워진다. 카르테시안 없음.
수동 split fetch (가장 일반화된 형태)
const posts = await postRepo.find({ take: 100 });
const postIds = posts.map(p => p.id);
const comments = await commentRepo.find({
where: { post: { id: In(postIds) } },
select: ['id', 'body', 'post'],
});
const byPost = new Map<number, Comment[]>();
for (const c of comments) {
const arr = byPost.get(c.post.id) ?? [];
arr.push(c);
byPost.set(c.post.id, arr);
}
for (const p of posts) p.comments = byPost.get(p.id) ?? [];이게 수동으로 짠 DataLoader 패턴이다 — IN으로 한 방, 메모리에서 group. 카르테시안 없음, take 정확.
비교 매트릭스 — 자식이 필요한 자리별 도구
| 필요한 것 | 최적 도구 | round-trip | 카르테시안 |
|---|---|---|---|
| 자식 없음 (필터만) | leftJoin (without AndSelect) | 1 | 없음 (행 안 부풀음) |
| 자식 개수 | loadRelationCountAndMap | 2 | 없음 |
| 자식 id 배열 | loadRelationIdAndMap | 2 | 없음 |
| 자식 전체 (얕음) | relations 또는 leftJoinAndSelect | 1 | 중간 |
| 자식 전체 (큰 컬렉션) | split fetch (In(ids)) | 2 | 없음 |
| 자식 전체 (깊음, 4+ depth) | split fetch 단계별 | N+1 | 없음 |
IN 절의 크기 한계
PostgreSQL은 IN (...)에 수만 개 인자를 받지만:
- MySQL은
max_allowed_packet제약 (기본 64MB) - SQL Server는 2,100개 매개변수 한도
- Oracle은 1,000개
IN한도
큰 부모 컬렉션을 IN으로 chunk할 때 chunk size를 500~1000으로 두는 게 호환성 안전선이다.
import { chunk } from 'lodash';
const allComments: Comment[] = [];
for (const idChunk of chunk(postIds, 500)) {
const c = await commentRepo.find({
where: { post: { id: In(idChunk) } },
});
allComments.push(...c);
}What-if — 잘못된 해석들
오해 1 — “count도 그냥 relations로 가져와서 .length 보면 된다”
posts[i].comments.length로 답은 맞지만 — comment row 5000개가 메모리에 들어온 대가. count만 필요할 때 loadRelationCountAndMap을 안 쓰는 건 낭비의 정의다.
오해 2 — “split fetch는 N+1이다”
아니다. 부모 1번 + IN으로 자식 1번 = 2번은 N+1이 아니다. N+1은 부모 1번 + 자식 N번이다. 2번은 상수다.
오해 3 — “IN 절은 항상 빠르다”
DB가 통계 정보가 없는 컬럼이거나 IN 인자가 매우 많으면 planner가 full scan을 선택할 수도 있다. EXPLAIN으로 확인이 필요하다 (06).
오해 4 — “loadRelationCountAndMap은 GROUP BY를 항상 안전하게 한다”
HAVING이나 복잡한 조건이 들어가면 DB의 index를 못 쓰는 경우가 있다. count()가 느리면* materialized count column이나 별도 캐시 카운터를 고려해야 한다 (예: comments_count 컬럼을 Subscriber로 유지).
Insight — 한 단락 이야기
“count-only는 데이터를 안 가져오는 데이터 액세스다”
2015년 Twitter가 팔로워 수를 별도의 Redis 카운터에 보관하기로 결정한 이유 —
SELECT COUNT(*) FROM follow WHERE user_id = ?가 수천만 행 인덱스 스캔이라는 사실을 발견했기 때문이다. UI는 숫자 하나만 필요했는데 DB는 모든 행을 세는 비용을 치르고 있었다.loadRelationCountAndMap은 그 단계 이전의 도구다 — 전체 컬렉션을 안 가져오면서 count만 받는 표준 ORM 패턴. 이걸로도 안 되면 materialized count column으로 넘어간다 (Subscriber로 insert/delete 시 +1/-1). DataLoader도 같은 원리다 — key 배열을 받아서 value 배열로 한 방. TypeORM의loadRelationCountAndMap은 DataLoader의 ORM 버전이고, split fetch 패턴은 수동 DataLoader다. “필요한 정보의 최소 형태가 무엇인가”를 묻는 것이 최적화의 출발점이다. 다음 문서(06)는 이 모든 도구를 언제 어디서 발사되는지 보는 로깅과 프로파일링을 본다.
요약 + Mermaid
| 핵심 키 | 값 |
|---|---|
loadRelationCountAndMap | count 전용 IN 쿼리 + 동적 필드 매핑 |
loadRelationIdAndMap | id 배열 전용 IN 쿼리 |
| split fetch 패턴 | 부모 1번 + In(ids)로 자식 1번 = round-trip 2 |
| 카르테시안 위험 | 없음 (행이 안 부풀음) |
| IN 절 chunk size | 500~1000 (DB 호환성) |
| 더 빠른 대안 | materialized count column + Subscriber |
한 줄 결론 — 자식의 내용이 필요 없을 때 loadRelationCountAndMap/loadRelationIdAndMap/split fetch가 카르테시안을 피한 정답이다. 다음 문서(06)는 이 모든 결정을 데이터로 검증하는 로깅과 프로파일링을 본다.