Skip to content

Query Builder — 집계 & 서브쿼리

GROUP BY, HAVING, 서브쿼리, DISTINCT는 find()가 표현력을 잃기 시작하는 지점입니다. 쿼리 빌더가 힘을 발휘하는 구간이죠. 이 페이지는 그 전부를 MySQL / PostgreSQL / SQLite에서 똑같이 동작하는 예제로 풀어봅니다.

GROUP BY와 집계

카테고리별 게시글 수를 세고, 게시글이 5개 이상인 카테고리만 남긴다고 해 봅시다. 요구 하나에 GROUP BY와 집계 함수와 HAVING이 전부 들어와요. 여기서 qAlias()가 특히 빛을 발합니다 — 같은 집계 표현식을 SELECT와 HAVING에 한 번씩만 적으면 되거든요.

typescript
import { qAlias } from "@stingerloom/orm";

const p = qAlias(Post, "p");
const count = p.id.count();                // 한 번 정의

const stats = await em
  .createQueryBuilder(Post, "p")
  .select(["category"])
  .addSelect(count.as("postCount"))         // SELECT에 투영
  .groupBy(["p.category"])
  .having(count.gte(5))                     // HAVING에 재사용
  .addOrderBy(count.desc())                 // 정렬에도 재사용
  .getRawMany();
// [{ category: "tech", postCount: 42 }, { category: "life", postCount: 17 }, ...]

같은 쿼리를 raw sql 템플릿으로 쓴다면 어떨까요? COUNT(*)를 세 번 적게 됩니다.

typescript
import sql from "sql-template-tag";

await em.createQueryBuilder(Post, "p")
  .select(["category"])
  .addSelect(sql`COUNT(*)`, "postCount")
  .groupBy(["p.category"])
  .having(sql`COUNT(*) >= ${5}`)            // ← 반복
  .appendSql(sql`ORDER BY "postCount" DESC`)
  .getRawMany();

addOrderBy("postCount", "DESC")는 키를 FROM 별칭으로 한정해서 "p"."postCount"가 됩니다. SELECT 리스트에만 존재하는 별칭이라 DB가 거부하죠. SELECT 별칭으로 정렬하려면 appendSql(sql\ORDER BY "alias" ...`)`로 한 단계 내려갑니다 — 별칭이 그대로 살아남습니다.

둘 다 지원하긴 합니다. 다만 동일한 집계를 여러 자리에서 쓴다면 qAlias() 쪽이 유리해요. 기준을 바꿀 때 한 군데만 고치면 되니까요. .count(), .sum(), .avg(), .min(), .max(), .countDistinct() 전부 같은 방식으로 쓰고, 전체 연산자는 QueryDSL 집계에 정리돼 있습니다.

SELECT에서 집계 함수 바로 쓰기

addSelect()sql 템플릿 리터럴을 넘기면 집계 컬럼을 추가할 수 있습니다. 두 번째 인자 별칭이 결과 객체의 키가 돼요.

typescript
import sql from "sql-template-tag";

const stats = await em
  .createQueryBuilder(Order, "o")
  .addSelect(sql`AVG("o"."price")`, "avgPrice")
  .addSelect(sql`SUM("o"."price")`, "totalRevenue")
  .addSelect(sql`MIN("o"."price")`, "cheapest")
  .addSelect(sql`MAX("o"."price")`, "mostExpensive")
  .addSelect(sql`COUNT(*)`, "orderCount")
  .getRawMany();
// [{ avgPrice: 42.5, totalRevenue: 850, cheapest: 10, mostExpensive: 99, orderCount: 20 }]

그룹화된 엔티티 컬럼과 집계 컬럼을 함께 쓸 수도 있습니다.

typescript
const salesByCategory = await em
  .createQueryBuilder(Product, "p")
  .select(["category"])
  .addSelect(sql`AVG("p"."price")`, "avgPrice")
  .addSelect(sql`COUNT(*)`, "productCount")
  .groupBy(["category"])
  .having(sql`AVG("p"."price") > ${50}`)
  .orderBy({ category: "ASC" })
  .getRawMany();
// [{ category: "electronics", avgPrice: 299.99, productCount: 15 }, ...]

엔티티 인식 조인까지 얹으면 테이블을 넘나드는 집계도 자연스럽게 됩니다.

typescript
const p = qAlias(Post, "p");
const u = qAlias(User, "u");
const postCount = p.id.count();
const avgLikes = p.likeCount.avg();

const authorStats = await em
  .createQueryBuilder(Post, "p")
  .leftJoin(User, "u", (join) => join.on(p.col("authorId"), "=", u.col("id")))
  .selectRaw([u.col("name")])
  .addSelect(postCount.as("postCount"))
  .addSelect(avgLikes.as("avgLikes"))
  .groupBy([u.col("name")])
  .having(postCount.gte(3))
  .addOrderBy(postCount.desc())
  .getRawMany();
// [{ name: "Alice", postCount: 12, avgLikes: 45.3 }, ...]

표현식 빌더 addSelect()

addSelect()는 콜백도 받습니다. 콜백은 @ComputedColumn({ expression })과 동일한, 다이얼렉트 이식성이 보장되는 e 컨텍스트를 받아요 — e.col("alias.prop"), e.iff(...), e.count() / e.sum() / e.avg() / e.min() / e.max(), 그리고 ScalarExpression의 전체 산술 체인(add / sub / mul / div / floor / …)까지. raw SQL 없이도 어느 다이얼렉트에서든 올바른 SQL이 렌더링됩니다.

typescript
// COUNT(node.name) - 1 AS depth — raw SQL 없는 중첩 집합 깊이 계산
qb.addSelect((e) => e.count("node.name").sub(1), "depth");

// (c.rgt - (c.lft + 1)) / 2 AS children
qb.addSelect(
  (e) => e.col("c.rgt").sub(e.col("c.lft").add(1)).div(2).floor(),
  "children",
);

스칼라 결과는 별칭 인자가 필수입니다 — addSelect((e) => ..., "alias") — 없으면 INVALID_QUERY를 던져요. 집계는 별칭 인자를 쓰거나 .as("alias")로 마무리하면 됩니다.

이 조합이 가능한 건 AggregateExpression이 산술을 직접 지원하게 됐기 때문입니다. .toScalar()가 집계를 ScalarExpression으로 이어 주고, .add() / .sub() / .mul() / .div().toScalar().add(...)의 단축형이에요. 같은 표면이 qAlias() 집계에도 열려 있으니 p.id.count().sub(1)도 그대로 동작합니다.

서브쿼리

쿼리 빌더는 WHERE, SELECT, FROM 어느 자리에든 서브쿼리를 넣을 수 있습니다. find()로는 불가능한 영역이지만, 빌더에서는 간단해요.

WHERE IN 서브쿼리

타입드 단축은 whereInSubquery(column, subBuilder)입니다 — 패턴 & 생산성에서 사용 예제를 확인하세요. 서브쿼리가 SelectQueryBuilder로 표현되지 않을 때(예: raw 소스에서 이미 Sql 조각을 받은 경우)에만 Conditions.inSubquery()로 내려갑니다.

typescript
import sql from "sql-template-tag";
import { Conditions } from "@stingerloom/orm";

// 한국 작성자의 게시글 찾기
const subquery = em
  .createQueryBuilder(User, "u")
  .select(["id"])
  .where("country", "KR")
  .toSql();

const posts = await em
  .createQueryBuilder(Post, "p")
  .where(Conditions.inSubquery(`"p"."authorId"`, sql`(${subquery})`))
  .getMany();

WHERE EXISTS / NOT EXISTS

상관 서브쿼리에는 Conditions.exists() 또는 Conditions.notExists()를 씁니다.

typescript
// 발행된 게시글이 한 편이라도 있는 작성자 찾기
const authors = await em
  .createQueryBuilder(User, "a")
  .where(Conditions.exists(sql`(SELECT 1 FROM "post" "p" WHERE "p"."author_id" = "a"."id" AND "p"."status" = ${"published"})`))
  .getMany();

SELECT 절의 스칼라 서브쿼리

addSelect()로 계산 컬럼처럼 스칼라 서브쿼리를 얹을 수 있습니다.

typescript
const authors = await em
  .createQueryBuilder(User, "u")
  .select(["id", "name"])
  .addSelect(
    sql`(SELECT COUNT(*) FROM "post" "p" WHERE "p"."author_id" = "u"."id")`,
    "postCount",
  )
  .getRawMany();
// [{ id: 1, name: "Alice", postCount: 5 }, { id: 2, name: "Bob", postCount: 0 }]

FROM 서브쿼리 (파생 테이블)

asSubquery()SelectQueryBuilder를 파생 테이블로 바꾸고, RawQueryBuilder에서 이 파생 테이블을 쿼리합니다.

typescript
// 1단계: 안쪽 쿼리
const inner = em
  .createQueryBuilder(Post, "p")
  .select(["authorId"])
  .addSelect(sql`COUNT(*)`, "cnt")
  .groupBy(["authorId"]);

// 2단계: 파생 테이블로 사용
const qb = em.createQueryBuilder();
const results = await em.query(
  qb
    .select(['"sub"."authorId"', '"sub"."cnt"'])
    .from(inner.asSubquery("sub"))
    .where([sql`"sub"."cnt" >= ${3}`])
    .build()
);
// 게시글 3편 이상인 작성자

CTE (공통 테이블 표현식)

복잡한 다단계 쿼리에는 RawQueryBuilder의 CTE를 씁니다.

typescript
const qb = em.createQueryBuilder();

const results = await em.query(
  qb
    .with("active_authors", (sub) =>
      sub
        .select(['DISTINCT "authorId"'])
        .from('"post"')
        .where([sql`"status" = ${"published"}`])
    )
    .select(['"u"."id"', '"u"."name"'])
    .from('"user" "u"')
    .where([sql`"u"."id" IN (SELECT "authorId" FROM "active_authors")`])
    .build()
);

재귀 CTE — 댓글 스레드나 조직도 같은 계층 데이터 — 가 필요하면 Raw SQL & CTE를 참고하세요.

SelectQueryBuilder를 안는 상위 헬퍼

서브쿼리 자체가 SelectQueryBuilder라면 타입드 헬퍼 쪽이 편합니다. 파라미터 바인딩이 그대로 유지되고, 안쪽 쿼리에도 자동완성이 돼요.

  • whereInSubquery(column, subQb) / whereNotInSubquery(...)
  • whereExistsSubquery(subQb) / whereNotExistsSubquery(...)
  • addSelectSubquery(subQb, alias)

전체 세트는 편의 패턴에서 다룹니다.

스칼라 집계 터미널

쿼리의 WHERE / JOIN 범위 안에서 단일 집계 값이 필요할 때, getSum(), getAvg(), getMin(), getMax() 터미널 메서드를 쓰면 addSelect(sql\SUM(...)`, "s").getRawOne()` 블록을 직접 작성하지 않아도 됩니다. 빌더의 FROM / JOIN / WHERE / soft-delete / 테넌트 범위를 그대로 재사용하며, 이미 설정된 LIMIT / OFFSET / ORDER BY는 무시합니다.

typescript
const qb = em
  .createQueryBuilder(Order, "o")
  .leftJoin(User, "u", (j) => j.on("o.userId", "=", "u.id"))
  .where("u.country", "KR")
  .where("o.status", "completed");

const total   = await qb.getSum("amount");   // SUM(o.amount)
const average = await qb.getAvg("amount");   // AVG(o.amount)
const lowest  = await qb.getMin("amount");   // MIN(o.amount)
const highest = await qb.getMax("amount");   // MAX(o.amount)

네 메서드 모두 Promise<number>를 반환합니다. 매칭 행이 없거나 해당 컬럼이 모두 NULL인 경우 결과는 0으로 변환됩니다. EntityManager.sum() / avg() / min() / max()와 동일한 동작이에요.

column 인자는 루트 엔티티의 camelCase 프로퍼티 이름으로 제한된 ColumnOf<T> 타입이라 오타는 컴파일 에러로 잡힙니다.

메서드SQL반환
getSum(column)SELECT SUM(col) FROM ...Promise<number> — 빈 결과 시 0
getAvg(column)SELECT AVG(col) FROM ...Promise<number> — 빈 결과 시 0
getMin(column)SELECT MIN(col) FROM ...Promise<number> — 빈 결과 시 0
getMax(column)SELECT MAX(col) FROM ...Promise<number> — 빈 결과 시 0

추가 GROUP BY 없이 동일 범위에서 여러 집계 스칼라가 필요할 때 getCount()와 함께 자연스럽게 조합할 수 있습니다.

DISTINCT

중복 없는 행만 받고 싶을 때는 DISTINCT를 활성화합니다.

typescript
const uniqueCities = await em
  .createQueryBuilder(User, "u")
  .select(["city"])
  .setDistinct()
  .getPartialMany();
// SELECT DISTINCT "u"."city" FROM "user" AS "u"

결과 집합에서 중복 행을 제거합니다. 선택한 컬럼의 조합이 겹치는 행이 많을 때 유용해요.

집계 내부 DISTINCT — COUNT(DISTINCT col) — 가 필요하면 .count() 대신 .countDistinct()를 씁니다. 두 DISTINCT는 의미가 다릅니다. 행 수준 DISTINCT는 결과 행 수 자체를 줄이고, 집계 내부 DISTINCT는 집계 대상 값의 중복만 제거합니다.

DISTINCT ON (PostgreSQL 전용)은 Raw SQL & CTE에서 다룹니다.

GROUP BY로는 부족할 때 — 윈도우 집계

GROUP BY는 그룹마다 행을 하나로 눌러 버립니다. "개별 행은 그대로 두면서 팀별 누적 합계를 같이 보여달라"처럼 원본 행을 유지한 채 집계만 얹어야 하는 경우에는 GROUP BY가 답이 되지 못해요. 이럴 때 등장하는 게 윈도우 함수입니다.

typescript
const e = qAlias(Event, "e");

qb.select(["id", "teamId", "score"])
  .addSelect(
    e.score.sum().over()
      .partitionBy(e.teamId)
      .orderBy(e.createdAt.asc())
      .rowsBetween("UNBOUNDED PRECEDING", "CURRENT ROW")
      .as("running_total"),
  )
  .getRawMany();

표현식을 .toScalar()로 마무리하면 WHERE / HAVING 안에 서브쿼리처럼 꽂을 수도 있습니다. API 전체와 리더보드 실전 예제는 QueryDSL → 윈도우 함수에서 이어서 볼 수 있어요.

다음 단계

Released under the MIT License.