본문으로 건너뛰기

Java의 동등성에 대한 고찰

· 약 21분
VSFe
블로그 주인장

개론

우연히 취준방에서 이런 질문을 받았다.

옛날에 이 방에서 a.equals(b)말고도 Objects.equals(a, b) 쓴다는 내용을 봤던 적이 있었는데 Objects.equals를 쓰는 이유가 단순히 null에 대해 안전하기 때문에 사용하는 걸까요?? 가독성은 전자가 더 좋다고 느껴집니다.

단순히 Null Safety를 위해서 저렇게 사용한다! 라고 이야기 할 수도 있지만, 사실 동등성을 비교하는데 있어서 고려해야 할 것은 생각보다 많다. (단순히 NPE를 방지하는 것이 답이 아닌 경우도 있고...)

원래는 질문에 답을 하기 위해 간단하게 작성해보려 했으나, 작성하다보니 자연스럽게 equals() 를 포함한 동등성 자체에 대한 고민을 주욱 작성하게 되었다.

기본적인 equals() 설계 방식

일단은 equals()를 좀 살펴보자. Java의 equals() 에 대한 명세에는 다음과 같은 내용이 포함된다.

Indicates whether some other object is "equal to" this one. The equals method implements an equivalence relation on non-null object references:

  • It is reflexive: for any non-null reference value x, x. equals(x) should return true.
  • It is symmetric: for any non-null reference values x and y, x. equals(y) should return true if and only if y. equals(x) returns true.
  • It is transitive: for any non-null reference values x, y, and z, if x. equals(y) returns true and y. equals(z) returns true, then x. equals(z) should return true.
  • It is consistent: for any non-null reference values x and y, multiple invocations of x. equals(y) consistently return true or consistently return false, provided no information used in equals comparisons on the objects is modified.
  • For any non-null reference value x, x. equals(null) should return false.

한글로 간단하게 번역하면 다음과 같다.

  • 반사성 (Reflexive): null이 아닌 참조값 x에 대해, x.equals(x) 는 참이다.
  • 대칭성 (Symmetric): null이 아닌 참조값 x, y에 대해, x.equals(y)가 true 이면 y.equals(x)도 참이다.
  • 추이성 (Transitivity): null이 아닌 참조값 x, y, z에 대해, x.equals(y)가 참이고 y.equals(z) 가 참이면 x.equals(z) 도 참이다.
  • 일관성 (Consistency): null이 아닌 참조값 x, y에 대해, x.equals(y) 의 값은 언제나 참이거나 거짓이다. (즉, 결과가 변하지 않는다.)
  • Not Null: null이 아닌 참조값 x에 대해, x.equals(null) 은 거짓이다.

생각보다 단순하다고 느껴질 수 있는 원칙들이지만 막상 구현하려고 하다보면 생각보다 원칙이 깨지기가 정말 쉽다. (Effective Java의 Item 10만 참고해도 깨질 수 있는 사례가 정말 많이 언급된다.)

물론 우리가 이 글에서 다루고자 하는 것은 동등성 이지, equals() 에 대한 설명이 아니므로, 우선 간단하게 넘어가도록 하자.

String의 동등성

Primitive Type이 아니지만, 마치 Primitive Type 처럼 활용하는 String을 살펴보자.

어떻게 동등성을 파악해야 하는가?

사실 이 글을 보는 사람들이라면 == 을 사용해서 문자열을 비교하는 경우는 없겠지만, Literal 간의 비교는 == 가 통하기 때문에, 다소 당황하는 경우가 많다.

JVM 구조에 대한 글이 아니므로 (추후 작성할 예정이 있지만, 여기에서는 다루지 않는다.) 간단하게 설명하자면 JVM 내부에 존재하는 String Pool에 의해 가능한 것이다.

구조상 형태가 절대로 변할 수 없기 때문에, 특정 문자열을 JVM 내부에 있는 String Pool 안에 삽입하게 되면 이 문자열들은 == 로 비교가 가능해진다. (== 은 원본에 대한 비교로, 참조형 데이터의 경우 데이터의 원본 주소를 비교한다. 만약 비교하는 문자열이 String Pool 안에 존재한다면, 동일한 문자열은 객체가 최대 1개이므로 같을 수 밖에 없다.)

결과적으로 놓고만 보면 성능이 .equals()에 비해 우수한게 맞다. (다만 삽입 자체의 오버헤드는 상당하므로, 실질적으로는 더 느리다고 봐야한다.)

그리고 String.intern() 을 사용하여 Pool에 해당 문자열을 삽입해 버리는 방식이 존재한다. 하지만 가능하면 사용하지 않는 것이 좋다. 일차적으로 Pool에 삽입하는 것 자체가 오버헤드이며, String Pool의 구조상 반복적으로 참조되어야 의미가 있는 만큼 모든 문자열을 String Pool에 삽입하는 것은 좋은 습관은 아니다.

Literal 이나 상수 형태로 지정된 문자열은 구조상 자주 참조될 수 밖에 없는 문자열이므로, 해당 문자열들은 JVM이 자체적으로 String Pool에 적재한다.

물론 우리가 Literal 끼리 비교할 일은 없으므로, (처음 자바를 공부하는게 아닌 이상) == 는 깔끔하게 잊어버리자.

Literal의 equals()에 대하여

다음과 같은 코드가 있다고 해보자.

String str1 = "Hello";
if ("Hello".equals(str1)) {
// logic...
}

당연하지만, "Hello".equals(str1)) 에서 str1과 "Hello" 라는 문자열은 서로 위치를 바꾸어도 문제 없이 동작한다.

다만, str1 = null 이라면, NPE가 발생하여 코드가 정상적으로 돌아가지 않을 것이다. 이로 인해 몇몇 사람들은 반드시 Literal을 앞에 두는 방식으로 코드를 작성해야 한다고 주장한다.

하지만... 이 부분에 대해서 단순히 null이 발생하니 막아야 한다! 라고 주장하기엔 고민해야 할 사항이 조금 더 있다.

우리는 이런 표기법을 Yoda Condition 으로 호칭하고 있는데, (https://en.wikipedia.org/wiki/Yoda_conditions) 이 표기법의 효용성에 대해서는 아주 많은 갑론을박이 존재한다.

Java의 관점에서 생각하면, (C++ 관점이면 오묘한 성능 차이도 발생하고 그렇지만, 여기서는 다루지 않는다.) 개인적으로는 해당 표기법을 불호하는 편이다.

꼭 Null-Safety 여야 하는가?

Yoda Condition의 장점을 검색하면, "NPE를 방지해준다" 라는 말이 항상 존재한다. 그런데 기계적으로 NPE를 방지해야 하는게 과연 맞는가?

다음과 같은 코드를 살펴보자.

String data = null;

if (!"true".equals(data)) {
int size = data.size();
}

위 코드에서 "true".equals(data) 에서 NPE를 회피한다고 시도해도, 결과적으로 아래에서 걸리게 된다.

이처럼, NPE를 "숨기는" 행동은 오히려 문제를 가중시킬 수 있다.

들어오는 문자열이 nullable 하다는 것이 보장되어 있고, 이것이 자연스러운 로직이라면 문제가 없을 수 있지만, 그렇지 않다면 (즉, 버그등의 문제로 인해 nullable 하지 않아야 하는 상황에서 null이 인입된 케이스라면) 이는 문제를 감추는 것 밖에 되지 않는다.

위 코드와 같이 바로 아랫줄에서 문제가 발생하는 경우가 아닌, 실제로 들어와선 안되는 데이터가 인입되어 DB까지 전달되었다고 가정 해보자. 이렇게 될 경우 우리는 어디에서 문제가 발생했기에 저런 데이터가 인입되었는지 판단하기 매우 어려워진다.

문제의 가능성이 있다면 감추는 것은 좋지 않다. (최소한 logging 등으로 기록이라도 해야 한다.) 특히나 코드의 규모가 커지게 될 경우 문제가 발생했을 때 그 원인을 파악하는 난이도가 더욱 올라갈 수 있기 때문에, 에러는 최대한 빨리 Catch 할 수 있어야 한다.

이 관점으로 위 코드를 다시 보자. 과연 "안전한" 코드라고 할 수 있을까?

가능하면, Null-Safety 여부는 상황에 따라 달라질 수 있어야 하고, 이러한 정책을 코드만 읽어도 이해할 수 있어야 한다. 예를 들어,

// case 1
if (str != null && str.equals(str2)) {

}

// case 2
if (str.equals(str2)) {

}

만약 어떤 코드에서 case 1과 case 2의 코드가 병립되어 있다고 해보자. 두 케이스 중 하나는 nullable 하고, 하나는 엄격하게 not null 이어야 한다고 할 때, 어떤 케이스가 어떤 상황에 해당하는지 이해할 수 있을 것이다.

(Jarkarta Validation 의 경우, 메서드 파라미터에도 적용될 수 있다. nullable을 사전에 체크하고 싶다면 관련 어노테이션을 사용하는 것도 좋은 방법이 될 수 있을 것이다.)

의미론적 혼선

한가지 재밌는 이야기지만, 영어권 화자들은 다른 방향성의 의미론적 혼선을 제기한다. str.equals("Hello")라는 코드를 영어 의미대로 (즉, str equals to "Hello" 와 같은 방식으로) 읽는 사람들이 많다보니, 저 둘의 위치가 뒤집히게 되면 그 자체로 가독성이 떨어진다는 주장이다. (우리는 다소 안 와닿을 수 있지만)

Collection의 동등성

우리가 원하는 동등성이 무엇인가?

개발을 하다보면, List 두 개의 동등성을 체크해야 하는 케이스가 생각보다 많다. 다만 이런 케이스의 경우, 우리는 .equals() 를 사용하지 않고 다른 방식으로 동등성을 체크해야 하는 경우도 생각보다 많다. List를 단순한 데이터의 container 용도로 사용하는 역할이 많다보니, 비교 연산을 수행해야 할 시점에는 데이터의 저장 순서가 중요하지 않게 되는 상황도 많기 때문이다.

하지만 순서가 없다고 해서 Set을 사용할 수는 없는 노릇이다. (중복된 데이터의 존재 가능성이 있으므로) 그러다보니, 두 List를 정렬하여 그 결과값을 비교하는 경우도 있는 등 기본적으로 제공되는 equals() 를 직접적으로 사용하지 않는 케이스도 많다.

대표적인 테스트 라이브러리인 assertJ에서도 containsExactlyInAnyOrder() 같은 검증 메서드가 존재하는 것 처럼, List의 데이터를 검증하는 상황에선 순서를 보장해야 하는지/그렇지 않은지를 충분히 고민하고 비교 로직/검증 로직을 작성해야 한다.

이처럼, Collection의 동등성을 비교해야 할 때는 과연 무조건적인 equals() 를 사용하는게 맞는지 생각해 볼 필요가 있다.

덤으로, 재미있는 코드 하나를 살펴보자.

// org.apache.commons.collections4.CollectionUtils.java
public static boolean isEqualCollection(Collection<?> a, Collection<?> b) {
if (a.size() != b.size()) {
return false;
} else {
CardinalityHelper<Object> helper = new CardinalityHelper(a, b);
if (helper.cardinalityA.size() != helper.cardinalityB.size()) {
return false;
} else {
Iterator var3 = helper.cardinalityA.keySet().iterator();

Object obj;
do {
if (!var3.hasNext()) {
return true;
}

obj = var3.next();
} while(helper.freqA(obj) == helper.freqB(obj));

return false;
}
}
}

Apache Commons 라이브러리에 존재하는 isEqualCollection() 메서드로, 이는 Collection의 종류와 상관없이, 동일한 데이터가 존재하는지에 대한 여부를 확인하는 메서드이다. CardinalityHelper 는 HashBag과 유사하게 데이터의 개수를 Counting 해주는 역할을 하기 때문에, Collection 종류와 상관 없이 데이터의 존재여부 자체만 확인할 수 있다.

Interface 구현체의 차이를 알아야 한다

JDK 에 포함되는 기본적인 Collection 의 구현체가 아닌, 서드파티 라이브러리/프레임워크 등에 포함된 Collection 구현체는 우리의 의도대로 동작하지 않는 경우가 매우 많다.

JDK에 포함된 Collection 구현체는 일반적으로 기본 형태만 같으면 .equals() 를 통한 동등비교가 가능하다.

List<String> list = List.of("1", "2", "3");
ArrayList<String> list2 = new ArrayList<String>(list);

if (list2.equals(list)) {
// true
}

다만 우리가 많이 사용하는 Spring 만 봐도 의도대로 동작하지 않는 경우가 많다.

var map1 = new LinkedMultiValueMap<String, String>(); // Spring에 포함된 자료구조
var map2 = new HashMap<String, String>();

map1.add("hi", "hello");
map2.put("hi", "hello");

타입을 감추기 위해 고의적으로 var 키워드를 사용했다.

과연 map1.equals(map2) 가 성립할까? 결론만 말하면 아니다.

사실, LinkedMultiValueHashMap<String, String> 의 원형은 Map<String, List<String>> 이다. 즉, 해당 map을 비교해야 할 필요성이 있다면, 둘 중 하나의 구조를 변형하는 등의 방식을 취해야 한다.

다른 객체들과 달리 Collection 은 상위 인터페이스로 우리가 타입을 작성하여 사용하는 경우가 많으므로, 이러한 상황에 대해서 꼼꼼하게 체크할 필요가 있다.

객체의 동등성

마지막으로 일반적인 객체의 동등성을 살펴보자. 이 케이스에서 동등성을 비교하는 경우는 대게 직접 equals() 를 오버라이딩하는 경우가 많은지라, 앞쪽과 또 약간의 차이가 존재한다.

은근한 함정

우리가 equals() 를 일반 객체에 도입하는 경우는 대부분 DTO/VO 와 같은 여러 필드를 갖고 있는 객체일 것이다. 하지만 항상 이런 경우엔 시간 정보 필드 때문에 생각 이상으로 equals() 를 짜기 어려운 경우도 많다.

특히나 DTO/VO의 규모가 커지면, 필드의 타입이 또 다른 DTO/VO 일 수 있는데, 위와 같은 필드가 들어가는 상황이라면 정말 조심해야 한다. (상위 클래스의 equals() 가 잘못되었다면, 은근히 원인을 못 찾는다...)

굳이 equals() 를 정의할 필요 없이, 일부 로직에서 특정 필드에 대해서만 비교가 필요하다면 Apache Commons가 제공하는 EqualsBuilder 를 사용해도 좋다.

A a1 = new A(1, 2, "hello");
A a2 = new A(1, 2, "world");

boolean isEqual = EqualsBuilder.reflectionEquals(
/** lhs = */ a1,
/** rhs = */ a2,
/** excludeFields = */ "c"
); // true

record A(
int a,
int b,
String c
) {

}

Stream.distinct() (중복 원소 제거) 등을 사용하고 싶다면 어쩔 수 없이 equals() 를 정의해야 하는데, 이를 이용하지 않아도 고차함수등의 기법을 활용할 수도 있다. (참고: https://stackoverflow.com/a/27872086)

Record의 equals()

Java 16 부터 도입된 Record는 우리가 특별하게 equals() 를 정의하지 않아도 알아서 만들어준다는 특성을 갖고 있다.

내부적으로 invokeDynamic 등의 방식으로 equals() 를 호출하기 때문에 내부 구조를 바로 알 수 없지만, 간단한 스펙은 다음과 같이 적혀있다.

The implicitly provided implementation returns true if and only if the argument is an instance of the same record type as this object, and each component of this record is equal to the corresponding component of the argument; otherwise, false is returned. Equality of a component c is determined as follows:

  • If the component is of a reference type, the component is considered equal if and only if Objects.equals(this.c(), r.c() would return true.
  • If the component is of a primitive type, using the corresponding primitive wrapper class PW (the corresponding wrapper class for int is java.lang.Integer, and so on), the component is considered equal if and only if PW.valueOf(this.c()).equals(PW.valueOf(r.c())) would return true.

The implicitly provided implementation conforms to the semantics described above; the implementation may or may not accomplish this by using calls to the particular methods listed.

간단하게 정리하면 다음과 같다.

  • 동일한 record 타입이어야 한다.
  • 특정 필드가 참조형인 경우, Objects.equals(this.c(), r.c()) 가 참이어야 한다.
  • 특정 필드가 Primitive 인 경우, boxing 타입으로 변환하여 equals() 를 사용한다.

결국, timestamp 와 같은 데이터가 record에 들어가게 되면 equals() 사용이 매우 어려워진다고 봐야한다. 결국 이 경우에도 필요에 따라 상술한 방식을 사용하여 새롭게 정의해야 할 수도 있다.

결론

동등성을 판단하기 위해 무조건적으로 어떤 것을 해야한다! 는 존재하지 않는다. 특정 상황/로직에 따라서 고려해야 할 점이 많고, 그렇기에 단순히 어떤 방법을 사용해야 한다라는 왕도는 없다.

상술했던 내용을 숙지하는 것을 넘어, 스스로 활용하기 위해선 결국 개발을 해보면서 많은 고민이 필요할 것으로 보인다. (필자 또한 매번 고민을 하고 있다...)