[JPA] 프록시

다룰 내용

이번 포스팅에서 다룰 내용은 아래와 같다.

  • 프록시, 즉시로딩, 지연로딩
    • 프록시를 사용하면 연관된 객체를 처음부터 DB에서 조회하지 않는다.
    • 실제 사용하는 시점에 DB에서 조회할 수 있다.

하나씩 알아보자.




프록시

프록시가 필요한 이유

배경

  • 특정 엔티티를 조회할 때, 이와 연관된 엔티티들이 항상 사용되는 것은 아니다.
    • 예시) 회원 엔티티 조회 시, 이와 연관된 팀 엔티티는 비즈니스 로직에 따라 사용되지 않을 수 있다.
    • 예시 코드를 통해 이 경우에 대해 자세히 알아보자.


예시 코드: 연관된 엔티티를 사용하지 않는 경우

  • 회원 엔티티

      @Entity
      public class Member {
        
      	private String username;
        
      	//연관
      	@ManyToOne
      	private Team team;
        
      	//getter, setter 생략
      }
    
  • 팀 엔티티

      @Entity
      public class Team {
        
      	private String name;
        
      	//getter, setter 생략
      }
    
  • 비즈니스 로직 - 연관된 엔티티를 사용하는 로직

      public void printUserAndTeam(String memberId) {
      	Member member = em.find(Member.class, memberId);
      	Team team = member.getTeam();
      	System.out.println("회원이름: " + member.getUsername());
        
      	//이때, 연관된 엔티티 team이 실제로 사용된다.
      	System.out.println("팀이름: " + team.getName());
      }
    
  • 비즈니스 로직 - 연관된 엔티티를 사용하지 않는 로직

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


  • 상세 설명
    • 비즈니스 로직에 해당되는 메서드 printUserAndTeam 은 회원과 팀 이름을 출력한다.
    • 비즈니스 로직에 해당되는 메서드 printUser 은 오직 회원 이름을 출력한다. (연관된 엔티티 Team을 사용하지 않는다.)
    • 회원 엔티티만을 사용하는 경우, em.find() 를 통해 회원과 연관된 팀 엔티티까지 DB에서 함께 조회하는 것은 비효율적이다.


프록시와 지연 로딩

  • 위 예시의 문제를 극복하기 위해, JPA는 지연 로딩이라는 기능을 제공한다.
    • 지연 로딩: 엔티티가 실제로 사용될 때까지 DB조회를 지연하는 것
    • 지연 로딩 예시) team.getName() 를 호출했을 때, 그제서야 team 엔티티를 DB에서 조회한다.
  • 프록시 객체를 통해, 지연 로딩을 사용할 수 있다.
    • 프록시 객체: 실제 엔티티 객체 대신 DB 조회를 지연할 수 있는 가짜 객체



프록시 기초

EntityManager.find() 메서드

  • EntityManager.find() 메서드는 영속성 컨텍스트에 원하는 엔티티가 없으면, DB를 조회한다.
  • 이 메서드를 통해 엔티티를 직접 조회하면, 조회한 엔티티를 실제로 사용하든 사용하지 않든 DB를 조회한다.


EntityManager.getReference() 메서드

  • EntityManager.getReference() 메서드는 EntityManager.find() 메서드와는 다르게, 엔티티를 실제로 사용하는 시점까지 DB 조회를 미룰 수 있게 해준다.

  • 사용 예시 코드

      Member member = em.find(Member.class, "member1");
    
  • EntityManager.getReference() 메서드를 호출할 때, DB를 조회하지 않고 실제 엔티티 객체도 생성하지 않는다!

    • 대신에 해당 메서드는 DB 접근을 위임한 프록시 객체를 반환한다.

Untitled


프록시의 구조

  • 프록시는 실제 클래스를 상속받아 만들어진다.
  • 따라서, 겉모양은 실제 클래스와 같다.

Untitled


프록시 객체의 초기화

  • 프록시 객체는 실제 사용될 때 DB를 조회해서 실제 엔티티 객체를 생성한다.
    • 이것을 프록시 객체 초기화라고 한다.
  • 프록시 초기화 예제

      //MemberProxy를 실제 엔티티 대신 반환한다.
      Member member = em.getReference(Member.class, "id1");
        
      //아래 코드 실행시, 프록시 초기화가 수행된다.
      member.getName();
    
  • 그렇다면 프록시 클래스는 어떻게 작성되어있을까? 아래 코드는 프록시의 특징에 맞춰, 예상한 프록시 클래스 내용이다.

      class MemberProxy extends Member {
        
      	//실제 엔티티 조회시, 결과를 담는 필드 (참조)
      	Member target = null;
        
      	//오버라이딩된 메서드
      	@Override
      	public String getName() {
        
      		//만약 실제 엔티티가 조회된적 없다면: 프록시 초기화 진행
      		if (target == null) {
      			// 1. 프록시 초기화 요청
      			// 2. DB 조회
      			// 3. 실제 엔티티 생성 및 참조 보관
      		}
        
      		//아래 코드를 통해, 실제 엔티티의 원하는 메서드 호출
      		// 4. target.getName();
        
      		return target.getName();
      	}
      }
    

    다시 말하지만, 위 코드는 이해를 위한 예상 코드이다. 실제 프록시 클래스가 위와 동일하게 구현되었다는 이야기가 아니다.


  • 프록시 초기화 과정

    Untitled

    1. 프록시 객체에 member.getName() 을 호출해서 실제 데이터 조회를 시도한다.
    2. 프록시 객체는 실제 엔티티가 생성되어 있지 않으면, 영속성 컨텍스트에 실제 엔티티 생성을 요청한다. ⇒ 초기화
    3. 영속성 컨텍스트는 DB를 조회해서 실제 엔티티 객체를 생성한다.
    4. 프록시 객체는 생성된 실제 엔티티 객체의 참조를 Member형 target 멤버변수에 보관한다.
    5. 프록시 객체는 실제 엔티티 객체의 getName() 을 호출해서 결과를 반환한다.


프록시 특징

  • 프록시 객체는 처음 사용할 때, 한 번만 초기화된다.
  • 프록시 객체를 초기화해도, 프록시 객체가 실제 엔티티로 바뀌는 것은 아니다!
    • 프록시 객체 초기화시, 프록시 객체를 통해서 실제 엔티티에 접근할 수 있는 정도이다.
  • 프록시 객체는 원본 엔티티를 상속받은 객체이므로, 타입 체크 시에 주의해야 한다.
  • 영속성 컨텍스트에 찾는 엔티티가 이미 있으면, em.getReference() 를 호출해도 실제 엔티티를 반환한다.
    • 영속성 컨텍스트에 원하는 엔티티가 이미 있다. ⇒ DB를 조회하지 않아도 실제 엔티티를 찾을 수 있다.
    • 따라서 이 경우, 프록시를 사용할 이유가 없다!
      (프록시는 DB 조회를 미루려고 사용하므로)
    • 즉 아래와 같다.

      Untitled

  • 프록시 초기화는 영속성 컨텍스트의 도움을 받아야 가능하다!
    • 위 그림 ‘프록시 초기화 과정’ 을 다시 보자.
    • 따라서 준영속 상태의 프록시를 초기화하면 문제가 발생하다.
      • 이 경우는 아래에서 자세히 알아보자.


준영속 상태와 초기화

참고로 준영속 상태에 대해 잘 모른다면, 이전 게시글을 참고하자.

  • 준영속 상태에서 프록시 초기화를 시도하는 예시 코드를 먼저 보자.
// member 변수에는 프록시 객체가 담긴다.
Member member = em.getReference(Member.class, "id1");
transaction.commit();
em.close(); // 이때, member 엔티티가 준영속 상태로 변경된다.

member.getName(); //준영속 상태에서 초기화 시도
//org.hibernate.LazyInitializationException 예외 발생


  • 상세 설명
    • member.getName() 을 호출하면 프록시를 초기화해야 하는데, 영속성 컨텍스트가 없으므로 실제 엔티티를 조회할 수 없다.
      • 즉 이 경우는, 프록시 초기화에 사용해야할 영속성 컨텍스트가 없는 경우이다. (close())


프록시와 식별자

  • 엔티티를 프록시로 조회할 때, 식별자(PK) 값을 파라미터로 전달하게 된다.
    • em.getReference(A.class, "PK값")
  • 이때 이 PK 값을 프록시 객체가 보관한다.
  • 프록시 객체에서 ‘해당 PK 값을 호출하는 것’으로는 프록시 초기화가 이루어지지 않는다.
  • 즉, 아래와 같다.

      // 변수 team에 프록시 객체를 담음
      Team team = em.getReference(Team.class, "team1"); // 식별자 보관
        
      team.getId(); //프록시 초기화가 이루어지지 않는다!!!
    
    • team.getId() 를 호출한 경우, 프록시 초기화가 이루어지지 않는다.
      • 왜냐하면 식별자는 DB조회 없이도 알 수 있기 때문이다.
    • 단, @Access(AccessType.PROPERTY) 로 설정한 경우에만 해당된다.
    • @Access(AccessType.FIELD) 로 설정한 경우, 위와 같은 상황이더라도 프록시 객체를 초기화한다.
      • 왜냐하면 FIELD 로 설정시, JPA는 getId() 라는 메소드가 id만 조회하는 메소드인지, 다른 필드까지 활용하는 메소드인지 알 수 없다.
      • 즉, getId() 메서드가 getter로 사용되는지 알 수 없다.

      @Access 에 대한 내용은 이전 게시글을 참고하자.


  • 프록시는 연관관계를 설정할 때, 유용하게 사용할 수 있다.

      Member member = em.find(Member.class, "member1"); //실제 엔티티
      Team team = em.getReference(Team.class, "team1"); //프록시 객체
        
      member.setTeam(team); //연관관계 설정
    
    • JPA는 연관관계 설정시, 식별자(PK) 값을 외래키로 사용한다.
    • 즉 연관관계 설정에 필요한 값은 식별자 값밖에 없으므로, 프록시 객체를 사용하여 연관관계를 설정할 수 있다.

    참고로 연관관계 설정시에는 FIELD 접근 방식으로 설정해도 프록시를 초기화하지 않는다.


프록시 확인

  • 프록시 인스턴스의 초기화 여부 확인하기
    • PersistenceUnitUtil.isLoaded(Object entity) 메서드
      • 초기화 X ⇒ false
      • 초기화 O ⇒ true
    • 예시 코드

        boolean isLoad = em.getEntityManagerFactory()
        				.getPersistenceUnitUtil().isLoaded(확인할_프록시_엔티티);
      


  • 실제 엔티티인지, 프록시 객체인지 확인하기
    • 클래스명을 직접 출력하여 확인
    • 클래스 명 뒤에 javassist 단어가 붙으면 프록시 객체이다.
    • 예시 코드

        System.out.println(member.getClass().getName());
      


  • 프록시 강제 초기화하기
    • org.hibernate.Hibernate.initialize(프록시_객체)



즉시 로딩과 지연 로딩

즉시 로딩과 지연 로딩 요약

  • 즉시 로딩
    • 엔티티를 조회할 때, 연관된 엔티티도 함께 조회한다.
    • 예시
      • em.find(Member.class, "member1") 을 호출할 때, 회원 엔티티와 연관된 팀 엔티티도 함께 조회한다.
    • 설정 방법
      • @ManyToOne(fetch = FetchType.EAGER)


  • 지연 로딩
    • 연관된 엔티티를 실제 사용할 때 조회한다.
    • 예시
      • member.getTeam().getName() 처럼 조회한 팀 엔티티를 실제로 사용하는 시점에 JPA가 SQL을 호출해서 팀 엔티티를 조회한다.
    • 설정 방법
      • @ManyToOne(fetch = FetchType.LAZY)

각각에 대해 자세히 알아보자.


즉시 로딩 (Eager Loading)

  • 즉시 로딩을 사용하려면, @ManyToOne 의 속성 fetchFetchType.EAGER 로 지정한다.


  • 예시 코드
    • 즉시 로딩 설정

        @Entity
        public class Member {
              
        	//...
              
        	@ManyToOne(fetch = FetchType.EAGER)
        	@JoinColumn(name = "TEAM_ID")
        	private Team team;
              
        }
      
    • 즉시 로딩 실행 코드

        //아래 코드 실행 시, 연관된 엔티티 Team도 같이 조회된다.
        Member member = em.find(Member.class, "member1");
        Team team = member.getTeam(); //객체 그래프 탐색
      
    • 시각화

      Untitled

    • 상세 설명

      • em.find(Member.class, "member1") 로 회원을 조회하는 순간 팀도 함께 조회한다.


  • JPA 구현체들은 즉시 로딩을 최적화하기 위해, 가능하면 조인 쿼리를 사용한다.
    • 연관된 엔티티까지 동시에 조회시
      • “기존 엔티티 SELECT 후, 연관된 엔티티 SELECT” 방식 사용 X
      • “두 테이블을 외부 조인하여, 기존 엔티티와 연관 엔티티 모두 한번에 SELECT” 방식 사용 O


즉시 로딩: NULL 제약조건과 JPA 조인 전략

  • 즉시 로딩 시, 외부조인을 사용하여 한번에 연관 엔티티까지 조회한다.
  • 외부조인을 사용하는 이유
    • 테이블의 FK 칼럼에 NOT NULL 제약조건이 없을 때를 대비하기 위해 외부조인을 사용한다.
    • FK 칼럼에 null이 허용될 때, 내부조인을 사용하면 누락되는 데이터가 많다.
  • 하지만 성능상 내부조인이 좋다.
    • 외래키에 NOT NULL 제약조건이 걸리면, 내부조인을 사용할 수 있다.
    • 따라서 외래키에 NOT NULL 제약조건이 걸리면, @JoinColumn 의 속성 nullablefalse 로 설정하여 JPA에서 내부조인을 사용하라고 알릴 수 있다.
    • 혹은 @JoinColumn 의 속성 optionalfalse 로 설정해도 된다.
    • 예시 코드

        @Entity
        public class Member {
              
        	// ...
              
        	@ManyToOne(fetch = FetchType.EAGER)
        	@JoinColumn(name = "TEAM_ID", nullable = false) //내부조인이 가능하다고 알린다.
        	private Team team;
              
        	// ...
              
        }
      
  • 정리
    • 선택적 관계 ⇒ 외부조인
    • 필수 관계 ⇒ 내부조인 (성능이 더 좋음)


지연 로딩

  • 지연 로딩을 사용하려면, @ManyToOnefetch 속성을 FetchType.LAZY 로 지정하면 된다.
  • 예시 코드
    • 지연 로딩 설정

        @Entity
        public class Member {
              
        	//...
              
        	@ManyToOne(fetch = FetchType.LAZY)
        	@JoinColumn(name = "TEAM_ID")
        	private Team team;
              
        }
      
    • 지연 로딩 실행 코드

        //member = 실제 엔티티, member.team = 프록시 객체
        Member member = em.find(Member.class, "member1");
              
        Team team = member.getTeam(); //객체 그래프 탐색
              
        //team 객체 실제 사용
        team.getName(); //이때, 프록시 초기화 (team 조회)
      
    • 시각화

      Untitled

    • 상세 설명

      • em.find(Member.class, "member1") 을 호출하면, 회원만 조회하고 팀은 조회하지 않는다.
        • 단, 회원의 team 멤버변수에 프록시 객체를 넣어둔다.
      • Team team = member.getTeam();
        • 해당 코드에서 team 변수에는 프록시 객체가 들어간다.
        • 이 프록시 객체는 실제 사용될 때까지 데이터 로딩을 미룬다.
      • team.getName();
        • 이때 실제 데이터 로딩을 진행한다. (프록시 초기화)
        • 조회 대상이 영속성 컨텍스트에 있으면 프록시 객체를 사용할 이유가 없다. (위에서 설명한 ‘프록시 특징’ 참고)


즉시 로딩, 지연 로딩 정리

  • 지연 로딩 (Lazy Loading)
    • 연관된 엔티티를 프록시로 조회한다.
    • 프록시를 실제로 사용할 때, 초기화하면서 DB를 조회한다.
  • 즉시 로딩 (Eager Loading)
    • 연관된 엔티티를 즉시 조회한다.
    • 하이버네이트는 가능하면 SQL 조인을 사용해서 한번에 조회한다.



지연 로딩 활용

사내 주문 관리 시스템

지연 로딩을 활용하는 예시를 들어, 지연 로딩이 어떻게 활용될 수 있는지 알아보자. ‘사내 주문 관리 시스템’을 예시로 삼겠다.


  • 예시
    • 클래스 모델

      Untitled

    • 모델 분석
      • 회원은 팀 하나에만 소속할 수 있다. (N:1)
      • 회원은 여러 주문내역을 가진다. (1:N)
      • 주문내역은 상품정보를 가진다. (N:1)
    • 로직 분석
      • Member와 연관된 Team은 자주 함께 사용되었다.
        • 그래서 MemberTeam 을 즉시 로딩으로 설정했다.
      • Member와 연관된 Order은 가끔 함께 사용되었다.
        • 그래서 MemberOrder 을 지연 로딩으로 설정했다.
      • Order와 연관된 Product은 자주 함께 사용되었다.
        • 그래서 OrderProduct을 즉시 로딩으로 설정했다.


  • 예시 코드: 회원 엔티티

      @Entity
      public class Member {
        
      	@Id
      	private String id;
      	private String username;
      	private Integer age;
        
      	//즉시
      	@ManyToOne(fetch = FetchType.EAGER)
      	private Team team;
        
      	//지연
      	@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
      	private List<Order> orders;
        
      }
    
    • 회원과 팀의 연관관계를 FetchType.EAGER 로 설정했다.
      • 따라서 회원 엔티티를 조회하면 연관된 팀 엔티티도 즉시 조회한다.
    • 회원과 주문내역의 연관관계를 FetchType.LAZY 로 설정했다.
      • 따라서 회원 엔티티를 조회하면 연관된 주문내역 엔티티는 프록시로 조회해서 실제 사용될 때까지 로딩을 지연한다.
    • 시각화

      Untitled

    • 회원 조회시
      • 외부조인을 사용하여 Member 엔티티와 Team 엔티티를 동시 SELECT 한다.
      • Order 엔티티 대신 프록시 객체가 조회된다.


프록시와 컬렉션 래퍼

  • 위 예시에서 즉시 로딩을 위주로 설명했다.
  • 그렇다면 지연 로딩으로 설정한 List<Order> orders 는 어떻게 처리될까? 예시 코드를 통해 알아보자.


  • 예시 코드: 주문내역 조회

      Member member = em.find(Member.class, "member1");
      List<Order> orders = member.getOrders(); //컬렉션형 프록시가 들어감
        
      System.out.println("orders = " + orders.getClass().getName());
      //결과: order = org.hibernate.collection.internal.PersistentBag
    
    • 상세 설명
      • 출력 결과가 이상한 것을 알 수 있다.
      • 엔티티를 영속 상태로 만들 때, 해당 엔티티에 컬렉션이 있으면 컬렉션을 추적·관리할 목적으로, 원본 컬렉션을 ‘하이버네이트 자체 내장 컬렉션’으로 변경한다.
      • 이 ‘하이버네이트 자체 내장 컬렉션’을 컬렉션 래퍼라고 한다.
      • 따라서 출력 결과가 ‘~.PersistentBag’ 으로 나온 것이다.


  • 컬렉션 래퍼
    • 컬렉션 래퍼는 컬렉션의 지연 로딩을 처리해준다.
    • 즉 아래와 같이 생각하면 쉽다.
      • 컬렉션 래퍼 = 컬렉션용 프록시
    • 컬렉션 래퍼가 프록시 초기화되는 경우
      • member.getOrders() ⇒ 프록시 초기화 X
      • member.getOrders().get(0) ⇒ 프록시 초기화 O
      • 즉 컬렉션에서 실제 데이터를 조회할 때, DB에서 조회하여 초기화한다.


  • 참고
    • member.getOrders() 가 초기화될 때, 상품(Product)도 같이 조회된다.
    • 왜냐하면 주문(Order)와 상품(Product)가 즉시 로딩으로 설정되어 있기 때문이다.
  • 시각화

Untitled


JPA 기본 페치 전략

  • fetch 속성의 기본 설정값은 아래와 같다.
    • @ManyToOne , @OneToOne : 즉시 로딩
    • @OneToMany , @ManyToMany : 지연 로딩
  • JPA의 기본 페치(Fetch) 전략
    • 즉시로딩: 연관된 엔티티가 하나일때
    • 지연로딩: 연관된 엔티티가 컬렉션형으로 처리될 때
  • 컬렉션에 연관 엔티티를 담을 때, 지연로딩으로 동작하는 이유
    • 연관된 엔티티의 개수가 수만 건일 때, 즉시로딩으로 처리한다면 매우 비효율적이기 때문이다.
    • 반면에 연관된 엔티티가 하나면 즉시 로딩해도 큰 문제가 발생하지는 않는다.
  • 권장하는 전략
    • 모든 연관관계에 지연 로딩을 사용하라!
    • 그리고 애플리케이션 개발이 어느정도 끝날때, 꼭 필요한 곳에만 즉시 로딩을 사용하도록 수정하라!
  • 여기서 알 수 있는 JPA의 장점
    • SQL을 직접 다룬다면, 위와 같은 권장 전략을 사용하기 어렵다. 많은 SQL과 코드를 일일이 수정해야 하기 때문이다.
    • 하지만 JPA를 사용하면, 비교적 손쉽게 로딩 설정을 바꿀 수 있다.


컬렉션에 FetchType.EAGER 사용 시 주의점

  • 컬렉션을 하나 이상 즉시 로딩하는 것은 권장하지 않는다.
    • 컬렉션과 조인하는 것 ⇒ 일대다 조인
    • 서로 다른 컬렉션을 2개 이상 조인한다면, 너무 많은 데이터를 반환할 수 있다.
    • 따라서 애플리케이션 성능이 저하될 수 있다.
  • 컬렉션 즉시 로딩은 항상 외부 조인을 사용한다.

  • 조인 전략 정리
    • @ManyToOne , @OneToOne
      • (optional = false) : 내부조인
      • (optional = true) : 외부조인
    • @OneToMany , @ManyToMany
      • (optional = false) : 외부조인
      • (optional = true) : 외부조인





  • 김영한, 『자바 ORM 표준 JPA 프로그래밍』, 에이콘
본 게시글은 위 교재를 기반으로 정리한 글입니다.