컬렉션과 부가 기능

컬렉션

JPA는 자바에서 기본으로 제공하는 Collection, List, Set, Map 컬렉션을 지원한다.

필요한 경우는 아래와 같다.

  • @OneToMany, @ManyToMany를 사용해서 일대다, 다대다 엔티티 관계를 매핑할 때 사용

  • @ElementCollection을 사용해서 값 타입을 하나 이상 보관할 때 사용

자바 컬렉션 구조

스크린샷 2023-11-06 오전 11.41.03.png

JPA와 컬렉션

@Entity
public class Team {

    @Id
    private String id;

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

하이버네이트는 엔티티를 영속 상태로 만들 때, 컬렉션 필드를 하이버네이트에서 준비한 컬렉션을 감싸서 사용한다.

members의 타입은 ArrayList지만, 영속 상태가 되면 PersistentBag 타입으로 한 단계 감싼 래퍼 컬렉션이 된다.

Collection, List

  • List는 중복을 허용하므로 객체를 추가할 때 내부적으로 어떤 비교도 하지 않고, true를 반환한다.

  • 엔티티를 추가해도 지연 로딩된 컬렉션을 초기화하지 않는다.

Set

  • Set은 중복을 허용하지 않는 컬렉션이다.

  • Set은 객체를 추가할 때마다 equals()를 통해 매번 같은 객체가 있는지 확인한다. 만약 HashSet을 사용 중이면 Hash 코드를 추가로 비교한다.

  • 엔티티를 추가할 때 지연 로딩된 컬렉션을 초기화한다.

List + @OrderColumn

  • List 인터페이스에 @OrderColumn을 추가하면 순서가 있는 특수한 컬렉션으로 인식한다.

  • 순서가 있다는 의미는 데이터베이스에 순서 값을 저장해서 조회한다는 뜻이다.

@OneToMany(mappedBy = "board")
@OrderColumn(name = "POSITION")
private List<Comment> comments = new ArrayList<>();

위 코드는 BoardComment가 일대다 관계이고, 테이블의 일대다 특성상 위치 값은 N쪽에 저장해야하므로 POSITION 컬럼은 Comment 테이블에 저장된다.

Untitled

@OrderColumn의 단점

  • @OrderColumnBoard 엔티티에서 매핑하므로 CommnetPOSITION 값을 알 수 없다. 그래서 Comment를 삽일할 때 POSITION 값이 저장되지 않으므로, Board.comments의 위치 값을 활용하여 POSITION을 업데이트하는 쿼리가 추가로 날아간다.

  • List를 변경하면 연관된 수많은 위치 값을 변경해야 한다.

  • 중간에 POSITION이 없으면 조회한 List에는 Null이 보관된다.

@OrderBy

  • 데이터베이스의 ORDER BY절을 사용해서 컬렉션을 정렬한다.

  • 모든 컬렉션에 사용할 수 있다.

@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>();
	...
}
SELECT M.*
FROM
	MEMBER M
WHERE
	M.TEAM_ID=?
ORDER BY
	M.MEMBER_NAME DESC,
	M.ID ASC

하이버네이트는 Set@OrderBy를 적용하여 결과를 조회하면 순서를 유지하기 위해 HashSet 대신 LinkedHashSet내부에서 사용한다.

@Converter

  • 엔티티의 데이터를 변환해서 데이터베이스에 저장할 수 있다.

    • ex) Boolean 타입을 데이터베이스에 저장할 때 Y 또는 N으로 저장하고 싶은 경우

@Entity
public class Member {

    @Id
    private String id;

    private String username;

    @Convert(converter=BooleanToYNConverter.class)
    private boolean vip;
}

@Converter(autoApply = true) // 모든 엔티티에 컨버터가 적용된다. 디폴트는 false
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 인터페이스를 구현해야 한다.

public interface AttributeConverter<X,Y> {
	public Y convertToDatabaseColumn (X attribute);
	public X convertToEntityAttribute (Y dbData);
}
  • convertToDatabaseColumn(): 엔티티의 데이터를 데이터베이스 컬럼에 저장할 데이터로 변환한다.

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

컨버터는 클래스 레벨에도 설정이 가능하다.

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

글로벌 설정

모든 Boolean 타입에 컨버터를 적용하려면 @Converter(autoApply = true) 옵션을 적용하면 된다.

@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 리스너 기능을 사용하면 엔티티의 생명 주기에 따른 이벤트를 처리할 수 있다.

예시로 모든 엔티티를 대상으로 언제 어떤 사용자가 삭제를 요청했는지 모두 로그로 남기고 싶은 경우, 이때 애플리케이션 삭제 로직을 하나씩 찾아서 남기는 것은 비효율적이다.

이벤트 종류

Untitled

PreLoad

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

PrePersist

  • persist() 를 호출하여 엔티티를 영속성 컨텍스트에 관리하기 직전에 호출

PreUpdate

  • flush나 commit을 호출하여 엔티티를 데이터베이스에 수정하기 직전에 호출

PreRemove

  • remove() 를 호출하여 엔티티를 영속성 컨텍스트에서 삭제하기 직전

  • orphanRemoval에 대해 flushcommit 시에 호출

PostPersist

  • flushcommit을 호출하여 엔티티를 데이터베이스에 저장한 직후에 호출

  • 식별자가 항상 존재

  • 참고로 식별자 생성 전략이 IDENTITY면 식별자를 생성하기 위해 persist()를 호출하면서 데이터베이스에 해당 엔티티를 저장하므로 이때는 persist() 호출 직후에 PostPersist가 호출된다.

PostUpdate

  • flushcommit을 호출하여 엔티티를 데이터베이스에 수정한 직후에 호출

PostRemove

  • flushcommit을 호출하여 엔티티를 데이터베이스에 삭제한 직후에 호출

이벤트 적용 위치

엔티티에 직접 적용

@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");
	}

}

별도의 리스너 등록

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

public class DuckListener {

	@PrePersist
	// 특정 타입이 확실하면 특정 타입을 받을 수 있다. 
	private void perPersist(Object obj) {
		System.out.println("DuckListener.prePersist obj = [" + obj + "]");
	}

	@PostPersist
	// 특정 타입이 확실하면 특정 타입을 받을 수 있다. 
	private void postPersist(Object obj) {
		System.out.println("DuckListener.postPersist obj = [" + obj + "]");
	}
}

기본 리스너 사용

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

<?xml version="1.0" encoding="UTF-8"?>
<entity-mappings ...>
	<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>

</entity-mappings>

더 세밀한 설정

  • javax.persistence.ExcludeDefaultListeners: 기본 리스너 무시

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

엔티티 그래프

  • 엔티티를 조회할 때 연관된 엔티티를 함께 조회하려면 FetchType.EAGER로 설정하면 되지만, 사용하지 않는 연관 관계까지 로딩하므로 효율적이지 않다.

  • JPQL에서 페치 조인을 사용할 수 있지만, 같은 JPQL을 중복해서 작성하는 경우가 많다.

    • 주문만 조회

    • 주문과 회원을 함께 조회

    • 주문과 주문 상품을 함께 조회

  • 엔티티 그래프 기능을 사용하면 엔티티를 조회하는 시점에 함께 조회할 연관된 엔티티를 선택할 수 있다.

  • 따라서 JPQL은 데이터를 조회하는 기능만 수행하면 되고, 연관된 엔티티를 함께 조회하는 기능은 엔티티 그래프를 사용하면 된다.

Named 엔티티 그래프

주문을 조회할 때 연관된 회원을 같이 조회한다.

@NamedEntityGraph(name = "Order.withMember", attributeNodes = {
    @NamedAttributeNode("member")
})
@Entity
public class Order {

    @Id
    @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "MEMBER_ID")
    private Member member;
}

member가 지연 로딩으로 설정되어 있지만, 엔티티 그래프에 의해 연관된 member도 함께 조회된다.

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);

엔티티 그래프는 JPA의 힌트 기능을 사용하기때문에 힌트의 키와 값을 같이 넣어 주어야 한다.

subgraph

Order → OrderItem → Item 과 같이 연달아서 엔티티 그래프를 조회할 경우 사용한다.

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

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() 메서드를 사용하면 된다.

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);

엔티티 그래프 정리

이미 로딩된 엔티티

영속성 컨텍스트에 해당 엔티티가 이미 로딩되어 있는 경우 엔티티 그래프가 적용되지 않는다.(아직 초기화 되지 않은 프록시에는 엔티티 그래프가 적용됨)

Order order = em.find(Order.class, orderId); // 이미 조회
hints.put("javax.persistence.fetchgraph", em.getEntityGraph("Order.withMember"));

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

fetchgraph, loadgraph의 차이

fetchgraph엔티티 그래프에 선택한 속성만 함께 조회한다.

반면에 loadgraph 속성은 엔티티 그래프에 선택한 속성뿐만 아니라 글로벌 fetch 모드가 Fetch.Type.EAGER 로 설정된 연관관계도 포함해서 함께 조회한다.

참고 자료

Last updated