Please enable JavaScript to view the comments powered by Disqus.Jpa Entity 의 Equals, 객체 동일성과 동등성, Lombok 을 써도 될까?
Search

Jpa Entity 의 Equals, 객체 동일성과 동등성, Lombok 을 써도 될까?

태그
Effective Java
Hibernate
JPA
공개여부
작성일자
2022/07/26
이전 면접에서 JPA entity 의 equalshashCode 를 어떻게 구현했는지 묻는 질문이 나왔었다. 당시에는 Lombok 을 사용하고 있었고 자연스럽게 @EqualsAndHashCode 를 사용했기 때문에 이 부분에 대해 고민해본 적이 없었지만, 이 Entity 를 구성한다면 DB의 동일성, 객체의 동일성에 대해 고민해볼 필요가 있다.

Jpa, Hibernate 의 Entity 특징.

@Entity 가 붙은 class 의 instance 인 A 와 B가 있다고 가정하자.
이 둘의 instance 는 동일한 Database, table 의 같은 row 를 instance로 만들었다. 즉, 같은 데이터를 기반하여 만들어진 instance 이다.
이 경우 다음과 같은 코드에서 true 를 반환한다.
A == B
Java
복사
이러한 동작이 가능한 이유는 Persistence Context 의 기능이다.
이 글은 Persistence Context 를 다루는 글이 아니기 때문에 이에 대한 설명을 자세히 하지 않지만, Java 객체의 동일성과 DB의 동일성의 범위를 Hibernate 는 다음과 같이 정의한다.

Persistence Context 에서 제공하는 동일성 scope.

Primitive persistence layer with no identity scope: 동일성 범위가 없는 원시적인 영속화 계층
한 행에 두번 접근해도 application 에서 동일한 객체로 반환함을 보장하지 않는다.
Persistence layer using persistence context-scoped identity: 영속성 컨텍스트 범위 동일성
특정 DB row 를 단 하나의 객체 instance 가 나타냄을 보장한다.
가장 선호된다.
Multi-thread 를 사용하는 사용하는 현대 환경에 가장 적합하다.
Process-scoped identity: 프로세서 범위 동일성
JVM 전체를 통틀어 row 를 단 하나의 객체 instance 가 나타냄을 보장한다.
다수의 작업 단위에 걸쳐 instance 재사용을 목적으로 하는 cache 이용에선 이득이다.
Multi-thread 에선 사용이 권장되지 않는다.
위 글을 읽었을 때 Process-scoped identity 가 상당히 괜찮겠다는 생각이 들었지만 이건 RESTful API를 구성해야 하는 입장에선 생각해볼 필요가 있다.
아마도 별다른 설정을 하지 않았다면 Persistence layer using persistence context-scoped identity 를 사용했을 것이다.

영속성과 준영속성의 문제 인식.

Session session1 = sessionFactory.openSession(); Transaction tx1 = session1.beginTransaction(); // PK 는 "1234" Object a = session1.get(Item.class, new Long(1234)); Object b = session1.get(Item.class, new Long(1234)); (a == b) // True, persistent a and b are identical tx1.commit(); session1.close(); // References a and b are now to an object in detached state Session session2 = sessionFactory.openSession(); Transaction tx2 = session2.beginTransaction(); Object c = session2.get(Item.class, new Long(1234)); (a == c) // False, a는 준영속 상태이다. tx2.commit(); session2.close();
Java
복사
위와 같은 코드가 있을경우, a, b 는 같은 영속성의 범위에 있기 때문에 참조값으로 같은 instance 인지 여부의 확인이 가능하다.
a, c 의 경우 a 는 준영속 상태이기 때문에 c 와 다른 참조를 갖고 있다.
하지만, a.getId().eqauls(c.getId()) 를 호출한다면 true 를 반환한다.
코드를 읽어보면 a, b, c 모두 1234 가 PK인 instance 이다. 모두 같은 것이다.

어떻게 같다고 판단할 수 있을까?

준영속 객체를 이용한 컨버세이션 구현
이와 같은 상황은 Persistence context 가 다른(session이 다른) 상황이기 때문에 동일성 문제를 반드시 검증해야 한다.
a, b, c 는 모두 동일한 row 를 반영하지만, memory 상에서는 동일한 instance 를 가리키지 않는다.
위의 코드 하단에 다음의 코드를 작성하여 실행 결과를 확인해보자
session2.close(); Set<Object> allObjects = new HashSet<>(); allObjects.add(a); allObjects.add(b); allObjects.add(c);
Java
복사
이때 allObjects 의 size 는 어떻게 나올까?
정답
Set 의 구현을 살펴보면 Set 에 저장하는 instance 의 eqauls 가 어떻게 구현되어 있는지가 중요하다.
모든 요소와 비교하여 eqauls() 를 호출하여 true 가 존재한다면, Set 에 저장하지 않는다.(물론 hashCode() 도 중요하다.)
Instance a, b, c 는 2개의 instance 를 3개의 참조가 갖고있는 상황이다.

eqauls(), hashCode() 이해하기

문제를 이해했으니 이 문제를 어떻게 해결해야 할까?
크게 3가지 접근 방법이 있다.
1.
PK 로만 equals, hashCode 구현하기
2.
PK를 제외하고 equals 구현하기
3.
비즈니스 키를 사용한 동등성 구현하기

PK 로만 equals() 구현하기

어떻게 보면 상당히 현명한 방법이다.
@Override public boolean equals(Object o) { if (this == o) return true; if (id == null) return false; if (!(o instanceof User)) return false; final User user = (User)o; return id.equals(user.getId()); } @Override public int hashCode() { int result = id != null ? id.hashCode() : 0; return result; }
Java
복사
User entity 의 equals 를 가져왔다.
이 구현은 다음의 특징을 갖는다.
여기서 id == null 인 경우 (비영속 instance) 아예 동일하지 않다고 명시한다.
즉, repository.save() 를 호출하지 않은 instance 는 동등성을 아예 사용할 수 없다.
어떻게 보면 준영속 instance 와 비교할 수 없으므로 타당하다고 볼 수 있다.
객체가 영속화 되기 전까지 Hibernate 는 PK를 할당하지 않는다.
만약 비영속 객체가 Set 에 추가되고 후에 영속화 되면 Set 은 어떻게 될까?
이는 Set 의 기능을 위반하게 만든다.
결론, 식별자를 통한 동등성 비교는 사용하지 않기를 권장한다. 하지만, Set 과 같은 collection 자료구조를 사용하지 않는다면 상당히 괜찮은 방법이라고 생각한다.
또한, 객체의 동등성 비교를 항상 영속, 준영속 상태에서만 실행한다고 보장된다면 괜찮은 방법이다.(어떻게 보면 비즈니스 로직에 달려있다.)

PK를 제외하고 equals 구현하기

위의 문제를 해결하면서, 더 좋은 방법은 모든 property 를 비교하여 eqauls 를 구현하는 것이다.
하지만, Entity 내부에 join 과 같은 관계가 있다면, 다른 테이블과 연관관계가 있기 때문에 포함해서는 안된다.
eqauls 하나 때문에 객체 그래프 모두를 꺼내와 비교하는 것은 문제가 될 수 있다.(만약 객체 그래프 어딘가 URL 이 property 라면?)
@Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof User)) return false; User user = (User)o; if (!Objects.equals(username, user.username)) return false; return Objects.equals(password, user.password); } @Override public int hashCode() { int result = username != null ? username.hashCode() : 0; result = 31 * result + (password != null ? password.hashCode() : 0); return result; }
Java
복사
username, password 를 사용해 eqauls, hashCode 구현한 예제
이러한 구현에선 다음과 같은 문제가 발생할 수 있다.
1.
임의 Persistence context 의 instance 를 수정(만약 비밀번호를 변경한다면) 다른 Persistence Context 의 instance 와는 더 이상 일치하지 않는다.
2.
Property의 조합이 유일성(unique)을 보장하지 않는다면, 전혀 다른 row 와 동일한 instance 로 판단될 수 있다.
이러한 문제들을 총제적으로 해결하기 위해 Business key 가 등장한다.

비즈니스 키를 사용한 동등성 구현

비즈니스 키란? DB 동일성과 함께 각 instance 에 대해 유일성을 갖는 property 이거나 여러 property 를 조합한 것 이다. 일반적으로 대리 주키를 사용하지 못하는 경우 사용하는 자연키이다.
사실 비즈니스키 라는 단어를 처음들어봤다. 이 비즈니스키는 다음과 같은 특징을 갖는다.
저대로 변경할 수 없는 것은 아니다. 하지만, 변경할 일이 거의 없다면 충분하다.
Entity class 에는 반드시 비즈니스 키가 있어야 한다.(그리고 대체로 존재한다.)
비즈니스 키는 application 에서 특정 record 를 유일하게 식별하는데 사용한다.
대리키는 DB, application 에서 사용한다.
eqauls 를 비즈니스 키 로만 구현한다면 지금까지 언급한 모든 문제를 해결할 수 있다.
하지만, 로직마다, 요구사항마다 다른 entity 에서 비즈니스 키를 어떠한 기준으로 선택할 수 있을까?

비즈니스 키를 식별하는 기준

위의 PK를 제외하고 구현하기 예제 코드에서 username 이 알맞은 예시이다.
변경이 불가능한 필드는 아니다, 하지만 변경의 횟수가 다소 적은편에 속하고, DB의 제약조건을 통해 유일성을 보장받는 특성을 갖고 있다. 즉, 이 문장이 식별의 기준이 된다.
객체를 식별할 때 어떠한 속성을 참고하여 판단할 것인가?
두 객체를 하나의 화면에 표시할 때 한 객체와 다른 객체를 어떻게 구별할 것인가?
변경 불가능한 속성은 모두 비즈니스 키의 후보군이다.
UNIQUE 제약 조건을 지닌 속성이다.
비즈니스 키는 최대한 중복을 피하는 정밀함을 갖춰야 한다.
따라서 Record 생성 시간도 좋은 후보군이 된다.
DB의 식별자를 사용하는 것도 가능하다.
위에서 PK 로 equals 를 구현하는 것이 권장되지 않는다고 했던 문장에 위배된다.
여기서 말하는 식별자는 FK를 의미한다.
User : Product = 1:N 의 관계를 만족한다고 가정하면 Product 에선 User 의 PK 를 비즈니스 키로 사용할 수 있다.
이와 같은 상황은 User entity 의 life cycle 에 Product 를 종속 시킬 수 있다.
이 기능이 항상 좋은것은 아니지만, 비즈니스 로직에 따라 유용할 수 있다.
만약, 적합한 비즈니스 키를 찾지 못한다면 JPA, hibernate 에 한정하지 않고 문제를 해결하자
→ 객체 지향적 관점으로 접근하자.

Equals 는 일반 규약을 지켜 재정의 하라(Effective java Item: 10)

수학에서 동치, 동치성과 같은 규칙이 있다. 이는 임의 group 안에 속한 임의의 원소 2개가 다음의 규칙에 의해 동치성이 증명된다.
반사성(reflective)
대칭성(symmetric)
추이성(transitive)
여기에 determinic 함을 위해 일관성을 추가하여 equals, hashCode 를 구현하는 것이 Effective Java 저자의 의견이다.

반사성(reflective)

null 이 아닌 모든 참조 값 x 에 대해 다음을 만족한다.
x.equals(x) -> true
Java
복사

대칭성(symmetry)

null 아닌 모든 참조 값 x, y 에 대해 다음을 만족한다.
x.equals(y)true 이면 y.equals(x)true 를 반환한다.
Java
복사

추이성(transitivity)

null 이 아닌 모든 참조 값 x, y, z 에 대해 다음을 만족한다.
x.equals(y)true 이고, y.eqauls(z)true 이면 x.equals(z)true 를 반환한다.
Java
복사

일관성(consistency)

이 특징은 수학과 연관 없이 객체지향적 특징이다.
null 이 아닌 모든 참조 값 x, y 에 대해 다음을 만족한다.
x.eqauls(y)true 이면 x.eqauls(y)true 이고 x.eqauls(y)true 이고 x.eqauls(y)true 이고 x.eqauls(y)true 이다.
Java
복사
즉 몇 번을 실행해도 동일한 결과가 반환되는 것이다.

상속의 주의점.

만약 상속을 통해 구현한 Entity 가 있다면, 하위 클래스에서 eqauls 를 Override 하여 비교하는 것은 옳지 못하다. 이유는 다음과 같다.
위의 4가지 규칙인 반사성, 대칭성, 추이성, 일관성을 만족하기 어렵다.
서로 다른 객체 type 이다.
비즈니스 키가 DB 에 정의된 table 과 매핑되지 않을 수 있다.
equals, hashCode 가 다른 객체의 property 에 접근한다.

결론

무엇이 항상 맞다고 정의하긴 어렵다. 예전에 인물사전 프로젝트를 개발했을 때 사용하지 말라고한 PK를 제외하고 equals 를 구현 했었다. 이유는 모든 인물의 property 를 조합하면 동일성을 보장할 수 있었기 때문이고, 인물과 같은 경우는 이 구현이 상당히 적합했다.
그래서 다음과 같은 기준을 제시하고자 한다. 왜냐하면 master key 는 존재하지 않는다.
1.
정의한 Entity 를 Set 과 같은 collection 에 담아 관리할 가능성이 있는가? 만약 없다면 application 이 확정 되는 과정에서도 계속해서 없음을 보장할 수 있는가?
PK 를 equals, hashCode 로 사용한다.
2.
영속, 비영속 상태의 entity 를 비교할 가능성이 있는가? 있다면 각 property 의 조합으로 식별성을 갖출 수 있는가?
PK 를 제외하고 eqauls, hashCode 를 구현한다.
3.
비즈니스 키를 사용하기에 적합한 상황인가?
예를 들면 개인 식별 정보를 포함한다. 다음과 같이 개인정보 두 개를 조합하면 개인 식별 정보가 된다.
이름 + 핸드폰
이름 + e-mail
모든 property 가 아닌 적은 개수의 property 로도 식별성을 어느 정도 보장할 수 있다.
부족하지만, 나의 경험으로 비즈니스 키를 추출하지 못한 Entity 는 없었다. 하지만 이 경험이 진리는 아니므로 매번 Entity 를 정의할 때 마다 고민할 필요가 있으며 Test code 범위에도 포함되어야 한다.