02 · relations 옵션 — 암묵적 join 한 방
이 문서가 답하는 질문:
find({ relations: { ... } })는 정확히 어떤 SQL을 발사하는가? 왜 그것이 한 방이지만 공짜는 아닌가? 한 줄 답: “relations옵션은 각 관계마다 LEFT JOIN을 자동 추가하고 결과를 엔티티 트리로 재매핑한다 — N+1은 사라지지만 카르테시안 폭발의 씨앗이 들어온다.”
Why — 왜 relations 옵션이 첫 번째 도구인가
TypeORM에서 N+1을 끄는 가장 짧은 한 줄이다.
// Before — N+1
const users = await userRepo.find({ take: 100 });
for (const u of users) {
u.posts = await postRepo.find({ where: { user: { id: u.id } } });
}
// After — 한 방
const users = await userRepo.find({
take: 100,
relations: { posts: true },
});이것이 TypeORM이 명시적으로 제공하는 N+1 끄기 공식 도구다. 하지만 공짜가 아니라는 사실을 모르면 다음 사고가 기다린다.
핵심 주장:
relations는 각 관계마다 LEFT JOIN을 추가한다 — 그리고 결과를 엔티티 트리로 재구성한다.- 깊이가 깊어질수록 카르테시안 곱이 행 수를 곱셈한다.
- 모든 컬럼을 다 가져오므로 select 최소화는 별도로 해야 한다.
How — relations가 발사하는 SQL
기본 사용법 (객체 문법, v0.3+)
const posts = await postRepo.find({
relations: {
user: true, // 1-N
comments: true, // 0-N
},
take: 10,
});발사되는 SQL
SELECT
post.*,
user.*,
comment.*
FROM post
LEFT JOIN user ON user.id = post.user_id
LEFT JOIN comment ON comment.post_id = post.id
LIMIT 10;모든 컬럼이
*로 빠져나간다. 컬럼 50개짜리 테이블이라도 전부 가져온다. 이것이 첫 번째 숨은 비용이다.
결과의 엔티티 트리 재매핑
SQL 결과는 flat한 행이지만 TypeORM은 런타임에 객체 트리로 재구성한다.
// SQL 결과 (raw)
[
{ post_id: 1, post_title: "A", user_id: 7, user_name: "rho", comment_id: 100, ... },
{ post_id: 1, post_title: "A", user_id: 7, user_name: "rho", comment_id: 101, ... },
{ post_id: 2, post_title: "B", user_id: 8, user_name: "sigma", comment_id: 200, ... },
]
// TypeORM이 재매핑한 결과
[
{ id: 1, title: "A", user: { id: 7, name: "rho" }, comments: [{ id: 100 }, { id: 101 }] },
{ id: 2, title: "B", user: { id: 8, name: "sigma" }, comments: [{ id: 200 }] },
]행 중복 제거 + 그룹핑이 애플리케이션 메모리에서 일어난다. 이 비용은 행 수에 비례한다.
중첩 relations
const users = await userRepo.find({
relations: {
posts: {
comments: {
author: true,
},
},
},
});이게 깊은 트리를 한 방에 가져오는 마법처럼 보이지만 — 카르테시안 곱이 누적된다.
SELECT user.*, post.*, comment.*, author.*
FROM user
LEFT JOIN post ON post.user_id = user.id
LEFT JOIN comment ON comment.post_id = post.id
LEFT JOIN user AS author ON author.id = comment.author_id;만약 한 user에 posts 50개, 각 post에 comments 20개가 있다면:
| 깊이 | 곱 | 누적 행 수 |
|---|---|---|
| user 1명 | 1 | 1 |
| × posts | × 50 | 50 |
| × comments | × 20 | 1,000 |
| × author (M:1, 곱 안 됨) | × 1 | 1,000 |
유저 100명을 가져오면 100,000행이 메모리에 적재된다. TypeORM은 이를 엔티티 트리로 dedupe하지만 네트워크와 메모리 비용은 이미 발생했다.
Mermaid — 한 방 join의 두 얼굴
What — relations vs 다른 도구
배열 문법 (legacy, v0.2)
// 같은 의미, 옛 문법
const posts = await postRepo.find({
relations: ['user', 'comments'],
});
// 중첩은 점 표기
const posts = await postRepo.find({
relations: ['user', 'comments', 'comments.author'],
});비교 매트릭스
| 도구 | SQL | 컬럼 제어 | 카르테시안 위험 | 코드 양 |
|---|---|---|---|---|
relations: { posts: true } | LEFT JOIN 한 방 | 불가 (SELECT *) | 중간 | 1줄 |
leftJoinAndSelect | LEFT JOIN 한 방 | 가능 (select 명시) | 중간 | 3~5줄 |
leftJoin (select 없이) | LEFT JOIN | 가능 (필터만) | 낮음 | 3~5줄 |
loadRelationCountAndMap | 별도 IN 쿼리 | count만 | 없음 | 2줄 |
| 수동 loop fetch | N+1 | 가능 | 없음 | 5~10줄 |
언제 relations를 쓰나
- OK: 한 단계 깊이, 결과 행이 적은 (< 1000행) 쿼리
- 위험: 두 단계 이상 깊이, 큰 컬렉션 × 큰 컬렉션
- 금지: API가 부분 selection을 받는 자리 (필요 없는 관계까지 무조건 가져옴)
where + relations 조합의 함정
const users = await userRepo.find({
where: {
posts: { title: 'hello' }, // ← 관계 필터
},
relations: { posts: true },
});이건 posts 중 hello인 것만 join하는 게 아니라 user 중 hello인 post를 하나라도 가진 user를 찾고, 그 user의 모든 posts를 join한다. 함정이다.
SELECT user.*, post.*
FROM user
LEFT JOIN post ON post.user_id = user.id AND post.title = 'hello'
WHERE post.title = 'hello';
-- where절은 user를 필터, JOIN ON은 어떤 post가 join될지를 결정조건이 어디에 가는지는 TypeORM 버전마다 미묘하게 다르다 — 04-query-builder에서 명시적으로 잡는 법을 본다.
What-if — 잘못된 해석들
오해 1 — “relations 한 방은 항상 옳다”
아니다. 카르테시안 곱이 행 수를 곱셈한다. 깊이가 깊으면 N+1보다 더 비싸진다 (round-trip은 1번이지만 적재 메모리가 100배).
오해 2 — “relations는 select를 좁힐 수 있다”
옵션 객체 안에는 select를 적을 수 없다. find({ select, relations })는 부모 컬럼에만 적용된다. 관계 컬럼까지 좁히려면 QueryBuilder로 가야 한다.
오해 3 — “join은 LEFT가 기본이라 안전하다”
LEFT JOIN은 부모를 잃지 않는 안전이지만, 없는 자식까지 NULL 행을 만들어 결과 행 수는 똑같이 늘어난다.
오해 4 — “v0.3 객체 문법은 단순한 sugar다”
객체 문법은 컴파일 타임 타입 안전을 준다 — 존재하지 않는 관계 이름을 적으면 TS 에러. 배열 문법은 문자열이라 타입 체크 안 됨. 새 코드는 객체 문법을 써야 한다.
Insight — 한 단락 이야기
“relations 옵션은 공짜처럼 보이는 트레이드오프다”
2018년 TypeORM 0.2가
relations: ['posts']문법을 정착시켰을 때 — 그것은 Hibernate의fetch="join"을 ORM 옵션 한 줄로 줄인 혁신이었다. find 호출에 옵션 하나만 추가하면 N+1이 사라진다는 마법이었다. 하지만 마법의 대가는 숨겨진다.relations: { posts: { comments: { author: true } } }가 내부적으로 4-way JOIN을 발사하고 N×M×K 행을 메모리에 적재한다는 사실은 SQL 로깅을 켜기 전까지는 안 보인다. 그래서 대부분의 TypeORM 사고는 N+1을 끄려고 relations를 켰는데 더 큰 사고를 만나는 자리에서 일어난다.relations는 N+1을 카르테시안 폭발과 교환하는 도구다. 다음 문서(03)는 그 교환을 명시적으로 통제하는leftJoinAndSelect를 본다.
요약 + Mermaid
| 핵심 키 | 값 |
|---|---|
relations의 정체 | LEFT JOIN 자동 추가 + 엔티티 트리 매핑 |
| SQL 발사 횟수 | 1번 (round-trip 측면) |
| 컬럼 제어 | 불가 (모든 컬럼) |
| 카르테시안 위험 | 깊이 2 이상에서 높음 |
| 권장 깊이 | 1단계 ({ posts: true })까지 |
| 대안 | leftJoinAndSelect (다음 문서), loadRelationCountAndMap |
한 줄 결론 — relations 옵션은 N+1을 LEFT JOIN 한 방으로 흡수하지만 컬럼·깊이·필터 통제를 포기한다. 다음 문서(03)는 그 통제를 되찾는 QueryBuilder의 leftJoinAndSelect와 그 위에서 만나는 카르테시안 폭발을 본다.