JPA(Java Persistence API)

[JPA] Part 10. 프록시

cwchoiit 2023. 10. 22. 20:29
728x90
반응형
SMALL
728x90
반응형
SMALL

JPA에서 중요한 개념 중 하나인 프록시라는 게 있다. 중요한 개념이라기보단 중요하게 사용되는 지연로딩과 즉시로딩을 사용하려면 반드시 깊은 이해가 필요하다고 개인적으로 생각한다. 왜 그러냐면 회사에서 프로젝트를 진행 중에 이해도가 깊지 않은 상태에서 지연로딩을 마구잡이로 썼다가 프록시 초기화 오류를 진짜 무진장 만났던 기억이 아직도 생생하다. 

 

 

우선 서사는 이렇다.

멤버라는 테이블과 팀이라는 테이블이 있을 때를 가정해보자. 멤버는 팀에 속하고 아래 그림과 같다.

이런 구조를 가진 두 테이블이 있을 때 멤버를 조회한다고 가정해보자. 특정 멤버를 조회할 때 팀도 같이 조회해야 할까?

예를 들어 이런 메서드가 있다고 생각해보자.

private static void printMember(EntityManager em) {
    Member member = em.find(Member.class, 1L);
    System.out.println("member = " + member.getName());
}
    
private static void printMemberAndTeam(EntityManager em) {
    Member member = em.find(Member.class, 1L);
    System.out.println("member name = " + member.getName());
    System.out.println("member's team = " + member.getTeam());
}

아래는 멤버와 팀을 모두 출력하는 메서드고 위에는 멤버만 출력하는 메서드다.

아래같은 경우 멤버를 조회했을 때 팀까지 같이 조회가 되면 더할나위 없이 좋은 상황이겠지만 위 같은 경우 팀이 같이 조회되는 것이 오히려 불필요한 리소스를 사용하는 경우가 된다.

 

이런 상황을 해결하기 위해 '프록시'가 있다.

 

 

프록시

우선 프록시를 깊이 있게 알기 전에 엔티티 매니저는 두 가지 핵심 메서드가 있다.

- em.find()

- em.getReference()

 

우선 find() 메서드는 이미 여러번 사용했기 때문에 잘 알고 있다. 영속성 컨텍스트 또는 데이터베이스로부터 특정 객체(레코드)를 조회하는 메서드이다. 그럼 getReference()는 무엇일까?

데이터베이스로 조회를 지연시키는 가짜 엔티티(프록시) 객체를 조회한다. 

 

이해하기 위해 코드를 실행해보자.

package org.example;

import org.example.entity.Member;
import org.example.entity.inheritance.Item;
import org.example.entity.inheritance.Movie;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

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

        tx.begin();

        try {
            Member member = new Member();
            member.setName("member1");
            em.persist(member);

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

            Member findMember = em.find(Member.class, member.getId());

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

이 코드를 수행해보면 다음과 같은 Hibernate 로그가 출력된다.

Hibernate: 
    insert 
    into
        Member
        (createdAt, description, email, lastModifiedAt, name, roleType, TEAM_ID, id) 
    values
        (?, ?, ?, ?, ?, ?, ?, ?)
Hibernate: 
    select
        member0_.id as id1_3_0_,
        member0_.createdAt as createda2_3_0_,
        member0_.description as descript3_3_0_,
        member0_.email as email4_3_0_,
        member0_.lastModifiedAt as lastmodi5_3_0_,
        member0_.name as name6_3_0_,
        member0_.roleType as roletype7_3_0_,
        member0_.TEAM_ID as team_id8_3_0_,
        team1_.TEAM_ID as team_id1_5_1_,
        team1_.name as name2_5_1_ 
    from
        Member member0_ 
    left outer join
        Team team1_ 
            on member0_.TEAM_ID=team1_.TEAM_ID 
    where
        member0_.id=?

실제 데이터베이스로부터 멤버를 조회하기 위한 SELECT문이 수행된다. 근데 getReference()를 사용해보면 어떻게 될까?

package org.example;

import org.example.entity.Member;
import org.example.entity.inheritance.Item;
import org.example.entity.inheritance.Movie;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

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

        tx.begin();

        try {
            Member member = new Member();
            member.setName("member1");
            em.persist(member);

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

            Member findMember = em.getReference(Member.class, member.getId());

            tx.commit();
        } catch (Exception e) {
            System.out.println(e.getMessage());
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}
Oct 22, 2023 7:06:06 PM org.hibernate.engine.jdbc.connections.internal.DriverManagerConnectionProviderImpl$PoolState stop
INFO: HHH10001008: Cleaning up connection pool [jdbc:h2:tcp://localhost/~/h2/test]

Process finished with exit code 0

SELECT문이 출력되지 않는다. 즉 데이터베이스에 직접 조회를 하지 않았단 소리다. 이게 프록시다. 우선 객체를 찾아내는 조회 과정을 미루는것이다. 그럼 언제 실제로 조회할까? 직접 사용할 때. 조회를 그때가서야 한다. 그리고 이 조회를 하는 순간을 "프록시를 초기화한다" 라고 말한다.아래 코드를 보자.

 

package org.example;

import org.example.entity.Member;
import org.example.entity.inheritance.Item;
import org.example.entity.inheritance.Movie;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

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

        tx.begin();

        try {
            Member member = new Member();
            member.setName("member1");
            em.persist(member);

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

            Member findMember = em.getReference(Member.class, member.getId());
            System.out.println("No SELECT QUERY Until this line");

            // Execute SELECT Query
            System.out.println("findMember = " + findMember.getName());

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

실제로 찾은 멤버가 가지고 있는 메서드를 호출할 때 비로소 프록시는 객체를 조회해온다. 실행 결과는 다음과 같다. "No SELECT QUERY Until this line"이 출력된 후 SELECT문이 실행됨을 알 수 있다. 

No SELECT QUERY Until this line
Hibernate: 
    select
        member0_.id as id1_3_0_,
        member0_.createdAt as createda2_3_0_,
        member0_.description as descript3_3_0_,
        member0_.email as email4_3_0_,
        member0_.lastModifiedAt as lastmodi5_3_0_,
        member0_.name as name6_3_0_,
        member0_.roleType as roletype7_3_0_,
        member0_.TEAM_ID as team_id8_3_0_,
        team1_.TEAM_ID as team_id1_5_1_,
        team1_.name as name2_5_1_ 
    from
        Member member0_ 
    left outer join
        Team team1_ 
            on member0_.TEAM_ID=team1_.TEAM_ID 
    where
        member0_.id=?
findMember = member1

 

그래서 프록시는 이러한 특징을 가지고 있다.

  • 실제 클래스를 상속 받아서 만들어진다.
  • 실제 클래스와 겉 모양이 같다.
  • 사용하는 입장에서는 진짜 객체인지 프록시 객체인지 구분하지 않고 사용할 수 있다.
  • 프록시 객체는 실제 객체의 참조(Target)를 보관
  • 프록시 객체를 호출하면 프록시 객체는 실제 객체의 메소드를 호출
  • 프록시 객체는 처음 사용할 때 한 번만 초기화
  • 프록시 객체를 초기화할 때, 프록시 객체가 실제 엔티티로 바뀌는 것은 아니다. 초기화되면 프록시 객체를 통해서 실제 엔티티에 접근 가능하다.
  • 프록시 객체는 원본 엔티티를 상속받기 때문에 타입 체크 시 주의 요망 ("=="를 사용하면 안되고 "instance of"를 사용해야한다)

이게 무슨 말이냐면, 아래 코드를 보자.

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

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

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

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

System.out.println("m1 = " + m1.getClass()); //m1 = class org.example.entity.Member
System.out.println("m2 = " + m2.getClass()); //m2 = class org.example.entity.Member$HibernateProxy$H8YFX6fR 

System.out.println("m1 == m2: " + (m1.getClass() == m2.getClass())); //false

m1은 find() 메서드를, m2는 getReference() 메서드를 사용했다. 그러면 가져오는 클래스는 MemberMemberProxy가 된다. 이 때 이 두 객체 모두 타입을 Member로 받아왔지만 (프록시가 원본 엔티티를 상속받기 때문에) 두 객체의 클래스를 비교하면 false를 리턴한다는 뜻이다. 

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

이라고 되어 있으나, 좀 더 정확히는 같은 트랜잭션 내에서 JPA는 무결성과 일관성을 유지해주는 속성 때문에 getReference()가 아니라 find() 메서드를 사용하더라도 이미 영속성 컨텍스트에 프록시가 있으면 find() 메서드로 호출해도 프록시로 반환한다.

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

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

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

System.out.println("m1 = " + m1.getClass());
System.out.println("m2 = " + m2.getClass());

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

위 코드를 보면, 영속성 컨텍스트를 깔끔히 정리한 후에 find() 메서드를 먼저 호출하고 getReference() 메서드를 호출한다. 이 때 find()메서드를 통해 실제 엔티티를 가져와 영속성 컨텍스트에 넣었기 때문에 getReference()를 호출해서 가져와도 실제 엔티티를 가져온다. 그 결과는 다음과 같다.

Hibernate: 
    select
        member0_.id as id1_3_0_,
        member0_.createdAt as createda2_3_0_,
        member0_.description as descript3_3_0_,
        member0_.email as email4_3_0_,
        member0_.lastModifiedAt as lastmodi5_3_0_,
        member0_.name as name6_3_0_,
        member0_.roleType as roletype7_3_0_,
        member0_.TEAM_ID as team_id8_3_0_,
        team1_.TEAM_ID as team_id1_5_1_,
        team1_.name as name2_5_1_ 
    from
        Member member0_ 
    left outer join
        Team team1_ 
            on member0_.TEAM_ID=team1_.TEAM_ID 
    where
        member0_.id=?
m1 = class org.example.entity.Member
m2 = class org.example.entity.Member
m1 == m2: true

 

반대도 마찬가지다. 만약 아래와 같은 코드가 있을 때를 보자.

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

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

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

System.out.println("m1 = " + m1.getClass());
System.out.println("m2 = " + m2.getClass());

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

이번엔 getReference()를 먼저 호출해서 멤버를 조회하면 이 때 m2는 프록시로 가져온다. 그럼 m1은 어떻게 가져올까? 그렇다. 프록시로 가져온다. 왜냐? JPA는 같은 트랜잭션 내 동일한 레코드에 대해서 일관성을 유지하려는 속성을 가지고 있기 때문이다.

이 코드의 결과는 다음과 같다.

Hibernate: 
    select
        member0_.id as id1_3_0_,
        member0_.createdAt as createda2_3_0_,
        member0_.description as descript3_3_0_,
        member0_.email as email4_3_0_,
        member0_.lastModifiedAt as lastmodi5_3_0_,
        member0_.name as name6_3_0_,
        member0_.roleType as roletype7_3_0_,
        member0_.TEAM_ID as team_id8_3_0_,
        team1_.TEAM_ID as team_id1_5_1_,
        team1_.name as name2_5_1_ 
    from
        Member member0_ 
    left outer join
        Team team1_ 
            on member0_.TEAM_ID=team1_.TEAM_ID 
    where
        member0_.id=?
m1 = class org.example.entity.Member$HibernateProxy$cFfoNpx3
m2 = class org.example.entity.Member$HibernateProxy$cFfoNpx3
m1 == m2: true

find() 메서드로 인해 SELECT문으로 실제 데이터베이스로부터 조회해도 가져오는 값은 Proxy이다.

근데 이제 복잡하게 생각말고, 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 em.getReference()를 호출해도 프록시가 아닌 엔티티를 가져온다. 라고 한 줄로 요약하는 것이다.

 

  • 영속성 컨텍스트의 도움을 받을 수 없는 준영속(Detach)상태 일 때, 프록시를 초기화하면 에러 발생

이게 가장 중요한 부분이다. 준영속 상태가 되는 상황은 크게 세가지 정도가 있을듯하다. 직접 detach하거나, 엔티티 매니저를 clear하거나 close하거나. 어떤 경우가 됐든 준영속 상태가 되었다면 프록시를 초기화할 때 LazyInitializationException이 발생한다.

 

코드로 보면 좀 더 이해가 빠르게 되더라. 코드를 보자.

package org.example;

import org.example.entity.Member;
import org.example.entity.inheritance.Item;
import org.example.entity.inheritance.Movie;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

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

        tx.begin();

        try {
            Member member = new Member();
            member.setName("member1");
            em.persist(member);

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

            Member m2 = em.getReference(Member.class, member.getId());

            em.detach(m2); //준영속 상태

            System.out.println("m2 Name: " + m2.getName());

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

위 코드처럼 프록시를 가져온 후 가져온 프록시를 detach했을 때, 프록시를 초기화하려고 시도하면 다음과 같은 에러를 마주할 수 있다. 

org.hibernate.LazyInitializationException: could not initialize proxy [org.example.entity.Member#1] - no Session
	at org.hibernate.proxy.AbstractLazyInitializer.initialize(AbstractLazyInitializer.java:176)
	at org.hibernate.proxy.AbstractLazyInitializer.getImplementation(AbstractLazyInitializer.java:322)
	at org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor.intercept(ByteBuddyInterceptor.java:45)
	at org.hibernate.proxy.ProxyConfiguration$InterceptorDispatcher.intercept(ProxyConfiguration.java:95)
	at org.example.entity.Member$HibernateProxy$AFKAFeFy.getName(Unknown Source)
	at org.example.Main.main(Main.java:37)

이 준영속 상태는 다시 말해 영속성 컨텍스트의 도움을 못받는 경우를 말하고, 영속성 컨텍스트는 하나의 트랜잭션이 곧 하나의 엔티티 매니저와 1:1로 매핑된다고 아주아주 이전에 말한 것 처럼 엔티티 매니저가 닫히거나(하나의 트랜잭션이 끝남), 비워지거나 등 어떤 식으로든 영속성 컨텍스트의 도움을 받지 못하는 경우 위 에러를 마주한다. 

 

이런 상황을 이해해야 지연 로딩과 즉시 로딩에 대해 깊은 이해와 사용성을 높일 수 있다. 나도 직접 이런 경우를 실제 회사 프로젝트에서 많이 많이 겪다보니까 정말 힘들었는데.. 알고 나니 어려운 게 하나도 아니더라. 

 

 

프록시 유틸 메소드

프록시 관련해서 쓸만한 메서드들을 소개하자면, 프록시가 초기화 된 상태인지 아닌지 확인 가능한 isLoaded()가 있다.

emf.getPersistenceUnitUtil().isLoaded(Object entity);
// emf.getPersistenceUnitUtil()은 PersistenceUnitUtil를 반환

또는 강제 초기화를 할 수 있는 initialize() 메서드도 있다.

Hibernate.initialize(Object entity);

근데 JPA 표준에서는 강제 초기화가 없기 때문에 그냥 해당 프록시가 상속 받는 클래스가 가진 메서드가 실제로 호출되는 순간이 곧 초기화 되는 순간이다. 예시: member.getName() 호출 

728x90
반응형
LIST