03 — Find 옵션
질문:
find({ where: ..., relations: ..., order: ... })— 이 8개 키와 5개 operator는 어떤 SQL로 풀려나가는가? 한 줄 답: Find 옵션은 선언적 객체 → QueryBuilder → SQL 3단 번역의 첫 입력이다. 8개 키는 SQL 절(WHERE/JOIN/ORDER/LIMIT) 과 1:1 매핑되고, 5개 operator는 WHERE의 비교 연산자를 표현한다.
Why — 왜 옵션 객체로 표현하는가
답은 find가 너무 자주 호출되기 때문이다.
- 매번
createQueryBuilder("u").leftJoinAndSelect("u.posts", "p").where(...).getMany()로 4줄을 쓰는 건 80% 케이스에 과하다. - 그렇다고
find(id)처럼 PK만 받는 함수는 너무 부족하다. - 그래서 대부분의 쿼리를 표현할 수 있는 옵션 객체가 필요하다 — 그게
FindOptions다.
트레이드오프: 80% 케이스를 짧고 선언적으로. 나머지 20%는
createQueryBuilder로 명시적으로. find 옵션은 그 80%의 경계선이다.
How — 8개 옵션 키
type FindManyOptions<T> = {
where?: FindOptionsWhere<T> | FindOptionsWhere<T>[];
relations?: FindOptionsRelations<T> | string[];
order?: FindOptionsOrder<T>;
take?: number;
skip?: number;
select?: FindOptionsSelect<T> | (keyof T)[];
cache?: boolean | number | { id: any; milliseconds: number };
withDeleted?: boolean;
// + loadRelationIds · loadEagerRelations · lock · transaction 등 부가
};1) where — WHERE 절
// 단순 동등
await userRepo.find({ where: { active: true } });
// WHERE active = true
// AND 결합
await userRepo.find({ where: { active: true, role: 'admin' } });
// WHERE active = true AND role = 'admin'
// OR — *배열*로 표현
await userRepo.find({
where: [
{ active: true },
{ role: 'admin' },
],
});
// WHERE active = true OR role = 'admin'
// 중첩 — 관계의 필드
await userRepo.find({
where: { profile: { country: 'KR' } },
});
// LEFT JOIN profile ON ... WHERE profile.country = 'KR'함정: 중첩 where는 자동으로 JOIN을 만든다.
relations를 안 적었어도 JOIN이 생긴다 — 하지만 select에 그 관계가 포함되지 않는다. 결과 객체에profile이 undefined일 수 있다.
2) relations — JOIN + select
// 1단계
await userRepo.find({ relations: { posts: true } });
// LEFT JOIN ... posts ON ...
// 결과 user.posts = [...]
// 2단계 (중첩)
await userRepo.find({
relations: { posts: { comments: true } },
});
// LEFT JOIN posts ON ... LEFT JOIN comments ON ...
// 문자열 배열 형태 (구식 — 권장하지 않음)
await userRepo.find({ relations: ['posts', 'posts.comments'] });N+1 함정: 1:N 관계를
relations로 가져오면 cartesian product가 만들어진다. user 100명 × posts 평균 10개 = 1000 row가 한 번에 온다. 큰 관계는 04 — QueryBuilder의loadRelationIdAndMap이나relationLoadStrategy: 'query'(v0.3+)로.
3) order — ORDER BY
await userRepo.find({ order: { createdAt: 'DESC' } });
// ORDER BY created_at DESC
await userRepo.find({
order: { active: 'DESC', name: 'ASC' },
});
// ORDER BY active DESC, name ASC
// 관계의 필드로도 정렬 가능
await userRepo.find({
relations: { profile: true },
order: { profile: { score: 'DESC' } },
});4) take / skip — LIMIT / OFFSET
await userRepo.find({ take: 20, skip: 40 });
// LIMIT 20 OFFSET 40함정:
relations(특히 1:N)와take를 같이 쓰면 결과가 덜 나올 수 있다. cartesian product 때문에 user 5명만 받고 싶어도 row 5개로는 user 1명만 채워질 수 있다. → TypeORM v0.3은 이 경우 *자동으로 두 쿼리(SELECT user ... LIMIT+SELECT posts WHERE user_id IN (...))*로 나눠 실행한다. 의도와 다르게 쿼리가 2배가 나는 것에 주의.
5) select — SELECT 컬럼 제한
// 객체 형태 — *권장*
await userRepo.find({
select: { id: true, name: true },
});
// SELECT id, name FROM user
// 배열 형태
await userRepo.find({ select: ['id', 'name'] });
// 관계 포함
await userRepo.find({
select: { id: true, profile: { country: true } },
relations: { profile: true },
});함정:
select로 PK를 빠뜨리면 관계가 정상적으로 매핑되지 않을 수 있다. 항상 PK를 포함시켜라.
6) cache — 결과 캐싱
// DataSource 설정에 cache: true 또는 cache 옵션이 있어야 함
await userRepo.find({ cache: true }); // 기본 TTL
await userRepo.find({ cache: 60000 }); // 60초
await userRepo.find({ cache: { id: 'all-users', milliseconds: 60000 } });
// → query-result-cache 테이블에 저장주의: 이 캐시는 애플리케이션 레벨이고 DB와 동기화되지 않는다. 쓰기 후 수동 invalidate 필요. 대부분의 팀은 Redis를 따로 쓴다.
7) withDeleted — soft delete 포함
// 기본 — soft 삭제 row 제외
await userRepo.find(); // WHERE ... AND deletedAt IS NULL
// soft 삭제 row까지 포함
await userRepo.find({ withDeleted: true });
// WHERE ... (deletedAt 조건 없음)8) loadEagerRelations — eager 관계 끄기
@Entity()
class User {
@OneToMany(() => Post, p => p.user, { eager: true })
posts: Post[];
}
// 기본 — eager가 동작해서 posts가 JOIN됨
await userRepo.find();
// eager 끄기 — 이 쿼리만 lazy처럼
await userRepo.find({ loadEagerRelations: false });What — 5개 operator + 부가 operator
where 안에서 값 위치에 들어가는 비교 연산자 wrapper다.
import {
In, Not, Like, ILike, Between, IsNull,
LessThan, LessThanOrEqual, MoreThan, MoreThanOrEqual,
Any, ArrayContains, Raw,
} from 'typeorm';핵심 5개
| Operator | 매핑 SQL | 예시 |
|---|---|---|
In([...]) | IN (...) | where: { id: In([1, 2, 3]) } |
Not(value) | != value 또는 NOT (...) | where: { active: Not(false) } |
Like('%a%') | LIKE '%a%' | where: { name: Like('%alice%') } |
Between(a, b) | BETWEEN a AND b | where: { age: Between(20, 30) } |
IsNull() | IS NULL | where: { deletedAt: IsNull() } |
// 종합 예시
await userRepo.find({
where: {
id: In([1, 2, 3]),
role: Not('guest'),
name: Like('%alice%'),
age: Between(20, 40),
deletedAt: IsNull(),
},
});
// WHERE id IN (1,2,3)
// AND role != 'guest'
// AND name LIKE '%alice%'
// AND age BETWEEN 20 AND 40
// AND deletedAt IS NULL부가 operator
| Operator | 매핑 | 비고 |
|---|---|---|
ILike('%A%') | ILIKE '%A%' | PostgreSQL only — 대소문자 무시 |
LessThan(n) | < n | |
LessThanOrEqual(n) | <= n | |
MoreThan(n) | > n | |
MoreThanOrEqual(n) | >= n | |
Any([...]) | = ANY (...) | PostgreSQL only |
ArrayContains([...]) | @> | PostgreSQL only — 배열 타입 |
Raw(alias => \…`)` | 그대로 SQL | 탈출구 — SQL 인젝션 주의 |
import { Raw } from 'typeorm';
// 컬럼끼리 비교 등 표준 operator로 안 되는 경우
await userRepo.find({
where: {
createdAt: Raw(alias => `${alias} > NOW() - INTERVAL '7 days'`),
},
});함정:
Raw는 그대로 SQL에 박힌다. 사용자 입력을 절대 문자열 결합으로 넣지 말 것 — 아래는 SQL 인젝션이다. 반드시 파라미터화하라.
// ❌ SQL 인젝션
Raw((alias) => `${alias} = '${userInput}'`)
// ✅ 파라미터화
Raw((alias) => `${alias} = :v`, { v: userInput })What-if — 잘못 쓰면
1) OR을 객체 안에 쉼표로 표현
// ✗ 이건 AND다
await userRepo.find({
where: { active: true, role: 'admin' },
});
// WHERE active = true AND role = 'admin'
// ✓ OR은 배열로
await userRepo.find({
where: [{ active: true }, { role: 'admin' }],
});2) relations와 take의 덫
await userRepo.find({
relations: { posts: true }, // 1:N
take: 5,
});
// 의도: user 5명 + 각자의 posts
// 실제 v0.2: user 5명*posts cartesian → row 5개로 user 1명만 채워질 수 있음
// 실제 v0.3: 자동으로 2쿼리로 분리 (SELECT user ... LIMIT 5; SELECT posts WHERE user_id IN (...))
// → 의도대로 5명 + posts가 온다 *하지만 쿼리 2번*→ 항상 의도한 결과인지 실제 SQL을 로그로 확인해야 한다 (logging: ['query']).
3) where가 관계 필드를 받으면 묻지 않은 JOIN
await userRepo.find({
where: { profile: { country: 'KR' } },
});
// JOIN profile은 추가됐지만 select되지 않는다
// → user.profile === undefined
// JOIN하면서 같이 받으려면 relations도 명시
await userRepo.find({
where: { profile: { country: 'KR' } },
relations: { profile: true },
});4) Like에 escape 미처리
const term = userInput; // 예: "10%할인"
await userRepo.find({
where: { name: Like(`%${term}%`) },
});
// LIKE '%10%할인%' — 10 뒤 모든 것에 매칭 (의도 아님)
// 안전한 방법 — % 와 _ 를 escape
const safe = term.replace(/[\%_]/g, c => `\\${c}`);
await userRepo.find({
where: { name: Like(`%${safe}%`) },
});5) select로 PK 빠뜨리기
await userRepo.find({
select: { name: true },
relations: { posts: true },
});
// id가 빠지면 posts 매핑이 깨질 수 있음
// → 항상 PK를 select에 포함Insight — Find 옵션은 QueryBuilder의 90% 완성도 버전이다
TypeORM 내부에서 find(opts)는 그대로 createQueryBuilder 호출로 번역된다. src/find-options/FindOptionsParser.ts가 옵션 객체를 읽고 QueryBuilder의 where/leftJoinAndSelect/orderBy/limit 등을 호출한다.
→ Find 옵션은 QueryBuilder의 선언적 DSL 표면이다. 그래서:
- 할 수 있는 것: 80% 케이스 — 단순 WHERE, JOIN, ORDER, LIMIT.
- 할 수 없는 것: 서브쿼리, UNION, 윈도우 함수, CTE — 이건 QueryBuilder가 직접 필요.
v0.2 → v0.3의 옵션 객체 진화
v0.2: relations: ['posts', 'posts.comments'] (문자열 배열)
v0.3: relations: { posts: { comments: true } } (중첩 객체)
// v0.3은 타입스크립트가 *키 이름을 검증*한다
relations: { postss: true } // ✗ 컴파일 에러 — 'posts'의 오타→ 객체 형태는 타입 안전하다. 가능한 한 객체 형태를 써라.
select도 비슷한 진화
v0.2: select: ['id', 'name']
v0.3: select: { id: true, name: true, profile: { country: true } } — 관계의 필드까지 타입 안전하게 선택
요약
8개 키: where(필터)·relations(JOIN+select)·order·take·skip·select·cache·withDeleted. 5개 operator: In·Not·Like·Between·IsNull. 부가로 LessThan류·ILike·Any·ArrayContains·Raw. 핵심 규칙:
- OR은 배열로 표현 (객체는 AND).
relations+take는 cartesian/2-query 함정에 주의.- 중첩
where는 자동 JOIN을 만들지만 select하지 않는다.Raw는 마지막 수단이고 파라미터화 필수.- 표현 못 하는 건 04 — QueryBuilder로.
다음: 04 — save의 의미 —
save()가 왜 upsert이고 왜 항상 SELECT부터 하는가.