03 · Join 전략

이 문서가 답하는 질문: innerJoin/leftJoin/leftJoinAndSelect/leftJoinAndMapOne/Many각각 무엇이 다른가, 그리고 join 결과를 엔티티로 받느냐 raw로 받느냐는 어떻게 결정되는가? 한 줄 답: “join 메서드 이름의 And 한 단어가 모든 것을 결정한다 — And가 붙으면 결과가 부모 엔티티에 매핑되고, 없으면 WHERE/HAVING 용 join으로 끝난다.”


Why — 왜 5가지 join 메서드가 있나

find의 join은 항상 LEFT JOIN + 항상 부모 엔티티에 매핑이다. QueryBuilder는 그 두 축모두 풀어준다:

축 1선택지
어떤 join 종류?INNER · LEFT
결과를 부모에 매핑할까?Yes (And) · No (without And)
매핑한다면 어떻게?relation을 따라 자동 (leftJoinAndSelect) · 임의 alias를 수동 (leftJoinAndMapOne/Many)

3축의 조합이 5개 메서드를 만든다. 잘못된 조합은 Cartesian explosion이나 N+1을 부른다.

핵심 주장:

  • innerJoin/leftJoin필터링 전용 — 결과 엔티티에는 join 데이터가 안 들어간다.
  • leftJoinAndSelectrelation을 따라 join + 부모 엔티티의 relation 필드에 매핑.
  • leftJoinAndMapOne/Manyrelation이 아닌 join부모 엔티티의 임의 필드에 매핑.
  • findAndSelect깊은 관계를 다 join하면 Cartesian explosion (1만 row가 100만으로 폭증).

How — 각 메서드의 정확한 SQL

1. innerJoin / leftJoin (And 없음)

// 글이 있는 user만 — innerJoin
await userRepo.createQueryBuilder('u')
  .innerJoin('u.posts', 'p')
  .where('p.is_public = :pub', { pub: true })
  .getMany();
// → SELECT u.* FROM users u INNER JOIN posts p ON p.user_id = u.id WHERE p.is_public = true
// 반환: User[] (posts는 *비어있음* — SELECT에 안 들어갔으니까)

And가 없다 — p의 컬럼은 SELECT에 들어가지 않는다. join은 WHERE 조건의 도구로만 쓰였다.

2. leftJoinAndSelect (And 있음, relation)

await userRepo.createQueryBuilder('u')
  .leftJoinAndSelect('u.posts', 'p')
  .getMany();
// → SELECT u.*, p.* FROM users u LEFT JOIN posts p ON p.user_id = u.id
// 반환: User[] (user.posts에 Post[]가 매핑됨)

And 한 단어로 — p의 컬럼이 SELECT에 들어가고, 부모 user.posts 필드에 매핑된다.

3. leftJoinAndMapOne / leftJoinAndMapMany

relation엔티티에 정의되지 않은 자리는 어떻게 join하나? 또는 임의 alias 결과를 매핑하고 싶을 때?

// 각 user의 "가장 최근 글" 1개만 매핑
await userRepo.createQueryBuilder('u')
  .leftJoinAndMapOne(
    'u.latestPost',           // 매핑할 대상 필드 (엔티티에 없어도 OK)
    Post,                      // join할 엔티티
    'lp',                      // alias
    'lp.user_id = u.id AND lp.created_at = (SELECT MAX(created_at) FROM posts WHERE user_id = u.id)',
  )
  .getMany();
// → 결과 user 객체에 user.latestPost: Post 가 동적으로 박힌다

leftJoinAndMapMany배열로 매핑한다. relation이 없는 데이터도 엔티티 필드처럼 다룰 수 있다.

Mermaid — 4가지 메서드 매트릭스


What — raw로 받느냐 엔티티로 받느냐

getMany vs getRawMany vs getRawAndEntities

메서드반환 타입매핑
getMany()User[]부모 엔티티 + relations 자동
getOne()User | null동상
getRawMany()Record<string, any>[]매핑 없음 — 컬럼별 dictionary
getRawAndEntities(){ entities: User[], raw: any[] }둘 다 — addSelect로 추가한 표현식을 받을 때 쓴다
getCount()numberCOUNT(*) 결과

함정 — addSelect는 엔티티에 매핑되지 않는다

// 의도: User에 postCount 필드를 박고 싶다
const users = await userRepo.createQueryBuilder('u')
  .leftJoin('u.posts', 'p')
  .addSelect('COUNT(p.id)', 'postCount')
  .groupBy('u.id')
  .getMany();
 
console.log(users[0].postCount);  // ❌ undefined — 엔티티에 매핑되지 않음

해결책 둘:

// 방법 1 — getRawAndEntities
const { entities, raw } = await userRepo.createQueryBuilder('u')
  .leftJoin('u.posts', 'p')
  .addSelect('COUNT(p.id)', 'postCount')
  .groupBy('u.id')
  .getRawAndEntities();
 
const users = entities.map((u, i) => ({ ...u, postCount: Number(raw[i].postCount) }));
 
// 방법 2 — @VirtualColumn (TypeORM 0.3.7+)
@Entity()
class User {
  @VirtualColumn({ query: alias => `SELECT COUNT(*) FROM posts WHERE user_id = ${alias}.id` })
  postCount: number;
}

함정 — Cartesian explosion

// User 1만 × Post 평균 5 × Comment 평균 10 = 50만 row
await userRepo.createQueryBuilder('u')
  .leftJoinAndSelect('u.posts', 'p')
  .leftJoinAndSelect('p.comments', 'c')
  .getMany();
// 메모리 폭발 — 같은 user.name이 50만 번 반복

TypeORM은 알아서 이걸 별도 SELECT로 쪼개지 않는다findrelations는 쪼개지만, leftJoinAndSelect한 SQL로 나간다.

해결책: loadRelationCountAndMap / loadRelationIdAndMap / 별도 쿼리Post와 Comment를 따로 가져오기.

비교 테이블 — 같은 의도, 다른 메서드

// "공개 글이 1개 이상인 user" — 5가지 방법
 
// 1. innerJoin (필터 전용, 결과에 posts 없음)
.innerJoin('u.posts', 'p', 'p.is_public = :pub', { pub: true }).getMany()
 
// 2. leftJoinAndSelect (모든 user + 공개 글만 매핑)
.leftJoinAndSelect('u.posts', 'p', 'p.is_public = :pub', { pub: true }).getMany()
 
// 3. WHERE 서브쿼리 (다음 문서)
.where('EXISTS (SELECT 1 FROM posts p WHERE p.user_id = u.id AND p.is_public = true)').getMany()
 
// 4. find + relations (조건부 join 불가!)
userRepo.find({ relations: { posts: true }, where: { posts: { isPublic: true } } })
// !! posts가 없는 user는 결과에서 사라짐
 
// 5. find + relations + 후처리 (성능 최악)
const users = await userRepo.find({ relations: { posts: true } });
users.forEach(u => u.posts = u.posts.filter(p => p.isPublic));
방법SQL 횟수정확성의도
1. innerJoin1”공개 글 있는 user만”필터
2. leftJoinAndSelect + ON 조건1”모든 user + 공개 글만”매핑
3. EXISTS 서브쿼리1”공개 글 있는 user만”필터 (다음 문서)
4. find + where relations1틀린 결과 (find의 함정)
5. find + 후처리2 + 매핑메모리 낭비

What-if — 잘못된 해석들

오해 1 — “leftJoinAndSelect는 항상 옳다”

깊은 관계까지 다 매핑하면 Cartesian explosion. 1만 user × 100 post = 100만 row가 메모리에 전부 올라온다. 일정 깊이 이상은 별도 SELECT로 끊는 게 정답.

오해 2 — “innerJoin은 필터링이고 결과에 데이터가 없으니 쓸모없다”

오히려 정반대 — 결과에 필요 없는 joininnerJoin 또는 And 없는 leftJoin으로 표현해야 SELECT 컬럼이 줄어들고, Cartesian 비용도 줄어든다. “매핑이 필요한가”가 결정 기준.

오해 3 — “leftJoinAndMapOne은 자주 안 쓰니까 외울 필요 없다”

오히려 복잡한 SQL을 엔티티 모양으로 만들 때 가장 강력한 도구다. 예: 각 user의 최근 글 1개, 각 product의 최저가 1개. relation으로 표현 불가능한 집계 결과엔티티 필드처럼 다룰 수 있다.

오해 4 — “join 조건은 ON에만 넣으면 된다”

LEFT JOIN의 경우 — ON 절의 조건WHERE 절의 조건완전히 다르다. ON에 넣으면 부모 row는 살고 매핑된 데이터만 필터링, WHERE에 넣으면 매핑 데이터가 없는 부모 row가 통째로 사라진다. 이 차이를 모르면 count가 안 맞는다 버그가 시작된다.


Insight — 한 단락 이야기

And 한 단어가 ORM의 추상 경계를 그린다”

메서드 이름에서 And부모 엔티티에 매핑한다는 강력한 약속을 담은 건 — TypeORM 설계자의 작은 작품이다. innerJoin은 SQL 의미만, innerJoinAndSelectSQL + 매핑. 이 명명 규약 하나가 사용자에게 “이 메서드는 어디까지 책임지는가”를 한눈에 알려준다. Prisma는 include 한 단어로 같은 일을 한다 — 하지만 Prisma는 조건부 join이 제한적이라 leftJoinAndSelectON 조건 + 매핑동시에 표현하기 어렵다. TypeORM의 5가지 메서드이름이 길어진 대가로 표현력을 산 거래다. API 설계의 묘수는 “메서드 수를 줄이는 것”이 아니라 “각 메서드 이름이 책임의 경계시각적으로 드러내는 것”And는 그 경계의 이름이다.


요약 + Mermaid

메서드SQL 동작결과 매핑언제
innerJoinINNER JOIN❌ (필터만)“조건 만족하는 부모만”
leftJoinLEFT JOIN❌ (필터만)“조건 + 부모는 유지”
innerJoinAndSelectINNER JOIN✅ relation”있는 것만 + 매핑”
leftJoinAndSelectLEFT JOIN✅ relation”전부 + 매핑”
leftJoinAndMapOneLEFT JOIN✅ 임의 alias 단일”엔티티 모양에 임의 데이터 1개”
leftJoinAndMapManyLEFT JOIN✅ 임의 alias 배열”엔티티 모양에 임의 데이터 N개”

한 줄 결론 — join 메서드 이름의 And 한 단어매핑 책임을 결정한다. 다음 문서(04)는 join으로도 표현 못 하는 서브쿼리를 본다.