
JPA는 Java에서 기본적으로 제공하는 Collection, List, Set, Map 컬렉션을 지원하여 다음과 같은 상황에 사용할 수 있다.
Java의 컬렉션 인터페이스의 특징은 다음과 같다.
Collection: 최상위 컬렉션으로 하이버네이트는 중복을 허용하고 순서를 보장하지 않는다고 가정한다.Set: 중복을 허용하지 않고 순서를 보장하지 않는다.List: 중복을 허용하고 순서를 보장한다.Map: Key, Value 구조의 특수한 컬렉션이다.하이버네이트는 엔터티를 영속화할 때 컬렉션 타입의 필드를 하이버네이트에서 지원하는 컬렉션 타입으로 래핑하여 사용한다.
예제 14.1. JPA 컬렉션 사용
@Entity
@Getter
@Setter
public class Team {
@Id
@GeneratedValue
private Long id;
@OneToMany
@JoinColumn
private Collection<Member> members = new ArrayList<>();
}
영속화 시 래핑 클래스 확인
@Test
@Transactional
void persistTeam() {
Team team = new Team();
assertInstanceOf(ArrayList.class, team.getMembers());
em.persist(team);
assertInstanceOf(org.hibernate.collection.spi.PersistentBag.class, team.getMembers());
}
영속화 전 java.util.ArrayList 타입이었던 members 필드가 영속화 이후에는 하이버네이트의 내장 컬렉션 타입인 org.hibernate.collection.spi.PersistentBag 으로 래핑되었다. 하이버네이트가 제공하는 내장 컬렉션은 래퍼 컬렉션이라고도 부른다.
이러한 특징 때문에 하이버네이트는 컬렉션 타입의 필드 사용 시 Collection<Member> members = new ArrayList<>(); 처럼 즉시 초기화해서 사용하는 것을 권장한다.
예제 14.2. 인터페이스와 컬렉션 래퍼
@Entity
@Getter
public class TestEntity {
@Id
@GeneratedValue
private Long id;
@OneToMany
Collection<Member> collection = new ArrayList<>();
@OneToMany
List<Member> list = new ArrayList<>();
@OneToMany
Set<Member> set = new HashSet<>();
@OneToMany
@OrderColumn
List<Member> orderColumnList = new ArrayList<>();
}
@Test
@Transactional
void persistAndWrapped() {
TestEntity entity = new TestEntity();
assertInstanceOf(ArrayList.class, entity.getCollection());
assertInstanceOf(ArrayList.class, entity.getList());
assertInstanceOf(HashSet.class, entity.getSet());
assertInstanceOf(ArrayList.class, entity.getOrderColumnList());
em.persist(entity);
assertInstanceOf(PersistentBag.class, entity.getCollection());
assertInstanceOf(PersistentBag.class, entity.getList());
assertInstanceOf(PersistentSet.class, entity.getSet());
assertInstanceOf(PersistentList.class, entity.getOrderColumnList());
}
컬렉션 인터페이스에 따라 사용되는 래퍼 컬렉션의 특징은 다음과 같다.
| 컬렉션 인터페이스 | 내장 컬렉션 | 중복 허용 | 순서 보존 |
|---|---|---|---|
| Collection, List | PersistentBag | O | X |
| Set | PersistentSet | X | X |
| List + @OrderColumn | PersistentList | O | O |
Collection, List 인터페이스는 중복을 허용하며 PersistentBag을 래퍼 컬렉션으로 사용한다. 이 인터페이스 타입의 객체로는 ArrayList를 사용한다.
예제 14.3. Collection, List 예제
@Entity
public class Parent {
@Id
@GeneratedValue
private Long id;
@OneToMany
@JoinColumn
private Collection<Child> collection = new ArrayList<>();
@OneToMany
@JoinColumn
private List<Child> list = new ArrayList<>();
...
}
중복을 허용하는 인터페이스이므로 add() 메서드는 중복 검사 없이 항상 true를 반환하고 엔터티를 탐색하거나 삭제할 때는 equals() 메서드를 사용한다.
Collection, List는 엔터티 추가 시 중복된 엔터티가 있는지 비교하지 않고 단순 저장만 수행한다. 그러므로 엔터티 추가 시에도 지연 로딩된 컬렉션을 초기화하지 않는다.
Set 인터페이스는 중복을 허용하지 않으며 PersistentSet을 래퍼 컬렉션으로 사용한다. 이 인터페이스 타입의 객체로는 HashSet을 사용한다.
예제 14.4. Set 예제
@Entity
public class Parent {
@OneToMany
@JoinColumn
private Set<Child> set = new HashSet<>();
...
}
Set은 중복을 허용하지 않으므로 엔터티 추가 시마다 add() 메서드는 equals() 메서드를 호출해 동등한 객체가 있는지 확인한다. 동등한 객체가 없을 시 true, 있을 시 false를 반환하며 HashSet의 경우 해시(Hash) 알고리즘에 기반해 있으므로 hashcode() 메서드도 함께 사용하여 동등성을 판단한다.
Set은 엔터티 추가 시 중복된 엔터티가 있는지 비교해야 한다. 그러므로 엔터티 추가 시 지연 로딩된 컬렉션을 초기화한다.
List 인터페이스 타입의 필드를 @OrderColumn 애너테이션으로 지정하면 순서가 있는 특수 컬렉션으로 인식한다. 순서가 있다는 것은 데이터베이스에 순서 값을 저장하여 조회 시 사용한다는 의미이다. 하이버네이트는 PersistentList을 래퍼 컬렉션으로 사용한다.
예제 14.5. List + @OrderColumn 예제
@Entity
public class Board {
@Id
@GeneratedValue
private Long id;
private String title;
private String content;
@OneToMany(mappedBy = "board")
@OrderColumn(name = "POSITION")
private List<Comment> comments = new ArrayList<>();
...
}
@Entity
public class Comment {
@Id
@GeneratedValue
private Long id;
private String comment;
@ManyToOne
@JoinColumn(name = "BOARD_ID")
private Board board;
...
}
Board.comments 필드 타입을 List 인터페이스로 설정하고 @OrderColumn 애너테이션으로 지정했다. 그러므로 이 필드는 순서가 있는 컬렉션으로 인식된다. Java의 List 컬렉션은 내부적으로 인덱스를 위치 값으로 갖고 있기 때문에 이를 순서에 대한 근거로 활용 가능하다.

순서가 있는 컬렉션은 데이터베이스에 순서 값도 함께 관리한다. 이 경우 @OrderColumn 애너테이션의 name 속성에 전달한 POSITION이라는 이름이 붙여진 컬럼이 데이터베이스의 Many-side 테이블인 COMMENT에 매핑된다.
예제 14.6. 사용 코드
Board board = new Board("제목1", "내용1");
em.persist(board);
Comment comment1 = new Comment("댓글1");
comment1.setBoard(board);
board.getComments().add(comment1); // POSITION = 0
em.persist(comment1);
Comment comment2 = new Comment("댓글2");
comment2.setBoard(board);
board.getComments().add(comment2); // POSITION = 1
em.persist(comment2);
Comment comment3 = new Comment("댓글3");
comment3.setBoard(board);
board.getComments().add(comment3); // POSITION = 2
em.persist(comment3);
Comment comment4 = new Comment("댓글 4");
comment4.setBoard(board);
board.getComments().add(comment4); // POSITION = 3
em.persist(comment4);
실무에서는 @OrderColumn 애너테이션을 사용한 순서 매핑 방식보다는 개발자가 직접 POSITION 컬럼 값을 관리하며 @OrderBy 애너테이션을 사용하는 것이 권장된다.
Board)에 매핑되므로 Many-side의 엔터티(Comment)는 대상 컬럼(POSITION)의 값을 알 수 없어 삽입 시점에는 대상 컬럼에 값이 저장되지 않고 추가적인 UPDATE 쿼리 수행이 필요하다.List 객체에 변경 사항이 발생할 시 연관된 엔터티들에 대한 회의 잦은 위치 값 갱신이 필요하다.List 객체의 해당 인덱스에 null이 저장되기 때문에 NullPointerException에 취약해진다.@OrderBy 애너테이션으로 지정한 컬렉션 필드는 데이터를 데이터베이스의 ORDER BY 절을 활용하여 정렬한다. 따라서 순서용 컬럼을 매핑하지 않아도 된다. 그리고 이 애너테이션은 모든 컬렉션에 사용할 수 있다.
예제 14.7. @OrderBy 예제
@Entity
public class Team {
@Id
@GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "team")
@OrderBy("username DESC, id ASC")
private Set<Member> members = new HashSet<>();
...
}
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
@Column(name = "member_name")
private String username;
private Integer age;
@ManyToOne
private Team team;
}
@OrderBy 애너테이션의 값으로는 JPQL의 ORDER BY 절 조건을 전달하면 된다. 참고로 하이버네이트는 Set 컬렉션을 @OrderBy 애너테이션으로 지정할 시 내부적으로 HashSet 대신 LinkedHashSet을 사용한다.
컨버터(converter)를 사용하면 엔터티의 데이터를 변환하여 데이터베이스에 저장할 수 있다. 예를 들어 애플리케이션 레벨에서는 회원의 VIP 여부를 Java의 boolean 타입으로 관리하고자 한다고 가정하자. 이는 데이터베이스에 0 또는 1의 숫자로 저장된다. 그러나 데이터베이스에 숫자 대신 문자 Y 또는 N으로 저장하고 싶다면 컨버터를 활용할 수 있다.
예제 14.8. 매핑할 테이블
CREATE TABLE MEMBER (
ID VARCHAR(255) NOT NULL,
USERNAME VARCHAR(255),
VIP VARCHAR(1) NOT NULL,
PRIMARY KEY (ID)
);
예제 14.9. 회원 엔터티
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
@Column(name = "member_name")
private String username;
private Integer age;
@Convert(converter = BooleanToYNConverter.class)
private Boolean vip;
@ManyToOne
private Team team;
}
예제 14.10. Boolean을 YN으로 변환해주는 컨버터
@Converter
public class BooleanToYNConverter implements AttributeConverter<Boolean, String> {
@Override
public String convertToDatabaseColumn(Boolean attribute) {
return (attribute != null && attribute) ? "Y" : "N";
}
@Override
public Boolean convertToEntityAttribute(String dbData) {
return "Y".equals(dbData);
}
}
컨버터 클래스는 @Converter 애너테이션으로 지정하고 AttributeConverter 인터페이스를 구현하면서 제네릭을 현재 타입과 반환할 타입으로 지정해야 한다. 위 예제에서는 <Boolean, String> 제네릭으로 Boolean 타입과 String 타입 간의 변환에 대한 계약을 구현한다.
예제 14.11. AttributeConverter
public interface AttributeConverter<X, Y> {
Y convertToDatabaseColumn(X var1);
X convertToEntityAttribute(Y var1);
}
convertToDatabaseColumn(): 엔터티의 데이터를 데이터베이스 컬럼에 저장할 데이터로 변환한다.convertToEntityAttribute(): 데이터베이스에서 조회한 컬럼 데이터를 엔터티의 데이터로 변환한다.컨버터는 다음과 같이 클래스 레벨에도 설정할 수 있다. 이때는 attributeName 속성을 사용해 어떤 필드에 컨버터를 적용할지 명시해야 한다.
예제 14.12. 컨버터 클래스 레벨에 설정하기
@Entity
@Convert(converter = BooleanToYNConverter.class, attributeName = "vip")
public class Member {
...
}
모든 Boolean 타입에 대해 컨버터를 적용하고자 한다면 컨버터 클래스의 정의부에서 Converter 애너테이션의 autoApply = true 옵션을 적용하면 된다.
예제 14.13. 컨버터 글로벌 설정
@Converter(autoApply = true)
public class BooleanToYNConverter implements AttributeConverter<Boolean, String> {
...
}
예제 14.14. 컨버터 글로벌 설정 결과
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
@Column(name = "member_name")
private String username;
private Integer age;
private Boolean vip;
@ManyToOne
private Team team;
}
@Convert 애너테이션의 속성은 다음과 같다.
| 속성 | 기능 | 기본 값 |
|---|---|---|
| converter | 사용할 컨버터를 지정한다. | |
| attributeName | 컨버터를 적용할 필드를 지정한다. | |
| disableConversion | 글로벌 컨버터나 상속받은 컨버터를 사용하지 않는다. | false |
JPA 리스너 기능을 사용하면 엔터티의 생명주기에 따른 이벤트를 처리할 수 있다.

refresh 호출 후(2차 캐시에 저장되어 있어도 호출)에 호출된다.persist() 또는 merge() 메서드 호출 후 엔터티를 영속성 컨텍스트에 관리하기 직전에 호출된다. 식별자 생성 전략을 사용한 경우 엔터티에 식별자는 아직 존재하지 않는다.flush 또는 commit 호출 후 엔터티의 변경 사항을 데이터베이스에 반영하기 직전에 호출된다.remove() 메서드 호출 후 엔터티를 영속성 컨텍스트에서 삭제하기 직전에 호출된다. 또한 삭제 명령어로 인해 영속성 전이가 발생할 때도 호출된다. orphanRemoval에 대해서는 flush, commit 시 호출된다.flush 또는 commit 호출 후 엔터티의 변경 사항을 데이터베이스에 반영한 직후에 호출된다. 엔터티에 식별자가 존재하는 것이 보장된다.flush 또는 commit 호출 후 엔터티의 변경 사항이 데이터베이스에 반영된 직후에 호출된다.flush 또는 commit 호출 후 엔터티를 데이터베이스에서 삭제한 직후에 호출된다.예제 14.15. 엔터티에 직접 적용
@Entity
public class Duck {
@Id
@GeneratedValue
private Long id;
private String name;
@PrePersist
public void prePersist() {
System.out.println("Duck.prePersist id=" + id);
}
@PostPersist
public void postPersist() {
System.out.println("Duck.postPersist id=" + id);
}
@PostLoad
public void postLoad() {
System.out.println("Duck.postLoad");
}
@PreRemove
public void preRemove() {
System.out.println("Duck.preRemove");
}
@PostRemove
public void postRemove() {
System.out.println("Duck.postRemove");
}
...
}
예제 14.16. 별도의 리스너 사용
@Entity
@EntityListeners(DuckListener.class)
public class Duck {
@Id
@GeneratedValue
private Long id;
private String name;
...
}
public class DuckListener {
@PrePersist
private void prePersist(Object obj) {
System.out.println("DuckListener.prePersist obj = [" + obj + "]");
}
@PostPersist
private void postPersist(Object obj) {
System.out.println("DuckListener.postPersist obj = [" + obj + "]");
}
}
리스너는 대상 엔터티를 매개변수로 전달받을 수 있으며 반환 타입은 void로 설정해야 한다.
기본 리스너는 모든 엔터티에 대해 적용되는 리스너로, META-INF/orm.xml에 등록한다.
예제 14.17. 기본 리스너 등록
<?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="jpabook.ch14collections.entity.listener.DuckListener"></entity-listener>
</entity-listeners>
</persistence-unit-defaults>
</persistence-unit-metadata>
</entity-mappings>
여러 리스너를 등록했을 때 이벤트 호출 순서는 다음과 같다.
보다 세밀한 설정을 위한 애너테이션은 다음과 같다.
jakarta.persistence.ExcludeDefaultListeners: 기본 리스너 무시jakarta.persistence.ExcludeSuperclassListeners: 상위 클래스 이벤트 리스너 무시예제 14.18. 기타 애너테이션 적용 코드
@Entity
@EntityListeners(DuckListener.class)
@ExcludeDefaultListeners
@ExcludeSuperclassListeners
public class Duck extends BaseEntity {
...
}
엔터티 조회 시 연관된 엔터티들을 함께 조회하려면 글로벌 페치 전략(fetch)을 즉시 로딩(FetchType.EAGER)으로 설정하거나 JPQL의 페치 조인(JOIN FETCH)을 사용한다. 글로벌 페치 전략은 애플리케이션 전역에 영향을 주며 변경할 수 없기 때문에 기본적으로 지연 로딩 전략(FetchType.LAZY)으로 설정하고 필요할 때만 페치 조인을 사용한다.
하지만 페치 조인 사용 시엔 같은 엔터티에서 함께 조회되는 연관된 엔터티에 따라 페치 조인을 위한 중복된 JPQL 작성이 잦아진다는 문제점이 있다. 이는 JPQL이 데이터 조회라는 본래적인 기능에 연관 엔터티 조회라는 추가적인 기능을 제공하기 때문이다.
JPA 2.1에 추가된 엔터티 그래프 기능을 사용하면 엔터티 조회 시점에 함께 조회할 연관된 엔터티를 선택할 수 있다. 그러므로 JPQL은 데이터 조회 기능만 수행하고 페치 조인에 해당하는 기능은 엔터티 그래프로 수행할 수 있다.
엔터티 그래프 기능은 엔터티 조회 시점에 연관된 엔터티들을 함께 조회하는 기능이다. 이는 정적으로 정의하는 명명된(Named) 엔터티 그래프와 동적으로 정의하는 엔터티 그래프로 구분된다.

예제에서는 엔터티 간의 관계가 위 다이어그램과 같이 설계되어 있다고 가정한다.
다음은 주문 조회 시 연관된 회원도 함께 조회하는 예제이다.
예제 14.19. 엔터티 그래프 예제
@NamedEntityGraph(name = "Order.withMember", attributeNodes = {
@NamedAttributeNode("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;
...
}
Named 엔터티 그래프는 @NamedEntityGraph 애너테이션으로 정의한다.
name: 엔터티 그래프의 이름attributeNodes: 해당 엔터티 그래프 사용 시 함께 조회할 속성(필드)들의 목록, @NamedAttributeNode 애너테이션으로 나열정의한 Order.withMember 엔터티 그래프를 사용하면 주문 엔터티만 조회해도 연관된 회원 엔터티까지 함께 조회하게 된다.
예제 14.20. 엔터티 그래프 사용
EntityGraph graph = em.getEntityGraph("Order.withMember");
Map hints = new HashMap();
hints.put("jakarta.persistence.fetchgraph", graph);
Order order = em.find(Order.class, 1L, hints);
Named 엔터티 그래프를 사용하려면 EntityManager.getEntityGraph() 메서드를 통해 지정한 이름으로 엔터티 그래프를 찾아오고 JPA의 힌트 기능을 사용하여 키로 jakarta.persistence.fetchgraph, 값으로 찾은 엔터티 그래프를 등록하면 된다.
예제 14.21. 실행된 SQL
Hibernate:
select
o1_0.order_id,
o1_0.member_id,
m1_0.id,
m1_0.age,
m1_0.team_id,
m1_0.member_name,
m1_0.vip
from
orders o1_0
join
member m1_0
on m1_0.id=o1_0.member_id
where
o1_0.order_id=?
Order > OrderItem > Item 순서의 조회는 단일 엔터티 그래프만으로는 불가능하다. 이때는 subgraph를 사용하면 된다.
예제 14.22. subgraph
@NamedEntityGraphs({
@NamedEntityGraph(name = "Order.withMember", attributeNodes = {
@NamedAttributeNode("member")
}),
@NamedEntityGraph(
name = "Order.withAll",
attributeNodes = {
@NamedAttributeNode("member"),
@NamedAttributeNode(value = "orderItems", subgraph = "orderItems")
},
subgraphs = {@NamedSubgraph(
name = "orderItems",
attributeNodes = {
@NamedAttributeNode("item")
})
}
)
})
@Entity
@Table(name = "ORDERS")
public class Order {
...
}
@Entity
@Table(name = "ORDER_ITEM")
public class OrderItem {
@Id
@GeneratedValue
@Column(name = "ORDER_ITEM_ID")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "ITEM_ID")
private Item item;
...
}
서브 그래프 사용
Map hints = new HashMap();
hints.put("jakarta.persistence.fetchgraph", em.getEntityGraph("Order.withAll"));
Order order = em.find(Order.class, 1L, hints);
예제 14.23. 실행된 SQL
Hibernate:
select
o1_0.order_id,
o1_0.member_id,
m1_0.id,
m1_0.age,
m1_0.team_id,
m1_0.member_name,
m1_0.vip,
oi1_0.order_id,
oi1_0.order_item_id,
oi1_0.count,
i1_0.id,
i1_0.name,
i1_0.price,
i1_0.stock_quantity,
oi1_0.order_price
from
orders o1_0
join
member m1_0
on m1_0.id=o1_0.member_id
left join
order_item oi1_0
on o1_0.order_id=oi1_0.order_id
left join
item i1_0
on i1_0.id=oi1_0.item_id
where
o1_0.order_id=?
서브 그래프를 사용하여 객체 그래프를 두 단계 탐색하게 하였다. 생성된 SQL문도 이를 위해 조인을 두 번 사용하는 것을 확인할 수 있다.
JPQL에서도 JPA 힌트를 추가하여 엔터티 그래프를 사용할 수 있다.
예제 14.24. JPQL에서 엔터티 그래프 사용
List<Order> resultList =
em.createQuery("SELECT o FROM Order o WHERE o.id = :orderId")
.setParameter("orderId", 1L)
.setHint("jakarta.persistence.fetchgraph", em.getEntityGraph("Order.withAll"))
.getResultList();
예제 14.25. 실행된 SQL
Hibernate:
/* SELECT
o
FROM
Order o WHERE
o.id = :orderId */ select
o1_0.order_id,
o1_0.member_id,
m1_0.id,
m1_0.age,
m1_0.team_id,
m1_0.member_name,
m1_0.vip,
oi1_0.order_id,
oi1_0.order_item_id,
oi1_0.count,
i1_0.id,
i1_0.name,
i1_0.price,
i1_0.stock_quantity,
oi1_0.order_price
from
orders o1_0
join
member m1_0
on m1_0.id=o1_0.member_id
left join
order_item oi1_0
on o1_0.order_id=oi1_0.order_id
left join
item i1_0
on i1_0.id=oi1_0.item_id
where
o1_0.order_id=?
EntityManager.find() 메서드와 동일한 SQL을 실행하는 것을 확인할 수 있다.
@ManyToOne 매핑 애너테이션의 optional 속성을 false로 설정하여 필수 관계로 설정한 엔터티에 대해 하이버네이트는 엔터티 그래프 사용 시 SQL 내부 조인을 사용한다. 하지만 JPQL에서는 필수 관계와 상관없이 항상 SQL 외부 조인을 사용하기 때문에 내부 조인이 필요할 시 페치 조인을 JPQL 쿼리 안에 명시적으로 사용해야 한다.
EntityManager.createEntityGraph() 메서드로 생성된 EntityGraph 객체를 사용하여 동적 엔터티 그래프를 구성할 수 있다.
예제 14.26. 동적 엔터티 그래프
EntityGraph<Order> graph = em.createEntityGraph(Order.class);
graph.addAttributeNodes("member");
Map hints = new HashMap();
hints.put("jakarta.persistence.fetchgraph", graph);
Order order = em.find(Order.class, 1L, hints);
subgraph 기능은 다음과 같이 구현할 수 있다.
예제 14.27. 동적 엔터티 그래프 subgraph
EntityGraph<Order> graph = em.createEntityGraph(Order.class);
Subgraph<OrderItem> orderItems = graph.addSubgraph("orderItems");
orderItems.addAttributeNodes("item");
Map hints = new HashMap();
hints.put("jakarta.persistence.fetchgraph", graph);
Order order = em.find(Order.class, 1L, hints);
EntityGraph.addSubgraph() 메서드를 사용하여 Subgraph 객체를 생성하고 동일한 방식으로 Subgraph 객체에 속성 노드를 추가하여 연쇄적인 객체 그래프 탐색이 가능하도록 만든다.
jakarta.persistence.fetchgraph JPA 힌트는 엔터티 그래프에서 선택한 속성만 함께 조회하고, jakarta.persistence.loadgraph JPA 힌트는 글로벌 페치 전략이 EAGER인 연관 관계도 함께 조회한다.