03 · leftJoinAndSelect — 카르테시안의 자리
이 문서가 답하는 질문:
leftJoinAndSelect는 정확히 어떤 SQL을 발사하고 어떻게 객체로 매핑하는가? 카르테시안 폭발은 어디서 시작되는가? 한 줄 답: “leftJoinAndSelect는 명시적 LEFT JOIN + 명시적 SELECT 두 가지를 한 메서드로 묶는다 — 그래서 통제는 되찾지만, 깊이 2 이상에서는 행 수가 곱셈으로 폭발한다.”
Why — 왜 leftJoinAndSelect가 제어의 도구인가
relations 옵션이 마법 한 줄이라면, leftJoinAndSelect는 명시적 SQL을 객체로 매핑하는 현실의 도구다.
- 어떤 컬럼을 가져올지 — 선택 가능 (
.select([...])) - 어떤 조건으로 join할지 — 선택 가능 (
ON ...) - 추가 WHERE / ORDER BY를 어떻게 결합할지 — 선택 가능
relations의 모든 숨은 디폴트를 명시적으로 만든다. 그 대가로 코드 양이 늘어나고, 카르테시안 위험은 그대로 남는다.
핵심 주장:
leftJoinAndSelect는relations보다 통제력이 크지만 카르테시안 위험은 동일하다.- 깊이 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 : user가 N: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 10이 post 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 2 | round-trip 2 | round-trip N+1 | 정확 |
loadRelationCountAndMap | round-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 곱셈은 여전히 개발자가 손으로 막아야 한다.leftJoinAndSelect는 N+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)는 같은 무거운 쿼리가 반복될 때 그 비용을 캐시로 흡수하는 길을 본다.