JPA 표준 명세는 지연 로딩의 구현 방법을 JPA 구현체에 위임했고, 교재에선 하이버네이트를 통한 내용으로 설명됐다. 하이버네이트는 프록시를 사용하거나 바이트코드를 수정하는 방법으로 지연로딩을 지원한다고 한다! (책에서는 프록시를 활용한 지연 로딩을 기술했다.)
JPA에서 식별자로 엔티티 하나를 조회하는 EntityManager.find() 대신, EntityManager.getReference()
를 호출해 실제 사용하는 시점까지 DB 조회를 미루도록 한다.
이때 JPA는 DB 조회한 뒤 실제 엔티티 객체를 생성하는 것 대신 DB 접근을 위임한 프록시 객체를 반환한다.
프록시 객체는 실제 클래스를 상속받아 만들어지기 때문에 겉모양은 같다.
프록시 객체는 실제 객체에 대한 참조target
를 보관하다가 프록시 객체의 메서드가 호출되면 실제 객체의 메서드를 호출한다.
프록시 객체는 member.getName()
처럼 실제 사용될 때 DB를 조회해 실제 엔티티 객체를 생성하는데, 이를 프록시 객체의 초기화라고 한다.
em.getReference()
를 호출해도 프록시가 아닌 실제 엔티티를 반환한다.org.hibernate.LazyOnitializationException
예외가 발생한다.엔티티 객체를 프록시로 조회 시 식별자 값PK
을 파라미터로 전달하면, 프록시 객체는 이 식별자 값을 보관하여 식별자 값에 대한 getter를 호출하여도 프록시가 초기화되지 않는다.
단, 엔티티 접근 방식을 프로퍼티 @Access(AccessType.PROPERTY)
로 설정한 경우에는 초기화하지 않는다.
Team team = em.getReference(Team.class, "team1");
team.getId(); // 프록시가 초기화되지 않음.
연관관계 설정 시 식별자 값만 사용하므로 프록시를 사용하면 DB 접근 횟수를 줄일 수 있다.
JPA가 제공하는 PersistenceunitUtil.isLoaded(Object entity)
메소드를 사용하면 프록시 인스턴스의 초기화 여부를 알 수 있다. JPA 표준에는 강제 초기화 메서드가 없기 때문에 프록시의 메서드를 직접 호출해보면 된다.
getClass() 메소드로 현재 사용하는 인스턴스가 프록시 객체인지 진짜 객체인지 확인할 수 있다.
하이버네이트에서 initalize() 메소드를 통해 강제 초기화 메소드를 제공한다.
기존에 내가 사용하던 방식이다. 엔티티를 조회 시 연관된 엔티티도 함께 조회한다.
@Entity
public class Member {
// ...
@ManyToOne(fetch = FetchType.EAGER) // 즉시 로딩 설정
@JoinColumn(name = "TEAM_ID")
private Team team;
// ...
}
// 즉시 로딩 실행 코드
Member member = em.find(Member.class, "member1");
Team team = member.getTeam();
회원과 팀 두 테이블을 조회해야 하므로 주로 JOIN 쿼리로 한번에 DB에서 조회한다.
이때 @JoinColumn(nullable=true)면 외부 조인을 사용하고, 그렇지 않으면 내부 조인을 사용한다.
Null 제약 조건과 JPA 조인 전략
JPA는 매핑관계의 필수 여부에 따라 실제 DB로 보내는 SQL 구문이 달라지는데, Outer Join 보다 Inner Join이 성능과 최적화에서 더 유리하며 NotNull 조건을 걸면 Inner Join을 수행하게 하기 때문에 가능하면 NotNull 조건을 걸어보자.
@Entity
public class Member {
// ...
@ManyToOne(fetch = FetchType.LAZY) // 지연 로딩 설정
@JoinColumn(name = "TEAM_ID")
private Team team;
// ...
}
// 지연 로딩 실행 코드
Member member = em.find(Member.class, "member1");
Team team = member.getTeam();
team.getName();
team.getName() 을 호출하는 시점이 되어야 Team 엔티티를 조회한다. 즉, 프록시를 실제 사용할 때 초기화하면서 데이터베이스를 조회한다.
만약 조회 대상이 영속성 컨텍스트에 이미 있으면 프록시 객체가 아닌 실제 객체를 사용해 DB 접근을 최소화할 수도 있다.
연관된 엔티티를 프록시로 조회하며, 프록시를 실제 사용할 때 초기화하면서 데이터베이스를 조회한다.
회원과 팀은 조인 쿼리를 통해 함께 조회되고, 회원과 주문 내역은 주문 내역에 대한 결과를 프록시 객체로 반환한다. 따라서 회원을 조회했을 땐 주문 내역은 DB에 조회되지 않는다.
Member member = em.find(Member.class, "member1");
List<Order> orders = member.getOrders();
System.out.println("orders = " + orders.getClass().getName());
// 출력 결과: orders = org.hibernate.collections.internal.PersistenBag
엔티티를 지연 로딩하면 프록시 객체가 지연로딩을 수행하지만, 주문 내역 같은 컬렉션은 컬렉션 래퍼가 지연 로딩을 처리해 준다. 결국 둘다 프록시는 마찬가지지만, 명칭이 다르다.
다만, 위 코드에서 orders 컬렉션은 아직 초기화되지 않는다. member.getOrders().get(0) 처럼 컬렉션에서 실제 데이터를 조회할 때 초기화된다.
fetch 속성의 기본 설정 값은 다음과 같다.
컬렉션을 하나 이상 즉시 로딩하는 것을 권장하지 않는다.
컬렉션 즉시 로딩은 항상 외부 조인을 사용한다.
특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속 상태로 만들고 싶을 때 CASCADE를 사용하자.
@Entity
public class Parent {
@Id
@GeneratedValue
private Long id;
@OneToMany(mappedBy = "parent", cascade = CasecadeType.PERSIST)
private List<Child> children = new ArrayList<>();
}
@Entity
public class Child {
@Id
@GeneratedValue
private Long id;
@ManyToOne
private Parent parent;
}
// 부모를 영속화할 때 연관된 자식들도 함께 영속화하는 코드이다.
Parent parent = new Parent();
Child child1 = new Child();
Child child2 = new Child();
parent.addChild(child1);
parent.addChild(child2);
em.persist(parent);
위와 같이 부모만 영속화하더라도 그 안에 연관된 자식 엔티티도 영속화된다.
@Entity
public class Parent {
@Id
@GeneratedValue
private Long id;
@OneToMany(mappedBy = "parent", cascade = CasecadeType.REMOVE)
private List<Child> children = new ArrayList<>();
}
@Entity
public class Child {
@Id
@GeneratedValue
private Long id;
@ManyToOne
private Parent parent;
}
부모를 제거할 때 자식 엔티티까지 모두 제거할 수 있다. 만약 해당 REMOVE 옵션을 주지 않고 부모 객체를 제거하면 자식 테이블에 걸려 있는 외래 키 무결성 예외가 발생한다.
public enum CascadeType {
ALL, // 모두 적용
PERSIST, // 영속
MERGE, // 병합
REMOVE, // 삭제
REFRESH, // REFRESH
DETACH // DETACH
}
참고로 PERSIST와 REMOVE는 플러시를 호출해야 전이가 발생한다.
부모 엔티티와 연관 관계가 끊어진 자식 엔티티를 고아 객체라고 부르며, JPA는 이를 자동 삭제해 주는 기능을 제공한다.
@Entity
public class Parent {
@Id
@GeneratedValue
private Long id;
@OneToMany(mappedBy = "parent", orphanRemoval = true)
private List<Child> children = new ArrayList<>();
}
@Entity
public class Child {
@Id
@GeneratedValue
private Long id;
@ManyToOne
private Parent parent;
}
// 이제 children 컬렉션에서 제거한 자식 엔티티는 자동으로 삭제된다.
Parent parent = em.find(Parent.class, id);
parent.getChildren().remove(0);
위와 같이 children 컬렉션에서 첫 번째 코드를 제거하면, 해당 엔티티는 영속성 컨텍스트는 물론 데이테베이스의 데이터도 같이 삭제된다. 참고로 이 기능은 당연히 영속성 컨텍스트에서 플러시할 때 적용된다.
고아 객체 제거는 참조가 제거된 엔티티는 다른 곳에서 참조하지 않는 고아 객체로 보고 삭제하는 기능이다.
이 기능은 참조하는 곳이 하나일 때만 사용해야 하며, 쉽게 이야기해서 특정 엔티티가 개인 소유하는 엔티티에만 이 기능을 사용해야 한다.
이런 이유로 orphanRemoval은 @OneToOne, @OneToMany에만 사용할 수 있다.
부모를 제거하면 자동으로 자식은 고아가 되므로 모든 자식 엔티티가 제거된다. 그래서 이때는 CascadeType.REMOVE 와 동일한 역할을 한다.
만약 orphanRemoval 과 CascadeType.ALL 을 같이 사용하면 어떨까?
일반적으로 엔티티는 em.persist() 를 통해 영속화되고 em.remove() 를 통해 제거된다. 이것은 엔티티 스스로 생명 주기를 관리한다는 의미이다.
그래서 두 옵션을 모두 활성화면 부모 엔티티를 통해서 자식의 생명 주기를 관리할 수 있다.
// 자식을 저장하려면 부모에 등록하면 됨
Parent parent = em.find(Parent.class, parentId);
parent.addChild(child1);
// 자식을 제거하려면 부모에서 제거하면 됨
parent.getChildren().remove(child1);
김영한, 『자바 ORM 표준 JPA 프로그래밍』 에이콘(2015)