[JPA-13] 컬렉션과 부가 기능

이가희·2025년 1월 26일
1

JPA

목록 보기
14/16
post-thumbnail

이번 시간에는 JPA에서 지원하는 컬렉션과 부가 기능에 대해 알아 볼 것이다.
컬렉션, 컨버터, 리스너, 엔티티 그래프에 대해 알아볼 것인데 컬렉션부터 차근 차근 살펴보도록 하겠다.


1. 컬렉션

JPA에서 @OneToManu, @ManyToMany 를 사용해서 일대다나 다대다 관계를 매핑할 때 혹은 @ElementCollection을 사용해서 값 타입을 하나 이상 보관할 때 컬렉션을 사용하게 된다.

JPA 명세에는 컬렉션 인터페이스에 대한 특별한 언급이 없으므로, 구현체에 따라 제공하는 기능이 조금씩 다를 수도 있는데 하이버네이트 구현체를 기준으로 설명하겠다.

하이버네이트는 엔티디를 영속 상태로 만들 때 컬렉션 필드를 하이버네이트에서 준비한 컬렉션으로 감싸서 사용한다.
더 자세히 설명하자면,
하이버네이트는 컬렉션을 효율적으로 관리하기 위해서
엔티티를 영속 상태로 만들 때 원본 컬렉션을 감싸고 있는 내장 컬렉션을 생성해서 이 내장 컬렉션을 사용하도록 참조를 변경한다.

인터페이스에 따라 어떤 래퍼 컬렉션이 사용되는지는 아래와 같다.

// org.hibernate.collection.internal.PersistentBag
@OneToMany
Collection<Membber> collection = new ArrayList<>();

// org.hibernate.collection.internal.PersistentBag
@OneToMany
List<Member> list = new ArrayList<>();

// org.hibernate.collection.internal.PersistentSet
@OneToMany
Set<Member> set = new HashSet<>();

// org.hibernate.collection.internal.PersistentList
@OneToMany @OrderColumn
List<Member> orderColumnList = new ArrayList<>();

내장 컬렉션과 그 특징을 표로 살펴보자면 다음과 같다.

컬렉션 인터페이스내장 컬렉션중복허용순서보관
Collection , ListPersistenBagOX
SetPersistentSetXX
List + @OrderColumnPersistentListOO

그 특징들을 하나씩 살펴보자.

✏️ 1.1 Collection , List

Collection, List 인터페이스는 중복을 허용하는 컬렉션이고, PersistentBag을 래퍼 컬렉션으로 사용한다.
중복을 허용하기 때문에 add() 메소드는 어떤 비교를 내부에서 하지 않고 항상 true를 반환한다.
따라서 엔티티를 추가해도 지연 로딩된 컬렉션을 초기화하지 않는다.

+) 초기화 하지 않는 이유 더 자세히 :
지연 로딩된 컬렉션에서 중복을 허용하지 않으려면, 중복을 검사하기 위해 DB에서 값을 가져와야지 데이터가 중복인지 확인할 수 있다. 하지만 중복을 허용하기 때문에 굳이 DB에 값을 가져와서 비교할 필요가 없다.

✏️ 1.2 Set

Set은 중복을 허용하지 않는 컬렉션이다.
따라서 add()메소드로 객체를 추가할 때, 중복된 엔티티가 있는지 비교해야 한다.
그래서 엔티티를 추가할 때 지연 로딩된 컬렉션을 초기화한다.

✏️ 1.3 List + @OrderColumn

List 인터페이스에 @OrderColumn 어노테이션을 추가하면 순서가 있는 컬렉션으로 인식한다.
여기서 순서가 있다는 의미는 데이터베이스에 순서 값을 저장해서 조회할 때 사용한다는 의미이다.

@Entity
public class Board {
	@Id @GeneratedValue
    private Long id;
    
    private String title;
    
    @OneToMany(mappedBy = "board")
    @OrderColumn(name = "POSITION")
    private List<Comment> comments = new ArrayList<Comment>();
}

@Entity
public class Comment {
	@Id @GeneratedValue
    private Long id;
    
    private String comment;
    
    @ManyToOne
    @JoinColumn(name = "BOARD_ID")
    private Board board;
 }

위의 예제로 살펴보자.
순서가 있는 컬렉션은 데이터베이스에 순서 값도 함께 관리한다.
그래서 JPA는 List의 위치 값을 테이블의 POSITION 컬럼에 보관한다.
여기서 테이블의 일대다 관계 특성상 위치 값은 다 쪽에 저장해야 하고, 따라서 POSITION 컬럼은 COMMENT 테이블에 매핑된다.

언뜻 보면 @OrderColumn 어노테이션만을 이용해서 순서를 관리할 수 있으니 편리할 것 같지만 단점이 상당하여 개발자가 직접 POSITION 값을 관리하거나 이따 설명할 @OrderBy를 사용하는 것이 권장된다.

@OrderColumn 의 단점

  1. @OrderColumn 을 Board 엔티티에서 매핑하니 Comment는 POSITION의 값을 알 수 없다. 그래서 Comment을 insert 할 때는 POSITON 값이 저장되지 않는다. Board.comments의 위치 값을 사용해서 POSITION의 값을 UPDATE하는 SQL이 추가로 발생하게 된다.
  2. List 를 변경하면 연관된 많은 위치 값을 변경해야 한다. 만약 포지션 2에 해당하는 댓글을 삭제하면, 포지션 3, 4, 5...n 를 1씩 줄이는 SQL이 n번 추가로 실행된다.
  3. 중간에 POSITION값이 없으면 조회한 List에는 null이 보관된다. 예를 들어 댓글2를 DB에서 강제로 삭제하고 다른 댓글들의 POSITION 값을 수정하지 않으면 DB의 POSITION 값은 (0,2,3...)이 되어서 중간에 1 값이 없다. 이 경우 List를 조회하면 1번 위치에 null 값이 보관되고 컬렉션을 순회할 때 NullPointerException이 발생한다.

2. @OrderBy

@OrderBy는 데이터베이스의 ORDER BY 절을 사용해서 컬렉션을 정렬한다.
그래서 @OrderColumn처럼 순서용 컬럼을 매핑하지 않아도 된다.

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

이렇게 설정하고 Team.members를 초기화 하면

SELECT M.*
FROM
MEMBER M
WHERE
M.TEAM_ID = ?
ORDER BY
M.MEMBER_NAME DESC, M.ID ASC

라는 쿼리문이 사용된 것을 확인할 수 있따.

참고로, Set에 @OrderBy를 적용해서 결과를 조회하면 순서를 유지하기 위해 하이버네이트는 LinkedHashSet을 내부에서 사용한다.

3. @Converter

컨버터를 사용하면 엔티티의 데이터를 변환해서 데이터베이스에 저장할 수 있다.

예를 들어 회원 VIP 여부를 자바의 boolean 타입으로 사용할 때,
방언에 따라 다르지만 대게 데이터베이스에 0 혹은 1인 숫자로 저장된다.
그런데 숫자 대신에 Y 또는 N으로 저장하고 싶을 때 컨버터를 사용하면 된다.

사용 방법은 다음과 같다.

  1. 컨버터 클래스 만들기
    @Converter 어노테이션을 사용하고, AttributeConverter 인터페이스를 구현해야 한다. 그리고 제네릭에 현재 타입과 반환할 타입을 지정해야 한다.
    AttributeConverter 인터페이스는 2가지 메소드를 구현해야 하는데,
    convertToDatabaseColumn ()은 엔티티의 데이터를 데이터베이스 컬럼에 저장할 데이터로 변환시키는 로직을, convertToEntityAttribute()는 데이터베이스에서 조회한 컬럼 데이터를 엔티티의 데이터로 변환시키는 로직을 구현하면 된다.
@Converter
public class BooleanToYNConverter implements AttributeConverter<Boolean, String> {
	@Override
    public String converToDatabaseColumn (Boolean attribute){
    	return (attribute != null && attribute ) ? "Y" : "N";
      }
      
    @Override 
    public Boolean convertToEntityAttribute(String dbData) {
    	return "Y".equals(dbData);
    }
 }
  1. 엔티티에서 해당 속성에 @Convert 어노테이션 적용하기.
    converter 속성에 아까 만들어둔 클래스를 넣어 해당 컨버터를 사용하도록 해야한다.
@Entity
public class Member {
	@Id
    private String id;
    
    @Convert (converter = BooleanToYNConverter.class)
    private bollean vip;
 }
  1. 만약 모든 Boolean 타입에 컨버터를 적용하려면 1에서 만든 클래스에 @Converter(autoApply = true) 옵션을 적용시키면 된다.

4. 리스너

JPA의 리스너 기능을 사용하면 엔티티의 생명주기에 따른 이벤트를 처리할 수 있다.

이벤트의 종류와 발생 시점은 아래 그림과 같다.

  1. PostLoad
    엔티티가 영속성 컨텍스트에 조회된 직후 또는 refresh를 호출한 후(2차 캐시에 저장되어 있어도 호출된다)

  2. PrePersist
    persist() 메소드를 호출해서 엔티티를 영속성 컨텍스트에 관리하기 직전에 호출된다. 식별자 생성 전략을 사용한 경우 엔티티에 식별자는 아직 존재하지 않는다. 새로운 인스턴스를 merge 할 때도 수행된다.

  3. PreUpdate
    flush나 commit을 호출해서 엔티티를 데이터베이스에 수정하기 직전에 호출된다.

  4. PreRemove
    remove() 메소드를 호출해서 엔티티를 영속성 컨텍스트에서 삭제하기 직전에 호출된다. 또한 삭제 명령어로 영속성 전이가 일어날 때도 호출된다. orphanRemoval에 대해서는 flush나 commit 시에 호출된다.

  5. PostPersist
    flush나 commit을 호출해서 엔티티를 데이터베이스에 저장한 직후에 호출된다. 식별자가 항상 존재한다.

  6. PostUpdate
    flush나 commit을 호출해서 엔티티를 데이터베이스에 수정한 직후에 호출된다.

  7. PostRemove
    flush나 commit을 호출해서 엔티티를 데이터베이스에 삭제한 직후에 호출된다.

이벤트는 엔티티에 직접 적용 / 별도의 리스너 등록 / 기본 리스너 사용의 방법을 통해 받을 수 있다.
하나씩 살펴보겠다.

✏️4.1 엔티티에 직접 적용

@Entity
public class Duck {
	@Id @GeneratedValue
    public Long id;
    
    @PrePersist
    public void prePersist() {
    	System.out.println("Duck.prePersist id =" + id);
    }
    
    @PostPersist
    public void postPersist() {
    	System.out.println("Duck.postPersist id=" +id);
    }
}

어노테이션을 통해 간단하게 적용할 수 있다.
물론 예시에서 사용한 어노테이션 말고 @PostLoad, @PreRemove ... 등에 대해서도 사용 가능하다.
이렇게 적용한 다음 엔티티를 저장하면
Dock.prePersist id = null
Dock.postPersist id = 1
콘솔에 이렇게 출력될 것이다.

✏️4.2 별도의 리스너 등록

@Entity
@EntityListeners (DuckListener.class)
public class Duck {
	...
}



public class DuckListener {
		
	@PrePersist
    //특정 타입일 확실하면 특정 타입을 받을 수 있다.
    public void prePersist(Object obj) {
    	System.out.println("DuckListener.prePersist obj =" + obj);
    }
    
    @PostPersist
    //특정 타입일 확실하면 특정 타입을 받을 수 있다.
    public void postPersist(Object obj) {
    	System.out.println("DuckListener.postPersist obj=" +obj);
    }
 }

사용방법은 위의 예시 코드와 같다.
주의할 점으로 반환 타입은 void로 설정해야 한다.

✏️4.3 기본 리스너 사용

META-INF/orm.xml에 기본 리스너로 등록하면 모든 엔티티에 이벤트를 적용할 수 있다.

<persistence-unit-metadata>
	<persistence-unit-defaults>
    	<entity-listeners>
        	<entity-listener class ="jpabook.jpashop.domain.test.listener.DefaultListener"/>
        </entity-listeners>
    </persistence-unit-defaults>
</persistence-unit-metadata>

여러 리스너를 등록했을 때 이벤트 호출 순서는 다음과 같다.
기본 리스너 > 부모 클래스 리스너 > 리스너 > 엔티티

여기서 엔티티에 직접 적용할 때 @ExcludeDefaultListners (기본 리스너 무시), @ExcludeSuperclassListeners (상위 클래스 이벤트 리스너 무시) 어노테이션들을 이용해 세밀하게 설정할 수도 있다.

5. 엔티티 그래프

JPA 2.1에서 추가된 엔티티 그래프 기능은 엔티티 조회시점에 연관된 엔티티들을 함께 조회하는 기능이다.
엔티티 그래프 기능을 사용하면 함께 조회할 연관된 엔티티를 선택할 수 있다.

Named 엔티티 그래프와 동적으로 정의하는 엔티티 그래프가 있는데 Named 엔티티 그래프부터 알아보겠다.

✏️5.1 Named 엔티티 그래프

Named 엔티티 그래프는 @NamedEntityGraph로 정의한다.
이 어노테이션의 name 속성은 엔티티 그래프의 이름을 정의하고, attributeNodes 는 함께 조회할 속성을 선택하는데 @NamedAttributeNode를 사용해서 함께 조회할 속성을 선택하면 된다.
둘 이상을 정의하려면 @NamedEntityGraphs를 사용하면 된다.

주문과 회원 테이블이 연관되어 있다 했을 때 주문 엔티티에서 회원도 함께 조회하는 엔티티 그래프를 만들어 보겠다.

@NamedEntityGraph (name = "Order.withMember" , 
	attributeNodes = { @NamedAttributeNode("member")
    })
@Entity
public class Order{
	@Id 
    @Column (name = "ORDER_ID")
    private Long id;
    
    @ManyToOne(fetch = FetchType.LAZY , optional = false)
    @JoinColumn(name = "MEMBER_ID")
    private Member member;
 }

이렇게 만든 다음에 em.find()에서 엔티티 그래프를 사용하면 된다.

EntityGraph graph = em.getEntityGraph("Order.withMember");
	//사용할 그래프의 이름을 넣어서 EntityGraph를 찾아옴

Map hints = new HashMap();
hints.put("javax.persistence.fetchgraph", graph);
//힌트의 값으로 찾아온 엔티티 그래프를 넣어줌

Order order = em.find(Order.class , orderId, hints);
//파라미터에 힌트를 넣으면 엔티티 그래프에서 선택한 연관된 엔티티들을 함께 조회해서 오게 됨.
//여기서는 join을 통해 연관된 회원을 함께 조회하게 됨.

여기서, Order -> OrderItem -> Item 이렇게 연관되어 있다고 가정했을 때 Item까지 조회되도록 설정해 보겠다.

subgraph를 사용하면 되는데 코드를 보면 어렵지 않게 적용할 수 있을 것이다.

@NamedEntityGraph (name = "Order.withAll" , 
	attributeNodes = {
    @NamedAttributeNode("member"),
    @NamedAttributeNode(value = "orderItems", subgraph = "orderItems")},
    subgraphs = @NamedSubgraph(name = "orderItems", attributeNodes = {
    	@NamedAttributeNode("item")
        })
    )
@Entity
public class Order{
	@Id 
    @Column (name = "ORDER_ID")
    private Long id;
    
    @ManyToOne(fetch = FetchType.LAZY , optional = false)
    @JoinColumn(name = "MEMBER_ID")
    private Member member;
    
    @OneToMany(mappedBy = "order")
    private List<OrderItem> orderItems = new ArrayList<>();
 }
 
 
 @Entity
 public class OrderItem {
 	@Id
    @Column (name = "ORDER_ITEM_ID")
    private Long id;
    
    @ManyToOne
    @JoinColumn(name = "ITEM_ID")
    private Item item;
}
    

여기서 주의할 점으로 OrderItem -> Item 은 Order의 객체 그래프가 아니므로 subgraphs 속성으로 정의해야 한다.

만들어진 엔티티 그래프를 이번에는 JPQL에서 사용하겠다.

List<Order> resultList =
	em.createQuery ("select o from Order o where o.id = :orderId", Order.class)
    .setParameter("orderId" , orderId)
    .setHint("javax.persistence.fetchgraph", em.getEntityGraph("Order.withAll))
    .getResultList();

사용방법이 크게 다르지 않다.

✏️5.2 동적 엔티티 그래프

createEntityGraph() 메소드를 통해 동적으로 구성할 수 있다.

EntityGraph<Order> graph = em.createEntityGraph(Order.class);
graph.addAttributeNodes("member");
Subgraph<OrderItem> orderItems = graph.addSubgraph("orderItems");
orderItems.addAttributeNodes("item");

Map hints = new HashMap();
hints.put("javax.persistence.fetchgraph", graph);

Order order = em.find(Order.class, orderId, hints);

앞서 정적으로 만들었던 엔티티 그래프를 동적으로 만들어 보았다.
크게 어렵지 않다.

엔티티 그래프 사용할 때 주의점들 💃
1. 영속성 컨텍스트에 해당 엔티티가 이미 로딩되어 있으면 엔티티 그래프가 적용되지 않는다.
2. 예제에서 사용한 javax.persistence.fetchgraph힌트는 엔티티 그래프에 선택한 속성만 함께 조회한다. javax.persistence.loadgraph를 사용하면 엔티티 그래프에 선택한 속성뿐만 아니라 글로벌 fetch 모드가 FetchType.EAGER로 설정된 연관관계도 포함해서 함께 조회한다.

이번 시간에 고생이 많으셨다.
다음 시간에는 JPA의 다양한 심화 주제와 성능 최적화 방법에 대해 살펴보겠다.

참조 : 자바 ORM 표준 JPA 프로그래밍 - 김영한

profile
안녕하세요 개발하는 사람입니다.

0개의 댓글

관련 채용 정보