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에 그 관계가 포함되지 않는다. 결과 객체에 profileundefined일 수 있다.

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 — QueryBuilderloadRelationIdAndMap이나 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 },
});

함정: selectPK를 빠뜨리면 관계가 정상적으로 매핑되지 않을 수 있다. 항상 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 bwhere: { age: Between(20, 30) }
IsNull()IS NULLwhere: { 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) relationstake

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) Likeescape 미처리

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) selectPK 빠뜨리기

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 + takecartesian/2-query 함정에 주의.
  • 중첩 where자동 JOIN을 만들지만 select하지 않는다.
  • Raw마지막 수단이고 파라미터화 필수.
  • 표현 못 하는 건 04 — QueryBuilder로.

다음: 04 — save의 의미save()왜 upsert이고 왜 항상 SELECT부터 하는가.