Kotlin

[Effective Kotlin] 45 - 불필요한 객체 생성을 피하라

매운돌 2023. 12. 4. 03:54

객체 생성은 언제나 비용이 들어갑니다. 따라서 불필요한 객체 생성을 피하는 것이 최적화의 관점에서 좋습니다.

예를 들어 JVM에서는 하나의 가상 머신에서 동일한 문자열을 처리하는 코드가 여러 개 있다면, 기존의 문자열을 재사용합니다.

val str1 = "Lorem ipsum dolor sit amet"
val str2 = "Lorem ipsum dolor sit amet"
print(str1 == str2) // true
print(str1 === str2) // true


val i1: Int? = 1
val i2: Int? = 1
print(i1 == i2) // true
print(i1 === i2) // true - i2f를 캐시로 부터 읽오 들이기 때문입니다.


val j1: Int? = 1234
val j2: Int? = 1234
print(j1 == j2) // true
print(j1 === j2) // false - 기본적으로 Int는 -128 ~ 128 범위를 캐시해 둡니다.

 

객체 생성 비용은 항상 클까?

어떤 객체를 Wrap하면, 크게 3 가지 비용이 발생합니다.

  1. 객체는 더 많은 용량을 차지 합니다.
    현대 64비트 JDK에서 객체는 8바이트의 배수만큼 공간을 차지합니다.
  2. 요소가 캡슐화되어 있다면, 접근에 추가적인 함수 호출이 필요합니다.
    (함수를 사용하는 처리는 굉장히 빠르므로 마찬가지로 큰 비용이 발생하지는 않지만, 수 많은 객체를 처리하면 비용이 커집니다.)
  3. 객체는 생성되고, 메모리 영역에 할당되고, 이에 대한 레퍼런스 등의 작업이 필요합니다.
    따라서 객체를 재사용하면 이 비용을 제거할 수 있습니다.
class A
private val a = A()

// 벤치마크 결과: 2.698 ns/op
fun accessA(blackhole: Blackhole) {
	blackhole.consume(a)
}

// 벤치마크 결과: 3.814 ns/op
fun createA(blackhole: Blackhole) {
	blackhole.consume(A())
}

// 벤치마크 결과: 3828.540 ns/op
fun createListAccessA(blackhole: Blackhole) {
	blackhole.consume(List(1000) { a })
}

// 벤치마크 결과: 5322.857 ns/op
fun createListCreateA(blackhole: Blackhole) {
	blackhole.consume(List(1000) { A() })
}

 

개체 선언

매 순간 객체를 생성하지 않고, 객체를 재사용하는 간단한 방법은 객체 선언(싱글톤)을 사용하는 것입니다.

sealed class LinkedList<T>

class Node<out T>(
	val head: T,
    val tail: LinkedList<T>
): LinkedList<T>()

object Empty : LinkedList<Nothing>()

val list: LinkedList<Int> = Node(1, Node(2, Node(3, Empty)))

위의 코드를 보면 Empty클래스는 하나만 생성되고 다른 모든 곳에서 활용될 수 있습니다. 그리고 Nothing은 모든 타입의 서브타입이기 때문에, 빈 리스트가 다른 타입의 서브타입이어야 하는 문제를 해결 할 수 있습니다.

 

캐시를 활용하는 팩토리 함수

일반적으로 객체는 생성자를 사용해서 만듭니다. 하지만 팩토리 메서드를 사용해서 만드는 경우도 있습니다. 왜냐하면 팩토리 함수는 캐시를 가질 수 있기 때문입니다. 따라서 팩토리 함수는 상황에 따라서 항상 같은 객체를 리턴하게 만들 수도 있습니다.

실제 stdlib의 emptyList가 이를 활용하여 구현되어 있습니다.

fun <T> List<T> emptyList() {
	return EMPTY_LIST
}

 

parameterized 팩토리 메서드도 캐싱을 활용할 수 있습니다. 예를 들어 객체를 다음과 같이 map에 저장해 두는 방식으로 구현할 수 있습니다.

private val connections = 
	mutableMapOf<String, Connection>()

fun getConnection(host: String) =
	connections.getOrPut(host) { createConnection(host) }

위와 같은 방식을 메모이제이션(memoization)이라고 부릅니다.

다만 큰 단점으로 캐시를 위해 Map에 저장하므로, 더 많은 메모리를 사용하게 됩니다.

참고로, 메모리가 필요할 때 가비지 컬렉터가 자동으로 메모리를 해제해 주는 SoftReference를 (WeakReference가 아닙니다.) 사용하면 좋습니다.

WeakReference는 가비지 컬렉터가 값을 정리 하는것을 막지 않습니다. 따라서 다른 레퍼런스(변수)가 이를 사용하지 않으면 곧바로 제거됩니다.

SoftReference는 가비지 컬렉터가 값을 정리할 수도 있고, 정리하지 않을 수도 있습니다. 일반적인 JVM 구현의 경우, 메모리가 부족해서 추가로 필요한 경우에만 정리합니다.

따라서 Cache를 만들 때는 SoftReference를 사용하는 것이 좋습니다.

 

캐시는 언제나 메모리와 성능의 트레이드 오프가 발생하므로, 캐시를 잘 설계하는 것은 쉽지 않습니다. 따라서 여러 가지 상황을 잘 고려해서 현명하게 사용해야 합니다.

 

무거운 객체를 외부 스코프로 보내기

성능을 위한 굉장히 유용한 트릭으로, 무거운 객체를 외부 스코프로 보내는 방법이 있습니다. 컬렉션 처리에서 이루어지는 무거운 연산은 컬렉션 처리 함수 내부에서 외부로 빼는 것이 좋습니다. 예를 들어 Iterable 내부에 '최댓값의 수를 세는 확장 함수'를 만드는 경우를 생각해 보면

fun <T: Comparable<T>> Iterable<T>.countMax(): Int = 
	count { it == this.max() }

앞 코드를 더 수정하면 다음과 같이 만들 수 있습니다.
(최댓값을 나타내는 max를 countMax 함수의 레벨로 옮겼습니다.)

fun <T: Comparable<T>> Iterable<T>.countMax(): Int {
    val max = this.max()
    return count { it == max }
}

이렇게 구현하게 되면 처음에 max값을 찾아 두고, 이를 활용해서 수를 세게 됩니다. 따라서 반복 처리 중에 max값을 한 번만 확인하므로 코드의 성능이 좋아집니다.

 

지연 초기화

무거운 클래스를 만들 때는 지연되게 만드는 것이 좋을 때가 있습니다. 예를 들어 A 클래스에 B, C, D라는 무거운 인스턴스가 필요하다고 가정해보면, 클래스를 생성할 때 이를 모두 생성한다면, A 객체를 생성하는 과정이 굉장히 무거워질 것입니다. 따라서 내부에 있는 인스턴스들을 지연 초기화하면, A라는 객체를 생성하는 과정을 가볍게 만들 수 있습니다.

class A {
    val b by lazy { B() }
    val c by lazy { C() }
    val d by lazy { D() }
    
    // ...
}

 

다만 이러한 지연 초기화는 단점도 있습니다. 클래스가 무거운 객체를 가졌지만, 메서드의 호출은 빨라야 하는 경우에는 메서드가 처음 호출될 때 응답시간이 느려지기 때문에 적합하지 않습니다.

 

기본 자료형 사용하기

JVM은 숫자와 문자 등의 기본적인 요소를 나타내기 위한 특별한 기본 내장 자료형을 갖고 있습니다. 이를 기본 자료형(primitives)이라고 부릅니다. 코틀린/JVM 컴파일러는 내부적으로 이러한 기본 자료형을 사용합니다. 다만 다음과 같은 두 가지 상황에서는 기본 자료형을 Wrap한 자료형이 사용됩니다.

1. nullable 타입을 연산할 때 (기본 자료형은 null일 수 없으므로)

2. 타입을 제너릭으로 사용할 때

  Kotlin의 자료형   Java의 자료형
Int int
Int? Integer
List<Int> List<Integer>

 

이를 알면 Wrap한 자료형 대신 기본 자료형을 사용하게 코드를 최적화 할 수 있습니다.

참고로 이러한 최적화는 Kotlin/JVM, 일부 Kotlin/Native 버전에서만 의미가 있으며, Kotlin/JS에서는 아무러 의미가 없습니다. 또한 숫자 작업이 여러 번 반복될 떄만 의미가 있습니다.

fun Iterable<Int>.maxOrNull(): Int? {
    var max: Int? = null
    for (i in this) {
    	max = if(i > (max ?: Int.MIN_VALUE)) i else max
    }
    return max
}

위 구현에는 두 가지 심각한 단점이 있습니다.

1. 각각의 단계에서 엘비스(Elvis)연산자를 사용해야 합니다.

2. nullable 값을 사용했기 때문에, JVM 내부에서 int가 아니라 Integer로 연산이 일어납니다.

 

이 두 가지 문제를 해결하려면, 다음과 같이 반복문을 사용해서 구현해야 합니다.

fun Iterable<Int>.maxOrNull(): Int? {
    val iterator = Iterator()
    if (!iterator.hasNext()) return null
    var max: Int = iterator.next()
    while (iterator.hasNext()) {
    	val e = iterator.next()
        if (max < e) max = e
    }
    return max
}

필자의 컴퓨터에서는 위 두개의 코드 컬렉션에 100 ~ 1000만 개의 요소를 넣고 함수를 실행하면 2배(289ms vs 518ms)의 성능차이를 확인할 수 있었다고 합니다. 

(하지만 위 예시는 차이를 확인하기 위해서 극단적으로 보여준 예시입니다.)

따라서 라이브러리와 같은 성능이 중요한 부분에서 활용할 수 있습니다.

정리

이번 절에서는 객체를 생성할 떄 발생할 수 있는 문제를 피하는 방법에 대해서 살펴봤습니다. 몇 가지(무건운 객체를 외부 스코프로 보내기)는 코드의 가독성을 향상시켜 주는 장점도 있으므로, 적극적으로 사용하는 것이 좋습니다.