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명11
× posts× 5050
× comments× 201,000
× author (M:1, 곱 안 됨)× 11,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줄
leftJoinAndSelectLEFT JOIN 한 방가능 (select 명시)중간3~5줄
leftJoin (select 없이)LEFT JOIN가능 (필터만)낮음3~5줄
loadRelationCountAndMap별도 IN 쿼리count만없음2줄
수동 loop fetchN+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와 그 위에서 만나는 카르테시안 폭발을 본다.