elevne's Study Note
JPA 컬렉션과 부가기능 본문
하이버네이트는 엔티티를 영속 상태로 만들 때 컬렉션 필드를 하이버네이트에서 준비한 컬렉션으로 감싸서 사용한다. 예를 들어, ArrayList 타입으로 Member 리스트를 가지고 있는 Team 엔티티를 em.persist() 메소드를 통해 영속 상태로 만들고 나서 다시 Member 리스트를 확인해보면 org.hibernate.collection.internal.PersistentBag 타입으로 바뀌어있는 것을 확인할 수 있다. 하이버네이트는 컬렉션을 효율적으로 관리하기 위해 엔티티를 영속 상태로 만들 때 원본 컬렉션을 감싸고 있는 내장 컬렉션을 생성해서 이 컬렉션을 사용하도록 참조를 변경한다. 하이버네이트는 이런 특징 때문에 컬렉션을 사용할 때 아래와 같이 즉시 초기화해서 사용하는 것을 권장한다.
@Entity
public class Team {
@Id @Column @GeneratedValue private Long id;
// org.hibernate.collection.internal.PersistentBag
@OneToMany
Collection<Member> 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(name = "POSITION")
List<Member> orderColumnList = new ArrayList<>();
}
Collection Interface | 내장 컬렉션 | 중복 허용 | 순서 보관 |
Collection, List | PersistentBag | O | X |
Set | PersistentSet | X | X |
List + @OrderColumn | PersistentList | O | O |
Collection, List 는 중복을 허용하는 컬렉션이며 PersistentBag 를 래퍼 컬렉션으로 사용한다. Collection, List 는 엔티티를 추가할 때 중복된 엔티티가 있는지 비교하지 않고 단순히 저장만 하기에 엔티티를 추가해도 지연 로딩된 컬렉션을 초기화하지 않는다. Set 은 이와는 다르게 중복을 허용하지 않는다. 엔티티를 추가할 때마다 엔티티가 있는지 비교하고, 지연로딩된 컬렉션을 초기화하게 된다.
@OrderColumn 을 추가하면 순서가 있는 특수한 컬렉션으로 인식한다. (데이터베이스에 순서 값을 저장해서 조회할 때 사용한다는 의미) 위 코드에서는 name 속성에 POSITION 이라는 값을 넘긴다. JPA 는 List 의 위치 값을 테이블의 POSITION 컬럼에 보관하는 것이다. (실제 POSITION 컬럼은 Team 테이블이 아니라 Member 테이블로 들어간다. 그래서 INSERT 문에 더해 UPDATE 하는 SQL 이 추가로 발생한다)
그런데 실제로 @OrderColumn 을 사용해서 List 의 위치 값을 보관하는 것은 편리하지 않다고 한다. 예를 들면, List 원소 중 하나를 삭제하면 다른 리스트 원소들의 위치 값을 상당 수 업데이트 해야하는 문제가 발생한다. 또, 중간에 POSITION 값이 없으면 조회한 List 에는 null 이 보관되어 NullPointerException 이 발생할 수 있다. (중간 원소를 강제 삭제하고 다른 댓글들의 POSITION 값을 수정하지 않은 경우) @OrderColumn 을 사용하는 것보다는 직접 POSITION 값을 관리하거나 @OrderBy 를 사용하는 편이 좋다고 한다.
@OrderBy 는 모든 컬렉션에 적용할 수 있으며, 데이터베이스의 ORDER BY 절을 이용하여 컬렉션을 정렬한다. @OrderBy 는 JPQL 의 order by 처럼 엔티티의 필드를 대상으로 한다.
@OneToMany
@OrderBy("id DESC")
List<Member> orderByList = new ArrayList<>();
@Converter 을 사용하여 데이터를 변환하여 데이터베이스에 저장할 수 있다. 예를 들어, 회원의 VIP 여부를 자바의 boolean 타입으로 표시할 때, JPA 를 사용하면 boolean 타입은 (방언에 따라 다르지만) 1 혹은 0 으로 저장된다. 그런데 데이터베이스에 대신 문자 Y/N 으로 저장하고 싶다면 Converter 을 사용할 수 있다.
public class Member {
...
@Convert(converter = BooleanToYNConverter.class)
private boolean vip;
...
@Converter
public class BooleanToYNConverter implements AttributeConverter<Boolean, String> {
@Override
public String convertToDatabaseColumn(Boolean aBoolean) {
return (aBoolean != null && aBoolean) ? "Y" : "N";
}
@Override
public Boolean convertToEntityAttribute(String s) {
return "Y".equals(s);
}
}
@Convert 애노테이션을 컬럼에 적용한다. 컨버터를 적용해줘야 하는데, 컨버터 클래스는 @Converter 을 사용하여 만든다. 또, AttributeConverter 이라는 인터페이스를 구현하게끔 한다. converToDatabaseColumn 은 엔티티 데이터를 데이터베이스 컬럼에 저장할 데이터로 변환, convertToEntityAttribute 는 데이터베이스에서 조회한 컬럼 데이터를 엔티티의 데이터로 변환한다. (컨버터 클래스에서 autoApply=true 속성으로 글로벌 설정을 해줄 수도 있다. 만약 이와 같이 설정하면 모든 boolean 타입에 대해 해당 컨버터가 자동 적용된다)
그 다음으로는 리스너에 대해 알아본다. 모든 엔티티를 대상으로, 언제 사용자가 삭제를 요청했는지 모두 로그를 남겨야하는 요구사항이 있다고 가정해본다. 이 때 애플리케이션의 삭제 로직을 하나씩 찾아 로그를 남기는 것은 비효율적이다. JPA 리스너 기능을 이용하여 엔티티의 생명주기에 따른 이벤트를 처리할 수 있다.
종류 | 설명 |
PostLoad | 엔티티가 영속성 컨텍스트에 조회된 직후 또는 refresh 를 호출한 후 (2차캐시에 저장되어 있어도 호출된다) 수행된다. |
PrePersist | persist() 메소드를 호출해서 엔티티를 영속성 컨텍스트에 관리하기 직전에 호출된다. 식별자 생성전략을 사용한 경우 엔티티에 식별자는 아직 존재하지 않는다. 새로운 인스턴스를 merge 할 때에도 수행된다. |
PreUpdate | flush 나 commit 을 호출해서 엔티티를 데이터베이스에 수정하기 직전에 호출한다 |
PreRemove | remove() 메소드를 호출해서 엔티티를 영속성 컨텍스트에서 삭제하기 직전에 호출된다. 또한 삭제 명령어로 영속성 전이가 일어날 때도 호출된다. orphanRemoval 에 대해서는 flush 나 commit 시에 호출된다. |
PostPersist | flush 나 commit 을 호출해서 엔티티를 데이터베이스에 저장한 직후에 호출된다. 식별자가 항상 존재한다. 참고로 식별자 생성 전략이 IDENTITY 면 식별자를 생성하기 위해 persist() 를 호출하면서 데이터베이스에 해당 엔티티를 저장하므로 이 때는 persist() 를 호출한 직후에 바로 PostPersist 가 호출된다. |
PostUpdate | flush 나 commit 을 호출해서 엔티티를 데이터베이스에 수정한 직후에 호출된다. |
PostRemove | flush 나 commit 을 호출해서 엔티티를 데이터베이스에 삭제한 직후에 호출된다. |
이러한 이벤트는 엔티티에서 직접 받거나 별도의 리스너를 등록해서 받을 수 있다. 직접 적용할 때에는 각각의 이벤트에 대한 애노테이션 (e.g., @PostPersist, @PostLoad) 을 사용하여 엔티티에 메소드를 등록한다.
별도의 리스너를 등록할 때에는 아래와 같이 @EntityListeners 애노테이션을 사용한다.
@EntityListeners(MemberListener.class)
public class Member {
...
public class MemberListener {
@PrePersist
private void prePersist(Object obj) {
System.out.println("MemberListener.prePersist obj = [" + obj + "]");
}
@PostPersist
private void postPersist(Object obj) {
System.out.println("MemberListener.postPersist obj = [" + obj + "]");
}
}
만약 모든 엔티티의 이벤트를 처리하려면 META-INF/orm.xml 에 기본 리스너로 등록하면 된다.
<?xml version="1.0" encoding="UTF-8" ?>
<entity-mappings xmlns="http://java.sun.com/xml/ns/persistence/orm" version="1.0">
<persistence-unit-metadata>
<persistence-unit-defaults>
<entity-listeners>
<entity-listener class="com.example.jpastudy.domain.MemberListener"></entity-listener>
</entity-listeners>
</persistence-unit-defaults>
</persistence-unit-metadata>
</entity-mappings>
추가로, @ExcludeDefaultListeners 로 기본 리스너를 무시하게 할 수 있으며 @ExcludeSuperclassListeners 로 상위 클래스 이벤트 리스너를 무시할 수 있다. 이러한 이벤트를 잘 활용하면 엔티티에 공통으로 적용되는 등록일자, 수정일자 처리와 해당 엔티티를 누가 등록하고 수정했는지에 대한 기록을 리스너 하나로 처리할 수 있다.
이전에 fetch 를 사용하며, 함께 조회할 엔티티에 따라 JPQL 이 추가되는 문제가 발생한다는 것을 알 수 있었다. 엔티티 그래프 기능을 사용하여 엔티티를 조회하는 시점에 함께 조회할 연관된 엔티티를 선택할 수 있다. JPQL 은 데이터를 조회하는 기능만 수행하고, 연관된 엔티티를 함께 조회하는 기능은 엔티티 그래프를 사용하면 된다.
@Entity
public class Class {
@Id @GeneratedValue
private Long id;
private int classNum;
@OneToMany
List<Member> member = new ArrayList<>();
@OneToMany(mappedBy = "schoolClass", cascade = CascadeType.ALL)
private List<Item> items = new ArrayList<>();
}
@Entity
@NamedQuery(
name = "Member.findByUsername",
query = "SELECT m FROM Member m WHERE m.name = :name"
)
@EntityListeners(MemberListener.class)
@NamedEntityGraph(name = "Member.withClass", attributeNodes = {@NamedAttributeNode("schoolClass")})
public class Member {
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "MEMBER_ID")
private Class schoolClass;
...
Named Entity Graph 는 @NamedEntityGraph 로 정의한다. name 속성에 엔티티 그래프의 이름을 정의, attributeNodes 에 함께 조회할 속성을 @NamedAttributeNode 를 사용하여 선택한다. 아래와 같이 사용한다.
public void entityGraph() {
Long memberId = 1L;
EntityGraph<?> graph = em.getEntityGraph("Member.withClass");
Map<String, Object> hints = new HashMap<>();
hints.put("javax.persistence.fetchgraph", graph);
Member member = em.find(Member.class, memberId, hints);
}
Named Entity Graph 를 사용하려면 정의한 엔티티 그래프를 em.getEntityGraph 를 통해 찾아온다. 엔티티 그래프는 JPA 의 힌트 기능을 사용해서 동작하는데, 힌트의 키로 javax.persistence.fetchgraph 를 사용하고 힌트의 값으로 찾아온 엔티티 그래프를 사용하면 된다.
만약 Class 필드로 있는 Item 엔티티까지 한 번에 조회하고 싶다면, Subgraph 를 사용하면 된다.
@NamedEntityGraph(name = "Member.withAll", attributeNodes = {
@NamedAttributeNode(value = "schoolClass", subgraph = "schoolClass")
},
subgraphs = @NamedSubgraph(name = "schoolClass", attributeNodes = {
@NamedAttributeNode("items")
})
)
public class Member {
@Entity
public class Class {
@Id @GeneratedValue
private Long id;
private int classNum;
@OneToMany
List<Member> member = new ArrayList<>();
@OneToMany(mappedBy = "schoolClass", cascade = CascadeType.ALL)
private List<Item> items = new ArrayList<>();
}
@Entity
public class Item {
@Id @GeneratedValue @Column(name = "ITEM_ID")
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "ITEM_ID")
private Class schoolClass;
}
Class -> Item 은 Member 의 객체 그래프가 아니므로 subgraph 속성으로 정의해야 하는 것이다. 조회방법은 이전과 동일하다.
엔티티 그래프를 아래와 같이 동적으로 구성할 수도 있다. createEntityGraph(), addSubgraph(), addAttributeNodes() 메소드가 사용된다.
public void dynamicEntityGraph() {
EntityGraph<Member> graph = em.createEntityGraph(Member.class);
graph.addAttributeNodes("schoolClass");
Subgraph<Class> subgraph = graph.addSubgraph("subgraph");
subgraph.addAttributeNodes("item");
Map<String, Object> hints = new HashMap<>();
hints.put("javax.persistence.fetchgraph", graph);
Member member = em.find(Member.class, 1L, hints);
}
Reference:
자바 ORM 표준 JPA 프로그래밍
'Backend > JPA 프로그래밍' 카테고리의 다른 글
JPA 트랜잭션과 락, 2차 캐시 (0) | 2023.07.29 |
---|---|
JPA 고급주제와 성능 최적화 (0) | 2023.07.27 |
JPA N+1 & OSIV (0) | 2023.07.20 |
Spring Data JPA (0) | 2023.07.19 |
Spring Maven 설정 (0) | 2023.07.17 |