[JPA] 하이버네이트가 컬렉션을 관리하는 방법

olsohee·2023년 10월 4일
0

JPA

목록 보기
21/21
post-custom-banner

프로젝트 진행중 JPA와 관련된 문제 발생과 그 해결 과정을 기록하고자 한다.

1. 상황

하나의 상품에는 여러개의 상품 이미지가 있을 수 있다. 즉 ProductProductImage는 일대다 관계이다. 그리고 Product 엔티티의 List<ProductImage>는 영속성 전이 설정과 고아객체 설정이 되어 있다.

상품을 업데이트하는 과정에서 Product와 연결된 ProductImage들을 업데이트하는 상황이다. 즉 Product 엔티티의 List<ProductImage> productImages를 업데이트하는 상황이다.


2. 문제 발생

Product

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product extends BaseEntity {

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

    @OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true)
    List<ProductImage> productImages = new ArrayList<>();

    public void updateProductImages(List<ProductImage> newProductImages) {

        this.productImages = newProductImages;
    }
    
    ...
    
}

ProductImage

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ProductImage {

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

    @ManyToOne
    @JoinColumn(name = "product_id")
    Product product;

	...
    
}

ProductService

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;
    private final ProductImageRepository productImageRepository;

    @Transactional
    public ProductResponse update(Long productId, UpdateProductRequest dto, List<MultipartFile> multipartFiles) throws IOException {

        // 조회
        Product product = productRepository.findById(productId).orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_PRODUCT));

        // Product 수정
        product.updateProduct(dto.getName(), dto.getPrice(), dto.getStock());

        // List<ProductImage> 수정
        List<ProductImage> newProductImages = createProductImages(multipartFiles);
        product.updateProductImages(newProductImages);
        
        // 응답
        return ProductResponse.createResponse(product);
    }
    
    ...
    
}

ProductService의 product.updateProductImages(newProductImages); 코드를 통해 다음과 같이 ProductList<ProductImage>가 기존 리스트가 아닌 아예 새로운 리스트를 참조하도록 했다.

public void updateProductImages(List<ProductImage> newProductImages) {

	this.productImages = newProductImages;
}

그러면 기존의 리스트는 참조되지 않기 때문에 orphalRemoval=true 옵션으로 인해 DB에서 기존의 ProductImage들이 삭제될 것이라고 생각했다.

그런데 이때 다음과 같은 예외가 발생했다.

org.hibernate.HibernateException: A collection with cascade="all-delete-orphan" was no longer referenced by the owning entity instance: project.shop.entity.product.Product.productImages

해당 예외 발생 원인을 알아보기 위해서는 JPA가 컬렉션을 어떻게 관리하는지 알아야 한다.


3. JPA와 컬렉션

3.1. 하이버네이트가 컬렉션을 관리하는 방식

하이버네이트는 엔티티를 영속 상태로 만들 때 컬렉션 필드를 하이버네이트에서 미리 만들어둔 컬렉션으로 감싸서 사용한다.

Team team = new Team();

System.out.println(team.getMembers().getClass());

// 영속화
em.persist(team);

System.out.println(team.getMembers().getClass());

예를 들어 위 코드를 실행하면, 두개의 출력문 결과는 다음과 같다.

  • 첫번째 출력문: calss java.util.ArrayList

  • 두번째 출력문: class org.hibernate.collection.internal.PersistentBag

즉 원래 ArrayList 타입이었던 컬렉션이 엔티티가 영속 상태가 되자, 하이버네이트가 제공하는 PersistentBag 타입으로 변경되었다.

하이버네이트는 컬렉션을 효율적으로 관리하기 위해 엔티티를 영속 상태로 만들 때, 원본 컬렉션을 감싸고 있는 래퍼 컬렉션을 생성하고 이 래퍼 컬렉션을 사용하도록 참조를 변경한다.

하이버네이트는 이런 특징 때문에 컬렉션을 사용할 때 다음처럼 즉시 초기화해서 사용할 것을 권장한다.

Collection<Member> members = new ArrayList<>();

3.2. Collection, List

컬렉션 인터페이스에 따른 하이버네이트의 래퍼 클래스는 다음과 같다.

컬렉션 인터페이스래퍼 클래스중복 허용순서 유지
Collection, ListPersistentBagOX
SetPersistentSetXX
List + @OrderColumnPersistentListOO

Collection과 List 인터페이스는 중복을 허용하는 컬렉션이고, PersistentBag을 래퍼 컬렉션으로 사용한다.

Collection, List는 중복을 허용하기 때문에 add() 메소드로 객체를 추가할 때 내부에서 어떤 비교도 하지 않고 단순히 저장만 한다. 반면 같은 엔티티가 있는지 찾거나 삭제할 때는 equals() 메소드를 사용한다.

List<Comment> comments = new ArrayList<>();

// 비교 없이 추가만 한다. 따라서 결과는 항상 true이다.
boolean result = comments.add(data);

// 같은 엔티티가 있는지 찾거나 삭제할 때는 equals() 메소드를 사용한다.
comments.contains(comment);
comments.remove(comment);

Collection, List는 엔티티를 추가할 때 중복된 엔티티가 있는지 비교하지 않고 단순히 저장만 한다. 따라서 엔티티를 추가해도 지연 로딩된 컬렉션을 초기화하지 않는다.

3.3. Set

Set은 중복을 허용하지 않는 컬렉션이고, PersistentSet을 래퍼 컬렉션으로 사용한다.

중복을 허용하지 않기 때문에 add() 메소드로 객체를 추가할 때마다 equals() 메소드로 같은 객체가 있는지 비교한다. 같은 객체가 없으면 객체를 추가하고 true를 반환하고, 같은 객체가 이미 있어서 추가에 실패하면 false를 반환한다. 참고로 HashSet은 해시 알고리즘을 사용하므로 hashcode()도 함께 사용해서 비교한다.

Set<Comment> comments = new HashSet<>();

// hashcode + equals 비교
boolean result = comments.add(data);
comments.contains(comment);
comments.remove(comment);

Set은 엔티티를 추가할 때 중복된 엔티티가 있는지 비교해야 한다. 따라서 엔티티를 추가할 때 지연 로딩된 컬렉션을 초기화한다.

3.4. List + @OrderColumn

List 인터페이스에 @OrderColumn을 추가하면 순서가 있는 특수한 컬렉션으로 인식된다. 순서가 있다는 의미는 데이터베이스에 순서 값을 저장해서 조회할 때 사용한다는 의미이다. 이때 하이버네이트는 내부 컬렉션으로 PersistentList를 사용한다.

@Entity
public class Board {

	@Id @GeneratedValue
    private Long id;
    
    @OneToMany(mappedBy = "board")
    @OrderColumn(name = "POSITION")
    private List<Comment> comments = new ArrayList<>();
}

위 예제를 보면 comments@OrderColumn을 사용했다. 따라서 comments는 순서가 있는 컬렉션으로 인식된다.

참고로 자바의 List는 내부에 위치 값을 가지고 있다. 따라서 다음과 같이 List 위치 값을 활용할 수 있다.

list.add(1, data); // 1번 위치에 data 저장
list.get(10); // 10번 위치의 값 조회

순서가 있는 컬렉션은 데이터베이스 내부에 순서 값도 함께 관리한다. 여기서는 @OrderColumn의 name 속성에 POSITION이라는 값을 주었다. 그러면 JPA는 List의 위치 값을 테이블의 POSITOIN 컬럼에 보관한다. 그런데 comments 컬렉션은 Board 엔티티에 있지만, 테이블의 일대다 관계의 특성상 List의 위치 값은 다(N) 저장해야 한다. 따라서 실제 POSITION 컬럼은 comment 테이블에 매핑된다.

@OrderColumn의 단점

@OrderColumn는 다음과 같은 단점 때문에 실무에서 잘 사용하지 않는다.

  • @OrderColumn을 Board 엔티티에서 매핑하므로 Comment는 POSITION의 값을 알 수 없다. 그래서 Comment를 insert 할 때는 POSITION 값이 저장되지 않는다. POSITION은 Board.comments의 위치 값이므로, 이를 사용해서 POSITION 값을 update하는 추가 sql이 실행된다.

  • List를 변경하면 연관된 값들의 위치 값까지 변경해야 한다. 예를 들어 댓글1, 2, 3, 4가 순서대로 저장되어 있을 때, 댓글2를 삭제하면 댓글 3, 4의 POSITION 값을 각각 하나씩 줄이는 update sqldㅣ 실행된다.

  • 중간에 POSITION 값이 없으면 조회한 List에는 null이 보관된다. 예를 들어 댓글2를 데이터베이스에서 강제로 삭제하고 다른 댓글들의 POSITION 값을 수정하지 않으면 데이터베이스의 POSITION 값은 [0, 2, 3]이 되어 중간에 1 값이 없다. 이 경우 List를 조회하면 인덱스 1 위치에 null 값이 보관된다. 따라서 컬렉션을 순회할 때 NullPointerException이 발생한다.

@OrderBy

@OrderColumn 대신에 @OrderBy를 사용하는 것이 권장된다. @OrderColumn이 데이터베이스에 순서용 컬럼을 매핑해서 관리했다면 @OrderBy는 데이터베이스의 order by절을 사용해서 컬렉션을 정렬한다. 따라서 순서용 컬럼을 따로 매핑하지 않아도 된다. 그리고 @OrderBy는 모든 컬렉션에 사용할 수 있다.

@Entity
public class Team {

	@Id @GeneratedValue
    private Long id;
    
    @OneToMany(mappedBy = "team")
    @OrderBy("username desc, id asc")
    private Set<Member> members = new HashSet<>();
}

4. 문제 원인과 해결

앞서 프로젝트 코드를 보면 ProductList<ProductImage> productImagesProduct가 영속화될 때 래퍼 클래스로 감싸서 하이버네이트에 의해 관리되고 있을 것이다. 따라서 상품 이미지를 업데이트 하는 과정에서 productImages가 기존 리스트가 아닌 새로운 리스트를 가리키도록 변경하면, Product와 기존 리스트의 참조가 끊기게 된다. 이때 cascade = CascadeType.ALL, orphanRemoval = true 옵션이 걸려있을 때, 하이버네이트가 오류를 발생시킨다.

정확히 왜 두 옵션이 걸려있을 때 오류가 발생하는지는 모르겠지만..

따라서 두 옵션이 걸려있을 때는 새로운 컬렉션을 참조하도록 하지 말고, 다음과 같이 기존 컬렉션의 값을 clear()로 비우고 새로운 값들을 순차적으로 넣어주면 된다. 즉 기존 컬렉션을 그대로 사용해야 한다!

Product

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product extends BaseEntity {

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

    @OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true)
    List<ProductImage> productImages = new ArrayList<>();

    public void updateProductImages(List<ProductImage> newProductImages) {

        // 기존 리스트 clear
        this.productImages.clear();

        // 기존 리스트에 새로운 ProductImage들을 추가
        for (ProductImage newProductImage : newProductImages) {
            productImages.add(newProductImage);
        }
    }
    
    ...
    
}

Reference

https://gowoonsori.com/error/onetomanyerror/

https://jerry92k.tistory.com/44

https://jerry92k.tistory.com/66

profile
공부한 것들을 기록합니다.
post-custom-banner

0개의 댓글