02 — Repository API
질문:
findvsfindOnevsfindOneBy,savevsinsert,deletevsremovevssoftDelete— 비슷해 보이는 메서드들이 실제로 어떻게 다른가? 한 줄 답: Repository의 메서드는 SELECT 그룹(find*)·쓰기 그룹(save/insert/update)·삭제 그룹(delete/remove/softDelete) 셋으로 갈리고, 각 그룹 안에서도 반환 타입·인자 모양·side effect가 다르다.
Why — 왜 메서드가 이렇게 많은가
답은 TypeORM이 두 가지 영속성 의미를 동시에 다루기 때문이다.
- 객체 단위 의미 (
save,remove) — 엔티티 인스턴스를 받고, 관계까지 처리하며, 이벤트(subscriber/listener)를 발화시킨다. - 쿼리 단위 의미 (
insert,update,delete) — 컬럼 값만 받고, 주어진 컬럼만 쓰며, 이벤트를 발화시키지 않는다.
같은 작업(예: “한 명을 지운다”)에 두 가지 API가 있는 것은 추상화 수준의 선택을 주기 위함이다. 잘못 고르면 cascade가 안 돌아가거나, 반대로 너무 많은 SQL이 나간다.
How — 11개 메서드 한 표
읽기 그룹
| 메서드 | 반환 | 인자 | 특징 |
|---|---|---|---|
find(options) | Entity[] | FindManyOptions | 0건이면 [] (에러 아님) |
findOne(options) | Entity | null | FindOneOptions | 0건이면 null, where 필수 (v0.3+) |
findOneBy(where) | Entity | null | FindOptionsWhere | findOne({ where })의 축약형 |
findOneOrFail(opt) | Entity | FindOneOptions | 0건이면 throw EntityNotFoundError |
findAndCount(opt) | [Entity[], number] | FindManyOptions | 페이지네이션용 — 데이터 + 전체 카운트 동시 |
count(opt) | number | FindManyOptions | 카운트만 |
exists(opt) (v0.3+) | boolean | FindManyOptions | 존재 검사 — count > 0보다 빠르다 |
// find — 0건이면 빈 배열
const users: User[] = await userRepo.find({ where: { active: true } });
// findOne — 0건이면 null
const user: User | null = await userRepo.findOne({ where: { id: 1 } });
// findOneBy — where만 받는 축약형
const user2 = await userRepo.findOneBy({ id: 1 });
// findOneOrFail — 0건이면 throw
try {
const user = await userRepo.findOneOrFail({ where: { id: 1 } });
} catch (e) {
// EntityNotFoundError
}
// findAndCount — 페이지네이션
const [list, total] = await userRepo.findAndCount({
take: 20, skip: 0,
});
// list.length는 ≤ 20, total은 전체 매칭 수 (LIMIT 무시)v0.3 호환성 주의:
findOne(id)같은 PK 단축형은 v0.2에서 가능했지만 v0.3에서 제거됐다. 반드시findOneBy({ id })또는findOne({ where: { id } }).
쓰기 그룹 — save가 핵심
| 메서드 | 인자 | 반환 | 핵심 |
|---|---|---|---|
save(entity) | Entity | Entity[] | 저장된 entity | upsert + SELECT 선행 + cascade + 이벤트 |
insert(entity) | Entity | QueryDeepPartial | InsertResult | 순수 INSERT — SELECT 없음, cascade 없음, 이벤트 일부만 |
update(criteria, partial) | criteria + partial | UpdateResult | 순수 UPDATE — 주어진 컬럼만, cascade 없음 |
upsert(entity, conflictPaths) | entity + 충돌 키 | InsertResult | DB 네이티브 ON CONFLICT 사용 |
// save — upsert 의미
await userRepo.save(user);
// 1. user.id가 있으면 SELECT로 존재 확인 → 있으면 UPDATE, 없으면 INSERT
// 2. user.id가 없으면 바로 INSERT
// 3. cascade 관계도 같이 저장
// 4. @BeforeInsert/@BeforeUpdate 데코레이터 발화
// insert — 순수 INSERT
const result = await userRepo.insert({ name: "Alice" });
// result.identifiers = [{ id: 42 }]
// SELECT 없음, 빠르다. 하지만 user 인스턴스는 갱신되지 않는다.
// update — 순수 UPDATE
await userRepo.update({ id: 1 }, { name: "Bob" });
// UPDATE user SET name='Bob' WHERE id=1
// 다른 컬럼 건드리지 않음. dirty checking 없음.
// upsert — DB 네이티브
await userRepo.upsert(
{ email: "a@x.com", name: "A" },
['email']
);
// INSERT ... ON CONFLICT (email) DO UPDATE자세한 의미는 04 — save 의미에서.
삭제 그룹 — hard / soft / instance / criteria
| 메서드 | 인자 | 동작 | side effect |
|---|---|---|---|
remove(entity) | Entity | Entity[] | hard DELETE + cascade + 이벤트 | 인스턴스의 id가 undefined가 됨 |
delete(criteria) | id | FindOptionsWhere | 순수 DELETE | 이벤트·cascade 없음, 빠르다 |
softDelete(criteria) | criteria | UPDATE SET deletedAt = NOW() | @DeleteDateColumn 필요 |
softRemove(entity) | entity | softDelete + cascade + 이벤트 | softDelete의 엔티티 버전 |
restore(criteria) | criteria | UPDATE SET deletedAt = NULL | softDelete 복구 |
// remove — hard delete, 인스턴스 받음
const user = await userRepo.findOneBy({ id: 1 });
await userRepo.remove(user);
// DELETE FROM user WHERE id = 1
// cascade·이벤트 동작, user.id는 undefined로
// delete — criteria 받음, 빠르다
await userRepo.delete(1);
await userRepo.delete({ active: false });
// DELETE FROM user WHERE ...
// softDelete — DeleteDateColumn 필요
@Entity()
class User {
@DeleteDateColumn() deletedAt: Date | null;
}
await userRepo.softDelete(1);
// UPDATE user SET deletedAt = NOW() WHERE id = 1
await userRepo.restore(1);
// UPDATE user SET deletedAt = NULL WHERE id = 1함정:
softDelete후find는 해당 레코드를 안 보여준다 — 모든 SELECT에WHERE deletedAt IS NULL이 자동 붙기 때문. 보려면withDeleted: true(03 문서 참조).
What — 그룹별 반환 타입과 부수 효과 매트릭스
결정 표 — 어떤 메서드를 골라야 하나
| 상황 | 선택 | 이유 |
|---|---|---|
| 새 객체 1개 만들고 끝 | insert | save보다 빠름 — SELECT 없음 |
| 객체 + 관계까지 묶어 저장 | save | cascade가 동작 |
| 객체가 존재할지 모르겠다 | save | upsert 의미 |
| ID로 1건 보기 | findOneBy | 짧다 |
| 0건이면 throw 해라 | findOneOrFail | 도메인 invariant |
| 페이지네이션 | findAndCount | 데이터+카운트 1트랜잭션 |
| 특정 컬럼만 갱신 | update | dirty 없으니 명시적 |
| 데이터 백업 필요한 삭제 | softDelete | 복구 가능 |
| 진짜 영구 삭제 | delete | 빠름 |
| 삭제하면서 cascade·이벤트 | remove | 객체 받음 |
What-if — 잘못 골랐을 때
1) insert로 cascade를 기대
const post = new Post();
post.title = "Hello";
post.author = newUser; // 새 user도 같이 저장될까?
await postRepo.insert(post); // ✗ — user는 저장 안 됨, FK 에러
await postRepo.save(post); // ✓ — cascade가 동작→ insert는 순수 SQL INSERT 1개만 만든다. 관계는 무시한다.
2) update가 dirty checking인 줄 안다
const user = await userRepo.findOneBy({ id: 1 });
user.name = "Bob";
user.email = "bob@x.com";
// 이렇게 부르면 *어떤 컬럼이 바뀌었는지 모른다*
await userRepo.update({ id: 1 }, user);
// → 전체 컬럼이 SET되며 위험할 수 있음
// 안전한 방법
await userRepo.update({ id: 1 }, { name: "Bob", email: "bob@x.com" });
// 또는
await userRepo.save(user); // upsert + SELECT→ TypeORM에는 dirty checking이 없다 (04 문서).
3) delete 후 cascade를 기대
await userRepo.delete(1);
// posts 테이블의 author_id=1인 row가 자동 삭제될 거라고?
// ✗ — DB의 ON DELETE 옵션을 안 걸었으면 FK 에러
// ✓ TypeORM cascade를 쓰려면 remove(entity)→ delete criteria 호출은 DB에 직접 DELETE만 날린다. cascade가 필요하면 remove(entity).
4) softDelete 후 진짜 지워졌다고 착각
await userRepo.softDelete(1);
const u = await userRepo.findOneBy({ id: 1 });
// u === null — 안 보이지만,
const u2 = await userRepo.findOne({
where: { id: 1 },
withDeleted: true,
});
// u2 !== null — *DB에 그대로* 있다→ soft delete는 물리적 삭제가 아니라 표식이다. GDPR/개인정보보호 요구에는 delete가 필요할 수 있다.
5) findOne 인자 PK 단축형을 v0.3에서 시도
// v0.2 — 가능
await userRepo.findOne(1);
// v0.3 — 컴파일 에러 또는 런타임 에러
await userRepo.findOne(1); // ✗
await userRepo.findOneBy({ id: 1 }); // ✓
await userRepo.findOne({ where: { id: 1 } }); // ✓→ v0.3에서 명시적 where를 강제했다 — 모호함 제거가 목적.
Insight — 세 그룹이 곧 세 추상화 수준이다
- save/remove/softRemove는 엔티티 인스턴스를 받아 도메인 의미를 보존한다. 느리지만 안전.
- insert/update/delete/softDelete는 SQL 한 줄에 가깝다. 빠르지만 조립 책임이 호출자에게 넘어온다.
→ 언제 어느 쪽을 쓰는가는 그대로 코드의 추상화 수준 선택이다. 대량 입력(import 스크립트)은 insert로 빠르게, 도메인 행위(주문 생성)는 save로 의미를 보존.
TypeORM source의 재미있는 비대칭
Repository의 메서드 11개 중,
save/remove/softRemove는 내부적으로EntityManager로 위임하고,EntityManager는 subject·SubjectExecutor를 만들어 cascade를 풀어낸다 — 코드가 길다.insert/update/delete는 바로 QueryBuilder로 우회해 SQL 1개를 만든다 — 코드가 짧다.
→ 이 비대칭이 성능 차이의 출처다. save는 근본적으로 insert보다 느릴 수밖에 없다.
요약
읽기는 0건 처리 방식으로 갈린다 —
find는[],findOne은null,findOneOrFail은 throw. 쓰기는 추상화 수준으로 갈린다 —save는 엔티티 의미(upsert+cascade),insert/update는 SQL 의미. 삭제는 영구성으로 갈린다 —delete/remove는 hard,softDelete/softRemove는 표식. 한 줄 규칙: “도메인 의미가 필요하면 엔티티 메서드(save/remove), SQL 통제권이 필요하면 criteria 메서드(insert/update/delete).”
다음: 03 — Find 옵션 —
find*메서드가 받는 옵션 8키와 5개 operator의 정확한 SQL 매핑.