🗄️ TypeORM7. 성능 & N+103-query-builder-join-and-select

03 · leftJoinAndSelect카르테시안의 자리

이 문서가 답하는 질문: leftJoinAndSelect는 정확히 어떤 SQL을 발사하고 어떻게 객체로 매핑하는가? 카르테시안 폭발은 어디서 시작되는가? 한 줄 답: leftJoinAndSelect명시적 LEFT JOIN + 명시적 SELECT 두 가지를 한 메서드로 묶는다 — 그래서 통제는 되찾지만, 깊이 2 이상에서는 행 수가 곱셈으로 폭발한다.”


Why — 왜 leftJoinAndSelect제어의 도구인가

relations 옵션이 마법 한 줄이라면, leftJoinAndSelect명시적 SQL을 객체로 매핑하는 현실의 도구다.

  • 어떤 컬럼을 가져올지 — 선택 가능 (.select([...]))
  • 어떤 조건으로 join할지 — 선택 가능 (ON ...)
  • 추가 WHERE / ORDER BY를 어떻게 결합할지 — 선택 가능

relations의 모든 숨은 디폴트명시적으로 만든다. 그 대가로 코드 양이 늘어나고, 카르테시안 위험은 그대로 남는다.

핵심 주장:

  • leftJoinAndSelectrelations보다 통제력이 크지만 카르테시안 위험은 동일하다.
  • 깊이 2 이상에서는 leftJoin(매핑 없이) + 별도 쿼리 split fetch가 더 싸다.
  • TypeORM 0.3은 이 split fetch를 내부적으로 자동화하기도 한다.

How — leftJoinAndSelect가 발사하는 SQL

기본 사용법

const posts = await postRepo
  .createQueryBuilder('post')
  .leftJoinAndSelect('post.user', 'user')
  .leftJoinAndSelect('post.comments', 'comment')
  .where('post.id = :id', { id: 1 })
  .getMany();

발사되는 SQL

SELECT
  post.id AS post_id, post.title AS post_title,
  user.id AS user_id, user.name AS user_name,
  comment.id AS comment_id, comment.body AS comment_body
FROM post
LEFT JOIN user ON user.id = post.user_id
LEFT JOIN comment ON comment.post_id = post.id
WHERE post.id = 1;

이것이 relations 옵션과 거의 동일한 SQL이다 — 차이는 별칭(post, user, comment)을 명시했다는 점뿐.

컬럼 좁히기 (.select)

const posts = await postRepo
  .createQueryBuilder('post')
  .select(['post.id', 'post.title'])
  .leftJoin('post.user', 'user')           // ← AndSelect가 아님 — join만
  .addSelect(['user.id', 'user.name'])     // ← 필요한 컬럼만
  .getMany();

발사되는 SQL — 훨씬 가볍다

SELECT
  post.id AS post_id, post.title AS post_title,
  user.id AS user_id, user.name AS user_name
FROM post
LEFT JOIN user ON user.id = post.user_id;

leftJoin (without AndSelect) + addSelect 조합은 행 모양을 정확히 통제한다. 이게 relations로는 불가능한 자리다.

Mermaid — leftJoin의 세 변형


What — 카르테시안 폭발의 정확한 메커니즘

단일 깊이는 안전

postRepo.createQueryBuilder('post')
  .leftJoinAndSelect('post.user', 'user')   // post : user = N : 1
  .getMany();
SELECT post.*, user.*
FROM post
LEFT JOIN user ON user.id = post.user_id;

post : userN:1이므로 행 수는 post 수와 동일하다. 안전.

1-N 두 갈래의 함정

postRepo.createQueryBuilder('post')
  .leftJoinAndSelect('post.user', 'user')           // N:1, 안전
  .leftJoinAndSelect('post.comments', 'comment')    // 1:N, 곱셈 시작
  .leftJoinAndSelect('post.tags', 'tag')            // 1:N, 또 곱셈
  .getMany();

post가 100개, 각 post에 comment 20개, tag 5개라면:

SQL이 반환하는 행 수 = 100 × 20 × 5 = 10,000행

1-N 관계를 나란히 두 개 이상 join하는 순간 카르테시안이 발생한다. TypeORM은 이를 엔티티 트리로 dedupe하지만 네트워크와 메모리에는 이미 10,000행이 흘렀다.

take/limit이 깨지는 자리

postRepo.createQueryBuilder('post')
  .leftJoinAndSelect('post.comments', 'comment')
  .take(10)        // 10개의 post를 원함
  .getMany();

발사되는 SQL:

SELECT post.*, comment.*
FROM post
LEFT JOIN comment ON comment.post_id = post.id
LIMIT 10;       -- ← 행이 10개 — post가 아니다!

LIMIT 10post 10개가 아니라 행 10개에 걸린다 — post 1개에 comment가 10개면 post는 1개만 반환된다!

TypeORM의 자동 대응getMany 내부의 subquery 트릭

TypeORM 0.2.40+ 는 이 문제를 부분 자동으로 해결한다 — take가 있고 leftJoinAndSelect컬렉션이면:

-- 1단계: post id만 LIMIT
SELECT id FROM post LIMIT 10;
 
-- 2단계: 그 id로 JOIN
SELECT post.*, comment.*
FROM post
LEFT JOIN comment ON comment.post_id = post.id
WHERE post.id IN (1, 2, 3, ..., 10);

두 번의 round-trip으로 늘지만 take 의미가 보존된다. 이를 명시적으로 강제하려면:

postRepo.createQueryBuilder('post')
  .leftJoinAndSelect('post.comments', 'comment')
  .take(10)
  .skip(0)
  // ↑ 자동으로 subquery로 변환됨
  .getMany();

비교 테이블 — 깊이 × 도구

도구깊이 1깊이 2 (parallel)깊이 3 (deep)take 정확도
relations: { posts: true }OK카르테시안곱셈 폭발OK (자동 subq)
leftJoinAndSelect (collection)OK카르테시안곱셈 폭발OK (자동 subq)
leftJoin + addSelect (작은 컬럼)OK카르테시안 (행 수만큼)같음같음
split fetch (별도 쿼리 2번)round-trip 2round-trip 2round-trip N+1정확
loadRelationCountAndMapround-trip 2적용 안 됨적용 안 됨정확

split fetch가 더 싼 자리

// 1번 — parent만
const posts = await postRepo.find({ take: 100 });
const ids = posts.map(p => p.id);
 
// 2번 — comments를 IN으로 한 방
const comments = await commentRepo.find({
  where: { post: { id: In(ids) } },
});
 
// 3번 — 메모리에서 grouping
const byPost = groupBy(comments, c => c.post.id);
posts.forEach(p => p.comments = byPost[p.id] ?? []);

이게 복잡해 보이지만 깊이가 깊거나 컬렉션이 크면 leftJoinAndSelect보다 훨씬 싸다. 2번의 작은 쿼리1번의 카르테시안 쿼리를 이긴다.


What-if — 잘못된 해석들

오해 1 — “leftJoinAndSelect 한 방이 가장 빠르다”

아니다. round-trip은 1번이지만 카르테시안으로 적재된 데이터split fetch보다 10배 무거울 수 있다. DB CPU와 네트워크가 더 비싸진다.

오해 2 — “INNER JOIN을 쓰면 행이 줄어드니까 안전하다”

INNER JOIN은 없는 자식 row를 빼는 것이지 행 곱셈을 막는 게 아니다. 1-N 두 갈래는 INNER든 LEFT든 같이 곱해진다.

오해 3 — “select 명시는 별로 안 중요하다”

엔티티에 컬럼이 수십 개라면 SELECT *네트워크 비용메모리 매핑 비용수십 배로 만든다. addSelect필요한 5개 컬럼만 가져오면 체감 속도가 다르다.

오해 4 — “JOIN이 항상 IN보다 빠르다”

작은 데이터셋은 JOIN이 빠르지만, 깊이 2 이상의 1-N에서는 IN 분할 쿼리훨씬 빠르다. DB의 행 셔플 비용round-trip 비용보다 비싸지는 순간이 있다.


Insight — 한 단락 이야기

“카르테시안 폭발은 SQL이 1번이라는 사실에 가려진다”

2010년대 ORM 진영의 공통된 신화“round-trip이 적을수록 빠르다”. 이것은 작은 데이터에는 맞지만 1-N 관계가 나란히 두 갈래 들어가는 순간 완전히 깨진다. PostgreSQL의 query planner는 LEFT JOIN을 cross product로 펼친 뒤 dedupe하는데, 그 펼친 단계의 메모리가 수십만 행에 이르면 워크메모리가 디스크로 spill된다. TypeORM의 자동 subquery 트릭은 이 함정의 가장 흔한 표면(take + leftJoinAndSelect)만 막는다 — parallel 1-N 곱셈여전히 개발자가 손으로 막아야 한다. leftJoinAndSelectN+1을 카르테시안과 교환하는 도구다 — 그것이 옳은 교환인지를 매번 묻는 사람이 ORM을 잘 쓴다. 다음 문서(04)는 같은 쿼리를 반복 호출하는 다른 비용을 캐시로 흡수하는 길을 본다.


요약 + Mermaid

핵심 키
leftJoinAndSelect의 정체LEFT JOIN + 모든 컬럼 SELECT 자동
단일 깊이 1-N안전
parallel 1-N × 1-N카르테시안 폭발
take 정확도TypeORM 0.2.40+ 자동 subq로 부분 해결
더 싼 대안leftJoin + addSelect 또는 split fetch
다음 도구캐시로 반복 호출 흡수

한 줄 결론leftJoinAndSelect명시적 통제를 주지만 카르테시안 폭발의 위험은 그대로다. 깊이 2 이상에서는 split fetch 또는 loadRelationCountAndMap(05)이 더 싸다. 다음 문서(04)는 같은 무거운 쿼리반복될 때 그 비용을 캐시로 흡수하는 길을 본다.