개요
Java를 이용해 어플리케이션을 만들어봤다면, 아마 equals()나 ==를 한 번쯤은 사용해봤을 거다. 말 그대로 두 대상이 같은지 다른지를 판단해주는 친구들인데, 이전에 이 두 개를 잘못 사용하여 런타임 에러가 발생한 적이 있다 😇
equals()와 ==이 무엇이고 내부 동작 원리는 무엇인지, 어떤때 어느 것을 사용해야 하는지 알아보자.
Java에서 동등성을 판단하는 두 가지 방법
== (등가 비교 연산자)
비교 연산자는 두 피연산자를 비교하는 데 사용되는 연산자이다. 이 중 두 피연산자의 값이 같은지 또는 다른지를 비교하는 연산자가 등가 비교 연산자로, Java는 !=와 ==를 제공하고 있다.
비교 연산자 | 연산 결과 |
== | 두 값이 같으면 true 다르면 false |
!= | 두 값이 다르면 true 같으면 false |
자바의 정석에 따르면 ==은 기본형은 물론, 참조형에도 사용할 수 있다. 참조형은 객체의 주소값을 비교해 대상의 주소가 같지를 확인하는 식으로 동작한다고 한다. 단, 기본형과 참조형은 서로 형변환이 불가능하기에 기본형과 참조형을 등가비교 연산자로 비교할 순 없다.
equals()
equals()는 java.lang 패키지의 Object 클래스에 포함된 메서드로, 매개변수로 객체의 참조변수를 받아 비교하여 그 결과를 boolean으로 알려주는 친구다. 뜯어보면 다음과 같이 동작한다.
public boolean equals(Object obj) {
return (this == obj);
}
코드에서 알 수 있듯, 두 객체의 같고 다름을 참조 변수의 값을 이용해 판단한다. 따라서 값이 다른 객체라면 항상 false로 판정된다. Object 클래스의 equals 메서드 자체는 어차피 내부에서 ==을 사용하는 식으로 구현되었으므로, 사실 ==과 Object.equals()는 동일하게 동작한다.
equals() 사용에 주의할 것은, Object 클래스는 모든 클래스의 조상이고, Object의 하위 클래스는 대부분 equals()를 오버라이드하여 재정의한다는 점이다. 우리가 평소 자주 사용하는 String 클래스의 equals()를 살펴보자.
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String aString = (String)anObject;
if (coder() == aString.coder()) {
return isLatin1() ? StringLatin1.equals(value, aString.value)
: StringUTF16.equals(value, aString.value);
}
}
return false;
}
먼저 ==을 이용해 주소값을 비교하여 동일한 객체를 가리키는지 확인한 후, 동일한 객체가 아니라면 내부 String 인스턴스에 한해서만 다시한 번 값을 체크하는 식으로 동작한다. 위 코드의 StringLatin1.equals()도 열어보면 다음과 같다.
@HotSpotIntrinsicCandidate
public static boolean equals(byte[] value, byte[] other) {
if (value.length == other.length) {
for (int i = 0; i < value.length; i++) {
if (value[i] != other[i]) {
return false;
}
}
return true;
}
return false;
}
즉, 서로 다른 String 객체의 비교 요청이 들어오면 주소값이 아닌 String 객체에 저장된 문자열 값을 직접 하나하나 비교하는 방식이다. 이번엔 Integer 클래스의 equals()도 열어보자.
public boolean equals(Object obj) {
if (obj instanceof Integer) {
return value == ((Integer)obj).intValue();
}
return false;
}
Integer 인스턴스에 한해서 객체의 intValue를 이용해 비교한다.
==에서 발생하는 신기한 일
이제 ==와 equals()에 대해 알았으니 직접 사용해보자. 기본형인 int와 참조형인 String에 대해 각각 ==을 사용해보았다.
@Test
@DisplayName("==을 이용해 값을 비교한다")
void test1() {
// given
final int number1 = 1;
final int number2 = 2;
final int number3 = 3;
final String str1 = "test";
final String str2 = "test";
// when
final boolean areEqual = (number1 == number2);
final boolean areEqual2 = (number1 != number3);
final boolean areEqual3 = (str1 == str2);
// then
assertEquals(areEqual, true);
assertEquals(areEqual2, true);
assertEquals(areEqual3, true);
}
값을 잘 비교해준다.
하지만 여러분이 자바를 좀 다뤄보셨다면 이런 생각을 하실 것 같다.
🤔 어? ==은 참조형에선 동작하지 않으니, 참조형은 equals()로 비교해주라고 했는데?
위에서 언급했던 ==이 참조형에서 동작하는 방식을 다시 한 번 살펴보자. 등가비교 연산자는 참조형 대상은 객체의 주소값을 비교해 대상의 주소가 같지를 확인하는 식으로 동작한다. 이 내용대로면, String str1와 String str2는 값 자체는 같지만, 서로 다른 변수이기에 주소가 다를 것이다. 그런데 어떻게 테스트를 통과한걸까?
이번엔 다른 참조형, Integer에 대해서도 테스트해보자.
@Test
@DisplayName("==을 이용해 참조형 대상을 비교한다")
void test1() {
// given
final Integer number1 = 2000;
final Integer number2 = 2000;
// when
final boolean areEqual = (number1 == number2);
// then
assertEquals(areEqual, true);
}
우리의 생각대로, number1와 number2은 값은 2000으로 같지만 주소가 달라 ==으로 비교하면 false가 된다.
테스트 코드가 잘못되었나? 아니면 ==이 잘못 설계된걸까? 둘 다 아니다. 정답은 Java가 String을 생성하는 방식, 그리고 문자열 리터럴을 다루는 방식 중 하나인, Constant Pool을 살펴보면 알 수 있다.
Java에서 String을 만드는 방식
Java에서 String 변수, 즉 문자열을 만들 때는 두 가지 방법을 이용할 수 있다. 문자열 리터럴을 직접 지정하는 방법과, String 클래스의 생성자를 이용하여 만드는 방법이다. 바로 코드로 살펴보자.
String str1 = "test"; // 문자열 리터럴 'test'의 주소가 str1에 저장된다
String str2 = "test"; // 문자열 리터럴 'test'의 주소가 str2에 저장된다
String str3 = new String("test"); // 새로운 String 인스턴스를 생성한다
str1, str2, str3은 모두 String 자료형에 "test"값을 갖는다. 각 변수의 주소를 System.identityHashCode()를 이용해 출력해보자.
String str1 = "test";
String str2 = "test";
String str3 = new String("test");
String str4 = new String("test");
System.out.println(System.identityHashCode(str1));
System.out.println(System.identityHashCode(str2));
System.out.println(System.identityHashCode(str3));
System.out.println(System.identityHashCode(str4));
😮 str1와 str2는 서로 다른 String 변수인데 주소가 같다! 반대로, str3와 str4는 주소가 다르다. 어떻게 된걸까? 이건 각 문자열 변수를 생성하는 방법이 다르기 때문에 발생한 일이다.
Java에서 new을 이용해 String 변수를 선언하면, new 연산자에 의해 메모리 할당이 이루어지고, 따라서 항상 새로운 String 인스턴스가 생성된다. str3과 str4는 서로 다른 인스턴스이고 각각 메모리를 할당받았기에 주소가 다른 것이다.
반대로, 문자열 리터럴을 직접 지정할 경우도 알아보자. Java 소스파일에 포함된 모든 문자열 리터럴은 컴파일할 때 자동적으로 미리 생성한다. 만약 생성 요청이 들어왔을 때 이미 같은 내용의 문자열 리터럴이 있다면, 새로 만드는게 아니라 기존의 것을 재사용하는 식으로 동작한다. 문자열 리터럴도 String 클래스이고, 한 번 생성하면 내용을 변경할 수 없으니 하나의 인스턴스를 공유하는 것이다.
Java가 문자열 리터럴을 관리하는 방법을 다시 한 번 정리해보자.
- 문자열 리터럴을 선언한다.
- .java 소스파일이 .class 클래스 파일로 컴파일되는 과정에서 문자열 리터럴이 클래스 파일에 저장된다.
- 클래스 파일이 클래스 로더에 의해 메모리에 올라갈 때, 클래스 파일에 있는 문자열 리터럴 목록의 모든 리터럴들이 JVM 내에 있는 상수 저장소 (constant pool)에 저장된다.
- 문자열 리터럴을 지정하여 생성된 String 변수에 상수 저장소의 주소를 할당한다.
이 프로세스대로 str1과 str2의 참조 관계를 살펴보면 이렇다.
"test" 문자열 리터럴이 클래스 로더에 의해 메모리에 올라가, 주소를 할당받은 String 인스턴스가 되고, str1과 str2는 해당 인스턴스를 참조하게 되는 것이다.
따라서 ==을 이용해 비교하더라도, str1와 str2는 서로 같은 인스턴스를 참조하기에 주소가 같아 true로 판정되는 것이다. 그럼 이쯤에서 위에서 열어봤던 String 클래스의 equals()를 다시 한 번 흝어보자.
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String aString = (String)anObject;
if (coder() == aString.coder()) {
return isLatin1() ? StringLatin1.equals(value, aString.value)
: StringUTF16.equals(value, aString.value);
}
}
return false;
}
대상이 참조형 변수일 경우 주소만 비교하고 끝내는 ==과 다르게, 만약 주소값이 달라도 String value를 한 번 더 비교하고 있다. 따라서 equals()를 사용하면 String 변수를 문자열 리터럴을 이용해 만들든, new를 이용해 만들든 value 값이 같으면 무조건 동일한 값이라고 판별해준다.
Integer Cache
아하! String은 생성 방식에 따라 ==를 쓸 수도, equals()를 쓸 수도 있구나! 그럼 String만 주의하면 되겠다!
아쉽게도 아니다. 위에서 작성했던 Integer 테스트 코드를 조금 고쳐보자.
@Test
@DisplayName("==을 이용해 참조형 대상을 비교한다")
void test1() {
// given
final Integer number1 = 100; // before : 2000
final Integer number2 = 100; // before : 2000
// when
final boolean areEqual = (number1 == number2);
// then
assertEquals(areEqual, true);
}
아마 여러분은 이렇게 생각할 것 같다.
🤔 number1과 number2의 값은 같지만, 이 두 변수는 서로 다른 주소를 받았을 거야. areEquals은 false고 이 테스트는 실패하겠지?
하지만 테스트 결과는 놀랍게도 성공이다.
위에선 Integer의 값이 같아도 false로 판정되었는데, 이번엔 왜 성공한걸까? 그 이유는 자바 Integer 클래스의 IntegerCache 때문이다.
Integer number1 = 100;
이 코드를 다시 살펴보자. Integer이라는 reference 타입에 100이라는 primitive 타입을 할당했다. 이 경우, 자바 컴파일러는 auto boxing을 통해 number1을 생성한다. 그러니까 위의 선언 코드는 컴파일러에 의해 다음과 같이 변경된다.
Integer number1 = Integer.valueOf(100);
그럼 Integer 클래스의 valueOf() 메서드 구현을 뜯어보자.
코드를 보면 입력 값이 IntegerCache의 log~high 범위에 있다면 IntegerCache의 캐시 주소값을 리턴하고, 아닌 경우 new를 이용해 새로운 Integer 인스턴스를 생성해 리턴하고 있다.
주석을 번역해보자.
지정된 int 값을 나타내는 Integer 인스턴스를 반환합니다. 새로운 Integer 인스턴스가 필요하지 않은 경우, 이 메서드는 자주 요청되는 값을 캐싱하여 공간 및 시간 성능을 크게 향상시킬 수 있으므로 일반적으로 Integer(int) 생성자보다 이 메서드를 우선적으로 사용해야 합니다. 이 메서드는 항상 -128~127 범위의 값을 캐시하며, 이 범위를 벗어난 다른 값은 캐시할 수 있습니다.
이제 모든 의문이 풀린다. -128에서 127까지는 자주 요청되는 값이니 매번 새로운 인스턴스를 만들어 메모리를 할당하는 것이 비효율적이니, 이 범위의 값을 캐시해두고, 만약 이 범위 사이의 값을 갖는 Integer 객체 생성 요청이 들어오면 미리 생성해두었던 Integer 인스턴스, 즉 IntegerCache의 주소값을 그대로 참조하게 하는 것이다.
IntegerCache의 코드도 뜯어보자.
private static class IntegerCache {
static final int low = -128;
static final int high;
static final Integer[] cache;
static Integer[] archivedCache;
static {
// high value may be configured by property
int h = 127;
String integerCacheHighPropValue =
VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
if (integerCacheHighPropValue != null) {
try {
h = Math.max(parseInt(integerCacheHighPropValue), 127);
// Maximum array size is Integer.MAX_VALUE
h = Math.min(h, Integer.MAX_VALUE - (-low) -1);
} catch( NumberFormatException nfe) {
// If the property cannot be parsed into an int, ignore it.
}
}
high = h;
// Load IntegerCache.archivedCache from archive, if possible
CDS.initializeFromArchive(IntegerCache.class);
int size = (high - low) + 1;
// Use the archived cache if it exists and is large enough
if (archivedCache == null || size > archivedCache.length) {
Integer[] c = new Integer[size];
int j = low;
for(int i = 0; i < c.length; i++) {
c[i] = new Integer(j++);
}
archivedCache = c;
}
cache = archivedCache;
// range [-128, 127] must be interned (JLS7 5.1.7)
assert IntegerCache.high >= 127;
}
private IntegerCache() {}
}
low 값은 항상 -128로 고정이고, high는 디폴트는 127이며, 만약 원한다면 VM의 옵션 (-XX:AutoBoxCacheMax=size)을 이용해 상한값을 변경하는 식으로 캐시 범위를 직접 지정할 수 있다. 이 캐시는 Integer[]로 관리된다.
정리하자면, 첫 테스트는 2000으로 IntegerCache -128~127 범위를 벗어났기에 new를 통해 새로운 주소값을 할당받았고, 두번째 테스트는 100으로 IntegerCache 범위에 들기에 새로운 인스턴스를 생성한게 아니라, 이미 존재하는 Integer 인스턴스의 주소를 참조하게 되었기에 서로 다른 두 변수가 같은 주소를 공유하게 되었고, 따라서 ==으로 비교했을 때 같다고 판정된 것이다.
마무리
이렇게 참조형에 ==을 사용했을 때 만나볼 수 있는 문제점에 대해 알아봤다. 이제 우리는 문자열 리터럴을 직접 지정한 String과, -128 ~ 127 사이의 정수 참조형은 ==를 이용해 비교할 수 있다는 것을 안다.
하지만 매 코드에서 이 점을 고려하여, 어떤 String은 ==로 비교하고 어떤 String은 equals()로 비교하는 행동은 추천하고 싶지 않다. 혼란을 야기하기도 하고, 휴먼 에러가 발생할 수도 있기에 위험하기 때문이다. 아마 이런 맥락에서 자바 참조형은 ==로 비교하면 안돼! 라고 많이들 외우시는게 아닐까?
참조형에 ==가 아니라 equals()를 쓰는게 훨씬 의미 파악도 쉽고, 안전한 방법이지만, 단순히 이렇게 하자! 고 외우기보단 내부 동작 원리를 이해해서 아~ 이런 이유가 있으니 되도록 참조형엔 equals()만 쓰는게 좋겠다~ 하고 담아두는게 훨씬 좋은 사고 방식이라고 생각한다. 앞으로도 언어, 프레임워크 등의 동작 원리를 잘 찾아봐야겠다!
레퍼런스
https://www.yes24.com/Product/Goods/24259565
'Develop > Java' 카테고리의 다른 글
[Java/Spring] DIP 활용해서 Testability 높이기 (0) | 2024.07.14 |
---|---|
[Java] equals()와 hashCode()를 털어보자 (feat. 동일성 vs 동등성) (1) | 2023.12.23 |
[Java] 맥북에서 자바 버전 여러 개 돌려쓰기 (1) | 2023.05.01 |