프로젝트의 초기 코드에서 객체 간 비교는 단순했습니다.
예를들면
- 요청자의 ID와 DB에 저장된 ID가 같다면?(Integer 타입)
- 요청자의 password와 DB에 저장된 password가 같다면?(String 타입)
등등
하지만 직접 만든 클래스의 인스턴스를 비교할 일이 생겼고 여기서 equals와 hashcode를 재정의해주지 않아 문제가 발생 했습니다.
어떤 상황이었는지 볼까요?
문제는 상품 주문 기록 객체인 PurchaseActivity에서 발생했습니다.
public class PurchaseActivity { //주문 아이디 private Long purchaseId; //주문자 아이디 private Long memberId; //상품 아이디 private Long productId; //구매한 양 private Long amount; //구매 일시 private LocalDateTime timestamp; }
타임딜 프로젝트는 일정 시각마다 한정된 시간동안 상품을 특가로 구매할 수 있는 purchase API를 제공 합니다.
purchase API의 제약 중 아래와 같은 조건이 있습니다.
멤버가 해당 상품을 구매한 이력이 없어야 구매할 수 있다.
제약 조건을 검사하기 위해 상품의 구매 리스트를 불러와 주문 내용과 동일한 기록이 있는지 체크 합니다.
public void purchase(PurchaseRequest requset) { ... //구매 요청을 주문 기록 도메인으로 변환 Activity newActivity = PurchaseRequest.toActivityDomain .activity(request) .build(); //해당 상품의 구매 기록을 가져옴 List<Activity> activities = activityDao.findById(productId); //구매 이력을 검사 boolean isPurchased = acitivities.contains(newActivity); ... }
편의를 위해 재구성한 코드이지만 요지는
요청한 주문이 기존의 주문 리스트에 없어야 한다 입니다.
테스트를 위해 코드를 아래의 코드를 작성 했습니다.@Test @DisplayName("Activity equals test") void test() { List<Activity> activityList = new ArrayList<>(); activityList.add(Activity.builder() .memberId(1L) .productId(1L) .amount(1L) .build()); activityList.add(Activity.builder() .memberId(2L) .productId(1L) .amount(2L) .build()); Activity newAct = Activity.builder() .memberId(1L) .productId(1L) .amount(1L) .build(); Assertions.assertThat(activityList.contains(newAct)) .isTrue(); }
예상한 대로 true 가 나올까요?
org.opentest4j.AssertionFailedError: Expecting value to be true but was false Expected :true Actual :false
아닙니다. 예상과는 달리 false가 나왔습니다.
purchaseId와 timestamp는 값을 할당해주지 않았으니 memberid, productid, amount가 같은 객체가 있는데 왜 false로 나올까요?결과를 먼저 봅시다. 제목에 언급한대로 equals와 hashcode를 재정의 후 다시 테스트 해 볼까요?
@EqualsAndHashCode(exclude = {"purchaseId", "timestamp"}) public class PurchaseActivity { ...
lombok으로 Equals와 HashCode를 재정의 했습니다.
purchaseId와 timestamp는 새 주문을 DB에 저장하기 전까지는 동등성 비교에 의미 없는 필드이므로 제외했습니다.다시 동일한 테스트를 해보겠습니다.
@Test @DisplayName("Activity equals test") void test() { ... Boolean isContain = activityList.contains(newAct); log.info(isContain); }
이전 테스트에서 결과가 true이면 아무것도 나오지 않으니 로그를 찍어보겠습니다.
... hello.PurchaseTest : true
🤔 어째서 이런 결과가 나온걸까요?
분명, 자바의 기본을 배울 때 아래의 내용들을 많이 보셨을 겁니다.
- 객체간 동등성 비교는 == 이 아닌 euqals로 해야한다.
- 객체의 동등성 비교를 위해 Object의 equals를 재정의 해야한다.
- equals를 재정의 하면 hashcode 도 함께 재정의 해야한다.
등등
하지만 글쓴이는 기본적인 내용을 배울 당시에는 equals와 hahcode를 재정의해야할 이유를 체감하지 못했고, 코딩테스트를 풀 때에도 따로 재정의를 해본적이 없었습니다.
프로젝트를 만들 때에는 lombok의 편의성에 혹해 어떤 곳에 재정의해야할지 명확하게 알지 못했죠.
때문에 부제와 같이 직접 해보지 않으면 모를 내용 이라는 글을 적어 회고하면서 사용 목적에 대해 정리하고자 합니다.
주의 : 아래 내용은 euqals, hashCode의 규약 등을 포함하고 있지 않습니다.
비유를 통해 쉽게 풀어나고자 쓴 내용이니 필요하다면 euqals와 hashCode의 규약 등은 공식 문서를 활용 해주세요.
먼저 용어 정리부터 하겠습니다.
- 동일성 : 두 객체가 JVM의 힙 메모리 내에 같은 주소를 가짐
- 동등성 : 두 객체가 논리적으로 동일한 값을 가짐
비유하자면 논리적으로 라는 말은 떼고 위의 주문 기록의 필드가 같음으로 생각해도 됩니다.- == : 두 객체의 동일성을 비교(메모리 주소가 같은지 비교)
- Object.equals : 재정의 대상 메서드. ==과 같음
- @Override equals : 재정의 해야할 메서드. 재정의 후 동등성 비교
- Object.hashcode : 재정의 대상 메서드. 객체 자체의 메모리 주소를 16진수로 리턴
- @Override hashcode : 재정의 해야할 메서드. 재정의 후 객체의 값이 같다면 같은 주소 리턴
equals를 재정의 하는 이유는 객체의 동등성을 비교하기 위해서 입니다.
동등성을 비교라는 것은 동일한 값을 가지고 있다 라고 생각할 수 있습니다.
하지만 이때 == 은 객체의 메모리 주소가 같은지 비교하기 때문에 필드 값이 같은지 비교할 수 가 없습니다.
새로운 구매 요청이 들어왔을 때 PurchaseActivity 인스턴스를 새로 생성 합니다.
새로운 주문 = (1번 멤버, 2번 상품, 구매한 양 3개) = 0x1001
레퍼런스 타입이므로 새로운 힙 메모리에 할당 됩니다.2번 상품의 구매 리스트를 불러옵니다.
DB를 통해 불러온 값을 자바 인스턴스로 만들면서 새로운 힙 메모리 주소(0x1100)를 할당 합니다.
설사 1번 멤버가 2번 상품을 3개 구매한 이력이 있다고 해도 구매 리스트에 있는 이력과 새로 생성된 요청의 이력은 서로 물리적으로 다른 주소를 갖고 있기 때문에 ==과 Object.equals는 false를 반환 합니다.
Obejct.equals는 내부적으로 ==으로만 비교하기 때문에 == 결과와 동일 합니다.
동등성을 비교를 위해서는 Object.equals를 아래와 같이 재정의 해 동일한 값을 갖고 있는지 비교해야 합니다.
public class PurchaseActivity { ... @Override public boolean equals(Object o) { //비교 대상이 null인지 확인 if (o == null) return false; //같은 주소를 가진 인스턴스인지 확인 if (this == o) return true; //객체 타입과 형 변환 여부 확인 if (!(o instanceof PurchaseActivity)) return false; PurchaseAcrivity pa = (PurchaseActivity)o; //동등성 확인 부분. 원하는 필드의 값이 같은지 확인 return this.memberId == pa.memberId && this.productId == pa.productId && this.amount == pa.amount; } }
lombok을 사용하면 아래와 같이 간단한 정의가 가능합니다.
//exclude의 필드는 제외하고 나머지 필드만 재정의 @EqualsAndHashCode(exclude = {"purchaseId", "timestamp"})
이제 PurchaseActivity의 equals를 사용하면 더이상 동일성이 아닌 동등성으로 주문 기록이 동일한지 확인할 수 있습니다.
그리고 ArrayList의 contains 메서드는 내부적으로 equals를 사용해 동등성을 확인합니다.//ArrayList.contains ... for (int i = start; i < end; i++) { if (o.equals(es[i])) { return i; } ...
주문 기록의 equals를 재정의해두었으니 ArrayList.contains 역시 정의한 세개의 필드(memberId, productId, amount)의 동등성을 확인할 것입니다.
많은 자바 기본서를 보면 equals를 재정의 한다면 hashCode 역시 재정의하라고 말하고 있습니다.
- equals(Object)가 두 객체를 같다고 판단헀다면, 두 객체의 hashCode는 똑같은 값을 반환해야한다.
- 논리적으로 같은 객체는 같은 해시코드를 반환해야 한다.
출처 : 이펙티브 자바 3/E
Ojbect의 규약이나 hashing 기법은 차치하고 질문으로 돌아가 보겠습니다.
결론은 커스텀 객체의 동등성 판단을 위해 equals를 재정의 했다면 hashCode도 재정의 해야 합니다.
타임딜 프로젝트도 주문 정보를 List 자료구조로 받아오고 있어 현재는 hashCode가 필요하지 않습니다.
하지만 지금은 hashing 기법을 사용하는 자료구조를 사용하지 않더라도 서비스가 확장될 때 HashSet, HashMap 등의 자료구조를 사용 안한다는 보장은 없습니다.
만약 hash 자료구조를 사용할 때 hashCode를 적절하게 재정의하지 않으면 Object의 hashCode가 작동해 객체의 동등성 판단이 아닌 메모리 주소를 리턴하기 때문에 hash 자료구조에서는 다른 값으로 판단해 주문 내역을 찾을 수 없게 됩니다.
예제에서는 lombok을 이용해 hashCode를 재정의 해보겠습니다.
극단적 예시 이지만 주문 기록 자체를 키로 삼아 hash 자료구조에서 검색해 보겠습니다.
@Test @DisplayName("HashMap test") void test2() { Map<PurchaseActivity, Long> activityMap = new HashMap<>(); for (int i = 1; i < 99999; i++) { activityMap.put(PurchaseActivity.builder() .memberUniqueId(Long.parseLong(String.valueOf(i))) .productId(1L) .amount(1L) .build(), Long.parseLong(String.valueOf(i))); } Random r = new Random(); Long randomMemberId = r.nextLong(99999); PurchaseActivity newAct = PurchaseActivity.builder() .memberUniqueId(randomMemberId) .productId(1L) .amount(1L) .build(); Long st = System.currentTimeMillis(); log.info("start searching"); Boolean result = activityMap.containsKey(newAct); log.info("id : {}", randomMemberId); log.error("is exist : {}", result); Long ed = System.currentTimeMillis(); log.info("searching time : {}", ed - st + " s"); } }
hashCode를 사용하지 않은 예
@NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter //hashCode를 재정의 하지 않은 주문 정보 public class PurchaseActivity {...}
결과
[Test worker] -- start searching [Test worker] -- id : 96504 [Test worker] -- is exist : false [Test worker] -- searching time : 5 s
hashCode를 사용한 예
@NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter @EqualsAndHashCode(exclude = {"purchaseId", "amount", "timestamp"}) public class PurchaseActivity {...}
결과
[Test worker] -- start searching [Test worker] -- id : 27233 [Test worker] -- is exist : true [Test worker] -- searching time : 5 s
결론적으로 개발자가 만든 커스텀 객체의 equals를 재정의 했다면 hashCode도 함께 재정의 하여 hash 자료구조의 이점을 적극 활용하는 것이 좋습니다.
또한 lombok과 같은 라이브러리의 도움을 받아 간단하게 사용할 수 있지만 hashing 기법을 바꾼다던지 직접 작성할 필요가 있을 때에는 IDE의 기능을 적극 활용(ctrl + n) 하여 생산성을 확보하기시 바랍니다.
EqualsAndHashCode를 재정의 하여 자료구조의 성능을 향상시키려고 합니다. 각 성능은 어떨까요?
먼저 스펙을 살펴봅시다.
Collection | Insert | Contains |
---|---|---|
ArrayList | O(1) | O(n) |
HashMap | O(1) | O(1) |
ArrayList는 동적으로 변하는 List 이지만 내부적으로 배열을 가지고 있기 때문에 검색을 한다면 0번 인덱스 부터 순차적으로 검색하게 됩니다. 때문에 배열의 길이인 n을 검색 시간으로 가지게 되죠.
반면, HashMap은 Hasing 기법을 활용해 키값이 유일한 값을 가지기 때문에 해시 충돌 등의 문제만 없다면 상수시간 내 검색이 가능 합니다.
이 때문에 커스텀 객체의 hashCode를 잘 재정의 해야하는 것이죠.
HashMap때 썼던 코드를 ArrayList로 재활용해 보겠습니다.
@Test @DisplayName("ArrayList test") void test() { List<PurchaseActivity> activityList = new ArrayList<>(); for (int i = 1; i < 100; i++) { activityList.add(PurchaseActivity.builder() .memberUniqueId(Long.parseLong(String.valueOf(i))) .productId(1L) .amount(1L) .build()); } Random r = new Random(); Long randomMemberId = r.nextLong(100); PurchaseActivity newAct = PurchaseActivity.builder() .memberUniqueId(randomMemberId) .productId(1L) .amount(1L) .build(); Long st = System.currentTimeMillis(); log.info("start searching"); Boolean result = activityList.contains(newAct); log.info("id : {}", randomMemberId); log.info("is exist : {}", result); Long ed = System.currentTimeMillis(); log.info("searching time: {}", ed - st + " s"); }
//n = 100 searching time: 6 s //n = 10000 searching time : 7 s //n = 1000000 searching time : 24 s
시간이 늘수록 검색 시간이 증가하는 것을 볼 수 있습니다.
이번엔 hash 자료구조를 사용해 시간을 측정해볼까요?
코드는 번외 이전과 같습니다.//n = 100 searching time : 4 s //n = 10000 searching time : 5 s //n = 1000000 searching time : 6 s
참고
- 이펙티브 자바 3/E
- 자바의 신