본문 바로가기
  • 실행력이 모든걸 결정한다
Spring 사전 준비/JPA Hibernate

[JPA] 프록시 객체

by 김코더 김주역 2022. 8. 1.
반응형

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]

 

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 프로그래밍

 

반응형

댓글