프록시, 즉시 로딩 그리고 지연로딩

hyyyynjn·2021년 9월 7일
0
post-thumbnail

✅ 프록시

데이터베이스에서 Member를 조회할 때 Team도 함께 조회해야 할까?

비즈니스 상황마다 다르지만
회원과 팀을 함께 출력하는 printUserAndTeam의 경우 Member, Team을 따로 가져오는 것 보다 한번에 같이 가져오는게 성능상 이득이다
회원만 출력하는 printUser의 경우 Team 데이터까지 같이 가져올 필요 없다. (낭비)
👉 JPA는 이런 상황을 프록시지연로딩으로 해결한다.

프록시 기초

📌em.find() vs em.getReference()

  • em.find()
    • 데이터베이스를 통해서 실제 엔티티 객체 조회
  • em.getReference()
    • 데이터베이스 조회를 미루는 가짜(프록시) 엔티티 객체 조회
    • 데이터베이스에 쿼리를 날리지 않고 조회하는 방법이다.
Member member = new Member();
member.setUserName("hello");

em.persist(member);

em.flush();
em.clear();

// select 쿼리가 나간다
Member findMember = em.find(Member.class, member.getId());
System.out.println("=== em.find(Member.class, member.getId()) ===");
System.out.println("findMember.getUserName() = " + findMember.getUserName());
System.out.println("findMember.getId() = " + findMember.getId());

System.out.println("=== commit ===");
tx.commit();

em.find(Member.class, member.getId()); 호출 시점에 select 쿼리가 나간뒤 findMember 변수에 데이터를 담는다.

Member member = new Member();
member.setUserName("hello");

em.persist(member);

em.flush();
em.clear();

// getReference 시점에 select 쿼리가 나가지 않는다
Member findMemberRef = em.getReference(Member.class, member.getId());
System.out.println("=== em.getReference(Member.class, member.getId()) ===");

System.out.println("=== commit ===");
tx.commit();

em.getReference(Member.class, member.getId()); 호출 시점에 select 쿼리가 나가지 않는다.

Member member = new Member();
member.setUserName("hello");

em.persist(member);

em.flush();
em.clear();

// getReference 시점에 select 쿼리가 나가지 않는다
Member findMemberRef = em.getReference(Member.class, member.getId());
System.out.println("=== em.getReference(Member.class, member.getId()) ===");
System.out.println("BEFORE findMemberRef = " + findMemberRef.getClass());
System.out.println("findMemberRef.getId() = " + findMemberRef.getId());
// findMemberRef의 데이터를 참조하는 시점에 select 쿼리가 나간다
System.out.println("findMemberRef.getUserName() = " + findMemberRef.getUserName());
System.out.println("findMemberRef.getUserName() = " + findMemberRef.getUserName());
System.out.println("AFTER findMemberRef = " + findMemberRef.getClass());

System.out.println("=== commit ===");
tx.commit();

  • findMemberRef.getUserName() 호출시점에 select 쿼리가 나간다
    • findMemberRef.getId() 호출 시점에는 이미 findMemberRef 변수에 id값이 담겨져 있기 때문에 쿼리가 나가지 않는다.
      • findMemberRef 는 id 값만 가지고 있는 텅 빈 껍데기 객체이다.
    • findMemberRef 객체의 userName 속성 데이터가 없기 때문에 데이터베이스에서 가져오기 위해 select 쿼리를 날린다.
      • 두번째 findMemberRef.getUserName() 호출 시점에는 쿼리가 나가지 않고 findMemberRef 객체의 값을 참조한다.
  • findMemberRef.getClass() 를 출력하면 Member$HibernateProxy$LehDcP3W이다.
    • 하이버네이트가 만든 가짜 클래스 (프록시 클래스) 라는 의미이다

프록시 특징

  • 실제 클래스를 상속 받아서 만들어진다 (하이버네이트가 내부적으로 프록시 라이브러리를 이용하여 만든다)
  • 실제 클래스와 겉 모양이 같다.
  • 사용하는 입장에서는 진짜 객체인지 프록시 객체인지 구분하지 않고 사용하면 됨 (이론상)

  • 프록시 객체는 실제 객체의 target 이라는 참조를 보관한다.
  • 프록시 객체를 호출하면 프록시 객체는 실제 객체의 메소드 호출한다
    • 즉, Proxy 객체의 getName()이 호출되면 target이라는 실제 객체인 EntitygetName()을 대신 호출한다.
    • 데이터베이스에 조회하지 않는 이상 처음에는 target이 없다

프록시 객체의 초기화 과정

처음에 getName()을 호출한다
👉 MemberProxy의 getName()이 호출되면 target (Member)의 getName()이 대신 호출된다
👉 하지만 target (Member)는 null이므로 JPA가 영속성 컨택스트에 target 초기화를 요청한다.
👉 그제서야 영속성 컨택스트는 데이터베이스에 조회하고 실제 Entity 객체를 생성하여 반환한다
👉 그 다음 Member Proxy의 Member target에 실제 Entity 객체를 연결한다
👉 target.getName() 이 호출되면 실제 Entity 객체의 getName()이 호출된다.

프록시 특징 정리

  • 프록시 객체는 처음 사용할 때 한 번만 초기화된다.
  • 프록시 객체를 초기화 할 때, 프록시 객체가 실제 엔티티로 바뀌는 것은 아니다.
    초기화되면 프록시 객체를 통해서 실제 엔티티에 접근 가능하게 되는 것이다.
    • 프록시 객체는 유지가 되고 프록시 객체 내부의 target값이 채워지는 것이다.
  • 프록시 객체는 원본 엔티티를 상속받는다. 따라서 타입 체크시 주의해야한다.
    (== 비교 대신 instance of 사용해야한다.)
  • 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 em.getReference()를 호출해도 실제 엔티티 반환한다.
    • 이 반대 상황도 마찬가지다. (프록시 객체로 조회하면 em.find()를 호출해도 프록시 객체를 반환한다.)
    • 왜냐하면, JPA는 동일 트랜잭션안에서 동일 영속성 컨택스트 속에 조회되는 Entity의 동일성을 보장해줘야하기 떄문이다.
  • 영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태일 때, 프록시를 초기화하면 문제가 발생한다.
    (하이버네이트는 org.hibernate.LazyInitializationException 예외를 터트림)
  • 📌프록시 객체는 원본 엔티티를 상속받는다. 따라서 타입 체크시 주의해야한다.
Member member1 = new Member();
member1.setUserName("hello1");
em.persist(member1);

Member member2 = new Member();
member2.setUserName("hello2");
em.persist(member2);

em.flush();
em.clear();

Member m1 = em.find(Member.class, member1.getId());
Member m2 = em.find(Member.class, member2.getId());

logic(m1, m2);
---
private static void logic(Member m1, Member m2) {
    System.out.println("m1 == m2 : " + (m1.getClass() == m2.getClass()));
}

Member m1 = em.find(Member.class, member1.getId());
Member m2 = em.getReference(Member.class, member2.getId());

logic(m1, m2);
---
private static void logic(Member m1, Member m2) {
    System.out.println("m1 == m2 : " + (m1.getClass() == m2.getClass()));
}

findgetReference으로 얻은 객체 타입은 다르다.

  • 실제 비즈니스 로직에서 타입 비교시
    객체가 프록시 객체인지 실제 객체인지 확인하기 어렵기 때문에 == 으로 타입 비교하면 안된다.
    👉 타입비교는 m1 instance of Member로 해야한다.
  • 📌영속성 컨텍스트에 찾는 엔티티가 이미 있으면 em.getReference()를 호출해도 실제 엔티티 반환한다.
Member m1 = em.find(Member.class, member1.getId());
System.out.println("m1 = " + m1.getClass());

Member reference = em.getReference(Member.class, member1.getId());
System.out.println("reference = " + reference.getClass());

System.out.println("m1 == reference : " + (m1 == reference));

// 프록시 객체 refMember
Member refMember = em.getReference(Member.class, member1.getId());
System.out.println("refMember = " + refMember.getClass());

// 실제 객체 findMember ? => 프록시 객체 findMember
Member findMember = em.getReference(Member.class, member1.getId());
System.out.println("reference = " + findMember.getClass());

// 프록시 refMember == 실제 findMember : true
System.out.println("refMember == reference : " + (refMember == findMember));

  • 📌영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태일 때, 프록시를 초기화하면 문제가 발생한다.
Member member1 = new Member();
member1.setUserName("hello1");
em.persist(member1);

em.flush();
em.clear();

// 프록시 객체 refMember
Member refMember = em.getReference(Member.class, member1.getId());
System.out.println("refMember = " + refMember.getClass());

// refMember는 영속성 컨택스트에서 더이상 관리하지 않는다.
// em.clear() 도 마찬가지
em.detach(refMember);

// 에러 발생
// could not initialize proxy [hellojpa.Member#1] - no Session
System.out.println("refMember = " + refMember.getUserName());

org.hibernate.LazyInitializationException 예외

프록시 객체가 영속성 컨택스트의 도움을 받지 못하면 초기화 요청을 할 수 없게 된다.
👉 실제 객체를 가져오지 못하기 때문에 프록시 객체를 초기화할 수 없다.

프록시 확인

  • 프록시 인스턴스의 초기화 여부 확인
    PersistenceUnitUtil.isLoaded(Object entity)
  • 프록시 클래스 확인 방법
    entity.getClass().getName() 출력(..javasist.. or HibernateProxy…)
  • 프록시 강제 초기화
    org.hibernate.Hibernate.initialize(entity);
  • 참고: JPA 표준은 강제 초기화 없음
    강제 호출: member.getName()

프록시 인스턴스의 초기화 여부 확인

PersistenceUnitUtil.isLoaded(Object entity)

Member refMember = em.getReference(Member.class, member1.getId());
// 프록시 객체 refMember
System.out.println("refMember = " + refMember.getClass());
// 아직 프록시 초기화 하지 않았으므로 false
System.out.println("1 isLoaded = " + emf.getPersistenceUnitUtil().isLoaded(refMember));
String userName = refMember.getUserName();
// 프록시가 초기화 되었으므로 true
System.out.println("2 isLoaded = " + emf.getPersistenceUnitUtil().isLoaded(refMember));


✅ 즉시 로딩과 지연 로딩

지연로딩

Member를 조회할 때 Team도 함께 조회해야 할까?

단순히 Member 정보만 사용하는 비즈니스 로직에서 연관관계 매핑되었다는 이유로 Team 정보를 조인해서 가져오면 손해다.
그래서 JPA는 지연 로딩이라는 옵션을 제공한다.

지연 로딩 LAZY을 사용해서 프록시로 조회

@Entity
public class Member extends BaseEntity {

    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    @Column(name = "USERNAME")
    private String userName;
    
    // LAZY : 지연로딩 전략을 따른다
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn
    private Team team;}

@ManyToOne(fetch = FetchType.LAZY)

  • 지연 로딩 전략을 따른다.
  • Team team 객체는 프록시 객체로 조회한다.
Team team = new Team();
team.setName("teamA");
em.persist(team);

Member member1 = new Member();
member1.setUserName("hello1");
member1.setTeam(team);
em.persist(member1);

em.flush();
em.clear();

System.out.println("=== BEFORE em.find(Member.class, member1.getId()) ===");
Member m = em.find(Member.class, member1.getId());
System.out.println("=== AFTER em.find(Member.class, member1.getId()) ===");

// Team 객체는 프록시 객체이다.
System.out.println("m.getTeam().getClass() = " + m.getTeam().getClass());

// 프록시 객체인 Team을 조회하는 시점에 select 쿼리가 나간다
System.out.println("=== BEFORE m.getTeam().getName() ===");
System.out.println("m.getTeam().getName() = " + m.getTeam().getName());
System.out.println("=== AFTER m.getTeam().getName() ===");

System.out.println("=== commit ===");
tx.commit();

지연 로딩의 내부 동작 방식

member1 로딩시 team1 객체는 DB에서 값을 가져오지 않고 프록시 객체로 세팅되어있다.

Team 객체는 지연 로딩(@ManyToOne(fetch = FetchType.LAZY) )으로 설정 되어있다.
member 조회시 Team 객체는 프록시 객체로 초기화 된다.

프록시 객체인 team의 데이터를 참조하는 시점에 프록시 객체 team은 실제 엔티티 객체를 가리키도록 초기화된다.

즉시 로딩

Member와 Team을 자주 함께 사용한다면?

비즈니스 로직에서 Member와 Team을 같이 사용하는 경우엔 함께 조회하는게 좋다

즉시 로딩 EAGER를 사용해서 함께 조회

@Entity
public class Member extends BaseEntity {

    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    @Column(name = "USERNAME")
    private String userName;
    
    // EAGER: 즉시 로딩 전략을 따른다
    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn
    private Team team;}

Member 객체를 조회할 때, Team 객체 정보까지 한번에 조회한다. (조인)

  • 즉시 로딩이므로 Team 객체는 프록시가 아닌 실제 엔티티 객체이다.

즉시 로딩의 내부 동작 방식

member1 로딩시 team1 까지 조인하여 조회한다.

즉시 로딩(EAGER), Member조회시 항상 Team도 조회

  • JPA 구현체는 가능하면 조인을 사용해서 SQL 한번에 함께 조회한다.
  • 즉시 로딩하는 2가지 방법
    • member를 조회할 때, 한번에 team 데이터까지 조인하여 가져오는 방법 👉 대부분의 하이버네이트 구현체는 이 방식을 사용한다.
    • member를 조회한 뒤, team을 나중에 조회 (em.find() 호출 시점에 쿼리가 2번 나간다)

프록시와 즉시로딩 주의점

  • 가급적 지연 로딩만 사용해야 한다.(특히 실무에서)
    • 실무에서는 즉시 로딩을 사용하면 안된다. NEVER EVER
  • 즉시 로딩을 적용하면 예상하지 못한 SQL이 발생한다.
    • 하나의 테이블에 여러 개의 연관관계가 물려있을 경우, 즉시 로딩을 따르면 여러 테이블 조인된다
      👉 성능문제 발생
  • 즉시 로딩은 JPQL에서 N+1 문제를 일으킨다.
  • @ManyToOne, @OneToOne은 기본이 즉시 로딩 EAGER 👉 LAZY로 설정해야 한다.
  • @OneToMany, @ManyToMany는 기본이 지연 로딩 LAZY
  • 📌즉시 로딩은 JPQL에서 N+1 문제를 일으킨다.
System.out.println("=== em.createQuery ===");
List<Member> members = em.createQuery("select m from Member m", Member.class)
        .getResultList();

em.find() 를 사용하면 JPA가 조인으로 최적화하여 가져올 수 있지만,
JPQL은 SQL으로 번역되어 나가기 때문에 조인하여 가져오지 않는다.

  • select m from Member m 를 sql로 번역하면 👉 그냥 Member에 대한 select쿼리가 나가게 된다.
  • Member 정보를 가져왔더니, Team 참조 필드가 즉시 로딩 (EAGER) 으로 설정 되어있기 때문에 👉 Team에 대한 select 쿼리가 나간다.
  • 즉, 값을 채워야할 데이터만큼 추가 쿼리가 나간다.
    👉 처음 나가는 쿼리 1 + 추가 쿼리 N : N + 1 문제를 일으킨다.
  • 📌N + 1 문제의 해결 방법
  • 처음에 연관관계 매핑시 지연 로딩 (LAZY)으로 설정한다.
  • 3가지 방법을 사용한다.
    • fetch join
      - 런타임시 동적으로 원하는 연관관계를 선택해서 조인하여 가져온다.
      - Member만 가져올 때는 기본적으로 Member 만 select한다 (Team은 지연로딩으로 설정되어있기 떄문).
      Member와 Team을 가져올 때는 fetch join을 사용하여 함께 가져온다.
      - em.createQuery("select m from Member m join fetch m.team", Member.class)

✅ 지연 로딩 활용

이론적인 활용이기 때문에 실무에서 예시처럼 활용하면 큰일난다. (실무에선 무조건 지연 로딩만)

  • Member와 Team은 자주 함께 사용 👉 즉시 로딩
  • Member와 Order는 가끔 사용 👉 지연 로딩
  • Order와 Product는 자주 함께 사용 👉 즉시 로딩

member1 조회시

  • team 은 조인하고
  • orders는 프록시 객체로 초기화한다.

실무에서

  • 모든 연관관계에 지연 로딩을 사용해라!
  • 실무에서 즉시 로딩을 사용하지 마라!
  • JPQL fetch 조인이나, 엔티티 그래프 기능을 사용해라!
  • 즉시 로딩은 상상하지 못한 쿼리가 나간다.

0개의 댓글