04 · 서브쿼리

이 문서가 답하는 질문: WHERE x IN (SELECT ...), FROM (SELECT ...) sub, EXISTS (SELECT 1 ...) 같은 서브쿼리는 QueryBuilder로 어떻게 표현하는가? 한 줄 답: “서브쿼리는 QB 안의 QB다 — where(qb => qb.subQuery()...)로 안쪽 QB를 만들고, getQuery()로 SQL 문자열을 추출해 바깥 SQL에 끼워 넣는다.”


Why — 왜 서브쿼리를 별도 문서로 빼나

서브쿼리는 QueryBuilder의 가장 어려운 영역이다. 왜냐하면:

  1. QB 안에서 QB를 만드는 콜백 문법이 시각적으로 낯설다.
  2. 바인딩 파라미터의 scope가 안쪽/바깥쪽 사이에서 공유되지 않을 수 있다.
  3. where에 서브쿼리를 넣는 자리와 from에 넣는 자리가 문법이 다르다.

이 세 가지를 한 번에 정리하지 않으면 — 문서를 봐도 어디서 시작할지 못 잡는다.

핵심 주장:

  • where(qb => qb.subQuery()...)바깥 QB의 인스턴스에서 서브 QB를 파생시킨다.
  • from(subquery, alias)서브쿼리를 테이블처럼 다룬다 — FROM (SELECT ...) sub SQL.
  • 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. INWHERE u.id IN (SELECT p.user_id FROM posts p)일반적
B. EXISTSWHERE EXISTS (SELECT 1 FROM posts p WHERE p.user_id = u.id)상 (보통)상관 조건 있을 때
C. innerJoinINNER 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은 상관 EXISTSINNER 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 ...) subfrom(qb => qb.subQuery()..., 'sub')❌ getRawMany 필요
EXISTS (SELECT 1 ...)where(qb => 'EXISTS ' + qb.subQuery()...getQuery())✅ getMany 가능
바인딩바깥 QB의 setParameter로 통일

한 줄 결론 — 서브쿼리는 QB 안의 QB고, 콜백 형식못생긴 대신 표현력을 준다. 다음 문서(05)는 서브쿼리로도 부족할 때 내려가는 raw 결과를 본다.