[자바 ORM 표준 JPA 프로그래밍] 14주차 스터디

박서영·2026년 8월 29일

14장. 컬렉션과 부가기능

14.1 컬렉션

JPA는 자바에서 기본으로 제공하는 Collection, List, Set, Map 컬렉션을 지원하고 아래의 경우에 사용 가능함.

  • @OneToMany, @ManyToMany를 사용해 일대다 또는 다대다 엔티티 관계를 매핑할 때
  • @ElementCollection을 사용해 값 타입을 하나 이상 보관할 때

자바 컬렉션 인터페이스의 특징

  • Collection: 최상위 컬렉션. 하이버네이트에서는 중복을 허용하고 순서를 보장하지 않는다고 가정.
  • Set: 중복을 허용하지 않고 순서를 보장하지 않는 컬렉션
  • List: 순서가 존재하고 중복을 허용하는 컬렉션
  • Map: 키(key)와 값(value) 구조로 되어 있는 특수한 컬렉션.

(1) JPA와 컬렉션

특징

  • 하이버네이트는 엔티티를 영속 상태로 만들 때, 컬렉션 필드를 하이버네이트에서 준비한 컬렉션으로 감싸 사용함.
  • 컬렉션의 효율적 관리를 위해 엔티티를 영속 상태로 만들 때, 원본 컬렉션을 감싸고 있는 내장 컬렉션을 생성 → 해당 내장 컬렉션을 사용하도록 참조를 변경함.
    • 하이버네이트가 제공하는 내장 컬렉션은 래퍼 컬렉션으로도 부름.
  • 이런 특징으로 인해 컬렉션을 사용할 때 즉시 초기화해 사용하는 것을 권장함. 예) Collection members = new ArrayList();

예시:

@Entity
public class Team {
	
	@Id
	private String id;
	
	@OneToMany
	@JoinColumn
	private Collection<Member> members = new ArrayList<Member>();
}

아래의 코드를 통해 Team을 영속 상태로 만들면

Team team = new Team();

System.out.println("before persist = " + team.getMembers().getClass());
System.out.println("after persist = " + team.getMembers().getClass());

출력 결과가 아래와 같다.

before persist = class.java.util.**ArrayList**
after persist = class.org.hibernate.collection.internal.**PersistentBag**

결과를 보면 원래 ArrayList 타입이었던 컬렉션이 엔티티를 영속상태로 만든 직후 하이버네이트가 제공하는 PersistentBag 타입으로 변경됨.

인터페이스에 따른 래퍼 컬렉션

컬렉션 인터페이스내장 컬렉션중복 허용순서 보관
Collection.ListPersistenceBagOX
SetPersistenceSetXX
List + @OrderColumnPersistentListOO
//PersistenceBag
@OneToMany
Collection<Member> collection = new ArrayList<Member>();

//PersistenceBag
@OneToMany
List<Member> list = new ArrayList<Member>();

//PersistenceSet
@OneToMany
Set<Member> set = new HashSet<Member>();

//PersistentList
@OneToMany @OrderColumn
List<Member> orderColumnList = new ArrayList<Member>();

(2) Collection, List

  • Collection과 List 인터페이스는 중복을 허용하는 컬렉션으로 PersistentBag를 래퍼 컬렉션으로 사용함.
  • 해당 인터페이스는 ArrayList로 초기화
  • 중복을 허용하는 것을 가정하기에 객체 추가 add() 메소드는 내부에서 어떤 비교도 하지 않고 항상 true를 반환.
  • 같은 엔티티가 있는지 찾거나 삭제할 때는 equals() 메소드를 사용.
@Entity
public class Parent {

	@Id @GeneratedValue
	private Long id;
	
	@OneToMany
	@JoinColumn
	private Collection<CollectionChild> collection = 
		new ArrayList<CollectionChild> ();
	
	@OneToMany
	@JoinColumn
	private List<ListChild> list = new ArrayList<ListChild>();
	
	...
	
}

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


(3) Set

  • Set은 중복을 허용하지 않는 컬렉션
  • 하이버네이트는 PersistentSet을 컬렉션 래퍼로 사용함
  • 해당 인터페이스는 HashSet으로 초기화
  • 메소드로 객체를 추가할 때마다 equals() 메소드로 같은 객체가 있는지의 여부를 비교. (HashSet의 경우, 해시 알고리즘을 사용하기에 hashcode()도 함께 사용해 비교)
    • 같은 객체 존재X → 객체 추가 후 true 반환
    • 같은 객체 이미 존재 → 추가 실패 후 false 반환
@Entity
public class Parent {
	
	@OneToMany
	@JoinColumn
	private Set<SetChild> set = new HashSet<SetChild>();
	...
}

Set은 엔티티를 추가할 때 중복된 엔티티가 있는지 비교해야하기에 엔티티를 추가할 때 지연 로딩된 컬렉션을 초기화함.


(4) List + @OrderColumn

  • List 인터페이스에 @OrderColumn을 추가하면 순서가 있는 특수한 컬렉션으로 인식
  • 순서가 있다의 의미 = 데이터베이스에 순서 값을 저장해 조회할 때 사용한다는 의미
  • 하이버네이트는 내부 컬렉션인 PersistentList를 사용함
@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<Comment>();
	
	...
}

@Entity
public class Comment {

	@Id @GeneratedValue
	private Long id;
	
	private String comment;
	
	@ManyToOne
	@JoinColumn(name = "BOARD_ID")
	private Baord board;
	
	...
}
  • Board.comments에 List 인터페이스를 사용하고 @OrderColumn을 추가. 따라서 Board.comments는 순서가 있는 컬렉션으로 인식됨
  • 자바가 제공하는 List 컬렉션은 내부에 위치 값을 가지고 있음. 따라서 List의 위치값을 활용 가능
    • list.add(1, data1); → 1번 위치에 data1 저장
    • list.get(10); → 10번 위치에 있는 값을 조회
  • 순서가 있는 컬렉션의 경우 데이터베이스에 순서값도 함께 관리
    • 위에서는 @OrderColumn의 name 속성에 POSITION이라는 값을 줌.
    • JPA는 List의 위치값을 테이블의 POSITION 컬럼에 보관함
    • Board.comments 컬렉션은 Board 엔티티에 있지만, 테이블의 일대다 관계 특성상 위치값은 다(N)쪽에 지정해야함. 따라서 실제 POSITION 컬럼은 COMMENT 테이블에 매핑됨.

@OrderColumn의 단점

실무에서 잘 사용하지 않는 이유의 단점들이 존재

  • @OrderColumn을 Board 엔티티에서 매핑하기에 Comment는 POSITION의 값을 알 수 없음. 따라서 Comment를 INSERT할 때는 POSITION 값이 저장되지 않음.
    • POSITION은 Board.comments의 위치값이기에 해당값을 사용해 POSITION 값을 사용해 POSITION의 값을 UPDATE하는 SQL이 추가로 발생함
  • List 변경 시 연관된 많은 위치값을 변경해야함
  • 중간에 POSITION값이 없으면 조회한 List에 널(null)이 보관됨

(5) OrderBy

  • @OrderBy는 데이터베이스의 ORDER BY절을 사용해 컬렉션을 정렬함. 즉, 순서용 컬럼을 매핑하지 않아도됨.
  • @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<Member>();
	...

}

@Entity
public class Member {

	@Id @GeneratedValue
	private Long id;
	
	@Column(name = "MEMBER_NAME")
	private String username;
	
	@ManyToOne
	private Team team;
	...
}
  • Team.members를 보면 @OrderBy를 적용함. 그 값으로 username desc, id asc를 사용
  • OrderBy의 값은 JPQL의 order by절 처럼 엔티티의 필드를 대상으로 함.

14.2 @Converter

컨버터(Converter)

사용 시 엔티티의 데이터를 변환해 데이터베이스에 저장

예) 회원의 VIP 여부를 boolean 타입을 사용할 때. JPA를 사용하면 0 또는 1인 숫자로 저장됨.

→ 이때 데이터베이스에 숫자 대신 Y/N으로 저장하려면 컨버터를 사용하면 됨.

@Entity
public class Member {
	
	@Id
	private String id;
	private String username;
	
	@Convert(converter = BooleanToYNConverter.class)
	private boolean vip;
	
	...

}

@Convert
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 타입의 변환을 실행.
  • 컨버터는 클래스 레벨에도 적용이 가능하지만, 이때는 반드시 attributeName 속성을 사용해 어떤 필드에 컨버터를 적용할 것인지 명시해야함.

AttributeConverter 인터페이스

아래 두 가지 메소드를 구현해야함.

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

(1) 글로벌 설정

모든 Boolean 타입에 컨버터를 적용하기 위해서는 @Converter(autoApply = true) 옵션을 적용하면 됨.

**@Convert(autoApply = true)**
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);
	}
}
  • 글로벌 설정 시, @Convert를 지정하지 않아도 모든 Boolean 타입에 대해 자동으로 컨버터가 적용됨.

@Converter 속성 정리

속성기능기본값
converter사용할 컨버터를 지정
attributeName컨버터를 적용할 필드를 지정
disableConversion글로벌 컨버터나 상속 받은 컨버터를 사용하지 않음false

14.3 리스너

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

예) 모든 엔티티를 대상으로 언제, 어떤 사용자가 삭제를 요청했는지 모두 로그로 남겨야하는 요구사항

→ 모든 애플리케이션의 삭제 로직마다 로그를 남기는 것은 효율적.

(1) 이벤트 종류

  1. PostLoad: 엔티티가 영속성 컨텍스트에 조회된 직후 또는 refresh 호출 후
  2. PrePersist: persist() 메소드를 호출해 엔티티를 영속성 컨텍스트에 관리하기 직전 호출. (식별자 생성 전략 사용 시 식별자는 아직 존재X). 새로운 인스턴스 merge 시에도 수행
  3. PreUpdate: flush 또는 commit을 호출해 엔티티를 데이터베이스에 수정하기 직전 호출
  4. PreRemove: remove() 메소드를 호출해 엔티티를 영속성 컨텍스트에서 삭제하기 직전 호출. 삭제 명령어로 영속성 전이가 일어날 때도 호출. (orphanRemoval에 대해서는 flush/commit할 때에 호출됨)
  5. PostPersist: flush나 commit을 호출해 엔티티를 데이터베이스 저장한 직후 호출.
  6. PostUpdate: flush나 commit을 호출해 엔티티를 데이터베이스에 수정한 직후 호출.
  7. PostRemove: flush나 commit을 호출해 엔티티를 데이터베이스에 삭제한 직후 호출.

(2) 이벤트 적용 위치

이벤트는 엔티티에서 직접 받거나 별도의 리스너를 등록할 수 있음.

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

엔티티에 직접 적용

엔티티에 이벤트가 발생할 때마다 어노테이션으로 지정한 메소드가 실행됨.

@Entity
public class Duck {

	@Id @GeneratedValue
	public 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 id=" + id);
	}
	
	@PreRemove
	public void preRemove() {
		System.out.println("Duck.preRemove id=" + id);
	}
	
	@PostRemove
	public void postRemove() {
		System.out.println("Duck.postRemove id=" + id);
	}
}

별도의 리스너 등록

리스너의 경우 대상 엔티티를 파라미터로 받을 수 있다. 이때 반환 타입은 void로 설정해야함.

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

public class DuckListener {

	@PrePersist
	//특정 타입인 것이 확실하면 특정 타입을 받을 수 있음.
	public void prePersist(Object obj) {
		System.out.println("Duck.prePersist obj" + obj);
	}
	
	@PostPersist
	//특정 타입인 것이 확실하면 특정 타입을 받을 수 있음.
	public void postPersist(Object obj) {
		System.out.println("Duck.postPersist obj=" + obj);
	}
}

기본 리스너 사용

모든 엔티티의 이벤트를 처리하기 위해서는 META-INF/orm.xml에 기본 리스너로 등록하면 됨.

여러 리스너를 등록하면 아래의 순서대로 이벤트가 호출됨.

기본 리스너 → 부모 클래스 리스너 → 리스너 → 엔티티

+) 더 세밀한 설정

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

14.4 엔티티 그래프

엔티티 조회 시 연관 엔티티를 함께 조회하기 위해서는 글로벌 옵션을 FetchType.EAGER로 설정

@Entity
class Order {
	
	@ManyToOne(fetch = FetchType.EAGER)
	Member member;
	...
}

또는 JPQL에서 페치 조인을 사용하면 됨.

select o from Order o join fetch o.member

⇒ 글로벌 fetch 옵션의 경우 애플리케이션 전체에 영향을 주고 변경할 수 없는 단점이 존재.

→ 따라서 글로벌 fetch 옵션은 FetchType.LAZY를 사용하고 엔티티를 조회할 때 연관 엔티티를 함께 조회할 필요가 있으면 JPQL의 페치조인을 사용함

  • 단점: 페치 조인 사용 시에는 같은 JPQL을 중복해서 작성하는 경우가 많음.

예) 주문 상태를 검색조건으로 주문 엔티티를 조회하는 JPQL 작성

  • 기본
    • select o from Order o where o.status = ?
  • 주문과 회원을 함께 조회
    • select o from Order o
      join fetch o.member
      where o.status = ?
  • 주문과 주문상품을 함께 조회
    • select o from Order o
      join fetch o.orderItems
      where o.status = ?

⇒ 3가지 JPQL 모두 주문을 조회하는 JPQL이지만, 함께 조회할 엔티티에 따라 다른 JPQL을 사용해야함.

  • 원인: 위의 문제점은 JPQL이 데이터 조회 기능 + 연관 엔티티를 함께 조회하는 기능을 모두 제공하기 때문.
  • 해결책: JPA 2.1에 추가된 엔티티 그래프 기능을 사용하면 엔티티를 조회하는 시점에 함께 조회할 연관 엔티티 선택 가능.

엔티티 그래프

  • 엔티티 그래프 기능은 엔티티 조회시점에 연관된 엔티티들을 함께 조회하는 기능임.
  • 엔티티 그래프에는 정적으로 정의하는 Named 엔티티 그래프와 동적으로 정의하는 엔티티 그래프가 존재.

(1) Named 엔티티 그래프

**@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.member가 지연로딩 설정되어있지만, 엔티티 그래프에서 함께 조회할 속성으로 member를 선택했기에 해당 엔티티 그래프를 사용하면 Order를 조회할 때 연관 member 역시 조회 가능.
  • 둘 이상 정의할 때에는 @NamedEntityGraphs 이용

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

EntityGraph graph = em.getEntity("Order.withMember");

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

Order order = em.find(Order.class, orderId, hints);
  • Named 엔티티 그래프를 사용하기 위해서는 정의한 엔티티 그래프를 em.getEntityGraph("Order.withMember")를 통해 찾아오면 됨
  • 엔티티 그래프는 JPA의 힌트 기능을 사용해 동작. 힌트키로는 fetchgraph를 사용하고, 힌트의 값으로 찾아온 엔티티 그래프를 사용하면 됨.
  • em.find()를 통해 엔티티를 조회할 때 힌트 정보 역시 포함함.

(3) subgraph

목표: Order → OrderItem → Item 조회.

Order → OrderItem의 경우에 Order가 관리하는 필드지만, OrderItem → Item은 Order가 관리하는 필드가 아님 ⇒ 이런 경우에 subgraph를 사용

@NameEntityGraph(name = "Order.withAll", attributeNodes = {
		@NameAttributeNode("member"),
		@NameAttributeNode(value = "orderItems", subgraph = "orderItems")
		},
		**subgraphs = @NamedSubgraph(name = "orderItems", attributeNodes = {
				@NameAttributeNode("item")
		}**)
)
@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; //주문 회원

		@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
		private List<OrderItem> orderItems = new ArrayList<OrderItem>();

		...
}

@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; //주문 상품

		...
}
  • Order.withAll이라는 Named 엔티티 그래프 정의. 해당 그래프는 Order → Member, Order → OrderItem, OrderItem → Item의 객체 그래프를 함께 조회함.
    • 이때 OrderItem → Item은 Order의 객체 그래프가 아니기에 subgraph 속성으로 정의

(4) JPQL에서 엔티티 그래프 사용

JPQL에서 엔티티 그래프를 사용할 때에는 em.find()와 동일하게 힌트만 사용하면 됨.

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) 동적 엔티티 그래프

엔티티 그래프를 동적으로 구성하기 위해서는 createEntityGraph() 메소드를 사용

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

예) 앞의 Named 엔티티 그래프의 동적 구성

**EntityGraph<Order> graph = em.createEntityGraph(Order.class);**
graph.addAttributeNodes("member");

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

Order order = em.find(Order.class, orderId, hints);
  • createEntityGraph() 메소드를 사용해 동적으로 엔티티 그래프를 만든 후 addAttributeNodes()를 통해 Order.member 속성을 엔티티 그래프에 포함

예) 동적 엔티티 그래프 구성 + subgraph 추가

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

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

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

(6) 엔티티 그래프 정리

  • ROOT에서 시작: 엔티티 그래프는 항상 조회하는 엔티티의 루트(ROOT)에서 시작해야함.
  • 이미 로딩된 엔티티: 영속성 컨텍스트에 해당 엔티티가 이미 로딩되어 있음녀 엔티티 그래프가 적용되지 않음.
    (아직 초기화되지 않은 프록시에는 적용됨)
    Order order1 = em.find(Order.class, orderId); //이미 조회
    hints.put("javax.persistence.fetchgraph", em.getEntityGraph("Order.withMember"));
    Order order2 = em.find(Order.class, orderId, hints);
    ⇒ 위의 경우 조회된 order2에는 엔티티 그래프가 적용되지 않고 처음 조회한 order1과 같은 인스턴스가 반환됨
  • fetchgraph, loadgraph의 차이
    • fetchgraph: 엔티티 그래프에 선택한 속성만 함께 조회
    • loadgraph: 선택한 속성뿐 아니라 글로벌 fetch 모드가 FetchType.EAGER로 설정된 연관관계도 포함해서 조회.
profile
이불 밖은 위험해.

0개의 댓글