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은 이 한 줄에 두 가지 의미를 동시에 담았다.
- 새 객체면 INSERT —
user.id가 없으면 새로 만들기. - 존재하는 객체면 UPDATE —
user.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 비교 매트릭스
| 기준 | save | insert | update |
|---|---|---|---|
| 인자 | 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언제 어느 쪽
| 상황 | 선택 | 이유 |
|---|---|---|
| 신규 사용자 가입 (관계 없음) | insert | SELECT 없으니 빠르다 |
| 신규 사용자 + 프로필 (cascade) | save | 관계 처리 필요 |
| 사용자 정보 갱신 | save | dirty 없지만 cascade·이벤트 |
| 사용자의 email만 갱신 | update | 다른 컬럼 안 건드림 |
| 대량 임포트 (10만 행) | insert (또는 query + COPY) | save 10만 번 = 20만 SQL |
| 외부 데이터 sync | upsert (네이티브) | 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는
Session이 Unit of Work + Identity Map이라 변경 추적이 가능하다. TypeORM은EntityManager/Repository가 stateless에 가깝다 — 변경을 감지할 수단이 없다.
2) save 후 PK가 안 바뀐다고 착각
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초insertbatch: ~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도 같이 INSERTcascade는 기본 꺼져 있다.
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" })만 보고는 이게 새 객체인지 기존 객체인지 알 수 없다. 두 가지 선택지가 있었다.
- 엄격하게 —
insert와update만 두고 사용자가 골라야 한다. - 너그럽게 — 알아서 처리해 준다 (upsert).
TypeORM은 2번을 골랐다. 대신:
- 정확한 의미를 원하면
insert/update를 명시적으로 쓰면 된다. save는 80% 케이스에서 가장 짧은 코드가 된다.
이 결정 덕분에 빠른 프로토타이핑이 쉽다 — 하지만 프로덕션에서는 의식적으로 골라야 한다.
재미있는 사실 — save의 SELECT는 옵션으로 끌 수 없다
// 가짜 코드 — 이런 옵션은 없다
await userRepo.save(user, { skipSelect: true });TypeORM 이슈 트래커에 수년간 올라온 요청이지만, “그러면 cascade가 깨진다”는 이유로 거부됐다. 정말 빠르게 하고 싶으면 insert나 QueryBuilder.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 환경에서의 주입과 함정.