14. 컬렉션과 부가기능

이주호·2025년 1월 25일
0

컬렉션

JPA는 자바의 컬렉션을 위와 같이 제공하고 다음과 같은 경우에 컬렉션을 사용합니다.

  • @OneToMany, @ManyToOne를 사용하여 일대다나 다대다 엔티티 관계를 매핑할 경우
  • @ElementCollection을 사용해서 값 타입을 하나 이상 보관할 때

JPA 구현체마다 제공하는 컬렉션 기능이 다를 수 있다. 여기서는 하이버네이트 구현체를 기준으로 이야기한다.

JPA와 컬렉션

하이버네이트에서 엔티티를 영속 상태로 만들 때 컬렉션 필드를 하이버네이트의 컬렉션으로 감싸서 사용한다.
예를 들어 일대다 관계인 팀 엔티티와 멤버 엔티티가 가정하자. 멤버 엔티티는 팀엔티티에 @OneToMany로 연관관계가 설정되어 있다.

@Entity
public class Team {

	@Id
    private String id;
    
    @OneToMany
    @JoinColumn
    private Collection<Member> members = new ArrayList<Member>();
    ...
}    

(8장 프록시에서 배운 것을 기억해보자. @OneToMany처럼 다 엔티티를 연관관계로 가지면 지연로딩이 기본값)

Team team = new Team();

System.out.println("before persist = " + team.getMembers().getClass());
// before persist = class.java.util.ArrayList
em.persist(team);
System.out.println("after persist = " + team.getMembers().getClass());
//after persist = class org.hibernate.collection.internal.PersistentBag

팀과 일대다로 연관관계를 가진 members의 타입이 team 엔티티가 영속 상태 이전에는 ArrayList를 가지고 영속 상태 이후에는 하이버네이트가 제고하는 PersistentBag 타입으로 변경된 것을 볼 수 있다. 하이버네이트는 이처럼 영속 상태 이후에 관리를 편리하게 하기 위해 원본 컬렉션을 감싸고 있는 래퍼 컬렉션을 제공한다.
이런 특징 때문에 위처럼 바로 컬렉션을 사용할 때 위처럼 바로 초기화해서 사용하는 것을 권장한다.

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

Collection, List

Collection과 List는 중복을 허용하는 컬렉션으로 PersistentBag을 래퍼 컬렉션으로 사용한다. 해당 래퍼 컬렉션은 순서는 보장하지 않는다.
이 인터페이스는 ArrayList()로 초기화한다.
Collection, List는 중복을 허용하므로 객체를 추가하는 add() 메서드에서는 어떤 비교도 하지 않고 저장하며 항상 true를 반환한다.
단순히 저장만 하기 때문에 객체를 추가한다고 해도 지연 로딩된 컬렉션을 초기화하지 않는다.
같은 엔티티가 있는지 찾거나(contains()), 삭제하는 경우(remove())할 때는 eqauls() 비교를 한다.

Set

중복을 허용하지 않고 순서를 보장하지 않는 컬렉션으로 래퍼 컬렉션으로는 PersistentSet을 이용한다.
이 인터페이스는 HashSet()으로 초기화한다.
Set은 중복을 허용하지 않으므로 add() 메서드로 객체를 추가할 때 equals()를 사용해서 같은 객체가 있는지 비교한다. 같은 객체가 없으면 객체를 추가하고 true를, 같은 객체가 있으면 추가하지 않고 false를 반환한다.
HashSet은 해쉬 알고리즘을 사용하므로 hashcode()도 함께 사용하여 비교한다.

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

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

Set은 객체를 추가하기 위해서 이미 존재하는지 확인해야 한다. 따라서 엔티티를 추가할 때 지연 로딩된 컬렉션을 초기화한다.

List + @OrderColumn

기본적으로 List 컬렉션은 PersistentBag을 래퍼 컬렉션으로 사용한다고 했다. 이 래퍼 컬렉션은 순서를 보장하지 않기 때문에 순서가 필요한 경우 @OrderColumn을 사용해야 한다.
이 경우 하이버네이트는 내부 컬렉션인 PersistentList를 사용한다.

@Entity
public class Board {
	...
    @OneToMany(mappedBy = "board")
    @OrderColumn(name = "POSITION")
    private List<Comment> comments = new ArrayList<Comment>();
    ...
}

@Entity
public class Comment {
	...
    @ManyToOne
    @JoinColumn(name = "BOARD_ID")
    private Board board;
  	...
}

comments는 List 인터페이스를 사용하고 @OrderColumn을 사용해서 순서가 있는 컬렉션으로 인식한다.
순서가 있는 컬렉션은 데이터베이스에 순서 값도 함께 관리한다. 이 때, @OrderColumn의 name 속성으로 지정한 "POSITION"으로 관리되며 일대다 관계의 특성상 다쪽인 Comment 테이블에서 관리된다.

@OrderColumn의 단점

  • @OrderColumn을 Board 엔티티에서 매핑하므로 Comment는 POSITION의 값을 알 수 없다. 그래서 Comment를 INSERT할 때는 POSITION 값이 저장되지 않는다.

  • List를 변경하면 연관된 위치 값들을 모두 변경해야 한다. 예를 들어 댓글 2를 삭제하면 댓글3, 댓글4 처럼 뒤에 있는 POSTION들을 1씩 줄이는 update sql이 발생한다.

  • 중간에 POSTION이 없으면 조회한 LIST에는 null이 보관된다. 예를 들어 강제로 comment에서 댓글 2를 삭제하고 다른 POSTION 값들을 수정하지 않으면 데이터 베이스에 POSITION값들은 [0,2,3]이 된다.
    그래서 컬렉션을 조회할 경우 원래 POSTION 1 자리에는 null이기 때문에 NullPointException이 발생한다.

@OrderBy

@OrderBy는 데이터베이스의 order by를 잉요해서 컬렉션을 정렬하다. 따라서 정렬용 컬럼이 따로 필요하지 않고 모든 컬렉션에서 사용가능하다.

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

위는 @OrderBy를 사용한 예이다. @OrderBy의 값은 JPQL의 order by절처럼 엔티티의 필드를 대상으로 한다.
해당 컬렉션을 초기화하면 SQL에 ORDER BY가 사용된다.
(하이버네이트는 Set에서 @OrderBy를 사용해서 결과를 조회하면 순서 유지를 위해 HashSet대신에 LinkedHashSet을 내부에서 사용함)


@Converter

컨버터를 사용하면 엔티티의 데이터를 변환해서 데이터베이스에 저장할 수 있다.
예를 들어 엔티티의 vip 필드를 자바의 boolean 타입을 이용하면 데이터베이스에는 0 또는 1이 저장된다. 근데 0 또는 1말고 'Y', 'N'이라는 문자를 저장하고 싶을 때 converter를 사용할 수 있다.

@Entity
public class Member {
	...
    
    @Convert(converter=BooleanToYNConverter.class)
    private boolean vip;
    ...
}

@Converter를 사용해서 데이터베이스에 저장되기 전에 BooleanToYNConverter가 동작하도록 했다.

@Converter
public class BooleanToYNConverter implements AttributeConverter<Boolean, String> {
	
    @Override
    public String convertToDatebaseColumn(Boolean attribute) {
    	return (attribute != null && attribute) ? "Y" : "N";
    }
    
    @Override
    public Boolean convertToEntityAttribute(String dbData)
    	return "Y".equals(dbData);
    }
}

컨버터 클래스는 @Converter 어노테이션을 사용하고 AttributeConverter 인터페이스를 구현해야 한다. 제네릭에 현재 타입과 변환할 타입을 지정해야 한다. (여기서는 <Boolean, String>)

AttributeConverter 구현 메서드

  • convertToDatabaseColumn() : 엔티티의 데이터를 데이터베이스 컬럼에 저장할 데이터로 변환한다.
  • convertToEntityAttribute() : 데이터베이스에서 조회한 컬럼 데이터를 엔티티의 데이터로 변환한다.

컨버터는 클래스 레벨에도 설정할 수 있는데 이 때, attributeName 속성으로 어떤 필드에 컨버터를 적용할지 명시해야 한다.

@Entity
@Convert(converter=BooleanToYNConverter.class, ttributeName = "vip")
public class Member {
	...
    private boolean vip;
    ...
}

글로벌 설정

@Converter의 autoApply = true 옵션을 사용해서 글로벌 설정을 할 수 있다. 예를 들어 위에서 만든 컨버터를 모든 boolean 타입에 대해 적용하려면 다음과 같이 사용할 수 있다.

@Converter(autoApply = true)
public class BooleanToYNConverter implements AttributeConverter<Boolean, String> {
	
    @Override
    public String convertToDatebaseColumn(Boolean attribute) {
    	return (attribute != null && attribute) ? "Y" : "N";
    }
    
    @Override
    public Boolean convertToEntityAttribute(String dbData)
    	return "Y".equals(dbData);
    }
}

리스너

리스너는 특정 이벤트가 발생했을 때 동작하는 콜백 메서드를 제공하여, 해당 이벤트에 대한 특정 동작을 실행하도록 설계된 구성 요소이다. 리스너는 주로 이벤트 기반 프로그래밍에서 사용되며, 애플리케이션에서 "특정 시점"에 동작하도록 정의된다.

JPA의 리스너 기능을 사용하면 엔티티의 생명주기에 따른 이벤트(엔티티의 상태 변화 - 저장/수정등)를 처리할 수 있다.

이벤트 종류

  1. PostLoad
    엔티티가 영속성 컨텍스트에 조회된 직후 또는 refresh를 호출한 후 (2차 캐시에 저장되어 있어도 호출)
  2. PrePersist
    persist() 메서드를 호출해서 엔티티를 영속성 컨텍스트에 관리하기 직전에 호출된다. 식별자 생성 전략을 사용한 경우 엔티티에 식별자는 아직 존재하지 않는다. 간략하게 설명하면 [persist() -> PrePersist -> db와 상호작용하여 식별자 가져옴] 이 순서이기 때문에 식별자 생성 전략을 사용하면 식별자와 관련된 로깅 시 문제가 될 수 있다는 말이다.
    새로운 인스턴스를 merge할 때도 수행된다.
  3. PreUpdate
    flush나 commit을 호출해서 엔티티를 데이터베이스에 수정하기 직전에 호출된다.
  4. PreRemove
    remove() 메서드를 호출해서 엔티티를 영속성 컨텍스트에서 삭제하기 직전에 호출된다. 또한 삭제 명령어로 영속성 전이가 일어날 때도 호출된다. orphanRemoval에 대해서는 flush나 commit 시에 호출된다.
  5. PostPersist
    flush나 commit을 호출해서 엔티티를 데이터베이스에 저장한 직후에 호출된다. 식별자가 항상 존재한다.
    식별자 생성 전략이 IDENTITY면 식별자를 생성하기 위해 persist()를 호출하면서 데이터베이스에 해당 엔티티를 저장하므로 이때는 persist()를 호출한 직후에 바로 PostPersist가 호출된다.
  6. PostUpdate
    flush나 commit을 호출해서 엔티티를 데이터베이스에 수정한 직후에 호출된다.
  7. PostRemove
    flush나 commit을 호출해서 엔티티를 데이터베이스에 삭제한 직후에 호출된다.

JPA 리스너

정의:

JPA 리스너는 엔티티 객체의 생명주기 동안 발생하는 이벤트(예: 엔티티 생성, 수정, 삭제 등)에 반응하여 동작합니다. 이는 JPA의 엔티티 리스너(Entity Listener)콜백 메서드를 통해 구현됩니다.

주요 특징:

  • 생명주기 이벤트 처리: JPA 엔티티에서 발생하는 이벤트를 캡처하여, 추가 로직(예: 데이터 감사, 로깅)을 수행할 수 있습니다.
  • 어노테이션 기반: JPA 리스너는 특정 이벤트를 처리하기 위해 @PrePersist, @PostPersist, @PreUpdate, @PostUpdate 등 어노테이션을 사용합니다.
  • 독립적인 리스너 클래스: 리스너 로직을 엔티티 외부의 독립적인 클래스에 작성할 수 있습니다.

생명주기 이벤트:

이벤트어노테이션설명
PrePersist@PrePersist엔티티가 저장되기 전에 실행
PostPersist@PostPersist엔티티가 저장된 후 실행
PreUpdate@PreUpdate엔티티가 수정되기 전에 실행
PostUpdate@PostUpdate엔티티가 수정된 후 실행
PreRemove@PreRemove엔티티가 삭제되기 전에 실행
PostRemove@PostRemove엔티티가 삭제된 후 실행
PostLoad@PostLoad엔티티가 로드된 후 실행

이벤트 적용 위치

  • 엔티티에 직접 적용
  • 별도의 리스너 등록
  • 기본 리스너 사용

엔티티에 직접 적용

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

위에서 설명한 어노테이션을 이용해서 필요에 따라 엔티티에 직접 적용할 수 있다.

별도의 리스너 등록

1. 엔티티 리스너 정의

public class AuditListener {

    @PrePersist
    public void beforePersist(Object entity) {
        System.out.println("Before persisting: " + entity);
    }

    @PostPersist
    public void afterPersist(Object entity) {
        System.out.println("After persisting: " + entity);
    }
}

2. 엔티티에 리스너 연결

@Entity
@EntityListeners(AuditListener.class)
public class User {
    @Id
    @GeneratedValue
    private Long id;

    private String name;

    // Getters and setters...
}

리스너는 대상 엔티티를 파라미터로 받을 수 있다. 반환 타입은 void로 설정해야 한다.

기본 리스너 사용

모든 엔티티의 이벤트를 처리하려면 META-INF/orm.xml에 기본 리스너로 등록하면 된다.

이벤트 호출 순서

  1. 기본 리스너
  2. 부모 클래스 리스너
  3. 리스너
  4. 엔티티

더 세빌한 설정

  • javax.persistence.ExcludeDefaultListeners : 기본 리스너 무시
  • javax.persistence.ExcludeSuperclassListeners: 상위 클래스 이벤트 리스너 무시

엔티티 그래프

엔티티를 조회할 때 연관된 엔티티까지 조회하기 위해서 글로벌 fetch 옵션을 FetchType.EAGER 설정할 수 있다. 그러나 이 방법은 성능 저하를 가져올 수 있어 보통 FetcyType.LAZY를 사용하고 필요한 경우에만 JPQL 페치 조인을 사용한다.
그러나 이 방법도 경우에 따라서 함께 조회하는 엔티티가 무엇이냐에 따라서 JPQL을 여러개 작성해야 한다는 단점이 있다.
이는 이 방법이 JPQL이 데이터를 조회하는 기능뿐만 아니라 연관된 엔티티를 함께 조회하기 때문에 발생하는 문제이다.

JPA2.1에 추가된 엔티티 그래프 기능을 사용하면 엔티티를 조회하는 시점에 함께 조회할 연관된 엔티티를 선택할 수 있다. 따라서 JPQL은 데이터를 조회하는 기능만 하고 연관된 엔티티를 함께 조회하는 기능은 엔티티 그래프를 사용하면 된다.
엔티티 그래프 기능은 엔티티 조회시점에 연관된 엔티티들을 함께 조회하는 기능이다.

엔티티 그래프 예제 모델

Named 엔티티 그래프

  • 엔티티 클래스에 어노테이션을 사용하여 정적으로 정의.

주문(Order)을 조회할 때 연관된 회원(Member)도 함께 조회하는 엔티티 그래프를 사용하는 예제.

@NamedEntityGraph(
    name = "Order.withMember",  // 엔티티 그래프의 이름
    attributeNodes = @NamedAttributeNode("member")  // 연관된 Member 엔티티를 명시
)
@Entity
@Table(name = "ORDERS")
public class Order {

    @Id @GeneratedValue
    @Column(name = "ORDER_ID")
    private Long id;
    
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "MEMBER_ID")
    private Member member;	//주문 회원

    // other fields and methods...
}

@NamedEntityGraph 어노테이션을 이용해서 Named 엔티티 그래프를 정의한다.

  • name : 엔티티 그래프의 이름을 정의
  • attributeNodes : 함께 조회한 속성 선택. @NamedAttributeNode 사용

member가 지연로딩으로 설정되어 있어도 엔티티 그래프로 인해서 Order를 조회할 때 연관된 member도 함께 조회할 수 있다.

em.find() 에서 엔티티 그래프 사용

//em.find() 에서 엔티티 그래프 사용
EntityGraph graph = em.getEntityGraph("Order.withMember");

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

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

em.getEntityGraph()로 정의한 엔티티 그래프를 찾아온다. 엔티티 그래프는 JPA의 힌트 기능을 이용해서 동작하므로 javax.persistence.fetchgraph를 사용하고 힌트의 값으로 찾아온 엔티티 그래프를 사용하면 된다.

//실행된 SQL
select o.*, m.*
from ORDERS o
inner join Member m
	on o.MEMBER_ID=m.MEMBER_ID
where
	o.ORDER_ID=?

subgraph

subgraph는 연관된 엔티티의 서브 그래프를 명시적으로 정의해서 사용하는 방법이다.
예를 들어 Order -> OrderItem -> Item 까지 조회하는 경우라고 하자. OrderItem은 Order가 관리하는 필드가 맞지만 Item은 OrderItem이 관리하는 필드이다. 이 경우 subgraph를 사용한다.

@NamedEntityGraph(name = "Order.withAll", attributeNodes = {
	@NamedAttributeNode("member"),
    @NamedAttributeNode(value = "orderItems", subgraph = "orderItems")},
    subgraphs = @NamedSubgraph(name = "orderItems", attributesNodes = {
    	@NamedAttributeNode("item")})
@Entity
@Table (name = "ORDERS")
public class Order {
...
}

여기서 item은 Order의 객체 그래프가 아니므로 subgraphs 속성으로 정의했다. @NamedSubgraph를 사용해서 서브 그래프를 정의하고 orderItems라는 이름의 서브 그래프가 item을 조회하도록 정의했다.

사용코드

Map hints = new HashMap();
hints.put("javax.persistence.fetchgraph", em.getEntityGraph("Order.withAll"));

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

실행 SQL

select o.*, m.*, oi.*, i.*
from ORDERS o
inner join Member m
	on o.Member_ID=m.MEMBER_ID
left outer join
	ORDER_ITEM oi
    	on o.ORDER_ID = oi.ORDER_ID
left outer join
	ITEM i
    	on oi.ITEM_ID=i.ITEM_ID
where
	o.ORDER_ID=?

JPQL에서 엔티티 그래프 사용

em.find()에서처럼 힌트를 추가하면 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();

동적 엔티티 그래프

엔티티 그래프를 동적으로 구성하려면 createEntityGraph() 메서드를 사용하면 된다.

public <T> EntityGraph<T> createEntityGraph(Class<T> rootType);

// 예제
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);

해당 메서드로 동적 그래프(graph)를 만들고 graph.addAttributeNodes("member")를 사용해서 Order.member 속성을 엔티티 그래프에 포함시켰다.
또한 graph.addSubgraph("orderItems")를 통하여 subgraph 기능도 동적으로 구성하였다.

엔티티 그래프 정리

ROOT에서 시작

엔티티 그래프를 시작하려면 조회하려는 엔티티의 ROOT부터 시작해야 한다. 즉, Order에서 Member의 엔티티의 그래프를 구성하려면 Order 엔티티부터 조회해야 한다.

이미 로딩된 엔티티

이미 영속성 컨텍스트에 엔티티가 로딩되어 있으면 엔티티 그래프가 적용되지 않는다. 아직 초기화 되지 않은 프록시에는 엔티티 그래프가 적용된다.

Orer order1 = em.find(Order.class, orderId); // 이미 조회하여 영속성 컨텍스트에 저장됨
hints.put("javax.persistence.fetchgraph", em.getEntityGraph("Order.withMember"));
Order order2 = em.find(Order.class, orderId, hints);

이 경우 조회된 order2는 orderId로 조회가 이루어져 있어 엔티티 그래프가 적용되지 않고 처음 조회한 order2와 같은 인스턴스를 반환한다.

em.clear(); // 영속성 컨텍스트 초기화
Order order2 = em.find(Order.class, orderId, hints); // 엔티티 그래프 적용 가능

해결방법으로 위처럼 영속성 컨텍스트를 초기화하고 엔티티 그래프 적용하는 방법이 있다.

fetchgraph, loadgraph의 차이

javax.persistence.fetchgraph 힌트를 사용해서 엔티티 그래프를 조회하는 것은 엔티티 그래프에 선택한 속성만 함께 조회한다.
반면에 javax.persistence.loadgraph 속성은 엔티티 그래프에 선택한 속성뿐만 아니라 글로벌 fetch 모드가 FetchType.EAGER로 설정된 연관관계도 포함해서 함께 조회한다.
즉, fetchgraph는 fetch 모드가 EAGER이여도 무시하고 가져오라고 설정한 속성만 가져오는데 반해 loadgraph는 가져오라고 설정한 속성뿐만 아니라 fetch모드가 EAGER인 속성도 가져와 더 많은 범위의 데이터를 반환한다.

참고 : 하이버네이트 4.3.10.Final 버전에서는 loadgraph 기능이 em.find()를 사용할 때 정상동작하지만 JPQL을 사용할 때는 정상동작하지 않고 fetchgraph와 같은 방식으로 동작한다.

참고의 참고 : 이러한 문제는 이후 버전에서 수정되었으며, 최신 Hibernate 버전에서는 loadgraph 힌트가 JPQL 쿼리에서도 의도한 대로 동작합니다. 그러나 여전히 특정 상황에서 엔티티 그래프와 관련된 문제가 발생할 수 있으므로, 사용 중인 Hibernate 버전의 릴리스 노트를 확인하고, 최신 버전을 사용하는 것이 좋습니다.
예를 들어, Hibernate 6.6.x 버전에서 엔티티 그래프 사용 시 오류가 발생한다는 보고가 있습니다.

참조 : [자바 ORM 표준 JPA 프로그래밍]

profile
코드 위에서 춤추고 싶어요

0개의 댓글