05 — Cascade & Orphan
질문: 부모 엔티티를 save/delete할 때 자식들은 어떻게 따라가는가? TypeORM의
cascade와 DB의ON DELETE CASCADE는 같은 것인가? 한 줄 답: TypeORMcascade는save()시점에 동작하는 애플리케이션 레벨 전파이고,onDelete: 'CASCADE'는 DB FK에 박히는 DDL이다 — 둘은 다른 시점, 다른 레이어에서 동작한다. 두 개를 모두 의식적으로 결정해야 안전한 삭제가 보장된다.
한 장 그림
Why — 왜 애플리케이션 cascade와 DB cascade가 다른가
TypeORM의 cascade는 런타임 코드 동작이다:
userRepo.save(user)호출 시 → user.posts도 같이 saveuserRepo.remove(user)호출 시 → user.posts도 같이 remove
DB의 ON DELETE CASCADE는 DDL이다:
- FK 제약에 박혀 있어 → *raw SQL
DELETE FROM users*만 해도 자식 row 삭제
| 시나리오 | TypeORM cascade가 처리? | DB CASCADE가 처리? |
|---|---|---|
userRepo.remove(user) | ★ (cascade: true면) | (DB 제약 있어도 동작) |
DELETE FROM users WHERE id=1 (raw) | ✗ | ★ (ON DELETE CASCADE 있을 때만) |
| 마이그레이션에서 일괄 삭제 | ✗ | ★ |
| 다른 서비스가 직접 DB 조작 | ✗ | ★ |
결정적 사실: 애플리케이션을 우회하는 경로가 하나라도 있으면 DB cascade가 필수. 둘 다 켜는 게 방어 깊이.
How — 세 가지 옵션 코드
1) cascade: true — save 전파
@Entity()
export class User {
@OneToMany(() => Post, (post) => post.user, {
cascade: true, // ★ 모든 cascade 동작
})
posts: Post[];
}
// 사용
const user = new User();
user.name = 'A';
user.posts = [
Object.assign(new Post(), { title: 'First' }),
Object.assign(new Post(), { title: 'Second' }),
];
await userRepo.save(user); // ★ user + posts 둘 다 INSERTcascade 옵션 값:
| 값 | 의미 |
|---|---|
true | 모든 cascade 동작 (insert, update, remove, soft-remove, recover) |
false (기본) | 자식 무시 |
['insert'] | 새 자식만 저장, 업데이트는 안 함 |
['insert', 'update'] | 흔한 조합 |
['remove'] | 부모 remove 시 자식도 remove |
권장:
true대신 필요한 액션만 —['insert', 'update']가 흔하다.
2) onDelete: 'CASCADE' — DB 제약
@Entity()
export class Post {
@ManyToOne(() => User, (user) => user.posts, {
onDelete: 'CASCADE', // ★ DDL에 박힘
})
user: User;
}생성되는 DDL:
ALTER TABLE posts
ADD CONSTRAINT FK_posts_user
FOREIGN KEY (userId) REFERENCES users(id)
ON DELETE CASCADE; -- ★이제 *raw SQL DELETE FROM users WHERE id=1*만 해도 해당 user의 모든 posts가 DB 레벨에서 삭제된다.
가능한 onDelete 값:
| 값 | 의미 |
|---|---|
'CASCADE' | 부모 삭제 시 자식도 삭제 |
'SET NULL' | 자식의 FK를 NULL로 |
'SET DEFAULT' | FK를 컬럼 기본값으로 |
'RESTRICT' (기본) | 자식이 있으면 부모 삭제 거부 |
'NO ACTION' | RESTRICT와 거의 같음 (deferred check) |
3) orphanedRowAction — OneToMany 컬렉션 변경 처리
@Entity()
export class User {
@OneToMany(() => Post, (post) => post.user, {
cascade: true,
orphanedRowAction: 'delete', // ★
})
posts: Post[];
}
// 사용
const user = await userRepo.findOne({
where: { id: 1 },
relations: { posts: true }, // posts: [p1, p2, p3]
});
user.posts = [user.posts[0]]; // ★ p2, p3를 *고아*로 만듦
await userRepo.save(user);
// → orphanedRowAction이 'delete'면 p2, p3가 DELETE
// → 'nullify'면 p2, p3의 userId가 NULL
// → 'soft-delete'면 p2, p3의 deletedAt 셋값:
| 값 | 동작 |
|---|---|
'nullify' (기본) | 고아의 FK를 NULL |
'delete' | 고아 row 삭제 |
'soft-delete' | 고아의 deletedAt 셋 (@DeleteDateColumn 필요) |
주의:
orphanedRowAction은@OneToMany에서만 동작한다 —@ManyToMany에는 조인 테이블 row 자동 삭제가 기본 동작.
What — TypeORM cascade vs DB CASCADE 비교 테이블
| 항목 | TypeORM cascade | DB onDelete: 'CASCADE' |
|---|---|---|
| 동작 시점 | save() / remove() 호출 시 | DB가 모든 DELETE에 자동 적용 |
| 동작 레이어 | 애플리케이션 (JS/TS) | DB (DDL FK 제약) |
| raw SQL 우회 시 | 동작 안 함 | 동작 |
| 마이그레이션 시 | 동작 안 함 | 동작 |
| 다른 언어/서비스에서 접근 | 동작 안 함 | 동작 |
| save에서 자식 insert도 됨? | ★ | (DB CASCADE는 DELETE만) |
| 정확한 순서 제어 | ★ (라이프사이클 훅 가능) | DB가 결정 |
| 트랜잭션과의 관계 | 같은 트랜잭션 | 같은 트랜잭션 (FK가 보장) |
| 권장 조합 | 둘 다 켜기 ★ | 둘 다 켜기 ★ |
결정 규칙: 애플리케이션 cascade만 켜고 DB CASCADE를 빼면 — raw SQL 한 줄에 고아 row가 생긴다. DB CASCADE만 켜고 애플리케이션 cascade를 빼면 — save 시 자식 INSERT가 안 된다.
What-if — 자주 만나는 함정
1) cascade: true만 켜고 raw SQL로 DELETE
@OneToMany(() => Post, (p) => p.user, { cascade: true })
posts: Post[]; // ★ DB 제약 없음
// 어디선가 raw SQL
await dataSource.query('DELETE FROM users WHERE id = ?', [1]);
// → posts는 *그대로 남아 고아*. userId가 가리키는 user가 없음.결과: FK 무결성 깨짐 (RESTRICT가 기본이면 에러 / NO ACTION이면 통과). 다음 조회에서 LEFT JOIN에 NULL user 등장.
2) onDelete: 'CASCADE'만 켜고 cascade 없이 save
@ManyToOne(() => User, { onDelete: 'CASCADE' }) // ★ DB만
user: User;
// 코드
const user = new User();
user.posts = [new Post()];
await userRepo.save(user); // ★ post는 *저장 안 됨*결과: user만 저장, posts는 무시. cascade: ['insert']가 추가로 필요.
3) 양방향 cascade — 무한 update 루프 위험
// User
@OneToMany(() => Post, (p) => p.user, { cascade: true })
posts: Post[];
// Post
@ManyToOne(() => User, (u) => u.posts, { cascade: true }) // ★
user: User;결과: postRepo.save(post) → post.user도 save → user의 모든 다른 컬럼이 영속 객체로 덮어쓰기. 정확한 동작이 언제 깨질지 모르는 상태.
규칙: cascade는 한 방향만 — 일반적으로 aggregate root에서 자식 쪽으로.
4) orphanedRowAction: 'delete' + lazy 로딩
@OneToMany(() => Post, (p) => p.user, {
cascade: true,
orphanedRowAction: 'delete',
})
posts: Post[];
// 코드
const user = await userRepo.findOne({ where: { id: 1 } }); // ★ relations 안 지정
user.posts = []; // 빈 배열?
await userRepo.save(user);결과: posts를 로드하지 않은 상태에서 []로 덮어써도 → TypeORM은 기존 자식을 모름 → 삭제 안 됨. 반드시 relations: { posts: true }로 로드한 후 변경.
5) MySQL과 PostgreSQL의 ON DELETE CASCADE 차이
MySQL의 CASCADE는 트리거 무시하고 직접 DELETE 발생. PostgreSQL은 각 row 트리거 fire. 감사 로그(audit log)를 트리거로 구현 중이면 PostgreSQL은 자식 삭제도 기록되지만 MySQL은 누락. DB별 동작 차이 인지 필수.
6) cascade를 remove까지 켰는데 soft delete 사용
@OneToMany(() => Post, (p) => p.user, { cascade: ['remove'] })
posts: Post[];
// 부모 soft delete
await userRepo.softRemove(user);
// → cascade: ['remove']는 *hard remove*용. 자식은 soft delete 안 됨.결과: 부모만 deletedAt 셋, 자식은 그대로. soft delete까지 cascade하려면 cascade: ['soft-remove'] 명시.
Insight — 방어 깊이는 시점의 차이에서 온다
”TypeORM cascade는 코드 협력자만 신뢰”
TypeORM cascade는 TypeORM API를 통해서만 동작한다. 즉 모든 코드가 TypeORM을 통해 DB에 접근한다는 암묵적 가정이 깔려 있다.
- 같은 DB를 다른 서비스가 직접 SQL로 만지면 → cascade 안 풀림
- 마이그레이션이 직접 SQL로 일괄 삭제 → cascade 안 풀림
- DB 콘솔에서 수동 DELETE → cascade 안 풀림
DB CASCADE는 마지막 안전망이다. TypeORM cascade는 편의, DB CASCADE는 최후의 정합성 보장.
”Aggregate Root 패턴과 cascade”
DDD의 aggregate root는 자기 자식의 라이프사이클을 책임진다 — User가 aggregate root면, Post의 생성/삭제는 User를 통해서만 이루어져야 한다.
TypeORM cascade는 이 패턴의 자연스러운 구현체다:
@OneToMany(() => Post, (p) => p.user, {
cascade: ['insert', 'update', 'soft-remove'], // ★ 코드 레벨 캡슐화
orphanedRowAction: 'delete',
})
posts: Post[];이렇게 두면 코드 전체가 userRepo.save(user)만 호출해도 자식까지 책임진다. 다른 곳에서 postRepo를 직접 만지면 경계 위반.
”cascade를 true로 두지 말 것”
cascade: true는 모든 동작을 cascade한다 — 의도하지 않은 update 전파까지 포함. 명시적인 배열 사용이 코드 의도를 드러낸다:
cascade: ['insert'] // 생성만
cascade: ['insert', 'update'] // 생성 + 수정
cascade: ['insert', 'soft-remove'] // 생성 + 소프트 삭제“Prisma의 onDelete만으로 충분한 이유”
Prisma는 애플리케이션 레벨 cascade가 없다 — onDelete: Cascade (DDL)만 존재.
model Post {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}이유: DB 레벨에 cascade가 박혀 있으면 어차피 동작. *애플리케이션 레벨 cascade는 편의고 DB의 책임을 흐린다. 단순성 대 편의의 트레이드오프 — TypeORM은 둘 다 제공, Prisma는 DB만 제공.
”soft delete + cascade의 조용한 함정”
@DeleteDateColumn을 쓰면 softRemove/softDelete로 deletedAt만 셋. 하지만:
find()는 기본적으로 deletedAt이 NULL인 것만 반환 (자동 필터)- 자식 cascade가 없으면 자식은 살아있는 상태로 남음
withDeleted: true로 조회해야 삭제된 부모와 그 자식을 볼 수 있음
soft delete 도입 시 cascade 옵션 전체를 재검토 필요.
요약
TypeORM cascade와 DB CASCADE는 다른 레이어, 다른 시점. 둘 다 켜는 게 방어 깊이 — 한 쪽만 켜면 다른 경로로 무결성 깨짐. cascade는 한 방향만 — aggregate root → 자식.
cascade: true금지 — 명시 배열 사용 —['insert', 'update']등.orphanedRowAction은 relations 로드 후에만 동작 — 빈 배열 덮어쓰기 함정.
다음: 06 — 자기참조 & polymorphic — 관계가 자기 자신을 가리킬 때, 그리고 TypeORM이 지원하지 않는 polymorphic.