02 — Repository API

질문: find vs findOne vs findOneBy, save vs insert, delete vs remove vs softDelete — 비슷해 보이는 메서드들이 실제로 어떻게 다른가? 한 줄 답: Repository의 메서드는 SELECT 그룹(find*)·쓰기 그룹(save/insert/update)·삭제 그룹(delete/remove/softDelete) 셋으로 갈리고, 각 그룹 안에서도 반환 타입·인자 모양·side effect가 다르다.


Why — 왜 메서드가 이렇게 많은가

답은 TypeORM이 두 가지 영속성 의미를 동시에 다루기 때문이다.

  1. 객체 단위 의미 (save, remove) — 엔티티 인스턴스를 받고, 관계까지 처리하며, 이벤트(subscriber/listener)를 발화시킨다.
  2. 쿼리 단위 의미 (insert, update, delete) — 컬럼 값만 받고, 주어진 컬럼만 쓰며, 이벤트를 발화시키지 않는다.

같은 작업(예: “한 명을 지운다”)에 두 가지 API가 있는 것은 추상화 수준의 선택을 주기 위함이다. 잘못 고르면 cascade가 안 돌아가거나, 반대로 너무 많은 SQL이 나간다.


How — 11개 메서드 한 표

읽기 그룹

메서드반환인자특징
find(options)Entity[]FindManyOptions0건이면 [] (에러 아님)
findOne(options)Entity | nullFindOneOptions0건이면 null, where 필수 (v0.3+)
findOneBy(where)Entity | nullFindOptionsWherefindOne({ where })축약형
findOneOrFail(opt)EntityFindOneOptions0건이면 throw EntityNotFoundError
findAndCount(opt)[Entity[], number]FindManyOptions페이지네이션용 — 데이터 + 전체 카운트 동시
count(opt)numberFindManyOptions카운트만
exists(opt) (v0.3+)booleanFindManyOptions존재 검사 — 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[]저장된 entityupsert + SELECT 선행 + cascade + 이벤트
insert(entity)Entity | QueryDeepPartialInsertResult순수 INSERT — SELECT 없음, cascade 없음, 이벤트 일부만
update(criteria, partial)criteria + partialUpdateResult순수 UPDATE — 주어진 컬럼만, cascade 없음
upsert(entity, conflictPaths)entity + 충돌 키InsertResultDB 네이티브 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 + 이벤트인스턴스의 idundefined가 됨
delete(criteria)id | FindOptionsWhere순수 DELETE이벤트·cascade 없음, 빠르다
softDelete(criteria)criteriaUPDATE SET deletedAt = NOW()@DeleteDateColumn 필요
softRemove(entity)entitysoftDelete + cascade + 이벤트softDelete의 엔티티 버전
restore(criteria)criteriaUPDATE SET deletedAt = NULLsoftDelete 복구
// 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

함정: softDeletefind해당 레코드를 안 보여준다 — 모든 SELECT에 WHERE deletedAt IS NULL이 자동 붙기 때문. 보려면 withDeleted: true (03 문서 참조).


What — 그룹별 반환 타입부수 효과 매트릭스

결정 표 — 어떤 메서드를 골라야 하나

상황선택이유
새 객체 1개 만들고 끝insertsave보다 빠름 — SELECT 없음
객체 + 관계까지 묶어 저장savecascade가 동작
객체가 존재할지 모르겠다saveupsert 의미
ID로 1건 보기findOneBy짧다
0건이면 throw 해라findOneOrFail도메인 invariant
페이지네이션findAndCount데이터+카운트 1트랜잭션
특정 컬럼만 갱신updatedirty 없으니 명시적
데이터 백업 필요한 삭제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) updatedirty 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) deletecascade를 기대

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/softDeleteSQL 한 줄에 가깝다. 빠르지만 조립 책임이 호출자에게 넘어온다.

언제 어느 쪽을 쓰는가는 그대로 코드의 추상화 수준 선택이다. 대량 입력(import 스크립트)은 insert로 빠르게, 도메인 행위(주문 생성)는 save로 의미를 보존.

TypeORM source의 재미있는 비대칭

Repository의 메서드 11개 중,

  • save/remove/softRemove내부적으로 EntityManager로 위임하고, EntityManagersubject·SubjectExecutor를 만들어 cascade를 풀어낸다 — 코드가 길다.
  • insert/update/delete바로 QueryBuilder로 우회해 SQL 1개를 만든다 — 코드가 짧다.

→ 이 비대칭이 성능 차이의 출처다. save근본적으로 insert보다 느릴 수밖에 없다.


요약

읽기0건 처리 방식으로 갈린다 — find[], findOnenull, 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 매핑.