본문 바로가기
💻 개발 이야기/Java, Kotlin

[Kotlin] value class를 활용해서 "원시 값과 문자열을 포장하라" 성능 최적화하기

by Jinseong Hwang 2023. 12. 10.


안녕하세요!

이번 글에서는 "모든 원시 값과 문자열을 포장하라" 원칙에 대해 알아보고, Kotlin으로 성능 최적화 하는 방법에 대해 알아보겠습니다.
 

요약

  • 객체지향 생활체조의 "모든 원시 값과 문자열을 포장하라" 원칙을 Kotlin으로 구현할 때 value class를 '잘' 활용하면 불필요한 오버헤드를 줄일 수 있습니다.
  • 생성 성능 측정 결과, 유의미한 차이는 없었습니다.
  • 참조 및 연산 성능 측정 결과, value class가 일반 클래스보다 약 32% 느렸습니다.
  • JVM Heap dump를 분석한 결과, value class가 일반 클래스에 비해 약 58%의 공간만 차지했습니다. 

 

이 글에서 얻을 수 있는 것

  • 객체지향 생활체조의 "모든 원시 값과 문자열을 포장하라" 원칙을 이해할 수 있다.
  • Kotlin의 (Inline) value class를 성능 최적화 목적으로 사용할 수 있다.
  • JMH를 활용한 마이크로 벤치마킹 사례를 확인할 수 있다.
  • JMX를 활용해서 Heap dump를 분석하는 방법을 알 수 있다.

 

개요

객체지향 프로그래밍을 하다 보면 더 나은 코드를 작성하기 위한 많은 원칙이 있는 것을 보셨을 것입니다. 그 원칙들을 따르면 유지보수가 쉬워지고 가독성이 좋아지며 효율적인 코드가 됩니다. 

 

유명한 원칙 중 소트웍스 앤솔로지의 "객체지향 생활체조"라는 것이 있습니다. 객체지향 생활체조에는 "모든 원시 값과 문자열을 포장하라"라는 규칙이 있는데 이는 단순하면서도 코드 품질에 큰 영향을 미칩니다. 특히 이는 Kotlin의 value class와 결합되면 더 읽기 좋을뿐더러 성능에도 상당한 이점이 있습니다. 하나씩 알아봅시다.

 

 

"모든 원시 값과 문자열을 포장하라" 원칙이란?

코드에서 원시 값과 문자열을 포장했을 때 얻을 수 있는 이점으로 다음 세 가지가 있습니다.

 

  1. 타입 안정성
  2. 코드 가독성
  3. 캡슐화

하나씩 알아봅시다!

 

[1] 타입 안정성

프로그램 코드 전반에 걸쳐 원시 값을 용도와 다르게 사용하는 문제를 예방할 수 있습니다.
예를 들어, 좌표를 나타낼 때 아래와 같이 나타낼 수 있습니다.
 

fun createCoordinate(x: Int, y: Int) = Coordinate(x, y)

fun main() {
    val x = 1
    val y = 2

    createCoordinate(x, y) // Good!
    createCoordinate(y, x) // Bad :( But, ...
}

 
x좌표와 y좌표 모두 Int 타입입니다. 따라서 x좌표 값이 필요한 메서드 파라미터에 y좌표 값이 들어가도 컴파일러는 어떠한 문제도 알아낼 수 없습니다. 컴파일러가 보기에는 멀쩡한 코드이기 때문입니다.

 

value class를 사용하면 아래와 같이 수정해서, 파라미터가 뒤바뀐 경우에 컴파일 에러가 발생하도록 의도할 수 있습니다.

@JvmInline value class CoordX(val x: Int)
@JvmInline value class CoordY(val y: Int)

fun createCoordinate(x: CoordX, y: CoordY) = Coordinate(x, y)

fun main() {
    val x = CoordX(1)
    val y = CoordY(2)

    createCoordinate(x, y) // Good!
    createCoordinate(y, x) // COMPILE ERROR
}

 

[2] 코드 가독성

더 읽기 쉬운 코드가 됩니다. 예를 들어, 상품 ID와 카테고리 ID가 모두 UUID로 구성된 String 타입일 때 아래와 같이 구현할 수 있습니다.
 

val productId: String
val categoryId: String

fun getXXXById(id: String)

 
이런 경우에도 `getXXXById()` 메서드 파라미터에 어떤 ID가 들어가야 하는지 파악하기 위해서 추가적인 시간이 필요합니다. 메서드명, 클래스명, 주석, 상속관계 등을 보며 파악할 수 있습니다. 하지만 아래와 같이 구현 가능하다면 불필요한 시간을 아낄 수 있습니다.
 

val productId: ProductId
val categoryId: CategoryId

fun getXXXById(id: ProductId)

 
1번에서 언급했던 타입 안정성도 추가로 챙길 수 있으며, 가독성도 좋아지면서 코드를 이해하는 속도가 빨라지는 장점이 있습니다.
 

[3] 캡슐화

포장된 원시 값과 문자열에 필요한 동작을 메서드로서 구현하면 코드를 보다 모듈화 하고 유지보수 하기 쉽게 만들 수 있습니다.

 

class Length(
    val meters: Double
) {
    fun toCm(): Double = meters * 100
    fun toKm(): Double = meters / 1000

    companion object {
        fun fromCm(cm: Double): Length = Length(cm / 100)
        fun fromKm(km: Double): Length = Length(km * 1000)
    }
}

 

위 Length 클래스는 meters라는 실수 값 하나를 포장하고 있습니다. Length 객체는 meter 값을 가지고 있지만, 정의된 메서드를 통해 centimeter, kilometer로 변환될 수 있습니다.

 

Kotlin value class란?

https://quickbirdstudios.com/blog/kotlin-value-classes/

Project Valhalla를 진행하며 Kotlin 1.5 버전부터 Stable 기능으로 value class가 포함됐습니다. value class는 처음부터 단일 값을 캡슐화하도록 설계된 특수 클래스입니다. 일반적인 클래스와 달리 캡슐화 관련 오버헤드가 발생하지 않습니다. 따라서 성능에 민감한 대용량 시스템이나 최적화가 필요한 애플리케이션에서 "모든 원시 값과 문자열을 포장하라" 원칙을 지켜야 할 때 value class를 사용하는 것이 좋습니다.
 

 

Kotlin value class 사용해보기

위 "캡슐화" 예시에서 사용한 Length 클래스를 value class로 변환해 봅시다.

`class Length` 에서 `@JvmInline value class Length` 로 바꿔주면 끝납니다!

 

@JvmInline
value class Length(
    val meters: Double
) {
    fun toCm(): Double = meters * 100
    fun toKm(): Double = meters / 1000

    companion object {
        fun fromCm(cm: Double): Length = Length(cm / 100)
        fun fromKm(km: Double): Length = Length(km * 1000)
    }
}

 
value class인 Length를 활용해서 길이를 더하는 메서드를 작성해 보면 다음과 같습니다.
 

fun main() {
    val totalLength = addLengths(Length(12.34), Length(42.195))
    println(totalLength.meters) // 54.535
}

fun addLengths(length1: Length, length2: Length): Length {
    return Length(length1.meters + length2.meters)
}

 
 

Kotlin value class 디컴파일

그렇다면 Kotlin value class를 디컴파일 해서 Java 코드로 보면 어떻게 동작할까요?

위 Length를 더하는 메서드를 디컴파일 해보면 다음과 같습니다.

 

public static final void main() {
  double totalLength = addLengths-4yrUNvM(Length.constructor-impl(12.34), Length.constructor-impl(42.195));
  System.out.println(totalLength);
}

 
계산 결과를 저장한 변수인 `totalLength`의 타입이 double이며,

`addLength()`를 호출하는 부분에서도 Length 객체를 생성하지 않고 있음을 확인할 수 있습니다.

 

Kotlin value class의 특징

사용 방법은 일반적인 클래스와 별 다른 바 없지만, 특징은 조금 다릅니다.

 

  1. 반드시 @JvmInline 어노테이션과 함께 사용해야 합니다.
  2. 자동 생성 메서드는 equals(), toString(), hashCode()가 전부입니다.
  3. 불변(val) 프로퍼티 1개만 가질 수 있습니다.
  4. 컴파일 타임에 "===" 비교를 허용하지 않습니다.

하나씩 알아봅시다!

 

[1] 반드시 @JvmInline 어노테이션과 함께 사용해야 합니다.

Kotlin에서 value class가 value class 답게 동작하기 위해서는 `@JvmInline` 어노테이션이 필수로 붙어야 합니다. 컴파일 과정에서 컴파일러는 @JvmInline 어노테이션을 보고 value class의 인스턴스가 런타임에 원시 값 혹은 문자열로 저장되어야 함을 알 수 있습니다.
 

@JvmInline 어노테이션을 붙이지 않으면 컴파일 에러가 발생한다.

 

[2] 자동 생성 메서드는 equals(), toString(), hashCode()가 전부입니다.

Kotlin data class는 `equals()`, `toString()`, `hashCode()` 뿐만 아니라 `copy()`, `componentN()` 메서드까지 모두 자동으로 만들어 줍니다. 하지만 value class는 `equals()`, `toString()`, `hashCode()` 만 만들어 줍니다.

 

Java로 디컴파일을 해보면 이 부분은 쉽게 알 수 있습니다. 아래는 디컴파일 한 Java 코드의 일부입니다.
(최대한 남기려 했으나 너무 길어서 설명에 필요 없는 부분은 잘라냈습니다.)
 

// Length.java
import kotlin.Metadata;
import kotlin.jvm.JvmInline;
import kotlin.jvm.internal.DefaultConstructorMarker;
import org.jetbrains.annotations.NotNull;

@JvmInline
@Metadata(...)
public final class Length {
   private final double meters;
   @NotNull
   public static final Companion Companion = new Companion((DefaultConstructorMarker)null);

   public final double getMeters() {
      return this.meters;
   }

   // $FF: synthetic method
   private Length(double meters) {
      this.meters = meters;
   }

   public static final double toCm_impl/* $FF was: toCm-impl*/(double $this) {
      return $this * (double)100;
   }

   public static final double toKm_impl/* $FF was: toKm-impl*/(double $this) {
      return $this / (double)1000;
   }
   
   // ...
   
   public String toString() {
      return toString-impl(this.meters);
   }

   public int hashCode() {
      return hashCode-impl(this.meters);
   }

   public boolean equals(Object var1) {
      return equals-impl(this.meters, var1);
   }

   @Metadata(...)
   public static final class Companion {
      public final double fromCm_n18KooI/* $FF was: fromCm-n18KooI*/(double cm) {
         return Length.constructor-impl(cm / (double)100);
      }

      public final double fromKm_n18KooI/* $FF was: fromKm-n18KooI*/(double km) {
         return Length.constructor-impl(km * (double)1000);
      }
      
      // ...
   }
}

 
실제로 확인해 보니 자동 생성 메서드가 equals(), toString(), hashCode()로 다소 제한적입니다. 더 많은 기능을 제공하지 않도록 Kotlin에서 의도한 것 같습니다.
 
그리고 또 하나 눈에 들어오는 것이 있습니다.

Companion 블록에 만든 `fromCm(), `fromKm()` 메서드의 이름이 예상과 다릅니다. 뒤에 이상한 문자열이 붙어 있습니다. 이 이유는 컴파일 이후 value class의 값과 메서드가 벗겨지면서 상위 클래스로 스며들어야 하는데, 그때 메서드명이 중복되는 문제가 발생할 수도 있어서 난수를 붙여줘서 문제를 해결합니다. 이 과정을 정확히는 Mangling 과정이라고 부릅니다. 메서드명 뒤에 /-[a-zA-Z_]{7}/ 을 만족하는 랜덤 문자열을 덧붙여줍니다.
 

[3] 불변(val) 프로퍼티 1개만 가질 수 있습니다.

 val 은 본질적으로 value의 약자입니다. 객체가 아닌 "값"으로 나타내기 위해서 하나의 프로퍼티만 존재해야 하고, 최적화를 위해 불변 값이어야 합니다.
 

[4] 컴파일 타임에 "===" 비교를 허용하지 않습니다.

"===" 비교는 레퍼런스 비교입니다. 컴파일 타임에 객체 타입이 아닌 원시 값으로 변경되기 때문에 근본적으로 "===" 비교를 할 수 없습니다.
 
 

얼마나 개선이 될까? 테스트 해보자

글의 시작 부분에서 "value class를 사용하면 성능 향상의 이점이 있다"라고 언급했는데 실제로 얼마나 이점이 있는지 확인해 보겠습니다.

 

  1. 객체 생성 속도 테스트
  2. 값 참조 및 연산 속도 테스트
  3. 힙 메모리 테스트 (1, 2)
  4. 힙 메모리 테스트 방법

위 순서대로 테스트를 진행할 계획입니다. 하나씩 알아봅시다!

 

[1] 객체 생성 속도 테스트

우선 일반 클래스인 RegularClass를 만들고, value class인 ValueClass를 만들었습니다.

class RegularClass(val number: Int)

@JvmInline
value class ValueClass(val number: Int)

 

각 타입의 객체를 1000만 개씩 만들어서 리스트에 넣는데 걸리는 시간을 측정해 봤습니다.

 

JMH(Java Microbenchmark Harness)를 활용해서 벤치마크 코드를 작성해 봤습니다.

 

@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations = 5, time = 1000, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = 5, time = 1000, timeUnit = TimeUnit.MILLISECONDS)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Fork(2)
open class BenchmarkTests {

    @Benchmark
    fun testCreateRegularClass() {
        val instances = mutableListOf<RegularClass>()
        (1..10_000_000).forEach {
            instances.add(RegularClass(it))
        }
    }
    
    @Benchmark
    fun testCreateValueClass() {
        val instances = mutableListOf<ValueClass>()
        (1..10_000_000).forEach {
            instances.add(ValueClass(it))
        }
    }
}
# result
Benchmark                              Mode  Cnt   Score   Error  Units
BenchmarkTests.testCreateRegularClass  avgt   10  41.254 ± 7.879  ms/op
BenchmarkTests.testCreateValueClass    avgt   10  42.603 ± 6.556  ms/op

 
객체 생성 속도 측면에서는 유의미한 차이가 없다고 보는 것이 맞겠습니다.

 

[2] 값 참조 및 연산 속도 테스트

각 타입의 객체를 1000만 개씩 만들고 number 값을 가져와서 모두 더해봤습니다.

 

@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Fork(2)
open class BenchmarkTests {

    @Benchmark
    fun testSumRegularClass() {
        var sum = 0L
        (1..10_000_000).forEach {
            val obj = RegularClass(it)
            sum += obj.number
        }
    }

    @Benchmark
    fun testSumValueClass() {
        var sum = 0L
        (1..10_000_000).forEach {
            val obj = ValueClass(it)
            sum += obj.number
        }
    }
}
Benchmark                           Mode  Cnt  Score   Error  Units
BenchmarkTests.testSumRegularClass  avgt   10  5.919 ± 0.168  ms/op
BenchmarkTests.testSumValueClass    avgt   10  7.813 ± 0.214  ms/op

 

이 부분은 신기하게도 value class를 사용한 것보다 regular class를 사용한 쪽이 성능이 더 좋게 나왔습니다. value class를 사용했을 때 처리 속도가 약 32% 더 느린 것으로 확인됐습니다. 이 부분은 일반 클래스를 사용할 경우 JVM에서 추가적인 최적화를 진행해 주는 것으로 추측됩니다. 추후 미래의 Kotlin에서 개선되기를 기대합니다.

 

[3] Heap 메모리 테스트 - 1

LengthRegular 객체 1000만 개를 생성하고 리스트에 저장 후 Heap dump를 떠봅시다.

 

`createHeapDump()` 메서드 및 메모리 테스트 방법에 대해서는 아래에서 다시 언급합니다.

 

fun testMemoryRegularClassList() {
    val instances = mutableListOf<LengthRegular>()
    (1..10_000_000).forEach {
        instances.add(LengthRegular(it + 0.0))
    }
    createHeapDump("RegularClass in List")
}

 

LengthRegular 객체 1000만 개가 생성됐고 힙에 생성된 총 바이트는 421,037,016 Byte입니다.

 

이제는 LengthValue 객체 1000만 개를 생성하고 리스트에 저장 후 Heap dump를 떠봅시다.

fun testMemoryValueClassList() {
    val instances = mutableListOf<LengthValue>()
    (1..10_000_000).forEach {
        instances.add(LengthValue(it + 0.0))
    }
    createHeapDump("ValueClass in List")
}

 

마찬가지로 LengthValue 객체가 1000만 개 생성됐고 Heap에 생성된 총 바이트는 419,988,200 Byte입니다.

 

뭔가 이상한데?

분명 Value class는 컴파일 이후에 Primitive type으로 변경되고, 별다른 객체 생성이 되지 않아야 합니다. 하지만 객체가 생성된 모습입니다. 이 문제는 저희가 저장 공간으로 List를 선택했기 때문입니다. List에 Primitive type은 저장할 수 없으며, 저장을 시도했을 때 Auto boxing이 발생합니다. 따라서 모두 객체로 생성이 되고 사용하는 메모리 또한 큰 차이가 없음을 확인할 수 있었습니다.

 

여기서 가볍게 생각하면 "그럼 List 대신 Array를 사용하면 되는 것 아니냐?"라는 궁금증이 생길 수 있습니다. 하지만 이 또한 해결 방법이 아닙니다. Primitive type array를 생성한다고 하면 해당 Array에는 Primitive type 값만 저장될 수 있습니다. 만약 value class에 어떠한 메서드를 가지고 있다고 가정하면, 그 메서드는 함께 저장될 수 없습니다. 따라서 온전히 저장될 수 없음을 의미하죠.

 

미래의 Kotlin에서는 Value class 객체를 온전한 Primitive type으로 받아들이고 Array에도 저장할 수 있는 날이 오기를 기대합니다.

 

[4] Heap 메모리 테스트 - 2

그렇다면 무작정 객체를 많이 만들고 Heap dump를 떠보면 어떨까요? 위 상황과 동일하게 객체 1000만 개를 만들어 보겠습니다.

 

fun testMemoryRegularClassArray() {
    (0..10_000_000).forEach {
        LengthRegular(it + 0.0)
    }
    createHeapDump("RegularClass")
}

 

예상대로 LengthRegular 객체 1000만 개가 생성됐고 힙에 생성된 총 바이트는 11,357,240 Byte입니다.

 

이제는 LengthValue 객체 1000만 개를 생성하고 Heap dump를 떠봅시다.

fun testMemoryValueClassArray() {
    (0..10_000_000).forEach {
        LengthValue(it + 0.0)
    }
    createHeapDump("ValueClass")
}

 

LegularValue 객체가 생성되지 않았고 힙에 생성된 총 바이트는 6,640,696 Byte입니다. 일반 클래스인 LengthRegular를 생성한 것보다 약 58% 공간만 차지하는 것을 확인할 수 있었습니다. 이로써 저희는 Auto Boxing만 잘 고려해 준다면 Value class를 활용했을 때 메모리 측면에서 이점이 있음을 확인했습니다.

 

Heap 메모리 테스트 방법

Heap 메모리는 JMX (Java Management eXtensions)를 활용해서 측정해 봤습니다. jmap, jcmd 등 메모리 측정을 할 수 있는 다양한 도구가 있지만, JMX를 활용하면 코드로써 제가 원하는 시점에 Heap dump를 생성할 수 있기 때문에 편리하다는 장점이 있습니다.

 

JMX로 Heap dump파일 (확장자 .hprof)을 생성하고, 프로파일링 도구는 IntelliJ (v2023.2) 내장 프로파일러를 활용했습니다.

 

JMX를 활용해서 Heap dump를 생성할 때 별도 라이브러리 의존성 추가는 필요 없으며, 아래와 같이 Heap dump 생성하는 메서드를 작성하고, 원하는 시점에 호출하기만 하면 됩니다.

import com.sun.management.HotSpotDiagnosticMXBean
import java.lang.management.ManagementFactory

fun createHeapDump(key: String = "") {
    val path = "{{ YOUR_PATH }}"
    val liveOnly = false
    val server = ManagementFactory.getPlatformMBeanServer()
    try {
        val mxBean = ManagementFactory.newPlatformMXBeanProxy(
            server, "com.sun.management:type=HotSpotDiagnostic", HotSpotDiagnosticMXBean::class.java
        )
        mxBean.dumpHeap("$path/heapdump-$key-${System.currentTimeMillis()}.hprof", liveOnly)
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

 

 

다른 언어에서는?

C/C++

typedef struct Node Node;
struct Node {
    int value;
    Node* next;
};

 
옛날에 C++로 Linked List를 구현할 때 이런 방식으로 코딩을 많이 했었던 기억이 납니다. Kotlin의 value class는 C/C++ 진영에서 굳이 뽑자면 typedef와 일부 유사한 기능입니다.
 

Java

안타깝지만 Java에서는 이 방법을 사용할 수 없다고 합니다. (지금까지 찾은 바로는..)
엄청난 개발 대가처럼 보이는 분이 이렇게 말씀하셨습니다.
https://www.quora.com/What-is-an-alternate-to-typedef-in-Java

So, in C a “typedef” is a way to create (and name) custom data types. For something like a “typedef struct”, that is basically what a “class” is in Java. For a “typedef long int ….” there is no such thing — the only primitives in Java are the defined primitives. And I’m pretty sure there is a “good” reason for that.
In 10 years with Java (post C/C++) I don’t think I’ve missed it (well, maybe once or twice, but not a huge amount).
# DeepL 번역
따라서 C에서 "typedef"는 사용자 정의 데이터 유형을 생성하고 이름을 지정하는 방법입니다. "typedef 구조체"와 같은 것은 기본적으로 Java에서 "클래스"에 해당합니다. "typedef long int ...."의 경우 그런 것은 존재하지 않습니다. Java의 유일한 프리미티브는 정의된 프리미티브뿐입니다. 그리고 저는 거기에 "좋은" 이유가 있다고 확신합니다.
10년 동안 Java(C/C++ 이후)를 사용하면서 한 번도 놓친 적이 없는 것 같습니다(한두 번 정도는 있겠지만 엄청난 양은 아닙니다).

 

 

긴 글 읽어주셔서 감사합니다.

좋은 하루 보내세요~~ 

 

 

References