05 — Cascade & Orphan

질문: 부모 엔티티를 save/delete할 때 자식들은 어떻게 따라가는가? TypeORM의 cascade와 DB의 ON DELETE CASCADE는 같은 것인가? 한 줄 답: TypeORM cascadesave() 시점에 동작하는 애플리케이션 레벨 전파이고, onDelete: 'CASCADE'DB FK에 박히는 DDL이다 — 둘은 다른 시점, 다른 레이어에서 동작한다. 두 개를 모두 의식적으로 결정해야 안전한 삭제가 보장된다.


한 장 그림


Why — 왜 애플리케이션 cascadeDB cascade가 다른가

TypeORM의 cascade런타임 코드 동작이다:

  • userRepo.save(user) 호출 시 → user.posts도 같이 save
  • userRepo.remove(user) 호출 시 → user.posts도 같이 remove

DB의 ON DELETE CASCADEDDL이다:

  • 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 둘 다 INSERT

cascade 옵션 값:

의미
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 cascadeDB 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 JOINNULL 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.usersave → 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) cascaderemove까지 켰는데 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/softDeletedeletedAt만 셋. 하지만:

  • 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.