FindOptions란?
TypeORM의 FindOptions는 Repository의 find(), findOne(), findAndCount() 메서드에 전달하는 쿼리 옵션 객체다. QueryBuilder 없이도 타입 안전한 조건 검색, 정렬, 페이징, 관계 로딩을 선언적으로 수행할 수 있다. 단순 CRUD의 80%는 FindOptions만으로 해결된다.
기본 옵션 전체 구조
const users = await userRepository.find({
select: { // 반환 컬럼 지정
id: true,
name: true,
email: true,
profile: { avatar: true }, // 관계 필드의 특정 컬럼만
},
where: { // 검색 조건
isActive: true,
role: In(['admin', 'editor']),
},
relations: { // 관계 로딩
profile: true,
posts: { comments: true }, // 중첩 관계
},
order: { // 정렬
createdAt: 'DESC',
name: 'ASC',
},
skip: 0, // 오프셋 (페이징)
take: 20, // 리밋
cache: true, // 쿼리 캐시
withDeleted: false, // soft delete 포함 여부
});
where 조건: FindOperator 심화
TypeORM은 typeorm 패키지에서 다양한 FindOperator를 제공한다.
import {
Equal, Not, LessThan, LessThanOrEqual,
MoreThan, MoreThanOrEqual, Between,
In, Like, ILike, IsNull, ArrayContains,
ArrayContainedBy, ArrayOverlap, Raw, And, Or
} from 'typeorm';
// 비교 연산
where: { age: MoreThan(18) } // age > 18
where: { age: Between(18, 65) } // age BETWEEN 18 AND 65
where: { age: Not(LessThan(18)) } // NOT (age < 18)
// 문자열 검색
where: { name: Like('%alice%') } // LIKE '%alice%'
where: { name: ILike('%alice%') } // ILIKE (PostgreSQL, 대소문자 무시)
// NULL 체크
where: { deletedAt: IsNull() } // deletedAt IS NULL
where: { deletedAt: Not(IsNull()) } // deletedAt IS NOT NULL
// 배열 (PostgreSQL)
where: { tags: ArrayContains(['typescript']) } // tags @> '{typescript}'
where: { tags: ArrayOverlap(['ts', 'js']) } // tags && '{ts,js}'
// IN 쿼리
where: { status: In(['active', 'pending']) } // status IN ('active', 'pending')
// Raw SQL (복잡한 조건)
where: { createdAt: Raw(alias => `${alias} > NOW() - INTERVAL '7 days'`) }
OR 조건과 복합 조건
// OR: where에 배열 전달
const users = await userRepository.find({
where: [
{ role: 'admin' }, // role = 'admin'
{ email: Like('%@company.com') }, // OR email LIKE '%@company.com'
],
});
// AND + OR 복합
const orders = await orderRepository.find({
where: [
{ status: 'pending', total: MoreThan(100) }, // (pending AND > 100)
{ status: 'failed', retryCount: LessThan(3) }, // OR (failed AND retry < 3)
],
});
// 같은 필드에 여러 조건 (TypeORM 0.3.x And/Or 연산자)
where: {
price: And(MoreThan(10), LessThan(100)), // price > 10 AND price < 100
category: Or(Equal('A'), Equal('B')), // category = 'A' OR category = 'B'
}
relations vs select: 성능 최적화
// ❌ 불필요한 관계 전체 로딩
const users = await userRepository.find({
relations: { profile: true, posts: true }, // posts가 수백 개면 메모리 폭발
});
// ✅ select로 필요한 컬럼만
const users = await userRepository.find({
select: {
id: true,
name: true,
profile: { avatar: true }, // profile에서 avatar만
},
relations: { profile: true }, // posts는 로드하지 않음
});
// ✅ 관계 조건 검색 (where에서 관계 필드 사용)
const users = await userRepository.find({
where: {
posts: {
status: 'published',
createdAt: MoreThan(new Date('2026-01-01')),
},
},
relations: { posts: true },
});
관계를 조건으로만 사용하고 데이터는 로드하지 않으려면 QueryBuilder의 innerJoin()(select 없는 JOIN)을 사용해야 한다.
페이징 패턴
// 오프셋 기반 페이징
async function findPaginated(page: number, size: number) {
const [items, total] = await userRepository.findAndCount({
where: { isActive: true },
order: { createdAt: 'DESC' },
skip: page * size,
take: size,
});
return {
items,
total,
page,
totalPages: Math.ceil(total / size),
hasNext: (page + 1) * size < total,
};
}
// 커서 기반 페이징 (대량 데이터에서 성능 우수)
async function findAfterCursor(cursor: Date | null, size: number) {
const where: FindOptionsWhere<User> = { isActive: true };
if (cursor) {
where.createdAt = LessThan(cursor);
}
const items = await userRepository.find({
where,
order: { createdAt: 'DESC' },
take: size + 1, // 다음 페이지 존재 여부 확인용
});
const hasNext = items.length > size;
if (hasNext) items.pop();
return {
items,
nextCursor: items.at(-1)?.createdAt ?? null,
hasNext,
};
}
동적 쿼리 빌드
// DTO 기반 동적 필터
interface UserFilterDto {
name?: string;
role?: string;
minAge?: number;
maxAge?: number;
isActive?: boolean;
}
function buildFindOptions(filter: UserFilterDto): FindManyOptions<User> {
const where: FindOptionsWhere<User> = {};
if (filter.name) where.name = ILike(`%${filter.name}%`);
if (filter.role) where.role = Equal(filter.role);
if (filter.isActive !== undefined) where.isActive = filter.isActive;
if (filter.minAge && filter.maxAge) {
where.age = Between(filter.minAge, filter.maxAge);
} else if (filter.minAge) {
where.age = MoreThanOrEqual(filter.minAge);
} else if (filter.maxAge) {
where.age = LessThanOrEqual(filter.maxAge);
}
return {
where,
order: { createdAt: 'DESC' },
take: 20,
};
}
// NestJS 컨트롤러에서 사용
@Get()
async findUsers(@Query() filter: UserFilterDto) {
return this.userRepository.find(buildFindOptions(filter));
}
캐시와 Soft Delete
// 쿼리 캐시 (DataSource에서 cache 옵션 활성화 필요)
const users = await userRepository.find({
where: { role: 'admin' },
cache: {
id: 'admin-users', // 캐시 키
milliseconds: 60000, // 60초 TTL
},
});
// Soft Delete된 엔티티 포함 조회
const allUsers = await userRepository.find({
withDeleted: true, // @DeleteDateColumn이 있는 엔티티
where: { deletedAt: Not(IsNull()) }, // 삭제된 것만
});
TypeORM Soft Delete 운영에서 Soft Delete와 unique 제약, cascade 설정의 함정을 확인할 수 있다.
FindOptions vs QueryBuilder
| 비교 항목 | FindOptions | QueryBuilder |
|---|---|---|
| 타입 안전성 | ✅ 컴파일 타임 체크 | ⚠️ 문자열 기반 |
| 학습 곡선 | 낮음 | 중간 |
| 서브쿼리 | ❌ | ✅ |
| GROUP BY / HAVING | ❌ | ✅ |
| 복잡한 JOIN | ⚠️ 제한적 | ✅ 자유로움 |
| 적합 상황 | 단순 CRUD, 필터링 | 리포트, 통계, 복잡 쿼리 |
주의사항
- relations 없이 where에 관계 필드 사용 불가 —
where: { posts: { status: 'active' } }를 쓰려면 반드시relations: { posts: true }도 함께 설정해야 한다. - take와 관계 로딩 — relations과 take를 함께 사용하면 LEFT JOIN 후 LIMIT이 적용되어 예상보다 적은 결과가 나올 수 있다. 정확한 페이징이 필요하면 QueryBuilder를 사용하라.
- select와 relations 충돌 — select에서 관계 필드를 지정할 때는 해당 관계도 relations에 포함해야 한다.
- Raw() SQL 인젝션 주의 — Raw 연산자에 사용자 입력을 직접 넣지 마라. 파라미터 바인딩을 사용하라.
정리
TypeORM FindOptions는 타입 안전한 선언적 쿼리의 핵심 도구다. FindOperator 조합으로 대부분의 검색 조건을 커버하고, select로 불필요한 데이터 로딩을 줄이고, findAndCount로 페이징을 구현한다. 서브쿼리나 GROUP BY가 필요한 시점이 QueryBuilder로 넘어가는 경계선이다. 단순 CRUD는 FindOptions, 복잡한 쿼리는 QueryBuilder — 이 기준으로 선택하면 된다.