본문 바로가기
jpa

jpa hibernate 프록시와 entity equals

by 권성호 2023. 3. 25.

사실이 아니라 공부한 내용과 생각을 정리한 글입니다. 언제든 가르침을 주신다면 감사하겠습니다.

jpa hibernate 지연로딩과 프락시

지연로딩의 필요성: 연관된 엔티티가 항상 필요하지 않을 수 있다

아래와 같이 팀과 멤버 엔티티가 있다고 하자.

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column
    private String name;

    @JoinColumn(name = "team_id")
    @ManyToOne(fetch = FetchType.LAZY)
    private Team team;

    public Member(String name, Team team) {
        this.name = name;
        this.team = team;
    }
    ...
}

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Team {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column
    private String name;
    
    @OneToMany(fetch = FetchType.LAZY)
    private List<Member> members;

    public Team(String name) {
        this.name = name;
    }
    ...
}

팀과 멤버는 1:N 관계이고 팀은 멤버에 대한 참조 리스트를, 멤버는 팀에 대한 참조를 가지고 있다.

그런데, 도메인 요구사항에 따라 팀을 조회할 때 항상 멤버 리스트가 필요할 수도 있고 아닐 수도 있다. 멤버도 마찬가지로, 멤버를 조회할 때 항상 팀이 필요할 수도 있고 아닐 수도 있다.

  • 멤버 엔티티를 사용할 때 항상 팀 엔티티가 필요한 도메인을 개발하고 있다면, 멤버 엔티티를 조회할 때 항상 팀 엔티티를 같이 조회해 오는 게 효율적일 것이다.
  • 반대로, 팀 엔티티를 사용하는 대부분의 경우 멤버 엔티티는 필요 없다면, 팀 엔티티를 조회할 때 연관된 모든 멤버 엔티티를 같이 조회해 오는 것은 비 효율적일 것이다. 정말 멤버 엔티티가 필요한 순간에만 멤버 엔티티를 조회해 오는 게 효율적일 것이다.

jpa는 이러한 상황에 유연하게 대처할 수 있도록 연관관계 메핑에서 FetchType을 제공한다.

FetchType.LAZY로 설정해 두면, 연관된 엔티티가 정말 필요한 시점까지 엔티티에 대한 조회를 지연시킬 수 있다.

(1:N 관계에서 N 쪽은 연관된 엔티티가 1개라는 보장이 있어서 FetchType에 따른 성능 차이가 미비하지만, 1 쪽은 연관된 엔티티가 몇 개인지 조회 시점에 알 수 없기 때문에 FetchType에 따른 성능 이슈가 크게 나타날 수 있어서 더욱 민감하게 다뤄야 할 설정이다.)

그렇다면, jpa를 구현한 hibernate에서 어떻게 지연 로딩 스펙을 구현했을까?

Hibernate에서 지연로딩 구현: 프락시

jpa의 구현중 하나인 hibernate는 스프링에서 적극적으로 사용하고 있는 프락시 패턴을 사용해 지연로딩을 구현한다.

(프락시 패턴에 대한 기본적인 내용은 여기를 참고)

즉, 연관된 엔티티에 대한 FetchType을 LAZY로 설정하면, 실제 엔티티를 조회해서 해당 참조를 넣는 것이 아니라, 프락시를 만들고 그 프락시에 대한 참조를 넣는다.

프락시는 실제 엔티티를 상속받은 형태이기 때문에 사용하는 쪽에서 해당 객체가 프락시인지 실제 객체인지 알지 못한다. 프락시를 사용하는 측에서는 단지 연관된 엔티티를 사용할 뿐이다.

연관된 엔티티를 사용하는 시점에 내부적으로 프락시가 메서드 호출을 가로채고 실제 객체가 없다면 비로소 그때 디비를 조회해 실제 객체를 생성한다. jpa의 영속성 컨택스트는 조회한 엔티티에 대한 동일성을 보장하기 때문에, 영속성 컨텍스트에 한번 프락시로 등록된 참조는 실제 객체가 생성된 후에도 프락시로 유지된다.


jpa entity의 올바른 equals 정의

일반적으로 jpa의 equals는 id 를 기준으로 만든다. id 를 기준으로 인텔리제이의 자동 생성 기능을 사용하면 아래와 같은 equals 메소드가 만들어진다.

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

이렇게 정의된 equals 는 hibernate 프락시와 함께 사용되었을 때 문제가 된다.

아래 예시를 보자.

Team team = member.getTeam();
Team sameTeam = member.getTeam();
assertTrue(team.equals(sameTeam));

team과 sameTeam 은 동일한 참조를 가진 객체인데, 3번째 줄의 결과를 무엇이라고 예상하는가?

상식적으로 동일 참조 객체의 equals의 결과는 true 가 나와야 한다. 하지만 결과는 그렇지 않다. 차분하게 따라가 보면 그 이유를 금방 알 수 있다.

  1. team 은 실제 객체가 아니라 프락시 객체이다. -> team.equals 를 호출하면 프락시 객체의 equals 가 호출된다.
  2. 프락시의 equals 가 호출되면 프락시는 실제 객체에 대한 참조가 있는지 여부를 확인하고 없으면 실제 객체를 그 시점에 초기화한다.(지연로딩)
  3. 프락시는 실제 객체의 equals 를 호출한다. -> 실제 객체의 equals 에 인자로 프락시 객체가 전달된다.
  4. getClass()!= o.getClass() 부분에서 this는 실제객체이고 o.getClass()는 프락시 객체이기 때문에 결과는 true 가 된다
  5. equals 메서드는 false를 반환한다.

이러한 문제를 해결하기 위해 아래와 같이 equals를 정의할 수 있다.

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

getClass()!= o.getClass() 대신에!(o instanceof Team)를 사용하여, 실제 객체의 상속본인 프락시 객체가 들어와도 원하는 동작을 수행할 수 있도록 변경했다. 그런데, 이걸로 충분할까?

위 equals 정의는 hibernate 프락시뿐 아니라 Team을 상속한 모든 타입에 대해 id 만 같으면 true 를 반환한다. 즉, 위 equals 정의는 Team 을 상속한 모든 타입을 팀과 동일하게 바라보고 있는 문제가 있다. 조금 더 개선할 수 있는 방법이 없을까? 

 

아래 코드는 인텔리제이의 jpa-buddy 플러그인이 자동 생성해 주는 equals이다.

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) {
            return false;
        }
        Team that = (Team) o;
        return id != null && Objects.equals(id, that.id);
    }

최초에 살펴봤던 인텔리제이가 자동 생성한 equals 와의 차이를 보면, getClass()!= o.getClass()가 Hibernate.getClass(this)!= Hibernate.getClass(o)로 변했다.

Hibernate.getClass 메서드는 아래와 같다!

package org.hibernate;

import ... 생략

public final class Hibernate {
	
    ... 생략
    
	// org.hibernate.Hibernate.getClass(Object proxy) 110 Line
	public static Class getClass(Object proxy) {
		if ( proxy instanceof HibernateProxy ) {
			return ( (HibernateProxy) proxy ).getHibernateLazyInitializer()
					.getImplementation()
					.getClass();
		}
		else {
			return proxy.getClass();
		}
	}
    
	... 생략
    
}

equals에 전달된 객체가 hibernate 프락시 타입인지 확인해서 프락시라면 실제 객체의 클래스 타입을 반환한다.

이렇게 되면, 일반적인 상황에서는 본래 equals의 로직을 적용받고 hibernate 프락시일 때만 프락시의 실제 타입을 적용받게 되어 가장 완벽한 equals 가 된다.

 

참고자료

 

 

 

댓글