본문 바로가기
Backend/JPA

[JPA] 프록시와 연관관계 관리

by 2245 2023. 12. 20.

출처

https://www.inflearn.com/course/ORM-JPA-Basic/dashboard

 

자바 ORM 표준 JPA 프로그래밍 - 기본편 강의 - 인프런

JPA 를 공부하고 책을 보며 어려웠던 내용을 위주로 먼저 보았습니다. 옆에서 1:1 과외해주는 것 같이 생생하고 이해 잘되는 설명, 예제(코드)가 너무 좋았습니다. 어느 것 하나 애매함없이 모두

www.inflearn.com

 

 

목차

     

     

    프록시

    다음과 같은 연관관계에서 Member를 조회할 때 Team도 함께 조회해야 할까요?

    • 회원과 팀을 함께 출력하는 비지니스 로직이라면, em.find(Member.class, MemberId); 할 때 Member와 Team을 함께 찾아오는 것이 좋습니다. 
    • 하지만, 회원만 출력하는 로직이라면, DB에서 연관관계 매핑이 되어 있다고 Team 테이블을 조회하여 함께 가져오는 것은 손해일 수 있습니다. (사용하지 않는데 가져오는 것은 최적화가 되어 있지 않은 것)

    ⇒ 지연 로딩과 프록시를 사용하여 해결할 수 있습니다.

     

     

    프록시 기초 (em.getReference())

    • em.find() vs em.getReference()
    • em.find(): 데이터베이스를 통해서 실제 엔티티 객체 조회
    • em.getReference(): 데이터베이스 조회를 미루는 가짜(프록시) 엔티티 객체 조회
      • em.getReference() 호출 시점에는 데이터베이스 Member 테이블에 SQL이 실행되지 않습니다. 
      • 이후 findMember.getName() 호출 시점에 SQL이 실행됩니다. 
      • System.out.println(findMember.getClass()); → hellojpa.Member$HibernateProxy$odcVHpjy ⇒ 프록시 객체

     

     

    프록시 특징

    • 실제 클래스를 상속 받아서 만들어집니다. 
    • 실제 클래스와 겉모양이 같지만, 내부는 비어있습니다.
    • 사용하는 입장에서는 진짜 객체인지 프록시 객체인지 구분하지 않고 사용하면 됩니다. (몇 가지만 조심해서 사용하면 됩니다.)

    • 프록시 객체는 실제 객체의 참조(target)를 보관합니다. 
    • 프록시 객체를 호출하면, 프록시 객체는 실제 객체의 메소드를 호출합니다. 

     

    프록시 객체 초기화

    Member member = em.getReference(Member.class, “id1”);
    member.getName();

     

    • em.getReference()하면, MemberProxy를 생성하는데, 해당 프록시에는 실제 객체의 참조(target)를 보관합니다. 
    • member.getName()이 호출이 될때, 프록시 객체는 영속성 컨텍스트에게 초기화를 요청합니다. 그리고 영속성 컨텍스트는 DB를 조회하여 실제 엔티티 객체를 생성합니다.
    • 프록시는 target.getName()을 호출하여 값을 반환합니다.

     

    프록시 정리 ⭐⭐⭐

    • 프록시 객체는 처음 사용할 때 한 번만 초기화합니다. (이미 실제 엔티티를 가져왔다면, 가져온 엔티티를 사용합니다.)
    • 프록시 객체를 초기화 할 때, 프록시 객체가 실제 엔티티로 바뀌는 것은 아닙니다. 초기화되면 프록시 객체를 통해서 실제 엔티티에 접근이 가능합니다. 
    • 프록시 객체는 원본 엔티티를 상속받습니다. 따라서 타입 체크 시 주의해야 합니다. (== 비교 실패, 대신 instance of 사용)
    • 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 em.getReference()를 호출해도 실제 엔티티를 반환합니다.
    • 만약, 연속으로 프록시를 가져오면 같은 프록시가 반환이 됩니다. (프록시 객체 또한 영속성 컨텍스트에 저장되며, 같은 영속성 컨텍스트에서 가져온 객체는 == 비교 시 반드시 true를 반환합니다.)
    Member m1 = em.getReference(Member.class, member1.getId());
    System.out.println(m1.getClass());    //hellojpa.Member$HibernateProxy$odcVHpjy
    
    Member m2 = em.getReference(Member.class, member1.getId());
    System.out.println(m2.getClass());    //hellojpa.Member$HibernateProxy$odcVHpjy
    
    //같은 영속성 컨텍스트에서 가져온 객체는 == 했을 때 True 여야 한다. 
    System.out.println("a == a: " + (m1 == m2));   //true
    • 프록시 조회 후, em.find() 하면 어떻게 될까? em.find()도 프록시를 반환합니다.
    Member refMember = em.getReference(Member.class, member1.getId());
    System.out.println(refMember.getClass());  //Proxy
    
    Member findMember = em.find(Member.class, member1.getId());
    System.out.println(findMember.getClass()); //Member가 아니라 Proxy 반환
    
    System.out.println("refMember == findMember:" + (refMember == findMember)); //true
    • 영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태일 때, 프록시를 초기화하면 문제가 발생합니다. (하이버네이트는 org.hibernate.LazyInitializationException 예외를 터트립니다.)
    Member refMember = em.getReference(Member.class, member1.getId());
    
    em.detach(refMember);
    //em.close();
    //em.clear();
    
    refMember.getUsername();     //LazyInitializationException 예외 발생

     

     

    프록시 확인

    • 프록시 인스턴스의 초기화 여부 확인
    Member refMember = em.getReference(Member.class, member1.getId());
    System.out.println(emf.getPersistenceUnitUntil().isLoaded(refMember));  //false
    
    refMember.getUsername(); //초기화 진행
    System.out.println(emf.getPersistenceUnitUntil().isLoaded(refMember));  //true
    • 프록시 클래스 확인
    entity.getClass().getName(); //..javasist.. or HibernateProxy…
    • 프록시 강제 초기화
    Hibernate.initailize(refMember);  //강제 초기화
    참고 JPA 표준은 강제 초기화가 없습니다. 
    강제 초기화 호출 - member.getName()

     

     

     

     


    즉시 로딩과 지연 로딩

    다시 처음으로 돌아가서, 다음과 같이 단순히 member 정보만 사용하는 비지니스 로직에서 Member를 조회할 때 Team도 함께 조회해야 할까요?

    println(member.getName());

     

    프록시와 지연 로딩을 사용해서 Team이 필요한 경우에만 조회할 수 있습니다. 

     

    지연 로딩(LAZY)를 사용한 프록시 조회

    @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;
    		...
    }

     

    Member member = em.find(Member.class, 1L);

     

     

    Team team = member.getTeam();  //getTeam()이 아닌 getName()할 때 초기화
    team.getName(); // 실제 team을 사용하는 시점에 초기화 (DB 조회)

     

     

     

    만약, Memer와 Team을 함께 자주 사용한다면?

     즉시 로딩(EAGER)을 사용하면 됩니다.

    @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;
    		...
    }

     

     

    즉시 로딩

    • Member 조회 시 항상 Team도 함께 조회합니다. 
    • JPA 구현체는 가능하면 조인을 사용하여 SQL 한 번에 함께 조회합니다.

     

     

    💡 프록시와 즉시 로딩 주의

    가급적 지연 로딩만 사용해야 합니다. (특히 실무에서)

    • 즉시 로딩을 적용하면 예상하지 못한 SQL이 발생합니다.
    Member m = em.find(Member.class, member1.getId());
    • 위와 같이 Member만 조회하는 코드를 작성해도, 만약 즉시 로딩으로 Team이 연관되어 있다면 Team을 Join 하는 SQL이 발생합니다. 
    • 만약, 연관관계가 한 개가 아니라 수십 개라면? 그만큼 예상치 못한 SQL문 발생과 더불어, Join을 많이 하게 돼 성능이 나오지 않습니다. 

     

    • 즉시 로딩은 JPQL에서 N+1 문제를 일으킵니다.
    List<Member> members = em.createQuery("select m from Member m", Member.class)
                  		  .getResultList();
    
    //SQL1: select * from Member;   //JPQL은 작성된 SQL를 그대로 실행한다.  
    //SQL2: select * from Team where TEAM_ID = xxx   //실행 후 EAGER로 되어 있는 각각 Member의 Team을 조회하기 위해 한번더 SQL이 발생한다. 
    //만약, 저장된 Member가 2개라면, Team을 조회하기 위한 SQL이 2번 추가로 발생한다.
    • N+1이란, 최초 쿼리 1개 때문에 추가 쿼리 N개가 발생한다는 의미입니다. 
    • LAZY로 바꾸면, Team을 조회하기 위한 추가 쿼리가 발생하지 않습니다.

     

    • 해결책 1
      • 모든 연관관계를 지연로딩으로 설정합니다.
      • Member만 가져와도 될 때는 Member만 가져오고, Member와 Team을 가져와야 할 때는 fetch join을 사용합니다.
      • 패치 조인은, 런타임에 동적으로 원하는 데이터를 쿼리 한 개로 가져올 수 있습니다.
    List<Member> members = em.createQuery("select m from Member m join fetch m.team", Member.class)
                  		  .getResultList();

    한 개의 쿼리로 Member와 Team 모든 데이터를 가져옵니다.

     

    • 해결책 2 : 엔티티 그래프와 어노테이션 사용
    • 해결책 3 : 배치 사이즈

     

    • @ManyToOne, @OneToOne은 기본 값이 즉시 로딩입니다. → LAZY로 바꿔야 합니다.
    • @OneToMany, @ManyToMany는 기본 값이 지연 로딩입니다. 

     

     


    지연 로딩 활용

    • MemberTeam은 자주 함께 사용 → 즉시 로딩
    • MemberOrder는 가끔 사용 → 지연 로딩
    • OrderProduct는 자주 함께 사용 → 즉시 로딩

     

     

    Member와 Team과 Order 로딩

     

     

    Order와 Product 로딩

     

     

     

    지연 로딩 활용 - 실무

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

     

     


    영속성 전이: CASCADE

    • 특정 엔티티를 영속 상태로 만들 때, 연관된 엔티티도 함께 영속 상태로 만들고 싶을 때 사용합니다. 
    • 예: 부모 엔티티를 저장할 때, 연관된 자식 엔티티도 함께 저장합니다.

     

    @OneToMany(mappedBy = "parent", cascade=CascadeType.PERSIST)

     

     

    영속성 전이: 저장 실습

    @Entity
    public class Parent {
        @Id
        @GeneratedValue
        private Long id;
        private String name;
    
        @OneToMany(mappedBy = "parent", cascade=CascadeType.PERSIST)
        private List<Child> childList = new ArrayList<>();
    
        public void addChild(Child child) {
            childList.add(child);
            child.setParent(this);
        }
    }
    @Entity
    public class Child {
        @Id
        @GeneratedValue
        private Long id;
        private String name;
    
        @ManyToOne
        @JoinColumn(name = "parent_id")
        private Parent parent;
    }

     

    [Cascade 사용 전]

    Child child1 = new Child();
    Child child2 = new Child();
    
    Parent parent = new Parent();
    parent.addChild(child1);
    parent.addChild(child2);
    
    em.persist(parent);
    em.persist(child1);
    em.persist(child2);
    • parent, child1, child2 각각 따로 저장해야 합니다. 

     

    [Cascade 사용 후]

    Child child1 = new Child();
    Child child2 = new Child();
    
    Parent parent = new Parent();
    parent.addChild(child1);
    parent.addChild(child2);
    
    em.persist(parent);
    • parent만 저장해도 child1와 child2는 자동으로 저장이 됩니다. 

     

     

    💡영속성 전이: CASCADE 주의

    • 영속성 전이는 연관관계를 매핑하는 것과 아무 관련이 없습니다. 
    • 엔티티를 영속화할 때 연관된 엔티티도 함께 영속화하는 편리함을 제공할 뿐입니다. 
    • 한 개의 부모만이 자식들을 관리할 때 유용합니다 (단일 엔티티에 종속적일 때) 예) 게시판과 첨부파일들 관계
    • 또, 부모와 자식의 라이프사이클이 동일할 때 사용해야 합니다.

     

    CASCADE의 종류

    • ALL: 모두 적용
    • PERSIST: 영속
    • REMOVE: 삭제
    • MERGE: 병합
    • REFRESH: REFRESH
    • DETACH: DETACH

     


    고아 객체

    • 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능입니다.
    • orphanRemoval = true
    @Entity
    public class Parent {
        @Id
        @GeneratedValue
        private Long id;
        private String name;
    
        @OneToMany(mappedBy = "parent", cascade=CascadeType.PERSIST, orphanRemoval = true)
        private List<Child> childList = new ArrayList<>();
    
        public void addChild(Child child) {
            childList.add(child);
            child.setParent(this);
        }
    }

     

    Parent parent1 = em.find(Parent.class, id);
    parent1.getChildren().remove(0);  //자식 엔티티를 컬렉션에서 제거 → DELETE SQL 발생
    DELETE FROM CHILD WHERE ID=?

     

     

    💡고아 객체 주의

    • 참조가 제거된 엔티티는 다른 곳에서 참조하지 않는 고아 객체로 보고 삭제하는 기능입니다.
    • 참조하는 곳이 하나일 때 사용해야 하빈다. 
    • 특정 엔티티가 개인 소유일 때 사용해야 합니다. 
    • @OneToOne, @OneToMany만 가능합니다. 
    • CascadeType.REMOVE 처럼 동작합니다.

     

     

    영속성 전이 + 고아 객체의 생명주기

    • CascadeType.ALL + orphanRemoval=true
    • 스스로 생명주기를 관리하는 엔티티는 em.persist()로 영속화하고, em.remove()로 제거합니다. 
    • 두 옵션을 모두 활성화 하면, 부모 엔티티를 통해서 자식의 생명 주기를 관리할 수 있습니다.
    //parent를 통해서 child를 관리할 수 있다. 
    //parent의 생명주기는 영속성 컨텍스트가 관리하지만, child는 parent가 관리한다. 
    em.persist(parent);
    em.remove(parent);
    parent.getChildList().remove(0);
    • 도메인 주도 설계(DDD)의 Aggregate Root개념을 구현할 때 유용합니다.

     

     


    실전 예제 - 5. 연관관계 관리

    모든 연관관계를 지연 로딩으로 변경

    • @ManyToOne, @OneToOne은 기본이 즉시 로딩이므로 지연 로딩으로 변경해야 합니다.
    @Entity
    public class Category extends BaseEntity {
        ...
        @ManyToOne(fetch = LAZY)
        @JoinColumn(name = "PARENT_ID")
        private Category parent;
    }
    @Entity
    public class Delivery extends BaseEntity {
    
        ...
        @OneToOne(mappedBy = "delivery", fetch = FetchType.LAZY)
        private Order order;
    }
    @Entity
    @Table(name = "ORDERS")
    public class Order extends BaseEntity {
       ...
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name = "MEMBER_ID")
        private Member member;
    
        @OneToOne(fetch = FetchType.LAZY)
        @JoinColumn(name = "DELIVERY_ID")
        private Delivery delivery;
    }
    @Entity
    public class OrderItem extends BaseEntity {
       ...
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name = "ORDER_ID")
        private Order order;
    
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name = "ITEM_ID")
        private Item item;
    }

     

     

    영속성 전이 설정

    • Order → Delivery를 영속성 전이 ALL 설정
    • Order → OrderItem을 영속성 전이 ALL 설정
    @Entity
    @Table(name = "ORDERS")
    public class Order extends BaseEntity {
        ...
        @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
        @JoinColumn(name = "DELIVERY_ID")
        private Delivery delivery;
    
        @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
        private List<OrderItem> orderItems = new ArrayList<>();
    
        public void addOrderItem(OrderItem orderItem) {
            orderItems.add(orderItem);
            orderItem.setOrder(this);
        }
    }
    • Order를 저장하면, Delivery도 자동으로 저장됩니다. 
    • Order를 저장하면, orderItems도 자동으로 저장됩니다. 

     

     

    'Backend > JPA' 카테고리의 다른 글

    [JPA] JPA의 다양한 쿼리 방법 소개  (0) 2023.12.24
    [JPA] 값 타입  (0) 2023.12.20
    [JPA] 고급 매핑 (상속 관계 매핑, 공통 속성 매핑)  (0) 2023.12.20
    [JPA] 다양한 연관관계 매핑  (0) 2023.12.18
    [JPA] 연관관계 매핑  (0) 2023.12.18