JVM 개발자라면 객체의 비교가 얼마나 섬세한 주제인지 잘 알고 있을 것입니다. 특히 Java와 Kotlin을 사용할 때 동등성(equality)과 동일성(identity)의 차이를 정확히 이해하지 못하면, 미묘한 버그부터 심각한 설계 오류까지 발생할 수 있습니다. 이 글에서는 두 언어에서 동등성과 동일성이 무엇을 의미하는지 여러 가지 비유를 들어 설명하고, equals()와 hashCode() 메서드를 각 언어에서 어떻게 재정의하는지 (그리고 왜 필요한지)를 실제 코드 예제와 함께 살펴보겠습니다. 또한 안티패턴이나 흔히 하는 실수들도 함께 소개하여, 실무에서 자주 접하는 문제와 그 해결책을 심도 있게 다루겠습니다. 마지막으로, 불변(Immutable) Value Object를 설계할 때 Java의 record vs 일반 클래스, Kotlin의 data class 사이의 차이점과 선택 기준을 비교하고, JVM 메모리 모델이나 성능, 컬렉션 동작 측면에서의 고려사항까지 폭넓게 알아보겠습니다.
동등성(equality)과 동일성(identity)은 언뜻 보면 비슷하지만, 객체를 비교할 때 완전히 다른 개념을 나타냅니다. 간단히 정리하면:
동등성(equality): 두 객체가 내용적으로 같은지를 뜻합니다. 즉, 객체 내부의 데이터가 동일하면 두 객체를 동등하다고 봅니다. 서로 다른 인스턴스라도 필드 값 등이 같다면 논리적으로 "같은 것"으로 취급하는 개념입니다.
동일성(identity): 두 객체가 정말 같은 객체인지를 뜻합니다. 다시 말해, 두 변수가 가리키는 메모리 주소가 동일한지를 판단합니다.동일성은 물리적으로 동일한 객체(동일한 인스턴스)일 때만 성립합니다.
이 차이를 일상 비유로 표현하면 다음과 같습니다. 예를 들어 동등성은 두 개의 책이 내용과 제목, 저자가 모두 동일한 경우 서로 동등하다고 보는 것이고, 동일성은 그 두 책 중 실제로 같은 책 한 권인지(동일한 물건인지)를 보는 것입니다. 또 다른 비유로, 쌍둥이 두 사람이 있다고 할 때 둘은 키, 생김새 등이 똑같아서 동등하게 보일 수 있지만 두 사람은 다른 개체이므로 동일한 존재는 아닙니다. 프로그래밍에 적용해 보면, 동등성은 두 객체의 상태나 값이 같은지를 비교하는 것이고, 동일성은 두 객체의 레퍼런스(참조)가 같은 메모리를 가리키는지를 비교하는 것입니다.
Java에서는 객체의 동일성을 비교하기 위해 == 연산자를 사용합니다. 두 객체 참조를 ==로 비교하면, 두 참조가 같은 객체를 가리킬 때만 true가 됩니다. 반면, 객체의 동등성(논리 동등함)을 확인하려면 일반적으로 해당 클래스의 equals() 메서드를 사용해야 합니다.
String s1 = new String("hello");
String s2 = new String("hello");
System.out.println(s1 == s2); // false - 서로 다른 인스턴스 (동일성 비교)
System.out.println(s1.equals(s2)); // true - 문자열 내용이 같음 (동등성 비교)
위 예에서 s1과 s2는 각각 다른 String 객체이므로 s1 == s2는 false입니다. 그러나 문자열 "hello"라는 동일한 내용을 가지고 있으므로 s1.equals(s2)는 true를 반환합니다. Java의 String 클래스는 equals()가 오버라이드되어 있어 리터럴 값(문자열 내용)이 같으면 true를 반환하도록 구현되어 있기 때문입니다.
참고: Java에서 equals() 메서드는 기본적으로 Object 클래스에 정의되어 있으며, 별도로 오버라이드하지 않으면 기본 구현은 ==와 동일하게 객체 동일성을 기준으로 비교합니다. 따라서 equals()를 논리적 동등성 비교에 사용하려면 해당 클래스에서 이 메서드를 오버라이드해야 합니다. 이 부분은 아래에서 자세히 다룹니다.
한편 Java의 원시 타입(primitive)은 예외적으로 동등성과 동일성이 구분되지 않습니다. 원시 타입 변수(int, double 등)에 대해 ==를 쓰면 그 값 자체를 비교하므로 값이 같으면 true가 됩니다. 즉, 원시 타입에는 별도의 equals()가 없고 ==가 곧 값 비교를 수행합니다. 그러나 참조 타입(reference type) (객체)에서는 ==가 동일성을, equals()가 동등성을 의미한다는 점을 꼭 기억해야 합니다.
오토박싱 주의: Java 5부터 도입된 오토박싱/언박싱 때문에, 예를 들어 Integer와 같은 래퍼(wrapper) 클래스는 객체이지만 마치 원시 타입처럼 쓸 수 있습니다. 이때도 ==를 사용하면 객체 동일성을 비교하게 되니 조심해야 합니다. 예를 들어 Integer a = 128; Integer b = 128;에서 a == b는 false인데, 이는 자바가 -128~127 범위를 넘어서는 Integer에 대해서는 새로운 객체를 생성하기 때문입니다. 동등성 비교를 위해서는 a.equals(b)를 사용해야 합니다.
Kotlin에서는 동등성과 동일성을 구분하는 연산자가 Java와는 조금 다르게 설계되어 있습니다. Kotlin 코드에서:
== 연산자는 동등성(equality) 비교입니다. Kotlin의 ==는 내부적으로 해당 객체의 equals() 메서드를 호출하여 두 객체의 값이 같은지 확인합니다. 만약 객체 참조가 null일 가능성이 있으면 안전하게 처리
(null과의 비교 결과는 false)한 후 equals를 호출합니다.
=== 연산자는 동일성(identity) 비교입니다. 즉, 두 객체 참조가 동일한 객체를 가리키는지 (같은 주소인지) 검사하며, Java에서의 ==와 동일한 역할입니다.
data class Person(val name: String, val age: Int)
val p1 = Person("Alice", 30)
val p2 = Person("Alice", 30)
val p3 = p1
println(p1 == p2) // true (동등성: 프로퍼티 값이 모두 같으므로)
println(p1 === p2) // false (동일성: 서로 다른 인스턴스)
println(p1 === p3) // true (동일성: 같은 인스턴스를 가리킴)
위 코드에서 Person은 Kotlin의 data class이므로 equals()가 자동 구현되어 있습니다. 따라서 p1과 p2는 내용(이름과 나이)이 같아 p1 == p2 결과가 true가 됩니다. 하지만 p1과 p2는 별개의 객체이므로 p1 === p2는 false입니다. 한편 p3는 p1과 동일한 객체를 가리키므로 p1 === p3는 true입니다.
만약 Kotlin에서 일반 클래스(예를 들어 data가 아닌 클래스)를 사용한다면, == 연산자가 호출하는 equals()의 기본 구현은 Java와 마찬가지로 Any(Java의 Object에 대응)의 구현을 따르므로 동일성 비교를 합니다. 즉, Kotlin도 특정 클래스에서 equals()를 오버라이드하지 않으면 ==로 비교 시 동일한 객체가 아니면 false가 나옵니다. 이 점은 Kotlin을 사용하는 개발자가 종종 혼동하는 부분인데, Kotlin 문법의 편의성 때문에 ==가 마치 자동으로 내용을 비교해줄 것 같지만 그것은 equals()가 올바로 구현된 경우에만 해당합니다. 따라서 Kotlin의 일반 클래스에서 논리적 동등성 비교를 원한다면 equals() 오버라이드가 필요하며, 다행히도 Kotlin에서는 이러한 용도의 data class를 제공하여 편의성을 높였습니다 (자세한 내용은 아래에서 다룹니다).
요약:
- 동등성(equality): 두 객체의 내용(데이터)이 같은가? (Java에서는 .equals()로 비교, Kotlin에서는 == 연산자로 비교. 단, equals() 메서드가 적절히 구현되어 있어야 함).
- 동일성(identity): 두 참조가 동일한 객체(메모리 주소)를 가리키는가? (Java에서는 == 연산자로 비교, Kotlin에서는 === 연산자로 비교).
이제 이러한 개념을 토대로, 두 언어에서 equals()와 hashCode()를 어떻게 다루는지 살펴보겠습니다.
동등성을 제대로 구현하려면 equals() 메서드 재정의는 선택이 아닌 필수인 경우가 많습니다. Java와 Kotlin 모두 기본적으로 Object/Any에서 상속된 equals()를 쓰는데, 이 기본 구현은 동일성(==)을 비교하도록 되어 있기 때문입니다. 따라서 객체의 내용을 기준으로 동등성을 판단하려면 클래스를 정의할 때 equals()를 재정의해야 합니다. 이와 동시에 꼭 기억해야 할 점은: equals()를 재정의할 때는 반드시 hashCode()도 재정의해야 한다는 것입니다. 그 이유와 방법을 각각 Java와 Kotlin에 대해 알아보겠습니다.
Java에서는 equals() 메서드를 다음과 같이 오버라이드합니다:
@Override
public boolean equals(Object o) {
if (this == o) return true; // 1. 자기 자신과 비교일 때 true
if (o == null || getClass() != o.getClass()) return false; // 2. null 비교 및 클래스 타입 체크
Person other = (Person) o; // 3. 적절한 타입으로 캐스팅
return age == other.age && Objects.equals(name, other.name);
}
자기 자신과의 비교: 전달된 객체 o가 this와 같은 참조이면 (this == o), 곧 같은 객체이므로 바로 true를 반환합니다. 이러면 불필요한 비교를 줄이고, 자기 자신과의 비교에 대해 대칭성(symmetry)을 잘 지킬 수 있습니다.
타입 및 null 체크: o가 null이면 바로 false를 반환합니다. 또한 비교하려는 두 객체의 클래스 타입이 다르면(여기서는 getClass()로 정확히 동일한 클래스인지 확인) false를 반환합니다. 이 부분은 equals 구현 시 상당히 중요한데, 클래스 타입이 다르면 논리적으로 동일하다고 볼 수 없기 때문입니다. (getClass()를 사용할지 instanceof를 사용할지는 설계에 따라 다를 수 있는데, 이는 뒤에서 언급합니다.)
필드 비교: 적절한 타입으로 캐스팅한 후, 해당 객체의 중요한 필드들이 모두 서로 같은지 비교합니다. 예제의 Person 클래스에서는 name과 age 두 필드로 동등성을 정의하고 있으므로, 각각의 값을 비교합니다. Objects.equals(name, other.name)를 사용하면 null-safe하게 문자열을 비교할 수 있습니다 (Java의 Objects.equals는 내부에서 (a == b) || (a != null && a.equals(b)) 처리를 합니다).
hashCode()는 equals에서 사용된 필드들을 기반으로 일관성 있게 구현해야 합니다. Java 17부터는 Objects.hash(...) 유틸리티를 사용하거나, IDE의 자동 생성 기능을 써서 편하게 만들 수 있습니다. 예를 들어 위 Person 클래스에 대응되는 hashCode()는 다음처럼 구현할 수 있습니다:
@Override
public int hashCode() {
return Objects.hash(name, age);
}
또는 수동으로 구현한다면 흔히 사용하는 방식으로:
@Override
public int hashCode() {
int result = name != null ? name.hashCode() : 0;
result = 31 * result + Integer.hashCode(age);
return result;
}
여기서 31은 임의의 소수(prime)로, 해시 충돌을 줄이기 위해 자주 사용하는 값입니다. hashCode()는 가능한 한 빠르고 간결하게 객체를 해싱해야 하므로, 너무 복잡한 연산은 피하는 것이 좋습니다. 위 구현은 문자열의 해시코드와 나이 값을 조합하여 해시를 생성합니다.
왜 equals와 hashCode를 모두 재정의해야 할까? Java의 객체 계약에 따르면, equals를 오버라이드하면 반드시 hashCode도 오버라이드해야 합니다. 그 이유는 해시 기반 컬렉션(HashSet, HashMap 등)에서 두 객체를 비교하는 방식 때문입니다. HashSet에 객체를 넣거나 HashMap의 키로 객체를 사용할 때, Java는 다음 과정을 거칩니다:
객체의 hashCode()를 먼저 호출하여 해시(bucket)를 결정합니다.
같은 해시(bucket)에 속한 객체들만 대상으로 equals()를 비교하여 동등성을 검사합니다.
만약 equals()만 override하고 hashCode()는 override하지 않은 경우, 논리적으로 동등한 두 객체가 서로 다른 해시코드를 가질 수 있습니다. 그 결과, 해시 컬렉션은 이 둘을 같은 bucket에 두지 않으므로 HashSet에 넣으면 중복으로 인식하지 못하고 두 개 다 들어가 버릴 수 있습니다. 아래 코드를 봅시다.
Student s1 = new Student("ekko", 33);
Student s2 = new Student("ekko", 33);
System.out.println(s1.equals(s2)); // true (equals 오버라이드로 동등성 비교 구현했다고 가정)
HashSet<Student> set = new HashSet<>();
set.add(s1);
set.add(s2);
System.out.println(set.size()); // 만약 hashCode 오버라이드 안했다면 2 출력될 수도!
System.out.println(set.contains(s2)); // hashCode 미구현 시 false가 나올 수 있음
위 예에서 Student 클래스가 equals()는 name과 age로 동등 비교를 해놓고 hashCode()를 오버라이드하지 않았다면, s1.equals(s2)는 true이지만 HashSet에는 두 객체가 다른 해시 버킷에 들어가 결국 size()가 2가 될 수 있습니다. 또한 contains(s2)로 검색해도 같은 객체를 넣었음에도 불구하고 false를 반환할 수 있습니다. 이는 동등한 두 객체는 반드시 같은 hashCode를 가져야 한다는 규약을 어겼기 때문입니다. 실제로 JavaDoc (Object.hashCode의 설명)에 이 규약이 명시되어 있습니다.
따라서, Java에서는 equals와 hashCode는 항상 쌍으로 재정의해야 합니다. 이를 IDE에서 자동 생성하면 편리하며, 롬복(Lombok)의 @EqualsAndHashCode 같은 어노테이션을 활용하는 것도 실무에서 많이 사용됩니다. (Java 16 이후로는 record가 등장하면서 이런 보일러플레이트를 줄일 수 있는데, 이에 대해서는 뒤에서 다룹니다.)
주의: equals()를 구현할 때 클래스의 가변성에 주의해야 합니다. equals/hashCode에 사용하는 필드는 가급적 불변(immutable)이거나 객체 생성 후 변경되지 않는 것이 좋습니다. 만약 equals에 사용되는 필드 값을 나중에 변경하면, 그 객체를 컬렉션에 넣은 후 변경시킬 때 논리적 동등성 기준이 변해버려 컬렉션 내부에서 문제가 발생합니다. 예를 들어 HashSet에 넣은 객체의 equals/hashCode 기준 필드를 수정하면, 더 이상 그 객체를 HashSet에서 contains로 찾을 수 없게 될 수 있습니다. 이러한 문제를 피하려면 equals/hashCode에 쓰이는 필드는 객체 생성 이후 변경하지 않거나, 아예 그런 객체는 컬렉션의 키/원소로 사용하지 않는 것이 바람직합니다.
Kotlin의 모든 클래스는 기본적으로 Any를 상속하며, Any.equals(other: Any?): Boolean 메서드를 가집니다. Kotlin에서 직접 클래스를 정의할 때 equals()를 오버라이드하는 방법은 Java와 비슷하지만 문법적으로 조금 다릅니다. 다음은 Kotlin에서 Java의 Person 예제를 일반 클래스와 data class로 각각 구현한 비교입니다:
// (1) 일반 클래스에서 수동으로 equals/hashCode 재정의
class Person(val name: String, val age: Int) {
override fun equals(other: Any?): Boolean {
if (this === other) return true // 동일한 객체 참조인지 (`===` Kotlin의 동질비교)
if (other == null || this.javaClass != other.javaClass) return false // null 또는 클래스 타입 다르면 false
other as Person // 스마트 캐스트 (이후부턴 Person 타입으로 취급)
return name == other.name && age == other.age
}
override fun hashCode(): Int {
var result = name.hashCode()
result = 31 * result + age
return result
}
}
// (2) data class 사용 - equals/hashCode 자동 생성
data class PersonData(val name: String, val age: Int)
위 (1)의 일반 클래스 Person은 Kotlin에서 equals와 hashCode를 재정의한 예입니다. Java와 거의 동일한 논리를 따르지만, == 연산자가 이미 equals() 호출로 정의되어 있으므로 내부에서 name == other.name와 같이 사용할 수 있습니다. 또한 ===는 동일성 비교, this.javaClass로 클래스 타입 확인 등을 하고 있습니다.
(2)의 PersonData는 똑같은 필드를 가진 data 클래스인데, 별도의 equals나 hashCode 구현을 적어주지 않아도 컴파일러가 자동으로 둘 다 만들어줍니다. Kotlin의 data 클래스는 모든 주 생성자(primary constructor) 파라미터들을 기반으로 equals()와 hashCode()를 생성합니다. 즉, data class PersonData(val name: String, val age: Int)라고 하면 name과 age 두 프로퍼티 값이 같으면 equals가 true를 반환하도록 알아서 구현됩니다. hashCode 역시 둘을 조합한 값으로 자동 생성됩니다. 뿐만 아니라 toString()과 copy() 메서드도 자동 생성되므로, 보일러플레이트 코드 작성이 크게 줄어듭니다.
val a = Person("Alice", 30)
val b = Person("Alice", 30)
val c = PersonData("Alice", 30)
val d = PersonData("Alice", 30)
println(a == b) // false (일반 클래스, equals 재정의 안했으므로 동등성 실패)
println(c == d) // true (data 클래스, equals 자동 구현되어 동등성 성공)
println(c.hashCode() == d.hashCode()) // true (data 클래스라 같은 내용이면 해시코드도 같음)
위에서 Person은 일반 클래스이며 equals를 오버라이드하지 않았으므로 a == b는 false가 됩니다(동일성 비교로 동작). 반면 PersonData는 data 클래스라 c == d가 true를 반환하고, 두 객체의 hashCode도 동일하게 나옵니다.
Kotlin의 경우 Java와 달리 hashCode()를 직접 작성할 일이 거의 없습니다. data 클래스를 쓰거나, 그렇지 않은 경우라도 Objects.hash(...)에 해당하는 유틸을 쓰기 보다는 간단히 주요 필드의 hashCode()를 조합하는 식으로 (위 코드처럼) 작성하면 됩니다. Objects.hash는 Java 전용이므로 Kotlin에서는 사용할 수 없지만, 코틀린 표준 라이브러리에도 비슷하게 vararg를 받아 해시코드를 만들어주는 함수가 있을 수 있으니 활용 가능합니다.
주의: Kotlin data 클래스의 equals/hashCode 자동 구현은 주 생성자에 선언된 프로퍼티만을 기준으로 합니다. 만약 data 클래스 내부에 val이나 var 프로퍼티를 추가로 정의했다면 (즉, 주 생성자 밖에서), 그 필드는 equals/hashCode 계산에 포함되지 않습니다.
예를 들어 아래 코드를 봅시다.
data class Point(val x: Int, val y: Int) {
var label: String = "origin"
}
val p1 = Point(1, 2).apply { label = "A" }
val p2 = Point(1, 2).apply { label = "B" }
println(p1 == p2) // true or false?
p1과 p2는 주 생성자상의 x, y 값은 둘 다 (1,2)로 동일합니다. 그러나 label은 각각 "A", "B"로 다르게 설정했습니다. 이 경우 Point의 equals()는 자동생성되어 x와 y만 비교하므로 p1 == p2는 true가 됩니다. 만약 label까지 포함하여 논리적 동등성을 판단하고 싶었다면, data 클래스로 자동 생성된 equals를 쓰면 안 되고 직접 오버라이드하거나, 애초에 label도 주 생성자에 포함시키거나 해야 합니다. 이처럼 data 클래스 자동 구현에 포함되지 않는 필드가 있다면 동등성 정의가 직관과 어긋날 수 있으니 조심해야 합니다.
또한, Kotlin에서는 data 클래스라도 var로 프로퍼티를 가질 수 있습니다. var 프로퍼티는 변경 가능하므로, 이 값이 변하면 equals/hashCode 결과도 변하게 됩니다. 따라서 mutable data class를 HashSet이나 HashMap의 키로 사용하는 것은 위험하며, 가급적 data 클래스의 프로퍼티는 val로 불변성을 유지하거나 컬렉션의 키로 사용할 때는 수정하지 않아야 합니다. Kotlin data 클래스를 불변처럼 쓰려면 모든 프로퍼티를 val로 선언하면 되지만, 그렇지 않아도 data 클래스 자체가 shallow immutable (얕은 불변)을 강제하는 것은 아님을 기억합시다(record와 달리 Kotlin은 언어 차원에서 완전한 불변을 강제하진 않습니다).
마지막으로, Java와 마찬가지로 Kotlin에서도 equals 재정의 시 hashCode를 꼭 재정의해야 합니다. data 클래스는 둘 다 자동으로 처리해주지만, 직접 equals를 오버라이드하는 클래스를 만들 경우 hashCode를 빼먹지 않도록 합니다. 다행히 Kotlin 컴파일러가 둘 중 하나만 override하면 경고를 주기도 하므로 (IntelliJ IDE 등에서도) 이를 활용하면 실수를 줄일 수 있습니다.
이제까지 내용을 정리하면서, 실무에서 자주 나오는 잘못된 사용 사례나 안티패턴을 한 번 짚어보겠습니다:
Java에서는 ==로 객체를 비교하면 동일성을 따진다는 것을 잊지 않아야 합니다. 가령 String str1 = "test"; String str2 = new String("test");일 때 str1 == str2는 false입니다. 문자열 내용 비교는 str1.equals(str2)로 해야 합니다. 이 실수는 컬렉션 정렬이나 조건문에서 객체를 비교할 때도 종종 나타나는데, 항상 내가 이 경우 필요한 것이 객체의 동일성인지 동등성인지 생각하고, 동등성을 원하면 equals를 사용하세요. (Kotlin에서는 ==가 이미 equals를 대신하므로 이런 실수가 덜하지만, Kotlin에서도 간혹 ===와 헷갈려서 모든 비교에 ===를 쓰는 실수를 하는 경우가 있습니다. ===는 정말 같은 객체인지 물어보는 연산자라는 점을 기억합시다.)
앞서 설명했듯 이 경우 hash 기반 컬렉션에서 논리적으로 같은 객체가 중복 저장되거나 조회가 안 되는 문제가 발생합니다. 반드시 둘을 함께 재정의해야 합니다.
가변 필드를 equals/hashCode 비교 기준에 넣을 수는 있지만, 그 값이 변경되면 컬렉션 동작이 망가질 수 있습니다. 예컨대 객체를 HashSet에 넣은 후 그 객체의 equals 기준 중 하나인 필드를 바꾸면, HashSet은 더 이상 제대로 해당 객체를 추적하지 못합니다. 그러므로 동등성의 기준이 되는 필드는 불변으로 만들거나 객체 삽입 후 변경하지 않는 것이 안전합니다. 불변 객체로 만들어버리면 가장 좋습니다.
equals를 구현할 때 other 객체의 타입을 체크할 때 getClass()를 쓸지 instanceof를 쓸지 결정해야 합니다. getClass()를 쓰면 상속 관계의 하위/상위 객체를 동등하다고 보지 않겠다는 의미이고, instanceof를 쓰면 상위 타입에 대한 동등성까지 허용하는 의미가 됩니다. 일반적으로 값 객체(Value Object)의 경우 클래스를 final로 두고 getClass()로 체크하는 게 안전합니다. 만약 equals를 instanceof로 구현하고 클래스가 상속이 가능하면, 자식 클래스가 equals를 재정의하지 않을 경우 대칭성 위배 등의 문제가 생길 수 있습니다. (Effective Java 책에서는 equals를 재정의하는 클래스는 아예 final 클래스로 만들 것을 권장하기도 합니다.) 따라서 equals 구현 시 자신의 판단에 따라 타입 비교 방식을 선택하되, 그에 따른 영향(특히 상속 관계에서)을 고려해야 합니다.
Java에는 특별히 Identity HashMap이라는 것이 있어 키 비교를 무조건 ==로 하는 맵이 있습니다. 하지만 일반적인 컬렉션에서는 대부분 equals 기반으로 동작합니다. 그럼에도 불구하고 간혹 객체를 식별할 때 동일성을 써야 하는 상황도 존재합니다 (예: Flyweight 패턴에서 동일 객체인지를 확인 등). 이런 특수한 경우가 아니라면, 비즈니스 로직에서는 동일성보다는 동등성이 의미있는 경우가 많습니다. 반대로, 싱글톤 같은 경우에는 동일성이 중요하겠죠. 결국 문맥에 맞게 올바른 방식을 선택하는 것이 중요합니다.
이제 equals와 hashCode의 개념적 내용은 다루었으므로, 이를 토대로 불변 객체(Immutable Value Object)를 만드는 방법과 Java/Kotlin 언어 기능(record, data class 등)을 활용한 설계 방법을 알아보겠습니다.
Value Object란 그 이름처럼 값으로써 동등성을 판단하는 객체를 말합니다. 동일성이 중요한 엔티티(Entity)와 달리, Value Object는 고유한 식별자나 정체성은 없고 내포한 값으로 모든 것이 정의됩니다. 예를 들어, 돈의 금액과 통화 정보를 담는 Money 객체를 생각해 봅시다. 1만원이라는 값을 표현하는 두 Money 객체가 있다면 우리는 그 둘을 따로 구분하지 않고 동일한 값(동등)이라고 간주합니다. 즉 Value Object는 동등성 비교를 위해 존재한다고 해도 과언이 아니므로, equals/hashCode 구현이 특히 중요합니다. 또한 Value Object는 일반적으로 불변(immutable)으로 만들어서 객체 생성 후 상태가 변하지 않게 함으로써, 신뢰성과 사용 편의성을 높입니다.
Java 17부터 등장한 record와 Kotlin의 data class는 이러한 value object/불변 객체 설계를 극도로 단순화해주는 언어 도구입니다. 또한 Java에서 불변 객체를 만들 때 전통적으로 쓰던 방법(불변 필드만 가진 final 클래스)과 비교해 어떤 차이가 있는지 살펴보겠습니다.
Java의 record는 Java 16에서 정식 도입된 새로운 종류의 클래스입니다. Record는 불변 데이터 캐리어를 만들기 위해 설계되었으며, 필드, 생성자, 접근자, equals, hashCode, toString 등을 자동으로 생성해주는 것이 특징입니다. 정의 문법도 간결해서 마치 Kotlin의 클래스 선언과 유사하게 생겼습니다. 예를 들면:
public record Member(String account, String name, int age) { }
위 한 줄만 써주면 Java 컴파일러는 Member라는 클래스를 만들면서, 내부에 private final String account; private final String name; private final int age; 필드를 정의하고, 모든 필드를 인자로 받는 Canonical Constructor를 생성합니다. 또한 각 필드에 대응하는 accessor 메서드(account(), name(), age())를 public으로 생성하며, equals(), hashCode(), toString()도 알아서 만들어줍니다. 즉, Kotlin의 data class와 거의 비슷한 역할을 언어 차원에서 지원하는 것입니다.
Record가 만들어주는 equals()/hashCode()는 모든 레코드 컴포넌트 (즉, 선언된 모든 필드)를 기준으로 동등성을 판단합니다【23†】. JavaDoc 명세에 따르면, 어떤 record R에 대해 모든 컴포넌트가 동일하면 equals가 true가 되며, hashCode는 모든 컴포넌트의 해시값을 조합하여 계산됩니다. 특별히 기록할 만한 점은, record는 클래스로 선언되지만 사실 컴파일 시 자동으로 java.lang.Record라는 추상 클래스를 상속받는 final 클래스로 구현됩니다. 모든 필드는 암묵적으로 private final로 선언되므로 setter를 만들 수 없고, 설령 클래스 본문에 setter나 상태를 바꾸는 메서드를 작성하려 해도 컴파일 에러가 납니다. 이러한 제약 덕분에 record는 철저하게 불변성을 유지하는 용도로 쓰입니다. 즉, 레코드 객체 생성 이후에는 상태가 변하지 않으며, equals/hashCode의 불변 조건도 자연스럽게 확보됩니다.
Record의 제약 및 특징 요약:
생성자를 통해 한 번 값이 정해지면 변경할 수 없습니다. (자바 메모리 모델 측면에서, final 필드는 생성자 완료 후 다른 스레드에 안전하게 공개될 수 있다는 점도 이점입니다. 이는 불변 객체의 스레드 안전성과 직결되는 부분입니다.)
record로 선언한 클래스는 다른 클래스를 상속할 수 없고, 또 다른 클래스가 record를 상속할 수도 없습니다. 항상 extends java.lang.Record로 고정되며 final입니다. 인터페이스 구현은 가능합니다.
모든 필드에 대해 public 접근자 메서드(getter와 유사하지만 이름이 필드명과 동일)가 생기고, equals(Object), hashCode(), toString()이 컴파일러에 의해 만들어집니다. (toString()은 Member[account=..., name=..., age=...] 형식으로 출력하도록 구현됩니다.)
record에는 Kotlin data class의 copy()에 해당하는 메서드가 자동 지원되지 않습니다. 이유는 record 자체가 이미 불변이고, 필요한 경우 그냥 새로운 인스턴스를 생성하면 되기 때문입니다. (필요하다면 수동으로 유사한 메서드를 추가할 순 있지만, 언어 차원 지원은 없습니다.)
record 본문에 인스턴스 초기화 블록이나 별도 생성자(정적 팩토리 포함)를 정의하여, 생성 시에 데이터 검증이나 가공을 할 수 있습니다. 이를 통해 불변 객체의 불변식(class invariant)을 확립할 수 있습니다. 예를 들어, 음수가 될 수 없는 값을 컴포넌트로 받는 record라면, compact constructor에서 체크하여 음수면 IllegalArgumentException을 던지는 식입니다.
Java 16~17 이후 record와 관련해 등장한 멋진 기능 중 하나는 패턴 매칭입니다. instanceof나 switch에서 record의 구성요소를 쉽게 분해(destructure)하여 사용할 수 있습니다. 예를 들어 if (obj instanceof Member(var acc, var name, var age)) { ... }처럼 작성하면 obj가 Member이면 자동 언패킹이 되어 지역변수 acc, name, age를 바로 쓸 수 있습니다. Kotlin의 분해 선언(val (a, b) = data)과 개념은 유사하나, 현재(자바 17 기준) preview 기능을 포함한 모습입니다.
Record는 불변 객체를 만들 때 코드양을 획기적으로 줄여주고 오류 가능성도 줄여줍니다. 과거에는 Lombok의 @Data 또는 @Value 애노테이션, 혹은 IDE의 코드 생성으로 클래스를 만들었다면 이제 record 한 줄로 끝나는 경우가 많습니다.
예시: Java record 사용
public record Point(int x, int y) { }
Point p1 = new Point(3, 5);
Point p2 = new Point(3, 5);
System.out.println(p1.equals(p2)); // true (x,y 값이 같으므로 동등)
System.out.println(p1 == p2); // false (별개의 인스턴스이므로)
System.out.println(p1); // 출력: Point[x=3, y=5]
record를 사용하기 어려운 상황(예: Java 17 미만을 사용하거나, 또는 상속이 반드시 필요하다면)에는 기존 방식대로 불변 클래스를 만들 수 있습니다. 불변 클래스를 설계하기 위한 일반적인 지침은 다음과 같습니다:
클래스를 final로 선언하여 하위 클래스가 추가 상태를 갖으며 상속받지 못하게 한다. (하위 클래스에서 equals를 건드려 버리면 문제가 될 수 있고, 불변 규칙도 깨뜨릴 수 있기 때문입니다.)
모든 필드를 private final로 선언하여 한 번 생성자에서 초기화되면 변경할 수 없게 한다.
가변 객체를 필드로 가져야 한다면 (예: List나 배열), 해당 필드도 외부에 노출하지 말고, 복사본을 만들거나 불변 래퍼로 감싸서 보관한다. (이런 걸 방어적 복사(defensive copy)라고 합니다. 불변 객체 내부에 가변 컬렉션이 있다면, 생성자에서 Collections.unmodifiableList(new ArrayList<>(mutableList)) 같은 방어적 복사가 필요합니다.)
equals()와 hashCode()를 재정의하여 값을 기준으로 동등성 판단. 그리고 toString()도 가급적 유용하게 오버라이드하면 디버깅에 편합니다.
필요하면 캐시나 flyweight 패턴을 적용해 동일한 값의 객체를 재사용하거나, 아예 싱글턴으로 만들어 메모리 사용을 최적화할 수 있습니다. (예: 값 범위가 제한적이라면 미리 생성해둔 상수 인스턴스를 돌려주는 등.)
이렇게 만들면 record와 기능적으로 거의 유사하지만, 코드 양은 비교가 안 될 정도로 많아집니다. 예를 들어 위 Point 클래스를 record 대신 일반 클래스로 불변 설계하면:
public final class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() { return x; }
public int getY() { return y; }
@Override
public boolean equals(Object o) { ... } // x, y 비교 구현 (앞서 본 패턴)
@Override
public int hashCode() { ... } // x, y 조합하여 해시 생성
@Override
public String toString() {
return "Point(" + x + ", " + y + ")";
}
}
사실 IntelliJ 같은 IDE의 Record to Class 변환 기능을 써 보면 한 눈에 보이는데, record를 풀어쓰면 상당히 장황해집니다. 따라서 특별한 이유가 없다면 (예: 호환성 이슈, 프레임워크 제약) Java 17+ 환경에서는 불변 값 객체에 record를 활용하는 것이 좋습니다.
참고: record를 쓸 수 없는 상황 중 하나는 JPA @Entity 같은 프레임워크를 사용할 때입니다. JPA는 기본 생성자와 프록시 등을 필요로 하는데, record는 이에 맞지 않기 때문입니다. 이런 경우는 어쩔 수 없이 일반 클래스로 구현해야 합니다. JPA 엔티티는 엄밀히 말하면 Value Object가 아니라 Identity가 존재하는 객체이므로 이 글의 범위를 벗어나긴 하지만, equals/hashCode 구현에 있어 또 다른 전략이 필요합니다 (예: DB 식별자 기반으로 equals 정의 등). 이는 경우에 따라 달라지므로 여기서는 자세히 다루지 않겠습니다.
Kotlin의 data class는 Kotlin 언어의 대표적인 편의 장치로, 값을 저장하기 위한 클래스에 자동으로 유용한 메서드들을 제공합니다. Java record와 유사하지만 몇 가지 면에서 다릅니다:
equals(), hashCode(), toString(), 그리고 copy() 메서드를 자동 생성합니다. 또한 모든 주 생성자 프로퍼티에 대해 componentN() 함수를 만들어주어 구조 분해(destructuring) 선언을 지원합니다.
data 클래스의 프로퍼티는 val 또는 var로 선언할 수 있습니다. val로 하면 불변 필드가 되고, var이면 setter가 생겨버립니다. 언어적으로 필드를 모두 final로 강제하지는 않으므로, record보다는 개발자에게 자유와 책임을 더 부여한 형태입니다. Kotlin 팀은 불변성을 권장하지만, 필요하다면 가변 필드도 허용하는 디자인을 한 것입니다. (참고로, data 클래스 자체는 기본적으로 final이며, open data class는 지원되지 않습니다. 때문에 data 클래스의 상속은 제한적입니다.)
data 클래스는 다른 클래스를 상속받는 것은 가능합니다 (abstract 클래스를 구현하거나, 일반 클래스를 open으로 두고 상속). 그러나 data 클래스가 부모 클래스가 되는 것은 기본적으로 허용되지 않습니다 (컴파일러가 data를 final로 만듦). 일부 플러그인(AllOpen)이나 트릭을 쓰지 않는 이상 data 클래스를 상속할 수는 없습니다. 따라서 상속 계층 구조보다는 평평한 구조의 값 객체를 만들 때 적합합니다.
Kotlin data 클래스의 강력한 기능 중 하나는 copy() 메서드입니다. 이 메서드는 현재 객체와 동일한 필드 값을 가지지만 일부 지정한 필드만 변경한 새로운 객체를 만들어줍니다. 불변 객체를 다룰 때 매우 유용하며, 특히 많은 필드 중 몇 개만 바꿔 새로운 인스턴스를 만들어야 할 때 유용합니다. (Java에서는 record라도 copy 메서드가 없어 매번 new를 호출하거나 별도의 빌더/생성자를 만들어야 하는데, Kotlin은 언어 차원에서 지원합니다.)
구조분해 선언 (val (name, age) = personData) 을 통해 한 줄로 여러 값을 꺼낼 수 있고, copy와 더불어 이게 제공된다는 것은 분명 Kotlin data 클래스의 매력입니다. Java도 record 패턴 매칭이 있긴 하지만, Kotlin만큼 간결하지는 않습니다 (패턴 매칭은 여전히 if/switch 문맥에서 사용).
예시: Kotlin data class 사용
data class Address(val city: String, val zip: String)
val addr1 = Address("Seoul", "12345")
val addr2 = Address("Seoul", "12345")
println(addr1 == addr2) // true (동등성: 모든 프로퍼티 같음)
println(addr1 === addr2) // false (서로 다른 인스턴스)
println(addr1.toString()) // 출력: Address(city=Seoul, zip=12345)
val addr3 = addr1.copy(zip = "99999")
println(addr3) // 출력: Address(city=Seoul, zip=99999)
위에서 addr1과 addr2는 같은 값이므로 == 비교시 true가 나오고, toString()도 자동 생성된 형식으로 나타납니다. addr3은 addr1과 같은 city를 가지되 zip만 바꾼 새로운 객체입니다 (이 때 addr1은 불변이라 그대로이고, 변경이 필요한 경우 copy로 새 객체를 만든 것을 알 수 있습니다). 이런 방식은 함수형 프로그래밍이나 데이터 가공 처리에서 불변 객체를 다루기 편하게 해줍니다.
주의: Kotlin data 클래스도 내부에 가변 컬렉션이나 객체를 필드로 가질 수 있습니다. data 클래스의 불변은 shallow 불변이기 때문에, 예를 들어 data class Foo(val nums: MutableList)라면 nums 리스트 자체는 참조가 불변일 뿐 내용은 변할 수 있습니다. equals도 리스트의 내용을 비교하겠지만 (List의 equals 구현은 요소 동등 비교임), 여러 스레드가 해당 리스트를 공유하면 불변 객체라 해서 안전한 것은 아닙니다. 완전한 의미의 불변 객체를 만들려면 깊은 불변성(deep immutability)을 확보해야 합니다. (Java의 record도 마찬가지입니다; 필드가 참조 타입이면 그 참조가 가리키는 객체까지 불변인지 별도 보장 못합니다.)
특징 | Java record | Java 불변 클래스 | Kotlin data class |
---|---|---|---|
보일러플레이트 | 거의 없음 → 생성자, getters, equals, hashCode, toString 자동 생성 | 많음 → 필드, 생성자, getters, equals, hashCode 직접 작성 | 거의 없음 → 주 생성자 + data 키워드로 equals , hashCode , toString , copy , componentN 자동 생성 |
불변성 보장 | 필드 모두 묵시적 final → 완전 불변 객체 강제 | 설계에 따라 다름 → final 필드로 설계 시 불변, 그렇지 않으면 가변 가능 | val 사용 시 불변var 도 허용→ 불변 권장이나 강제는 아님 |
상속 | final 클래스항상 extends Record | 보통 final 권장(불변 객체의 equals 안정성 위해) | 기본적으로 final (상속 불가)추상 클래스 구현 등은 가능 |
필드 접근자 | 자동 생성된 명명된 accessor (필드명() ) | 표준 getter (getX() 등) 직접 작성 필요 | val/var 에 대한 자동 getter 제공 |
equals/hashCode | 모든 필드 기준 자동 구현 | 직접 구현 필요 (IDE/autogen 사용 권장) | 주 생성자 프로퍼티 기준 자동 구현 |
toString | ClassName[field1=..., field2=...] 형식 자동 생성 | 직접 구현 필요 | ClassName(field1=..., field2=...) 형식 자동 생성 |
기타 메서드 | 임의 정의 가능 (단, 가변 동작 지양) | 제약 없음 | copy() 자동 생성componentN 자동 생성 |
사용 편의 기능 | Java 19부터 Pattern Matching 지원 (e.g., instanceof Point(int x, int y) ) | - | 구조 분해 지원 (예: val (x, y) = point ) |
주요 용도 | 불변 DTO / 값 객체 (타 언어의 case class 느낌) | 불변 객체 또는 일반 객체 등 설계 자유 | 불변 데이터 홀더, DTO, 값 객체 |
성능/메모리 | 일반 클래스와 동일final 로 인해 JIT 최적화에 유리할 수 있음 | 필드 수, 가변성에 따라 다름 | JVM 상에서는 일반 클래스와 동일final 로 인해 JIT 최적화 유리 |
한계 | @Entity 등 일부 프레임워크와 호환성 이슈 있음(기본 생성자 없음 등) Java 17+ 이상 필요 | 코드량 많고 실수 여지 있음 | Java와의 호환 고려 필요 JVM 상에서는 일반 클래스이므로 interoperability 문제 없음 |
※ JVM 메모리 측면에서 record나 data class 모두 특별한 이점이 있는 것은 아닙니다. 둘 다 객체로 힙에 할당되며, 객체 당 약간의 헤더 메모리가 필요합니다. 다만 record/data class를 사용함으로써 불변성과 equals/hashCode의 일관성을 쉽게 보장할 수 있어 논리적 오류를 줄이고, JIT 컴파일러 최적화를 간접적으로 도울 수 있는 장점이 있습니다. (예를 들어 final 클래스이므로 가상 메서드 호출이 많이 줄어들어 인라이닝 등에 유리합니다.)
이 표에서 볼 수 있듯, Java record와 Kotlin data class는 유사한 목적을 가지지만 언어 철학의 차이도 있습니다. Java record는 불변성과 단순함을 강제하는 쪽이고, Kotlin data class는 편의를 주되 개발자에게 선택의 여지를 주는 쪽입니다. 어느 쪽이든 이러한 기능을 활용하면 equals/hashCode 구현을 직접 할 일이 크게 줄어들고, 오류 가능성도 낮아집니다.
마지막으로, 이 주제와 관련된 JVM 메모리 모델이나 성능 측면의 심화 내용을 간략히 짚어보겠습니다:
불변 객체에서는 필드를 final로 선언하는데, 이는 멀티스레드 환경에서 유용합니다. Java 메모리 모델(JMM)에 따르면, 객체 생성 후 final 필드는 생성자가 완료된 시점에 다른 스레드에 바로 가시적(visible)이 됩니다. 즉, 별도 동기화 없이도 다른 스레드가 그 객체를 볼 때 final 필드들은 초기화된 값으로 보장됩니다. 이는 가변 필드와는 다른 강력한 보장으로, 불변 객체가 스레드 안전하게 동작할 수 있는 근거입니다. (물론 객체 참조를 안전하게 공유하기 위한 최소한의 조건은 충족해야겠지만, 보통의 상황에서는 문제 없습니다.)
불변 객체 설계 시 흔히 드는 고민은 필요할 때마다 새 객체를 생성해야 하는 비용입니다. 가변 객체였다면 값을 바꿔끼우면 되지만, 불변 객체는 한 번 만들면 끝이니 새로운 값을 표현하려면 새로운 객체를 만들어야 합니다. 예를 들어 addr.copy(zip = "99999")는 새로운 Address 객체를 힙에 할당합니다. 이런 방식은 객체가 매우 많이 만들어질 경우 GC 부담이 될 수 있지만, Java의 GC는 짧은 생명주기의 객체를 효율적으로 처리하도록 설계되어 있습니다. 특히 최신 G1 GC 등은 젊은 세대 영역을 최적화하여 금방 쓰고 버리는 작은 객체들을 아주 빠르게 수거합니다. 그러므로 불변 객체를 많이 생성하는 것 자체는 크게 문제되지 않을 때가 많습니다. (프로파일링을 해서 병목이 되는 경우에만 최적화를 고민하면 됩니다.)
불변 객체는 생성 시 계산해둔 해시코드를 캐싱해둘 수 있습니다. 예를 들어 Java의 String 클래스는 한 번 계산한 해시코드를 내부에 캐시하여, 이후에 hashCode()를 호출할 때 매번 재계산하지 않도록 최적화하고 있습니다. 이런 기법은 불변 객체에 적합합니다. 우리도 커스텀 불변 객체를 만들 때 해시코드 계산이 복잡하거나 자주 쓰인다면, 미리 계산해 final int hash 필드에 저장해둘 수 있습니다. 다만 대부분의 경우 해시코드 계산이 그리 비싸지 않으므로 (몇 개 int 연산) 필요할 때만 적용합니다. Kotlin data class나 Java record는 해시코드 캐싱을 자동으로 해주지는 않으니, 정말 필요하다면 수동 최적화가 필요합니다.
equals 구현은 대체로 O(n) (n은 비교하는 필드 수 혹은 데이터 양)입니다. HashMap 등에서 키 비교를 할 때 우선 hashCode로 거르고 필요 시 equals 비교를 하므로, hashCode가 잘 구현되어 충돌이 적으면 성능이 좋아집니다. 예를 들어 모든 객체가 hashCode를 1로 반환하게 구현하면 모든 키가 한 버킷에 모여 O(n) 탐색을 하게 되겠죠 (최악의 경우 해시맵이 링크드리스트처럼 동작). 그러므로 hashCode는 가능하면 객체를 잘 분산시키도록 작성하는 것이 중요합니다. 또한 equals도 불필요하게 비용이 크지 않도록, 중요한 필드부터 비교해서 빠르게 다른 것을 판정하거나, 비교해야 할 필드가 많으면 그중 해시가 빠른 것을 먼저 비교하는 등의 미세 최적화도 가능합니다. (예: 문자열 두 개를 비교하는 equals라면, 미리 길이(length)가 다르면 바로 false를 리턴하는 식으로 하면 좋습니다.)
자바의 == 연산은 원시 타입 비교에는 빠르고, 참조 비교에도 단순 포인터 비교라 빠릅니다. equals()는 메서드 호출인 만큼 조금은 overhead가 있습니다. 하지만 JIT이 잘 최적화해주기에 일반적으로는 미미합니다. 다만 수십억번 객체 비교를 해야 하는 상황이라면, equals와 hashCode를 잘 구현하는 것뿐 아니라 자료구조 선택 (예: 해시맵 vs 트리맵)까지 고민해야 할 것입니다. 이 부분은 특정 상황에 특화된 최적화 영역이므로 여기서는 자세히 다루진 않겠습니다.
2020년대 중반 이후 Java에는 값 타입(Value Type) 지원이 논의되고 있습니다. 만약 프리미티브 타입처럼 동작하는 객체가 지원되면, 현재 record로 만들어지는 불변 객체들도 진정한 의미의 identity 없는 값으로 다룰 수 있게 될 것입니다. 예를 들어 두 좌표 객체를 더하면 새로운 좌표 객체를 생성하는 대신 CPU 레지스터나 스택 상에서 더해질 수도 있고, 박싱/언박싱 오버헤드 없이 컬렉션에 저장될 수도 있습니다. Kotlin도 JVM 위에서는 이러한 이점을 자동으론 못 누리지만, Kotlin/Native 등에서는 값 타입을 활용한 데이터 클래스를 언젠가 지원할 수도 있습니다. 어쨌든 현재 시점(Java 17~21)에서는 record나 data class 모두 일반적인 힙 객체라는 점을 인지하고, 지나친 마이크로 최적화는 피하되 구조적인 최적화는 향후를 대비해 염두에 두는 것이 좋겠습니다.
동등성과 동일성의 개념 차이는 간단하지만, 실제 코드에서는 자주 혼동되어 버그의 원인이 되곤 합니다. Java에서는 ==와 equals()를, Kotlin에서는 ===와 ==를 구분하여 사용하고, 언어가 제공하는 record나 data class를 적극 활용하여 불변이고 신뢰성있는 Value Object를 만드는 것이 현대 소프트웨어 개발에서의 트렌드입니다. 불변 객체는 멀티스레드 환경에서 안전하고, 여기저기 전달되어도 예상치 못한 부작용이 없으며, 해시 키로 써도 안전하기 때문에 유지보수성이 높아집니다. 요약하자면, 동등성은 내용의 같음, 동일성은 객체 자체의 같음입니다. 우리의 코드에서 무엇이 중요한지를 판단해 올바른 방법으로 비교해야 합니다. 또한, Java와 Kotlin에서 equals()와 hashCode()를 구현하거나 자동 생성해주는 도구들을 활용하여, 논리적 동등성의 정의를 클래스에 녹여내야 합니다. 그리고 잊지 말아야 할 점은 equals/hashCode의 구현에서는 반드시 일관성(contract)을 지켜야 하며, 이를 어기는 흔한 실수들(안티패턴)을 주의해야 합니다. 마지막으로, Java의 record와 Kotlin의 data class를 비롯한 언어 특징들을 통해 우리는 더욱 쉽고 빠르게 불변의 값 객체를 만들 수 있으니, 적재적소에 활용하여 코드 품질과 개발 생산성을 함께 높일 수 있을 것입니다.