JPA - 프록시(Proxy)

Let’s learn about proxy

다음과 같이 회원 이름만 출력하는 코드가 있을 때 과연 멤버와 팀을 다 조회할 필요가 있을까? 조회해도 되지만 굳이 불필요하다는 걸 알 수 있다. 어떻게 개선할 수 있을까? 이를 위해 프록시(Proxy), 지연 로딩(Lazy Loading), 즉시 로딩(Eager Loading)에 대해 한번 알아보자.

public void printUser(String memberId) {
  Member member = em.find(Member.class, memberId);
  Team team = member.getTeam();
  System.out.println("회원 이름: " + member.getUseranme());
}

프록시(Proxy)

프록시는 대리(행위), 대리권, 대리인 등의 의미를 가지는데 여기서는 프록시 객체가 실제 객체의 참조를 보관하다 실제 사용할 때 실제 객체에 접근한다고 생각하면 된다.

프록시의 특징으로는

  • 실제 클래스를 상속 받아서 만들어졌다.
  • 실제 클래스와 겉 모양이 같다.
  • 프록시 객체는 실제 객체의 참조(target)를 보관한다.
  • 프록시 객체를 호출하면 프록시 객체는 실체 객체의 메소드 호출한다.

em.find() vs em.getReference()

em.find()는 데이터베이스를 통해서 실제 엔티티 객체를 조회해오고, em.getReference()는 데이터베이스 조회를 미루는 가짜(프록시) 엔티티 객체를 조회해온다.


...

Member findMember = em.find(Member.class, member.getId());
System.out.println("findMember: " + findMember.getClass());

// -> findMember: Member 객체

...

Member refMember = em.getReference(Member.class, member.getId());
System.out.println("refMember: " + refMember.getClass());

// -> refMember: 프록시 객체(ex) Member$HibernateProxy$odcVHpjy)

프록시 객체 초기화 과정

Member member = em.getReference(Member.class, id1); 
member.getName();
  1. getName() 호출 (처음에 Member target에 값이 없으면 2번 진행, 있으면 5번)
  2. JPA가 영속성 컨텍스트에 초기화 요청
  3. 영속성 컨텍스트가 DB를 조회한 후
  4. 실제 Entity를 생성해서 target에 연결
  5. target.getName()으로 진짜 Member의 getName을 접근해 이름을 반환

프록시의 특징

  • 프록시 객체는 처음 사용할 때 한 번만 초기화
    • 처음 한 번만 초기화하면 target에 값이 채워져, target에 값이 있을 때부터는 바로 5번(초기화 과정 5번)을 실행한다.
    • 즉, DB에 쿼리를 또 날리지 않는다.
  • 프록시 객체를 초기화할 때, 프록시 객체가 실제 엔티티로 바뀌는 것은 아님, 초기화되면 프록시 객체를 통해서 실제 엔티티에 접근 가능
    • 프록시 객체를 초기화하고 getClass를 해봐도 그대로 프록시 객체임
  • 프록시 객체는 원본 엔티티를 상속받음, 따라서 타입 체크 시 주의해야 함
    • 원본 엔티티와 ==를 하면 비교 실패할 수가 있기 때문에 instance of를 사용
  • 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 em.getReference()를 호출해 도 실제 엔티티 반환
      Member findMember = em.find(Member.class, member.getId());
      System.out.println("findMember: " + findMember.getClass());
    
      Member refMember = em.getReference(Member.class, member.getId());
      System.out.println("refMember: " + refMember.getClass());
    
      Systemout.println("findMember == refMember:" + (findMember == refMember));
    
      // -> findMember: Member 객체
      // -> refMember: Member 객체
      // -> findMember == refMember: true
    
    • 분명 em.getReference() 하면 프록시가 나온다 했는데 왜 실제 엔티티가 나왔을까?
      • JPA에서는 같은 인스턴스의 ==에 대해 같은 영속성 컨텍스트 안에서 조회하면 항상 같다고 나옴.(이게 실제 객체든 프록시든 간에 상관없이 마치 자바 컬렉션에서 가져온 걸 == 비교하듯이)
      • 이미 멤버를 영속성 컨텍스트(1차 캐시)에 올려놨는데 굳이 프록시로 받을 필요가 없어서(프록시로 받으면 오히려 손해)
    • 그럼 반대로 em.getReference()를 먼저 하고 뒤에 em.find()를 하게 되면 어떻게 될까?
      • 둘 다 프록시 객체가 반환되게 된다.
  • 영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태일 때, 프록시를 초기화하면 문제 발생
      Member refMember = em.getReference(Member.class, member.getId());
      System.out.println("refMember: " + refMember.getClass()); // Proxy
    
      em.detach(refMember); //영속성 분리
    
      refMember.getUsername(); //Exception 발생!
    
    • 하이버네이트는 org.hibernate.LazyInitializationException 예외를 터트림

프록시 확인

  • EntityManagerFactory로 부터 getPersistenceUnitUtil()
  • 프록시 인스턴스의 초기화 여부 확인
    • PersistenceUnitUtil.isLoaded(Object entity)
    • 초기화 시 true
  • 프록시 클래스 확인 방법
    • entity.getClass().getName()
    • ex) Entity$HibernateProxy$QPwAxVTy
  • 프록시 강제 초기화
    • org.hibernate.Hibernate.initialize(entity);
  • 참고: JPA 표준은 강제 초기화 없음
    • 강제 호출: member.getName()

근데 실제로는 getReference 이런 건 잘 쓰지 않는다. 하지만 이제 즉시 로딩과 지연 로딩에 대해 설명할 건데 앞의 프록시 매커니즘을 잘 알고 있어야 즉시 로딩, 지연 로딩에 대해 깊이 있게 이해할 수 있기 때문에 자세하게 설명했다. JPA가 제공하는 지연 로딩과 즉시 로딩에 대해 알아보자.

지연 로딩(Lazy Loading), 즉시 로딩(Eager Loading)

지연 로딩 (FetchType.LAZY)

단순히 member 정보만 사용하는 비즈니스 로직이 있다고 하면 Member를 조회할 때 Team도 함께 조회한다고 하면 손해이다. 그래서 JPA는 지연로딩이라는 Option을 제공한다.

@Entity
 public class Member {
  @Id
  @GeneratedValue
  private Long id;

  @Column(name = "USERNAME")
  private String name;

  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "TEAM_ID")
  private Team team;

  .. 
}

위와 같이 FetchType.LAZY를 주게 되면 Member를 조회하더라도 Team은 프록시 객체로 조회하게 되어서 나중에 실제로 team을 사용하는 시점에 팀을 초기화(DB 조회) 할 수 있다.

즉시 로딩 (FetchType.EAGER)

하지만 Member와 Team을 자주 함께 사용한다고 하면? 즉시 로딩이라는 Option도 사용할 수 있다.

@Entity
 public class Member {
  @Id
  @GeneratedValue
  private Long id;

  @Column(name = "USERNAME")
  private String name;

  @ManyToOne(fetch = FetchType.EAGER)
  @JoinColumn(name = "TEAM_ID")
  private Team team;
  .. 
}

위와 같이 FetchType.EAGER를 주게 되면 Member 조회시 항상 Team도 조회하게 된다. JPA 구현체는 가능하면 조인을 사용해서 SQL 한번에 함께 조회한다.

프록시와 즉시 로딩 주의

  • 가급적 지연 로딩만 사용하자(특히 실무에서)
  • 즉시 로딩을 적용하면 예상하지 못한 SQL이 발생한다.
    • 어떤 엔티티를 조회하면 즉시 로딩으로 연결된 엔티티를 다 Join 하기 때문에
  • 즉시 로딩은 JPQL에서 N+1 문제를 일으킨다.
    • N+1: 최초 쿼리를 한 개 날렸는데 추가로 N 개가 나가는 것
  • @ManyToOne, @OneToOne은 기본이 즉시 로딩
    • LAZY로 설정해 주자
  • @OneToMany, @ManyToMany는 기본이 지연 로딩

지연 로딩 활용 - 실무

  • 모든 연관관계에 지연 로딩을 사용하자!
  • 실무에서 즉시 로딩을 사용하지 말자!
  • JPQL fetch 조인이나, 엔티티 그래프 기능을 사용하자!

참고: JPA

*틀린 부분이 있으면 언제든지 말씀해 주시면 공부해서 수정하겠습니다.


© 2022. All rights reserved.

Powered by 애송이