관리 메뉴

나만을 위한 블로그

[이펙티브 코틀린] 아이템 48. 더 이상 쓰지 않는 객체의 레퍼런스를 제거하라 본문

책/Effective Kotlin

[이펙티브 코틀린] 아이템 48. 더 이상 쓰지 않는 객체의 레퍼런스를 제거하라

참깨빵위에참깨빵 2023. 5. 14. 00:03
728x90
반응형

자바는 가비지 컬렉터가 객체 해제와 관련된 모든 작업을 해 준다. 그렇다고 메모리 관리를 완전히 무시하면 메모리 누수가 발생해서 상황에 따라 OOM이 발생하기도 한다. 따라서 더 이상 쓰지 않는 객체의 레퍼런스를 유지하면 안 된다는 규칙 정도는 지키는 게 좋다. 특히 어떤 객체가 메모리를 많이 차지하거나 객체가 많이 생성될 경우에는 규칙을 꼭 지켜야 한다.

안드로이드를 처음 시작하는 많은 개발자가 실수로 흔히 Activity를 여러 곳에서 자유롭게 접근하기 위해 companion 프로퍼티(고전적 형태론 static field)에 이를 할당해 두는 경우가 있다.

 

class MainActivity : AppCompatActivity() {

    companion object {
        // 이렇게 하면 메모리 누수가 발생하니 하지 마라
        var activity: MainActivity? = null
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }
}

 

객체에 대한 참조를 companion으로 유지해 버리면 가비지 컬렉터가 해당 객체에 대한 메모리 해제를 할 수 없다. 액티비티는 굉장히 큰 객체다. 따라서 굉장히 큰 메모리 누수가 발생한다. 이를 개선할 수 있는 방법이 몇 가지 있다. 일단 이런 리소스를 정적으로 유지하지 않는 게 가장 좋다. 의존 관계를 정적으로 저장하지 말고 다른 방법을 활용해서 적절하게 관리하라.

또한 객체에 대한 레퍼런스를 다른 곳에 저장할 때는 메모리 누수의 발생 가능성을 언제나 염두에 둬라.

 

메모리 문제는 굉장히 미묘한 곳에서 발생하는 경우가 많다. 아래 스택 구현을 확인한다.

 

class Stack {
    companion object {
        private const val DEFAULT_INITIAL_CAPACITY = 16
    }
    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)
        }
    }
}

 

이 코드에서 어디가 문제인가? 문제는 pop을 할 때 size를 감소시키기만 하고 배열 위의 요소를 해제하는 부분이 없다는 것이다. 스택에 1,000개의 요소가 있을 때 pop을 실행해서 size를 1까지 줄였다고 가정한다. 요소 1개만 의미가 있고 나머지는 무의미하다. 하지만 위 코드의 스택은 1,000개 요소를 모두 붙들고 놔주지 않으므로 가비지 컬렉터가 해제하지 못한다. 따라서 999개의 요소가 메모리를 낭비하게 되어 메모리 누수가 발생한다. 이런 누수가 쌓이다 보면 OOM이 발생할 것이다. 어떻게 수정해야 하는가? 간단하게 객체를 더 이상 쓰지 않을 때 그 레퍼런스에 null을 설정하면 된다.

 

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

 

이 예시는 메모리 누수를 확실하게 볼 수 있지만 그렇게 자주 접할 수 있는 예는 아니다. 좀 더 일상적으로 볼 수 있는 예를 본다. lazy처럼 동작해야 하지만 상태 변경도 할 수 있는 걸 만들어야 한다.

 

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가 사용된 후에도 해제되지 않는다. MutableLazy에 대한 참조가 존재한다면 이게 더 이상 필요없어도 유지된다. 이걸 개선하면 아래와 같다.

 

private class MutableLazy<T>(
    var initializer: (() -> T)? // 이 부분이 변경됨
): ReadWriteProperty<Any?, T> {

    private var value: T? = null

    override fun getValue(thisRef: Any?, property: KProperty<*>): T {
        synchronized(this) {
            val initializer = initializer
            if (initializer != null) {
                value = initializer()
                this.initializer = null
            }
            return value as T
        }
    }

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

 

 

initializer를 null로 설정하기만 하면 가비지 컬렉터가 처리할 수 있다. 이런 최적화 처리가 과연 중요한가?

거의 쓰이지 않는 객체까지 이런 걸 신경 쓰는 건 오히려 안 좋을 수 있다. 쓸데없는 최적화가 만악의 근원이란 말도 있다. 하지만 오브젝트에 null을 설정하는 건 그리 어려운 일이 아니므로 무조건 하는 게 좋다. 특히 많은 변수를 캡쳐할 수 있는 함수 타입, Any 또는 제네릭 같은 미지의 클래스일 때는 이런 처리가 중요하다.

앞서 본 Stack으로 좀 더 큰 객체들을 다루는 경우가 있을 수 있다. Stack 같이 범용적으로 쓰이는 것들은 어떤 식으로 사용될지 예측이 어렵다. 따라서 이런 것들은 최적화에 더 신경을 써야 한다. 즉 라이브러리를 만들 때 이런 최적화가 중요하다.

코틀린 stdlib에 구현된 alzy 델리게이트는 사용 후에 모든 initializer를 null로 초기화한다.

일반적인 규칙은 상태 유지 시에는 메모리 관리를 염두에 둬야 한다는 것이다. 코드를 작성할 때는 메모리와 성능 뿐 아니라 가독성과 확장성을 항상 고려해야 한다. 일반적으로 가독성이 좋은 코드는 메모리와 성능적으로도 좋다. 가독성이 나쁜 코드는 메모리, CPU 리소스의 낭비를 숨기고 있을 가능성이 높다. 둘 사이에 트레이드 오프가 발생하는 경우도 있다. 이럴 땐 일반적으로 가독성, 확장성을 더 중시하는 게 좋다. 예외적으로 라이브러리 구현 시에는 메모리, 성능이 더 중요하다.

 

객체를 수동으로 해제해야 하는 경우는 굉장히 드물다. 일반적으로 스코프를 벗어나면서 어떤 객체를 가리키는 레퍼런스가 제거될 때 객체가 자동 해제된다. 따라서 메모리 관련 문제를 피하는 가장 좋은 방법은 변수를 지역 스코프에 정의하고 톱레벨 프로퍼티 또는 객체 선언(companion 객체 포함)으로 큰 데이터를 저장하지 않는 것이다.

반응형
Comments