Data class는 무엇이고, 왜 Dto는 Data class로 다루는 것이 좋을까요?
발단
Dto를 무의식적으로 Data class로 사용하고 있다가 문득 왜 Dto는 Data class로 다루는지, 어떨 때 가장 효율적인지 궁금하여 해당 주제를 정리해보게 되었습니다.
왜 Dto는 Data class로 다룰까요?
"Data classes in Kotlin are classes whose main purpose is to hold data."
"데이터 클래스(Data class)는 데이터 보관 목적으로 만든 클래스를 말한다."
코틀린 공식 문서의 내용입니다.
데이터 보관을 목적으로 만들었다하니
프로세스 간 데이터를 전달하는 객체를 의미하는, 말 그대로 데이터를 전송하기 위해 사용하는 객체 DTO(Data Transfer Object, 데이터 전송 객체)의 역할에 적합하여 Dto는 Data class를 사용하는 것이 유리하구나 직관적으로 확인할 수 있었습니다.
domain model (Entity)은 데이터 보관 역할 넘어서 비즈니스 로직을 구현하고 도메인의 행위를 정의하는 역할을 가지고 있어 Data class를 활용하는데 어려움이 있겠구나 추가적으로 이해할 수 있었습니다.
일반 class 와 Data class를 비교하자면,
아래 예시와 같이 데이터 클래스는 입력한 정보가 나오지만 일반 클래스는 주소값을 반환합니다. 때문에 과제 내 Dto처럼 데이터만만 다루어야 할 때는 데이터 클래스가 일반 클래스보다 유용합니다.
data class CreateDto1(
var title : String = "",
var description: String = ""
)
class CreateDto2(
var title: String,
var description: String
)
fun main() {
var exam = CreateDto1()
var exam1 = CreateDto1("제목")
var exam2 = CreateDto1(description = "내용")
var exam3 = CreateDto1("제목2", "내용2")
var test = CreateDto2("제목3", "내용3")
println("CreateDto1 사용 "+exam3)
println("CreateDto2 사용 "+test)
}
// 출력값 : CreateDto1 사용 CreateDto1(title=제목2, description=내용2)
// 출력값 : CreateDto2 사용 CreateDto2@38082d64
추가적으로 데이터 클래스는 데이터를 더 편하게 사용할 수 있도록 컴파일러가 컴파일 시 다음과 같이
- equals()
- hashCode()
- copy()
- toString()
- componentsN()
등의 함수 제공해주고 있습니다. (명시적으로 사용하는 경우에는 컴파일러가 자동으로 생성해주지 않습니다.)
데이터 클래스라면 자동으로 제공되는 함수들을 살펴보기 위해 'CreatDto1' Data class를 디컴파일해보았으며, 위에서 설명한 함수들이 포함된 모습을 확인할 수 있습니다.
// CreateDto1.java
import kotlin.Metadata;
import kotlin.jvm.internal.DefaultConstructorMarker;
import kotlin.jvm.internal.Intrinsics;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@Metadata(
mv = {1, 9, 0},
k = 1,
xi = 48,
d1 = {"\u0000\"\n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0000\n\u0002\u0010\u000e\n\u0002\b\f\n\u0002\u0010\u000b\n\u0002\b\u0002\n\u0002\u0010\b\n\u0002\b\u0002\b\u0086\b\u0018\u00002\u00020\u0001B\u0019\u0012\b\b\u0002\u0010\u0002\u001a\u00020\u0003\u0012\b\b\u0002\u0010\u0004\u001a\u00020\u0003¢\u0006\u0002\u0010\u0005J\t\u0010\f\u001a\u00020\u0003HÆ\u0003J\t\u0010\r\u001a\u00020\u0003HÆ\u0003J\u001d\u0010\u000e\u001a\u00020\u00002\b\b\u0002\u0010\u0002\u001a\u00020\u00032\b\b\u0002\u0010\u0004\u001a\u00020\u0003HÆ\u0001J\u0013\u0010\u000f\u001a\u00020\u00102\b\u0010\u0011\u001a\u0004\u0018\u00010\u0001HÖ\u0003J\t\u0010\u0012\u001a\u00020\u0013HÖ\u0001J\t\u0010\u0014\u001a\u00020\u0003HÖ\u0001R\u001a\u0010\u0004\u001a\u00020\u0003X\u0086\u000e¢\u0006\u000e\n\u0000\u001a\u0004\b\u0006\u0010\u0007\"\u0004\b\b\u0010\tR\u001a\u0010\u0002\u001a\u00020\u0003X\u0086\u000e¢\u0006\u000e\n\u0000\u001a\u0004\b\n\u0010\u0007\"\u0004\b\u000b\u0010\t¨\u0006\u0015"},
d2 = {"LCreateDto1;", "", "title", "", "description", "(Ljava/lang/String;Ljava/lang/String;)V", "getDescription", "()Ljava/lang/String;", "setDescription", "(Ljava/lang/String;)V", "getTitle", "setTitle", "component1", "component2", "copy", "equals", "", "other", "hashCode", "", "toString", "PracticeProject"}
)
public final class CreateDto1 {
@NotNull
private String title;
@NotNull
private String description;
public CreateDto1(@NotNull String title, @NotNull String description) {
Intrinsics.checkNotNullParameter(title, "title");
Intrinsics.checkNotNullParameter(description, "description");
super();
this.title = title;
this.description = description;
}
// $FF: synthetic method
public CreateDto1(String var1, String var2, int var3, DefaultConstructorMarker var4) {
if ((var3 & 1) != 0) {
var1 = "";
}
if ((var3 & 2) != 0) {
var2 = "";
}
this(var1, var2);
}
@NotNull
public final String getTitle() {
return this.title;
}
public final void setTitle(@NotNull String var1) {
Intrinsics.checkNotNullParameter(var1, "<set-?>");
this.title = var1;
}
@NotNull
public final String getDescription() {
return this.description;
}
public final void setDescription(@NotNull String var1) {
Intrinsics.checkNotNullParameter(var1, "<set-?>");
this.description = var1;
}
@NotNull
public final String component1() {
return this.title;
}
@NotNull
public final String component2() {
return this.description;
}
@NotNull
public final CreateDto1 copy(@NotNull String title, @NotNull String description) {
Intrinsics.checkNotNullParameter(title, "title");
Intrinsics.checkNotNullParameter(description, "description");
return new CreateDto1(title, description);
}
// $FF: synthetic method
public static CreateDto1 copy$default(CreateDto1 var0, String var1, String var2, int var3, Object var4) {
if ((var3 & 1) != 0) {
var1 = var0.title;
}
if ((var3 & 2) != 0) {
var2 = var0.description;
}
return var0.copy(var1, var2);
}
@NotNull
public String toString() {
return "CreateDto1(title=" + this.title + ", description=" + this.description + ')';
}
public int hashCode() {
int result = this.title.hashCode();
result = result * 31 + this.description.hashCode();
return result;
}
public boolean equals(@Nullable Object other) {
if (this == other) {
return true;
} else if (!(other instanceof CreateDto1)) {
return false;
} else {
CreateDto1 var2 = (CreateDto1)other;
if (!Intrinsics.areEqual(this.title, var2.title)) {
return false;
} else {
return Intrinsics.areEqual(this.description, var2.description);
}
}
}
public CreateDto1() {
this((String)null, (String)null, 3, (DefaultConstructorMarker)null);
}
}
- 디컴파일 코드 내 componentN을 확인해 볼 수 있는데, 단순히 클래스의 데이터를 반환하는 함수입니다.
- 원래의 객체에 일부 데이터를 변형시키고 싶을 때 copy를 사용하여 복사할 수 있습니다.
- 위의 예시에서 우리는 data class를 출력할 때 별도의 구현 없이 코드는 println(exam3), 출력 형태는 CreateDto1(title=제목2, description=내용2)와 같은 형태였습니다. 그 이유는 바로 data class가 toString()을 기본적으로 제공해주기 때문이다.
- hashCode 함수로 각 데이터들의 hash 값을 구할 수 있습니다.
- equals는 주소 값의 비교가 아닌 값이 똑같은지 비교합니다.
equals()와 hashCode()를 더 깊게 들어가보자면,
먼저 일반 class에서 같은 프로퍼티를 갖고 있는 두 인스턴스를 비교할 때 어떻게 동작하는지 확인해봅니다.
class User(val name: String, val age: Int)
fun main() {
val user1 = User("minsu", 10)
val user2 = User("minsu", 10)
print(user1 == user2) // false
}
이렇게 class로 선언한 User 인스턴스를 같은 값을 같도록 생성하고 ==으로 비교해주면 false가 출력되는데요.
코틀린에서는 ==을 자동으로 equals 연산자로 호출합니다.
class에서는 equals 메서드를 오버라이딩 하지 않았기 때문에 프로퍼티 값을 비교하는 것이 아니라 인스턴스 자체가 같은지를 판단하게 됩니다.
그러나 Data class의 경우 컴파일러가 값을 비교하도록 구현해 놓았기 때문에 아래와 같이 동작하게 됩니다.
data class User(val name: String, val age: Int)
fun main() {
val user1 = User("minsu", 10)
val user2 = User("minsu", 10)
print(user1 == user2) // true
}
그러나 아래와 같이 문제가 발생할 수 있는데요,
주 생성자가 아닌 프로퍼티의 값은 다름에도 동등한지 판단할 경우 생성자의 값만 비교하여 같다는 결과가 출력됩니다.
data class User(val name: String, val age: Int) {
var isSuperStar: Boolean = false
}
fun main() {
val user1 = User("minsu", 10)
user1.isSuperStar = true
val user2 = User("minsu", 10)
print(user1 == user2) // true
}
따라서 다음과 같이 필요하다면 equals와 hashCode를 재정의하여 모든 프로퍼티를 비교할 수 있도록 해주어야 합니다.
import java.util.*
data class User(val name: String, val age: Int) {
var isSuperStar: Boolean = false
override fun equals(other: Any?): Boolean {
if (other == null || other !is User) return false
return this.name == other.name && this.age == other.age && this.isSuperStar == other.isSuperStar
}
override fun hashCode(): Int {
return Objects.hash(name, age, isSuperStar)
}
}
fun main() {
val user1 = User("minsu", 10)
user1.isSuperStar = true
val user2 = User("minsu", 10)
print(user1 == user2) // false
}
Data class 정의와 쓰임 관련하여 다시 한번 생각해 볼 수 있었으며, 해당 클래스를 언제 사용하는 것이 좋을지 고민해 볼 수 있었습니다.