프록시
Member 엔티티를 조회할 때 Team도 함께 조회해야 할까?
- 실제로 필요한 비즈니스 로직에 따라 다르다.
- 비즈니스 로직에서 필요하지 않는데, 항상 Team을 함께 가져와서 사용할 필요는 없다.
- 낭비가 발생하게 된다.
- JPA는 이 낭비를 하지 않기 위해, 지연로딩과 프록시라는 개념으로 해결한다.
프록시 기초
테이블을 조회해서 객체를 가져올 때 연관관계 객체는 안가져 오고 싶으면 어떻게 해야 할까 ?
- em.find() : 데이터베이스를 통해서 실제 엔티티 객체 조회
- em.getReference(): 데이터베이스 조회를 미루는 가짜(프록시) 엔티티 객체 조회
em.find()로 멤버를 조회하면 아래와 같이 데이터베이스에 쿼리가 바로 나간다.
Member member = new Member();
member.setName("hello");
em.persist(member);
em.flush();
em.clear();
Member findMember = em.find(Member.class, member.getId());
System.out.println("findMember.id = " + findMember.getId());
System.out.println("findMember.name = " + findMember.getName());
tx.commit();
그러나, em.getReference()로 멤버를 조회하면, 실제로 필요한 시점에 데이터베이스에 쿼리가 나간다.
- 실행 결과에서 보면 findMember.name 필드를 출력할 때, DB에서 조회가 필요하므로 그때 쿼리가 나간다.
- 그리고 findMember.getClass()로 객체를 확인하면 Member객체가 아니라, 하이버네이트가 만든 가짜 클래스인 HibernateProxy 객체인 것을 확인할 수 있다.
Member member = new Member();
member.setName("hello");
em.persist(member);
em.flush();
em.clear();
Member findMember = em.getReference(Member.class, member.getId());
System.out.println("findMember = " + findMember.getClass());
System.out.println("findMember.name = " + findMember.getName());
System.out.println("findMember.id = " + findMember.getId());
tx.commit();
즉, getReference() 메서드를 사용하면 진짜 객체가 아닌 하이버네이트 내부 로직으로 프록시 엔티티 객체가 반환된다.
이때 프록시 객체는 내부 구조 틀은 같지만 내용이 비어있다. 그리고 내부에서는 target 이라는 것이 진짜 객체의 reference를 가리킨다.
프록시 특징
- 실제 클래스를 상속 받아서 만들어진다.
- 실제 클래스와 겉 모양이 같다.
- 사용하는 입장에서는 진짜 객체인지 프록시 객체인지 구분하지 않고 사용하면 된다.
(이론상 - 주의 사항은 아래에서 설명)
- 프록시 객체는 실제 객체의 참조(target)를 보관한다.
- 프록시 객체를 호출(getName())하면 프록시 객체는 실제 객체의 메소드를 호출한다.
프록시 객체의 초기화
- em.getReference()로 프록시 객체를 가져온 다음에, getName() 메서드를 호출 하면 이때 처음엔 MemberProxy 객체에 target 값이 존재하지 않는다.
- JPA가 영속성 컨텍스트에 초기화 요청을 한다. (반드시 영속성 컨텍스트를 통한다)
- 영속성 컨텍스트가 DB에서 조회해서 (이 때 target 초기화를 위해 단 한번 select 쿼리가 실행된다.)
- 실제 Entity를 생성해준다.
- 그리고 프록시 객체가 가지고 있는 target(실제 Member의 Reference)의 getName()을 호출해서 결국 member.getName()을 호출한 결과를 받을 수 있다.
그리고 프록시 객체에 있는 target이 초기화를 통해 한번 할당 되고 나면, 더 이상 프록시 객체의 초기화 과정 없이 실제 Entitiy 사용이 가능하다.
※ 참고
실제 JPA 스팩에는 위와 같은 동작이 명시되어 있지 않는다. 하지만 Hibernate와 같은 구현체들이 각각의 방식으로 내부 구현을 해둠으로써 사용되는 것이다.
프록시 특징 정리
1. 프록시 객체는 처음 사용할 때 한 번만 초기화
2. 프록시 객체를 초기화 할 때, 프록시 객체가 실제로 엔티티로 바뀌는 것은 아니다.
- 초기화 되면 프록시 객체를 통해서 실제 엔티티에 접근 가능하다.
- 정확히 말하면 target에 값이 채워지는 것 뿐이다.
- em.getReference()로 조회한 클래스를 getClass()로 보면, HibernateProxy 클래스였던 것을 위에서 학습했다.
3. (중요!) 프록시 객체는 원본 엔티티를 상속 받는다고 했다. 하지만 프록시 객체와 원본 객체가 타입이 다르므로 타입 체크시 주의해야 한다.
== 비교는 실패한다. 그러므로 jpa에서 타입 비교는 반드시 instanceOf를 사용해야 한다.
Member m1 = em.find(Member.class, member1.getId());
Member m2 = em.getReference(Member.class, member2.getId());
System.out.println("m1 == m2 : " + (m1 == m2));
System.out.println(m2 instanceof Member);
4. (중요!) 영속성 컨텍스트에 찾는 엔티티가 이미 있으면, em.getReference()를 호출해도 실제 엔티티를 반환한다.
- 이때 이미 영속성 컨텍스트에 올려논 객체를 굳이 다시 프록시로 감싸서 반환하는 것은 이점이 없다.
- JPA는 같은 트랜잭션 내의 영속성 컨텍스트에서 조회하는 엔티티의 동일성을 보장한다.
- JPA가 기본적으로 제공하는 매커니즘 중 하나이다. 반복가능한 읽기(repeatable read) 제공
- 따라서, 아래의 코드에서 두 객체는 같다. em.gerReference()로 프록시 객체를 굳이 가져오지 않는다.
Member find = em.find(Member.class, member.getId());
Member reference = em.getReference(Member.class, member.getId());
System.out.println("find == reference : " + (find == reference)); // true
(중요!) 그렇다면 둘다 getReference() 로 가져오면?!
- 둘 다 프록시 객체를 반환한다. JPA는 한 트랜잭션에서 조회하는 같은 엔티티의 동일성을 보장해야 하기 때문에 프록시 객체도 마찬가지로 동일성을 보장한다.
Member reference1 = em.getReference(Member.class, member.getId());
Member reference2 = em.getReference(Member.class, member.getId());
System.out.println("reference1 = " + reference1.getClass());
System.out.println("reference2 = " + reference2.getClass());
System.out.println("reference1 == reference2 : " + (reference1 == reference2)); // true
그러면, getReference()로 먼저 가져오고, find()로 실제 객체를 조회하면?
- 하나는 프록시 객체, 하나는 당연히 find니까 실제 객체이지 않을까?
- 결론 부터 말하면 둘 다 같은 프록시 객체를 반환한다.
- 한 트랜잭션에서 조회하는 같은 엔티티에 대한 동일성을 보장 하기 위해서 즉, 한 트랜잭션 내에서 reference == find를 true로 반환하기 위해서 이렇게 동작한다.
- 여기서 가장 중요한 것은, 이렇게 내부적으로 JPA가 복잡하게 다 처리해주지만, 우리가 개발할때는 프록시던 진짜 객체던 중요하지 않다. 그냥 멤버 조회 하면서 개발 하면 된다.
Member refMember = em.getReference(Member.class, member.getId());
System.out.println("refMember = " + refMember.getClass()); // Proxy
Member findMember = em.getReference(Member.class, member.getId());
System.out.println("findMember = " + findMember.getClass()); // Member
System.out.println("refMember == findMember : " + (refMember == findMember));
5. (주의!) 영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태일 때, 프록시를 초기화하면 문제 발생
- 즉, 트랜잭션의 범위 밖에서 프록시 객체를 조회하려고 할 때 하이버네이트는 org.hibernate.LazyInitializationException 예외를 터트린다.
- em.detach(), em.close(), em.clear() 모두 똑같은 예외가 발생한다.
Member refMember = em.getReference(Member.class, member.getId());
System.out.println("refMember = " + refMember.getClass()); // Proxy
// em.detach(refMember);
em.clear();
refMember.getName();
그렇다면 준영속 상태일 때 왜 프록시를 조회하지 못하는 걸까?
영속성 컨텍스트는 엔티티를 관리하면서 엔티티와 스냅샷을 비교하여 변경을 감지하고, 그 변경 사항을 데이터베이스에 반영하는 역할을 한다. 따라서 영속성 컨텍스트에 의존하는 프록시 객체가 em.clear() 메서드로 인해 영속성 컨텍스트가 초기화되면, 그 이후에 해당 프록시 객체를 사용하려 할 때 영속성 컨텍스트가 없기 때문에 문제가 발생하는 것이다.
또한, 프록시는 실제 엔티티가 아니기 때문에 프록시 초기화를 하기 위해서는 영속성 컨텍스트가 필요하다. 만약 프록시가 필요한 시점에 영속성 컨텍스트가 없다면 프록시 초기화가 불가능하다.
따라서 준영속 상태일 때 프록시를 초기화하려면, 영속성 컨텍스트에 다시 엔티티를 영속화시켜야 하며, 이때는 em.merge() 메서드를 사용하여 엔티티를 병합한 후 프록시를 초기화해야 한다.
※ 참고
이를 해결하기 위해서 Spring Boot에서는 open-in-view 설정을 true로 가져간다.
영속성 컨텍스트(하이버네이트 세션)를 뷰 렌더링하는 시점까지 유지시키는 방법인데, open-session-in-view(OSIV)와 관련된 내용은 아래 두 포스팅을 참고하자.
https://kingbbode.tistory.com/27
https://kwonnam.pe.kr/wiki/springframework/springboot/jpa
프록시 확인 관련 Utils
- 프록시 인스턴스의 초기화 여부 확인
- PersistenceUnitUtil.isLoaded(Object entity)
- refMember.getName()를 통해 프록시를 초기화하면 true를 반환할 것이다.
Member refMember = em.getReference(Member.class, member.getId());
System.out.println("refMember = " + refMember.getClass()); // Proxy
// refMember.getName();
// 엔티티 매니저 팩토리로 부터 get
System.out.println("isLoaded = " + emf.getPersistenceUnitUtil().isLoaded(refMember)); // false
- 프록시 클래스 확인 방법
- entity.getClass().getName() 출력(..javasist.. or HibernateProxy…)
- 프록시 강제 초기화
- org.hibernate.Hibernate.initialize(entity);
Member refMember = em.getReference(Member.class, member.getId());
System.out.println("refMember = " + refMember.getClass()); // Proxy
// refMember.getName(); // 이 방법도 강제 초기화지만 메소드를 사용하는 것이 찝찝...
Hibernate.initialize(refMember); // 강제 초기화
※ 참고
JPA 표준은 강제 초기화 메서드(initialize)가 없다. Hibernate가 지원한다.
그러므로 프록시 객체에서 getXXX() 호출해서 강제로 초기화 해야 한다.
즉시 로딩과 지연 로딩
지연 로딩
Member를 조회할 때 Team도 함께 조회 해야 할까?
- 비즈니스 로직에서 단순히 멤버 로직만 사용하는데 함께 조회하면, 아무리 연관관계가 걸려있다고 해도 손해이다.
JPA는 이 문제를 지연로딩(LAZY)를 사용해서 프록시로 조회하는 방법으로 해결 한다.
- Member와 Team 사이가 다대일 @ManyToOne 관계로 매핑되어 있는 상황에서, @ManyToOne 어노테이션에 fetch 타입을 줄 수 있다.
- FetchType.LAZY
// 패치 타입 LAZY 설정
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn
private Team team;
메인 함수에서 팀과 멤버를 저장하고 조회 해보자.
- Member를 조회하고, Team 객체의 클래스를 확인해보면 Proxy 객체가 조회 된다.
team team = new Team();
team.setName("teamA");
em.persist(team);
Member member = new Member();
member.setUsername("memberA");
em.persist(member);
member.changeTeam(team);
em.flush();
em.clear();
Member findMember = em.find(Member.class, member.getId());
System.out.println(findMember.getTeam().getClass());
팀의 이름을 출력해보자. 이 시점에 즉, 실제로 팀 객체의 조회가 필요한 시점에 쿼리가 나간다.
Team team = new Team();
team.setName("teamA");
em.persist(team);
Member member = new Member();
member.setName("member1");
member.setTeam(team);
em.persist(member);
em.flush();
em.clear();
Member m = em.find(Member.class, member.getId());
System.out.println("m = " + m.getTeam().getClass());
System.out.println("=================");
m.getTeam().getName(); // 초기화
System.out.println("=================");
- 내부 매커니즘은 위의 그림과 같다.
- 로딩되는 시점에 Lazy 로딩 설정이 되어있는 Team 엔티티는 프록시 객체로 가져온다.
- 이때는 DB에 쿼리가 나가지 않는다.
- 후에 실제 객체를 사용하는 시점에(Team을 사용하는 시점에) 초기화가 된다. 즉, DB에 쿼리가 나간다.
- getTeam()으로 Team을 조회하면 프록시 객체가 조회 된다.
- getTeam().getXXX()으로 팀의 필드에 접근 할 때, 쿼리가 나간다.
Q. 대부분 비즈니스 로직에서 Member와 Team을 같이 사용한다면?
- 이런 경우 LAZY 로딩을 사용한다면, SELECT 쿼리가 따로따로 2번 나간다.
- 네트워크를 2번 타서 조회가 이루어 진다는 이야기이다. 손해다.
- 이때는 즉시 로딩(EAGER) 전략을 사용해서 함께 조회하면 된다.
즉시 로딩(EAGER)
- fetch 타입을 EAGER로 설정하면 된다.
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn
private Team team;
- 대부분의 JPA 구현체는 가능하면 조인을 사용해서 SQL 한번에 함께 조회하려고 한다.
- 이렇게 하면, 실제 조회할 때 한방 쿼리로 다 조회해온다.(실제 Team을 사용할 때 쿼리 안나가도 된다.)
- 실행 결과를 보면 Team 객체도 프록시 객체가 아니라 실제 객체이다.
Team team = new Team();
team.setName("teamA");
em.persist(team);
Member member = new Member();
member.setName("member1");
member.setTeam(team);
em.persist(member);
em.flush();
em.clear();
Member m = em.find(Member.class, member.getId());
System.out.println("m = " + m.getTeam().getClass());
System.out.println("=================");
System.out.println("team.name = " + m.getTeam().getName()); // 초기화
System.out.println("=================");
※ 프록시와 즉시로딩 주의
1. 실무에서는 가급적 지연 로딩만 사용하자. 즉시 로딩은 쓰지 말자.
- JPA 구현체도 한번에 가저오려고 하고, 한번에 가져와서 쓰면 좋지 않나?
2. 즉시 로딩을 적용하면 예상하지 못한 SQL이 발생한다.
- @ManyToOne이 설정된 Table이 10개 있는데 전부 EAGER로 설정되어 있다고 생각해보자.
- 예상 조인만 10개 일어난다. 실무에선 테이블이 더 많다.
3. 즉시 로딩은 JPQL에서 N+1 문제를 일으킨다.
- 실무에서 복잡한 쿼리를 많이 풀어내기 위해서 뒤에서 학습할 JPQL을 많이 사용한다.
- em.find()는 PK를 정해놓고 DB에서 가져오기 때문에 JPA 내부에서 최적화를 할 수 있다.(한방 쿼리)
- 하지만, JPQL에선 입력 받은 query string이 그대로 SQL로 변환된다.
- "select m from Member m" 이 문장은 당연히 Member만 SELECT 하는 것을 기대할 것이다.
- 그래서 MEMBER를 쭉 다 가져와서 보니까
- 어?! 근데, Member 엔티티의 Team의 fetchType이 EAGER 이네?
- LAZY면 프록시를 넣으면 되겠지만, EAGER는 반환하는 시점에 다 조회가 되어 있어야 한다.
- 따라서, Member를 다 가져오고 나서, 그 Member와 연관된 Team을 다시 다 가져온다.
코드
- 멤버가 2명이고, 팀도 2개다. 각각 다른 팀이다.
- 모든 멤버를 조회해보자.
/*Member*/
@Entity
public class Member{
...
@ManyToOne(fetch = FetchType.EAGER) //즉시로딩 사용
@JoinColumn(name="TEAM_ID")
private Team team;
...
}
Team team1 = new Team();
team1.setName("teamA");
em.persist(team1);
Team team2 = new Team();
team2.setName("teamB");
em.persist(team2);
Member member1 = new Member();
member1.setUsername("memberA");
em.persist(member1);
member1.changeTeam(team1);
Member member2 = new Member();
member2.setUsername("memberB");
em.persist(member2);
member2.changeTeam(team2);
em.flush();
em.clear();
List<Member> members = em
.createQuery("select m from Member m", Member.class)
.getResultList();
tx.commit();
- 실행 결과를 보면, 일단 모든 멤버를 조회해서 가져온다.
- 그리고 나서 Member들의 Team이 비어있으니까 채워서 반환시키기 위해서 TEAM을 각각 쿼리 날려서 가져온다.
- 멤버가 수천 수만명이라고 생각하면...... ? 감당 못한다.
- 즉, N + 1의 문제의 의미는
- 아래 처럼 쿼리를 1개 날렸는데, 그것 때문에 추가 쿼리가 N개 나간다는 의미이다.
- 결론: 실무에서는 LAZY 로딩 전략을 가져가자.
- 근데 실무에서는 대부분 멤버와 팀을 함께 사용하는 경우가 있는데, 그러면 LAZY로 해놓고 계속 쿼리 두방 날려서 조회 해올까?
- 이런 경우를 위해서 JPQL의 fetch join 을 통해서 해당 시점에 한방 쿼리로 가져와서 쓸 수 있다.
- 추가적으로 엔티티 그래프와 어노테이션으로 푸는 방법, 배치 사이즈 설정으로 해결하는 방법도 있다.
- 대부분 fetch join으로 해결 한다.
4. @ManyToOne, @OneToOne과 같이 @XXXToOne 어노테이션들은 기본이 즉시 로딩(EAGER) 이다.
- 꼭 LAZY로 명시적으로 설정해서 사용하자
5. @OneToMany, @ManyToMany는 기본이 지연 로딩
지연 로딩 활용
이론적으로
- Member와 Team 은 자주 함께 사용 → 즉시 로딩
- Member와 Order는 가끔 사용 → 지연 로딩
- Order와 Product는 자주 함께 사용 → 즉시 로딩
지연 로딩 활용 - 실무
- 모든 연관관계에 지연 로딩을 사용해라!
- 실무에서 즉시 로딩을 사용하지 마라!
- JPQL fetch 조인이나, 엔티티 그래프 기능을 사용해라!
- 즉시 로딩은 상상하지 못한 쿼리가 나간다.
참고