[JPA] Entity에서 equals, hashcode 사용시 발생할 수 있는 문제점

sinbom·2022년 7월 16일
7

소개

단방향이 아닌 양방향 연관관계의 엔티티는 순환참조를 가지고 있습니다. 기본적으로 equals, hashcode, toString 메소드에서 양방향 연관관계의 필드를 포함시키게 되면 메소드 호출 스택이 반복적으로 쌓이면서 StackOverflowError 예외가 발생하기 때문에 반드시 포함시키지 않아야합니다. 그래서 일반적으로 순환참조를 회피하면서 데이터베이스와 객체의 패러다임 불일치를 해결하기 위해 기본키를 기준으로 객체의 동등성 보장할 수 있도록 오버라이딩합니다.
이러한 내용들은 JPA를 공부하고 사용하다보면 당연히 알고 있을 내용들입니다. 해당 글에서는 앞서 언급한 내용에서 사용하는 equals, hashcode 메소드들을 엔티티에서 오버라이딩할 때 발생할 수 있는 문제점들에 대해서 이야기해보고자 합니다.


발생할 수 있는 문제점

@Getter
@Entity
@EqualsAndHashCode(of = "id")
@NoArgsConstructor(access = PROTECTED)
public class Users {

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

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private int age;

    public Users(String name, int age) {
        this.name = name;
        this.age = age;
    }

}

정수형 기본키 필드를 기준으로 객체의 동등성이 보장되는 유저 엔티티가 있습니다. @EqualsAndHashCode lombok 애노테이션으로 id 필드를 사용하는 equals, hashcode 메소드를 오버라이딩합니다.


@Entity
public class Users {
    @Id
    @GeneratedValue(
        strategy = GenerationType.IDENTITY
    )
    private Long id;
    @Column(
        nullable = false
    )
    private String name;
    @Column(
        nullable = false
    )
    private int age;

    public Users(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public Long getId() {
        return this.id;
    }

    public String getName() {
        return this.name;
    }

    public int getAge() {
        return this.age;
    }

    public boolean equals(final Object o) {
        if (o == this) {
            return true;
        } else if (!(o instanceof Users)) {
            return false;
        } else {
            Users other = (Users)o;
            if (!other.canEqual(this)) {
                return false;
            } else {
                Object this$id = this.getId();
                Object other$id = other.getId();
                if (this$id == null) {
                    if (other$id != null) {
                        return false;
                    }
                } else if (!this$id.equals(other$id)) {
                    return false;
                }

                return true;
            }
        }
    }

    protected boolean canEqual(final Object other) {
        return other instanceof Users;
    }

    public int hashCode() {
        int PRIME = true;
        int result = 1;
        Object $id = this.getId();
        int result = result * 59 + ($id == null ? 43 : $id.hashCode());
        return result;
    }

    protected Users() {
    }
}

실제로 컴파일 후에 생성되는 바이트코드로 변환된 소스 코드에서 id 필드를 사용하는 equals, hashcode 메소드가 생성된 것을 직접 확인할 수 있습니다. id 필드 값이 동일한 객체는 equals, hashcode 리턴 값이 동일합니다.

해시 충돌이란 해시 함수가 서로 다른 두 개의 입력 값에 대해 동일한 출력 값을 내는 상황을 의미합니다.

하지만 id 필드 값이 다른 객체는 equals 리턴 값은 다르지만 hashcode 리턴 값은 동일할 수도 있고 다를 수도 있습니다.


@Test
void id필드가_null인경우_동일하고_동등하다() {
	Users adam = new Users("Adam", 30);
	Users emily = new Users("Emily", 23);

	assertSame(adam.hashCode(), emily.hashCode());
	assertEquals(adam, emily);
}

null 값을 대상으로 equals, hashcode 메소드는 동등한 결과를 리턴합니다.


@Test
void 엔티티가_영속화되어_id필드가_고유한값인경우_동일하지않다() {
	Users adam = new Users("Adam", 30);
	Users emily = new Users("Emily", 23);

	userRepository.save(adam);
	userRepository.save(emily);

	assertNotEquals(adam, emily);
}

반면, null이 아닌 서로 다른 값을 대상으로 equals 메소드는 동등하지 않은 결과를 리턴하고 hashcode 메소드는 결과를 보장할 수 없습니다. 해당 사례를 해시 자료구조에 사용하게 되면 동일한 결과를 얻을 수 있습니다.


@Test
void 엔티티가_영속화되기전과_영속화된후의_hashcode값이_다르다() {
	Users adam = new Users("Adam", 30);
	Users emily = new Users("Emily", 23);

	Set<Users> users = new HashSet<>();

	users.add(adam);
	users.add(emily);

	assertTrue(users.contains(adam));
	assertTrue(users.contains(emily));

	userRepository.save(adam);
	userRepository.save(emily);

	assertFalse(users.contains(adam));
	assertFalse(users.contains(emily));
}

테스트 환경에서는 h2(in memory database)를 사용했습니다. 위에서 엔티티의 기본키 생성전략을 identity로 설정했기 때문에 엔티티가 영속화되는 시점에 insert 쿼리가 발생하고 기본키 필드에 값이 주입됩니다. 엔티티를 영속화한 후에 해시 자료구조에 해당 값이 존재하는지 확인하게되면 동일한 객체가 실제로 존재할지라도 해시 자료구조에 객체를 삽입할 때와 hashcode 리턴 값이 다르기 때문에 찾을 수 없습니다.
이처럼 해시 자료구조에 엔티티 객체를 삽입하는 사례로 @OneToMany 연관관계 매핑 필드로 해시 자료구조를 사용하는 경우가 있습니다.


@Getter
@Entity
@EqualsAndHashCode(of = "id")
@NoArgsConstructor(access = PROTECTED)
public class Users {

	... 생략

    @OneToMany(fetch = LAZY, mappedBy = "user")
    private Set<Orders> orders;

    public Users(String name, int age) {
        this.name = name;
        this.age = age;
        this.orders = new HashSet<>();
    }

    public void addOrder(Orders order) {
        this.orders.add(order);
    }

}
@Getter
@Entity
@EqualsAndHashCode(of = "id")
@NoArgsConstructor(access = PROTECTED)
public class Orders {

    ... 생략

    @ManyToOne(fetch = LAZY, optional = false)
    private Users user;

    public Orders(Users user) {
        this.user = user;
    }

}
@Test
void 연관관계의_엔티티가_영속화되기전과_영속화된후의_hashcode값이_다르다() {
	Users adam = new Users("Adam", 30);
	Orders order = new Orders(adam);
	Orders order2 = new Orders(adam);
	Orders order3 = new Orders(adam);

	adam.addOrder(order);
	adam.addOrder(order2);
	adam.addOrder(order3);

    assertTrue(adam.getOrders().contains(order));
    assertTrue(adam.getOrders().contains(order2));
    assertTrue(adam.getOrders().contains(order3));

	userRepository.save(adam);
	orderRepository.save(order);
	orderRepository.save(order2);
	orderRepository.save(order3);

    assertFalse(adam.getOrders().contains(order));
    assertFalse(adam.getOrders().contains(order2));
    assertFalse(adam.getOrders().contains(order3));
}

마찬가지로 영속화 전/후 객체의 hashcode 리턴 값이 다르기 때문에 찾을 수 없습니다. 영속화 전/후에도 객체의 동등성을 보장하기 위해서는 @EqualsAndHashCode 애노테이션을 제거하고 직접 equals, hashcode 메소드를 오버라이딩해야합니다.


해결 방법

아래 소스 코드는 intellij jpa-buddy 플러그인이 자동 생성해주는 equals, hashcode 메소드 오버라이딩입니다

@Getter
@Entity
@NoArgsConstructor(access = PROTECTED)
public class Users {

    ... 생략

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

    @Override
    public int hashCode() {
        return getClass().hashCode();
    }

}
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();
		}
	}
    
	... 생략
    
}

hashcode 메소드는 엔티티 클래스의 메타 데이터를 가지고 있는 Class 타입 객체의 hashcode 리턴 값을 반환합니다. Class 객체는 싱글톤이므로 동일한 hashcode 값을 리턴하기 때문에 항상 해시 충돌이 발생하여 equals 메소드를 통해 동등성을 비교한 후에 동등하지 않다면 체이닝하여 삽입합니다.

해시 충돌 회피에는 대표적인 두 가지 방법이 있습니다. Java의 해시 자료구조에서는 회피 기법으로 Separate Chaining을 사용하고 있습니다.

Open Addressing: 이미 해시 버킷이 사용중인 경우 다른 해시 버킷에 데이터를 삽입합니다.
Separate Chaining: 동일한 해시 버킷에 위치하는 데이터를 연결 리스트를 사용하여 체이닝하여 삽입합니다.

equals 메소드는 동일 객체인 경우 동등하며, 객체 또는 기본키 필드가 null인 경우 동등하지 않고, 기본키 필드의 equals 리턴 값을 기준으로 동등성을 판단하게 됩니다. 기본키 필드가 동등하더라도 동일한 엔티티 객체가 아니면 동등하지 않기 때문에 타입이 다르면 동등하지 않은 것으로 간주합니다. 하지만 하이버네이트에 의해 생성된 프록시인 경우 객체의 타입이 다르기 때문에 위임한 실제 엔티티 객체의 클래스를 기준으로 비교해야합니다.

하이버네이트 프록시란 EntityManager의 getReferenceById 메소드 또는 Lazy FetchType으로 인해 생성되는 엔티티의 프록시 객체를 의미합니다.


@Test
void 엔티티가_영속화되기전과_영속화된후의_hashcode값이_같다() {
	Users adam = new Users("Adam", 30);
	Users emily = new Users("Emily", 23);

	Set<Users> users = new HashSet<>(1); // capacity 1 설정

	users.add(adam);
	users.add(emily);

	assertTrue(users.contains(adam));
	assertTrue(users.contains(emily));

	userRepository.save(adam);
	userRepository.save(emily);

	assertTrue(users.contains(adam));
	assertTrue(users.contains(emily));
}

equals, hashcode 메소드를 위와 같이 오버리이딩함으로써 엔티티를 영속화하기전에 해시 자료구조에 객체를 삽입하더라도 모두 동일한 해시 버킷에 체이닝되기 때문에 기본키 필드의 값이 변하더라도 equals 메소드로 삽입된 데이터를 찾을 수 있습니다. 하지만 이 방식에는 모든 엔티티 객체가 동일한 해시 버킷에 체이닝됨으로써 실제로 사용하는 하나의 버킷을 제외한 모든 버킷을 사용하지 않기 때문에 메모리가 불필요하게 낭비되지 않도록 용량을 설정할 필요가 있습니다.

Java의 HashMap은 최소 용량이 1부터 2배씩 증가할 수 있습니다. 지정한 capacity 만큼을 담을 수 있는 가장 최소한의 버킷 용량으로 설정되며 지정한 loadFactor 이상만큼 버킷 용량을 사용하게 되면 자동으로 버킷 용량을 증가시킵니다. 버킷 용량을 증가시키는 과정에서 새로운 버킷(배열)을 생성하고 체이닝을 재구성하게 됩니다.

references

profile
Backend Developer

0개의 댓글