TypeORM QueryBuilder 심화

TypeORM QueryBuilder란?

TypeORM의 Repository API(find, findOne)는 단순 CRUD에 적합하지만, 복잡한 조인, 서브쿼리, 집계, 동적 필터링에는 한계가 있습니다. QueryBuilder는 SQL에 가까운 체이닝 API로 타입 안전한 복잡 쿼리를 작성할 수 있게 합니다. 이 글에서는 SelectQueryBuilder의 핵심 메서드, 동적 필터 패턴, 서브쿼리, 페이지네이션 최적화, Raw SQL 혼합까지 실전 패턴을 다룹니다.

QueryBuilder 생성 방법

// 방법 1: Repository에서 생성
const qb = this.userRepository.createQueryBuilder('user');

// 방법 2: DataSource에서 생성
const qb = this.dataSource
  .createQueryBuilder()
  .select('user')
  .from(User, 'user');

// 방법 3: Entity Manager에서 생성
const qb = this.entityManager
  .createQueryBuilder(User, 'user');

// 별칭(alias)은 필수 — SQL의 테이블 별칭과 동일
// 'user'는 이후 체이닝에서 참조용으로 사용

SELECT와 JOIN

가장 자주 사용하는 조인 패턴을 정리합니다. N+1 문제를 QueryBuilder의 조인으로 해결할 수 있습니다.

// 기본 SELECT + WHERE
const users = await this.userRepository
  .createQueryBuilder('user')
  .select(['user.id', 'user.name', 'user.email'])  // 필요한 컬럼만
  .where('user.isActive = :isActive', { isActive: true })
  .orderBy('user.createdAt', 'DESC')
  .getMany();

// LEFT JOIN: 관계 엔티티 함께 로드
const orders = await this.orderRepository
  .createQueryBuilder('order')
  .leftJoinAndSelect('order.user', 'user')           // 관계 자동 매핑
  .leftJoinAndSelect('order.items', 'item')
  .leftJoinAndSelect('item.product', 'product')
  .where('order.status = :status', { status: 'pending' })
  .getMany();

// INNER JOIN: 조건에 맞는 관계만
const usersWithOrders = await this.userRepository
  .createQueryBuilder('user')
  .innerJoinAndSelect('user.orders', 'order')         // 주문 있는 유저만
  .where('order.createdAt > :date', { date: lastMonth })
  .getMany();

// leftJoin (Select 없이): 필터링만, 데이터 로드 안 함
const usersInSeoul = await this.userRepository
  .createQueryBuilder('user')
  .leftJoin('user.address', 'addr')
  .where('addr.city = :city', { city: '서울' })
  .getMany();  // user만 반환, address는 로드 안 됨

WHERE 조건 조합

// AND 조합
const qb = this.productRepository
  .createQueryBuilder('product')
  .where('product.price >= :minPrice', { minPrice: 1000 })
  .andWhere('product.price <= :maxPrice', { maxPrice: 50000 })
  .andWhere('product.category = :category', { category: 'electronics' });

// OR 조합
const qb = this.userRepository
  .createQueryBuilder('user')
  .where('user.role = :role1', { role1: 'admin' })
  .orWhere('user.role = :role2', { role2: 'manager' });

// Brackets로 복잡한 조건 그룹화
// WHERE (role = 'admin' OR role = 'manager') AND isActive = true
const qb = this.userRepository
  .createQueryBuilder('user')
  .where(new Brackets(qb => {
    qb.where('user.role = :role1', { role1: 'admin' })
      .orWhere('user.role = :role2', { role2: 'manager' });
  }))
  .andWhere('user.isActive = :isActive', { isActive: true });

// IN 절
const qb = this.productRepository
  .createQueryBuilder('product')
  .where('product.id IN (:...ids)', { ids: [1, 2, 3, 4, 5] });

// LIKE 검색
const qb = this.userRepository
  .createQueryBuilder('user')
  .where('user.name LIKE :name', { name: `%${keyword}%` });

// IS NULL / IS NOT NULL
const qb = this.userRepository
  .createQueryBuilder('user')
  .where('user.deletedAt IS NULL');

동적 필터 패턴

검색 API에서 선택적 필터를 조합하는 가장 실전적인 패턴입니다.

// DTO
export class SearchProductDto {
  keyword?: string;
  category?: string;
  minPrice?: number;
  maxPrice?: number;
  brand?: string;
  inStock?: boolean;
  sortBy?: 'price' | 'createdAt' | 'name';
  sortOrder?: 'ASC' | 'DESC';
  page?: number;
  limit?: number;
}

// 서비스: 동적 쿼리 빌드
async searchProducts(dto: SearchProductDto) {
  const qb = this.productRepository
    .createQueryBuilder('product')
    .leftJoinAndSelect('product.brand', 'brand')
    .leftJoinAndSelect('product.category', 'category');

  // 조건부 필터 추가
  if (dto.keyword) {
    qb.andWhere(
      '(product.name LIKE :keyword OR product.description LIKE :keyword)',
      { keyword: `%${dto.keyword}%` },
    );
  }

  if (dto.category) {
    qb.andWhere('category.slug = :category', { category: dto.category });
  }

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

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

  if (dto.brand) {
    qb.andWhere('brand.name = :brand', { brand: dto.brand });
  }

  if (dto.inStock !== undefined) {
    qb.andWhere('product.stock > 0');
  }

  // 정렬
  const sortBy = dto.sortBy || 'createdAt';
  const sortOrder = dto.sortOrder || 'DESC';
  qb.orderBy(`product.${sortBy}`, sortOrder);

  // 페이지네이션
  const page = dto.page || 1;
  const limit = dto.limit || 20;
  qb.skip((page - 1) * limit).take(limit);

  // 결과 + 총 개수
  const [items, total] = await qb.getManyAndCount();

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

집계와 GROUP BY

// 카테고리별 상품 수와 평균 가격
const stats = await this.productRepository
  .createQueryBuilder('product')
  .leftJoin('product.category', 'category')
  .select('category.name', 'categoryName')
  .addSelect('COUNT(product.id)', 'productCount')
  .addSelect('AVG(product.price)', 'avgPrice')
  .addSelect('MIN(product.price)', 'minPrice')
  .addSelect('MAX(product.price)', 'maxPrice')
  .groupBy('category.name')
  .having('COUNT(product.id) > :min', { min: 5 })
  .orderBy('productCount', 'DESC')
  .getRawMany();
// 반환: [{ categoryName: '전자제품', productCount: '42', avgPrice: '35000', ... }]

// 월별 매출 집계
const monthlySales = await this.orderRepository
  .createQueryBuilder('order')
  .select("DATE_FORMAT(order.createdAt, '%Y-%m')", 'month')
  .addSelect('SUM(order.totalAmount)', 'revenue')
  .addSelect('COUNT(order.id)', 'orderCount')
  .where('order.status = :status', { status: 'completed' })
  .groupBy('month')
  .orderBy('month', 'DESC')
  .getRawMany();

서브쿼리

서브쿼리는 WHERE, FROM, SELECT 절에서 모두 사용할 수 있습니다.

// WHERE 서브쿼리: 주문 금액이 평균 이상인 유저
const avgSubQuery = this.orderRepository
  .createQueryBuilder('o')
  .select('AVG(o.totalAmount)');

const highValueUsers = await this.userRepository
  .createQueryBuilder('user')
  .innerJoin('user.orders', 'order')
  .where(`order.totalAmount > (${avgSubQuery.getQuery()})`)
  .setParameters(avgSubQuery.getParameters())
  .getMany();

// EXISTS 서브쿼리: 최근 30일 내 주문한 유저
const recentOrderSubQuery = this.orderRepository
  .createQueryBuilder('order')
  .select('1')
  .where('order.userId = user.id')
  .andWhere('order.createdAt > :recentDate');

const activeUsers = await this.userRepository
  .createQueryBuilder('user')
  .where(`EXISTS (${recentOrderSubQuery.getQuery()})`)
  .setParameters({ recentDate: thirtyDaysAgo })
  .getMany();

// SELECT 서브쿼리: 각 유저의 총 주문 수
const orderCountSubQuery = this.orderRepository
  .createQueryBuilder('order')
  .select('COUNT(order.id)')
  .where('order.userId = user.id');

const usersWithCount = await this.userRepository
  .createQueryBuilder('user')
  .addSelect(`(${orderCountSubQuery.getQuery()})`, 'orderCount')
  .setParameters(orderCountSubQuery.getParameters())
  .getRawMany();

Cursor 기반 페이지네이션

대량 데이터에서 OFFSET은 성능이 저하됩니다. Cursor 방식이 더 효율적입니다. TypeORM Subscriber와 함께 사용하면 감사 로그에도 적용할 수 있습니다.

// Cursor 기반 페이지네이션
async findWithCursor(cursor?: string, limit: number = 20) {
  const qb = this.productRepository
    .createQueryBuilder('product')
    .orderBy('product.id', 'DESC')
    .take(limit + 1);  // 다음 페이지 존재 여부 확인용 +1

  if (cursor) {
    const decodedCursor = Buffer.from(cursor, 'base64').toString();
    qb.where('product.id < :cursor', { cursor: decodedCursor });
  }

  const items = await qb.getMany();
  const hasNext = items.length > limit;
  if (hasNext) items.pop();  // +1 제거

  const nextCursor = hasNext
    ? Buffer.from(String(items[items.length - 1].id)).toString('base64')
    : null;

  return { items, nextCursor, hasNext };
}

// 복합 정렬 Cursor (createdAt + id)
async findWithCompositeCursor(
  cursor?: { createdAt: string; id: string },
  limit: number = 20,
) {
  const qb = this.productRepository
    .createQueryBuilder('product')
    .orderBy('product.createdAt', 'DESC')
    .addOrderBy('product.id', 'DESC')
    .take(limit + 1);

  if (cursor) {
    qb.where(
      '(product.createdAt < :cursorDate) OR ' +
      '(product.createdAt = :cursorDate AND product.id < :cursorId)',
      { cursorDate: cursor.createdAt, cursorId: cursor.id },
    );
  }

  const items = await qb.getMany();
  const hasNext = items.length > limit;
  if (hasNext) items.pop();

  return { items, hasNext };
}

UPDATE와 DELETE QueryBuilder

// 대량 업데이트: 엔티티 로드 없이 직접 SQL
await this.productRepository
  .createQueryBuilder()
  .update(Product)
  .set({ price: () => 'price * 0.9' })  // 10% 할인
  .where('category = :category', { category: 'summer' })
  .andWhere('stock > 0')
  .execute();

// Soft Delete 대량 처리
await this.userRepository
  .createQueryBuilder()
  .update(User)
  .set({ deletedAt: new Date(), deletedBy: 'admin' })
  .where('lastLoginAt < :date', { date: oneYearAgo })
  .andWhere('deletedAt IS NULL')
  .execute();

// 대량 삭제
const result = await this.logRepository
  .createQueryBuilder()
  .delete()
  .from(AccessLog)
  .where('createdAt < :date', { date: ninetyDaysAgo })
  .execute();

console.log(`${result.affected}건 삭제됨`);

디버깅: 생성된 SQL 확인

// getQuery(): 파라미터 바인딩 전 SQL
const sql = qb.getQuery();
console.log(sql);
// SELECT "user"."id", "user"."name" FROM "users" "user" WHERE "user"."role" = :role

// getSql(): 파라미터 바인딩 후 SQL
const fullSql = qb.getSql();

// getQueryAndParameters(): SQL + 파라미터 배열
const [query, params] = qb.getQueryAndParameters();
console.log(query, params);

// printSql(): 체이닝 중간에 SQL 출력
const users = await qb
  .where('user.isActive = :isActive', { isActive: true })
  .printSql()  // 콘솔에 SQL 출력
  .getMany();

// EXPLAIN으로 쿼리 플랜 확인
const explain = await this.dataSource.query(
  `EXPLAIN ANALYZE ${qb.getSql()}`,
  qb.getParameters(),
);

정리

TypeORM QueryBuilder는 Repository API와 Raw SQL 사이의 최적 균형점입니다. 동적 필터 조합에서 andWhere 체이닝으로 조건부 쿼리를 깔끔하게 구성하고, Brackets로 복잡한 OR/AND 그룹을 표현합니다. 대량 데이터에서는 OFFSET 대신 Cursor 기반 페이지네이션을, 대량 변경에서는 엔티티를 로드하지 않는 UPDATE/DELETE QueryBuilder를 사용하세요. printSql()EXPLAIN으로 항상 생성된 쿼리를 검증하는 것이 성능의 시작입니다.

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