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, 타입 캐스팅 없이 코드를 구현할 수 있다. 그러나 너무 복잡하고 더블 디스패치를 사용하기 때문에 코드를 이해하기 어렵다는 단점이 있다.
비지터 패턴은 구조를 수정하지 않으면서 새로운 동작을 추가할 수 있지만 객체 구조가 변경되면 모든 코드를 수정해야 한다.

Categories:

Updated:

Comments