🗄️ TypeORM7. 성능 & N+105-batching-and-loadRelationCountAndMap

05 · Batching & loadRelationCountAndMapcount-only의 자리

이 문서가 답하는 질문: 자식의 내용은 필요 없고 개수만 필요할 때 무엇을 쓰는가? IN 절 배칭은 언제 카르테시안보다 싼가? 한 줄 답: loadRelationCountAndMap별도의 IN 쿼리로 count만 가져와 부모에 매핑한다 — 자식 row를 0개도 안 가져오면서 답을 준다.”


Why — 왜 count만 가져오는 도구가 필요한가

UI의 흔한 요구:

  • 게시글 카드에 댓글 수 (52) 표시
  • 유저 프로필에 팔로워 수 (1.2k) 표시
  • 채널 리스트에 읽지 않은 메시지 수 (3) 표시

이 모든 자리에서 댓글 본문/팔로워 정보/메시지 내용필요 없다. 그런데 leftJoinAndSelectrelations를 쓰면 모든 자식 row를 가져온다 — 메모리 낭비, 네트워크 낭비, 그리고 카르테시안 폭발.

핵심 주장:

  • loadRelationCountAndMapcount 전용 도구다.
  • 내부적으로 별도 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없음 (행 안 부풀음)
자식 개수loadRelationCountAndMap2없음
자식 id 배열loadRelationIdAndMap2없음
자식 전체 (얕음)relations 또는 leftJoinAndSelect1중간
자식 전체 (큰 컬렉션)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의 loadRelationCountAndMapDataLoader의 ORM 버전이고, split fetch 패턴수동 DataLoader다. “필요한 정보의 최소 형태가 무엇인가”를 묻는 것이 최적화의 출발점이다. 다음 문서(06)는 이 모든 도구를 언제 어디서 발사되는지 보는 로깅과 프로파일링을 본다.


요약 + Mermaid

핵심 키
loadRelationCountAndMapcount 전용 IN 쿼리 + 동적 필드 매핑
loadRelationIdAndMapid 배열 전용 IN 쿼리
split fetch 패턴부모 1번 + In(ids)로 자식 1번 = round-trip 2
카르테시안 위험없음 (행이 안 부풀음)
IN 절 chunk size500~1000 (DB 호환성)
더 빠른 대안materialized count column + Subscriber

한 줄 결론자식의 내용이 필요 없을 때 loadRelationCountAndMap/loadRelationIdAndMap/split fetch가 카르테시안을 피한 정답이다. 다음 문서(06)는 이 모든 결정을 데이터로 검증하는 로깅과 프로파일링을 본다.