NestJS + MikroORM QueryBuilder

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은 whereorderBy에서만 동작합니다. 루트 엔티티만 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()으로 엔티티 변환합니다.

참고 자료

📥 관련 무료 이북

NestJS + TypeORM 실전 가이드 — 실전 가이드 무료 제공

무료로 받기 →

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux