본문 바로가기

Dot Programming/JPA

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

    1프록시

    Member를 조회할 때 Team도 함께 조회해야 할까?

    1) 회원과 팀 함께 출력

    private static void printMemberAndTeam(Member member){
        String name = member.getName();
        System.out.println("name = " + name);
    
        Team team = member.getTeam();
        System.out.println("team = " + team.getName());
    }

     

    2) 회원만 출력

    private static void printMember(Member member){
        System.out.println("member = " + member.getName());
    }

     

    회원과 팀을 함께 출력할 경우에는 Member, Team을 모두 조회해야 하지만 회원만 출력할 경우에는 Member만 조회하고 Team까지 조회할 필요는 없다. 프로젝트 특성상 한 객체를 조회하는데 그 안의 연관관계가 아주 드물게 사용하는 경우 JPA입장에서는 굉장히 낭비이다.

     

    이러한 문제를 JPA는 지연로딩과 프록시를 사용하여 해결한다.

     

    프록시 기초

    프록시에서는 em.find()말고도 em.getReference()라는 메소드가 지원된다.

    em.find() 

    • 데이터베이스를 통해서 실제 엔티티 객체 조회
    Member findMember = em.find(Member.class, member.getId());
    System.out.println("findMember.getId() = " + findMember.getId());
    System.out.println("findMember.getName() = " + findMember.getName());
    Hibernate: 
        select
            member0_.id as id1_2_0_,
            member0_.createdBy as createdB2_2_0_,
            member0_.createdDate as createdD3_2_0_,
            member0_.lastModifiedBy as lastModi4_2_0_,
            member0_.lastModifiedDate as lastModi5_2_0_,
            member0_.USERNAME as USERNAME6_2_0_,
            member0_.team_id as team_id7_2_0_,
            team1_.id as id1_5_1_,
            team1_.createdBy as createdB2_5_1_,
            team1_.createdDate as createdD3_5_1_,
            team1_.lastModifiedBy as lastModi4_5_1_,
            team1_.lastModifiedDate as lastModi5_5_1_,
            team1_.name as name6_5_1_ 
        from
            Member member0_ 
        left outer join
            Team team1_ 
                on member0_.team_id=team1_.id 
        where
            member0_.id=?
    findMember.getId() = 1
    findMember.getName() = hello1

     

    em.getReference()

    • 데이터베이스 조회를 미루는 가짜(프록시) 엔티티 조회 
    Member findMember = em.getReference(Member.class, member.getId());
    System.out.println("findMember.getId() = " + findMember.getId());
    System.out.println("findMember.getName() = " + findMember.getName());
    findMember.getId() = 1
    Hibernate: 
        select
            member0_.id as id1_2_0_,
            member0_.createdBy as createdB2_2_0_,
            member0_.createdDate as createdD3_2_0_,
            member0_.lastModifiedBy as lastModi4_2_0_,
            member0_.lastModifiedDate as lastModi5_2_0_,
            member0_.USERNAME as USERNAME6_2_0_,
            member0_.team_id as team_id7_2_0_,
            team1_.id as id1_5_1_,
            team1_.createdBy as createdB2_5_1_,
            team1_.createdDate as createdD3_5_1_,
            team1_.lastModifiedBy as lastModi4_5_1_,
            team1_.lastModifiedDate as lastModi5_5_1_,
            team1_.name as name6_5_1_ 
        from
            Member member0_ 
        left outer join
            Team team1_ 
                on member0_.team_id=team1_.id 
        where
            member0_.id=?
    findMember.getName() = hello1

     

    getReference()메서드에서는 getId()는 이미 값이 있기 때문에 쿼리를 쓰지 않고 출력한다. 그래서 name을 조회할 때만 DB에 조회하는 쿼리를 보낸다.

     

    findMember.getClass() = class hellojpa.Member$HibernateProxy$tWArsPbb

     

    getReference()메서드가 생성한 findMember클래스를 조회해보면 Hibernate가 만들어낸 가짜 Proxy클래스임을 볼 수 있다.

    가짜 Proy 클래스 생성

    프록시 특징

     

    해당 Proxy클래스는 실제 클래스를 상속 받아서 만들어져서 겉 모양은 똑같이 생성된다.  그래서 이론상으로는 사용하는 입장에서는 진짜 객체인지
 프록시 객체인지 구분하지 않고 
사용하면 된다.

     

     

     

     

     

     

    프록시 객체는 실제 객체의 참조(target)을 보관하여 객체를 호출할 때마다 프록시 객체는 실제 객체 메소드를 호출한다.

    중요

    • 프록시 객체는 처음 사용할 때 한 번만 초기화된다.
    • 초기화되면 프록시 객체를 통해서 실제 엔티티에 접근 가능한 것이다. 프록시 객체를 초기화 할 때, 프록시 객체가 실제 엔티티로 바뀌는 것은 아니다.
    • 프록시 객체는 원본 엔티티를 상속받은 객체이다. 따라서 타입 체크시 주의해야한다. (== 비교 실패, 대신 instance of 사용)
    Member refMember = em.getReference(Member.class, member.getId());
    
    System.out.println("(refMember instanceof Member) = " + (refMember instanceof Member)); //true

     

    영속성 컨텍스트에 찾는 엔티티가 이미 있으면 em.getReference()를 호출해도 실제 엔티티 반환한다.

      Member findMember = em.find(Member.class, member.getId());
      Member refMember = em.getReference(Member.class, member.getId());
    
    System.out.println("(refMember==findMember) = " + (refMember==findMember)); // true

     

    영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태일 때, 프록시를 초기화하면 문제 발생
  (하이버네이트는 org.hibernate.LazyInitializationException 예외를 터트림)

     Member refMember = em.getReference(Member.class, member.getId());
     System.out.println("refMember.getClass() = " + refMember.getClass());
    
    em.detach(refMember); // 준영속 상태
    
    refMember.getName();

     

    프록시 객체 초기화

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

     

    프록시 객체를 가져온 다음 getName()을 호출하면 처음에 getName의 값이 없을 때 영속성 컨텍스트에 요청하여 DB에서 조회하여 실제 Entity를 생성한다. 그런 다음 프록시 클래스의 target변수와 실제 Entity와 연결시켜준다.

     

    프록시 기본 매커니즘

     

    그래서 결국 연결된 target변수를 통해 실제 Entity의 getName()이 반환이 된다.

     

    프록시 확인

    프록시 인스턴스의 초기화 여부 확인


    • PersistenceUnitUtil.isLoaded(Object entity)
    Member refMember = em.getReference(Member.class, member.getId());
    System.out.println("refMember.getClass() = " + refMember.getClass());
    refMember.getName();
    System.out.println("isLoaded=" + emf.getPersistenceUnitUtil().isLoaded(refMember));

     

    프록시 클래스 확인 방법


    • entity.getClass().getName() 출력(..javasist.. or HibernateProxy...)
    Member refMember = em.getReference(Member.class, member.getId());
    System.out.println("refMember.getClass().getName() = " + refMember.getClass().getName());

     

    프록시 강제 초기화


    • org.hibernate.Hibernate.initialize(entity);
    Hibernate.initialize(refMember); // 강제 초기화

     

    참고: JPA 표준은 강제 초기화 없음
. 그래서 초기화하려면 강제 호출: member.getName()

     

    2. 즉시 로딩과 지연 로딩

    Member를 조회할 때 Team도 함께 조회해야 할까?

     

    단순히 member 정보만 사용하는 비즈니스 로직이라면 Team까지 조회하면 성능상 손해

    예) println(member.getName());

     

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

    JPA는 그래서 지연 로딩이라는 옵션을 제공한다. 

    @Entity
    public class Member extends BaseEntity {
        @Id @GeneratedValue
        private Long id;
    
        @Column(name = "USERNAME")
        private String name;
    
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name = "TEAM_ID")
        private Team team;
    }

     

    Team객체를 지연로딩으로 설정해놓으면 Member를 조회했을 때 단순히 Member만 조회된다.

     Member member1 = new Member();
     member1.setName("abcd");
     em.persist(member1);
    
    em.flush();
    em.clear();
    
    Member m = em.find(Member.class, member1.getId());

     

     

    만약 팀을 넣고 해당 팀 클래스를 조회해보면?

     Team team = new Team();
     team.setName("teamA");
     em.persist(team);
                
    Member member1 = new Member();
    member1.setName("abcd");
    member1.setTeam(team);
    em.persist(member1);
                
    em.flush();
    em.clear();
    
    Member m = em.find(Member.class, member1.getId());
    
    System.out.println("m.getTeam().getClass() = " + m.getTeam().getClass());
    

     

    지연로딩으로 설정된 Team객체를 Member를 통해 조회했을 때 Proxy클래스로 조회된다.

     

     

     

     

    지연로딩은 프록시로 조회

     

    정리

    1)  em.find로 Member를 가지고 왔을 때 Member - Team이 지연로딩으로 세팅되어 있으면 Team객체를 가짜 프록시 객체로 넣는다.

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

     

    2) Member의 Team을 가져와서 실제 team을 사용하는 시점에 초기화를 시킨다. (DB 초기화)

    Team team = member.getTeam();
    team.getName(); // team 초기화 (DB 조회)

     

     

    그렇다면 만약 Member와 Team을 자주 함께 사용한다면?

     

    비즈니스 로직에 Member와 Team을 자주 같이 쓰인다면 지연로딩으로 설정되어있을 때 Member 따로, Team 따로 쿼리가 각자 나가기때문에 성능상 좋지않다. 

     

    즉시 로딩 EAGER을 사용해서 프록시로 조회

    그래서 이러한 경우를 위해 JPA 즉시 로딩 EAGER라는 옵션을 제공한다.

    @Entity
    public class Member extends BaseEntity {
    
        @Id @GeneratedValue
        private Long id;
    
        @Column(name = "USERNAME")
        private String name;
    
        @ManyToOne(fetch = FetchType.EAGER)
        @JoinColumn(name = "TEAM_ID")
        private Team team;
    }

     

    JPA 구현체는 가능하면 조인을 사용해서 SQL 한번에 함께 조회하기 때문에, 즉시로딩으로 설정을 해주면 member를 조회할 때 member와 team을 Join하여 한 번에 다 조회한다. 

    Team team = new Team();
    team.setName("teamA");
    em.persist(team);
                
    Member member1 = new Member();
    member1.setName("abcd");
    member1.setTeam(team);
    em.persist(member1);
                
    em.flush();
    em.clear();
    
    Member m = em.find(Member.class, member1.getId());
    
    System.out.println("m.getTeam().getClass() = " + m.getTeam().getClass());

     

    그래서 Team객체도 굳이 프록시 클래스일 필요가 없기 때문에 진짜 클래스로 조회된다.

     

     

    주의 사항  (중요)

     1)  가급적 지연 로딩만 사용 (특히 실무에서는 즉시 로딩 사용하면 안된다)

     2)  즉시 로딩을 적용하면 예상하지 못한 SQL이 발생한다.

        →  JOIN이 1,2개가 아닌 5~10개 그 이상이 된다고하면 DB 성능이 매우 안좋아짐

     3)  즉시 로딩은 JPQL에서 N+1 문제를 일으킨다.

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

     

    Eager로 세팅해도 조회 쿼리가 2번 발생하게된다.

     

    왜냐하면 JPQL은 "select m from Member m"을 SQL로 그대로 번역해서 전달하기 때문에 Member만 가져오게 된다. 그렇게 Member를 가져오고 나서야 즉시 로딩을 확인하고 그 값을 채우기 위해 Team을 또 가져오는 쿼리를 날리게 된다.

     

     

    데이터가 한 개가아닌 더 큰 N개의 데이터를 가지고 있는 상태로 조회를 하게되면?

    • Member를 가져오면(1) JPQL은 그에 따른 N개의 팀 정보를 또 가져오게 된다. 그래서 N+1문제라고 한다.

     

    LAZY를 사용하면 Team을 안쓰니깐 Member만 조회하고 추가 쿼리가 발생하지않는다. 그런데 만약 LAZY를 쓰면서 같이 조회하고 싶다면?  fetch join 사용한다.

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

     

     fetch join을 사용하면 LAZY상태여도 member와 team이 한 번에 같이 조회된다.

     

     

     

      4)  @ManyToOne, @OneToOne은 기본이 즉시 로딩
 -> LAZY로 설정

        → @ManyToOne, @OneToOne은 기본이 즉시 로딩(Eager)로 되어있기 때문에 꼭 LAZY를 명시해줘야 한다.

     

     5)  @OneToMany, @ManyToMany는 기본이 지연 로딩

       → 따로 명시해주지 않아도 LAZY로 작동 된다.

     

     

     

    3. 지연 로딩 활용

    • Member와 Team은 자주 함께 사용 -> 즉시 로딩
    • Member와 Order는 가끔 사용 -> 지연 로딩
    • Order와 Product는 자주 함께 사용 -> 즉시 로딩

     

     

      → 이론적인 내용으로 실무에서는 그냥 다 LAZY로 써야한다.

     

     

    em.find()를 했을 때 Member-Team은 한 번에 조회가 되고, Orders는 Proxy 객체로 들어온다.

    만약 프록시 클래스 Orders를 접근하게 되면 그에 즉시로딩이 걸려있는 Product도 같이 가져오게 된다.

     

    결론

    실무에서 즉시 로딩을 사용하지 말자.

    • 모든 연관관계에 지연 로딩을 사용하자.
    • EAGER의 기능을 사용하고 싶다면 JPQL fetch 조인이나, 엔티티 그래프 기능을 사용해라야 한다.
      • 즉시 로딩은 상상하지 못한 쿼리(N+1 문제)가 나가게 된다.

     

    4. 영속성 전이 : CASCADE

    특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속 상태로 만들도 싶을 때 사용한다. 이는 연관관계, 즉시 로딩, 지연 로딩과 관계가 없다.

     

     

     

     예시) 부모 엔티티를 저장할 때 자식 엔티티도 함께 저장할 때 CASCADE사용 

    @Entity
    public class Parent {
    
        @Id @GeneratedValue
        private Long id;
    
        private String name;
    
        @OneToMany(mappedBy = "parent")
        private List<Child> childList = new ArrayList<>();
    
        // 연관관계 메서드
        public void addChild(Child child){
            childList.add(child);
            child.setParent(this);
        }
    
       // getter, setter
    }
    
    
    @Entity
    public class Child {
    
        @Id
        @GeneratedValue
        private Long id;
    
        private String name;
    
        @ManyToOne
        @JoinColumn(name = "parent_id")
        private Parent parent;
    
        // getter, setter
    }
    

     

    Parent - Child가 1 : N 관계로 있을 때 아래와 같이 하면 Parent만 영속성에 추가되고 Child는 추가가 되지 않아 에러가 발생한다.

    // 영속성 전이 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만 영속성에 추가(em.persist)하고 child는 자동으로 추가될 수는 없을까? 이럴 때 CASCADE를 사용한다.

    @Entity
    public class Parent {
    
        //...
        
        @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL)
        private List<Child> childList = new ArrayList<>();
    
    
       // getter, setter
    }
    

     

    Parent클래스에 cascade all을 선언해주고 parent를 영속성 추가해주면 자동으로 CHILD객체도 추가되는 것을 볼 수 있다.

     

    ✖︎ 영속성 전이 : CASCADE - 주의사항

     → 영속성 전이는 연관관계를 매핑하는 것과 아무 관련이 없다

     → CASCADE는 단지 엔티티를 영속화할 때 연관된 엔티티도 함께 영속화하는 편리함을 제공하는 기능을 하는 것이다.

     

    ❍ CASCADE의 종류

    자주 사용하는 것은 ALL, PERSIST, REMOVE 이 세가지를 사용한다.

     

    LifeCycycle을 모두 맞춰서 저장 또는 삭제를해야 한다하면 ALL 또는 REMOVE를 사용하고, 아니면 저장할 때만 맞춘다하면은 PERSIST를 사용한다. 

    종류 설명
    ALL 모든 CASCADE를 적용한다.
    PERSIST 엔티티를 영속화할 때 이 필드에 보유된 엔티티도 유지한다.

    EntitiyManager가 flush 중에 새로운 엔티티를 참조하는 필드를 찾고 이 필드가 'CascadeType.PERSIST'를 사용하지 않으면 오류이므로 이 CASCADE 규칙의 자유로운 적용을 제안한다.
    REMOVE 엔티티를 삭제할 때, 이 필드에 보유된 엔티티도 삭제한다.
    MERGE 엔티티 상태를 병합할 때, 이 필드에 보유된 엔티티도 병합한다.
    REFRESH 엔티티를 새로고칠 때, 이 필드에 보유된 엔티티도 새로 고친다.
    DETACH 부모 엔티티가 detach()를 수행하게 되면, 연관된 엔티티도 detach() 상태가 되어 변경사항이 반영되지 않는다.

     

     

    ✔︎ 그런데 언제 사용해야 적절할까?

    ☛  한 부모가 여러 자식을 관리할 때 의미가 있다.

       * 예를 들어, 게시판(부모)이랑 첨부 파일1, 2, 3...(자식1, 2, 3)과 같은 경우

    ☛  파일을 다른 엔티티에서도 관리를 할 때는 사용을 하면 안된다. 오직 자식(파일)을 한 부모에 의해서만 관리가 될때 사용해야 한다.

     

     

     

    5. 고아 객체

    ∙  고아 객체 제거: 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능이다.

    ∙  orphanRemoval = true

    Parent parent1 = em.find(Parent.class, id); 
    parent1.getChildren().remove(0); //자식 엔티티를 컬렉션에서 제거

    ☛ 부모가 자식 엔티티를 컬렉션에서 제거하는 순간 삭제 쿼리가 발생한다.

    DELETE FROM CHILD WHERE ID=?

     

    예시) Parent클래스에 orphanRemoval=true로 설정한다.

    @Entity
    public class Parent {
    
        // ...
    
        @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
        private List<Child> childList = new ArrayList<>();
    }

    그리고나서 고아객체를 발생시키면 자동으로 DELETE쿼리가 발생하여 해당 자식 객체가 자동으로 삭제된다

    Child child1 = new Child();
    Child child2 = new Child();
    
    Parent parent = new Parent();
    parent.addChild(child1);
    parent.addChild(child2);
    
    em.persist(parent);
    
    em.flush();
    em.clear();
    
    Parent findParent = em.find(Parent.class, parent.getId());
    findParent.getChildList().remove(0); // 고아 객체
    

     

    ✖︎ 고아 객체 - 주의사항

     → 참조가 제거된 엔티티는 다른 곳에서 참조하지 않는 고아 객체로 보고 삭제하는 기능이기 때문에 참조하는 곳이 하나일 때 사용해야한다.

     →  특정 엔티티가 개인 소유할 때 사용 (CASCADE와 똑같)

     → @OneToOne, @OneToMany만 가능

     → 참고: 개념적으로 부모를 제거하면 자식은 고아가 된다.

         따라서 고아 객체 제거 기능을 활성화 하면, 부모를 제거할 때 자식도 함께 제거된다. 이것은  마치 CascadeType.REMOVE처럼 동작한다.

     

     

     

    6. 영속성 전이 + 고아 객체, 생명주기

    JPA에서는 스스로 생명주기를 관리하는 엔티티는 em.persist()로 영속화, em.remove()로 제거한다.

     

    아래의 두 옵션(Cascade.ALL, orphanRemoval=true)을 모두 활성화 하면 부모 엔티티를 통해서 자식의 생명 주기를 관리할 수 있다.

      → Parnet클래스가 생성되면 Child도 CASCADE로 자동으로 생성되고 Parent클래스가 삭제되면 Child클래스도 고아 객체의 성질에 따라 자동으로 삭제된다.

    CascadeType.ALL + orphanRemovel=true

     

    이는 도메인 주도 설계(DDD)의 Aggregate Root개념을 구현할 때 유용하게 사용된다.

     


    ❊ 출처

    인프런 김영한 개발자님 - 자바 ORM 표준 JPA 프로그래밍