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으로 항상 생성된 쿼리를 검증하는 것이 성능의 시작입니다.