관리 메뉴

나만을 위한 블로그

[이펙티브 코틀린] 아이템 45. 불필요한 객체 생성을 피하라 본문

책/Effective Kotlin

[이펙티브 코틀린] 아이템 45. 불필요한 객체 생성을 피하라

참깨빵위에참깨빵 2023. 4. 29. 23:05
728x90
반응형

객체 생성은 언제나 비용이 들어간다. 상황에 따라선 굉장히 큰 비용이 들어갈 수 있다. 따라서 불필요한 객체 생성은 피하는 게 최적화 관점에서 좋다. JVM에선 하나의 가상 머신에서 같은 문자열을 처리하는 코드가 여러 개 있다면 기존 문자열을 재사용한다. Integer, Long처럼 박스화한 기본 자료형도 작은 경우엔 재사용된다. 기본적으로 Int는 -128~127 범위를 캐시해 둔다.

nullable 타입은 int 대신 Integer를 사용하게 강제된다. Int를 쓰면 일반적으로 기본 자료형 Int로 컴파일된다. 하지만 nullable하게 만들거나 타입 아규먼트로 쓸 경우 Integer로 컴파일된다. 기본 자료형은 null일 수 없고 타입 아규먼트로도 쓸 수 없기 때문이다.

 

객체 생성 비용은 항상 큰가?

 

어떤 객체를 wrap하면 크게 3가지 비용이 발생한다.

 

  • 객체는 더 많은 용량을 차지한다. 현대 64비트 JDK에서 객체는 8바이트의 배수만큼 공간을 차지한다. 앞부분 12바이트는 헤더로서 반드시 있어야 하므로 최소 크기는 16바이트다. 객체에 대한 레퍼런스도 공간을 차지한다
  • 요소가 캡슐화돼 있다면 접근에 추가적인 함수 호출이 필요하다. 함수를 사용하는 처리는 굉장히 빠르므로 큰 비용이 발생하진 않는다. 하지만 티끌 모아 태산이다
  • 객체는 생성돼야 한다. 객체는 생성되어 메모리 영역에 할당되고 이에 대한 레퍼런스를 만드는 등의 작업이 필요하다. 마찬가지로 적은 비용이지만 모이면 큰 비용이 된다

 

객체를 제거함으로서 이런 비용들을 모두 피할 수 있다. 특히 객체를 재사용하면 1번, 3번 비용을 제거할 수 있다. 이걸 알면 불필요한 객체를 어떻게 제거해야 할지 알 수 있다.

 

객체 선언

 

매 순간 객체를 만들지 않고 재사용하는 간단한 방법은 객체 선언(싱글톤)을 사용하는 것이다. 아래의 링크드 리스트를 구현하는 예시에서 링크드 리스트는 비어 있거나 노드를 가질 수 있다. 노드는 요소를 갖고 다른 노드를 포인팅하는 객체를 의미한다.

 

fun main() {
    val list: LinkedList<Int> = Node(1, Node(2, Node(3, Empty())))
    val list2: LinkedList<String> = Node("A", Node("B", Empty()))
}

sealed class LinkedList<T>

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

class Empty<T>: LinkedList<T>()

 

이 구현의 문제는 리스트를 만들 때마다 Empty 인스턴스를 만들어야 하는 것이다. Empty 인스턴스를 하나만 만들고 다른 리스트들에서 활용할 수 있다면 어떤가? 하지만 이렇게 구현하려면 제네릭 타입 불일치로 문제가 될 수 있다. 빈 리스트는 다른 모든 리스트의 서브타입이 되어야 이 문제를 해결할 수 있다.

이를 해결하려면 Nothing 리스트를 만들어 쓰면 된다. Nothing은 모든 타입의 서브타입이다. 따라서 LinkedList<Nothing>은 리스트가 covariant라면(out 한정자) 모든 링크드 리스트의 서브타입이 된다.

 

fun main() {
    val list: LinkedList<Int> = Node(1, Node(2, Node(3, Empty)))
    val list2: LinkedList<String> = Node("A", Node("B", Empty))
}

sealed class LinkedList<out T>

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

object Empty: LinkedList<Nothing>()

 

이런 트릭은 immutable sealed class를 정의할 때 자주 사용된다. mutable 객체에 사용하면 공유 상태 관리와 관련된 버그를 찾아내기 어려울 수 있어서 좋지 않다. mutable 객체는 캐시하지 않는다는 규칙을 지키는 게 좋다.

 

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

 

일반적으로 객체는 생성자를 써서 만든다. 하지만 팩토리 메서드를 사용해서 만드는 경우도 있다. 팩토리 함수는 캐시를 가질 수 있다. 그래서 팩토리 함수는 항상 같은 객체를 리턴하게 만들 수 있다. 실제로 stdlib의 emptyList는 이걸 활용해서 구현돼 있다.

객체 세트가 있고 그 중에서 하나를 리턴하는 경우, 예를 들어 코루틴의 디폴트 디스패처인 Dispatchers.Default는 쓰레드 풀을 갖고 있으며 어떤 처리를 명령하면 쓰고 있지 않은 쓰레드 하나를 써서 명령을 수행한다. DB도 비슷한 형태로 커넥션 풀을 사용한다. 객체 생성이 무겁거나 동시에 여러 mutable 객체를 써야 하는 경우엔 이처럼 객체 풀을 쓰는 게 좋다.

모든 순수함수는 캐싱을 사용할 수 있다. 이를 메모이제이션이라고 부른다. 아래 함수는 피보나치 수의 정의를 기반으로 메모이제이션을 활용해 피보나치 수를 구하는 함수다.

 

private val FIB_CACHE = mutableMapOf<Int, BigInteger>()

fun fib(n: Int): BigInteger = FIB_CACHE.getOrPut(n) {
    if (n <= 1) BigInteger.ONE else fib(n - 1) + fib(n - 2)
}

 

이렇게 하면 이미 계산된 피보나치 수는 추가적인 계산 없이 바로 구해진다. 재귀 함수로 구현됐지만 반복문을 써서 구현하는 것만큼 효율적이다. 재귀 함수는 처음 쓸 때 피보나치 수를 구하는 오버헤드가 커서 반복문을 사용한 방식보다 시간이 더 오래 걸린다. 하지만 값이 한 번 계산되면 값을 즉시 구한다.

다만 캐시를 위한 Map을 저장해야 하므로 더 많은 메모리를 사용한다. 메모리 문제로 크래시가 생기면 메모리를 해제하면 된다. 메모리가 필요할 때 가비지 컬렉터가 자동으로 메모리를 해제해 주는 SoftReference를 쓰면 더 좋다. WeakReference와 혼동하지 마라.

 

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

 

컬렉션 처리에서 이뤄지는 무거운 연산은 컬렉션 처리 함수 안에서 밖으로 빼는 게 좋다.

 

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

 

위 코드를 수정하면 아래처럼 만들 수 있다.

 

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

 

연산을 밖으로 빼서 값 계산을 추가로 하지 않게 만든다는 것이 당연하게 들릴 수 있지만 사실 많은 사람이 실수하는 부분이다.

 

지연 초기화

 

무거운 클래스를 만들 때 지연되게 만드는 게 좋을 때가 있다. A 클래스에 B, C, D라는 무거운 인스턴스가 필요할 경우 클래스 생성 시 모두 만든다면 A 객체를 만드는 과정이 엄청 무거워질 것이다. 내부의 인스턴스들을 지연 초기화하면 A 객체를 만드는 과정을 가볍게 만들 수 있다.

다만 이 방법은 단점도 있다. 클래스가 무거운 객체를 가졌지만 메서드 호출은 빨라야 하는 경우가 있다. 지연되게 만들면 첫 번째 호출에 응답시간이 길어질 수 있다. 따라서 지연 초기화는 상황에 맞게 써야 한다.

 

기본 자료형 사용하기

 

JVM은 숫자, 문자 등의 기본 요소를 나타내기 위한 특별한 기본 내장 자료형을 갖고 있다. 이걸 기본 자료형(primitives)라고 부른다. Kotlin/JVM 컴파일러는 내부적으로 최대한 이 자료형을 쓴다. 다만 아래 2가지 상황에선 기본 자료형을 wrap한 자료형이 사용된다.

 

  • nullable 타입을 연산할 때
  • 타입을 제네릭으로 사용할 때

 

이걸 알면 wrap한 자료형 대신 기본 자료형을 쓰도록 코드를 최적화할 수 있다. 이 최적화는 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
}

 

이 구현의 단점은

 

  • 각 단계에서 엘비스 연산자를 써야 한다
  • nullable 값을 썼기 때문에 JVM 안에서 Int가 아닌 Integer로 연산된다

 

이 문제들을 해결하려면 while을 써서 반복을 구현한다.

 

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
}

 

이 정도의 최적화는 성능이 그렇게 중요하지 않은 코드에선 큰 의미가 없는 최적화다. 다만 라이브러리를 구현한다면 성능이 중요할 수 있다. 성능이 아주 중요한 경우에 활용하라.

반응형
Comments