QueryBuilder 생성과 타입 시스템: em.qb() 단축 문법
MikroORM의 QueryBuilder(이하 QB)는 ORM 엔티티 메타데이터를 활용하면서도 SQL 수준의 제어가 필요할 때 사용하는 도구입니다. v4부터는 반드시 드라이버 패키지에서 타입이 지정된 EntityManager를 사용해야 createQueryBuilder()에 접근할 수 있습니다.
// ⚠️ @mikro-orm/core가 아닌 드라이버 패키지에서 import
import { EntityManager } from '@mikro-orm/mysql'; // 또는 postgresql, sqlite
// v5+ 단축 문법
const qb = em.qb(Author); // em.createQueryBuilder(Author)와 동일
const qb2 = em.qb(Author, 'a'); // 별칭(alias) 지정
v5부터 QB는 호출한 메서드에 따라 자동으로 타입이 분기됩니다:
| 호출 메서드 | QB 타입 | await 결과 |
|---|---|---|
.select() |
SelectQueryBuilder | Entity[] (getResultList()와 동일) |
.count() |
CountQueryBuilder | number |
.insert() |
InsertQueryBuilder | QueryResult<T> |
.update() |
UpdateQueryBuilder | QueryResult<T> |
.delete() |
DeleteQueryBuilder | QueryResult<T> |
// v5+ await 직접 가능 — 타입이 자동 추론됨
const books = await em.qb(Book).select('*').where({ price: { $gt: 10 } });
// books: Book[]
const count = await em.qb(Book).count().where({ price: { $gt: 10 } });
// count: number
const result = await em.qb(Book).delete().where({ archived: true });
// result: QueryResult<Book> — { affectedRows, insertId, row }
execute() vs getResult() vs await: 결과 형태 선택
QB는 세 가지 실행 방식을 제공하며, 각각 반환 형태가 다릅니다:
| 메서드 | 반환 타입 | Identity Map 등록 | 사용 시점 |
|---|---|---|---|
execute('all') |
plain object[] | 안 됨 | raw 데이터만 필요할 때 |
execute('get') |
plain object (단일) | 안 됨 | 집계/통계 쿼리 |
execute('run') |
{ affectedRows, insertId } | 안 됨 | INSERT/UPDATE/DELETE |
getResult() |
Entity[] | 등록됨 | 엔티티 인스턴스 필요할 때 |
getSingleResult() |
Entity | null | 등록됨 | 단일 엔티티 조회 |
// execute(): raw 객체 — 컬럼 매핑만 (camelCase 변환)
const raw = await em.qb(Book).select('*').execute('get', true);
console.log(raw.createdAt); // Date — 프로퍼티명으로 매핑됨
console.log(raw instanceof Book); // false — plain object
// getResult(): 엔티티 인스턴스 — Identity Map에 등록
const book = await em.qb(Book).select('*').where({ id: 1 }).getSingleResult();
console.log(book instanceof Book); // true
console.log(em.getUnitOfWork().getById(Book, 1) === book); // true
핵심: getResult()/getSingleResult()로 반환된 엔티티는 Identity Map에 등록되어 이후 em.flush()에서 변경 감지(change tracking)가 동작합니다. execute()는 ORM을 우회하는 raw 쿼리이므로 Unit of Work에 포함되지 않습니다.
Implicit Joining: 관계 메타데이터 기반 자동 조인
MikroORM QB의 가장 강력한 기능 중 하나는 where/orderBy에서 관계 프로퍼티를 사용하면 자동으로 JOIN을 생성하는 것입니다.
// 1단계 관계 자동 조인
const tags = await em.qb(BookTag, 't')
.select('*')
.where({ books: 123 })
.getResultList();
// 생성되는 SQL:
// SELECT `t`.*, `e1`.`book_tag_id`, `e1`.`book_uuid_pk`
// FROM `book_tag` AS `t`
// LEFT JOIN `book_to_book_tag` AS `e1` ON `t`.`id` = `e1`.`book_tag_id`
// WHERE `e1`.`book_uuid_pk` = ?
// 다중 레벨 중첩 — Author → Book → BookTag
const authors = await em.qb(Author)
.select('*')
.where({ books: { tags: { name: 'TypeScript' } } })
.orderBy({ books: { tags: { createdBy: QueryOrder.DESC } } })
.getResultList();
// 3개의 LEFT JOIN이 자동 생성됨:
// LEFT JOIN `book` AS `e1` ON `e0`.`id` = `e1`.`author_id`
// LEFT JOIN `book_to_book_tag` AS `e3` ON `e1`.`uuid_pk` = `e3`.`book_uuid_pk`
// LEFT JOIN `book_tag` AS `e2` ON `e3`.`book_tag_id` = `e2`.`id`
// WHERE `e2`.`name` = ?
제약사항: Implicit joining은 where와 orderBy에서만 동작합니다. 루트 엔티티만 select되며, 관계 엔티티를 함께 로드하려면 em.populate()를 별도로 호출하거나 explicit join을 사용해야 합니다.
Explicit Joining: join()·leftJoin()·joinAndSelect()
기본 join: 조건에만 사용
const results = await em.qb(BookTag, 't')
.select(['b.uuid', 'b.*', 't.*'], true) // true = DISTINCT
.join('t.books', 'b')
.where({ 'b.title': 'MikroORM in Action' })
.limit(10, 0)
.execute('all');
// join() = INNER JOIN
// leftJoin() = LEFT JOIN
joinAndSelect: 관계 엔티티까지 hydrate
leftJoinAndSelect()를 사용하면 조인된 관계까지 엔티티 인스턴스로 hydrate하여 populate된 것처럼 사용할 수 있습니다.
// Author에 books와 tags가 populate된 상태로 반환
const authors = await em.qb(Author, 'a')
.select('*')
.leftJoinAndSelect('a.books', 'b')
.leftJoinAndSelect('b.tags', 't')
.where({ 't.name': ['backend', 'database'] })
.getResultList();
// authors[0].books.isInitialized() === true
// authors[0].books[0].tags.isInitialized() === true
populate vs joinAndSelect 선택 기준
| 방식 | SQL 전략 | 장점 | 단점 |
|---|---|---|---|
em.populate() |
SELECT_IN (기본) 또는 JOINED | N+1 자동 방지, 간결한 API | WHERE 조건에 관계 사용 불가 |
leftJoinAndSelect() |
단일 JOIN 쿼리 | 관계 필드로 필터링 가능 | 카르테시안 곱 주의, 수동 alias 관리 |
운영 팁: “관계 필드를 WHERE/ORDER BY 조건으로 사용해야 하면” joinAndSelect(), “단순히 관계를 로드만 하면” populate()를 사용합니다.
서브쿼리: WHERE·JOIN·SELECT에서 활용
WHERE 서브쿼리
// 리뷰가 5개 이상인 책의 저자만 조회
const subquery = em.qb(Book, 'b')
.select('b.author')
.where({ reviewCount: { $gte: 5 } });
const authors = await em.qb(Author, 'a')
.select('*')
.where({ id: { $in: subquery.getKnexQuery() } })
.getResultList();
JOIN 서브쿼리: 관계 조인을 서브쿼리로 대체
MikroORM은 관계 조인의 타겟을 서브쿼리로 오버라이드하면서도 원래 메타데이터를 유지하여 hydration을 정상 수행합니다.
// 저자별 가장 최신 책 1권만 조인 (lateral join 대안)
const bookSubquery = em.qb(Book, 'b')
.where({ price: { $gt: 0 } })
.orderBy({ createdAt: 'DESC' })
.limit(1);
const authors = await em.qb(Author, 'a')
.select('*')
// 튜플로 [관계경로, 서브쿼리] 전달
.leftJoinAndSelect(['a.books', bookSubquery], 'b')
.leftJoinAndSelect('b.tags', 't')
.getResultList();
// 생성되는 SQL:
// SELECT `a`.*, `b`.`id` AS `b__id`, ...
// FROM `author` AS `a`
// LEFT JOIN (
// SELECT `b`.* FROM `book` AS `b`
// WHERE `b`.`price` > 0
// ORDER BY `b`.`created_at` DESC LIMIT 1
// ) AS `b` ON `b`.`author_id` = `a`.`id`
// LEFT JOIN `book_tags` AS `e1` ON `b`.`uuid_pk` = `e1`.`book_uuid_pk`
// LEFT JOIN `book_tag` AS `t` ON `e1`.`book_tag_id` = `t`.`id`
복잡한 WHERE 조건: $and·$or·$not과 raw() 조합
Smart Query Conditions 객체 방식
const books = await em.qb(Book)
.select('*')
.where({
$and: [
{ price: { $gte: 10, $lte: 100 } },
{
$or: [
{ status: BookStatus.PUBLISHED },
{ author: { verified: true } } // implicit join 발생
]
},
{ id: { $nin: [3, 4, 5] } }
]
})
.getResultList();
// WHERE (`e0`.`price` >= 10 AND `e0`.`price` <= 100)
// AND (`e0`.`status` = 'published' OR `e1`.`verified` = true)
// AND `e0`.`id` NOT IN (3, 4, 5)
andWhere()·orWhere() 체이닝
const qb = em.qb(Book)
.select('*')
.where({ status: BookStatus.PUBLISHED })
.andWhere({ price: { $gt: 0 } })
.orWhere({ featured: true });
// WHERE ((`e0`.`status` = ? AND `e0`.`price` > ?) OR (`e0`.`featured` = ?))
raw() / sql 태그로 SQL 프래그먼트 삽입
import { raw, sql } from '@mikro-orm/core';
// sql`` 태그 템플릿 함수
const books = await em.qb(Book)
.select('*')
.where({ [sql`lower(title)`]: 'mikro-orm guide' })
.orderBy({ [raw('price * (1 + tax_rate)')]: 'ASC' })
.getResultList();
// WHERE lower(title) = ?
// ORDER BY price * (1 + tax_rate) ASC
주의: raw()에 사용자 입력을 직접 삽입하면 SQL Injection 위험이 있습니다. 반드시 파라미터 바인딩을 사용하세요:
// ✅ 안전: 파라미터 바인딩
.where({ [sql`lower(title)`]: userInput })
// ❌ 위험: 직접 문자열 삽입
.where(raw(`title = '${userInput}'`))
집계·그룹핑: groupBy·having·select 컬럼 지정
// 카테고리별 평균 가격과 도서 수
const stats = await em.qb(Book, 'b')
.select([
'b.category',
raw('count(*) as book_count'),
raw('avg(b.price) as avg_price'),
raw('max(b.price) as max_price'),
])
.groupBy('b.category')
.having({ [raw('count(*)')]: { $gte: 5 } })
.orderBy({ [raw('avg_price')]: 'DESC' })
.execute('all');
// stats: [{ category: 'tech', book_count: 42, avg_price: 35.5, max_price: 89.99 }, ...]
// 저자별 출판 도서 수 — 관계 조인 + 그룹핑
const authorStats = await em.qb(Author, 'a')
.select(['a.id', 'a.name', raw('count(b.id) as total_books')])
.leftJoin('a.books', 'b')
.groupBy(['a.id', 'a.name'])
.having({ [raw('count(b.id)')]: { $gt: 0 } })
.orderBy({ [raw('total_books')]: 'DESC' })
.limit(10)
.execute('all');
핵심: 집계 쿼리에서는 execute('all')을 사용하세요. getResult()는 엔티티로 hydrate하려 하므로 집계 결과의 커스텀 컬럼(book_count 등)이 누락됩니다.
INSERT·UPDATE·DELETE: DML 쿼리
// INSERT
const res = await em.qb(Book).insert({
title: 'MikroORM Mastery',
price: 29.99,
author: 1, // FK 값 직접 지정
});
console.log(res.insertId); // 새로 생성된 PK
// UPDATE — em.flush()를 거치지 않는 직접 업데이트
const updated = await em.qb(Book)
.update({ price: raw('price * 1.1') }) // 10% 인상
.where({ category: 'tech', price: { $lt: 50 } });
console.log(updated.affectedRows);
// DELETE
const deleted = await em.qb(BookTag)
.delete()
.where({ createdAt: { $lt: new Date('2024-01-01') } });
console.log(deleted.affectedRows);
중요: QB의 INSERT/UPDATE/DELETE는 Unit of Work를 우회합니다. Identity Map에 있는 엔티티와 DB 상태가 불일치할 수 있으므로, DML 후에는 em.clear()로 Identity Map을 초기화하거나 영향받는 엔티티를 em.refresh()하는 것이 안전합니다.
Pagination: limit·offset과 count 최적화
// 기본 페이지네이션
const page = 2;
const pageSize = 20;
const [books, total] = await Promise.all([
em.qb(Book)
.select('*')
.where({ status: BookStatus.PUBLISHED })
.orderBy({ createdAt: 'DESC' })
.limit(pageSize, (page - 1) * pageSize) // limit(limit, offset)
.getResultList(),
em.qb(Book)
.count()
.where({ status: BookStatus.PUBLISHED }),
]);
// total: number — getCount()와 동일
// books: Book[] — 해당 페이지의 엔티티
// Cursor 기반 페이지네이션 — 대용량 테이블에서 offset보다 효율적
const lastId = 1000; // 이전 페이지의 마지막 ID
const books = await em.qb(Book)
.select('*')
.where({
status: BookStatus.PUBLISHED,
id: { $gt: lastId },
})
.orderBy({ id: 'ASC' })
.limit(20)
.getResultList();
getQuery()·getParams(): 디버깅과 쿼리 검증
const qb = em.qb(Book)
.select('*')
.where({ price: { $gt: 10 }, status: 'published' })
.orderBy({ createdAt: 'DESC' })
.limit(5);
// 실행 없이 SQL 확인
console.log(qb.getQuery());
// SELECT `e0`.* FROM `book` AS `e0`
// WHERE `e0`.`price` > ? AND `e0`.`status` = ?
// ORDER BY `e0`.`created_at` DESC LIMIT ?
console.log(qb.getParams());
// [10, 'published', 5]
// 포맷된 전체 쿼리 (테스트/디버깅용)
console.log(qb.getFormattedQuery());
// SELECT `e0`.* FROM `book` AS `e0`
// WHERE `e0`.`price` > 10 AND `e0`.`status` = 'published'
// ORDER BY `e0`.`created_at` DESC LIMIT 5
Knex.js 통합: getKnexQuery()로 확장
MikroORM의 QB는 내부적으로 Knex.js를 사용합니다. getKnexQuery()로 Knex QueryBuilder를 추출하여 MikroORM이 지원하지 않는 기능을 직접 사용할 수 있습니다.
// Knex 수준에서 window function 사용
const knex = em.getKnex();
const ranked = await knex
.select('*')
.from(function() {
this.select(
'*',
knex.raw('ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) as rn')
)
.from('book')
.where('status', 'published')
.as('ranked');
})
.where('rn', '<=', 3);
// 결과를 엔티티로 변환
const books = ranked.map(row => em.map(Book, row));
em.map(Entity, rawData)는 raw 결과를 프로퍼티명으로 매핑한 뒤 Identity Map에 등록(merge)합니다. Knex나 다른 서드파티 쿼리 빌더 결과를 엔티티로 변환할 때 유용합니다.
QB vs em.find() 선택 기준 체크리스트
| 상황 | 권장 방식 | 이유 |
|---|---|---|
| 단순 CRUD + populate | em.find() |
간결하고 타입 안전 |
| 관계 필드 기반 WHERE 필터링 | em.find() (smart conditions) |
implicit join 자동 생성 |
| 집계(COUNT, AVG, SUM) + GROUP BY | em.qb() |
em.find()는 집계 미지원 |
| 서브쿼리 WHERE / JOIN | em.qb() |
서브쿼리 API 필요 |
| 대량 UPDATE/DELETE (batch) | em.qb() |
UoW 우회하여 단일 쿼리 실행 |
| raw SQL 프래그먼트 (함수, 연산) | em.qb() + raw() |
SQL 수준 제어 필요 |
| Window Function, CTE | getKnex() 직접 |
QB가 지원하지 않는 구문 |
핵심 정리
- QB는 v5부터 await 가능하며, 호출한 메서드(.select/.count/.update 등)에 따라 반환 타입이 자동 추론됩니다.
- getResult() vs execute(): 엔티티 인스턴스가 필요하면 getResult(), raw 데이터나 집계 결과는 execute()를 사용합니다.
- Implicit joining은 where/orderBy에서 자동으로 관계 조인을 생성하지만, select에는 적용되지 않습니다. 관계 로드가 필요하면 leftJoinAndSelect()를 사용합니다.
- 서브쿼리 JOIN은
['관계경로', 서브쿼리QB]튜플로 전달하며, 원래 메타데이터 기반 hydration이 유지됩니다. - QB의 DML(INSERT/UPDATE/DELETE)은 Unit of Work를 우회합니다. 실행 후 Identity Map과의 불일치에 주의하세요.
- 집계·Window Function처럼 QB가 지원하지 않는 구문은
getKnex()로 Knex를 직접 사용하고,em.map()으로 엔티티 변환합니다.
참고 자료