TypeORM Cascade 관계 심화

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를 중복 사용하지 말고, 관계 로딩은 항상 명시적으로 지정하자.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux