프록시, 즉시로딩과 지연로딩

작성일

개요

객체는 데이터베이스에 저장되어 있으므로 연관된 객체를 탐색하기 어렵다. JPA에서 이 문제를 해결하기 위해 프록시 기술을 사용한다. 프록시를 사용하면 연관된 객체를 처음부터 DB에서 조회하는 것이 아니라 실제 사용하는 시점에 DB에서 조회할 수 있다. 하지만, 자주 함께 사용하는 객체들은 조인을 사용해서 함께 조회하는 것이 효과적이다. JPA는 즉시 로딩과 지연 로딩 두 방법을 모두 지원한다.

프록시

엔티티를 조회할 때 연관된 엔티티들이 항상 사용되는 것은 아니다. 연관관계의 엔티티는 비즈니스 로직에 따라 사용될 때도 있고 그렇지 않을 때도 있다.

JPA는 이런 문제를 해결하기 위해 엔티티가 실제 사용될 때까지 DB 조회를 지연하는 방법을 제공하는데 이를 지연 로딩(LAZY)이라 한다. 지연 로딩을 사용하려면 실제 엔티티 객체 대상에 DB 조회를 지연할 수 있는 가짜 객체가 필요한데 이것을 프록시 객체라 한다.

프록시 기초

JPA에는 em.find()와 em.getReference() 메서드가 있다.

em.find()

em.find()는 데이터베이스를 통해서 실제 엔티티 객체를 조회하는 메서드이다.

Member findMember = em.find(Member.class, member.getId());
Hibernate: 
    select
        member0_.MEMBER_ID as member_i1_4_0_,
        member0_.createdBy as createdb2_4_0_,
        member0_.createdDate as createdd3_4_0_,
        member0_.lastModifiedBy as lastmodi4_4_0_,
        member0_.modifiedDate as modified5_4_0_,
        member0_.city as city6_4_0_,
        member0_.name as name7_4_0_,
        member0_.street as street8_4_0_,
        member0_.TEAM_ID as team_id11_4_0_,
        member0_.username as username9_4_0_,
        member0_.zipcode as zipcode10_4_0_,
        team1_.TEAM_ID as team_id1_7_1_ 
    from
        Member member0_ 
    left outer join
        Team team1_ 
            on member0_.TEAM_ID=team1_.TEAM_ID 
    where
        member0_.MEMBER_ID=?

em.getReference()

엔티티를 실제 사용하는 시점까지 DB 조회를 미루고 싶을 때 em.getReference()를 사용한다. 이 메소드를 호출하면 프록시 객체를 반환한다. (하이버네이트가 어떤 라이브러리를 사용하여 가짜를 조회한다.)

Member findMember = em.getReference(Member.class, member.getId());
// select 문 발생 안함.

3

em.getReference() 후 실제 엔티티를 사용했을 때 DB를 조회하는 select 문이 발생한다.

Member findMember = em.getReference(Member.class, member.getId());
System.out.println("findMember = " + findMember);
System.out.println("findMember.id = " + findMember.getId());
Hibernate: 
    select
        member0_.MEMBER_ID as member_i1_4_0_,
        member0_.createdBy as createdb2_4_0_,
        member0_.createdDate as createdd3_4_0_,
        member0_.lastModifiedBy as lastmodi4_4_0_,
        member0_.modifiedDate as modified5_4_0_,
        member0_.city as city6_4_0_,
        member0_.name as name7_4_0_,
        member0_.street as street8_4_0_,
        member0_.TEAM_ID as team_id11_4_0_,
        member0_.username as username9_4_0_,
        member0_.zipcode as zipcode10_4_0_,
        team1_.TEAM_ID as team_id1_7_1_ 
    from
        Member member0_ 
    left outer join
        Team team1_ 
            on member0_.TEAM_ID=team1_.TEAM_ID 
    where
        member0_.MEMBER_ID=?
findMember = jpabook.jpashop.domain.Member@6bcb12e6
findMember.id = 1

프록시 구조

프록시 클래스는 실제 클래스(엔티티)를 상속 받아서 만들어져 실제 클래스와 겉 모양이 같다. 따라서, 사용하는 입장에서는 진짜 객체인지 프록시 객체인지 구분하지 않고 사용하면 된다. (이론상)

1

프록시 객체는 실제 객체의 참조(target)를 보관하고 있다가 프록시 객체를 호출하면 프록시 객체는 실제 객체의 메소드를 호출한다.

2

프록시 특징

  • 프록시 객체는 처음 사용할 때 한 번만 초기화한다.
  • 프록시 객체를 초기화 한다고 프록시 객체가 실제 엔티티가 되는 것이 아니라 프록시 객체가 초기화 되면서 프록시 객체를 통해서 실제 엔티티에 접근할 수 있게 된다.
  • 프록시 객체는 원본 엔티티를 상속 받은 객체이므로 타입 체크 시에 주의해서 사용해야 한다.
    • instanceof를 사용
      Member member1 = new Member();
      member1.setUsername("member1");
      em.persist(member1);
        
      Member member2 = new Member();
      member2.setUsername("member2");
      em.persist(member2);
        
      em.flush();
      em.clear();
        
      Member m1 = em.find(Member.class, member1.getId());
      Member m2 = em.getReference(Member.class, member2.getId());
        
      System.out.println("m1 == m2: " + (m1.getClass() == m2.getClass()));
      System.out.println("m1 == m2: " + (m1 instanceof Member));
      System.out.println("m1 == m2: " + (m2 instanceof Member));
    
      m1 == m2: false
      m1 == m2: true
      m1 == m2: true
    
  • 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 em.getReference()를 호출해도 프록시가 아닌 실제 엔티티 반환한다.

    [예시] 엔티티 조회 후 Proxy 객체 조회할 때

      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());
    
      m1 = class jpabook.jpashop.domain.Member
      reference = class jpabook.jpashop.domain.Member
    

    [예시] Proxy 객체 조회 후 엔티티 조회 할 때

      Member refMember = em.getReference(Member.class, member1.getId());
      System.out.println("refMember = " + refMember.getClass());
        
      // Member를 가져올 것 같지만 위에서 Proxy를 한번 호출했기 때문에
      // em.find를 해도 Proxy를 가져온다
      Member findMember = em.find(Member.class, member1.getId());
      System.out.println("findMember = " + findMember.getClass());
        
      System.out.println("refMember == findMember: " + (refMember == findMember));
    
      refMember = class jpabook.jpashop.domain.Member$HibernateProxy$VJEp6TpB
      Hibernate: 
          select
              member0_.MEMBER_ID as member_i1_4_0_,
              member0_.createdBy as createdb2_4_0_,
              member0_.createdDate as createdd3_4_0_,
              member0_.lastModifiedBy as lastmodi4_4_0_,
              member0_.modifiedDate as modified5_4_0_,
              member0_.city as city6_4_0_,
              member0_.name as name7_4_0_,
              member0_.street as street8_4_0_,
              member0_.TEAM_ID as team_id11_4_0_,
              member0_.username as username9_4_0_,
              member0_.zipcode as zipcode10_4_0_,
              team1_.TEAM_ID as team_id1_7_1_ 
          from
              Member member0_ 
          left outer join
              Team team1_ 
                  on member0_.TEAM_ID=team1_.TEAM_ID 
          where
              member0_.MEMBER_ID=?
      findMember = class jpabook.jpashop.domain.Member$HibernateProxy$VJEp6TpB
      refMember == findMember: true
    
  • 영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태일 때, 프록시를 초기화하면 문제가 발생한다.
    • 하이버네이트는 org.hibernate.LazyInitializationException 예외를 터트린다.
      Member refMember = em.getReference(Member.class, member1.getId());
      System.out.println("refMember = " + refMember.getClass());
        
      // 준영속 상태로 전환
      em.detach(refMember);
        
      // 프록시 초기화
      refMember.getUsername();
    
      refMember = class jpabook.jpashop.domain.Member$HibernateProxy$jeLlOsNZ
      org.hibernate.LazyInitializationException: could not initialize proxy [jpabook.jpashop.domain.Member#1] - no Session
      	at org.hibernate.proxy.AbstractLazyInitializer.initialize(AbstractLazyInitializer.java:176)
      	at org.hibernate.proxy.AbstractLazyInitializer.getImplementation(AbstractLazyInitializer.java:322)
      	at org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor.intercept(ByteBuddyInterceptor.java:45)
      	at org.hibernate.proxy.ProxyConfiguration$InterceptorDispatcher.intercept(ProxyConfiguration.java:95)
      	at jpabook.jpashop.domain.Member$HibernateProxy$jeLlOsNZ.getUsername(Unknown Source)
      	at jpabook.jpashop.JpaMain.main(JpaMain.java:38)
    

참고 Team이 프록시 객체일 때

m.getTeam(); // 이땐 조회 쿼리 발생 안함
m.getTeam().getName(); // 실제 사용하므로 조회 쿼리 발생함

프록시 객체와 지연 로딩, 즉시 로딩

엔티티를 조회할 때 연관된 엔티티가 함께 조회되는 것과 연관된 엔티티는 실제 사용 시점에 조회되는 것 둘 중 어느 방법이 좋을까? 정답은 없다. 상황에 따라 다르다.

  • 즉시 로딩: 엔티티를 조회할 때 연관된 엔티티도 함께 조회한다.
    • fetch = FetchType.EAGER
  • 지연 로딩: 연관된 엔티티를 실제 사용할 때 조회한다.
    • fetch = FetchType.LAZY

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

지연 로딩은 연관된 엔티티(Team)를 프록시로 가져온다. 그리고 실제 사용 시점에 초기화를 한다.

public class Member {
    ...

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "TEAM_ID")
    private Team team;
}
Team team = new Team();
team.setName("teamA");
 em.persist(team);

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

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

// Member만 조회
Member m = em.find(Member.class, member1.getId());

// Team은 Proxy
System.out.println("m = " + m.getTeam().getClass());

System.out.println("===================");
// 이땐 조회 안됨. 아래 처럼 실제로 사용할 때
m.getTeam();
// Team 조회
m.getTeam().getName();
System.out.println("===================");
Hibernate: 
    /* insert jpabook.jpashop.domain.Team
        */ insert 
        into
            Team
            (name, TEAM_ID) 
        values
            (?, ?)
Hibernate: 
    /* insert jpabook.jpashop.domain.Member
        */ insert 
        into
            Member
            (createdBy, createdDate, lastModifiedBy, modifiedDate, city, name, street, TEAM_ID, username, zipcode, MEMBER_ID) 
        values
            (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
Hibernate: 
    select
        member0_.MEMBER_ID as member_i1_4_0_,
        member0_.createdBy as createdb2_4_0_,
        member0_.createdDate as createdd3_4_0_,
        member0_.lastModifiedBy as lastmodi4_4_0_,
        member0_.modifiedDate as modified5_4_0_,
        member0_.city as city6_4_0_,
        member0_.name as name7_4_0_,
        member0_.street as street8_4_0_,
        member0_.TEAM_ID as team_id11_4_0_,
        member0_.username as username9_4_0_,
        member0_.zipcode as zipcode10_4_0_ 
    from
        Member member0_ 
    where
        member0_.MEMBER_ID=?
m = class jpabook.jpashop.domain.Team$HibernateProxy$Y5Aao7FO
===================
Hibernate: 
    select
        team0_.TEAM_ID as team_id1_7_0_,
        team0_.name as name2_7_0_ 
    from
        Team team0_ 
    where
        team0_.TEAM_ID=?
===================

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

public class Member {
    ...

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "TEAM_ID")
    private Team team;
}
Hibernate: 
    /* insert jpabook.jpashop.domain.Team
        */ insert 
        into
            Team
            (name, TEAM_ID) 
        values
            (?, ?)
Hibernate: 
    /* insert jpabook.jpashop.domain.Member
        */ insert 
        into
            Member
            (createdBy, createdDate, lastModifiedBy, modifiedDate, city, name, street, TEAM_ID, username, zipcode, MEMBER_ID) 
        values
            (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
Hibernate: 
    select
        member0_.MEMBER_ID as member_i1_4_0_,
        member0_.createdBy as createdb2_4_0_,
        member0_.createdDate as createdd3_4_0_,
        member0_.lastModifiedBy as lastmodi4_4_0_,
        member0_.modifiedDate as modified5_4_0_,
        member0_.city as city6_4_0_,
        member0_.name as name7_4_0_,
        member0_.street as street8_4_0_,
        member0_.TEAM_ID as team_id11_4_0_,
        member0_.username as username9_4_0_,
        member0_.zipcode as zipcode10_4_0_,
        team1_.TEAM_ID as team_id1_7_1_,
        team1_.name as name2_7_1_ 
    from
        Member member0_ 
    left outer join
        Team team1_ 
            on member0_.TEAM_ID=team1_.TEAM_ID 
    where
        member0_.MEMBER_ID=?
m = class jpabook.jpashop.domain.Team
===================
===================

프록시와 즉시로딩 주의

실무에선 가급적 지연 로딩만 사용하고 즉시로딩은 무조건 쓰지말자. 즉시 로딩을 적용하면 예상하지 못한 SQL이 발생할 가능성이 높다. 또한, 즉시 로딩은 JPQL에서 N+1 문제를 일으킨다. @ManyToOne, @OneToOne은 default가 즉시 로딩이므로 LAZY로 설정해줘야 한다. @OneToMany, @ManyToMany는 default가 지연 로딩이다.

[참고] JPQL의 N+1 문제

즉시로딩으로 설정하면 연관관계에 있는 엔티티를 모두 Join 하여 select 쿼리를 가져온다. 그러나, JPQL은 SQL 그대로 번역되므로 EAGER로 설정해도 Member만 먼저 select 한 후, Member 엔티티에 Team이 연관관계로 설정되있으니 Team도 select 한다.

Team team = new Team();
team.setName("teamA");
em.persist(team);

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

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

// em.find()는 pk를 지정해서 가져오는 것이기 때문에 내부적으로 최적화 가능
// Member m = em.find(Member.class, member1.getId());

List<Member> members = em.createQuery("select m from Member m", Member.class)
                    .getResultList();

// SQL: select * from Member;
// SQL: select * from Team where Team_id = member.team.id;

여기서 문제가 발생한다. Member가 1,2,3…N 있다면 그에 해당하는 Team을 조회하기 위해 N번 쿼리 날린다. 최초의 쿼리가 1이면 이에 해당하는 연관관계의 쿼리를 N번 날린다고 하여 N+1 문제라 한다.

아래 코드를 보면 JPQL을 사용해 Member를 조회(1)했지만 Member에 해당하는 개수(N)만큼 Team을 조회한다.

Team team = new Team();
team.setName("teamA");
em.persist(team);

Team teamB = new Team();
teamB.setName("teamB");
em.persist(teamB);

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

Member member2 = new Member();
member2.setUsername("member2");
member2.setTeam(teamB);
em.persist(member2);

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

List<Member> members = em.createQuery("select m from Member m", Member.class)
                    .getResultList();
Hibernate: 
    /* select
        m 
    from
        Member m */ select
            member0_.MEMBER_ID as member_i1_4_,
            member0_.createdBy as createdb2_4_,
            member0_.createdDate as createdd3_4_,
            member0_.lastModifiedBy as lastmodi4_4_,
            member0_.modifiedDate as modified5_4_,
            member0_.city as city6_4_,
            member0_.name as name7_4_,
            member0_.street as street8_4_,
            member0_.TEAM_ID as team_id11_4_,
            member0_.username as username9_4_,
            member0_.zipcode as zipcode10_4_ 
        from
            Member member0_
Hibernate: 
    select
        team0_.TEAM_ID as team_id1_7_0_,
        team0_.name as name2_7_0_ 
    from
        Team team0_ 
    where
        team0_.TEAM_ID=?
Hibernate: 
    select
        team0_.TEAM_ID as team_id1_7_0_,
        team0_.name as name2_7_0_ 
    from
        Team team0_ 
    where
        team0_.TEAM_ID=?

N+1을 해결하는 방법은 일단 모든 연관관계를 Lazy로 설정하는 것이다. N+1 문제를 해결하는 방법은 3가지가 있는데 대부분 fetch 조인 사용해서 해결한다.

List<Member> members = em.createQuery("select m from Member m join fetch m.team", Member.class)
                    .getResultList();

지연로딩을 실무에서 사용할 때

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

카테고리: