본문 바로가기

Development/Spring

[Spring] 영속성 전이(Cascade)와 고아 객체(Orphan)

영속성 전이(Cascade)

  • 영속성 전이란 특정 엔티티의 영속성 상태를 변경할 때 그 엔티티와 연관된 엔티티의 영속성에도 영향을 미쳐 영속성 상태를 변경하는 것
  • 예를 들어 아래 코드에서 @OneToMany 어노테이션의 인터페이스를 살펴보면 cascade()라는 요소를 볼 수 있다.
public @interface OneToMany {
    Class targetEntity() default void.class;

    CascadeType[] cascade() default {};

    FetchType fetch() default FetchType.LAZY;

    String mappedBy() default "";

    boolean orphanRemoval() default false;
}
  • 영속성 전이에 사용되는 타입은 엔티티 생명주기와 연관이 있음
  • 한 엔티티가 cascade 요소의 값으로 주어진 영속 상태의 변경이 일어나면 매핑으로 연관된 엔티티에도 동일한 동작이 일어나도록 전이를 발생시키는 것
  • cascade() 요소의 리턴 타입은 배열 형식인데, 이는 개발자가 사용하고자 하는 cascade 타입을 골라 각 상황에 적용할 수 있다는 것이다.

영속성 전이 타입의 종류

ALL: 모든 영속 상태 변경에 대해 영속성 전이를 적용
PERSIST: 엔티티가 영속화할 때 연관된 엔티티도 함께 영속화
MERGE: 엔티티를 영속성 컨텍스트에 병합할 때 연관된 엔티티도 병합
REMOVE: 엔티티를 제거할 때 연관된 엔티티도 제거
REFRESH: 엔티티를 새로고침할 때 연관된 엔티티도 새로고침
DETACH: 엔티티를 영속성 컨텍스트에서 제외하면 연관된 엔티티도 제외

 

영속성 전이 적용

예시 코드로 사용할 Product 엔티티와 `Provider` 엔티티는 다대일 양방향 연관관계로, 여러 Product는 하나의 Provider에 속할 수 있고, 하하나의 Provider는 여러 Product를 가질 수 있습니다. 

@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "product")
public class Product extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long number;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private Integer price;

    @Column(nullable = false)
    private Integer stock;

    @ManyToOne
    @JoinColumn(name = "provider_id")
    @ToString.Exclude
    private Provider provider;

}
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "provider")
public class Provider extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @OneToMany(mappedBy = "provider", cascade = CascadeType.PERSIST, orphanRemoval = true)
    @ToString.Exclude
    private List<Product> productList = new ArrayList<>();

}
  • Provider 클래스에서 Product 리스트에 대한 필드는 cascade 타입중 PERSIST를 적용했다.
  • PERSIST는 엔티티가 영속화할 때 연관된 엔티티도 함께 영속화 하는 것으로, Provider 엔티티가 DB에 저장되면 연관된 Product 엔티티까지 함께 저장된다.

아래는 테스트 코드이다. 

@Test
    void cascadeTest() {
	    Provider provider = new Provider();
        provider.setName("New Provider");

        Product product1 = savedProduct("상품1", 1000, 1000);
        Product product2 = savedProduct("상품2", 500, 1500);
        Product product3 = savedProduct("상품3", 750, 500);

        // 연관관계 설정
        product1.setProvider(provider);
        product2.setProvider(provider);
        product3.setProvider(provider);

        provider.getProductList().addAll(Lists.newArrayList(product1, product2, product3));

        providerRepository.save(provider);
    }
private Product savedProduct(String name, Integer price, Integer stock) {
        Product product = new Product();
        product.setName(name);
        product.setPrice(price);
        product.setStock(stock);

        return product;
    }

위 코드에서는 product를 직접적으로 저장하지 않고, provider에 필드인 productList에 product들을 추가하여 provider를 저장했다. 그러면 아래와 같은 Hibernate 쿼리가 발생한다.

Hibernate: 
    insert 
    into
        provider
        (created_at, name, updated_at) 
    values
        (?, ?, ?)
Hibernate: 
    insert 
    into
        product
        (created_at, name, price, provider_id, stock, updated_at) 
    values
        (?, ?, ?, ?, ?, ?)
Hibernate: 
    insert 
    into
        product
        (created_at, name, price, provider_id, stock, updated_at) 
    values
        (?, ?, ?, ?, ?, ?)
Hibernate: 
    insert 
    into
        product
        (created_at, name, price, provider_id, stock, updated_at) 
    values
        (?, ?, ?, ?, ?, ?)
  • insert 문으로 Product 객체들이 함께 쿼리가 발생한 것을 볼 수 있다.
  • 이처럼 CascadeType.PERSIST 을 설정하면 영속 상태의 변화에 따라 연관된 엔티티들의 동작도 함께 수행하게 된다. 
  • 다만 REMOVE, ALL 같은 타입을 무분별하게 사용하면 연관된 엔티티에 의도치 않은 영향을 미칠 수 있기 때문에 사이드 이펙트를 고려해서 사용해야 한다.

고아 객체(Orphan)

  • JPA에서 고아(orphan)란 부모 엔티티와 연관관계가 끊어진 엔티티를 의미한다.
  • JPA에는 이러한 고아 객체를 자동으로 제거하는 기능이 있다.

아래 Provider 엔티티에서 productList 필드에 대한 orphanRemoval = true 옵션은 고아 객체를 제거하는 기능이다.

@OneToMany(mappedBy = "provider", cascade = CascadeType.PERSIST, orphanRemoval = true)
    @ToString.Exclude
    private List<Product> productList = new ArrayList<>();

아래 테스트 코드로 동작을 확인해보자

@Test
    @Transactional
    void orphanRemovalTest() {
        Provider provider = savedProvider("New Provider");

        Product product1 = savedProduct("Product1", 1000, 1000);
        Product product2 = savedProduct("Product2", 500, 1500);
        Product product3 = savedProduct("Product3", 750, 500);

        // 연관관계 설정
        product1.setProvider(provider);
        product2.setProvider(provider);
        product3.setProvider(provider);

        provider.getProductList().addAll(Lists.newArrayList(product1, product2, product3));

        providerRepository.saveAndFlush(provider);

        System.out.println("## Before Removal ##");
        System.out.println("## provider list ##");
        providerRepository.findAll().forEach(System.out::println);

        System.out.println("## product list ##");
        productRepository.findAll().forEach(System.out::println);

        // 연관관계 제거
        Provider foundProvider = providerRepository.findById(1L).get();
        foundProvider.getProductList().remove(0);

        System.out.println("## After Removal ##");
        System.out.println("## provider list ##");
        providerRepository.findAll().forEach(System.out::println);

        System.out.println("## product list ##");
        productRepository.findAll().forEach(System.out::println);
    }
  • Provider 엔티티를 가져온 후 첫번째로 매핑되어 있는 Product 엔티티의 연관관계를 제거하였다.
  • 결국 첫번째로 매핑되어 있는 Product1 엔티티는 연관관계가 사라져 고아 객체가 되어버린다.
  • 이때 orphanRemoval = true를 적용하였으므로 이 객체는 삭제되어야 한다.

실제로 저 테스트 코드를 실행하면 아래와 같은 로그가 발생한다.

Product(super=BaseEntity(createdAt=2024-03-05T23:46:33.838355, updatedAt=2024-03-05T23:46:33.838355), number=1, name=상품1, price=1000, stock=1000, productDetail=null)
Product(super=BaseEntity(createdAt=2024-03-05T23:46:33.842978, updatedAt=2024-03-05T23:46:33.842978), number=2, name=상품2, price=500, stock=1500, productDetail=null)
Product(super=BaseEntity(createdAt=2024-03-05T23:46:33.844293, updatedAt=2024-03-05T23:46:33.844293), number=3, name=상품3, price=750, stock=500, productDetail=null)
## After Removal ##
## provider list ##
Hibernate: 
    select
        p1_0.id,
        p1_0.created_at,
        p1_0.name,
        p1_0.updated_at 
    from
        provider p1_0
Provider(super=BaseEntity(createdAt=2024-03-05T23:46:33.812593, updatedAt=2024-03-05T23:46:33.812593), id=1, name=새로운 공급업체)
## product list ##
Hibernate: 
    delete 
    from
        product 
    where
        number=?
Hibernate: 
    select
        p1_0.number,
        p1_0.created_at,
        p1_0.name,
        p1_0.price,
        p1_0.provider_id,
        p1_0.stock,
        p1_0.updated_at 
    from
        product p1_0
Product(super=BaseEntity(createdAt=2024-03-05T23:46:33.842978, updatedAt=2024-03-05T23:46:33.842978), number=2, name=상품2, price=500, stock=1500, productDetail=null)
Product(super=BaseEntity(createdAt=2024-03-05T23:46:33.844293, updatedAt=2024-03-05T23:46:33.844293), number=3, name=상품3, price=750, stock=500, productDetail=null)

Hibernate에 의해 delete 쿼리가 실행되어 Product1이 삭제된 것을 확인할 수 있다.

참고