본문 바로가기
Spring

[JPA Hibernate] 1:N 관계의 Entity Collection 참조 변경

by 지금 느낌 그대로 2023. 9. 6.

 

문제 발생

 

JPA를 사용해 프로젝트 진행하는 과정에서 단방향 1:N의 관계를 가진 엔티티에서 참조하고 있는 필드의 값을 변경하려고 시도하다가 발생한 문제입니다.

 

상품의 이미지를 등록하는 기능을 구현하던 중이었습니다.

상품 엔티티와 이미지 엔티티는 1:N의 관계를 가지고 있고, 상품에서 이미지를 참조할 수는 있지만 이미지에서 상품을 참조할 수는 없는 구조입니다.

 

두 엔티티의 코드는 아래와 같습니다.

 

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "product")
@Entity
public class Product extends BaseTimeEntity {

    @EmbeddedId
    @Column(name = "product_id")
    private ProductId id;

    @ManyToOne
    @JoinColumn(name = "store_id")
    private Store storeInfo;

    @Column(name = "product_name")
    private String name;

    @Convert(converter = MoneyConverter.class)
    private Money price;

    @BatchSize(size = 2000)
    @OneToMany(cascade = {CascadeType.ALL}, orphanRemoval = true, fetch = FetchType.LAZY)
    @JoinColumn(name = "product_id")
    @OrderColumn(name = "list_idx")
    private List<Image> images = new ArrayList<>();

    @Enumerated(EnumType.STRING)
    @Column(name = "product_state")
    private ProductState state;

    @Lob
    private String description;
    
}

 

@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "image_type")
@Table(name = "image")
@Entity
public abstract class Image {

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

    @Column(name = "image_path")
    private String path;

    @Column(name = "upload_time")
    private LocalDateTime uploadTime;


    protected Image() {}

    public Image(String path) {
        this.path = path;
        this.uploadTime = LocalDateTime.now();
    }

    protected String getPath() {
        return path;
    }

    protected LocalDateTime getUploadTime() {
        return uploadTime;
    }

    public abstract String getUrl();

    public abstract boolean hasThumbnail();

    public abstract String getThumbnailUrl();
    
}

 

 

상품의 이미지들을 등록할 때, 이미지를 하나 등록할 때마다 API를 매번 호출하는 것은 비효율적이기 때문에 이미지를 Collection 형태로 한 번에 받아서 DB에 저장하려고 했습니다.

그래서 이미지 업로드 서비스의 saveImages() 메소드에서 LIst<Image> 값을 반환하여 images 필드에 직접 할당해주도록 구현했습니다.

 

public void saveImages(List<MultipartFile> files, FileInfo fileInfo, ProductImageUploadService imageUploadService) {
    this.images = imageUploadService.saveImages(file, fileInfo)

}

 

위 코드에서는 문제를 단순화하기 위해, 기존의 이미지를 교체하는 경우, 이미지 최대개수 제한등의 로직은 제외하였습니다.

 

그 다음, 파일 업로드 API를 호출하여 서버에 저장한 이미지 경로를 DB에 저장하려고 하자 다음과 같은 오류가 발생했습니다.

 

A collection with cascade="all-delete-orphan" was no longer referenced by the owning entity instance:

 

 

원인 분석

위 오류는 엔티티의 관계를 나타내는 필드에서 orphanRemoval 옵션을 사용하고 있을 때, 참조하고 있는 필드에 새로운 Collection을 할당하는 경우에 발생합니다.

 

저의 경우에는 images 필드가 기존에 가지고 있는 Collection을 가져와서 이미지를 추가하지 않고, 새로운 Collection을 할당했기 때문에 발생한 문제입니다.

 

그렇다면 왜 새로운 Collection을 할당하면 안되는 지 찾아보도록 하겠습니다.

 

이유를 알기 위해서는 하이버네이트가 어떻게 연관관계를 가지고 있는 엔티티를 추적하고 영속화 하는지 알아야 합니다.

 

JPA는 엔티티를 관리하기 위해서 엔티티를 영속 상태로 만듭니다. 

JPA가 엔티티를 영속화 시키는 이유는 영속성 컨텍스트안에서 엔티티를 관리하기 위해서입니다.

그리고 이 관리 업무에는 엔티티 간의 관계를 추적하는 것도 포함되어 있습니다.

 

그래서 JPA가 엔티티를 영속화 하는 일은 반드시 필요한데, 컬렉션 타입을 영속화 할 때는 컬렉션 타입에 따라, 적절한 타입의 Wrapper 클래스로 감싸서 관리를 합니다.

엔티티에서 Collection 타입의 필드를 가지고 있을 때, 초기화를 권장하는 이유도, 적절한 Wrapper 클래스로 감싸 컬렉션의 변경사항을 하이버네이트가 인식할 수 있기 때문입니다.

Collection 타입에 따라 하이버네이트가 사용하는 Wrapper 클래스는 아래와 같습니다.

 

  • PersistentBag : Collection/List(중복 허용 O / 순서 X)
  • PersistentSet : Set(중복 허용 X / 순서 X)
  • PersistentList : List + @OrderColumn(중복 허용 O / 순서 O )

위 코드에서 Product 엔티티의 images 필드는 List 타입과 @OrderColumn이 선언되어 있기 대문에 PersistentList 타입으로 래핑됩니다.

그리고 래핑된 PersistentList  타입의 객체가 JPA 영속성 컨텍스트에서 관리됩니다.

 

하지만 런타임 도중 images 필드에 새로운 List<Image> 를 할당하면, 하이버네이트가 새로 할당된 images 필드를 PersistentList 타입으로 래핑할 수 없게 됩니다.

그렇게되면 images를 영속화 할 수 없게되어 두 엔티티의 참조관계를 유지할 수 없게됩니다.

참조관계가 끊기면 부모 테이블의 row가 삭제되었을 때, 자식 테이블을 참조하여 삭제할 수 없으므로 orphanRemoval 옵션에서 에러가 발생하게 되는 것입니다.

 

 

문제 해결

이 문제를 해결하기 위해서 엔티티간의 관계를 변경한다거나, orphanRemoval 옵션을 제거하는 것은 좋은 선택이 아닙니다. 상품과 이미지는 1:N의 관계를 가진다는 사실이 명확하고, 상품이 제거된 경우 상품의 이미지도 제거되는 것이 맞기 때문입니다.

 

그래서 PersistentList로 래핑된 클래스를 하이버네이트가 인식할 수 있도록 images 필드에 값을 직접적으로 할당하지 않고, 기존의 Collection에 요소를 추가하는 방법으로 구현을 하도록 하겠습니다.

 

public void saveImages(List<MultipartFile> files, FileInfo fileInfo, ProductImageUploadService imageUploadService) {
    for(MultipartFile file : files) {
        this.images.add(imageUploadService.saveImages(file, fileInfo));
    }
}

넘어온 이미지 파일들을 순회하면서 하나씩 images 필드에 추가하는 방식으로 구현하면 하이버네이트가 Collection 타입의 필드를 추적하기 위해 사용하는 Wrapper 클래스가 변경되지 않기 때문에 이 방법을 선택했습니다.