TypeORM Cascade와 관계 심화
TypeORM에서 엔티티 관계를 정의하는 것은 쉽지만, Cascade, Orphan Removal, Lazy Loading을 정확히 이해하지 못하면 데이터 유실이나 N+1 문제가 발생한다. 이 글에서는 TypeORM 관계 옵션의 내부 동작을 코드와 함께 심층적으로 다룬다.
1. Cascade 옵션 완전 이해
cascade는 부모 엔티티 저장/삭제 시 자식 엔티티에 연쇄 작업을 적용한다.
@Entity()
export class Order {
@PrimaryGeneratedColumn()
id: number;
@OneToMany(() => OrderItem, (item) => item.order, {
cascade: true, // insert + update
// cascade: ['insert'], // insert만
// cascade: ['update'], // update만
// cascade: ['remove'], // remove만
// cascade: ['soft-remove', 'recover'], // 소프트 삭제/복구
})
items: OrderItem[];
}
@Entity()
export class OrderItem {
@PrimaryGeneratedColumn()
id: number;
@Column()
productName: string;
@Column('decimal')
price: number;
@ManyToOne(() => Order, (order) => order.items)
order: Order;
}
| cascade 값 | 동작 | 주의사항 |
|---|---|---|
true |
insert + update + remove 전부 | 의도치 않은 삭제 위험 |
['insert'] |
부모 저장 시 자식도 INSERT | 가장 안전한 옵션 |
['update'] |
부모 저장 시 자식도 UPDATE | 기존 자식만 대상 |
['remove'] |
부모 삭제 시 자식도 DELETE | DB CASCADE와 중복 주의 |
['soft-remove'] |
부모 소프트 삭제 시 자식도 | @DeleteDateColumn 필요 |
// cascade: ['insert'] 동작 예시
const order = new Order();
order.items = [
{ productName: '키보드', price: 50000 } as OrderItem,
{ productName: '마우스', price: 30000 } as OrderItem,
];
// order INSERT + items 2개 INSERT가 한번에 실행
await orderRepository.save(order);
2. Orphan Removal (orphanedRowAction)
부모의 컬렉션에서 자식을 제거했을 때 DB에서도 삭제하려면 orphanedRowAction을 사용한다.
@Entity()
export class Order {
@OneToMany(() => OrderItem, (item) => item.order, {
cascade: true,
eager: true,
orphanedRowAction: 'delete', // 'nullify' | 'delete' | 'disable'
})
items: OrderItem[];
}
// 사용
const order = await orderRepository.findOne({ where: { id: 1 } });
// 배열에서 제거 → DB에서도 DELETE
order.items = order.items.filter(item => item.id !== 3);
await orderRepository.save(order);
| orphanedRowAction | 동작 |
|---|---|
delete |
컬렉션에서 빠진 자식을 DB에서 삭제 |
nullify |
FK를 NULL로 설정 (관계만 끊기) |
disable |
아무 동작 안 함 (기본값) |
⚠️ 주의: orphanedRowAction: 'delete'는 반드시 cascade: true와 함께 사용해야 한다. cascade 없이 사용하면 동작하지 않는다.
3. Eager vs Lazy Loading
관계 로딩 전략은 성능에 직접적인 영향을 미친다.
// Eager: 항상 JOIN으로 함께 로드
@OneToMany(() => OrderItem, (item) => item.order, {
eager: true,
})
items: OrderItem[];
// Lazy: 접근 시점에 별도 쿼리
@OneToMany(() => OrderItem, (item) => item.order, {
lazy: true,
})
items: Promise<OrderItem[]>;
// Lazy 사용
const order = await orderRepository.findOne({ where: { id: 1 } });
const items = await order.items; // 이 시점에 SELECT 실행
| 전략 | 장점 | 단점 |
|---|---|---|
| Eager | 코드 단순, 한번에 로드 | 불필요한 JOIN, 성능 저하 |
| Lazy | 필요할 때만 쿼리 | N+1 위험, Promise 타입 |
| 명시적 relations | 가장 예측 가능 | 매번 지정 필요 |
실무 권장: eager/lazy 모두 피하고 find({ relations: ['items'] })로 명시적으로 로드한다. 복잡한 쿼리는 QueryBuilder를 사용한다. 자세한 내용은 TypeORM QueryBuilder 심화 글을 참고하자.
4. onDelete·onUpdate 전략
DB 레벨 참조 무결성 제약을 TypeORM에서 제어한다.
@ManyToOne(() => Order, (order) => order.items, {
onDelete: 'CASCADE', // 부모 삭제 시 자식도 삭제
onUpdate: 'CASCADE', // 부모 PK 변경 시 FK도 갱신
nullable: false,
})
@JoinColumn({ name: 'order_id' })
order: Order;
| 옵션 | 동작 |
|---|---|
CASCADE |
부모 변경/삭제 시 자식도 연쇄 |
SET NULL |
FK를 NULL로 설정 |
RESTRICT |
자식 있으면 삭제 거부 |
NO ACTION |
RESTRICT와 유사 (기본값) |
⚠️ TypeORM의 cascade: ['remove']와 DB의 onDelete: 'CASCADE'를 동시에 사용하면 이중 삭제 시도가 발생할 수 있다. 둘 중 하나만 사용하는 것이 안전하다.
5. 실전 패턴: 주문-아이템 CRUD
@Injectable()
export class OrderService {
constructor(
@InjectRepository(Order)
private readonly orderRepo: Repository<Order>,
) {}
// 생성: cascade insert
async create(dto: CreateOrderDto): Promise<Order> {
const order = this.orderRepo.create({
...dto,
items: dto.items.map(item => ({ ...item })),
});
return this.orderRepo.save(order);
}
// 수정: orphan removal로 아이템 동기화
async update(id: number, dto: UpdateOrderDto): Promise<Order> {
const order = await this.orderRepo.findOne({
where: { id },
relations: ['items'],
});
// 새 아이템 목록으로 교체 → orphanedRowAction: 'delete'가
// 빠진 아이템을 자동 삭제
order.items = dto.items.map(item => {
if (item.id) {
const existing = order.items.find(i => i.id === item.id);
return Object.assign(existing, item);
}
return this.orderRepo.manager.create(OrderItem, item);
});
return this.orderRepo.save(order);
}
// 소프트 삭제: cascade soft-remove
async softDelete(id: number): Promise<void> {
const order = await this.orderRepo.findOne({
where: { id },
relations: ['items'],
});
await this.orderRepo.softRemove(order);
// items도 함께 soft delete (cascade: ['soft-remove'] 필요)
}
}
TypeORM의 이벤트 훅과 조합하면 더 정교한 제어가 가능하다. TypeORM Subscriber 이벤트 훅 글도 함께 참고하자.
마무리
TypeORM의 관계 옵션은 편리하지만, cascade와 onDelete의 차이, orphanedRowAction의 전제 조건, eager/lazy의 성능 트레이드오프를 정확히 이해해야 프로덕션에서 안전하게 사용할 수 있다. 핵심 원칙: DB 레벨 제약(onDelete)과 ORM 레벨 cascade를 중복 사용하지 말고, 관계 로딩은 항상 명시적으로 지정하자.