1. 시작
클래스 명칭에서 드러나듯이 '데이터 자체'를 표현하기 위해 만들어진, 그리고 그렇게 사용하는 Data class에 대해서 알아본다.
2. 누구냐 넌
클래스 명 앞에 data 제어자를 사용해 정의하는 코틀린의 Class 중 하나로, 코틀린의 철학 중 하나인 실용성(Pragmatic)이 강하게 반영된 결과물 중 하나다. 다시 말하면, 어떤 점에서 실용성을 만족시키고 있는가를 이해하는 것이 'data class'를 이해하는 가장 중요한 키라고 생각한다.
대규모 시스템의 안정적인 운영과 용이한 유지보수를 위해 활성화된 ‘객체지향’이라는 하나의 규약을 지켜내기 위해 Java 진영에서 만들어진 문화는, 결국 개발자에게 반복적인 노동을 요구하게 된다. 특히 객체의 내부 상태를 직접 노출하지 않고 캡슐화하기 위해 private field를 두고, 외부에서는 getter/setter를 통해 접근하도록 하는 방식은 Java 생태계에서 사실상 표준처럼 자리잡았다. 하지만 DTO와 같이 본질적으로 “데이터를 담아 전달하는 목적”에 충실한 객체조차도, 객체지향 규약을 따른다는 이유로 수많은 보일러플레이트 코드를 필요로 하게 된 것이다.
예를 들어 단순히 값 두 개를 담는 객체를 만들고 싶을 뿐인데도 개발자는 다음과 같은 반복적인 작업을 수행해야 한다.
- 필드 선언
- 생성자 작성
- getter/setter 작성
- equals()와 hashCode() 구현
- toString() 구현
즉, 객체지향을 지키기 위해 만들어진 규약이 어느 순간부터는 “의미 있는 설계”라기보다는, DTO를 작성할 때마다 반복되는 노동으로 굳어져버린 셈이었고 코틀린은 이 지점을 언어 수준에서 해결하고자 했으며 그 결과가 data class다. 코틀린은 DTO를 “개발자가 객체지향 규약을 손으로 구현하는 영역”으로 남겨두지 않고, 언어 레벨에서 해결해준다. DTO가 반드시 필요로 하는 기능들을 컴파일러가 자동으로 생성하도록 한 것이다.
그 방식은 크게 두 가지로 제공되고 있다.
첫째, 코틀린의 모든 클래스가 상속하는 최상위 클래스인 Any가 제공하는 메서드들을 data class가 목적에 맞게 자동으로 overriding 한다.
- equals()
- hashCode()
- toString()
이는 DTO가 값 기반으로 비교되고, 출력될 수 있어야 한다는 요구를 언어 차원에서 충족시키는 것이다.
※ 최상위 클래스 Any
public actual open class Any {
public actual open operator fun equals(other: Any?): Boolean
public actual open fun hashCode(): Int
public actual open fun toString(): String
}
Java의 Object와 같이 Kotlin의 최상위 타입인 Any는 앞서 언급한 바와 같이 세 개의 메서드를 제공하고 있다. Object와는 유사하지만, Kotlin의 타입 안정성과 null 안정성 때문에 Any는 non-null 타입의 최상위라는 점에서 차이가 있다.
1. equals
기본적으로 동일성(===, Referntial equality)를 제공함에 따라 두 객체의 주소가 같을 때 true를 반환한다.
2. hashCode
객체의 주소를 숫자로 바꿔 제공한다.
3. toString
"$클래스이름@해시코드16진수" 형태로 반환한다.
둘째, DTO라는 목적성에 특화된 추가 메서드들을 제공한다.
- copy()
- componentN() (구조 분해 선언 지원)
결국 data class는 단순히 코드를 줄여주는 문법이 아니라, DTO가 본질적으로 필요로 하는 기능들을 반복적인 노동 없이도 사용할 수 있도록 만든 코틀린식 수단이다. 즉, 객체지향이라는 규약을 유지하면서도, DTO라는 목적에 불필요하게 부과되던 비용을 언어가 대신 지불해주는 구조라고 볼 수 있다.
3. 주요 메서드
주요 메서드를 살펴보기 앞서 예시로 사용할 data class와 decompile된 Java 코드를 유심히 살펴보자.
data class Subject(
val name: String,
val capacity: Int,
)
// Decompiled Subject
public final class Subject {
@NotNull
private final String name;
private final int capacity;
public Subject(@NotNull String name, int capacity) {
Intrinsics.checkNotNullParameter(name, "name");
super();
this.name = name;
this.capacity = capacity;
}
@NotNull
public final String getName() {
return this.name;
}
public final int getCapacity() {
return this.capacity;
}
@NotNull
public final String component1() {
return this.name;
}
public final int component2() {
return this.capacity;
}
@NotNull
public final Subject copy(@NotNull String name, int capacity) {
Intrinsics.checkNotNullParameter(name, "name");
return new Subject(name, capacity);
}
// $FF: synthetic method
public static Subject copy$default(Subject var0, String var1, int var2, int var3, Object var4) {
if ((var3 & 1) != 0) {
var1 = var0.name;
}
if ((var3 & 2) != 0) {
var2 = var0.capacity;
}
return var0.copy(var1, var2);
}
@NotNull
public String toString() {
return "Subject(name=" + this.name + ", capacity=" + this.capacity + ')';
}
public int hashCode() {
int result = this.name.hashCode();
result = result * 31 + Integer.hashCode(this.capacity);
return result;
}
public boolean equals(@Nullable Object other) {
if (this == other) {
return true;
} else if (!(other instanceof Subject)) {
return false;
} else {
Subject var2 = (Subject)other;
if (!Intrinsics.areEqual(this.name, var2.name)) {
return false;
} else {
return this.capacity == var2.capacity;
}
}
}
}
3.1 equals
data class의 equals는 동등성(==, Structural equality)를 제공한다. 즉, 두 객체가 동일한 값을 가지고 있는가를 비교하게끔 overriding을 하는 것이다. 하지만 여기서 중요한 것은 '주 생성자에 포함된 속성 값이 일치하는가'이다. 즉, 클래스를 아래와 같이 작성했을 때, 동등 비교에 description은 포함되지 않는다.
data class Subject(
val name: String,
val capacity: Int,
) {
// 추가함
var description: String?= null
}
3.2 hashCode
해시코드는 동등한 객체는 동일한 값을 반환해야 하므로, data class의 동등 비교에 속성 값들이 포함된 이상 hashCode에 각 개별 속성의 hashCode가 반영되어야 하는 것은 어찌보면 자명한 일이다. 객체의 속성 값이 모두 일치한다면 동일한 hashCode가 생성된다는 얘기다.
fun main() {
println(Subject("네덜란드 문학의 경계를 넘어서", 20).hashCode()) // -1730734124
println(Subject("네덜란드 문학의 경계를 넘어서", 20).hashCode()) // -1730734124
println(Subject("네덜란드 정치", 20).hashCode()) // 1658567557
}
3.3 toString
동등 비교의 맥락에 이어서, 결국 각 객체의 속성값이 중요한 포인트이므로 '클래스 이름'과 주 생성자에서 정의한 속성 별 '이름=값' 쌍을 문자열로 반환한다.
Subject(name=네덜란드 문학의 경계를 넘어서, capacity=20)
Subject(name=네덜란드 문학의 경계를 넘어서, capacity=20)
Subject(name=네덜란드 정치, capacity=20)
3.4 copy
주 생성자의 속성들을 매개변수로 받는 함수로, 각 매개변수의 기본값은 연결된 속성의 현재 값으로 설정한다. 이 때 중요한 것은 '얇은 복사' 로 생성함에 따라, 객체의 상태가 변경 가능하다면 조심할 필요가 있다.
fun main() {
val hell = Subject("네덜란드 문학의 경계를 넘어서", 20)
// Subject(name=네덜란드 문학의 경계를 넘어서, capacity=20)
val anotherHell = hell.copy(name = "네덜란드 정치")
// Subject(name=네덜란드 정치, capacity=20)
}
3.5 componentN
코틀린에서 제공되는 '위치 기반 구조 분해(destructuring)'을 지원하기 위한 함수로, 함수형 + 데이터 중심 프로그래밍을 지원하는 강력한 기능 중 하나다.
val hell = Subject("네덜란드 문학의 경계를 넘어서", 20)
val (name, capacity) = hell
REFERENCE
1. 마르친 모스카와. 『코틀린 아카데미 - 핵심편』. 프로그래밍인사이트, 2022.
2. Kotlin language guide - Data classes
3. Kotlin language guide - equality
'Language > Kotlin' 카테고리의 다른 글
| [Kotlin] 인라인 함수(inline function) (0) | 2026.02.10 |
|---|---|
| [Kotlin] 봉인된 클래스(Sealed Class)와 봉인에 대하여 (0) | 2026.02.09 |
| [Kotlin] 다양한 형태의 객체(object, companion, data, constant) (0) | 2026.02.04 |