1. 프록시
먼저, 프록시를 언제 사용해야 하는지 알아보자. 아래에서 Member를 조회할 때 Team도 함께 조회해야 할까?
회원 이름과 소속 팀 이름을 함께 출력하려면 Member를 조회할 때 Team도 같이 조회하면 좋다. 회원 이름만 출력하면 된다면 Member만 조회하는 게 낫다. 따라서 사용하지 않는다면, 연관관계가 걸려있다고 해도 같이 조회하지 않는 게 성능상으로 더 효율적이다.
- JPA는 이 문제를 지연 로딩이나 프록시를 통해 해결한다. 프록시보단 지연 로딩을 주로 사용하지만, 프록시 구조를 알아둬야 즉시 로딩과 지연 로딩에 대해 깊이 이해할 수 있다.
// 회원과 팀 함께 출력
public void printUserAndTeam(String memberId) {
Member member = em.find(Member.class, memberId);
Team team = member.getTeam();
System.out.println("회원 이름: " + member.getUsername());
System.out.println("소속 팀: " + team.getName();
}
// 회원만 출력
public void printUser(String memberId) {
Member member = em.find(Member.class, memberId);
Team team = member.getTeam();
System.out.println("회원 이름: " + member.getUsername());
}
프록시 특징
JPA에선 em.find() 말고도 em.getReference()라는 메서드를 제공한다.
- em.find()
- DB를 통해 실제 엔티티 객체를 조회한다.
- em.getReference()
- DB 조회를 미루는 가짜(프록시) 엔티티 객체를 조회한다.
- DB에 SELECT SQL이 실행되지 않지만, 객체를 조회할 수 있다.
코드로 살펴보면, 맨 밑에서 em.getReference()로 조회한 가짜 엔티티 객체의 class를 출력한다. 이때 순수한 엔티티 객체가 아닌 하이버네이트가 만든 프록시 객체로 출력되는 걸 확인할 수 있다.
Member member = new Member(); // 회원 생성
member.setUsername("hi"); // 회원 이름 설정
em.persist(member); // 회원 영속화
em.flush(); // INSERT SQL을 실행
em.clear(); // 영속성 컨텍스트 비우기
// 메서드 이름 그대로 '참조' -> 가짜 엔티티 객체를 조회 -> SELECT SQL이 실행되지 않음
Member findMember = em.getReference(Member.class, member.getId());
// getReference() 메서드에 member.getId()를 넘겨줬기 때문에 SELECT SQL이 실행되지 않음
System.out.println("findMember.id = " + findMember.getId());
// 실제 엔티티 객체가 필요한 시점에서 SELECT SQL이 실행됨
System.out.println("findMember.username = " + findMember.getUsername());
// 출력 findMember = class hellojpa.Member$HibernateProxy$odcVHpjy
System.out.println("findMember = " + findMember.getClass());
프록시 엔티티 객체는 하이버네이트에서 라이브러리를 사용해 실제 class를 상속받아 만들기 때문에 겉모양이 똑같다. 사용하는 입장에선 이론상으론 실제 객체인지 프록시 객체인지 구분하지 않고 사용하면 된다. 상속받더라도 부모 타입으로 보고 쓸 수 있기 때문이다.
프록시 객체의 구조를 살펴보자. 프록시 객체는 실제 객체의 참조(target)를 보관한다. 따라서 프록시 객체를 호출하면 참조 값을 통해 실제 객체의 메서드를 호출한다.
- 이때 target은 DB에 SELECT SQL을 날려 실제 객체를 조회할 때 가져온다.
💫프록시 객체의 초기화
프록시 객체가 초기화되는 과정을 살펴보자.
- 우선 em.getReference()로 프록시 객체를 가져와 Member member에 저장한다.
- member.getName()을 호출하면, 프록시 객체의 Member target에 값이 존재하지 않기 때문에 영속성 컨텍스트에 초기화를 요청한다.
- 이후 영속성 컨텍스트가 DB에 SELECT SQL을 실행하고,
- 실제 엔티티 객체를 생성한 뒤
- 프록시 객체의 target에 참조 값을 담아 프록시 객체와 실제 객체를 연결한다.
프록시 객체는 처음 사용할 때 한 번만 초기화하면 된다.
- 프록시 객체를 초기화할 때, 프록시 객체가 실제 엔티티로 바뀌는 것은 아니다. 초기화는 프록시 객체를 통해 실제 엔티티에 접근할 수 있도록 참조 값을 추가하는 과정이라고 보면 된다.
Member findMember = em.getReference(Member.class, member.getId());
// 출력 findMember = class hellojpa.Member$HibernateProxy$odcVHpjy
System.out.println("findMember = " + findMember.getClass());
System.out.println("findMember.id = " + findMember.getId());
// 프록시 객체 초기화 -> target에 값이 채워지며 실제 엔티티를 참조할 수 있게 됨
System.out.println("findMember.username = " + findMember.getUsername());
// 출력 findMember = class hellojpa.Member$HibernateProxy$odcVHpjy
// 초기화돼도 프록시 객체가 실제 엔티티로 변하지는 않음
System.out.println("findMember = " + findMember.getClass());
- 프록시 객체는 원본 엔티티를 상속받기 때문에 타입을 체크할 때 주의해야 한다. == 비교를 시도하면 당연히 class가 달라 실패하게 되므로 instanceof를 사용해야 한다.
// 출력 False
System.out.println(findMember.getClass() == Member.class);
// 출력 True
System.out.println(findMember instanceof Member);
- 영속성 컨텍스트(1차 캐시)에 찾는 엔티티가 이미 있으면, em.getReference()를 호출해도 실제 엔티티가 반환된다. 이렇게 동작하는 이유는 아래 두 가지가 있다.
- 원본 엔티티가 이미 1차 캐시에 올라와있는 상태(동일한 영속성 컨텍스트)에서 굳이 프록시 객체를 가져올 필요가 없다.
- JPA는 동일한 영속성 컨텍스트 안에서 PK가 같은 두 객체를 == 비교를 할 경우 항상 TRUE를 출력해야 한다. 따라서 em.getReference()를 2번 호출해 프록시 객체를 두 번 꺼내더라도 같은 프록시 객체가 꺼내진다.
Member m1 = em.find(Member.class, member1.getId()); // 실제 엔티티 조회 & 1차 캐시 저장
System.out.println("m1: " + m1.getClass());
Member ref = em.getReference(Member.class, member1.getId()); // 실제 엔티티
System.out.println("ref: " + ref.getClass());
// 출력은 무조건 TRUE
System.out.println("m1 == ref: " (m1 == ref));
Member refMember = em.getReference(Member.class, member1.getId()); // 프록시 객체 조회
System.out.println("refMember: " + refMember.getClass());
Member findMember = em.find(Member.class, member1.getId()); // SELECT SQL 실행
System.out.println("findMember: " + findMember.getClass()); // 프록시 객체
// 출력은 무조건 TRUE
// 프록시 객체가 한 번 반환되면, em.find()를 호출해도 프록시 객체가 반환된다.
System.out.println("refMember == findMember: " (refMember == findMember));
- 💫영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태에서 프록시를 초기화하면 문제가 발생한다.
- 하이버네이트에서 org.hibernate.LazyInitializationException을 터트린다.
- 프록시 객체 초기화는 영속성 컨텍스트의 도움을 받아 진행되기 때문이다.
- 실무에서 자주 만날 수 있다. 꼭 기억해 두자.
Member refMember = em.getReference(Member.class, member1.getId()); // 프록시 객체 조회
System.out.println("refMember: " + refMember.getClass());
// 영속 상태에서 준영속 상태로 변경
em.close(); // em.detach(); || em.clear();
// 프록시 객체 초기화 -> 문제 발생 (영속성 컨텍스트의 도움을 받을 수 없기 때문)
System.out.println("refMember name: " + refMember.getUsername());
프록시 확인
프록시의 class를 확인하거나 도와주는 등의 기능이 있는 유틸리티 메서드가 존재한다.
메서드 | 설명 |
PersistenceUnitUtil.isLoaded(Object entity) | 프록시 인스턴스의 초기화 여부 확인 |
entity.getClass().getName() | 프록시 클래스 확인 방법 (출력: ..javasist.. or HibernateProxy...) |
org.hibernate.Hibernate.initialize(entity) | 프록시 강제 초기화 |
Member refMember = em.getReference(Member.class, member1.getId());
// 초기화 여부 확인 (출력 FALSE)
System.out.println("isLoaded=" + emf.getPersistenceUtilUnit().isLoaded(refMember));
// 강제 초기화 -> refMember.getUsername()으로도 가능
Hibernate.initialize(refMember);
// 초기화 여부 확인 (출력 TRUE)
System.out.println("isLoaded=" + emf.getPersistenceUtilUnit().isLoaded(refMember));
// class명 확인
System.out.println("refMember: " + refMember.getClass());
참고
JPA 표준엔 프록시 강제 초기화가 존재하지 않는다.
강제 호출: member.getName()
2. 즉시 로딩과 지연 로딩
지연 로딩
위에서 말했듯이, 단순히 Member 정보만 사용하는 비즈니스 로직이 있다면 Member를 조회할 때 굳이 Team까지 조회할 필요가 없다. 이런 경우를 위해 JPA에서 지연 로딩(Lazy Loading)을 지원한다.
- 아래처럼 페치 타입을 LAZY로 지정하면, Member을 조회할 때 Team은 프록시 객체로 조회한다.
@Entity
public class Member {
...
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "TEAM_ID")
private Team team;
}
지연 로딩 LAZY를 사용해서 연관된 엔티티를 프록시로 조회할 수 있다. 따라서 연관된 엔티티를 실제 사용하는 시점에 실제 엔티티를 조회한다.
Member member = em.find(Member.class, 1L); // Team은 프록시 객체로 조회
Team team = member.getTeam();
team.getName(); // 실제 Team을 사용하는 시점에 초기화(DB 조회)
즉시 로딩
반면에 Member와 Team을 자주 함께 사용한다면 즉시 로딩(Eager Loading)을 사용하는 게 더 효율적이다.
- 아래처럼 페치 타입을 EAGER로 지정하면, Member을 조회할 때 Team도 함께 조회한다.
@Entity
public class Member {
...
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "TEAM_ID")
private Team team;
}
즉시 로딩 EAGER를 사용하면 JPA 구현체는 가능하면 조인을 사용해서 한 번의 SQL로 연관된 엔티티까지 모두 조회한다.
- 또는 엔티티마다 em.find()를 호출해 조회하기도 한다.
Member member = em.find(Member.class, 1L); // Member와 연관된 Team까지 한 번에 조회
즉시 로딩을 적용하면 예상치 못한 SQL이 발생할 수 있기 때문에 실무에서는 가급적이면 지연 로딩만 사용해야 한다.
- 즉시 로딩은 JPQL에서 N+1 문제를 일으킨다. 실무에서 JPQL을 사용하면서 정말 자주 만나게 된다.
List<Member> members = em.createQuery("select m from Member m", Member.class)
.getResultList();
// Member에서 Team을 즉시 로딩(EAGER)으로 설정해 둔 경우,
// JPQL을 SQL로 변환해 (select * from MEMBER;)
// 모든 Member를 가져올 때 Member마다 연관된 Team을 모두 즉시 조회한다.
// 따라서 Member n명마다 연관된 Team을 조회하므로 최종 SQL은 N+1번 발생하게 된다. => N+1 문제
- @ManyToOne과 @OneToOne은 기본값이 즉시 로딩(EAGER)이므로 지연 로딩(LAZY)으로 명시해 사용하자.
- @OneToMany와 @ManyToMany는 기본값이 지연 로딩(LAZY)이다.
지연 로딩 활용
예시를 통해 알아보자.
- Member와 Team은 자주 함께 사용된다. → 즉시 로딩 사용
- Member와 Order는 가끔 사용된다. → 지연 로딩 사용
- Order와 Product는 자주 함께 사용된다. → 즉시 로딩 사용
실무 관점에서 나온 결론
- 모든 연관관계에 지연 로딩을 사용해라.
- 실무에서 즉시 로딩을 사용하지 마라.
- 내가 원하는 엔티티만 지정해 조회할 수 있는 JPQL fetch 조인이나, 애노테이션으로 적용할 수 있는 엔티티 그래프 기능을 사용해라.
3. 영속성 전이(CASCADE)와 고아 객체
영속성 전이: CASCADE
영속성 전이는 연관관계를 매핑하는 것과 아무런 관련이 없다. 그저 특정 엔티티를 영속화할 때, 연관된 엔티티도 함께 영속화하는 편리함을 제공한다고 보면 된다.
- ex. 부모 엔티티를 저장할 때 자식 엔티티도 함께 저장
총 6가지의 CascadeType이 존재한다. 대부분 ALL이나 PERSIST를 주로 사용한다.
- ALL: 모두 적용 → 엔티티의 라이프사이클을 맞출 때 사용
- PERSIST: 영속화
- REMOVE: 삭제
- MERGE: 병합
- REFRESH
- DETACH
참고
영속성 전이는 서로만 연관관계가 걸려 있는 엔티티 사이에서 사용하는 게 좋다. (=엔티티의 소유자가 하나일 때, 단일 엔티티가 종속적일 때) 또는, 두 엔티티의 라이프사이클이 똑같을 때 사용해도 된다.
영속성 전이: PERSIST
예시로 영속화를 전이할 수 있는 CascadeType.PERSIST를 살펴보자.
- 아래 코드에서 cascade를 설정하지 않으면, Child 객체 2개를 Parent에 설정할 때 em.persist()를 3번 호출해야 한다.
- cascade를 PERSIST(ALL)로 설정해 두면 em.persist(parent)를 한 번만 호출해도 연관된 child까지 함께 영속화된다.
@Entity
public class Parent {
...
@OneToMany(mappedBy = "parent", cascade = CascadeType.PERSIST)
private List<Child> childList = new ArrayList<>();
public void addChild(Child child) {
childList.add(child);
child.setParent(this);
}
}
@Entity
public class Child {
...
@ManyToOne
@JoinColumn(name = "CHILD_ID")
private Parent parent;
// setter
}
고아 객체
고아 객체는 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 의미한다. 아래처럼 @OneToMany에 orphanRemoval 속성을 true로 설정하면 고아 객체가 자동으로 삭제된다.
@Entity
public class Parent {
...
@OneToMany(mappedBy = "parent", cascade = CascadeType.PERSIST, orphanRemoval = true)
private List<Child> childList = new ArrayList<>();
...
}
///
Parent p1 = em.find(Parent.class, 1L);
p1.getChildList().remove(0); // 자식 엔티티를 컬렉션에서 제거 -> 고아 객체가 됨 -> 자동 삭제
// DELETE FROM CHILD WHERE ID = 1;
이런 연계 삭제 기능을 사용할 땐 항상 조심해야 한다. 참조가 제거된 엔티티는 다른 곳에서 참조하지 않을 고아 객체로 판단하고 삭제하는 기능이다.
- 따라서 고아 객체가 될 엔티티를 참조하는 곳이 하나이거나, 특정 엔티티가 개인 소유할 때 사용해야 한다.
- @OneToOne, @OneToMany에서만 사용할 수 있다.
참고
개념적으로 부모를 제거하면 자식은 고아가 된다. 따라서 고아 객체 제거 기능을 활성화하면, 부모를 제거할 때 자식도 함께 제거된다. 이 기능은 CascadeType.REMOVE처럼 동작한다.
영속성 전이 + 고아 객체, 생명주기
스스로 생명주기를 관리하는 엔티티(ex. 부모 엔티티)는 em.persist()로 영속화하고 em.remove()로 제거할 수 있다. 이때 CascadeType.ALL과 orphanRemoval=true를 모두 활성화하면 부모 엔티티를 통해 자식 엔티티의 생명주기를 관리할 수 있게 된다.
- 도메인 주도 설계(DDD)의 Aggregate Root 개념을 구현할 때 유용하다.