2024.10.30 업데이트
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());
}
- printMemberAndTeam(EntityManager em)은 멤버와 팀을 모두 출력하는 메서드이다.
- printMember(EntityManager em)은 멤버만 출력하는 메서드이다.
- printMemberAndTeam()을 호출한다면, 멤버를 조회할 때 팀까지 같이 조회가 되면 훨씬 효율적이겠지만, printMember()를 호출한다면, 멤버를 조회할 때 팀까지 조회되는게 비효율적일 것이다. (팀을 가져오기 위해 JOIN을 할테니)
이런 상황을 해결하기 위해 '프록시'가 있다.
Proxy
우선 프록시를 깊이 있게 알기 전에 엔티티 매니저는 두 가지 핵심 메서드가 있다. (핵심 메서드라고 말은 했지만, 사실 순수 JPA를 사용할 일은 거의 없고 Spring Boot + Spring Data JPA를 같이 사용하는 게 거의 99%이고, 이 경우에는 entityManager.find(), entityManager.getReference() 같은 메서드를 사용하지 않아서 이 메서드를 막 외우고 그럴 필요는 없다. 다만 이런것으로부터 프록시와 초기화에 대해 이해하는게 정말 중요하기 때문에 이 내용을 알아야 한다.)
- 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:
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문이 출력되지 않는다. 즉 데이터베이스에 직접 조회를 하지 않았단 소리다. 이게 프록시다. 우선 객체를 찾아내는 조회 과정을 미루는것이다. 그럼 언제 실제로 조회할까? 실제로 사용할 때! 조회를 그때가서야 한다. 그리고 이 조회를 하는 순간을 "프록시를 초기화 한다" 라고 말한다.
- 이렇게 getReference()를 호출하면, Proxy 객체를 반환하는데 이 상태에서는 DB로부터 조회하는 단계는 없다.
그럼, 이 프록시 객체를 초기화하는 순간을 보자.
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();
}
}
}
- 프록시 객체가 가지고 있는 메서드를 호출할 때 비로소 프록시는 실제 데이터를 DB로부터 조회해온다. 실행 결과는 다음과 같다. "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을 가지고 있다. 이 target은 초기화되기 전까지는 값이 없는 상태다.
- Client가 getName()과 같은 객체를 통해 가져와야 하는 값을 호출하는 무언가를 호출했을때 프록시는 초기화 요청을 진행하낟.
- 그럼 DB로부터 실제 데이터를 조회한다.
- 조회한 데이터에 대한 엔티티를 만들고 그 엔티티를 프록시 객체가 가지고 있는 target에 집어넣는다.
- target이 가진 getName()을 호출하고 그 값을 Client에게 반환한다.
그래서 프록시는 이러한 특징을 가지고 있다.
- 실제 클래스를 상속 받아서 만들어진다.
- 실제 클래스와 겉 모양이 같다.
- 사용하는 입장에서는 진짜 객체인지 프록시 객체인지 구분하지 않고 사용할 수 있다.
- 프록시 객체는 실제 객체의 참조(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() 메서드를 사용했다. 그러면 가져오는 클래스는 Member와 MemberProxy가 된다. 이 때 이 두 객체 모두 타입을 Member로 받아왔지만 (프록시가 원본 엔티티를 상속받기 때문에 프록시 객체여도 Member 타입으로 받아올 수 있음) 두 객체의 클래스를 비교하면 false를 리턴한다는 뜻이다.
- 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 em.getReference()를 호출해도 실제 엔티티 반환이라고 되어 있으나, 좀 더 정확히는 같은 트랜잭션 내에서 JPA는 무결성과 일관성을 유지해주는 속성 때문에 getReference()가 아니라 find() 메서드를 사용하더라도 이미 영속성 컨텍스트에 프록시가 있으면 find() 메서드로 호출해도 프록시로 반환한다. 반대로 이미 영속성 컨텍스트에 찾은 동일한 멤버 객체가 실제로 있으면 getReference()를 호출해도 실제 엔티티를 반환한다는 의미다. 아래 코드로 예시를 살펴보자.
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이 발생한다. 영속성 컨텍스트의 도움을 받을 수 없다라는 말은 영속성 컨텍스트는 결국 하나의 엔티티 매니저당 하나의 영속성 컨텍스트를 가진다. 그럼 엔티티 매니저가 끝나면 영속성 컨텍스트도 끝나는 것이다. 이게 이제 스프링 부트를 같이 사용하면 @Transactional 이라는 애노테이션을 사용해서 간편하게 엔티티 매니저를 만들고 영속성 컨텍스트를 만들어 주는데, 이 @Transactional이 적용된 메서드가 끝난 상태에서 초기화를 시도하면 영속성 컨텍스트는 없기 때문에 동일하게 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() 호출)
'JPA(Java Persistence API)' 카테고리의 다른 글
[JPA] Part 12. CASCADE, 고아 객체 (2) | 2023.10.23 |
---|---|
[JPA] Part 11. 지연로딩과 즉시로딩 (2) | 2023.10.23 |
[JPA] Part 9. @MappedSuperclass (0) | 2023.10.22 |
[JPA] Part 8. 상속관계 매핑 (2) | 2023.10.22 |
[JPA] Part 7. 다대일, 일대다, 일대일, 다대다 (0) | 2023.10.19 |