04 — save의 의미

질문: userRepo.save(user) 한 줄이 어떤 SQL을 만드는가? 왜 insert보다 느린가? user.name = "Bob"만 했는데 왜 DB가 안 바뀌는가? 한 줄 답: save()upsert다 — 항상 SELECT로 존재 확인 후 INSERT 또는 UPDATE를 한다. 그래서 insert()보다 느리다. TypeORM에는 Hibernate 같은 dirty checking이 없다 — 명시적 save가 필요하다.


Why — 왜 항상 SELECT부터 하는가

save()의 시그니처는 간단해 보인다.

async save<T>(entity: T): Promise<T>

하지만 TypeORM은 이 한 줄에 두 가지 의미를 동시에 담았다.

  1. 새 객체면 INSERTuser.id가 없으면 새로 만들기.
  2. 존재하는 객체면 UPDATEuser.id가 있고 DB에 있으면 갱신.

→ 둘을 구분하려면 DB에 그 ID가 존재하는지 알아야 한다. 그래서 SELECT를 먼저 친다.

-- save(user) 호출 시 (user.id = 1)
SELECT * FROM user WHERE id = 1;     -- ← 이게 추가됨
-- 있으면:
UPDATE user SET ... WHERE id = 1;
-- 없으면:
INSERT INTO user (...) VALUES (...);

결론: save최소 2번의 DB 왕복. 단순 INSERT 1회보다 느리다. 대량 입력 시 insert로 바꾸면 2~5배 빨라질 수 있다.


How — save내부에서 하는 일

단계별 SQL

// 시나리오: user.id = 1, DB에 존재, name만 "Bob"으로 변경
await userRepo.save(user);
-- 1. SELECT (존재 확인 + 현재 값 가져오기)
SELECT u.id, u.name, u.email, u.role, ...
FROM user u
WHERE u.id = 1;
 
-- 2. 변경 컬럼만 골라 UPDATE
UPDATE user SET name = 'Bob' WHERE id = 1;

→ 2번 SQL의 SET 절바뀐 컬럼만 들어간다. 이 점은 update()와 다르다 — update받은 객체의 모든 컬럼을 SET한다.

cascade가 동작하는 모습

const user = new User();
user.name = "Alice";
user.profile = new Profile();
user.profile.country = "KR";
 
await userRepo.save(user);
// 1. SELECT user WHERE id = ? — (id 없으니 스킵 or auto-gen)
// 2. INSERT INTO user (...) RETURNING id
// 3. profile.userId = 새 id 설정
// 4. SELECT profile WHERE id = ? — (없으니 스킵)
// 5. INSERT INTO profile (...)
// → 한 트랜잭션 안에서

cascade는 엔티티의 관계 옵션({ cascade: true })이 켜져 있을 때만 동작한다.


What — save vs insert vs update 비교 매트릭스

기준saveinsertupdate
인자Entity | Entity[]Entity | QueryDeepPartial(criteria, partial)
SELECT 선행✓ (존재 확인)
의미upsert순수 INSERT순수 UPDATE
cascade
이벤트 발화전부 (Before/After Insert/Update)InsertEvent만UpdateEvent만
@BeforeInsert/@BeforeUpdate✓ (Insert)
반환저장된 entity (PK 갱신됨)InsertResult (identifiers)UpdateResult (affected)
속도느림빠름빠름
트랜잭션자동 (관계 있을 때)단일 SQL단일 SQL
// save — 안전, 느림, 의미 보존
const saved = await userRepo.save({ name: "Alice", email: "a@x.com" });
saved.id;  // ← generated ID가 *인스턴스에 채워짐*
 
// insert — 빠름, 가벼움
const result = await userRepo.insert({ name: "Alice", email: "a@x.com" });
result.identifiers;  // [{ id: 42 }] — 원본 객체는 *수정 안 됨*
 
// update — 명시적 column-level
await userRepo.update({ id: 1 }, { name: "Bob" });
// criteria로 찾아서 *주어진 컬럼만* SET

언제 어느 쪽

상황선택이유
신규 사용자 가입 (관계 없음)insertSELECT 없으니 빠르다
신규 사용자 + 프로필 (cascade)save관계 처리 필요
사용자 정보 갱신savedirty 없지만 cascade·이벤트
사용자의 email만 갱신update다른 컬럼 안 건드림
대량 임포트 (10만 행)insert (또는 query + COPY)save 10만 번 = 20만 SQL
외부 데이터 syncupsert (네이티브)ON CONFLICT

What-if — 잘못 이해하면

1) “user.name = 'Bob'만 하면 자동 저장되겠지” — dirty checking 함정

const user = await userRepo.findOneBy({ id: 1 });
user.name = "Bob";
// 끝.
// ← DB는 바뀌지 않는다.
 
// 명시적으로 호출해야 함
await userRepo.save(user);

TypeORM에는 Hibernate의 dirty checking이 없다. 이 점이 Java 출신 개발자에게 가장 자주 혼란을 준다.

Hibernate vs TypeORM — 같은 코드, 다른 결과

// Hibernate (Java) — DB가 바뀐다!
User user = session.get(User.class, 1L);
user.setName("Bob");
session.getTransaction().commit();
// → flush 시 dirty 감지 → UPDATE
// TypeORM — DB가 안 바뀐다
const user = await userRepo.findOneBy({ id: 1 });
user.name = "Bob";
// 트랜잭션 commit해도 *명시적 save가 없으면* 무시

이유: Hibernate는 SessionUnit of Work + Identity Map이라 변경 추적이 가능하다. TypeORM은 EntityManager/Repositorystateless에 가깝다 — 변경을 감지할 수단이 없다.

2) savePK가 안 바뀐다고 착각

const data = { name: "Alice", email: "a@x.com" };
await userRepo.save(data);
console.log(data);
// → { id: 42, name: "Alice", email: "a@x.com" }
// 원본 객체에 *id가 추가됨*

save입력 객체를 mutating한다. 이 점이 함수형과 충돌하기도 한다 — 그래서 반환값을 받아 쓰는 게 안전.

const saved = await userRepo.save(data);
// saved와 data는 *같은 참조* (mutation)

3) 큰 배열을 save([...])로 — 느리다

const users = [...Array(10_000)].map((_, i) => ({ name: `u${i}` }));
await userRepo.save(users);
// → SELECT 10000번 + INSERT 10000번 = 20000 SQL
 
// 빠르게
await userRepo.insert(users);
// → INSERT batch 1~몇 개 SQL
 
// 더 빠르게 (PostgreSQL)
await dataSource.query(`COPY user FROM STDIN`, ...);

벤치마크 감각: 10,000건 기준

  • save: ~30초
  • insert batch: ~2초
  • COPY: ~0.3초

4) update부분 객체 가정

const user = await userRepo.findOneBy({ id: 1 });
user.name = "Bob";
// 다른 컬럼은 그대로 두고 싶다
 
await userRepo.update({ id: 1 }, user);
// ✗ 위험! user의 *모든 컬럼*이 SET됨
// → 만약 user.email이 stale이면 stale 값으로 덮어쓰기
 
// 안전한 방법
await userRepo.update({ id: 1 }, { name: "Bob" });
// 또는 save 사용
await userRepo.save(user);

update받은 객체 모양 그대로 SET한다 — dirty가 없으니 변경된 것만 골라 쓰는 일은 호출자가 한다.

5) cascade 기대

@Entity()
class User {
  @OneToMany(() => Post, p => p.user)   // ← cascade 없음
  posts: Post[];
}
 
const user = new User();
user.posts = [new Post()];
await userRepo.save(user);
// ✗ posts는 저장 안 됨
 
// cascade 켜기
@OneToMany(() => Post, p => p.user, { cascade: true })
posts: Post[];
 
await userRepo.save(user);
// ✓ posts도 같이 INSERT

cascade는 기본 꺼져 있다.


Insight — 이런 의미를 골랐는가

”dirty checking은 마법이고, 마법은 예측 불가능하다

Hibernate의 dirty checking은 Unit of Work + Proxy가 함께 있어야 한다.

  • session.get()프록시를 반환하고
  • 모든 setter 호출이 변경 플래그를 켜며
  • commit()에서 변경된 플래그들을 모아 UPDATE를 만든다.

이게 편하지만,

  • 어떤 SQL이 언제 날아가는지 예측이 어렵다.
  • 의도하지 않은 setter 호출(e.g. JSON deserialization)이 UPDATE를 발생시킨다.
  • 프록시가 equals/hashCode를 망친다.

TypeORM은 이 마법을 의도적으로 거부했다. 모든 영속화는 명시적 save 호출로만 일어난다. 그 대가는 코드 길어짐, 보상은 예측 가능성이다.

”그래서 왜 save는 upsert인가” — 모호함을 그대로 받아들인다

save({ name: "Alice" })만 보고는 이게 새 객체인지 기존 객체인지 알 수 없다. 두 가지 선택지가 있었다.

  1. 엄격하게insertupdate만 두고 사용자가 골라야 한다.
  2. 너그럽게알아서 처리해 준다 (upsert).

TypeORM은 2번을 골랐다. 대신:

  • 정확한 의미를 원하면 insert/update명시적으로 쓰면 된다.
  • save80% 케이스에서 가장 짧은 코드가 된다.

이 결정 덕분에 빠른 프로토타이핑이 쉽다 — 하지만 프로덕션에서는 의식적으로 골라야 한다.

재미있는 사실save의 SELECT는 옵션으로 끌 수 없다

// 가짜 코드 — 이런 옵션은 없다
await userRepo.save(user, { skipSelect: true });

TypeORM 이슈 트래커에 수년간 올라온 요청이지만, “그러면 cascade가 깨진다”는 이유로 거부됐다. 정말 빠르게 하고 싶으면 insertQueryBuilder.insert()로 가야 한다.


요약

save()는 upsert + cascade + 이벤트의 세트 메뉴다. 항상 SELECT부터 하기 때문에 느리다insert는 SELECT 없이 INSERT 1개. dirty checking은 없다entity.x = 'y'만으로 DB가 바뀌지 않는다, 반드시 save 호출. update는 dirty가 아니라 주어진 컬럼만 SET — 부분 객체를 주면 그것만 갱신. 한 줄 규칙: “의미를 보존할 땐 save, 속도가 필요할 땐 insert/update.”

다음: 05 — Custom Repository — 도메인 메서드를 어디 두는가. NestJS 환경에서의 주입과 함정.