Query Builder — 집계 & 서브쿼리
GROUP BY, HAVING, 서브쿼리, DISTINCT는 find()가 표현력을 잃기 시작하는 지점입니다. 쿼리 빌더가 힘을 발휘하는 구간이죠. 이 페이지는 그 전부를 MySQL / PostgreSQL / SQLite에서 똑같이 동작하는 예제로 풀어봅니다.
GROUP BY와 집계
카테고리별 게시글 수를 세고, 게시글이 5개 이상인 카테고리만 남긴다고 해 봅시다. 요구 하나에 GROUP BY와 집계 함수와 HAVING이 전부 들어와요. 여기서 qAlias()가 특히 빛을 발합니다 — 같은 집계 표현식을 SELECT와 HAVING에 한 번씩만 적으면 되거든요.
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(*)를 세 번 적게 됩니다.
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 집계에 정리돼 있습니다.
그룹 수 세기
그룹화된 빌더에서 getCount()는 HAVING을 통과한 그룹의 개수를 돌려줍니다. 어느 한 그룹의 크기가 아니에요. paginate()와 getManyAndCount()의 total이 그룹 행과 맞아떨어지는 것도 이 규칙 덕분입니다. 그룹별 크기는 위처럼 집계를 투영한 뒤 getRawMany()로 읽으세요. 자세한 내용은 GROUP BY가 있는 getCount()를 참고하세요.
표현식으로 그룹화하기
groupBy()의 문자열 항목은 모양에 따라 컬럼 참조 아니면 표현식으로 해석됩니다. prop이나 alias.prop 형태의 맨 이름은 컬럼 참조예요. NamingStrategy와 @Column({ name })을 거쳐 실제 컬럼명으로 바뀌고 별칭과 함께 인용됩니다. 함수 호출, 연산자, 공백처럼 SQL 문법이 섞여 있으면 표현식으로 보고 그대로 내보냅니다.
const byCategory = await em
.createQueryBuilder(Post, "p")
.selectRaw(["UPPER(category) AS category", "COUNT(*) AS postCount"])
.groupBy(["UPPER(category)"])
.addOrderBy("UPPER(category)", "ASC")
.getRawMany();
// [{ category: "LIFE", postCount: 17 }, { category: "TECH", postCount: 42 }, ...]이 규칙은 빌더의 문자열 자리 전부에 똑같이 적용됩니다 — selectRaw(), addSelect(), groupBy(), addOrderBy(), 그리고 윈도우 partitionBy()까지. 같은 토큰은 어디에 두든 같은 SQL이 되죠. "그대로 내보낸다"에는 두 가지 함의가 있습니다.
표현식 문자열은 DB 컬럼명을 씁니다. 위의
category는 컬럼명이지 속성명이 아니에요. 표현식 안쪽은 아무것도 매핑하거나 인용하지 않고, 방언에도 묶입니다. 속성명을 인식하면서 방언까지 넘나드는 형태가 필요하면 QueryDSL을 쓰세요.p.category.toUpperCase(),Expressions.dateTrunc(p.createdAt, "month")처럼 속성을 해석하고, 파라미터를 바인딩하고, 방언별로 렌더링합니다.typescriptconst p = qAlias(Post, "p"); const category = p.category.toUpperCase(); await em .createQueryBuilder(Post, "p") .select([category.as("category"), p.id.count().as("postCount")]) .groupBy([category]) .addOrderBy(category, "ASC") .getRawMany();표현식 문자열은 raw SQL입니다. 코드에 리터럴로 적으세요. 요청 입력으로 조립하면 안 됩니다. 값은 바인딩 파라미터로 넘깁니다:
groupBy([sql\ROUND(price / ${100})`])`.
맨 이름은 언제나 컬럼 참조입니다. selectRaw(["... AS month"]) 뒤에 groupBy(["month"])를 쓰면 "p"."month"가 되어 DB가 거부해요. SELECT 별칭으로 그룹화하려면 sql 조각을 넘기세요 — groupBy([sql\month`]). 위 addOrderBy("postCount", ...)설명과 같은 이치입니다. 빈 문자열 항목은 SQL을 만들기 전에INVALID_QUERY`로 거부됩니다.
SELECT에서 집계 함수 바로 쓰기
addSelect()에 sql 템플릿 리터럴을 넘기면 집계 컬럼을 추가할 수 있습니다. 두 번째 인자 별칭이 결과 객체의 키가 돼요.
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 }]그룹화된 엔티티 컬럼과 집계 컬럼을 함께 쓸 수도 있습니다.
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 }, ...]엔티티 인식 조인까지 얹으면 테이블을 넘나드는 집계도 자연스럽게 됩니다.
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이 렌더링됩니다.
// 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()로 내려갑니다.
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()를 씁니다.
// 발행된 게시글이 한 편이라도 있는 작성자 찾기
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()로 계산 컬럼처럼 스칼라 서브쿼리를 얹을 수 있습니다.
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에서 이 파생 테이블을 쿼리합니다.
// 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를 씁니다.
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는 무시합니다.
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를 활성화합니다.
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가 답이 되지 못해요. 이럴 때 등장하는 게 윈도우 함수입니다.
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 → 윈도우 함수에서 이어서 볼 수 있어요.
다음 단계
- QueryDSL 표현식 — 타입드 집계 / 윈도우 / CASE / 날짜 컴포넌트
- 편의 패턴 → 서브쿼리 통합 — 타입드
whereInSubquery/whereExistsSubquery/addSelectSubquery,whereHas/withCount - Raw SQL & CTE — UNION, 재귀 CTE
- Query Builder 개요 — 기본 사용법과 전체 지도