
유사하지만 다른 Java의 Record와 Kotlin의 Data Class를 비교 분석해보자.
Java의 Record는 JDK 14에서 Preview 기능으로 등장했고 JDK 16에서 정식 기능에 포함되었다.
Java에선 그동안 Lombok을 사용해 Data class의 역할을 하는 DTO 클래스들을 사용해왔다. record가 등장하면서 외부 라이브러리 없이도 간단한 DTO 클래스들을 만들 수 있게 된 셈이다.
예제를 다음과 같이 작성해보자.
package me.ramos;
public record Person (String name, int age) {}

빌드 후 생성된 Person.class 파일을 bytecode로 변환해보면 아래와 같이 나온다.
// class version 62.0 (62)
// RECORD
// access flags 0x10031
public final class me/ramos/Person extends java/lang/Record {
// compiled from: Person.java
// access flags 0x19
public final static INNERCLASS java/lang/invoke/MethodHandles$Lookup java/lang/invoke/MethodHandles Lookup
RECORDCOMPONENT Ljava/lang/String; name
RECORDCOMPONENT I age
// access flags 0x12
private final Ljava/lang/String; name
// access flags 0x12
private final I age
// access flags 0x1
public <init>(Ljava/lang/String;I)V
// parameter name
// parameter age
L0
LINENUMBER 3 L0
ALOAD 0
INVOKESPECIAL java/lang/Record.<init> ()V
ALOAD 0
ALOAD 1
PUTFIELD me/ramos/Person.name : Ljava/lang/String;
ALOAD 0
ILOAD 2
PUTFIELD me/ramos/Person.age : I
RETURN
L1
LOCALVARIABLE this Lme/ramos/Person; L0 L1 0
LOCALVARIABLE name Ljava/lang/String; L0 L1 1
LOCALVARIABLE age I L0 L1 2
MAXSTACK = 2
MAXLOCALS = 3
// access flags 0x11
public final toString()Ljava/lang/String;
L0
LINENUMBER 3 L0
ALOAD 0
INVOKEDYNAMIC toString(Lme/ramos/Person;)Ljava/lang/String; [
// handle kind 0x6 : INVOKESTATIC
java/lang/runtime/ObjectMethods.bootstrap(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/TypeDescriptor;Ljava/lang/Class;Ljava/lang/String;[Ljava/lang/invoke/MethodHandle;)Ljava/lang/Object;
// arguments:
me.ramos.Person.class,
"name;age",
// handle kind 0x1 : GETFIELD
me/ramos/Person.name(Ljava/lang/String;),
// handle kind 0x1 : GETFIELD
me/ramos/Person.age(I)
]
ARETURN
L1
LOCALVARIABLE this Lme/ramos/Person; L0 L1 0
MAXSTACK = 1
MAXLOCALS = 1
// access flags 0x11
public final hashCode()I
L0
LINENUMBER 3 L0
ALOAD 0
INVOKEDYNAMIC hashCode(Lme/ramos/Person;)I [
// handle kind 0x6 : INVOKESTATIC
java/lang/runtime/ObjectMethods.bootstrap(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/TypeDescriptor;Ljava/lang/Class;Ljava/lang/String;[Ljava/lang/invoke/MethodHandle;)Ljava/lang/Object;
// arguments:
me.ramos.Person.class,
"name;age",
// handle kind 0x1 : GETFIELD
me/ramos/Person.name(Ljava/lang/String;),
// handle kind 0x1 : GETFIELD
me/ramos/Person.age(I)
]
IRETURN
L1
LOCALVARIABLE this Lme/ramos/Person; L0 L1 0
MAXSTACK = 1
MAXLOCALS = 1
// access flags 0x11
public final equals(Ljava/lang/Object;)Z
L0
LINENUMBER 3 L0
ALOAD 0
ALOAD 1
INVOKEDYNAMIC equals(Lme/ramos/Person;Ljava/lang/Object;)Z [
// handle kind 0x6 : INVOKESTATIC
java/lang/runtime/ObjectMethods.bootstrap(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/TypeDescriptor;Ljava/lang/Class;Ljava/lang/String;[Ljava/lang/invoke/MethodHandle;)Ljava/lang/Object;
// arguments:
me.ramos.Person.class,
"name;age",
// handle kind 0x1 : GETFIELD
me/ramos/Person.name(Ljava/lang/String;),
// handle kind 0x1 : GETFIELD
me/ramos/Person.age(I)
]
IRETURN
L1
LOCALVARIABLE this Lme/ramos/Person; L0 L1 0
LOCALVARIABLE o Ljava/lang/Object; L0 L1 1
MAXSTACK = 2
MAXLOCALS = 2
// access flags 0x1
public name()Ljava/lang/String;
L0
LINENUMBER 3 L0
ALOAD 0
GETFIELD me/ramos/Person.name : Ljava/lang/String;
ARETURN
L1
LOCALVARIABLE this Lme/ramos/Person; L0 L1 0
MAXSTACK = 1
MAXLOCALS = 1
// access flags 0x1
public age()I
L0
LINENUMBER 3 L0
ALOAD 0
GETFIELD me/ramos/Person.age : I
IRETURN
L1
LOCALVARIABLE this Lme/ramos/Person; L0 L1 0
MAXSTACK = 1
MAXLOCALS = 1
}
bytecode를 들여다보면 Java의 Record는 다음과 같은 특징이 있다.
final class가 되며 java.lang.Record를 상속한다.name, age field 모두 private final로 선언된다.toString(), hashCode(), equals() 메소드가 생성되어 있다. → 컴파일러에 의해 컴파일 타임에 추가됨.getFieldName()이 아닌 fieldName()으로 생성되어 있다.Kotlin의 Data Class는 데이터 저장 목적으로 만든 클래스를 위한 문법이다.
toString(), hashCode(), equals() 등의 메소드를 자동으로 구현해주기 때문에 간단하게 DTO 클래스를 만들 때 자주 사용하게 된다.
예제를 다음과 같이 작성해보자.
package me.ramos
data class User(val name: String, val age: Int)

빌드 후 생성된 User.class 파일을 bytecode로 변환해보면 아래와 같이 나온다.
// class version 52.0 (52)
// access flags 0x31
public final class me/ramos/User {
// compiled from: User.kt
@Lkotlin/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\u0000\n\u0002\u0010\u0008\n\u0002\u0008\u0009\n\u0002\u0010\u000b\n\u0002\u0008\u0004\u0008\u0086\u0008\u0018\u00002\u00020\u0001B\u0015\u0012\u0006\u0010\u0002\u001a\u00020\u0003\u0012\u0006\u0010\u0004\u001a\u00020\u0005\u00a2\u0006\u0002\u0010\u0006J\u0009\u0010\u000b\u001a\u00020\u0003H\u00c6\u0003J\u0009\u0010\u000c\u001a\u00020\u0005H\u00c6\u0003J\u001d\u0010\r\u001a\u00020\u00002\u0008\u0008\u0002\u0010\u0002\u001a\u00020\u00032\u0008\u0008\u0002\u0010\u0004\u001a\u00020\u0005H\u00c6\u0001J\u0013\u0010\u000e\u001a\u00020\u000f2\u0008\u0010\u0010\u001a\u0004\u0018\u00010\u0001H\u00d6\u0003J\u0009\u0010\u0011\u001a\u00020\u0005H\u00d6\u0001J\u0009\u0010\u0012\u001a\u00020\u0003H\u00d6\u0001R\u0011\u0010\u0004\u001a\u00020\u0005\u00a2\u0006\u0008\n\u0000\u001a\u0004\u0008\u0007\u0010\u0008R\u0011\u0010\u0002\u001a\u00020\u0003\u00a2\u0006\u0008\n\u0000\u001a\u0004\u0008\u0009\u0010\n\u00a8\u0006\u0013"}, d2={"Lme/ramos/User;", "", "name", "", "age", "", "(Ljava/lang/String;I)V", "getAge", "()I", "getName", "()Ljava/lang/String;", "component1", "component2", "copy", "equals", "", "other", "hashCode", "toString", "kotlin-playground"})
// access flags 0x12
private final Ljava/lang/String; name
@Lorg/jetbrains/annotations/NotNull;() // invisible
// access flags 0x12
private final I age
// access flags 0x1
public <init>(Ljava/lang/String;I)V
// annotable parameter count: 2 (invisible)
@Lorg/jetbrains/annotations/NotNull;() // invisible, parameter 0
L0
ALOAD 1
LDC "name"
INVOKESTATIC kotlin/jvm/internal/Intrinsics.checkNotNullParameter (Ljava/lang/Object;Ljava/lang/String;)V
L1
LINENUMBER 3 L1
ALOAD 0
INVOKESPECIAL java/lang/Object.<init> ()V
ALOAD 0
ALOAD 1
PUTFIELD me/ramos/User.name : Ljava/lang/String;
ALOAD 0
ILOAD 2
PUTFIELD me/ramos/User.age : I
RETURN
L2
LOCALVARIABLE this Lme/ramos/User; L0 L2 0
LOCALVARIABLE name Ljava/lang/String; L0 L2 1
LOCALVARIABLE age I L0 L2 2
MAXSTACK = 2
MAXLOCALS = 3
// access flags 0x11
public final getName()Ljava/lang/String;
@Lorg/jetbrains/annotations/NotNull;() // invisible
L0
LINENUMBER 3 L0
ALOAD 0
GETFIELD me/ramos/User.name : Ljava/lang/String;
ARETURN
L1
LOCALVARIABLE this Lme/ramos/User; L0 L1 0
MAXSTACK = 1
MAXLOCALS = 1
// access flags 0x11
public final getAge()I
L0
LINENUMBER 3 L0
ALOAD 0
GETFIELD me/ramos/User.age : I
IRETURN
L1
LOCALVARIABLE this Lme/ramos/User; L0 L1 0
MAXSTACK = 1
MAXLOCALS = 1
// access flags 0x11
public final component1()Ljava/lang/String;
@Lorg/jetbrains/annotations/NotNull;() // invisible
L0
ALOAD 0
GETFIELD me/ramos/User.name : Ljava/lang/String;
ARETURN
L1
LOCALVARIABLE this Lme/ramos/User; L0 L1 0
MAXSTACK = 1
MAXLOCALS = 1
// access flags 0x11
public final component2()I
L0
ALOAD 0
GETFIELD me/ramos/User.age : I
IRETURN
L1
LOCALVARIABLE this Lme/ramos/User; L0 L1 0
MAXSTACK = 1
MAXLOCALS = 1
// access flags 0x11
public final copy(Ljava/lang/String;I)Lme/ramos/User;
@Lorg/jetbrains/annotations/NotNull;() // invisible
// annotable parameter count: 2 (invisible)
@Lorg/jetbrains/annotations/NotNull;() // invisible, parameter 0
L0
ALOAD 1
LDC "name"
INVOKESTATIC kotlin/jvm/internal/Intrinsics.checkNotNullParameter (Ljava/lang/Object;Ljava/lang/String;)V
NEW me/ramos/User
DUP
ALOAD 1
ILOAD 2
INVOKESPECIAL me/ramos/User.<init> (Ljava/lang/String;I)V
ARETURN
L1
LOCALVARIABLE this Lme/ramos/User; L0 L1 0
LOCALVARIABLE name Ljava/lang/String; L0 L1 1
LOCALVARIABLE age I L0 L1 2
MAXSTACK = 4
MAXLOCALS = 3
// access flags 0x1009
public static synthetic copy$default(Lme/ramos/User;Ljava/lang/String;IILjava/lang/Object;)Lme/ramos/User;
ILOAD 3
ICONST_1
IAND
IFEQ L0
ALOAD 0
GETFIELD me/ramos/User.name : Ljava/lang/String;
ASTORE 1
L0
FRAME SAME
ILOAD 3
ICONST_2
IAND
IFEQ L1
ALOAD 0
GETFIELD me/ramos/User.age : I
ISTORE 2
L1
FRAME SAME
ALOAD 0
ALOAD 1
ILOAD 2
INVOKEVIRTUAL me/ramos/User.copy (Ljava/lang/String;I)Lme/ramos/User;
ARETURN
MAXSTACK = 3
MAXLOCALS = 5
// access flags 0x1
public toString()Ljava/lang/String;
@Lorg/jetbrains/annotations/NotNull;() // invisible
L0
NEW java/lang/StringBuilder
DUP
INVOKESPECIAL java/lang/StringBuilder.<init> ()V
LDC "User(name="
INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
ALOAD 0
GETFIELD me/ramos/User.name : Ljava/lang/String;
INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
LDC ", age="
INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
ALOAD 0
GETFIELD me/ramos/User.age : I
INVOKEVIRTUAL java/lang/StringBuilder.append (I)Ljava/lang/StringBuilder;
BIPUSH 41
INVOKEVIRTUAL java/lang/StringBuilder.append (C)Ljava/lang/StringBuilder;
INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
ARETURN
L1
LOCALVARIABLE this Lme/ramos/User; L0 L1 0
MAXSTACK = 2
MAXLOCALS = 1
// access flags 0x1
public hashCode()I
L0
ALOAD 0
GETFIELD me/ramos/User.name : Ljava/lang/String;
INVOKEVIRTUAL java/lang/String.hashCode ()I
ISTORE 1
L1
ILOAD 1
BIPUSH 31
IMUL
ALOAD 0
GETFIELD me/ramos/User.age : I
INVOKESTATIC java/lang/Integer.hashCode (I)I
IADD
ISTORE 1
ILOAD 1
IRETURN
L2
LOCALVARIABLE result I L1 L2 1
LOCALVARIABLE this Lme/ramos/User; L0 L2 0
MAXSTACK = 2
MAXLOCALS = 2
// access flags 0x1
public equals(Ljava/lang/Object;)Z
// annotable parameter count: 1 (invisible)
@Lorg/jetbrains/annotations/Nullable;() // invisible, parameter 0
L0
ALOAD 0
ALOAD 1
IF_ACMPNE L1
ICONST_1
IRETURN
L1
FRAME SAME
ALOAD 1
INSTANCEOF me/ramos/User
IFNE L2
ICONST_0
IRETURN
L2
FRAME SAME
ALOAD 1
CHECKCAST me/ramos/User
ASTORE 2
ALOAD 0
GETFIELD me/ramos/User.name : Ljava/lang/String;
ALOAD 2
GETFIELD me/ramos/User.name : Ljava/lang/String;
INVOKESTATIC kotlin/jvm/internal/Intrinsics.areEqual (Ljava/lang/Object;Ljava/lang/Object;)Z
IFNE L3
ICONST_0
IRETURN
L3
FRAME APPEND [me/ramos/User]
ALOAD 0
GETFIELD me/ramos/User.age : I
ALOAD 2
GETFIELD me/ramos/User.age : I
IF_ICMPEQ L4
ICONST_0
IRETURN
L4
FRAME SAME
ICONST_1
IRETURN
L5
LOCALVARIABLE this Lme/ramos/User; L0 L5 0
LOCALVARIABLE other Ljava/lang/Object; L0 L5 1
MAXSTACK = 2
MAXLOCALS = 3
}
마찬가지로 bytecode를 들여다 보면 Kotlin의 data class는 다음과 같은 특징이 있다.
final class가 된다.name, age field 모두 private final로 선언 된다.toString(), hashCode(), equals() 메소드가 생성되어 있다. getFieldName()의 형식으로 생성되어 있다.componentN()과 같은 메서드가 생성되는데, 이는 destructuring을 위해 컴파일러가 만들어주는 메서드다.Kotlin으로 단순히 두 클래스를 출력해보자.
package me.ramos
fun main(args: Array<String>) {
val user = User("Kotlin", 28)
val person = Person("Java Record", 28)
println("Kotlin data class: $user")
println("Java record: $person")
}

copy()가 없지만, Kotlin Data class엔 존재한다.final인 Record의 특성상 copy()는 굳이 지원하지 않는다.final로 선언되지만, Kotlin Data class의 변수들은 var 또는 val로 사용할 수 있다.
Record 자체가 애초에 abstract class라서 이를 상속받아 구현된 Record class들은 다른 클래스를 상속받을 수 없다.
여러 차이점들을 비교해보면, Java의 Record는 Kotlin의 Data class에 비해 불변성에 초점을 좀 더 줬지 싶다.