Developing Myself Everyday
article thumbnail

가독성과 성능 사이에 트레이드 오프가 발생할 때, 개발자는 무엇이 더 중요한지 스스로 답할 수 있어야 합니다. 이를 결정할 수 있는 몇 가지 방법이 있지만, 최종적인 결정은 스스로 상황을 보고 해야 합니다.

 

 

아이템 45 - 불필요한 객체 생성을 피하라


 

객체 생성에는 비용이 들어갑니다. 그러니 불필요한 객체 생성은 피해야 합니다. 

 

이런 이유에서 JVM에는 동일한 문자열을 처리한다면, 기존의 문자열을 재사용합니다.

val str1 = "effective kotlin"
val str2 = "effective kotlin"

println(str1 === str2) // true

 

-128 ~ 127 범위에 해당하는 숫자도 마찬가지로 재사용합니다.

 

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

객체를 생성하면, 크게 세 가지 비용이 발생합니다.

  1. 객체는 더 많은 용량을 차지합니다.
  2. 요소가 캡슐화되어 있다면, 접근에 추가적인 함수 호출이 필요합니다.
  3. 객체는 생성되면 메모리 영역에 할당되고 이에 대한 레퍼런스를 만드는 등의 작업이 필요합니다. 이게 모이면 큰 비용이 됩니다.

 

객체 선언

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

 

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

일반적으로 객체는 생성자를 사용해서 만듭니다. 하지만 팩토리 함수를 사용하면, 캐시를 가질 수 있기 때문에 항상 같은 객체를 리턴하게 만들 수 있습니다. 실제로 emptyList는 이를 활용해서 구현되어 있습니다.

// stdlib의 emptyList
fun <T> List<T> emptyList() {
    return EMPTY_LIST;
}

 

모든 순수 함수는 캐싱을 활용할 수 있고, 이를 메모리제이션이라고 부릅니다.

 

캐시는 언제나 메모리와 성능의 트레이트 오프가 발생하므로, 잘 설계하는 것은 쉽지 않습니다.

 

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

성능을 위한 트릭으로, 무거은 객체를 외부 스코프로 보내는 방법이 있습니다. 간단한 예로 Iterable 내부의 '최댓값의 수를 세는 확장 함수'를 만드는 경우를 생각해 봅시다.

 

아래의 경우에는 매번 max의 값을 구해야 합니다.

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

 

이를 max를 외부 스코프로 옮기면, 처음에 max 값을 찾은 다음 이를 활용해서 수를 셉니다.

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

 

지연 초기화

무거운 클래스를 만들 때는 지연되게 만드는 것이 좋을 때가 있습니다. 아래와 같이 지연 초기화를 사용하면 클래스 A의 객체를 생성하는 과정을 가볍게 만들 수 있습니다.

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

 

다만, 호출이 빨라야 하는 경우에는 처음 호출될 때 무거운 객체들의 초기화가 필요할 것입니다. 따라서 지연 초기화는 상황에 맞게 사용해야 합니다.

 

정리

기본 자료형을 사용하는 것도 최적화를 가능합니다. 

 

최적화에 큰 변경이 필요하거나, 다른 코드에 문제를 일으킬 수 있다면 최적화를 미루는 것도 방법입니다.

 

 

 

아이템 46 - 함수 타입 파라미터를 갖는 함수에 inline 한정자를 붙여라


inline 한정하의 역할은 컴파일 시점에 '함수를 호출하는 부분'을 '함수의 본문'으로 대체하는 것입니다. 

 

예를 들어 아래와 같이 대체됩니다.

repeat(10) { index ->
    println("Index: $index")
}

for (index in 1..10) {
    println("Index: $index")
}

 

일반적인 함수는 함수 본문으로 점프하고, 본문의 모든 문장을 호출한 뒤에 다시 호출했던 위치로 돌아가는 과정을 거칩니다. inline을 사용하면 이러한 점프가 일어나지 않습니다.

 

inline을 사용하면 아래와 같은 장점이 있습니다.

  1. 타입 아규먼트에 reified 한정자를 붙여서 사용할 수 있습니다.
  2. 함수 타입 파라미터를 가진 함수가 훨씬 빠르게 동작합니다.
  3. 비지역 리턴을 사용할 수 있습니다.

 

타입 아규먼트에 reified를 사용할 수 있다.

JVM 바이트 코드에는 제네릭이 존재하지 않습니다. 컴파일을 하면 제네릭 타입과 관련된 내용은 제거됩니다. 

 

이때 함수를 인라인으로 만들면, 함수 본문이 대체되므로 이러한 제한을 무시할 수 있고 reified 한정자를 지정하면, 타입 파라미터를 사용한 부분이 타입 아규먼트로 대체됩니다. 

 

아래의 `printType`을 호출하는 부분은 컴파일하는 동안 본문이 실제로 대체됩니다.

inline fun <reified T> printType() {
    println("Type: ${T::class.java}")
}

fun main() {
    printType<Int>()
    printType<String>()
}

 

따라서 다음과 같이 됩니다.

println("Type: ${Int::class.java}")
println("Type: ${String::class.java}")

 

함수 타입 파라미터를 가진 함수가 훨씬 빠르게 동작한다.

모든 함수는 inline 한정자를 붙이면 여러 과정을 생략하기 때문에 더 빠르게 동작합니다.

 

하지만 함수 파라미터를 가지지 않는 함수에서는 큰 성능 차이가 나지 않습니다. 그렇기 때문에 인텔리제이는 아래와 같은 경고를 표시합니다.

 

함수 리터럴(함수 자체를 값으로 취급하기 위해 코드 내에 직접 작성하는 표현식)을 사용해 만들어진 객체는 어떤 방식으로든 저장되거나 유지되어야 합니다. 그렇기 때문에 코틀린/JVM에서는 함수를 객체로 만들어 냅니다.

 

따라서 다음과 같은 람다 표현식은

val lambda: () -> Unit = { }

 

이랗게 컴파일 됩니다.

public static final void main() {
   Function0 lambda = null.INSTANCE;
}

 

함수 타입이 단순한 인터페이스라는 것을 알면, 함수 본문을 wrap하면 하나의 계층이 더 생겨 코드의 속도가 느려진다는 것도 알 수 있습니다.

 

일반적으로 함수 타입의 파라미터가 어떤 식으로 동작하는지 이해하기 어려우므로, 함수 타입 파라미터를 활용해서 유틸리티 함수를 만들때는 그냥 인라인을 붙여 준다 생각하는 것도 좋습니다.

 

비 지역적(non-local return)을 사용할 수 있다.

inline 함수는 return을 사용할 때 제약이 없습니다. 

fun main() {
    repeat(10) {
        print(it)
        return // This will exit the main function
    }
}

 

inline 한정자의 비용

inline을 무조건 사용할 수는 없습니다. 대표적인 예로 인라인 함수는 재귀적으로 동작할 수 없습니다.

 

책은 이런 경우에는 오류로 잡아주지 못한다고 말하지만 현재는 아래와 같이 오류를 말해줍니다.

 

또한 인라인 함수에는 private와 internal 가시성 제한을 가진 요소를 사용할 수 없습니다.

 

crossinline과 noinline

함수를 인라인으로 만들고 싶지만, 일부 타입 파라미터는 inline으로 받고 시지 않은 경우에는 아래와 같인 한정자를 사용합니다.

crossinline

아규먼트로 인라인 함수를 받지만, 비지역적 리턴을 하는 함수는 받을 수 없게 만듭니다.

 

noinline

아규먼트로 인라인 함수를 받을 수 없게 만듭니다. 인라인 함수가 아닌 함수를 아규먼트로 사용하고 싶을 때 활용합니다.

 

 

정리

API를 정의할 때 인라인 함수를 사용하는 경우는 거의 없습니다. 또한 한 인라인 함수가 다른 인라인 함수를 호출하는 경우, 코드가 기하급수적으로 많이질 수 있으므로 유의해야 합니다.

 

 

 

아이템 47 - 인라인 클래스의 사용을 고려하라


객체도 inline으로 만들 수 있습니다. 클래스 앞에 inline을 붙이면 해당 객체를 사용하는 위치가 모두 해당 프로퍼티로 교체됩니다.

inline class Name(private val value: String) {
    
}

 

이러한 inline 클래스는 타입만 맞다면, 다음과 같이 그냥 값을 곧바로 집어 넣는 것도 허용됩니다.

val name: Name = Name("Kotlin")

// 컴파일 때 다음과 같은 형태로 바뀝니다.
val name: String = "Kotlin"

 

인라인 클래스는 다른 자료형을 래핑해서 새로운 자료형을 만들 때 많이 사용됩니다. 이때 어떠한 오버헤드도 발생하지 ㅇ낳습니다. inline 클래스는 다음과 같은 상황에서 많이 사용됩니다.

 

측정 단위를 표현할 때

올바른 타입을 강제할 필요가 있을 경우에는 아래와 같이 사용합니다.

inline class Millis(val millis: Long) {

}

 

이렇게 되면 해당 타입을 사용하는 것이 굉장히 안전해지며, 별도의 문제가 발생하지 않습니다.

 

인라인 클래스와 인터페이스

인라인 클래스도 인터페이스를 구현할 수 있습니다.

interface TimeUnit {
    val millis: Long
}

inline class Millis(val milliseconds: Long): TimeUnit {
    override val millis: Long
        get() = milliseconds
}

 

하지만 이 코드는 클래스가 inline으로 동작하지 않고, inline으로 만들었을 때 얻을 수 있는 이점이 하나도 없습니다.

 

이는 인터페이스를 통해서 타입을 나타내려면, 객체를 래핑해서 사용해야 하기 때문입니다.

 

typealias

 typealias를 사용하면 타입에 새로운 이름을 붙일 수 있습니다.

 

 

 

아이템 48 - 더 이상 사용하지 않는 객체의 레퍼런스를 제거하라


메모리 관리를 자동으로 해준다고 해서 메모리 관리를 완전히 무시해 버리면, 메모리 누수가 발생하여 `OutOfMemoryError`가 발생할 수 있습니다. 따라서 '더 이상 사용하지 않는 객체의 레퍼런스를 유지하면 안 된다'라는 규칙을 지키는 것이 좋습니다.

 

안드로이드에서는 Activity를 여러 곳에서 자유롭게 접근하기 위해서 companion 프로퍼티에 이를 할당해 두는 경우가 있습니다.

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        activity = this
    }
    
    companion object {
        // 이렇게 하면 메모리 누수가 발생하니 하지 마라
        var activity: MainActivity? = null
    }
}

 

이렇게 객체 참조를 companion으로 유지해 버리면, 가비지 컬렉터가 해당 객체에 대한 메모리 해제를 할 수 없습니다. 그렇기 때문에 이러한 리소스를 정적으로 유지하지 않는 것이 좋습니다. 또한 객체에 대한 레퍼런스를 다른 곳에 저장할 때는 메모리 누수가 발생할 가능성을 언제나 염두에 두기 바랍니다.

 

아래의 코드에는 문제가 있습니다.

class Stack {
    private var elements: Array<Any?> = arrayOfNulls(DEFAULT_INITIAL_CAPACITY)
    private var size = 0
    
    fun push(e: Any) {
        ensureCapacity()
        elements[size++] = e
    }
    
    fun pop(): Any? {
        if (size == 0) {
            throw EmptyStackException()
        }
        return elements[--size]
    }
    
    private fun ensureCapacity() {
        if (elements.size == size) {
            elements = elements.copyOf(2 * size + 1)
        }
    }
    
    companion object {
        private const val DEFAULT_INITIAL_CAPACITY = 16
    }
}

 

문제는 pop을 할 때 size를 감소시키기만 하고, 배열 위의 요소를 해제하는 부분이 없다는 것입니다. 스택에 1000개의 요소가 있을 때, 위의 코드는 1000개의 요소를 모두 붙들고 놓아주지 않으므로, 가비지 컬렉터가 이를 해제하지 못합니다. 

 

실질적으로 요소 하나만 사용하지만, 999의 요소가 메모리를 낭비하게 됩니다. 이를 해결하는 것은 간단합니다. 객체를 더 이상 사용하지 않을 때, 그 레퍼런스에 null을 설정하기만 하면 됩니다.

fun pop(): Any? {
    if (size == 0) {
        throw EmptyStackException()
    }
    val elem = elements[--size]
    elements[size] = null
    return elem
}

 

다른 예시로 아래의 코드를 보겠습니다. 아래의 코드는 변경 가능한 lazy 속성을 제공하는 mutableLazy 함수를 정의합니다.

fun <T> mutableLazy(initializer: () -> T): ReadWriteProperty<Any?, T> = MutableLazy(initializer)

private class MutableLazy<T>(
    val initializer: () -> T
): ReadWriteProperty<Any?, T> {
    
    private var value: T? = null
    private var initialized = false
    
    override fun getValue(thisRef: Any?, property: KProperty<*>): T {
        synchronized(this) {
            if (!initialized) {
                value = initializer()
                initialized = true
            }
            
            return value as T
        }
    }

    override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
        synchronized(this) {
            this.value = value
            initialized = true
        }
    }
}

 

위의 코드에서 initializer는 사용된 후에도 해지되지 않습니다. 이는 사용 후에 initalizer를 null로 설정하기만 하면, 가비지 컬렉터가 이를 처리할 수 있습니다.

 

거의 사용되지 않는 개체까지 최적화 처리를 하는 것은 좋지 않을 수도 있습니다. 하지만 오브젝트에 null을 설정하는 것은 그렇게 어려운 일이 아니므로, 무조건 하는 것이 좋습니다. 특히 많은 변수를 캡처할 수 있는 Any 또는 제네릭 타입과 같은 미지의 클래스일 때는 이러한 처리가 중요합니다.

class DataHolder<T> {
    private var data: T? = null

    fun setData(value: T) {
        data = value
    }

    fun clear() {
        data = null // 명확하게 null 처리하여 GC가 수거할 수 있도록 함
    }
}

 

일반적인 규칙은 상태를 유지할 때는 메모리 관리를 염두에 두어야 한다는 것입니다. 코드를 작성할 때에는 '메모리와 성능' 뿐만 아니라 '가독성과 확장성'을 항상 고려해야 합니다. 일반적으로 가독성이 좋은 코드는 메모리와 성능적으로도 좋습니다. 예외적으로 라이브러리를 구현할 때는 메모리와 성능이 더 중요합니다.

 

 

일반적으로 메모리 누수가발생하는 상황

절대 사용되지 않는 객체를 캐시해서 저장해 두는 경우

캐시를 해두는 것이 나쁜 것은 아니지만, OutOfMemoryError를 일으킬 수 있다면  도움이 되지 않습니다. 해결 방법은 Soft Reference를 사용하는 것입니다.

Soft Reference는 Weak Reference와 유사하지만 GC가 즉시 수거하지 않고 메모리가 부족할 때만 수거하는 참조 방식입니다. 

 

아래의 예시에서 이 둘이 어떻게 다르게 작동하는지 보여줍니다. 아래는 Soft Reference입니다.

fun main() {
    var obj: MyClass? = MyClass("Hello, Soft Reference!")
    val softRef = SoftReference(obj) // 소프트 참조 생성

    obj = null // 강한 참조 제거
    System.gc() // 가비지 컬렉터 실행 요청 (소프트 참조는 바로 수거되지 않음)

    Thread.sleep(100)
    println(softRef.get()) // MyClass@78308db1 출력
}

 

아래는 Weak Reference입니다.

fun main() {
    var obj: MyClass? = MyClass("Hello, Soft Reference!")
    val softRef = WeakReference(obj) // 약한 참조 생성

    obj = null // 강한 참조 제거
    System.gc() // 가비지 컬렉터 실행 요청 (약한 참조는 바로 수거됨)

    Thread.sleep(100)
    println(softRef.get()) // null 출력
}

 

 

사실 객체를 수동으로 해제해야 하는 경우는 굉장히 드뭅니다. 일반적으로 스코프를 벗어나면서, 어떤 객체를 가리키는 레퍼런스가 제거될 때, 객체가 자동으로 해제됩니다. 따라서 메모리와 관련된 문제를 피하는 가장 좋은 방법은 변수를 지역 스코프에 정의하고,

fun createAndUseObject() {
    val data = ByteArray(10_000_000) // 10MB 크기의 배열
    println("객체 생성 및 사용 중...")
} // 함수 종료 시 data는 스코프를 벗어나면서 자동으로 해제됨

 

톱레벨 프로퍼티 또는 개체 선언으로 큰 데이터를 저장하지 않는 것입니다.

val globalData = ByteArray(10_000_000) // 10MB 크기의 배열 (톱레벨 프로퍼티)

fun main() {
    println("톱레벨 프로퍼티를 사용하면 GC가 바로 해제하지 못할 수 있음")
}
profile

Developing Myself Everyday

@배준형

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!