[JPA 기본편] 7. 프록시와 영속성 전이

HJ·2024년 2월 23일
0

JPA 기본편

목록 보기
7/10

김영한 님의 자바 ORM 표준 JPA 프로그래밍 - 기본편 강의를 보고 작성한 내용입니다.


1. 프록시

1-1. 필요한 이유

현재 예제에서는 Member 와 Team 이 연관관계를 맺고 있습니다. 만약 JPA 가 Member 와 Team 을 한 번에 가져온다고 했을 때, 둘 다 비즈니스 로직에서 사용한다면 좋겠지만 실제로는 Member 만 사용할 수도 있습니다.

Member findMember = em.find(Member.class, member.getId());
System.out.println("findMember = " + findMember.getName());
select
    m1_0.MEMBER_ID,
    m1_0.city,
    m1_0.name,
    ...
from
    Member m1_0 
left join Team t1_0 
    on t1_0.id=m1_0.team_id 
where
    m1_0.MEMBER_ID=?

Member 의 이름을 조회하는 코드를 실행했을 때 나오는 쿼리입니다. Team 은 전혀 사용을 하지 않는 상황인데 조인을 통해 Team 까지 한 번에 가져오고 있습니다.

JPA 는 이러한 점을 지연로딩프록시라는 것으로 해결하는데 지연 로딩을 이해하기 위해서는 프록시부터 명확하게 이해해야 합니다.


1-2. 프록시

JPA 에는 em.find() 와 함께 em.getReference() 라는 참조를 가져오는 메서드가 존재합니다.

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

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

가짜 엔티티를 조회한다는 말은 DB에 쿼리가 나가지 않는데 객체가 조회되는 것을 말합니다.


Member findMember = em.getReference(Member.class, member.getId());
System.out.println("---------------------findMember 반환됨");
System.out.println("findMember = " + findMember.getName());
---------------------findMember 반환됨
select
    m1_0.MEMBER_ID,
    m1_0.city,
    m1_0.name,
    ...
from
    Member m1_0 
left join Team t1_0 
    on t1_0.id=m1_0.team_id 
where
    m1_0.MEMBER_ID=?

위의 코드를 실행시킨 SQL 로그를 보았을 때 findMember 를 반환하는 코드에서 SELECT 쿼리가 나가지 않고 그 이후에 쿼리가 실행된 것을 확인할 수 있습니다.

즉, findMember 를 반환했는데 DB 에 쿼리가 나가지 않았다는 의미이고 getName() 으로 이름을 가져올 때 DB 에 쿼리가 나간 것입니다.

findMember.getClass() = Member$HibernateProxy$OFQX4JAg

findMember.getClass() 를 출력해보면 위처럼 출력되는데 반환된 findMember 는 Hibernate 가 강제로 만든 가짜 클래스라는 의미입니다. 그리고 이것이 프록시라는 것입니다.


1-3. 프록시와 실제 엔티티

getReference() 로 가져온 프록시 객체를 그림으로 표현하면 위와 같은데 프록시 객체는 실제 클래스를 상속 받아서 만들어져 겉모양이 실제 클래스와 동일합니다. 사용자 입장에서 진짜 객체인지 프록시 객체인지 구분하고 사용하면 됩니다.

프록시 객체는 실제 객체의 참조(target)를 보관하는데 target 이 바로 진짜 레퍼런스를 가리키며 프록시 객체를 호출하면 프록시 객체는 실제 객체의 메소드 호출합니다.


1-4. 프록시 초기화

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

위의 코드에서 member 는 실제 DB 에서 조회한 적이 없어 target 이 존재하지 않습니다.
그 후 getName() 호출을 하면 프록시 객체는 아래와 같은 초기화 과정을 거치게 됩니다.

  1. 처음 getRefercnce() 로 가져온 Proxy 는 target 을 갖고 있지 않습니다.

  2. target 이 비어있는데 getName() 을 호출하면 JPA 가 영속성 컨텍스트에 초기화를 요청합니다.

  3. 영속성 컨텍스트는 DB 에서 조회를 해서 실제 Entity 객체를 생성합니다.

  4. 실제 Entity 가 생성되면 프록시 객체의 target 에 실제 Entity 를 연결해줍니다.

  5. target( 실제 Entity )의 getName() 을 통해 Member 의 이름이 반환됩니다.

  6. 프록시가 초기화되었기( target 이 연결되었기 ) 때문에 이후에 또 getName() 을 호출해도 DB 에 쿼리가 날라가지 않습니다.


1-5. 프록시의 특징

  1. 프록시 객체는 처음 사용할 때 한 번만 초기화되고, 한 번 초기화되면 초기화된 객체를 계속 사용하게 됩니다.( 실제 Entity 가 연결되었기 때문 )

  2. 프록시 객체를 초기화할 때 프록시 객체가 실제 엔티티로 변하는 것은 아닙니다. 초기화되면 프록시 객체를 통해 실제 엔티티에 접근이 가능하게 되는 것입니다.

  3. 프록시 객체는 원본 엔티티를 상속받는 것이기 때문에 타입 체크 시 주의해야 합니다 ( == 비교를 사용하면 실패합니다. instance of 를 사용해야 합니다. )

  4. 영속성 컨텍스트에 찾는 엔티티가 이미 존재하면 getReference() 를 호출해도 실제 엔티티가 반환됩니다. ( 그 반대도 마찬가지 )

  5. 영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태일 때, 프록시를 초기화하면 예외가 발생합니다. ( 하이버네이트의 경우 LazyInitializationException 예외가 발생 )


[ 4번 추가 설명 ]

[ find vs getReference ]

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

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

위의 코드를 실행하면 m1 도 Proxy 가 아닌 Member 이고, m2 도 Proxy 가 아닌 Member 입니다. 이 결과가 나오는 것은 두 가지 이유가 존재합니다.

첫 번째는 이미 Member 가 영속성 컨텍스트에 있는데 굳이 프록시로 가져와도 아무 이점이 없기 때문입니다. 원본을 반환하는게 성능 최적화 입장에서도 훨씬 좋기 때문입니다.

두 번째 이유는 JPA 는 같은 영속성 컨텍스트 안에서, 같은 트랜잭션 레벨 안에서 == 비교를 하면 항상 true 로 출력되어야 하기 때문입니다.

m1 이 실제 엔티티든 프록시든 상관없이 JPA 에서는 마치 Java 컬렉션에서 가져온 것을 == 비교하듯이, 위의 m1 과 m2 가 한 영속성 컨텍스트에서 가져온 것이고 PK 가 동일하다면 JPA 는 항상 true 를 반환해야 합니다.

쉽게 말해서 == 비교를 할 때 true 로 만들어주기 위해서 영속성 컨텍스트에 있다면 프록시가 아닌 실제 엔티티를 반환하는 것입니다.


[ getReference vs getReference ]

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

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

위의 코드를 실행해서 출력하면 둘이 동일한 프록시가 출력됩니다. 왜냐하면 4번에 의해 == 비교를 했을 때 true 를 출력해주어야 하기 때문입니다.


[ getReference vs find ]

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

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

refMember 는 프록시 객체가 출력됩니다. 그 뒤에 findMember 는 find() 로 조회했기 때문에 실제 엔티티가 출력될 것이라고 생각할 수 있지만 findMember 역시 refMember 와 동일한 프록시 객체가 출력됩니다.

만약 refMember 가 프록시 객체이고 findMember 가 실제 엔티티라면 == 비교를 했을 때 false 가 출력될 것입니다. 하지만 4번에 의해 true 가 출력되어야 하고, 그래서 find() 의 결과가 프록시를 반환합니다.


[ 5번 추가 설명 ]

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

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

System.out.println("name = " + refMember.getName());    // 프록시 객체 초기화 ( 강제 호출 )

만약 위처럼 영속성 컨텍스트를 종료하거나 영속 상태 엔티티를 준영속 상태로 변경하고 프록시 객체를 초기화하면 어떻게 될까요?

위에서 언급한 것처럼 하이버네이트가 org.hibernate.LazyInitializationException 예외를 터트려버립니다.

왜냐하면 1-4번에서 프록시에 대한 초기화 요청은 영속성 컨텍스트를 통해 일어난다고 했는데 해당 객체를 영속성 컨텍스트에서 관리하고 있지 않기 때문입니다.

참고로 getName() 과 같은 메서드를 써서 프록시 객체를 초기화하는 것을 강제 초기화라고 하며, Hibernate.initalize(entity) 를 통해서도 강제로 초기화할 수 있습니다.




2. 지연로딩과 즉시로딩

2-1. 지연로딩

처음으로 돌아가서 Member 만 사용을 하는데 굳이 Team 까지 같이 가져오는 문제를 해결하는 것이 지연로딩이라고 했습니다. JPA 는 지연로딩 LAZY 를 사용해서 프록시로 조회합니다.

public class Member {
    ...
    @ManyToOne(fetch = FetchType.LAZY)
    private Team team;
}
//-------------------------------------
Member findMember = em.find(Member.class, member.getId());
System.out.println("---------------------------findMember 반환");
System.out.println(findMember.getTeam().getName());
Hibernate: 
    select
        m1_0.MEMBER_ID,
        m1_0.name,
    from
        Member m1_0 
    where
        m1_0.MEMBER_ID=?
---------------------------findMember 반환
Hibernate: 
    select
        t1_0.id,
        t1_0.name 
    from
        Team t1_0 
    where
        t1_0.id=?

위처럼 FetchType.LAZY 를 사용하면 Member 클래스만 DB 에서 조회하고, Team 은 프록시 객체로 조회합니다. 쿼리 로그를 보면 Member 만 조회하고 Team 에 대한 것은 아무것도 없는 것을 확인할 수 있습니다.

그 이후에 getTeam().getName() 을 하면 프록시 초기화가 이루어져야 하기 때문에 DB 에서 Team 을 조회하게 됩니다. 즉, 실제 Team 을 사용하는 시점에 DB 에 쿼리가 나가 프록시가 초기화됩니다.


2-2. 즉시로딩

만약 Member 와 Team 을 함께 사용하는 일이 많다면 즉시로딩을 사용합니다.

public class Member {
    ...
    @ManyToOne(fetch = FetchType.EAGER)
    private Team team;
}
//-------------------------------------
Member findMember = em.find(Member.class, member.getId());
Hibernate: 
    select
        m1_0.MEMBER_ID,
        m1_0.name,
    from
        Member m1_0 
    left join
        Team t1_0 
            on t1_0.id=m1_0.team_id 
    where
        m1_0.MEMBER_ID=?

쿼리 로그를 보면 Member 를 DB 에서 조회할 때 Team 을 조인해서 바로 가져오는 것을 확인할 수 있습니다. 한 번에 다 가져오기 때문에 프록시를 사용하지 않습니다.

하지만 실무에서는 즉시로딩이 아닌 지연로딩을 사용해야 합니다. 즉시로딩을 적용하면 예상하지 못한 SQL 이 발생하고, JPQL 에서 N + 1 문제를 일으키기 때문입니다.

@ManyToOne, @OneToOne은 기본이 즉시 로딩이고, @OneToMany, @ManyToMany는 기본이 지연 로딩입니다.


[ N + 1 문제 ]

List<Member> members = em.createQuery("select m from Member m", Member.class)
                        .getResultList();
Hibernate: 
    select
        m1_0.MEMBER_ID,
        m1_0.name,
    from
        Member m1_0
Hibernate: 
    select
        t1_0.id,
        t1_0.name 
    from
        Team t1_0 
    where
        t1_0.id=?

즉시로딩을 설정하고 위의 JPQL 을 실행하면 쿼리가 2번 나가게 됩니다. find() 는 PK 를 찍어서 가져오는 것이기 때문에 JPA 가 최적화할 수 있습니다.

하지만 JPQL 은 그대로 SQL 로 번역이 이루어지기 때문에 아래처럼 실행되게 됩니다.

  1. JPQL 이 Member 를 조회하는 것이기 때문에 Member 에 대한 select 쿼리가 실행

  2. Member 를 가지고 왔는데 Team 이 즉시로딩이 되어 있습니다.

  3. 즉시로딩은 데이터를가져올 때 무조건 값이 다 들어있어야 하기 때문에 또 Team 을 조회하기 위한 쿼리가 실행됩니다.

지금은 Member 데이터가 1개만 저장되어 있어서 1번만 실행되었지만 10개의 데이터가 있다면 Team 을 조회하기 위해 10번의 쿼리가 나가게 됩니다.

이것이 N + 1 문제인데 처음의 쿼리를 1 이라고 하고, 처음 쿼리로 인해 추가 쿼리가 N 개가 나간다고 해서 N + 1 이라고 합니다.

LAZY 로 설정하고 실행하면 쿼리는 Member 만 조회하고, Team 에는 프록시가 들어갑니다. 하지만 member 를 반복하면서 team 을 사용했을 때 쿼리가 반복해서 나가는 것은 동일합니다.


[ N + 1 문제 해결 ]

우선 모든 연관관계를 지연로딩으로 설정하고 fetch join 을 사용합니다.

패치조인은 런타임에 동적으로 원하는 객체들을 선택해서 한 번에 가져오는 전략입니다.
join fetch m.team 을 하면 둘을 조인해서 한 번에 가져옵니다. ( 즉시 로딩처럼 )

이외에도 @EntityGraph 나 batch_size 를 사용하는 방법도 존재합니다.




3. 영속성 전이( CASCADE )

영속성 전이는 특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속 상태로 만들고 싶을 때 사용합니다.

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

    public void addChile(Child child) {
        childList.add(child);
        child.setParent(this);
    }
}
//------------------------------------------------------------------
@Entity
public class Child {
    @ManyToOne
    @JoinColumn(name = "PARENT_ID")
    private Parent parent;
}
//------------------------------------------------------------------
Parent parent = new Parent();
Child child1 = new Child();
Child child2 = new Child();

parent.addChile(child1);
parent.addChile(child2);

em.persist(parent);

위처럼 Parent 와 Child 가 있다고 가정했을 때 cascade = CascadeType.PERSIST 이 없다면 parent 와 child 를 저장하려면 각각 persist() 를 호출해야 저장됩니다.

하지만 해당 속성을 사용함으로써 parent 만 저장하면 자동적으로 child 도 저장되게 됩니다.

CASCADE 를 사용할 때 주의해야 할 점은 소유자가 하나일 때 사용해야 한다는 점입니다. 현재 예시에서는 child 의 소유자가 parent 가 있는데 만약 다른 엔티티에서 child 와 연관관계가 있다면 사용하면 안됩니다.


[ CASCADE 종류 ]

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




4. 고아객체

부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제할 때 사용하는 orphanRemoval 옵션이 있는데 이를 true 로 설정하면 자동으로 제거됩니다.

public class Parent {
    ...
    @OneToMany(mappedBy = "parent", cascade = CascadeType.PERSIST, orphanRemoval = true)
    private List<Child> childList = new ArrayList<>();
}
//-----------------------------------------------------------------------------------
Parent parent = em.find(Parent.class, id);
parent.getChildren().remove(0);

위처럼 옵션을 추가한뒤, 자식 엔티티를 컬렉션에서 제거하면 둘의 연관관계가 끊기게 되는데 그렇게 되면 DELETE 쿼리가 수행됩니다.

이 옵션도 CASCADE 와 마찬가지로 참조하는 곳이 하나일 때만 사용해야 하며, @OneToOne, @OneToMany 만 가능합니다.

참고로 고아 객체 제거 기능을 활성화 하면, 부모를 제거할 때 자식도 함께 제거되는데 이는 CascadeType.REMOVE 처럼 동작합니다.


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

CascadeType.ALLorphanRemoval = true 옵션을 모두 활성화 하면 부모 엔티티를 통해서 자식의 생명주기를 관리할 수 있습니다.

profile
공부한 내용을 정리해서 기록하고 다시 보기 위한 공간

0개의 댓글