왜 QueryBuilder를 깊이 알아야 하는가
TypeORM의 find() 계열 메서드는 간단한 CRUD에 적합하지만, 서브쿼리, 복합 조건, 집계, Raw SQL 표현식이 필요한 운영 API에서는 한계에 부딪힙니다. 이때 QueryBuilder가 필수 도구가 됩니다.
그러나 QueryBuilder는 find()와 동작 방식이 다릅니다. eager 관계가 무시되고, Subscriber/Entity Listener가 부분적으로만 작동하며, 파라미터 바인딩 실수가 SQL Injection으로 이어질 수 있습니다.
이 글은 TypeORM 공식 문서(Select QueryBuilder, Using Subqueries)를 근거로 NestJS 실무에서 자주 쓰는 QueryBuilder 고급 패턴과 함정을 정리합니다.
QueryBuilder 기본 구조 복습
const users = await this.userRepository
.createQueryBuilder('user') // alias 지정
.leftJoinAndSelect('user.posts', 'post') // 관계 JOIN
.where('user.isActive = :active', { active: true }) // 파라미터 바인딩
.orderBy('user.createdAt', 'DESC')
.take(10) // limit
.skip(0) // offset
.getMany(); // Entity 매핑 결과
핵심: 모든 조건 값은 :param 바인딩을 사용해야 합니다. 문자열 보간(`...${value}...`)은 SQL Injection 취약점입니다.
패턴 1: 서브쿼리 (Subquery)
TypeORM QueryBuilder는 WHERE, SELECT, FROM 절에서 서브쿼리를 지원합니다.
WHERE 서브쿼리: “특정 조건을 만족하는 관계가 있는” 엔티티 필터링
// 최근 30일 내 주문이 있는 사용자만 조회
const activeUsers = await this.userRepository
.createQueryBuilder('user')
.where((qb) => {
const subQuery = qb
.subQuery()
.select('order.userId')
.from(Order, 'order')
.where('order.createdAt > :since', { since: thirtyDaysAgo })
.getQuery();
return 'user.id IN ' + subQuery;
})
.setParameters({ since: thirtyDaysAgo })
.getMany();
주의: 서브쿼리 내부에서 setParameters()로 설정한 파라미터는 외부 QueryBuilder에도 setParameters()로 전달해야 합니다. 서브쿼리의 .where()에서 인라인으로 넣은 파라미터가 외부로 전파되지 않는 경우가 있습니다.
SELECT 서브쿼리: 집계 값을 컬럼으로 추가
// 각 사용자의 주문 수를 서브쿼리로 계산
const usersWithCount = await this.userRepository
.createQueryBuilder('user')
.addSelect((qb) => {
return qb
.subQuery()
.select('COUNT(order.id)')
.from(Order, 'order')
.where('order.userId = user.id')
.getQuery();
}, 'user_orderCount') // alias 지정
.getRawMany(); // getRawMany로 받아야 서브쿼리 결과 포함
핵심: addSelect()로 추가한 서브쿼리 결과는 getMany()가 아닌 getRawMany() 또는 getRawAndEntities()로 받아야 합니다. getMany()는 Entity 매핑만 하므로 추가 컬럼이 누락됩니다.
패턴 2: Raw Expression (SelectQueryBuilder.addSelect)
SQL 함수나 CASE 문을 사용해야 할 때 Raw Expression을 씁니다.
// CASE 문으로 계산 컬럼 추가
const orders = await this.orderRepository
.createQueryBuilder('order')
.addSelect(
`CASE
WHEN order.total >= 100000 THEN 'VIP'
WHEN order.total >= 50000 THEN 'Regular'
ELSE 'Basic'
END`,
'order_tier'
)
.getRawAndEntities();
// result.entities → Order[]
// result.raw → [{ order_id: 1, order_tier: 'VIP', ... }]
날짜 함수 활용
// MySQL DATE_FORMAT으로 월별 그룹핑
const monthlySales = await this.orderRepository
.createQueryBuilder('order')
.select("DATE_FORMAT(order.createdAt, '%Y-%m')", 'month')
.addSelect('SUM(order.total)', 'totalSales')
.addSelect('COUNT(order.id)', 'orderCount')
.groupBy('month')
.orderBy('month', 'DESC')
.getRawMany();
// [{ month: '2026-02', totalSales: '5000000', orderCount: '42' }, ...]
패턴 3: 복합 WHERE 조건 (Brackets)
andWhere/orWhere만으로는 (A AND B) OR (C AND D) 같은 복합 조건을 만들기 어렵습니다. Brackets 클래스를 사용합니다.
import { Brackets } from 'typeorm';
const results = await this.productRepository
.createQueryBuilder('product')
.where('product.isActive = :active', { active: true })
.andWhere(
new Brackets((qb) => {
qb.where('product.category = :cat', { cat: 'electronics' })
.orWhere('product.price > :price', { price: 100000 });
}),
)
.getMany();
// SQL: WHERE product.isActive = ? AND (product.category = ? OR product.price > ?)
중첩 Brackets
// (A OR B) AND (C OR D) 패턴
.where(
new Brackets((qb) => {
qb.where('product.category = :cat1', { cat1: 'electronics' })
.orWhere('product.category = :cat2', { cat2: 'books' });
}),
)
.andWhere(
new Brackets((qb) => {
qb.where('product.price > :min', { min: 10000 })
.orWhere('product.isFeatured = :feat', { feat: true });
}),
)
// SQL: WHERE (category = ? OR category = ?) AND (price > ? OR isFeatured = ?)
패턴 4: 동적 쿼리 빌딩 (필터 API)
NestJS API에서 검색 필터를 동적으로 조합하는 실무 패턴입니다.
@Injectable()
export class ProductService {
async search(filter: ProductFilterDto) {
const qb = this.productRepository
.createQueryBuilder('product')
.leftJoinAndSelect('product.category', 'category');
if (filter.name) {
qb.andWhere('product.name LIKE :name', { name: `%${filter.name}%` });
}
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.categoryIds?.length) {
qb.andWhere('product.categoryId IN (:...categoryIds)', {
categoryIds: filter.categoryIds,
});
}
if (filter.sortBy) {
qb.orderBy(`product.${filter.sortBy}`, filter.sortOrder ?? 'ASC');
}
return qb.take(filter.limit).skip(filter.offset).getManyAndCount();
}
}
주의: :...param(spread 파라미터)은 TypeORM이 배열을 IN (?, ?, ?)로 자동 확장합니다. 빈 배열을 넘기면 IN ()이 되어 SQL 에러가 발생합니다. 반드시 length 체크를 추가하세요.
getMany() vs getRawMany() vs getRawAndEntities()
| 메서드 | 반환 타입 | Entity 매핑 | 추가 컬럼 포함 | 적합 용도 |
|---|---|---|---|---|
getMany() |
Entity[] |
✅ | ❌ | 일반 CRUD 조회 |
getRawMany() |
object[] |
❌ (플랫 객체) | ✅ | 집계·리포트·서브쿼리 컬럼 |
getRawAndEntities() |
{ entities, raw } |
✅ | ✅ (raw에 포함) | Entity + 계산 컬럼 동시 필요 |
getCount() |
number |
❌ | ❌ | COUNT만 필요 |
getManyAndCount() |
[Entity[], number] |
✅ | ❌ | 페이지네이션 |
자주 빠지는 함정 4가지
함정 1: eager 관계가 QueryBuilder에서 무시됨
TypeORM 공식 문서에 명시: “Eager relations only work when you use find* methods. If you use QueryBuilder, eager relations are disabled.”
// Entity에 eager: true가 설정되어 있어도:
const users = await userRepo.createQueryBuilder('user').getMany();
// user.posts === undefined (eager 무시됨!)
// ✅ 명시적으로 join 추가
const users = await userRepo
.createQueryBuilder('user')
.leftJoinAndSelect('user.posts', 'post')
.getMany();
이 함정은 find()에서 QueryBuilder로 전환할 때 가장 자주 발생합니다.
함정 2: 파라미터 이름 충돌
같은 QueryBuilder에서 동일한 파라미터 이름을 재사용하면 마지막 값으로 덮어씌워집니다.
// ❌ 파라미터 이름 충돌
qb.where('user.createdAt > :date', { date: startDate })
.andWhere('user.updatedAt < :date', { date: endDate });
// 두 번째 :date가 첫 번째를 덮어씀 → 둘 다 endDate로 바인딩
// ✅ 고유한 파라미터 이름 사용
qb.where('user.createdAt > :startDate', { startDate })
.andWhere('user.updatedAt < :endDate', { endDate });
함정 3: getManyAndCount() + JOIN = COUNT 부풀림
OneToMany JOIN이 포함된 getManyAndCount()에서 COUNT가 JOIN 결과 행 수 기준으로 부풀어오릅니다.
// ❌ count가 post 수만큼 부풀어짐
const [users, count] = await userRepo
.createQueryBuilder('user')
.leftJoinAndSelect('user.posts', 'post')
.take(10)
.getManyAndCount();
// count = user 수가 아닌 JOIN된 행 수
// ✅ 해결: 서브쿼리로 ID만 페이징 후 재조회
const ids = await userRepo
.createQueryBuilder('user')
.select('user.id')
.take(10)
.skip(0)
.getRawMany();
const users = await userRepo
.createQueryBuilder('user')
.leftJoinAndSelect('user.posts', 'post')
.whereInIds(ids.map(i => i.user_id))
.getMany();
함정 4: addSelect()한 컬럼이 getMany()에서 사라짐
// ❌ addSelect 결과가 Entity에 없음
const users = await userRepo
.createQueryBuilder('user')
.addSelect('COUNT(post.id)', 'postCount')
.leftJoin('user.posts', 'post')
.groupBy('user.id')
.getMany();
// user.postCount === undefined (Entity에 해당 필드 없음)
// ✅ getRawAndEntities() 사용
const result = await userRepo
.createQueryBuilder('user')
.addSelect('COUNT(post.id)', 'postCount')
.leftJoin('user.posts', 'post')
.groupBy('user.id')
.getRawAndEntities();
// result.raw[0].postCount = '5'
// result.entities[0] = User { id: 1, name: '...' }
성능 최적화: select()로 필요한 컬럼만
기본적으로 QueryBuilder는 SELECT entity.*로 모든 컬럼을 가져옵니다. 목록 API에서 불필요한 대용량 컬럼(TEXT, BLOB)을 제외하려면 select()를 사용합니다.
const posts = await this.postRepository
.createQueryBuilder('post')
.select(['post.id', 'post.title', 'post.createdAt']) // content 컬럼 제외
.leftJoin('post.author', 'author')
.addSelect(['author.id', 'author.name'])
.take(20)
.getMany();
// SELECT post.id, post.title, post.created_at, author.id, author.name
// content(TEXT) 컬럼이 제외되어 전송량 절약
운영 체크리스트 5항목
- 모든 조건 값은
:param바인딩 — 문자열 보간 절대 금지.:...param으로 배열도 안전하게 처리하되, 빈 배열 체크 필수. - 파라미터 이름은 전체 QueryBuilder에서 고유하게 — 같은 이름 재사용 시 마지막 값으로 덮어씌워짐.
- QueryBuilder 전환 시 eager 관계 수동 추가 —
find()에서 잘 되던 관계가 QueryBuilder에서 누락되는지 확인. - 집계/서브쿼리 결과는
getRawMany()또는getRawAndEntities()—getMany()는 Entity 필드만 매핑. - 페이지네이션 + JOIN은 서브쿼리 패턴 —
getManyAndCount()+ OneToMany JOIN은 COUNT가 부풀어오름.
정리
TypeORM QueryBuilder는 “find()로 안 되는 것”을 해결하는 도구입니다. 서브쿼리, Raw Expression, Brackets를 활용하면 복잡한 운영 쿼리를 타입 안전하게 작성할 수 있습니다.
핵심은 세 가지입니다: 파라미터 바인딩으로 SQL Injection 방지, 반환 메서드(getMany vs getRawMany)의 차이 이해, 그리고 eager 관계·COUNT 부풀림 등 find()와의 동작 차이를 인지하는 것입니다.
참고 자료: TypeORM 공식 — Select QueryBuilder | TypeORM 공식 — Using Subqueries