JPA 프록시의 사실과 오해

Jihoon Oh·2022년 7월 19일
5
post-thumbnail

JPA에는 연관관계 로딩 방식 중 지연 로딩이라는 방식이 있습니다. 연관된 엔티티를 실제로 이용하기 전까지 조회하지 않는 것이죠. 실제로 사용하는 시점 전까지 조회를 미루기 때문에 즉시 로딩 방식에 비해 최초 로딩 시간이 빠르고 메모리 소모가 더 적다는 장점이 있습니다. 그런데 어쨌든 연관된 엔티티 필드 자리에 null이 들어가 있을 수는 없으니 뭔가 채워져 있어야 하는데요, 이 때 프록시 객체라는 가짜 엔티티가 그 자리에 들어갑니다. 이 프록시 객체를 제대로 이해하지 않고 넘어가면 비즈니스 로직을 작성하는 과정에서 예상치 못한 버그를 유발할 수 있는데요, 지연 로딩과 프록시 객체를 사용할 때 유의할 점에 대해서 알아보도록 하겠습니다.

프록시 객체는 엔티티 객체의 상속본이다

프록시 객체는 엔티티 객체를 대체하지만, 초기화 이후에는 실제 엔티티처럼 작동해야 합니다. 때문에 엔티티 객체를 상속해서 만들어집니다. 이 때문에 JPA에서는 엔티티 객체의 기본 생성자의 접근 제한자가 최소 protected가 되도록 제한되어 있습니다. 참고

하이버네이트에서 프록시.getId()는 프록시를 초기화 하지 않는다

프록시 객체는 엔티티 객체의 데이터를 사용할 때 초기화됩니다. 예를 들어 team.getName을 호출하면 해당 팀에 대한 select 쿼리가 날아가게 됩니다. 또한, 엔티티의 AccessTypeProperty가 아닌 Field라면 식별자 호출을 할 때도 초기화됩니다.

그런데 하이버네이트에서 식별자를 꺼내는 getId를 호출할 때는 초기화되지 않습니다.

Team team = new Team("팀");
em.persist(team);
Member member = new Member("멤버");
member.setTeam(team);
em.persist(member);
em.clear();

Member persistMember = em.find(Member.class, member.getId);

System.out.println(persistMember.getTeam().getId()); // team 초기화 X
System.out.println(persistMember.getTeam().getName()); // team 초기화 O, select 쿼리 발생

이는 하이버네이트 설정 중 hibernate.jpa.compliance.proxy 때문입니다.

hibernate.jpa.compliance.proxy (e.g. true or false (default value))
The JPA spec says that a javax.persistence.EntityNotFoundException should be thrown when accessing an entity Proxy which does not have an associated table row in the database.

Traditionally, Hibernate does not initialize an entity proxy when accessing its identifier since we already know the identifier value, hence we can save a database roundtrip.

If enabled Hibernate will initialize the entity proxy even when accessing its identifier.

하이버네이트는 JPA 명세와는 다르게, 식별자를 호출할 때는 엔티티를 초기화하지 않습니다. 만약 식별자를 호출할 때 엔티티를 초기화하고 싶다면 hibernate.jpa.compliance.proxy 값을 true로 설정해주어야 합니다.

다만 여기서 주의하실 점은, team.id는 null이라는 점입니다. 프록시 객체는 기본적으로 모든 필드 값을 null로 가지고 있습니다. 하지만 프록시 객체는 추가로 interceptor(ByteBuddyInterceptor)를 가지고 있는데요, 이 인터셉터가 id값, 원래 엔티티의 타입 정보 등을 가지고 있습니다. 그리고 target 이라는 필드가 있는데요, 프록시를 초기화하고 나면 select 쿼리의 결과로 생성된 엔티티를 바로 이 target에 저장하게 됩니다. 때문에 select 쿼리를 통해 프록시가 초기화되지 않은 상태에서 프록시는 id값은 알 수 있지만 target이 없기 때문에 id를 제외한 다른 필드 값은 알 수가 없는 것입니다.

그리고 방금 select 쿼리를 날리고 나면 target에 쿼리의 결과 엔티티를 저장한다고 했죠? 그래서 한 번 프록시로 만들어지면 프록시가 초기화된다 해도 실제 엔티티로 바뀌지 않습니다. 단지 실제 엔티티의 참조 만 연결될 뿐이죠.

프록시 객체가 생성되면 영속성 컨텍스트는 동일성 보장을 위해 프록시를 반환한다

프록시로 만들어진 엔티티에 대한 조회가 들어온다면, 영속성 컨텍스트는 실제 엔티티와 프록시 객체 중 어떤 객체를 반환해야 할까요?

영속성 컨텍스트의 특징으로 동일성 보장이 있습니다. 그런데 위에서 한 번 프록시로 만들어진 객체는 프록시 초기화를 하더라도 실제 엔티티로 변환되지 않고 참조값만 가지게 된다고 언급했습니다. 때문에 동일성을 보장해주기 위해서 한 트랜잭션 내에서 최초 생성이 프록시로 된 엔티티는 이후 초기화 여부에 상관 없이 영속성 컨텍스트가 무조건 프록시 객체를 반환해주게 됩니다.

단, 영속성 컨텍스트에 최초로 저장될 때 실제 엔티티로 저장될 경우, 이후로는 프록시가 아닌 실제 엔티티가 반환됩니다. 이는 지연 로딩으로 인해 프록시가 들어갈 자리에도 마찬가지입니다.

프록시의 equals 재정의를 조심하라

일반적으로 JPA 엔티티의 equals는 id 값으로 비교하도록 재정의하곤 합니다. 다음과 같이 말이죠.

public class Member {

    ...
    @Override
    public boolean equals(final Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        final Member member = (Member) o;
        return Objects.equals(id, member.id);
    }
}

하지만 이렇게 하면 프록시 객체를 equals로 비교할 때 false를 반환하게 됩니다. 아이러니한 것은 영속성 컨텍스트가 동일성은 보장해주기 때문에 ==으로 비교하면 true가 나온다는 것이죠. 왜 그럴까요?

이는 equals 중간에 있는 타입 검사 로직 때문입니다.

if (o == null || getClass() != o.getClass()) {
    return false;
}

equals를 호출하는 객체의 타입과 비교 대상 객체의 타입이 같은지 getClass를 통해 비교하게 되는데요, 이 if문에 걸려서 false를 반환하게 됩니다. 다음 예제를 통해 확인해보겠습니다. Review - Member가 연관관계를 맺고 있고, 지연 로딩을 사용합니다. (편의상 EntityManager 대신 Spring Data Jpa의 JpaRepository를 사용한다고 가정하겠습니다.)

Review review = reviewRepository.findById(1L).get();
Member member = memberRepository.findById(review.getId).get();

assertThat(member).isSameAs(review.getMember()); // 통과
assertThat(member).isEqualTo(review.getMember()); // 실패

member와 review.member 모두 같은 프록시 객체를 가리키고 있기 때문에 동일성 비교는 통과하는데 동등성 비교는 실패합니다. 앞서 말했던 타입 비교 로직에서 걸리게 되는데요, member와 review.member 모두 Member의 프록시 타입인데 통과해야 하는 것 아닌가요? 라고 생각할 수 있습니다. equals 과정을 따라가며 동등성이 성립하지 않는 이유를 찾아보겠습니다.

review.getMember()를 인수로 하여 member 객체의 equals를 호출합니다. 그런데 여기서 member는 프록시이기 때문에 equals를 직접 쓰지 못하고 실제 엔티티의 equals를 호출해야 합니다. 따라서 review.getMember()를 다시 한번 인수로 하여, 이번에는 target으로 지정된 실제 엔티티의 equals를 호출합니다. 그렇다면 다음과 같은 상황이 됩니다.

Member 타입의 equals에 Member의 프록시 타입이 들어온다

때문에 if (o == null || getClass() != o.getClass()) 로직에서 getClass()는 Member, o.getClass())는 Member의 프록시 타입이 되어 if에 걸리게 되는 것입니다.

그렇다면 어떻게 해야 동등성을 만족시켜 줄 수 있을까요?

public class Member {

    ...
    @Override
    public boolean equals(final Object o) {
        if (this == o) {
            return true;
        }
        if (!(o instanceof Member) {
            return false;
        }
        final Member member = (Member) o;
        return Objects.equals(id, member.getId());
    }
}

두 가지 변경이 필요합니다. 가장 먼저 타입 비교 로직을 통과할 수 있도록 getClass 대신 instanceof를 사용해 주는 것입니다. Member의 프록시는 Member의 하위 타입이기 때문에 instanceof를 만족해서 if문을 통과할 수 있습니다. 하지만 이것만으로는 equals가 제대로 작동하지 않는데요, 마지막에 Objects.equals(id, member.id)로 id를 직접 참조해서 비교하는 것이 아닌, Objects.equals(id, member.getId())처럼 getter로 id 값을 꺼내서 비교해야 합니다. 왜 그럴까요? 잠시 전으로 돌아가보도록 하겠습니다.

다만 여기서 주의하실 점은, team.id는 null이라는 점입니다. 프록시 객체는 기본적으로 모든 필드 값을 null로 가지고 있습니다.

equals를 호출하는 Member의 id 필드값에는 값이 들어 있는데 반해 비교 대상인 프록시의 id 필드값에는 null이 들어있어 false가 반환되게 됩니다. 따라서 실제 id 값을 가져올 수 있도록 getId를 호출한 뒤 비교해줄 필요가 있습니다.

참고 자료

자바 ORM 표준 JPA 프로그래밍 - 김영한 저
본인이 직접 인텔리제이 디버깅

profile
Backend Developeer

2개의 댓글

comment-user-thumbnail
2022년 7월 19일

오찌양
그럼 id 아닌 다른 필드로 find 해도 null값인 이유가 뭐야?

1개의 답글