04 · 서브쿼리
이 문서가 답하는 질문:
WHERE x IN (SELECT ...),FROM (SELECT ...) sub,EXISTS (SELECT 1 ...)같은 서브쿼리는 QueryBuilder로 어떻게 표현하는가? 한 줄 답: “서브쿼리는 QB 안의 QB다 —where(qb => qb.subQuery()...)로 안쪽 QB를 만들고, getQuery()로 SQL 문자열을 추출해 바깥 SQL에 끼워 넣는다.”
Why — 왜 서브쿼리를 별도 문서로 빼나
서브쿼리는 QueryBuilder의 가장 어려운 영역이다. 왜냐하면:
- QB 안에서 QB를 만드는 콜백 문법이 시각적으로 낯설다.
- 바인딩 파라미터의 scope가 안쪽/바깥쪽 사이에서 공유되지 않을 수 있다.
where에 서브쿼리를 넣는 자리와from에 넣는 자리가 문법이 다르다.
이 세 가지를 한 번에 정리하지 않으면 — 문서를 봐도 어디서 시작할지 못 잡는다.
핵심 주장:
where(qb => qb.subQuery()...)는 바깥 QB의 인스턴스에서 서브 QB를 파생시킨다.from(subquery, alias)는 서브쿼리를 테이블처럼 다룬다 —FROM (SELECT ...) subSQL.EXISTS패턴은 상관 서브쿼리로, 바깥 alias를 참조할 수 있다.- 바인딩 파라미터는 *바깥 QB의
setParameter*로 통일해 등록하는 것이 안전하다.
How — 세 가지 서브쿼리 형태
1. WHERE x IN (SELECT …)
// "글이 5개 이상인 user 목록"
await userRepo.createQueryBuilder('u')
.where(qb => {
const sub = qb.subQuery()
.select('p.user_id')
.from(Post, 'p')
.groupBy('p.user_id')
.having('COUNT(*) >= :n')
.getQuery();
return `u.id IN ${sub}`;
})
.setParameter('n', 5)
.getMany();생성 SQL:
SELECT u.*
FROM users u
WHERE u.id IN (
SELECT p.user_id FROM posts p GROUP BY p.user_id HAVING COUNT(*) >= $1
);핵심 포인트:
where(qb => ...)콜백 형식 — 바깥 QB 인스턴스를 받아qb.subQuery()로 새 QB를 파생..getQuery()는 SQL 문자열을 괄호 포함해서 반환 →(SELECT ...)형태.- 바인딩은 바깥 QB에 등록 (
setParameter('n', 5)) — 안쪽에 등록하면 덮어쓰기로 잃을 수 있다.
2. FROM (SELECT …) sub
// "각 카테고리에서 가장 비싼 product 1개"
await dataSource.createQueryBuilder()
.select('sub.*')
.from(qb => {
return qb.subQuery()
.select(['p.id', 'p.name', 'p.category', 'p.price',
'ROW_NUMBER() OVER (PARTITION BY p.category ORDER BY p.price DESC) AS rn'])
.from(Product, 'p');
}, 'sub')
.where('sub.rn = 1')
.getRawMany();생성 SQL:
SELECT sub.*
FROM (
SELECT p.id, p.name, p.category, p.price,
ROW_NUMBER() OVER (PARTITION BY p.category ORDER BY p.price DESC) AS rn
FROM products p
) sub
WHERE sub.rn = 1;핵심 포인트:
from(qb => qb.subQuery()..., 'sub')— 첫 번째 인자가 콜백, 두 번째가 alias.- 결과는 엔티티에 매핑되지 않는다 —
getRawMany()로 받아야 한다 (다음 문서). - WINDOW 함수 같은 raw 표현을 쓸 때 가장 자연스러운 자리.
3. EXISTS / NOT EXISTS
// "최근 30일 안에 글을 쓴 user"
await userRepo.createQueryBuilder('u')
.where(qb => {
const sub = qb.subQuery()
.select('1')
.from(Post, 'p')
.where('p.user_id = u.id')
.andWhere('p.created_at > NOW() - INTERVAL \'30 days\'')
.getQuery();
return `EXISTS ${sub}`;
})
.getMany();생성 SQL:
SELECT u.*
FROM users u
WHERE EXISTS (
SELECT 1 FROM posts p WHERE p.user_id = u.id AND p.created_at > NOW() - INTERVAL '30 days'
);핵심 포인트:
- 상관 서브쿼리 — 안쪽에서 바깥
u.id를 참조한다. IN대신EXISTS가 효율적인 경우가 많다 (DB 옵티마이저에 따라 다름).NOT EXISTS는 부정 — “한 번도 글을 안 쓴 user”.
Mermaid — 세 가지 형태
What — 서브쿼리의 정확한 의미와 함정
비교 테이블 — 같은 의도, 다른 SQL
// "글이 1개 이상인 user"
// A. IN 서브쿼리
.where(qb => {
const sub = qb.subQuery().select('p.user_id').from(Post, 'p').getQuery();
return `u.id IN ${sub}`;
})
// B. EXISTS 상관 서브쿼리
.where(qb => {
const sub = qb.subQuery().select('1').from(Post, 'p').where('p.user_id = u.id').getQuery();
return `EXISTS ${sub}`;
})
// C. innerJoin (단순)
.innerJoin('u.posts', 'p').groupBy('u.id') // DISTINCT 필요할 수도
// D. find — *불가능*| 방법 | SQL | 옵티마이저 친화도 | 사용 시점 |
|---|---|---|---|
| A. IN | WHERE u.id IN (SELECT p.user_id FROM posts p) | 중 | 일반적 |
| B. EXISTS | WHERE EXISTS (SELECT 1 FROM posts p WHERE p.user_id = u.id) | 상 (보통) | 상관 조건 있을 때 |
| C. innerJoin | INNER JOIN posts p ON p.user_id = u.id GROUP BY u.id | 상 (인덱스 시) | 매핑 불필요 시 |
| D. find | — | — | ❌ 불가능 |
함정 1 — 바인딩 scope
// ❌ 안쪽 setParameter는 사라질 수 있다
.where(qb => {
const sub = qb.subQuery()
.select('p.user_id')
.from(Post, 'p')
.having('COUNT(*) >= :n', { n: 5 }) // 안쪽 바인딩
.getQuery();
return `u.id IN ${sub}`;
})
// → :n이 *바깥 QB에 등록 안 됨*, 실행 시 missing parameter 오류
// ✅ 바깥 QB의 setParameter로 등록
.where(qb => { ... })
.setParameter('n', 5)규칙: 서브쿼리 안의 바인딩은 바깥 QB의 setParameter로 끌어올린다.
함정 2 — getQuery는 괄호 포함
// ✅ 올바름 — getQuery()가 이미 괄호 포함
return `u.id IN ${sub}`;
// → "u.id IN (SELECT ...)"
// ❌ 잘못 — 괄호 두 번
return `u.id IN (${sub})`;
// → "u.id IN ((SELECT ...))" — 문법 오류는 아니지만 의도 불일치함정 3 — alias 충돌
// ❌ 같은 alias 'p'를 안팎에서 사용
await userRepo.createQueryBuilder('u')
.leftJoinAndSelect('u.posts', 'p')
.where(qb => {
const sub = qb.subQuery().select('p.user_id').from(Post, 'p').getQuery();
return `u.id IN ${sub}`;
})
.getMany();
// → 어느 'p'를 가리키는지 모호 — 옵티마이저는 가까운 쪽을 잡지만 *시각적 함정*규칙: 안과 밖에서 alias를 다르게 짓는다 — p (바깥) / sp (서브).
함정 4 — 서브쿼리 결과는 엔티티 매핑되지 않는다
from(subquery)로 만든 결과는 임의 컬럼 dictionary다 — User 타입으로 매핑할 길이 없다. getRawMany()로 받아 수동 매핑 또는 엔티티화 단계를 거쳐야 한다 (다음 문서).
What-if — 잘못된 해석들
오해 1 — “서브쿼리는 성능에 항상 나쁘다”
옵티마이저에 따라 다르다. PostgreSQL은 상관 EXISTS를 INNER JOIN으로 자동 변환하는 경우가 많고, 그 둘의 실행 계획이 동일하다. EXPLAIN ANALYZE 없이 추측만으로 피하면 오히려 더 복잡한 SQL을 만든다.
오해 2 — “IN과 EXISTS는 같다”
결과는 같을 수 있지만 NULL 처리가 다르다. WHERE x IN (NULL, 1)은 x=1만 매치, WHERE x NOT IN (NULL, 1)은 항상 false (NULL 때문). EXISTS는 NULL을 row 존재 유무로 다룬다 — 대형 데이터에서는 EXISTS가 안전하다.
오해 3 — “서브쿼리 안에서 outer alias 참조는 안 된다”
상관 서브쿼리(p.user_id = u.id)는 바깥 alias를 참조한다 — 이건 SQL의 정상 기능이고 QueryBuilder도 그대로 통과시킨다. 단지 문자열로 작성해야 한다 — TypeScript 타입 검사는 여기서 멈춘다.
오해 4 — “QueryBuilder는 CTE(WITH)도 지원한다”
TypeORM 0.3.x에서 .addCommonTableExpression()이 제한적으로 지원되긴 하지만 — 복잡한 CTE는 여전히 raw SQL이 더 명확하다. 서브쿼리는 WHERE/FROM까지가 실용적 한계다.
Insight — 한 단락 이야기
“서브쿼리는 ORM이 SQL의 표현력을 인정하는 자리다”
ORM이 처음 등장했을 때 사람들이 가장 그리워한 SQL 기능이 서브쿼리였다.
WHERE x IN (SELECT ...)은 객체 그래프 탐색으로는 자연스럽게 표현되지 않는다 — 객체 세계의 “조건을 만족하는 객체들의 id 목록”은 집합 연산이지 그래프 따라가기가 아니기 때문. TypeORM의where(qb => qb.subQuery())콜백 문법은 못생긴 타협이다 — 시각적으로 두 단계 콜백이고, 바인딩 scope가 헷갈리며, alias 충돌이 조용히 일어난다. 하지만 그 못생김의 대가로 — SQL의 모든 서브쿼리 표현을 체이닝으로 살려낼 수 있다. API 설계의 묘수는 “아름다움”이 아니라 “표현 가능한 영역의 넓이” — 못생긴 콜백 한 단계가 SQL 표현력 전체를 끌어올린다.
요약 + Mermaid
| 패턴 | 문법 | 매핑 |
|---|---|---|
WHERE x IN (SELECT ...) | where(qb => 'u.id IN ' + qb.subQuery()...getQuery()) | ✅ getMany 가능 |
FROM (SELECT ...) sub | from(qb => qb.subQuery()..., 'sub') | ❌ getRawMany 필요 |
EXISTS (SELECT 1 ...) | where(qb => 'EXISTS ' + qb.subQuery()...getQuery()) | ✅ getMany 가능 |
| 바인딩 | 바깥 QB의 setParameter로 통일 | — |
한 줄 결론 — 서브쿼리는 QB 안의 QB고, 콜백 형식이 못생긴 대신 표현력을 준다. 다음 문서(05)는 서브쿼리로도 부족할 때 내려가는 raw 결과를 본다.