JPA (8) 프록시와 연관 관계 관리

김영한님의 자바 ORM 표준 JPA 프로그래밍 강의 정리

Proxy

  • em.find(): DB에서 실제 엔티티 객체 조회
  • em.getReference(): DB에서 바로 조회하는 것을 미루고 프록시 엔티티 객체 조회
  • HibernateProxy
    • 실제 엔티티 클래스를 상속받아 겉모양이 같음
    • 사용하는 입장에서는 진짜 객체인지 프록시 객체인지 구분하지 않고 사용(이론)
    • 실제 객체의 참조 보관(target)
    • 프록시 객체를 호출하면 실제 객체의 메소드 호출
  • 프록시 객체 초기화
    1. 엔티티 클래스의 메소드 호출
    2. 프록시 객체가 초기화되어 있지 않아 target이 null
    3. 영속성 컨텍스트에 초기화 요청
    4. 영속성 컨텍스트에서 DB 조회
    5. 실제 엔티티 생성
    6. 프록시 객체의 target이 처음 호출했던 엔티티 클래스의 메소드 호출
  • 프록시 확인
    • 초기화 확인: emf.getPersistenceUnitUtil().isLoaded(proxy);
    • 프록시 클래스 확인:proxy.getClass()
    • 강제 초기화: Hibernate.initialize(proxy);
  • 프록시 객체는 처음 사용할 때 한 번만 초기화
  • 초기화 후에 프록시 객체가 실제 엔티티로 바뀌는 것은 아니며 프록시 객체를 통해 실제 엔티티에 접근 가능
  • 프록시 객체는 실제 엔티티를 상속받은 것이며 타입 체크할 때는 ==이 아닌 instanceof로 해야 함

  • 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 em.getReference()를 호출해 도 실제 엔티티 반환
    • 이미 영속성 컨텍스트에 있기 때문에
    • JPA에서는 하나의 영속성 컨텍스트에서 조회한 엔티티를 ==으로 비교하면 true가 나오게 됨
    • getReference 후에 find 하면 프록시 반환
  • 준영속 상태일 때, 프록시를 초기화 하면 org.hibernate.LazyInitializationException 예외 발생

Book 객체를 생성한 후, 영속성 컨텍스트를 초기화 한 후에 find 메소드로 엔티티 객체를 가져왔다. (1)에서는 class class basic.domain.Book으로 출력되면서 엔티티 클래스인 것을 확인할 수 있다. 다시 영속성 컨텍스트를 초기화 한 후에 프록시 객체를 가져와서 출력한 (2)를 보면 (1)과 비슷하지만 class basic.domain.Book$HibernateProxy$cPTVewPO 이렇게 출력되면서 하이버네이트 프록시 객체라는 것을 알 수 있다. (3), (5)에서 초기화 여부를 출력하도록 하고 (4)에서 프록시 객체를 초기화 하면 (3)에서는 false, (5)에서는 true가 출력되면서 true가 출력되기 전에 영속성 컨텍스트가 DB에서 조회하는 쿼리 또한 확인할 수 있다.

public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();

        try {
            Book book = new Book();
            book.setName("book1");            
            em.persist(book);
            
            em.flush();
            em.clear();
          
            Book findBook = em.find(Book.class, book.getId());
            System.out.println("findBook.getClass() = " + findBook.getClass());	 // (1)

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

            Book reference = em.getReference(Book.class, book.getId());
            System.out.println("reference.getClass() = " + reference.getClass());	// (2)
          
            System.out.println(emf.getPersistenceUnitUtil().isLoaded(reference)); // (3)
            Hibernate.initialize(reference);       																// (4)     
            System.out.println(emf.getPersistenceUnitUtil().isLoaded(reference));	// (5)

            tx.commit();
        } catch (Exception e) {
            tx.rollback();
        } finally {
            em.close();
        }
        emf.close();
    }

즉시 로딩과 지연 로딩

플레이어와 팀이 서로 다대일 관계로 연결되어 있을 때 (1)처럼 플레이어를 조회하면 팀 테이블도 조인해서 함께 조회하게 된다. @ManyToOne의 fetch의 기본 설정이 FetchType.EAGER이기 때문이다. 이미 팀 데이터도 DB에서 조회해서 영속성 컨텍스트에서 관리하고 있기 때문에 (2)에서 팀의 클래스를 출력하면 프록시 클래스가 아닌 엔티티 클래스로 나오게 된다.

@Entity
public class Player {

    @Id @GeneratedValue
    private Long id;

    private String name;

    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;
}

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

    private String name;
  
}

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

Player player = new Player("player1", team);
em.persist(player);

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

Player findPlayer = em.find(Player.class, player.getId());	// (1)
System.out.println(findPlayer.getTeam().getClass());				// (2)

플레이어를 조회할 때마다 항상 팀도 조회하는 경우가 아니라면 굳이 매번 팀을 조회할 필요가 없을 것이다. 그럴 때는 fetch 설정을 @ManyToOne(fetch = FetchType.LAZY)로 해주면 된다. 그런 다음 위의 코드와 똑같이 조회하면 플레이어 테이블에서만 조회하고 (2)에서는 프록시 클래스인것을 알 수 있다.

즉시 로딩 주의

  • 가급적 지연 로딩을 사용
  • 즉시 로딩을 사용할 때 예상하지 못한 쿼리 발생
  • JPQL에서 N + 1 문제 발생
  • @ManyToOne, @OneToOne은 기본이 즉시 로딩이므로 지연 로딩으로 설정

즉시 로딩을 사용할 때 JPQL에서 발생할 수 있는 N+1 문제는 아래 같은 상황에서도 확인할 수 있다. 두 개의 팀과 플레이어를 만들고 각 플레이어는 각각 다른 팀과 연관 관계를 가지도록 했다. 그 다음 JPQL을 이용해 모든 플레이어 데이터를 조회하도록 했다. 작성한 쿼리만 보면 ‘select * from player’라는 쿼리가 나갈 거 같지만 즉시 로딩으로 설정되어 있다면 플레이어를 조회한 후에 각 플레이어와 연관된 팀의 개수만큼 팀 데이터를 조회하는 쿼리가 나가게 된다. 이렇게 예상하지 못하는 쿼리가 발생할 수 있기 때문에 즉시 로딩은 사용하지 않는 것이 좋다.

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

Player player = new Player("player1", teamA);
em.persist(player);

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

Player player2 = new Player("player1", teamB);
em.persist(player2);

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

List<Player> players = em.createQuery("select p from Player p", Player.class).getResultList();

지연 로딩 활용

  • 모든 연관 관계를 지연 로딩 사용
  • JPQL fetch join이나 엔티티 그래프 기능 사용

영속성 전이

  • 특정 엔티티를 영속 상태로 만들 때, 연관된 엔티티도 함께 영속 상태로 만들고 싶을 때 사용
  • @OneToMany에서 cascade 설정에서 적용
    • @OneToMany(mappedBy = "parent", cacade = CascadeType.ALL)
  • 영속성 전이와 연관 관계 매핑은 관련 없음
  • cascade 종류
    • ALL: 모두 적용
    • PERSIST: 영속
    • REMOVE: 삭제
    • MERGE: 병합
    • REFRESH: 부모 엔티티가 리프레시 될 때 자식 엔티티도 DB로부터 리로딩
    • DETACH: 부모 엔티티가 영속성 컨텍스트에서 지워질 때 자식 엔티티도 지워짐
  • 사용하는 경우
    • 하나의 부모 엔티티가 자식 엔티티를 관리해 종속적인 관계일 때 사용
    • 부모 엔티티와 자식 엔티티의 라이프 사이클이 유사할 때
    • 다른 엔티티와도 연관 관계가 있을 때는 사용하지 않아야 함

고아 객체

  • 부모 엔티티와 연관 관계가 끊어진 자식 엔티티를 자동으로 삭제하는 설정

  • @OneToMany에서 orphanRemoval 설정에서 적용
    • @OneToMany(mappedBy = "parent", orphanRemoval = true)
  • 참조하는 곳이 하나일 때만 사용해야 함
  • @OneToOne, @OneToMany 사용 가능
  • 고아 객체 기능을 활성화 하면 부모 엔티티를 삭제할 때 자식 엔티티도 삭제하게 되므로 CascadeType.REMOVE처럼 동작하게 됨
  • 영속성 전이와 고아 객체 삭제 함께 사용
    • cascade = CascadeType.ALL & orphanRemoval = true
    • 부모 엔티티를 통해 자식 엔티티의 생명 주기 관리 가능
    • 도메인 주도 설계의 Aggregate Root 개념 구현할 때 유용