04-query-builder — QueryBuilder
이 챕터가 답하는 질문:
find로 풀 수 없는 join·subquery·raw·having·union의 자리에서, QueryBuilder는 어떻게 SQL의 손잡이를 다시 손에 쥐여 주는가? 한 줄 답 (Pyramid Top): “QueryBuilder는 TypeORM의 추상의 탈출구다 — find가 표현할 수 없는 SQL을 타입 안전한 체이닝으로 다시 작성하게 해주는, ORM 위에 얹은 얇은 SQL DSL.”
한 문장 답 (Pyramid Top)
find/findOne은 단순 selection + 1-depth relations까지가 한계다. 복잡한 join 조건, 서브쿼리,HAVING,UNION, raw 표현은 모두 QueryBuilder의 자리다. QueryBuilder는 체이닝 메서드로 SQL 문장을 한 토막씩 조립하면서도 —:param바인딩을 강제해 SQL injection을 차단하고, 결과를 엔티티로 매핑하는 단계를 선택적으로 끼워준다. 하지만 문자열 인터폴레이션 한 번이면 그 모든 안전장치가 무너지고, raw 결과 매핑을 잘못 끼우면 ORM의 의미가 절반쯤 사라진다. 이 챕터는 QueryBuilder의 언제 등장하는가부터 select·where·join·subquery·raw·pagination까지 6층으로 분해한다.
챕터 지도 (Mermaid)
Why — 왜 이 챕터를 별도로 빼는가
TypeORM을 얼마나 잘 쓰는가는 거의 전부 QueryBuilder를 얼마나 잘 다루는가로 결정된다. 세 가지 잘못된 직관이 거의 모든 사고의 출처다.
| 잘못된 직관 | 실제 | 어디서 다루나 |
|---|---|---|
| ”find 옵션을 깊게 키우면 다 풀린다” | find의 join은 항상 LEFT JOIN이고, 조건부 join·subquery 비교는 불가능. | 01 |
| ”QueryBuilder도 어차피 SQL이니 문자열 그냥 끼우자” | ${userInput}을 SQL에 끼우는 모든 자리는 SQL injection 직행 통로다. | 02 |
”skip/take은 offset/limit의 별명이다” | join이 있을 때 skip/take는 별도 SELECT id 쿼리를 발사한다 — 결과가 다르다. | 06 |
이 챕터는 QueryBuilder를 언제 꺼내는가 → 무엇으로 조립하는가 → 어디서 추상을 깨는가 → 어떻게 자르는가의 네 층으로 본다.
How — 어떻게 읽나
다음 6개 문서를 순서대로 읽으면 약 70분이 걸린다. 각 문서는 독립적으로 읽혀도 되지만, 누적적이다.
| # | 파일 | 읽는 데 | 핵심 키워드 |
|---|---|---|---|
| 01 | 01-when-querybuilder.mdx | 10분 | find의 한계 · 복잡 join · having · union · raw 표현 |
| 02 | 02-select-and-where.mdx | 12분 | select · addSelect · where · andWhere · :param · injection |
| 03 | 03-join-strategies.mdx | 14분 | innerJoin · leftJoinAndSelect · leftJoinAndMapOne/Many · raw vs entity |
| 04 | 04-subqueries.mdx | 12분 | where(qb => qb.subQuery()) · from(subquery) · EXISTS · IN (SELECT …) |
| 05 | 05-raw-and-getRawMany.mdx | 10분 | getRawMany vs getMany · raw 결과 매핑 · 추상화 깨는 시점 |
| 06 | 06-pagination-and-counting.mdx | 12분 | skip/take · offset/limit · getManyAndCount · N+1 위험 |
의존성: 02는 01을, 03은 02를, 0405는 03을, 06은 0305를 가정한다.
What — 한 페이지 요약 (모든 문서의 핵심 한 줄)
| 문서 | 한 줄 결론 |
|---|---|
| 01 | find는 읽기 쉬운 selection까지의 도구다 — 그 너머의 조건부 join·subquery·having·union은 QueryBuilder의 자리다. |
| 02 | where('a = :x', { x })는 parameterized고, 문자열 인터폴레이션(예: `a = '${x}'`)은 SQL injection이다 — 둘은 시각적으로만 비슷하다. |
| 03 | leftJoinAndSelect는 엔티티로 매핑되지만, leftJoinAndMapOne/Many는 임의 alias 결과를 엔티티 필드로 수동 매핑한다. |
| 04 | 서브쿼리는 QB 안의 QB고, from(subquery)로 서브쿼리를 테이블처럼 쓸 때 진가가 드러난다. |
| 05 | getMany는 엔티티 객체를, getRawMany는 컬럼 dictionary를 준다 — 둘을 섞으면 ORM 의미가 절반 사라진다. |
| 06 | skip/take는 join이 있을 때 별도 id 쿼리를 발사해 정확성을 지키지만 2번 round-trip의 비용을 낸다. |
What-if — 이 챕터를 건너뛰면
01(when)을 모르면: 모든 곳을find로 풀려다가 옵션이 10단 중첩된 거대 객체를 만든다.02(select/where)를 모르면: 검색창 입력을where(`name LIKE '%${q}%'`)식으로 끼워 넣고 — 어느 새벽 DROP TABLE을 만난다.03(join)을 모르면: 모든 join을leftJoinAndSelect로 박아 Cartesian explosion으로 메모리 OOM을 만난다.04(subquery)를 모르면: 서브쿼리 자리에 raw 문자열을 끼워 바인딩이 깨진 SQL을 디버깅한다.05(raw)를 모르면:getRawMany결과를 엔티티처럼 다루다가 relation 접근에서 undefined를 만난다.06(pagination)을 모르면: join +skip/take조합에서 count가 안 맞는다고 틀린 쿼리를 추가하다가 더 망친다.
Insight — 한 단락 이야기
“QueryBuilder는 ORM의 겸손이 만든 추상이다”
ORM은 모든 SQL을 객체로 흡수하겠다는 야망에서 출발한다. 하지만 SQL은 40년에 걸쳐 정착한 모든 표현을 가진 언어고, 객체 그래프 탐색만으로는
WINDOW,LATERAL,WITH RECURSIVE, 복잡한HAVING을 표현할 수 없다. TypeORM의 설계자는 그 한계를 인정하고 QueryBuilder를 얇은 SQL DSL로 끼워 넣었다 —find로 풀리는 자리는 find로, 안 풀리는 자리는 QueryBuilder로, 그래도 안 되면 raw로. 이 3단 사다리가 TypeORM의 실용주의다. 추상의 묘수는 “모든 것을 흡수하는 것”이 아니라 “흡수가 안 되는 자리를 드러내고 거기 손잡이를 놓는 것” — QueryBuilder는 그 손잡이의 이름이다.
Mermaid 4색 규약
한 단락 요약
QueryBuilder는 SQL의 손잡이를 다시 손에 쥐는 TypeORM의 탈출구다 —
find가 표현 못하는 자리(01)를 체이닝 메서드로 조립한다. 그 조립의 최소 안전 단위는:param바인딩이고(02), 가장 자주 쓰이는 토막은 join(03)과 subquery(04)다. 추상이 부족할 땐getRawMany로 raw 결과까지 내려가지만(05), 그 경계는 의식적으로 그어야 한다. 마지막에 결과를 자르는 pagination/counting은 join 유무에 따라 완전히 다른 SQL이 나간다(06). 이 챕터를 끝내면 “이건 find로 될까?” 대신 “이 SQL은 어느 손잡이에 가장 잘 맞나?” 라는 질문을 던지게 된다. 다음 챕터(05-transaction-datasource)는 이렇게 만든 쿼리들을 한 트랜잭션으로 묶는 길을 다룬다.