Java의 Record와 Kotlin의 Data Class

라모스·2023년 10월 17일
post-thumbnail

유사하지만 다른 Java의 Record와 Kotlin의 Data Class를 비교 분석해보자.

Java Record

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를 상속한다.
    • record class는 결국 상속이 불가하다.
  • name, age field 모두 private final로 선언된다.
  • toString(), hashCode(), equals() 메소드가 생성되어 있다. → 컴파일러에 의해 컴파일 타임에 추가됨.
  • getter의 네이밍이 getFieldName()이 아닌 fieldName()으로 생성되어 있다.

Kotlin data class

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() 메소드가 생성되어 있다.
  • getter는 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")
}

차이점

  • Java Record엔 copy()가 없지만, Kotlin Data class엔 존재한다.
    • 원칙적으로 모든 것이 final인 Record의 특성상 copy()는 굳이 지원하지 않는다.
  • Java Record의 모든 변수들은 묵시적으로 final로 선언되지만, Kotlin Data class의 변수들은 var 또는 val로 사용할 수 있다.
  • Java Record는 static 변수만 정의할 수 있지만, Kotlin Data class엔 생성자가 아닌 가변 변수를 정의할 수 있다.
  • Java Record는 상속할 수 없지만, Kotlin Data class는 Data class가 아닌 클래스로부터 상속받을 수 있다.
    • 다만, 다른 클래스를 상속받은 Data class의 경우 권장되는 방식은 아니다.

Record 자체가 애초에 abstract class라서 이를 상속받아 구현된 Record class들은 다른 클래스를 상속받을 수 없다.

여러 차이점들을 비교해보면, Java의 Record는 Kotlin의 Data class에 비해 불변성에 초점을 좀 더 줬지 싶다.

References

profile
블로그 이전 → https://ramos-log.tistory.com/

0개의 댓글