Dev/JPA

JPA 에서 프록시 객체와 지연 로딩

린네의 2024. 2. 5. 23:59

 

 

  JPA 를 사용하면서 연관관계를 정의하고, 연관 관계 정의 시 사용하는 지연로딩에 대해 이해하기 위해서는 프록시라는 개념이 무엇인지 확실히 알아야 한다. 해당 게시글에서는 프록시에 대한 개념과 연관관계에 대해 정리하려고 한다.

 

들어가기 전에,  해당글에서 '프록시 객체'와 '프록시 엔티티'는 동일한 의미로 사용됨을 밝힌다.

이 글을 이해하려면 JPA 에서 제공하는 영속성의 의미를  알고 있어야 한다.  필요한 사람은 해당 글을 참고하면 좋을 것 같다.

2023.12.11 - [개발/jpa] - JPA에서 영속성의 의미와 사용하는 애노테이션 정리

 

 

1.  프록시 객체가 뭘까?   EntityManager에서 제공하는 getReference()로 프록시 객체의 기본 개념을 알아보자 

 일반적으로 entityManager 를 통해 find를 사용하면 데이터베이스를 통해 실제 엔티티를 조회한다. 

하지만 getReference() 를 사용할 경우에는 데이터베이스에 접근하여 조회를 하지 않지만 엔티티가 조회가 된다!   실제 예제 코드를 보자.

 

 

  •  Member Entity 
@Entity
public class Member extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO) 
    @Column(name = "member_id")
    private Long id;
    private String name;
    
    ...
    
    }

 

 

 사용자의 id, name 이 포함된 Member 엔티티를 선언했다.   

 

  •      find() 를 사용할 경우 
     Member member = new member();
     member.setUsername("hello");

     em.persist(member);

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


     Member findMember em.find(Member.class, member.getId());

 

  • 결과 :  member 에 대해  select 쿼리가 실행되는 것이 로그에 조회된다.
        

 이때 find 가 아니라 getReference를 사용하게 된다면 어떻게 될까?

 

  •      getReference()를 사용할 경우 
   Member findMember = em.getReference(Member.class, member.getId());

 

 

  • 결과 : member에 대해  select 쿼리가 실행되는 것이 로그에 조회되지 않는다.         

  getReference를 사용하면 실제로 select 쿼리가 실행되지 않는다! 하지만  findMember에 대해 실제 데이터를 사용하려고 하는 시점에 쿼리가 실행이 된다.  예를 들어, findMember.getUsername()을 사용하게 되면  데이터베이스의 Member 테이블에서 name을 조회하는 쿼리가 실행된다.  이때  getId의 경우 이미 getReference 호출 시 사용했기 때문에 쿼리 실행이 되지 않는데, 예외적인 경우이므로 기억하고 있도록 하자. 

 위 코드에서 getReference()를 통해 조회된 findMember 가 프록시 엔티티이다. 프록시 엔티티는, 데이터 베이스조회를 실제 데이터 사용전까지 미루는 가짜 엔티티 객체이다.

 

 

2.  프록시 객체의 특징 

  프록시 객체가 어떤 것인지는 위에서 테스트한 간단한 코드로 이해할 수 있다. 

이런 프록시 객체의 특징은 다음과 같다.

 

  • 실제 클래스를 상속받아서 만들어진다 ( hibernate 내부에서 자동으로 생성한다 ) 
  • 실제 클래스와 모양이 동일하다
  • 이론상으로는 사용하는 입장에서 진짜 객체와 구분 없이 사용한다
  • 프록시 객체는 실제 객체의 참조를 보관한다   
  • 처음 사용할 때 한 번만 초기화된다  ( getReference()를 여러 번 조회해도 다시 호출되지 않는다 )
  • 프록시 객체가 실제 엔티티로 바뀌는 것이 아니라, 초기화시 프록시 객체를 통해 실제 엔티티에 접근할 수 있다
  • 프록시 객체는 원본 엔티티를 상속받는다! 따라서 타입 체크 시 == 비교가 아니라 instance of를 사용해서 비교해야 한다
  • 영속성 콘텍스트에 찾는 엔티티가 이미 있다면 getRefrence()를 사용하더라도 프록시 객체가 아닌 실제 엔티티가 반환된다

 

 

 프록시 객체가 호출 되는 순서는 다음과 같다.  

 

[출처] 자바 ORM 표준 JPA 프로그래밍 - 기본편 / 김영한

 

 

  1. 원하는 값이 초기화 객체에 없을 경우 함수를 호출한다
  2. 프록시 객체가 영속성 콘텍스트에 초기화 요청을 진행한다
  3. DB에서 데이터를 조회하여 실제 엔티티를 생성한다
     

   예제 코드로 위 내용을 확인해 볼 수 있다. 

        Member refMember = em.getReference(Member.class, member1.getId());
        //proxy entity 가 출력 됨 

        Member findMember = em.find(Member.class, member1.get());
      	// db 에 쿼리를 날리긴 하지만 proxy entity 가 출력됨


 위 코드에서 refMember = findMember는 true를 만족한다. 이미 getReference()를 통해 영속성 콘텍스트에 프록시 객체가 초기화 된 상태기 때문에 find() 함수를 사용하더라도 실제 엔티티에 직접 접근하는 것이  아닌 프록시 객체를 통해 실제 엔티티에 접근할 수 있는 것이다. 따라서 반환은 모두 프록시 객체로 된다. 

 

 여기서 얻을 수 있는 결론은 JPA에서 getReference로 호출하는지, find로 호출하는지에 구분 없이 동일하게 취급한다는 것이다.  그렇기 때문에 객체 비교 시에는 instatnce of를 사용해야 한다.
     

 

3.  프록시 객체와  LazyInitializationException 

 사실 JPA를 실무에 쓸 때 프록시 객체가 뭐니 영속성 콘텍스트가 뭐니 하는 개념에 대해 깊게 이해하지 않고 코드만 긁어서 쓰는 경우가 많을 텐데, 그럴 때 가장 많이 볼 수 있는 에러가 LazyInitializationException이다.  나도 JPA 연관관관계 이해를 위해 간단한 프로그램을 만들었는데 이 에러가 나서 한참을 헤맸다.  

  LazyIntializationException 은 영속성 콘텍스트의 도움을 받을 수 없는 준영속 상태일 때, 프록시 객체를 초기화하면 발생하는 오류다.

 이게 무슨 소리인지는 다음 예제 코드를 보면 보다 명확하게 이해할 수 있을 것이다.

 

 

 테스트 코드 작성 시 entityManager의 clear를 통해 모든 영속성을 제거하지 않으면 getReference로 조회하더라도 member1 가 영속성 콘텍스트에 있기 때문에 원하는 테스트를 할 수 없다.  ( 호출하면 영속성 컨텍스트에  이미 선언된 member1 가 호출 된다.  프록시 객체에 대한 내용을 테스트하는 것이 목적이기 때문에 빼먹지 말고 clear를 해주자.  ) 

 

  •  정상적으로 호출되는 경우 
Member member1 = new Member();
member1.setName("hello");
em.persist(member1);
em.flush();
em.clear(); 


Member refMember = em.getReference(Member.class, member1.getId());
log.info( refMember.getUsername()) ; // refMember 는 여기서 초기화 된다

 

     위 코드에서 refMember 은 getReferecne를 통해 정의되었지만,  프록시 객체기 때문에 username을 호출하는 시점인 log.info( refMember.getUsername() ) 에서야 DB로 접근하여 초기화가 진행된다. 이 때는 큰 문제가 없다.

 

 

  •  준영속 상태일 경우 
Member member1 = new Member();
member1.setName("hello");
em.persist(member1);
em.flush();
em.clear(); 

Member refMember = em.getReference(Member.class, member1.getId());
em.detach(refMember);  // 강제로 비영속처리 
log.info(refMember.getUsername());

 

  refMember에 대해 영속성을 제거했다. 이렇게 되면  프록시 객체인 refMember 은 준영속상태가 되는데, 이때 userName을 조회하게 되면 LazyIntializationException 이 발생된다. 이유는 간단하다 프록시 객체는 영속성 컨텍스트를 거쳐 조회하는데, 비영속 상태인 프록시 객체에 대해 데이터를 요구 했으니 당연히 발생하게 되는 것이다.

        
        

 

4.  프록시 객체를 확인하는 방법 

 예제 코드에서 테스트해 본 것처럼 프록시 객체가 제대로 초기화되었는지 확인하는 방법으로는 세 가지가 있다. 

  • PersistenceUnitUtil.isLoaded(Object null)를 통해 프록시 객체의 초기화 여부를 확인할 수 있다
  • entity.getClass(). getName()를 사용하여 프록시 객체의 클래스를 확인할 수 있다
  • Hibernate에서 제공하는 org.hibernate.Hibernate.initailize(entity)를 통해서 강제 초기화를 진행할 수 있다 ( 단, JPA 표준에서 제공하고 있는 기능은 아니다. 표준에서는 강제 호출을 위해서는 member.getName() 등을 통해 호출한다 ) 


   

5.  프록시 객체의 내용이 왜 필요할까?

 여기까지 읽었다면 프록시 객체가 뜬금없이 왜 등장했는지 궁금할 수 있다.  왜 프록시 객체의 이해가 필요할까?

 

 우리는 JPA를 사용할 때 연관관계 선언 시 즉시/지연로딩을 ( EAGER / LAZY )를 사용하게 되는데,  이 내용의 핵심과 프록시 객체의 매커니즘이 밀접하게 연관되어 있기 때문이다.  프록시 객체에 대한 이해가 됐다면 이제 즉시/지연 로딩으로 넘어가 보자.


 

 

6.  프록시 객체를  통해 지연로딩 LAZY 이해하기 

 

Member와 Team 은 N : 1 관계를 가진다.  아래는 Member 엔티티의 구성 내용이다. 연관관계에 대한 자세한 관계를 이해하고 싶다면
아래 글을 참고하자

2023.12.27 - [개발/jpa] - JPA에서 연관관계의 정의

 

   Member와 Team 은 N : 1  관계를 가진다고 가정하자.  이때 Member 안의 Team 은 Team에 대해 조회될 때 프록시 객체 처럼 동작 한다.

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

        
    프록시 객체처럼 동작한다는 말은,  Team에 대해 바로 데이터베이스에 접근하는 것이 아니라 객체가 실제로 사용될 때  쿼리를 날려 데이터를 가져온다는 뜻이다. 결과적으로  Member와 Team을 각자 호출 할 수 있게 된다.
  굳이 Team에 대한 정보가 필요 없는데 함께 조회하는 게 아니라 각각 조회할 수 있는 것이다. 

 

7.  프록시 객체를 통해 즉시로딩 EAGER

  Team과 Member을 함께 조회하고 싶을 경우에는 EAGER을 사용할 수 있다.

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

 

 Lazy와 다르게 Member을 조회할 때 Team에 대한 정보도 함께 조회된다.  즉 DB에서 데이터를 조회할 때  Member 로딩 시 Team 도 함께 조인되어 가져온다.  따라서 EAGER를 사용하면 가지고 올 때부터 실제 엔티티처럼 동작한다. 

 

 

8.  JPQL N + 1 문제와 해결방안

 즉시(EAGER) 로딩이 보기에는 굉장히 편해 보이지만 실무에서는 지연로딩을 사용하는 것이 좋다. 즉시 로딩은 JPQL 사용 시 N + 1 문제를 발생시키기 때문이다.   

 N + 1 문제는 JPA에서 연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 개수(N) 만큼 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어오는 현상을 말한다.  여기서 1 최초 실행하는 쿼리를, N 은 최초 실행되는 쿼리로 인해 발생하는 N 개의 쿼리를 의미한다.

 

 예를 들어 하나의 Team을 조회하더라도 ( 최초 실행 1 ) Team에 연관된 Member들이 여러 명( N )이라면  Member에 대한 내용도 모두 조회되기 때문에  실제로 조회되는 쿼리 개수는 1 + N 개가 발생하는 것이다. 

 

 이런 문제를 해결하기 위해서 모든 연관 관계를 지연 관계로 설정하거나, fetch join을 사용할 수 있다.
    

  •  @ManyToOne, @OneToOne 은 기본이 즉시로딩 (EAGER) 이므로 LAZY로 변경해서 사용하자
  •  @OneToMany,@ManytoMany는 기본이 LAZY 이므로 신경 쓰지 않아도 된다


       

 

이상으로  JPA에서 연관관계에서 많이 사용하는 지연로딩에 대해  정리를 마친다 : )

다음 게시글에서는 지연 로딩과 헷갈릴 수 있는 영속성 전이에 대해 포스팅하도록 하겠다.

 

 

 

 

[출처] 자바 ORM 표준 JPA 프로그래밍 - 기본 편 / 김영한