JPA 프록시 심화
프록시는 원본 엔티티를 상속받아서 만들어지므로 엔티티를 사용하는 클라이언트는 엔티티가 프록시인지 원본 엔티티인지 구분하지 않고 사용할 수 있다. 이로 인해 예상하지 못한 문제들이 발생하기도 한다.
영속성 컨텍스트와 프록시
프록시 조회 후 엔티티 조회
영속성 컨텍스트는 프록시로 조회된 엔티티에 대해 같은 엔티티 조회 요청이 오면 조회된 프록시를 반환한다. 즉, 한번 프록시로 노출한 엔티티는 계속 프록시로 노출한다. 이를 통해 엔티티의 동일성을 보장한다.
@Test
public void test() {
Member newMember = new Member("member1", "회원1");
em.persist(newMember);
em.flush();
em.clear();
Member refMember = em.getReference(Member.class, "member1"); // 프록시 조회
Member findMember = em.find(Member.class, "member1"); // 프록시 조회
Assert.assertTrue(refMember == findMember); // 성공
}
원본 엔티티 조회 후 프록시 조회
원본 엔티티를 조회하면 DB에서 조회했으므로 em.getReference()
메서드를 호출했을 때 프록시를 반환할 이유가 없어 원본 엔티티를 반환한다.
@Test
public void test() {
Member newMember = new Member("member1", "회원1");
em.persist(newMember);
em.flush();
em.clear();
Member findMember = em.find(Member.class, "member1"); // 원본 조회
Member refMember = em.getReference(Member.class, "member1"); // 원본 조회
Assert.assertTrue(refMember == findMember); // 성공
}
프록시 타입 비교
프록시는 원본 엔티티를 상속받은 자식 타입이므로 프록시의 타입을 비교할 때는 ==
비교가 아닌 instanceof
를 사용해야 한다.
@Test
public void test() {
Member newMember = new Member("member1", "회원1");
em.persist(newMember);
em.flush();
em.clear();
Member refMember = em.getReference(Member.class, "member1");
Assert.assertFalse(Member.class == refMember.getClass()); // 성공
Assert.assertTrue(refMember instanceof Member); // 성공
}
프록시 동등성 비교
엔티티의 동등성을 비교하려면 비즈니스 키를 사용해서 equals()
메서드를 오버라이딩하고 비교해야 한다. 그런데 IDE나 외부 라이브러리를 사용해서 구현한 equals()
메서드로 엔티티를 비교할 때, 비교 대상이 프록시면 문제가 발생할 수 있다.
@Entity
public class Member {
@Id
private String id;
private String name;
...
@Override
public boolean equals(Object obj) {
if(this == obj) retrun true;
if(obj == null) return false;
// if(this.getClass() != obj.getClass()) return false;
if(!(obj instanceof Member)) return false;
Member member = (Member) obj;
// if(name != null ? !name.equals(member.name) : member.name != null) return false;
if(name != null ? !name.equals(member.getName()) : member.getName() != null) return false;
return true;
}
@Override
public int hashCode() {
return name != null ? name.hashCode() : 0;
}
}
- 프록시의 타입을 비교할 때는
==
비교가 아닌instanceof
를 사용해야 한다. - 프록시는 실제 데이터를 가지고 있지 않기 때문에 프록시의 멤버 변수에 직접 접근하면 아무값도 조회할 수 없다. 그러므로 프록시의 데이터를 조회할 때는 접근자(Getter)를 사용해야 한다.
@Test
public void test() {
Member saveMember = new Member("member1", "회원1");
em.persist(saveMember);
em.flush();
em.clear();
Member newMember = new Member("member1", "회원1");
Member refMember = em.getReference(Member.class, "member1");
Assert.assertTrue(newMember.equals(refMember)); // 성공
}
상속관계와 프록시
프록시를 부모 타입으로 조회하면 부모 타입을 기반으로 프록시가 생성되는 문제가 발생한다. 이 때 하위 타입으로 다운 캐스팅과 instanceof
연산을 사용할 수 없다.
@Test
public void test() {
Book saveBook = new Book();
saveBook.setName("jpaBook");
saveBook.setAuthor("kim");
em.persist(saveBook);
em.flush();
em.clear();
// Item은 Book의 부모
Item proxyItem = em.getReference(Item.class, saveBook.getId());
if(proxyItem instanceof Book) {
Book book = (Book)proxyItem;
}
Assert.assertFalse(proxyItem.getClass() == Book.class); // 성공
Assert.assertFalse(proxyItem instanceof Book); // 성공
Assert.assertTrue(proxyItem instanceof Item); // 성공
}
Item은 Book의 부모 클래스다. 이 때 실제 조회된 엔티티는 Book 이므로 Book 타입을 기반으로 원본 엔티티 인스턴스가 생성된다. 그런데 em.getReference()
메서드에서 Item 엔티티를 대상으로 조회할 때 프록시인 proxyItem은 Item 타입을 기반으로 만들어진다. 즉, proxyItem은 Item$Proxy타입이고 이 타입은 Book타입과 관계가 없다. 이런 이유로 proxyItem instanceof Book
연산은 false를 반환한다. 그리고 다운 캐스팅을 할 때도 java.lang.ClassCastException
예외가 발생한다.
상속관계에서 발생하는 프록시 문제 해결 방법
JPQL로 대상 직접 조회
처음부터 자식 타입을 직접 조회한다.
Book jpqlBook = em.createQuery
("select b from Book b where b.id=:bookId", Book.class)
.setParameter("bookId", item.getId())
.getSingleResult();
프록시 벗기기
하이버네이트가 제공하는 기능을 사용하면 원본 엔티티 사용이 가능하다. 그러나 이 방법은 프록시에서 원본 엔티티를 직접 꺼내기 때문에 프록시와 원본 엔티티의 동일성 비교가 실패한다는 문제점이 있다. 그래서 원본 엔티티가 꼭 필요한 곳에서 잠깐 사용하고 다른 곳에서 사용하지 않는 것이 중요하다.
Item item = orderItem.getItem();
Item unProxyItem = unProxy(item);
if(unProxyItem instanceof Book) {
Book book = (Book) unProxyItem;
}
Assert.assertTrue(item != unProxyItem);
public static <T> T unProxy(Object entity) {
if(entity instanceof HibernateProxy) {
entity = ((HibernateProxy) entity)
.getHibernateLazyInitializer();
.getImplementation();
}
return (T)entity;
}
기능을 위한 별도의 인터페이스 제공
public interface TitleView {
String getTitle();
}
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item implements TitleView {
@Id @GeneratedValue
@Column(name = "ITEM_ID")
private Long id;
private String name;
...
}
@Entity
@DiscriminatorValue("B")
public class Book extends Item {
private String author;
private String isbn;
...
@Override
public String getTitle() {
return "[제목:" + getName() + " 저자:" + author + "]";
}
}
...
특정 기능을 제공하는 공통 인터페이스를 만들고 자식 클래스들은 getTitle()
을 각각 구현한다. 다형성을 활용하는 좋은 방법인 동시에 클라이언트 입장에서 대상 객체가 프록시인지 아닌지 고민하지 않아도 된다.
비지터 패턴
// Visitor 인터페이스
public interface Visitor {
void visit(Book book);
void visit(Album album);
void visit(Movie movie);
}
// Visitor 구현
public class PrintVisitor implements Visitor {
@Override
public void visit(Book book) {
//넘어오는 book은 Proxy가 아닌 원본 엔티티
System.out.println("book.class = " + book.getClass());
System.out.println("[PrintVisitor] [제목:" + book.getName() + " 저자:" + book.getAutor() + "]");
}
@Override
public void visit(Album album) {...}
@Override
public void visit(Movie album) {...}
}
public class TitleVisitor implements Visitor {
private String title;
public String getTitle() {
return title;
}
@Override
public void visit(Book book) {
title = "[제목:" + book.getName() + "저자:" + book.getAuthor() + "]";
}
@Override
public void visit(Album album) {...}
@Override
public void visit(Movie movie) {...}
}
@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);
}
@Entity
@DiscriminatorValue("B")
public class Book extends Item {
...
@Override
public void accept(Visitor visitor){
visitor.visit(this);
}
}
@Entity
@DiscriminatorValue("M")
public class Movie extends Item {
...
@Override
public void accept(Visitor visitor) {
visitor.visit(this);
}
}
@Test
public void test() {
...
OrderItem orderItem = em.find(OrderItem.class, orderItemId);
Item item = orderItem.getItem();
// PrintVisitor
item.accept(new PrintVisitor());
}
item.accept()
를 호출하면서 파라미터 PrintVisitor를 전달- item은 프록시이므로 먼저 프록시가
accept()
를 받고 원본 엔티티의accept()
를 실행 - 원본 엔티티는 코드를 실행해 자신을 visitor에 파라미터로 전달
- visitor가 PrintVisitor타입이므로
PrintVisitor.visit(this)
가 실행
비지터 패턴을 사용하게 되면 프록시에 대한 걱정 없이 안전하게 원본 엔티티에 접근할 수 있다는 장점이 생긴다. 그래서
instanceof
, 타입 캐스팅 없이 코드를 구현할 수 있다. 그러나 너무 복잡하고 더블 디스패치를 사용하기 때문에 코드를 이해하기 어렵다는 단점이 있다.
비지터 패턴은 구조를 수정하지 않으면서 새로운 동작을 추가할 수 있지만 객체 구조가 변경되면 모든 코드를 수정해야 한다.
Comments