고딩왕 코범석
OneToMany 양방향 매핑에서 자식 객체 관리 본문
안녕하세요! 이번 포스팅은 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() 해주고 넣어주면 됩니다. Product
의 putImages
메서드를 수정해보겠습니다.
public void addImages(List<ProductImage> images) {
this.images.addAll(images);
}
public void putImages(List<ProductImage> images) {
this.images.clear();
addImages(images);
}
그리고 다시 테스트를 돌려보겠습니다.
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 객체를 찾지도 못합니다. 테스트 코드 상에서는 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
에 밑줄이 있습니다. 커서를 대보면
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);
}
}
그리고 테스트를 돌려보고 콘솔도 살펴보겠습니다.
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
단방향 매핑으로 리팩토링을 진행할 것 같습니다.
'Language & Framework > Spring' 카테고리의 다른 글
Jasypt 적용하기 + 이슈 (0) | 2022.01.22 |
---|---|
스프링 AOP (0) | 2021.12.18 |
빈 후처리기와 자동 빈 후처리기 (0) | 2021.12.14 |
Spring Security + JWT + Redis로 로그인 구현하기 (2) 로그인 (5) | 2021.11.23 |
Spring Security + JWT + Redis로 로그인 구현하기 (1) 설정과 회원가입 (2) | 2021.11.23 |