Java의 동등성에 대한 고찰
개론
우연히 취준방에서 이런 질문을 받았다.
옛날에 이 방에서 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" 와 같은 방식으로) 읽는 사람들이 많다보니, 저 둘의 위치가 뒤집히게 되면 그 자체로 가독성이 떨어진다는 주장이다. (우리는 다소 안 와닿을 수 있지만)