TypeORM QueryBuilder 심화

TypeORM QueryBuilder란?

TypeORM의 QueryBuilder는 Repository API로는 표현할 수 없는 복잡한 쿼리를 타입 안전하게 구성하는 빌더 패턴이다. JOIN, 서브쿼리, GROUP BY, HAVING, 동적 조건 조합 등 SQL의 모든 기능을 체이닝 방식으로 제공하며, Raw SQL 대비 리팩터링과 유지보수가 훨씬 쉽다.

QueryBuilder 생성 방법

방식 용도 예시
Repository SELECT 쿼리 repo.createQueryBuilder('user')
DataSource 범용 (INSERT/UPDATE/DELETE) dataSource.createQueryBuilder()
Entity Manager 트랜잭션 내부 manager.createQueryBuilder(User, 'user')
// 기본 SELECT
const users = await this.userRepository
  .createQueryBuilder('user')
  .where('user.isActive = :isActive', { isActive: true })
  .orderBy('user.createdAt', 'DESC')
  .take(20)
  .skip(0)
  .getMany();

// SELECT만 필요할 때 (엔티티 매핑 없이 경량 조회)
const result = await this.userRepository
  .createQueryBuilder('user')
  .select(['user.id', 'user.name', 'user.email'])
  .where('user.role = :role', { role: 'admin' })
  .getRawMany();  // 엔티티가 아닌 plain object 반환

JOIN 전략: leftJoin vs innerJoin

QueryBuilder의 JOIN은 두 가지 방식이 있다. leftJoinAndSelect는 관계 엔티티를 함께 로딩하고, leftJoin은 조건 필터링만 수행한다.

// leftJoinAndSelect: 관계 데이터를 엔티티에 포함
const orders = await this.orderRepository
  .createQueryBuilder('order')
  .leftJoinAndSelect('order.items', 'item')
  .leftJoinAndSelect('item.product', 'product')
  .leftJoinAndSelect('order.user', 'user')
  .where('order.status = :status', { status: 'completed' })
  .andWhere('order.createdAt >= :since', { since: thirtyDaysAgo })
  .orderBy('order.createdAt', 'DESC')
  .getMany();

// leftJoin: 필터링만 (SELECT에 포함 안 됨)
const usersWithOrders = await this.userRepository
  .createQueryBuilder('user')
  .leftJoin('user.orders', 'order')
  .where('order.totalAmount > :min', { min: 100000 })
  .select(['user.id', 'user.name'])
  .distinct(true)
  .getMany();

// innerJoinAndSelect: 관계가 있는 경우만 반환
const productsWithReviews = await this.productRepository
  .createQueryBuilder('product')
  .innerJoinAndSelect('product.reviews', 'review')
  .where('review.rating >= :rating', { rating: 4 })
  .getMany();

동적 WHERE 조건 빌더

검색·필터링 API에서는 조건이 동적으로 조합되어야 한다. Brackets와 조건부 체이닝으로 안전하게 구현할 수 있다.

import { Brackets } from 'typeorm';

interface ProductFilter {
  search?: string;
  categoryId?: string;
  minPrice?: number;
  maxPrice?: number;
  inStock?: boolean;
  tags?: string[];
}

async findProducts(filter: ProductFilter, page: number, limit: number) {
  const qb = this.productRepository
    .createQueryBuilder('product')
    .leftJoinAndSelect('product.category', 'category');

  // 동적 조건 추가
  if (filter.search) {
    qb.andWhere(
      new Brackets((sub) => {
        sub.where('product.name ILIKE :search', { search: `%${filter.search}%` })
           .orWhere('product.description ILIKE :search', { search: `%${filter.search}%` });
      }),
    );
  }

  if (filter.categoryId) {
    qb.andWhere('product.categoryId = :categoryId', {
      categoryId: filter.categoryId,
    });
  }

  if (filter.minPrice !== undefined) {
    qb.andWhere('product.price >= :minPrice', { minPrice: filter.minPrice });
  }

  if (filter.maxPrice !== undefined) {
    qb.andWhere('product.price <= :maxPrice', { maxPrice: filter.maxPrice });
  }

  if (filter.inStock) {
    qb.andWhere('product.stockQuantity > 0');
  }

  if (filter.tags?.length) {
    // 배열 IN 조건
    qb.innerJoin('product.tags', 'tag')
      .andWhere('tag.name IN (:...tags)', { tags: filter.tags });
  }

  // 페이지네이션 + 총 개수
  const [items, total] = await qb
    .orderBy('product.createdAt', 'DESC')
    .take(limit)
    .skip((page - 1) * limit)
    .getManyAndCount();

  return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
}

서브쿼리 활용

QueryBuilder의 서브쿼리는 WHERE 절, FROM 절, SELECT 절 어디서든 사용할 수 있다.

// WHERE 서브쿼리: 평균보다 비싼 상품
const expensiveProducts = await this.productRepository
  .createQueryBuilder('product')
  .where(qb => {
    const subQuery = qb
      .subQuery()
      .select('AVG(p.price)', 'avgPrice')
      .from(Product, 'p')
      .getQuery();
    return `product.price > (${subQuery})`;
  })
  .getMany();

// WHERE IN 서브쿼리: 최근 주문한 사용자
const recentBuyers = await this.userRepository
  .createQueryBuilder('user')
  .where(qb => {
    const subQuery = qb
      .subQuery()
      .select('DISTINCT order.userId')
      .from(Order, 'order')
      .where('order.createdAt >= :since', { since: sevenDaysAgo })
      .getQuery();
    return `user.id IN ${subQuery}`;
  })
  .setParameter('since', sevenDaysAgo)
  .getMany();

// SELECT 서브쿼리: 각 카테고리의 상품 수
const categoriesWithCount = await this.categoryRepository
  .createQueryBuilder('category')
  .addSelect(qb => {
    return qb
      .subQuery()
      .select('COUNT(p.id)')
      .from(Product, 'p')
      .where('p.categoryId = category.id');
  }, 'productCount')
  .getRawMany();

GROUP BY와 집계 함수

// 카테고리별 매출 통계
interface CategorySales {
  categoryName: string;
  totalRevenue: number;
  orderCount: number;
  avgOrderValue: number;
}

const salesStats = await this.orderRepository
  .createQueryBuilder('order')
  .innerJoin('order.items', 'item')
  .innerJoin('item.product', 'product')
  .innerJoin('product.category', 'category')
  .select('category.name', 'categoryName')
  .addSelect('SUM(item.quantity * item.unitPrice)', 'totalRevenue')
  .addSelect('COUNT(DISTINCT order.id)', 'orderCount')
  .addSelect('AVG(order.totalAmount)', 'avgOrderValue')
  .where('order.status = :status', { status: 'completed' })
  .andWhere('order.createdAt BETWEEN :start AND :end', {
    start: startDate,
    end: endDate,
  })
  .groupBy('category.id')
  .addGroupBy('category.name')
  .having('SUM(item.quantity * item.unitPrice) > :minRevenue', {
    minRevenue: 100000,
  })
  .orderBy('totalRevenue', 'DESC')
  .getRawMany<CategorySales>();

TypeORM Index·Unique 최적화에서 다룬 인덱스 전략을 GROUP BY·JOIN 쿼리의 대상 컬럼에 적용하면 집계 성능이 크게 향상된다.

INSERT·UPDATE·DELETE QueryBuilder

// 벌크 INSERT
await this.dataSource
  .createQueryBuilder()
  .insert()
  .into(ProductView)
  .values([
    { productId: 'id-1', userId: 'user-1', viewedAt: new Date() },
    { productId: 'id-2', userId: 'user-1', viewedAt: new Date() },
  ])
  .orIgnore()  // 중복 시 무시 (PostgreSQL: ON CONFLICT DO NOTHING)
  .execute();

// 조건부 UPDATE
await this.productRepository
  .createQueryBuilder()
  .update(Product)
  .set({
    price: () => 'price * 0.9',       // SQL 표현식
    updatedAt: new Date(),
  })
  .where('categoryId = :categoryId', { categoryId })
  .andWhere('price > :minPrice', { minPrice: 50000 })
  .execute();

// 서브쿼리 기반 DELETE
await this.dataSource
  .createQueryBuilder()
  .delete()
  .from(CartItem)
  .where(qb => {
    const subQuery = qb
      .subQuery()
      .select('cart.id')
      .from(Cart, 'cart')
      .where('cart.updatedAt < :expiry', { expiry: thirtyDaysAgo })
      .getQuery();
    return `cartId IN ${subQuery}`;
  })
  .setParameter('expiry', thirtyDaysAgo)
  .execute();

커서 기반 페이지네이션

대규모 데이터에서 OFFSET 기반 페이지네이션은 성능이 급격히 저하된다. 커서 기반은 인덱스를 활용해 일정한 성능을 유지한다.

interface CursorPage<T> {
  items: T[];
  nextCursor: string | null;
  hasMore: boolean;
}

async findWithCursor(
  cursor?: string,
  limit: number = 20,
): Promise<CursorPage<Product>> {
  const qb = this.productRepository
    .createQueryBuilder('product')
    .orderBy('product.createdAt', 'DESC')
    .addOrderBy('product.id', 'DESC')
    .take(limit + 1);  // 1개 더 가져와서 hasMore 판단

  if (cursor) {
    const [cursorDate, cursorId] = Buffer
      .from(cursor, 'base64')
      .toString()
      .split('|');

    qb.where(
      new Brackets(sub => {
        sub.where('product.createdAt < :cursorDate', { cursorDate })
           .orWhere(
             new Brackets(inner => {
               inner.where('product.createdAt = :cursorDate', { cursorDate })
                    .andWhere('product.id < :cursorId', { cursorId });
             }),
           );
      }),
    );
  }

  const items = await qb.getMany();
  const hasMore = items.length > limit;

  if (hasMore) items.pop();

  const lastItem = items[items.length - 1];
  const nextCursor = hasMore && lastItem
    ? Buffer.from(`${lastItem.createdAt.toISOString()}|${lastItem.id}`).toString('base64')
    : null;

  return { items, nextCursor, hasMore };
}

Drizzle ORM 동적 쿼리·필터 심화에서 다룬 커서 페이지네이션 패턴과 동일한 원리를 TypeORM QueryBuilder로 구현한 것이다.

쿼리 성능 디버깅

// 생성된 SQL 확인
const [sql, params] = this.productRepository
  .createQueryBuilder('product')
  .where('product.price > :price', { price: 10000 })
  .getQueryAndParameters();

console.log('SQL:', sql);
console.log('Params:', params);

// EXPLAIN ANALYZE 실행
const explain = await this.dataSource.query(
  `EXPLAIN ANALYZE ${sql}`,
  params,
);
console.table(explain);

// DataSource 로깅 설정
// ormconfig: { logging: ['query', 'error', 'slow'], maxQueryExecutionTime: 1000 }
// → 1초 이상 걸리는 쿼리 자동 로깅

마무리

TypeORM QueryBuilder는 Repository API의 한계를 넘어 복잡한 JOIN·서브쿼리·집계·동적 필터를 타입 안전하게 구현하는 핵심 도구다. Brackets로 동적 조건을 안전하게 조합하고, 커서 기반 페이지네이션으로 대규모 데이터를 효율적으로 처리하는 것이 실전에서 가장 중요한 패턴이다.

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