01 · QueryBuilder가 필요한 자리
이 문서가 답하는 질문:
find/findOne은 어디까지 가능하고, 어디서부터 QueryBuilder의 자리가 시작되는가? 한 줄 답: “find는 단순 selection + 1-depth relations + 단일 where까지의 도구다 — 조건부 join, 서브쿼리, HAVING, UNION, raw 표현은 모두 QueryBuilder의 자리다.”
Why — 왜 find의 한계부터 정의하나
TypeORM을 처음 만나는 사람은 find 옵션을 무한히 키우려고 한다. relations 안에 relations를 넣고, where 안에 where를 넣고, order 안에 객체를 깊게 박는다. 어느 시점에서 문서를 아무리 읽어도 원하는 SQL이 안 나오는 자리가 온다.
그 자리가 QueryBuilder가 등장해야 하는 자리다. 이 문서는 그 경계선을 다섯 가지 신호로 정리한다.
핵심 주장:
find의 join은 항상 LEFT JOIN이고, 조건부 join은 불가능하다.find의 where는 단일 엔티티의 컬럼 조건까지만 가능 — 서브쿼리 비교는 불가능하다.HAVING,UNION,WITH,WINDOW는find에 문법 자체가 없다.- raw 표현(
COUNT(DISTINCT),CASE WHEN, 함수 호출)도 마찬가지다.
이 다섯 가지 중 하나라도 필요하면 — QueryBuilder다.
How — find의 정확한 한계선
1. join 조건은 항상 ON entity.fk = parent.id다
// find의 join
await userRepo.find({
relations: { posts: true },
});
// → LEFT JOIN posts p ON p.user_id = user.id이 SQL은 Post의 모든 row를 매번 가져온다. 공개 글만 join하고 싶다면?
// find로는 불가능 — relations에 조건을 줄 수 없다
await userRepo.find({
relations: { posts: true },
where: { posts: { isPublic: true } }, // !! 함정
});위 코드는 동작은 하지만 의미가 다르다 — WHERE p.is_public = true는 부모 User까지 필터링해 공개 글이 없는 User는 결과에서 통째로 사라진다. join 조건이 아니라 전체 WHERE에 붙어버리기 때문.
QueryBuilder라면:
await userRepo.createQueryBuilder('u')
.leftJoinAndSelect('u.posts', 'p', 'p.is_public = :pub', { pub: true })
.getMany();
// → LEFT JOIN posts p ON p.user_id = u.id AND p.is_public = trueON 절에 조건이 박힌다 — 공개 글이 없는 User도 결과에 남고, 그 user의 posts만 빈 배열이 된다.
2. 서브쿼리 비교는 문법 자체가 없다
// "글이 5개 이상인 User만" → find로는 불가능
// QueryBuilder
await userRepo.createQueryBuilder('u')
.where(qb => {
const sub = qb.subQuery()
.select('p.user_id')
.from(Post, 'p')
.groupBy('p.user_id')
.having('COUNT(*) >= :n', { n: 5 })
.getQuery();
return `u.id IN ${sub}`;
})
.setParameter('n', 5)
.getMany();이런 상관 서브쿼리는 find에 문법 자체가 없다.
3. HAVING / UNION / WINDOW
// 카테고리별 평균 가격이 100 이상인 카테고리만
await productRepo.createQueryBuilder('p')
.select('p.category', 'category')
.addSelect('AVG(p.price)', 'avg_price')
.groupBy('p.category')
.having('AVG(p.price) >= :min', { min: 100 })
.getRawMany();groupBy, having, union, withRecursive — 모두 QueryBuilder만의 메서드다.
4. raw 표현 (함수 호출, CASE WHEN)
// 최근 7일 안에 활동한 사용자 수
await userRepo.createQueryBuilder('u')
.select('COUNT(DISTINCT u.id)', 'active_users')
.where('u.last_seen_at > NOW() - INTERVAL \'7 days\'')
.getRawOne();find에는 컬럼만 들어가고 표현식은 못 들어간다.
5. 다중 alias 같은 테이블 join (self-join)
// 사원과 그 매니저
await empRepo.createQueryBuilder('e')
.leftJoinAndSelect('employees', 'm', 'm.id = e.manager_id')
.getMany();같은 테이블을 다른 alias로 두 번 join하는 자리도 find에는 없다.
What — 경계선 신호 다섯 가지
| 신호 | find로 되나 | 대안 |
|---|---|---|
| 단순 selection + 1-depth relations | ✅ | — |
조건부 join (ON ... AND ...) | ❌ | leftJoinAndSelect(alias, 'p', 'p.x = :y', { y }) |
서브쿼리 비교 (WHERE x IN (SELECT ...)) | ❌ | where(qb => qb.subQuery()...) |
| 집계 + HAVING | ❌ | groupBy().having() |
| UNION / WITH / WINDOW | ❌ | QueryBuilder만 가능 |
| raw 표현 (CASE / 함수) | ❌ | addSelect('...', alias) + getRawMany |
| self-join · 임의 alias | ❌ | leftJoin(table, alias, on) |
Mermaid — 결정 트리
What-if — 잘못된 해석들
오해 1 — “find도 어차피 내부적으로 QueryBuilder를 쓴다”
맞다 — 하지만 find의 입력 schema가 제한적이다. find는 옵션 객체 → SQL 변환만을 지원하고, 그 변환에 없는 표현은 끼울 수 없다. 내부 구현과 바깥 API의 표현력은 다르다.
오해 2 — “복잡한 자리만 QueryBuilder로 바꾸고 나머지는 find로”
원칙은 그게 맞지만 — 한 자리에서 find와 createQueryBuilder를 섞어 쓰면 관계 매핑 의미가 불일치한다. 같은 도메인 함수 안에서는 하나로 통일하는 것이 유지보수에 낫다.
오해 3 — “QueryBuilder는 가독성이 떨어지니 피해야 한다”
find 옵션이 3단 이상 중첩되면 — QueryBuilder가 더 읽기 쉽다. 가독성은 옵션 객체의 깊이에 반비례한다.
오해 4 — “raw SQL을 그냥 쓰면 안 되나?”
dataSource.query('SELECT ...')도 가능하지만, 그 자리에서는 바인딩이 직접 책임이 되고 결과 매핑도 직접이다. QueryBuilder는 그 중간 단계 — 체이닝으로 SQL을 조립하면서도 바인딩과 매핑을 자동화한다.
Insight — 한 단락 이야기
“QueryBuilder는 ORM이 3단 사다리를 인정한 결과다”
1세대 ORM(Hibernate, Active Record)은 모든 것을 객체로를 외치다가, 어느 시점에서 HQL, Criteria API, Arel 같은 얇은 SQL DSL을 끼워 넣었다. TypeORM은 그 역사를 압축해서 처음부터 *find(객체) → QueryBuilder(체이닝) → raw(SQL)*의 3단 사다리를 제공한다. 사다리의 각 칸은 다른 추상 수준이고, 칸 사이를 의식적으로 오르내리는 것이 ORM을 잘 쓰는 일이다. 이 문서가 한 일은 언제 한 칸 위로 올라가야 하는지의 신호 다섯 가지를 못박는 것.
요약 + Mermaid
| 핵심 키 | 값 |
|---|---|
| find의 한계 | 조건부 join · subquery · having · union · raw 표현 |
| QueryBuilder의 자리 | 위 한계 직전까지의 표현력 + 바인딩 + 매핑 자동화 |
| raw의 자리 | ORM이 표현 자체가 없는 native 기능 |
| 사다리 | find ↔ QueryBuilder ↔ raw — 의식적으로 오르내려라 |
한 줄 결론 — find는 단순한 자리의 도구이고, QueryBuilder는 표현력이 필요한 자리의 도구다. 다음 문서(02)는 그 표현력의 최소 안전 단위인 :param 바인딩을 본다.