반응형
Notice
Recent Posts
Recent Comments
Link
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
Tags
more
Archives
Today
Total
관리 메뉴

안드로이드 개발자 노트

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

Kotlin/이펙티브 코틀린

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

어리둥절범고래 2024. 1. 14. 16:01
반응형

객체 생성은 언제나 비용이 들어가며, 상황에 따라서는 큰 비용이 들어갈 수도 있습니다.

 

 

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

 

어떤 객체를 랩(wrap)하면, 크게 세 가지 비용이 발생합니다.

 

  • 객체는 더 많은 용량을 차지한다. 현대 64비트 JDK에서 객체는 8바이트의 배수만큼 공간을 차지하며, 헤더 12바이트까지 최소 16바이트이다. 기본 자료형 int는 4바이트지만, 랩(wrap)되어 있는 Integer는 16바이트이다. 추가로 이에 대한 레퍼런스로 인해 8바이트가 더 필요하여, 5배 이상의 공간을 차지한다고 할 수 있다.
  • 요소가 캡슐화되어 있다면, 접근에 추가적인 함수 호출이 필요하다. 함수를 사용하는 처리는 빠르므로 큰 비용이 발생하지는 않지만, 티끌 모아 태산이 되므로 이 비용도 커질 수 있다.
  • 객체는 생성 시 메모리 영역에 할당되고 이에 대한 레퍼런스를 만드는 등의 작업이 필요하며, 이 또한 티끌 모아 태산이 된다.
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 createA(blackhole: Blackhole) {
    blackhole.consume(List(1000) { A() })
}

 

 

객체 선언

 

객체를 재사용하는 간단한 방법은 싱글톤 객체 선언을 사용하는 것입니다.

링크드 리스트를 구현하는 간단한 예를 살펴봅시다.

sealed class LinkedList<T>

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

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

// 사용
val list1: LinkedList<Int> = Node(1, Node(2, Node, Empty()) 
val list2: LinkedList<Int> = Node(3, Node(4, Node, Empty()) // Empty() 인스턴스를 매번 만들어야 함

Empty 인스턴스를 하나만 만들고, 다른 모든 리스트에서 활용할 수 있게 한다면 다음과 같습니다.

sealed class LinkedList<out T>

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

object Empty : LinkedList<Nothing>() 

// 사용
val list1: LinkedList<Int> = Node(1, Node(2, Empty))
val list2: LinkedList<Int> = Node(2, Node(2, Empty)) // 하나의 Empty 인스턴스를 재활용함

 

 

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

 

일반적으로 객체는 생성자를 사용해서 만들지만, 팩토리 메서드를 사용해서 만드는 경우 캐시(cache)를 가질 수 있습니다.

그래서 팩토리 함수는 항상 같은 객체를 리턴하게 만들 수 있습니다.

데이터베이스도 비슷한 형태로 커넥션 풀을 사용하며, 객체 생성이 무겁거나 동시에 여러 mutable 객체를 사용해야 하는 경우에 이와 같은 커넥션 풀을 사용하는 것이 좋습니다.

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

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

// 메모나이제이션을 활용한 피보나치 함수
private val FIB_CACHE = mutableMapOf<String, Connection>()

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

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

메모리 문제가 생긴다면 메모리를 해제하거나 애초에 SoftReference를 사용하면 메모리가 필요할 때만 GC가 자동으로 메모리를 해제 해줍니다.

이때 SoftReference를 WeakReference와 혼동하면 안됩니다.

 

  • WeakReference: GC가 값을 정리하는 것을 막지 않는다. 따라서 다른 레퍼런스가 이를 사용하지 않으면 곧바로 제거된다.
  • SoftReference: GC가 메모리 부족일 경우에만 정리합니다. 따라서 캐시를 만들 때는 SoftReference를 사용하는 것이 좋다.

 

 

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

 

컬렉션 처리에서 무거운 연산을 내부에서 외부 스코프로 보내는 방법도 있습니다.

다음은 Iterable 내부에 '최댓값의 수를 세는 확장 함수'를 만드는 예시입니다.

// max 연산이 element 수 만큼 수행됌
fun <T: Comparable<T>> Iterable<T>.countMax(): Int =
    count { it == this.max() }

// max 연산은 한번만 하면 됌
fun <T: Comparable<T>> Iterable<T>.countMax(): Int =
    val max = this.max()
    return count { it == max }
}

 

추가적인 예로 문자열이 IP 주소 형식을 갖는지 확인하는 다음 함수를 살펴봅시다.

fun String.isValidIpAddress(): Boolean {
    return this.matches("\\A(?:(?:25[0-5]|2[0-4][0-9]
    |[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]
    ?[0-9][0-9]?)\\z".toRegex())
}

print("5.173.80.254".isValidIpAddress()) // true

이 함수의 문제는 함수를 사용할 때마다 Regex 객체를 계속해서 새로 만든다는 것입니다.

정규 표현식을 톱레벨로 보내면, 문제를 해결할 수 있습니다.

private val IS_VALID_EMAIL_REGEX = "\\A(?:(?:25[0-5]|2[0-4][0-9]
    |[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]
    ?[0-9][0-9]?)\\z".toRegex()

fun String.isValidIpAddress(): Boolean = matches(IS_VALID_EMAIL_REGEX)

 

지연 초기화

 

위의 예시에서, 함수를 사용하지 않는다면 정규 표현식이 만들어지는 것 자체가 낭비며, 이런 경우에는 지연 초기화(lazy initialization)하면 됩니다.

private val IS_VALID_EMAIL_REGEX by lazy { "\\A(?:(?:25[0-5]|2[0-4][0-9]
    |[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]
    ?[0-9][0-9]?)\\z".toRegex()
}

 

지연 초기화는 장점도 있지만 단점도 갖고 있습니다.

지연 초기화하면 사용할때 만들어지므로 객체 생성이 가벼워집니다.

그러나 메서드의 호출이 빨라야 하는 경우, 호출 후 응답 시간이 길어질 수 있기 때문에 상황에 맞게 사용해야 합니다.

 

 

기본 자료형 사용하기

 

JVM은 숫자와 문자 등의 기본적인 요소를 나타내기 위한 기본 내장 자료형을 갖고 있습니다.

이를 기본 자료형(primitive)이라고 부릅니다.

다음과 같은 두 가지 상황에서는 기본 자료형을 랩(wrap)한 자료형이 사용됩니다.

 

  • nullable 타입을 연산할 때 (기본 자료형은 null일 수 없으므로)
  • 타입을 제네릭으로 사용할 때

숫자 연산의 경우 기본 자료형과 wrap 한 자료형의 성능 차이는 크지 않으며, 굉장히 큰 컬렉션을 처리할 때 차이를 확인할 수 있습니다.

간단한 예로 코틀린으로 컬렉션 내부의 최댓값을 리턴하는 함수를 만들어 봅시다.

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
}

이 구현에는 두 가지 단점이 있습니다.

 

  • 각각의 단계에서 엘비스(Elvis) 연산자를 사용해야 한다.
  • nullable 값을 사용했기 때문에 JVM 내부에서 int가 아니라 Integer로 연산이 일어난다.

iterator과 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
}

컬렉션 내부에 100~1000만 개의 요소를 넣고 함수를 실행하면, 이전 구현은 518ms 이번 구현은 289ms 정도 걸립니다.

이는 극단적인 예이며, 성능이 그렇게까지 중요하지 않은 코드에서는 큰 의미가 없는 최적화입니다.

반응형