[Spring] 영속성 전이(cascade), 고아 객체

WOOK JONG KIM·2022년 11월 6일
0
post-thumbnail
post-custom-banner

영속성 전이(cascade)란 특정 엔티티의 영속성 상태를 변경할 때 그 엔티티와 연관된 엔티티의 영속성에도 영향을 미쳐 영속성 상태를 변경하는 것

연관관계와 관련된 어노테이션을 보면 위와 같이 cascade()라는 요소 볼 수 있다

영속성 전이 타입

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

영속성 전이에 사용되는 타입은 엔티티 생명주기와 연관
-> 한 엔티티가 cascade 요소의 값으로 영속 상태 변경이 일어나면 매핑으로 연관된 엔티티에도 동일한 동작이 일어나도록 전이를 발생시키는 것

cascade 요소의 리턴 타입 : 배열 형식
-> 개발자가 사용하고자 하는 cascade 타입을 골라 각 상황에 적용 가능하다는 것


영속성 전이 적용

상품 엔티티와 공급업체 엔티티 사용
-> 한 가게가 새로운 공급업체와 계약하며 몇 가지 새로운 상품을 입고시키는 상황에 어떻게 영속성 전이가 되는지 보자

엔티티를 DB에 추가하는 경우 전이 타입을 PERSIST로 지정한 경우

@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)
    @ToString.Exclude
    private List<Product> productList = new ArrayList<>();
}
@SpringBootTest
public class ProviderRepositoryTest {
    
    @Autowired
    ProviderRepository providerRepository;
    @Test
    void cascadeTest() {
        Provider provider = savedProvider("새로운 공급 업체");

        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 Provider savedProvider(String name){
        Provider provider = new Provider();
        provider.setName(name);
        return 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;
    }
}

공급업체 하나와 상품 객체 3개 생성

위 코드에서 영속화 작업을 수행하지 않고 연관관계 설정
-> 이후 save에서 영속성 전이가 수행

Hibernate: 
    insert 
    into
        provider
        (created_at, updated_at, name) 
    values
        (?, ?, ?)
Hibernate: 
    insert 
    into
        product
        (created_at, updated_at, name, price, provider_id, stock) 
    values
        (?, ?, ?, ?, ?, ?)
Hibernate: 
    insert 
    into
        product
        (created_at, updated_at, name, price, provider_id, stock) 
    values
        (?, ?, ?, ?, ?, ?)
Hibernate: 
    insert 
    into
        product
        (created_at, updated_at, name, price, provider_id, stock) 
    values
        (?, ?, ?, ?, ?, ?)

그 전까지는 엔티티를 DB에 저장하기 위해 각 엔티티를 저장하는 코드를 작성하였음
-> 영속성 전이 사용시 부모 엔티티가 되는 Provider 엔티티만 저장하면 코드에 작성돼있는 Cascade.PERSIST에 맞춰 상품 엔티티도 같이 저장

특정 상황에 맞춰 영속성 전이 타입 설정 시 영속 상태 변화에 따라 연관된 엔티티들의 동작도 함께 수행할 수 있어 개발 생산성 높아짐

하지만 자동 설정 동작 코드들이 정확히 어떤 영향을 미치는지 파악을 잘하자
-> REMOVE와 REMOVE를 포함하는 ALL 같은 타입을 무분별하게 사용 시 연관 엔티티 까지 의도치 않게 삭제될 수 있음


고아객체(orphan)

JPA에서 orphan이란 부모 엔티티와 연관관계가 끊어진 엔티티 의미

JPA에서는 보통 이러한 고아 객체를 자동으로 제거하는 기능 존재
-> 자식 엔티티가 다른 엔티티와 연관관계를 가지고 있다면 사용 안하는 것이 좋음

상품 엔티티는 다른 엔티티와 연관관계가 많이 설정돼 있지만 그 부분을 예외로 두고 테스트 하였음

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<>();
}
@Test
    @Transactional
    void orphanRemovalTest(){
        Provider provider = savedProvider("새로운 공급 업체");

        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.saveAndFlush(provider);

        // 각 엔티티 출력시 공급업체 엔티티1개, 상품 엔티티 3개가 출력
        providerRepository.findAll().forEach(System.out::println);
        productRepository.findAll().forEach(System.out::println);
		
        // 고의로 고아 객체 생성하였음
        Provider foundProvider = providerRepository.findById(1L).get();
        foundProvider.getProductList().remove(0);

        providerRepository.findAll().forEach(System.out::println);
        productRepository.findAll().forEach(System.out::println);
    }
}
Hibernate: 
    select
        provider0_.id as id1_6_,
        provider0_.created_at as created_2_6_,
        provider0_.updated_at as updated_3_6_,
        provider0_.name as name4_6_ 
    from
        provider provider0_
Provider(super=BaseEntity(createdAt=2022-11-06T17:27:31.502914, updatedAt=2022-11-06T17:27:31.502914), id=1, name=새로운 공급 업체)
Hibernate: 
    select
        product0_.number as number1_3_,
        product0_.created_at as created_2_3_,
        product0_.updated_at as updated_3_3_,
        product0_.name as name4_3_,
        product0_.price as price5_3_,
        product0_.provider_id as provider7_3_,
        product0_.stock as stock6_3_ 
    from
        product product0_
Product(super=BaseEntity(createdAt=2022-11-06T17:27:31.524976, updatedAt=2022-11-06T17:27:31.524976), number=1, name=상품1, price=1000, stock=1000)
Product(super=BaseEntity(createdAt=2022-11-06T17:27:31.529169, updatedAt=2022-11-06T17:27:31.529169), number=2, name=상품2, price=500, stock=1500)
Product(super=BaseEntity(createdAt=2022-11-06T17:27:31.529806, updatedAt=2022-11-06T17:27:31.529806), number=3, name=상품3, price=750, stock=500)

고아 객체 생성(연관관계 제거)한 후 코드 수행 시 연관관계 끊긴 상품의 엔티티가 제거되는 것을 볼 수 있음

즉 공급 업체 엔티티를 가져온후 상품1 엔티티의 연관관계를 제거하니 Product 엔티티에서도 제거됨

Hibernate: 
    select
        provider0_.id as id1_6_,
        provider0_.created_at as created_2_6_,
        provider0_.updated_at as updated_3_6_,
        provider0_.name as name4_6_ 
    from
        provider provider0_
Provider(super=BaseEntity(createdAt=2022-11-06T17:27:31.502914, updatedAt=2022-11-06T17:27:31.502914), id=1, name=새로운 공급 업체)
Hibernate: 
    delete 
    from
        product 
    where
        number=?
Hibernate: 
    select
        product0_.number as number1_3_,
        product0_.created_at as created_2_3_,
        product0_.updated_at as updated_3_3_,
        product0_.name as name4_3_,
        product0_.price as price5_3_,
        product0_.provider_id as provider7_3_,
        product0_.stock as stock6_3_ 
    from
        product product0_
Product(super=BaseEntity(createdAt=2022-11-06T17:27:31.529169, updatedAt=2022-11-06T17:27:31.529169), number=2, name=상품2, price=500, stock=1500)
Product(super=BaseEntity(createdAt=2022-11-06T17:27:31.529806, updatedAt=2022-11-06T17:27:31.529806), number=3, name=상품3, price=750, stock=500)
2022-11-06 17:27:31.563  INFO 72723 --- [           main] o.s.t.c.transaction.TransactionContext   : Rolled back transaction for test: [DefaultTestContext@207b8649 testClass = ProviderRepositoryTest, testInstance = com.springboot.relationship.data.repository.ProviderRepositoryTest@ad6448e, testMethod = orphanRemovalTest@ProviderRepositoryTest, testException = [null], mergedContextConfiguration = [WebMergedContextConfiguration@65b3a85a testClass = ProviderRepositoryTest, locations = '{}', classes = '{class com.springboot.relationship.RelationshipApplication}', contextInitializerClasses = '[]', activeProfiles = '{}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.context.SpringBootTestContextBootstrapper=true}', contextCustomizers = set[org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@58594a11, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@68f1b17f, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.client.TestRestTemplateContextCustomizer@5c669da8, org.springframework.boot.test.autoconfigure.actuate.metrics.MetricsExportContextCustomizerFactory$DisableMetricExportContextCustomizer@1fd14d74, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@0, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@7357a011, org.springframework.boot.test.context.SpringBootTestArgs@1, org.springframework.boot.test.context.SpringBootTestWebEnvironment@345965f2], resourceBasePath = 'src/main/webapp', contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]], attributes = map['org.springframework.test.context.web.ServletTestExecutionListener.activateListener' -> true, 'org.springframework.test.context.web.ServletTestExecutionListener.populatedRequestContextHolder' -> true, 'org.springframework.test.context.web.ServletTestExecutionListener.resetRequestContextHolder' -> true, 'org.springframework.test.context.event.ApplicationEventsTestExecutionListener.recordApplicationEvents' -> false]]
profile
Journey for Backend Developer
post-custom-banner

0개의 댓글