1. 프록시 객체란?
- 실제 엔티티 객체 대신에 DB 조회를 지연할 수 있는 가짜 객체
- 연관 객체가 실제로 사용될 때만 DB에서 로딩해주는 지연 로딩을 이용할 때 필요하다.
- 글로벌 fetch 전략을 FetchType.LAZY로 지정한 필드에 자동으로 적용되며, 프록시 객체를 직접 생성할 수도 있다.
2. 프록시 객체의 동작
1) 프록시 객체의 직접 생성
- EntityManager의 getReference() 메소드를 이용하면 DB 접근을 위임한 프록시 객체를 반환한다.
Member member = em.getReference(Member.class, "member1");
- 프록시 클래스는 실제 클래스를 상속 받아서 만들어진다. 이 때 실제 객체에 대한 참조를 보관하며, 프록시 객체의 메소드를 호출하면 프록시 객체는 실제 엔티티 객체의 메소드를 호출한다.
2) 프록시 객체의 초기화
- 프록시 객체의 메소드가 실제로 사용되면 프록시 객체는 영속성 컨텍스트에 실제 엔티티 생성을 요청한다. 이 때 영속성 컨텍스트는 DB를 조회한 후에 실제 엔티티 객체를 생성해서 프록시 객체에게 반환해준다. 이렇게 프록시 객체의 참조는 실제 엔티티 객체를 가지게 되고, 이 후부터 프록시 객체를 통해 실제 엔티티 객체의 메소드를 이용할 수 있게 된다.
- 프록시 객체를 초기화한다고 실제 엔티티로 바뀌는 것은 아니니 오해하지 말도록 하자.
3) 프록시 객체의 특징
- 처음 사용할 때 한 번만 초기화된다.
- 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 getReference() 메소드를 호출해도 실제 인티티를 반환한다.
- 준영속 상태의 프록시를 초기화하면 org.hibernate.LazyInitializationException 예외가 발생한다.
4) 프록시 객체의 식별자 조회
- @Access(AccessType.PROPERTY))인 경우 : 프록시 객체를 생성할 때 식별자 값을 파라미터로 넘기기 때문에 식별자(ID)를 getter 메소드로 조회할 때는 프록시 객체를 초기화하지 않는다.
- @Access(AccessType.Field))인 경우 : 식별자를 getter 메소드로 조회할 때 프록시 객체를 초기화한다.
5) 프록시 객체의 사용
- 다음과 같이 프록시 객체를 연관관계로 넣어주면 연관 객체가 사용되는 시점에 DB를 조회하게 된다.
Member member = em.find(Member.class, "member1");
Team team = em.getReference(Team.class, "teamA");
member.setTeam(team); // 이로써 team을 조회하는 시점에 DB를 조회하게 됨
3. 프록시 확인
- JPA가 제공하는 PersistenceUnitUtil의 isLoaded() 메소드를 사용하면 특정 프록시 객체의 초기화 여부를 확인할 수 있다.
boolean isLoad = em.getEntityManagerFactory().getPersistenceUnitUtil.isLoaded(entity);
- 조회한 엔티티가 프록시 객체인지 실제 엔티티 객체인지 확인하기 위해 클래스명을 확인해볼 수도 있다. 프록시의 경우에는 클래스명의 뒤쪽에 javassist가 표시된다.
4. 영속성 컨텍스트와 프록시
- 영속성 컨텍스트에서 조회한 엔티티는 식별자만 같다면 프록시 객체 여부에 상관없이 동일성을 보장한다. 물론 영속성 컨텍스트에는 먼저 조회한 타입으로 저장된다.
Member refMember = em.getReference(Member.class, "member1");
Member member = em.find(Member.class, "member1"); // 원본 엔티티가 아닌 프록시 객체 반환
Assert.assertTrue(refMember==member); // 성공
Member member = em.find(Member.class, "member1");
Member refMember = em.getReference(Member.class, "member1"); // 프록시 객체가 아닌 원본 엔티티 반환
Assert.assertTrue(refMember==member); // 성공
5. 프록시 타입 비교
- 프록시는 원본 엔티티를 상속 받아서 만들어지기 때문에 프록시 타입을 비교할 때는 instanceof을 사용해야 한다.
Assert.assertTrue(refMember instanceof Member); // 성공
6. 프록시 동등성 비교
- 같은 영속성 컨텍스트의 엔티티를 비교할 때는 동일성 비교를 할 수 있지만 영속성 컨텍스트가 다르면 동일성 비교에 실패한다. 그래서 엔티티의 equals() 메소드를 오버라이딩하여 비즈니스 키를 기준으로 동등성 비교를 할 수 있도록 해야 한다.
※ 비즈니스 키 : 중복되지 않고 거의 변하지 않는 기본 키 후보
예시
// Member.class
@Override
public boolean equals(Object obj) {
if(this==obj) return true;
if(!(obj instanceof Member)) return false;
Member member = (Member) obj;
if(name!=null ? !name.equals(member.getName()) : member.getName()!=null) return false;
return true;
}
위의 예시에서 equals() 메소드의 파라미터로 비즈니스 키값이 동일한 프록시 객체가 들어왔다고 가정하고, equals() 메소드가 어떻게 최종적으로 true 값을 반환하는지 분석해보자.
Member newMember = new Member("member1", "jooyeok");
Member refMember = em.getReference(Member.class, "member1");
Assert.assertTrue(newMember.equals(refMember)); // 성공
첫 번째 줄부터 살펴보자.
두 객체가 동일(==)하다면 당연히 같은 회원일 것이다. 그러나, refMember은 영속성 컨텍스트에서 조회한 프록시 객체이고 newMember은 비영속 객체이기 때문에 서로 다른 인스턴스다. 즉, 이 if문은 넘어간다.
if(this==obj) return true;
프록시 클래스는 실제 엔티티 클래스를 상속받기 때문에 (obj instanceof Member)은 참이다. 즉, 이 if문도 넘어간다.
if(!(obj instanceof Member)) return false;
Member 객체의 메소드를 이용하기 위해 형변환을 수행한다. member에는 프록시 객체가 저장된다.
Member member = (Member) obj;
member의 메소드(getName)를 호출해서 프록시 객체를 초기화했기 때문에 name을 불러올 수 있게 되었다. (name!=null)은 거짓이 되고 (member.getName()!=null)도 거짓이 되면서 이 if문도 넘어간다. 이렇게 equals() 메소드는 최종적으로 true를 반환하게 되는 것이다.
if(name!=null ? !name.equals(member.getName()) : member.getName()!=null) return false;
return true;
7. 상속관계에서 발생하는 프록시의 문제점
1) 형변환 문제
- 다형성을 활용하기 위해 프록시를 부모 타입으로 조회하면 부모 타입을 상속받은 프록시가 생성될 것이다. 이 때, 부모의 프록시 타입과 자식 타입은 서로 부모-자식 관계가 아니기 때문에 하위 타입으로 다운캐스팅을 할 수 없게 되는 문제가 발생한다.
// Item과 Book은 상속 관계
Item proxyItem = em.getReference(Item.class, 1);
Book book = (Book) proxyItem; // java.lang.ClassCastException 예외 발생
- 처음부터 자식 타입을 직접 조회하면 형변환 문제를 해결할 수 있긴 하지만, 다형성 활용은 포기해야 한다.
- 아래와 같이 Hibernate가 제공하는 프록시로부터 원본 엔티티를 반환하는 기능을 이용해도 되지만, 프록시와 원본 엔티티의 동일성(==) 보장은 포기해야 한다.
public static <T> T unProxy(Object entity) {
if(entity instanceof HibernateProxy) {
entity = ((HibernateProxy) entity).getHibernateLazyInitializer().getImplementation();
}
return (T) entity;
}
2) 단순 인터페이스를 이용한 형변환 문제 해결
- 부모 엔티티에 인터페이스를 적용하면 부모 엔티티의 프록시와 자식 엔티티에서 모두 해당 인터페이스를 사용할 수 있다. 위에 있는 이미지[1]을 보면 이해가 잘 될 것이다.
3) Visitor 패턴을 이용한 형변환 문제 해결
(1) Visitor 패턴이란?
- 알고리즘을 객체 구조에서 분리시키는 디자인 패턴
- 실제 로직을 가지고 있는 Visitor 객체가 Visitor를 받아들이는 대상 클래스를 방문하면서 로직을 실행하는 원리다. Visitor를 받아들이는 대상 클래스는 Visitor를 받아들이기만 하고 실제 로직은 Visitor 객체가 처리한다.
(2) Visitor 인터페이스
- Visitor에는 visit() 메소드를 정의한다. Visitor을 받아들이는 대상 클래스는 Book, Fruit, Appliance 엔티티가 될 것이다.
public interface Visitor {
void visit(Book book);
void visit(Fruit fruit);
void visit(Appliance appliance);
}
(3) Visitor 구현체
- 가격 정보를 출력해주는 Visitor 구현체인 PriceVisitor 클래스를 만들었다.
public class PriceVisitor implements Visitor {
@Override
public void visit(Book book) { // 넘어오는 book은 프록시가 아닌 원본 엔티티
System.out.println("Book Price : " + book.getPrice());
}
@Override
public void visit(Fruit fruit) {...}
@Override
public void visit(Appliance appliance) {...}
}
(4) Visitor를 받아들이는 대상 클래스
- 최종적으로 Visitor을 받아들이는 공간인 accept() 메소드를 추가해주자.
<1> 부모 엔티티
- 자식 엔티티에서 accept() 메소드를 사용하도록 추상 메소드로 작성해둔다.
@Entity
@Inheritance(strategy=InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name="DTYPE")
public abstract class Item {
@Id
@GeneratedValue
@Column(name="ITEM_ID")
private Long id;
...
public abstract void accept(Visitor visitor);
}
<2> 자식 엔티티
- accept() 메소드에 방문한 Visitor의 visit() 메소드를 호출함으로써 실제 로직 처리를 Visitor에게 위임한다.
@Entity
@DiscriminatorValue("B")
public class Book extends Item {
...
@Override
public void accept(Visitor visitor) {
visitor.visit(this); // this는 프록시가 아닌 원본 엔티티다.
}
}
(5) Visitor 패턴 실행
- OrderItem의 item 필드에는 글로벌 fetch 전략으로 FetchType.LAZY가 적용되어 있다고 가정하자. 즉, Item은 프록시 객체로 조회된다.
OrderItem orderItem = em.find(OrderItem.class, 1);
Item item = orderItem.getItem(); // Item의 프록시 객체 반환
item.accept(new PriceVisitor()); // Item의 자식 엔티티에 있는 accept() 메소드가 호출된다.
- 위의 코드를 보면 부모 엔티티인 Item을 사용한다는 사실만 알 수 있고 어떤 자식 엔티티의 accept() 메소드가 사용되는지 알 수 없다. 즉, 책임 분리가 잘 이루어졌다고 볼 수 있다.
(6) Visitor 패턴 정리
- 프록시에 대한 걱정 없이 안전하게 원본 엔티티에 접근할 수 있다.
- instanceof 연산이나 형변환 없이 코드를 구현할 수 있다.
- 새로운 기능이 필요할 때 Visitor 부분만 추가하면 된다.
- 객체 구조가 변경되면 모든 Visitor을 수정해야 한다는 단점이 있다.
● 참고자료 : 자바 ORM 표준 JPA 프로그래밍
'Spring 사전 준비 > JPA Hibernate' 카테고리의 다른 글
[JPA] CASCADE 기능 (0) | 2022.08.03 |
---|---|
[JPA] 즉시 로딩과 지연 로딩 (0) | 2022.08.01 |
[JPA] 고급 매핑 기술 (0) | 2022.07.28 |
[JPA] 일대일 매핑과 다대다 매핑 (0) | 2022.07.26 |
[JPA] 단방향 매핑과 양방향 매핑 (0) | 2021.08.18 |
댓글