공부/JPA

프록시와 연관관계 정리

Stair 2024. 12. 21. 13:37
반응형

프록시

프록시는 왜 사용할까? 아래의 그림을 보자.

Member를 조회할때 Team도 함께 조회가 필수인가?

아래의 코드를 보자.

Member member = em.find(Member.class, 1L);
printMemberAndTeam(member);
private static void printMemberAndTeam(Member member) {
    String name = member.getName();
    System.out.println("name = " + name);

    Team team = member.getTeam();
    System.out.println("team = " + team);
}

 

em.find하여 쿼리가 나갈때 team과 member 쿼리가 두 방이 나가서 한번에 찾을 수 있다면 좋을것이다. 그러나 아래의 메서드는 어떡해야할까?

            Member member = em.find(Member.class, 1L);
            printMember(member);
//            printMemberAndTeam(member);
private static void printMember(Member member) {
    System.out.println("member.getName() = " + member.getName());
}

 

이 코드는 member만 찾으면 되는 코드이다. member와 team의 쿼리 두번이 나가는 것이 손해이다. 하지만 member에는 team이 연관관계로 걸려있다.

 

JPA는 지연로딩과 프록시를 통해 이 문제를 해결한다.

em.find() : 데이터베이스를 통해서 실제 엔티티 객체를 조회

em.getReference() : 데이터베이스 조회를 미루는 가짜(프록시) 엔티티 객체 조회

        try {

            Member member = new Member();
            member.setName("hello");

            em.persist(member);

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

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

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

em.flush와 em.clear를 사용하여 영속성 컨텍스트와 쿼리저장소를 깔끔하게 날려주고

em.find를 해서 soutv를 찍으면 em.find를 하는 순간 DB에 select문을 통해 member 객체를 호출한다.

그 밑에 주석 처리 된 em.getReference를 보자. 이

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

            tx.commit();

em.find와 달리 em.getReference는 메서드를 호출하여도 DB에 select문이 나가지 않는다.(아래 get을 주석처리시)

Hibernate: 
    /* insert for
        hellojpa.prac1.Member */insert 
    into
        Member (INSERT_MEMBER, createdDate, UPDATE_MEMBER, lastModifiedDate, USERNAME, MEMBER_ID) 
    values
        (?, ?, ?, ?, ?, ?)
12월 20, 2024 9:19:51 오전 org.hibernate.engine.jdbc.connections.internal.DriverManagerConnectionProviderImpl$PoolState stop
INFO: HHH10001008: Cleaning up connection pool [jdbc:h2:tcp://localhost/~/test]

 

Member findMember = em.getReference(Member.class, member.getId());
System.out.println("findMember.getId() = " + findMember.getId());
System.out.println("findMember.getName() = " + findMember.getName());

tx.commit();

findMember.get~를 하면 그제서야 select 쿼리문이 나가게 된다.

Hibernate: 
    /* insert for
        hellojpa.prac1.Member */insert 
    into
        Member (INSERT_MEMBER, createdDate, UPDATE_MEMBER, lastModifiedDate, USERNAME, MEMBER_ID) 
    values
        (?, ?, ?, ?, ?, ?)
findMember.getId() = 1
Hibernate: 
    select
        m1_0.MEMBER_ID,
        m1_0.INSERT_MEMBER,
        m1_0.createdDate,
        m1_0.UPDATE_MEMBER,
        m1_0.lastModifiedDate,
        m1_0.USERNAME,
        t1_0.TEAM_ID,
        t1_0.INSERT_MEMBER,
        t1_0.createdDate,
        t1_0.UPDATE_MEMBER,
        t1_0.lastModifiedDate,
        t1_0.name 
    from
        Member m1_0 
    left join
        Team t1_0 
            on t1_0.TEAM_ID=m1_0.TEAM_ID 
    where
        m1_0.MEMBER_ID=?
findMember.getName() = hello

 

 

findMember.getId() = 1 은 select 쿼리가 나가기 이전에 출력이 됐는데 그 이유는 getReference에서 이미 찔러서 getId를 가지고 있기 떄문이다.

그럼 이 findMember의 정체가 뭘까?get class를 통해 확인해보면,

Member findMember = em.getReference(Member.class, member.getId());
System.out.println("findMember = " + findMember.getClass());

findMember = class hellojpa.prac1.Member$HibernateProxy$VKoxVHWz

뭐 이상한 클래스가 튀어나온다.

em.getReference() : 데이터베이스 조회를 미루는 가짜(프록시) 엔티티 객체를 조회하는 것이다.

em.getReference()를 호출하면 프록시를 통해 member와 똑같이 생겼지만 속이 텅 빈 가짜 껍데이 객체를 반환한다.

이 프록시 객체는 진짜 reference를 가리킬 수 있는 target이 있다.(초기에는 널이다)

프록시의 특징

1. 실제 클래스를 상속 받아서 만들어진다.

2. 실제 클래스와 겉 모양이 같다.

3. 사용하는 입장에서는 진짜 객체인지 프록시 객체인지 구분하지 않고 사용하면 된다.(이론상)

 

 

프록시 객체는 실제 객체의 참조(target)를 보관한다.

프록시 객체를 호출하면 프록시 객체는 실제 객체의 메소드를 호출하게 된다.

 

 

 

 

프록시 객체의 초기화는 다음과 같다.

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

 

getReference를 통해 껍데기를 만들고,

getName을 호출할 시, 만둘어둔 프록시 껍데기가 영속성 컨텍스트에 요청을한다. 그럼 영속성 컨텍스트가 DB에 요청해서 실제 객체를 생성화고, 프록시 target에 실제 Member엔티티의 참조값을 넣게 된다.

 

프록시 객체를 초기화 한다고 해서, 프록시 객체가 실제 Member 엔티티로 바뀌어지는건 아니다. 아래 코드를 보자.

Member findMember = em.getReference(Member.class, member.getId());
System.out.println("before findMember.getClass() = " + findMember.getClass());
System.out.println("findMember.getName() = " + findMember.getName());
System.out.println("after findMember.getClass() = " + findMember.getClass());

getName을 하여 select쿼리가 나간 이후의 findMember의 클래스를 찍어보았다.

before findMember.getClass() = class hellojpa.prac1.Member$HibernateProxy$5oF5cMmq
Hibernate: 
    select
        m1_0.MEMBER_ID,
        ...
        t1_0.name 
    from
        Member m1_0 
    left join
        Team t1_0 
            on t1_0.TEAM_ID=m1_0.TEAM_ID 
    where
        m1_0.MEMBER_ID=?
findMember.getName() = hello
after findMember.getClass() = class hellojpa.prac1.Member$HibernateProxy$5oF5cMmq

 

여전히 findMember는 프록시 객체이다. 프록시 target의 값에 실제 엔티티 Member객체의 값이 채워지게 되는 것 뿐이다.

 

 

**프록시의 특징**

1. 프록시 객체는 처음 사용할 때 한번만 초기화 한다.

2. 프록시 객체를 초기화 할 때, 프록시 객체가 실제 엔티티로 바뀌는 것은 이다. 초기화 되면 프록시 객체를 통해서 실제 엔티티에 접근이 가능하다.

3. 프록시 객체는 원본 엔티티를 상속 받는다, 따라서 타입 체크 시 주의해야 한다.(== 비교 실패, 대신 instance of 사용)

Member member1 = new Member();
member1.setName("hello");
em.persist(member1);

Member member2 = new Member();
member2.setName("member2");
em.persist(member2);

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

Member m1 = em.find(Member.class, member1.getId());
Member m2 = em.getReference(Member.class, member2.getId());

System.out.println("m1 == m2: " + (m1 instanceof Member));
System.out.println("m1 == m2: " + (m2 instanceof Member));

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

Member m1 = em.find(Member.class, member1.getId());
System.out.println("m1.getClass() = " + m1.getClass());

Member reference = em.getReference(Member.class, member1.getId());
System.out.println("reference = " + reference.getClass());

m1.getClass() = class hellojpa.prac1.Member
reference = class hellojpa.prac1.Member

이유 : JPA에서는 == 비교를 항상 true를 반환해줘야 하기 때문이다. JPA가 기본적으로 제공하는 메커니즘이기 때문이다.

 

Member refMember = em.getReference(Member.class, member1.getId());
System.out.println("m1.getClass() = " + refMember.getClass()); //proxy

Member findMember = em.find(Member.class, member1.getId()); //Member
System.out.println("reference = " + findMember.getClass());

System.out.println("a == b: " + (refMember == findMember));

위의 코드는 getReference 후에 em.find를 호출하였다. JPA는 ==비교를 맞추기 위해 em.find를 해도 프록시가 반환된다.

 

5. 영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태일 떄, 프록시를 초기화하면 문제가 발생한다.

            Member refMember = em.getReference(Member.class, member1.getId());
            System.out.println("m1.getClass() = " + refMember.getClass()); //proxy

//            em.detach(refMember);
//            em.close();
            em.clear();
            
            refMember.getName();

            tx.commit();

refMember를 영속성 컨텍스트에서 떼어내거나 영속성 컨텍스트를 종료하거나, 클리어하여 없애버렸을 시엔, 문제가 발생하니 주의하자.

 

프록시 확인

프록시 인스턴스의 초기화 여부는 다음과 같은 메서드로 확인한다.

Member refMember = em.getReference(Member.class, member1.getId());
System.out.println("m1.getClass() = " + refMember.getClass()); //proxy

System.out.println("isLoaded = " + emf.getPersistenceUnitUtil().isLoaded(refMember));

 

위 코드는 아직 프록시가 초기화 되지 않은 상태여서  false가 나온다.

아래처럼 초기화를 해준다면 true를 반환한다.

Member refMember = em.getReference(Member.class, member1.getId());
System.out.println("m1.getClass() = " + refMember.getClass()); //proxy
refMember.getName();
System.out.println("isLoaded = " + emf.getPersistenceUnitUtil().isLoaded(refMember));

 

또한 하이버네이트에서 프록시를 강제 초기화 하는 메서드인 initialize를 제공한다.

Member refMember = em.getReference(Member.class, member1.getId());
System.out.println("reference = " + refMember.getClass());
Hibernate.initialize(refMember); //강제 초기화

 

 

 

즉시로딩과 지연 로딩

그래서 결국 Member를 조회할 떄 Team도 함께 조회해야 할까?

 

단순히 member 정보만 사용하는 비즈니스 로직인

println(member.getName());이 있다. 이 member는 Team도 조회해야 할까?

JPA는 이 문제를 지연로딩 LAZY를 사용해서 프록시로 조회한다.

 

Member 클래스의 Team의 fetch를 lazy 타입으로 변경하면  team을 프록시 객체로 조회한다.

@ManyToOne(fetch = FetchType.LAZY) //프록시객체로 조회
@JoinColumn
private Team team;

 

Team team = new Team();
team.setName("TeamA");
em.persist(team);

Member member1 = new Member();
member1.setName("hello");
member1.setTeam(team);
em.persist(member1);

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

Member m = em.find(Member.class, member1.getId());
System.out.println("m = " + m.getTeam().getClass());

위  코드의 결과를 확인해보면

Hibernate: 
    select
        m1_0.MEMBER_ID,
        ...
        m1_0.team_TEAM_ID 
    from
        Member m1_0 
    where
        m1_0.MEMBER_ID=?
m = class hellojpa.prac1.Team$HibernateProxy$HqWNYZMK

 

select 시엔 Team을 조인하지 않고, Team 객체를 프록시로 가져왔다.

 

이후 만약 내가 Team을 조회하게 되면 조회하는 시점에 team을 select하는 쿼리가 날아가게 될 것이다.

Team team = new Team();
team.setName("TeamA");
em.persist(team);

Member member1 = new Member();
member1.setName("hello");
member1.setTeam(team);
em.persist(member1);

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

Member m = em.find(Member.class, member1.getId());

System.out.println(" === ");
m.getTeam().getName();
System.out.println(" === ");

 === 
Hibernate: 
    select
        t1_0.TEAM_ID,
        t1_0.INSERT_MEMBER,
        t1_0.createdDate,
        t1_0.UPDATE_MEMBER,
        t1_0.lastModifiedDate,
        t1_0.name 
    from
        Team t1_0 
    where
        t1_0.TEAM_ID=?
 === 

 

즉 fetch타입을 lazy로 설정하면 객체를 프록시로 가져온다. 이것을 지연로딩이라 한다.

** 실제 Team을 사용할 때 Team을 초기화한다.

 

LAZY와 반대되는 EAGER도 있다.

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

 

EAGER는 한방에 진짜 객체까지 가져오게 된다.

Team team = new Team();
team.setName("TeamA");
em.persist(team);

Member member1 = new Member();
member1.setName("hello");
member1.setTeam(team);
em.persist(member1);

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

Member m = em.find(Member.class, member1.getId());

System.out.println(" === ");
m.getTeam().getName();
System.out.println(" === ");

Hibernate: 
    select
        m1_0.MEMBER_ID,
        m1_0.INSERT_MEMBER,
        m1_0.createdDate,
        m1_0.UPDATE_MEMBER,
        m1_0.lastModifiedDate,
        m1_0.USERNAME,
        t1_0.TEAM_ID,
        t1_0.INSERT_MEMBER,
        t1_0.createdDate,
        t1_0.UPDATE_MEMBER,
        t1_0.lastModifiedDate,
        t1_0.name 
    from
        Member m1_0 
    left join
        Team t1_0 
            on t1_0.TEAM_ID=m1_0.team_TEAM_ID 
    where
        m1_0.MEMBER_ID=?
 === 
 === 

즉시 로딩(EAGER)으로 Member 조회 시 항상 Team도 조회한다.

JPA 구현체는 가능하면 조인을 사용해서 SQL을 한번에 함께 조회하는 것이다.

 

 

즉시로딩 주의

- 가급적 지연 로딩만 사용(특히 실무에서)

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

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

- @ManyToOne, @OneToOne은 기본이 즉시 로딩이다 --> LAZY로 설정해야 한다.

- @OneToMany, @ManyToMany는 기본이 지연로딩이다.

 

코드에 JPQL을 적용하여보자.

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

            Member member1 = new Member();
            member1.setName("member1");
            member1.setTeam(team);
            em.persist(member1);

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

//            Member m = em.find(Member.class, member1.getId());

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

            for (Member m : members) {
                System.out.println("m = " + m);
            }

            tx.commit();

 

위 JPQL 쿼리는 단순히 Member만 셀렉트 하는 JPQL이므로 Team을 조회할 수 없는 쿼리이다. 하지만 EAGER 설정이 되어있다.

그렇기 때문에 결과를 돌리면 SELECT 쿼리가 두개가 날아가게 된다.

Hibernate: 
    /* select
        m 
    from
        Member m */ select
            m1_0.MEMBER_ID,
            m1_0.INSERT_MEMBER,
            m1_0.createdDate,
            m1_0.UPDATE_MEMBER,
            m1_0.lastModifiedDate,
            m1_0.USERNAME,
            m1_0.team_TEAM_ID 
        from
            Member m1_0
Hibernate: 
    select
        t1_0.TEAM_ID,
        t1_0.INSERT_MEMBER,
        t1_0.createdDate,
        t1_0.UPDATE_MEMBER,
        t1_0.lastModifiedDate,
        t1_0.name 
    from
        Team t1_0 
    where
        t1_0.TEAM_ID=?
m = hellojpa.prac1.Member@53e93fb7

 

그렇다면 지연로딩을 어떻게 활용할까?

이론적으로 볼때는 설계를 다음과 같이 한다.(실무에서는 무조건 LAZY(*지연로딩)으로 설정하자.)

Member와 Team은 자주 함께 사용 -> 즉시로딩

Member와 Order는 가끔 사용 -> 지연로딩

Order와 Product는 자주 함께 사용 -> 즉시 로딩 

 

 

결론

- 모든 연관관계에 지연 로딩을 사용하자

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

- JPQL fetch 조인이나, 엔티티 그래프 기능을 사용하자

- 즉시 로딩은 테이블이 여러개 맞물려서 돌아갈 때 상상하지 못한 쿼리가 나갈 수 있다.

 

 

 

영속성 전이 : CASCADE

- 특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속상태로 만들고 싶을떄 사용한다.

ex) 부모 엔티티를 저장할 떄 자식 엔티티도 함께 저장한다.

 

 

 

만약 다음과 같은 클래스를 만든다고 가정한다.

package hellojpa.prac1;

import jakarta.persistence.*;

import java.util.ArrayList;
import java.util.List;

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

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

}
package hellojpa.prac1;

import jakarta.persistence.*;

import java.util.ArrayList;
import java.util.List;

@Entity
public class Child {

    @Id
    @GeneratedValue
    private Long id;

    private String name;

    @ManyToOne
    @JoinColumn(name = "parent_id")
    private Parent parent;


    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Parent getParent() {
        return parent;
    }

    public void setParent(Parent parent) {
        this.parent = parent;
    }
}

 

양방향으로 매핑된 관계이고, 이 관계에 child 객체 두개와 parent 객체 한개를 생성하여 addChild를 하는 코드를 생성하면

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);

tx.commit();

 

위와 같은 코드가 생성되게 될 것이다. 그런데 persist 부분을 보면 3개를 각각 persist해주고 있는 것을 확인할 수 있다.

이 persist를 하나로 줄일 순 없을까?

이 문제를 CASCADE를 통해 해결할 수 있다.

@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL)
private List<Child> childList = new ArrayList<>();

 

CASCADE를 사용하게 되면 parent만 persist해주어도, child까지 persist되는 것을 확인할 수 있다.

Child child1 = new Child();
Child child2 = new Child();

Parent parent = new Parent();
parent.addChild(child1);
parent.addChild(child2);

em.persist(parent);

tx.commit();

SELECT * FROM PARENT;

ID  NAME  

1 null

(1 row, 3 ms)

SELECT * FROM CHILD;

ID  PARENT_ID  NAME  

1 1 null
2 1 null

(2 행, 2 ms)

 

즉 Parent를 persist할 때 연관된 애들도 함께 persist하는 것이다.

 

영속성 전이 : CASCADE - 주의!

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

- 엔티티를 영속화 할 떄 연관된 엔티티도 함께 영속화하는 편리함을 제공해 줄 뿐이다.

 

 

고아 객체

- 고아 객체 제거 : 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제한다.

- orphanRemoval = true

@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Child> childList = new ArrayList<>();

 

 만약 이 상태에서 연관관계가 끊어지게 되면 자식 엔티티를 자동으로 삭제한다.

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);

Hibernate: 
    /* delete for hellojpa.prac1.Child */delete 
    from
        Child 
    where
        id=?

 

orphanremoval을 통해 child객체 하나가 지워지는 것을 볼 수 있다.

 

고아객체 사용 시 주의사항

- 참조가 제거된 엔티티는 다른 곳에서 참조하지 않는 고아 객체로 보고 삭제하는 기능이다.

- 참조하는 곳이 하나일 때 사용해야 한다.

- 특정 엔티티가 개인 소유할 때 사용한다.

- @OneToOne, @OneToMany만 사용 가능하다.

- 참고 : 개념적으로 부모를 제거하면 자식은 고아가 된다. 따라서 고아 객체 제거 기능을 활성화 하면, 부모를 제거할 때 자식도 함께 제거된다. 이것은 CascadeType.REMOVE처럼 동작한다.

@OneToMany(mappedBy = "parent", orphanRemoval = true)
private List<Child> childList = new ArrayList<>();
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);

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

Parent findParent = em.find(Parent.class, parent.getId());
em.remove(findParent);

tx.commit();

부모인 findParent가 remove되었기 때문에 밑에 자식인 child1,2가 모두 제거가 된다.

Hibernate: 
    /* delete for hellojpa.prac1.Child */delete 
    from
        Child 
    where
        id=?
Hibernate: 
    /* delete for hellojpa.prac1.Child */delete 
    from
        Child 
    where
        id=?
Hibernate: 
    /* delete for hellojpa.prac1.Parent */delete 
    from
        Parent 
    where
        id=?

 

반대인 orphanremoval이 없이 cascade여도 부모객체가 제거되면 자식 객체들도 다 delete된다.

 

cascade = CascadeType.ALL,orphanRemoval = true

 

만약 속성에 위처럼 둘 다 쓰게 된다면 부모 엔티티를 통해서 자식의 생명 주기를 관리할 수 있다.

Parent findParent = em.find(Parent.class, parent.getId());
findParent.getChildList().remove(0);

tx.commit();

찾은 parent를 통해서 자식 Child를 제거할 수 있다. 즉 Child의 생명주기를 Parent가 관리하고 있는 것이다.

--> 도메인 주도 설계(DDD)의 Aggregate Root 개념을 구현할 때 유용하다.

반응형

'공부 > JPA' 카테고리의 다른 글

객체지향 쿼리 언어1 - 기본 문법  (0) 2024.12.30
값 타입  (0) 2024.12.23
JPA 고급 매핑  (2) 2024.12.08
다양한 연관관계 매핑  (0) 2024.12.03
연관관계 매핑 기초  (0) 2024.12.02