고딩왕 코범석

OneToMany 양방향 매핑에서 자식 객체 관리 본문

Language & Framework/Spring

OneToMany 양방향 매핑에서 자식 객체 관리

고딩왕 코범석 2022. 1. 23. 00:11
반응형

안녕하세요! 이번 포스팅은 JPA에서 OneToMany 양방향 관계에서 자식 객체를 부모 객체에서 다뤘을 때 겪었던 이슈를 포스팅해보고자 합니다.

우선, 준비할 엔티티 객체는 다음과 같습니다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "PRODUCT_ID")
    private Long id;

    @Builder.Default
    @OneToMany(
            mappedBy = "product",
            fetch = FetchType.LAZY,
            cascade = CascadeType.PERSIST,
            orphanRemoval = true
    )
    private List<ProductImage> images = new ArrayList<>();
}
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
public class ProductImage {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "PRODUCT_IMAGE_ID")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "PRODUCT_ID")
    private Product product;

    private String url;
}

구조는 하나의 Product가 여러 개의 ProductImage 객체를 가질 수 있으며 서로 양방향으로 매핑되어있습니다.

또한, 이 두 객체를 Product에서 관리하게끔 cascade, orphanRemoval 옵션을 주었습니다.

이제 테스트를 해보겠습니다. 테스트는 하나의 Product가 2개의 ProductImage를 가진 상황에서 새로운 ProductImage 두 개로 수정하는 코드입니다. 우선 Product 엔티티에 해당 코드를 추가했습니다.


@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
public class Product {

    ...

    public void addImages(List<ProductImage> images) {
        this.images.addAll(images);
    }

    public void putImages(List<ProductImage> images) {
        this.images = images;
    }
}

그리고 테스트 코드입니다.

@DataJpaTest
public class EntityTest {

    @Autowired
    EntityManager entityManager;
    @Autowired
    ProductRepository productRepository;

    @Test
    @DisplayName("여러 장의 사진을 덮어씌울 때")
    void putImages() throws Exception {

        // given
        Product product = Product.builder().build();
        ProductImage image1 = ProductImage.builder().product(product).url("test1").build();
        ProductImage image2 = ProductImage.builder().product(product).url("test2").build();
        product.addImages(List.of(image1, image2));
        productRepository.save(product);
        entityManager.flush();
        entityManager.clear();

        // when
        Product findProduct = productRepository.findById(product.getId()).orElseThrow();
        ProductImage image3 = ProductImage.builder().url("test3").build();
        ProductImage image4 = ProductImage.builder().url("test4").build();
        findProduct.putImages(List.of(image3, image4));
        entityManager.flush();

        // then
        assertEquals(2, findProduct.getImages().size());
    }
}

이렇게 테스트 코드를 실행했을 때 다음과 같은 에러가 발생합니다.

javax.persistence.PersistenceException: org.hibernate.HibernateException: A collection with cascade="all-delete-orphan" was no longer referenced by the owning entity instance: com.mapping.relation.Product.images

    at org.hibernate.internal.ExceptionConverterImpl.convert(ExceptionConverterImpl.java:154)
    at org.hibernate.internal.ExceptionConverterImpl.convert(ExceptionConverterImpl.java:181)
    at org.hibernate.internal.ExceptionConverterImpl.convert(ExceptionConverterImpl.java:188)
    at org.hibernate.internal.SessionImpl.doFlush(SessionImpl.java:1406)
    at org.hibernate.internal.SessionImpl.flush(SessionImpl.java:1389)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.base/java.lang.reflect.Method.invoke(Method.java:566)
    at org.springframework.orm.jpa.SharedEntityManagerCreator$SharedEntityManagerInvocationHandler.invoke(SharedEntityManagerCreator.java:311)
    at com.sun.proxy.$Proxy106.flush(Unknown Source)
    at com.mapping.relation.EntityTest.putImages(EntityTest.java:39)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.base/java.lang.reflect.Method.invoke(Method.java:566)
    at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:725)
    at org.junit.jupiter.engine.execution.MethodInvocation.proceed(MethodInvocation.java:60)
    at org.junit.jupiter.engine.execution.InvocationInterceptorChain$ValidatingInvocation.proceed(InvocationInterceptorChain.java:131)
    at org.junit.jupiter.engine.extension.TimeoutExtension.intercept(TimeoutExtension.java:149)
    at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestableMethod(TimeoutExtension.java:140)
    at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestMethod(TimeoutExtension.java:84)
    at org.junit.jupiter.engine.execution.ExecutableInvoker$ReflectiveInterceptorCall.lambda$ofVoidMethod$0(ExecutableInvoker.java:115)
    at org.junit.jupiter.engine.execution.ExecutableInvoker.lambda$invoke$0(ExecutableInvoker.java:105)
    at org.junit.jupiter.engine.execution.InvocationInterceptorChain$InterceptedInvocation.proceed(InvocationInterceptorChain.java:106)
    at org.junit.jupiter.engine.execution.InvocationInterceptorChain.proceed(InvocationInterceptorChain.java:64)
    at org.junit.jupiter.engine.execution.InvocationInterceptorChain.chainAndInvoke(InvocationInterceptorChain.java:45)
    at org.junit.jupiter.engine.execution.InvocationInterceptorChain.invoke(InvocationInterceptorChain.java:37)
    at org.junit.jupiter.engine.execution.ExecutableInvoker.invoke(ExecutableInvoker.java:104)
    at org.junit.jupiter.engine.execution.ExecutableInvoker.invoke(ExecutableInvoker.java:98)
    at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeTestMethod$7(TestMethodTestDescriptor.java:214)
    at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
    at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeTestMethod(TestMethodTestDescriptor.java:210)
    at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:135)
    at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:66)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:151)
    at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141)
    at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139)
    at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95)
    at java.base/java.util.ArrayList.forEach(ArrayList.java:1541)
    at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:41)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:155)
    at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141)
    at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139)
    at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95)
    at java.base/java.util.ArrayList.forEach(ArrayList.java:1541)
    at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:41)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:155)
    at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141)
    at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139)
    at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95)
    at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.submit(SameThreadHierarchicalTestExecutorService.java:35)
    at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:57)
    at org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine.execute(HierarchicalTestEngine.java:54)
    at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:107)
    at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:88)
    at org.junit.platform.launcher.core.EngineExecutionOrchestrator.lambda$execute$0(EngineExecutionOrchestrator.java:54)
    at org.junit.platform.launcher.core.EngineExecutionOrchestrator.withInterceptedStreams(EngineExecutionOrchestrator.java:67)
    at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:52)
    at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:114)
    at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:86)
    at org.junit.platform.launcher.core.DefaultLauncherSession$DelegatingLauncher.execute(DefaultLauncherSession.java:86)
    at org.junit.platform.launcher.core.SessionPerRequestLauncher.execute(SessionPerRequestLauncher.java:53)
    at com.intellij.junit5.JUnit5IdeaTestRunner.startRunnerWithArgs(JUnit5IdeaTestRunner.java:71)
    at com.intellij.rt.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:33)
    at com.intellij.rt.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:221)
    at com.intellij.rt.junit.JUnitStarter.main(JUnitStarter.java:54)
Caused by: org.hibernate.HibernateException: A collection with cascade="all-delete-orphan" was no longer referenced by the owning entity instance: com.mapping.relation.Product.images
    at org.hibernate.engine.internal.Collections.processDereferencedCollection(Collections.java:100)
    at org.hibernate.engine.internal.Collections.processUnreachableCollection(Collections.java:51)
    at org.hibernate.event.internal.AbstractFlushingEventListener.lambda$flushCollections$1(AbstractFlushingEventListener.java:251)
    at org.hibernate.engine.internal.StatefulPersistenceContext.forEachCollectionEntry(StatefulPersistenceContext.java:1136)
    at org.hibernate.event.internal.AbstractFlushingEventListener.flushCollections(AbstractFlushingEventListener.java:248)
    at org.hibernate.event.internal.AbstractFlushingEventListener.flushEverythingToExecutions(AbstractFlushingEventListener.java:94)
    at org.hibernate.event.internal.DefaultFlushEventListener.onFlush(DefaultFlushEventListener.java:39)
    at org.hibernate.event.service.internal.EventListenerGroupImpl.fireEventOnEachListener(EventListenerGroupImpl.java:107)
    at org.hibernate.internal.SessionImpl.doFlush(SessionImpl.java:1402)
    ... 75 more

javax.persistence.PersistenceException: org.hibernate.HibernateException: A collection with cascade="all-delete-orphan" was no longer referenced by the owning entity instance: com.mapping.relation.Product.images

해당 에러는 기존의 image1, image2 객체가 고아 상태가 되어 두 객체를 지우고 image3, image4를 컬렉션에 집어 넣었을 때, 해당 고아 객체들을 명시적으로 삭제하지 않아서 발생한 에러입니다.

하이버네이트에서는 이 고아 객체를 더 이상 참조하지 않을 때 delete를 발생시키는 트리거가 지원되지 않습니다.

그렇다면 이렇게 개별적 수정이 아닌 덮어씌우는 로직은 어떻게 해야할까요?

컬렉션을 clear() 해주고 넣어주면 됩니다. ProductputImages 메서드를 수정해보겠습니다.

    public void addImages(List<ProductImage> images) {
        this.images.addAll(images);
    }

    public void putImages(List<ProductImage> images) {
        this.images.clear();
        addImages(images);
    }

그리고 다시 테스트를 돌려보겠습니다.

image

Hibernate: 
    delete 
    from
        product_image 
    where
        product_image_id=?
Hibernate: 
    delete 
    from
        product_image 
    where
        product_image_id=?

이렇게 delete 쿼리도 발생하고, 테스트도 잘 통과되는 것을 알 수 있습니다.

부모 객체에서 자식 객체를 지워보기

이번에는 하나의 image를 지워보겠습니다.

    public void removeImage(ProductImage target) {
        this.images.removeIf(target::equals);
    }
    @Test
    @DisplayName("하나의 사진을 지울 때")
    void removeImage() throws Exception {

        // given
        Product product = Product.builder().build();
        ProductImage image1 = ProductImage.builder().product(product).url("test1").build();
        ProductImage image2 = ProductImage.builder().product(product).url("test2").build();
        product.addImages(List.of(image1, image2));
        productRepository.save(product);
        entityManager.flush();
        entityManager.clear();

        // when
        Product findProduct = productRepository.findById(product.getId()).orElseThrow();
        findProduct.removeImage(image1);
        entityManager.flush();

        // then
        assertEquals(1, findProduct.getImages().size());
    }

이 테스트 코드를 돌리면 결과는 실패합니다. 콘솔을 봐도

Hibernate: 
    insert 
    into
        product
        (product_id) 
    values
        (null)
Hibernate: 
    insert 
    into
        product_image
        (product_image_id, product_id, url) 
    values
        (null, ?, ?)
Hibernate: 
    insert 
    into
        product_image
        (product_image_id, product_id, url) 
    values
        (null, ?, ?)
Hibernate: 
    select
        product0_.product_id as product_1_1_0_ 
    from
        product product0_ 
    where
        product0_.product_id=?
Hibernate: 
    select
        images0_.product_id as product_3_2_0_,
        images0_.product_image_id as product_1_2_0_,
        images0_.product_image_id as product_1_2_1_,
        images0_.product_id as product_3_2_1_,
        images0_.url as url2_2_1_ 
    from
        product_image images0_ 
    where
        images0_.product_id=?
Hibernate: 
    select
        likes0_.product_id as product_2_0_0_,
        likes0_.like_id as like_id1_0_0_,
        likes0_.like_id as like_id1_0_1_,
        likes0_.product_id as product_2_0_1_ 
    from
        likes likes0_ 
    where
        likes0_.product_id=?

delete 쿼리가 발생하지 않았습니다. Product 엔티티의 removeImage 메서드에 디버깅을 찍어보겠습니다.

image

지우려는 image 객체를 찾지도 못합니다. 테스트 코드 상에서는 id가 같지만 참조하는 객체 주소가 달라서 equals로 비교해도 false로 결과를 반환합니다.

원인을 파악했으니 코드를 수정해보겠습니다. 간단하게 lombok의 @EqualsAndHashCode로 해결해보겠습니다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
@EqualsAndHashCode(of = "id")
public class ProductImage {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "PRODUCT_IMAGE_ID")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "PRODUCT_ID")
    private Product product;

    private String url;
}

다시 테스트 코드를 돌리면 초록불이 뜹니다. 하지만 @EqualsAndHashCode에 밑줄이 있습니다. 커서를 대보면

image

JPA에서는 이 어노테이션 사용을 권장하지 않는다고 나옵니다. 그래서 저는 코드를 다음과 같이 수정했습니다.

    public void removeImage(ProductImage target) {
        this.images.removeIf(target::isEqual);
    }
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
public class ProductImage {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "PRODUCT_IMAGE_ID")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "PRODUCT_ID")
    private Product product;

    private String url;

    public boolean isEqual(ProductImage target) {
        return target.getId().equals(id);
    }
}

그리고 테스트를 돌려보고 콘솔도 살펴보겠습니다.

image

Hibernate: 
    insert 
    into
        product
        (product_id) 
    values
        (null)
Hibernate: 
    insert 
    into
        product_image
        (product_image_id, product_id, url) 
    values
        (null, ?, ?)
Hibernate: 
    insert 
    into
        product_image
        (product_image_id, product_id, url) 
    values
        (null, ?, ?)
Hibernate: 
    select
        product0_.product_id as product_1_1_0_ 
    from
        product product0_ 
    where
        product0_.product_id=?
Hibernate: 
    select
        images0_.product_id as product_3_2_0_,
        images0_.product_image_id as product_1_2_0_,
        images0_.product_image_id as product_1_2_1_,
        images0_.product_id as product_3_2_1_,
        images0_.url as url2_2_1_ 
    from
        product_image images0_ 
    where
        images0_.product_id=?
Hibernate: 
    delete 
    from
        product_image 
    where
        product_image_id=?

delete 쿼리가 발생했음을 알 수 있습니다.

제가 이 주제로 포스팅한 이유는

  • 자식 객체가 부모 객체에 종속적인 경우 부모 객체에서 관리하고 싶었다.
  • @OneToMany 단방향은 의도하지 않은 쿼리가 발생할 수 있어 @ManyToOne 단방향이 제일 좋은 방법이고, @OneToMany 양방향으로 매핑하는 것이 차선책
  • 근데 양방향으로 매핑하다보니 양쪽 객체 모두 신경을 써줘야한다.

위 사항들을 정리하고 싶어서였습니다. 만약 자식 객체가 할 일이 점점 많아진다면 아마 @ManyToOne 단방향 매핑으로 리팩토링을 진행할 것 같습니다.

반응형