Better Kotlin —메모리 관리

Jaeho Choe
6 min readJun 1, 2021

--

잊지 말아야할 원칙은 더 이상 사용하지 않는 객체에 대한 참조는 유지하면 안된다는 것입니다.

JVM이나 Android Runtime의 gc와 같이 메모리를 자동으로 관리해주는 언어에 익숙한 프로그래머는 일반적으로 객체 참조의 해제에 대해 깊이 고민하지 않습니다. gc가 사용하지 않는 참조를 파악해 메모리를 회수하는 역할을 대행해주고 있기때문입니다. 하지만 메모리관리를 완전히 잊어버린다면 불필요한 메모리 소비와 메모리 누수가 발생하여 OutOfMemory 를 발생시킬 수도 있습니다. 역설적으로 기기의 성능이 좋아진 요즈음이야 말로 메모리 부족에 대한 테스트나 모니터링이 소홀하기 쉽기 때문에 더욱 더 메모리 회수에 대한 고민이 필요합니다.

무거운 객체는 정적으로 유지하지 않는것이 좋습니다.

companion object의 프로퍼티는 애플리케이션이 실행되는 동안 gc의 대상이 되지 않습니다. 만약 companion object에 Activity와 같은 무거운 객체를 참조하고 있으면 엄청난 메모리 누수가 발생합니다. Java나 Kotlin 할것 없이 무거운 객체는 정적으로 유지 하지 않는것이 가장 좋습니다.

class MyActivity : Activity() {  
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
activity = this // This is will be huge memory leak.
}
companion object {
var activity: Activity? = null
}
}

위 코드는 그나마 메모리 누수가 될만한 부분이 눈에 잘 들어오는 편입니다. 좀 더 미묘한 경우를 알아볼까요? 다음 코드를 보며 어디에서 메모리 누수가 일어날 수 있을지 한번 생각해봅시다.

class MyActivity : Activity() {  

init {
log = { label: String? ->
println("${this::class.simpleName} $label")
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
log?.invoke("onCreated!")
}
companion object {
var log: ((String?) -> Unit)? = null
}
}

눈치 채셨나요? log 를 구현하며 클래스 네임을 얻기 위해 사용한 this::class.simpleName 은 메모리 누수의 원인이 됩니다. 이 코드는 컴파일 되며 MyActivity.this.getClass().getSimpleName() 로 대체 되어 MyActivity의 참조를 캡쳐합니다. 만약 이와 동일한 결과를 유지하며 누수를 피하려면 this::class.simpleName 대신 MyActivity::class.simpleName 을 사용하면 됩니다.

더이상 필요하지 않은 객체는 null을 할당합니다.

C의 free() 와 다르게 Java나 Kotlin 에서는 할당된 메모리를 해제하는 명시적인 함수가 없습니다. 대신 객체를 null 로 선언하여 다음 gc의 대상으로 포함시키는 방법으로 메모리를 반환받을 수 있습니다. 하지만 일반적인 경우 사용한 객체에 하나하나 null을 할당하며 사용하진 않습니다. 대부분의 경우 사용이 끝나 더이상 참조가 유지되어야 하지 않는 변수는 자동으로 gc의 대상이 되기 때문입니다. 하지만 이런 편리함때문에 우리는 null 처리가 꼭 필요한 부분을 종종 놓치곤 합니다. 아래 Stack 을 구현한 코드를 보며 어떤 메모리 누수가 발생하고 있는지 찾아보세요.

class Stack<T> {
private var stack = arrayOfNulss(DEFAULT_SIZE)
private var size = 0
fun push(v: T) {
checkSize()
stack[size] = v
size += 1
}
fun pop(): T? {
if(size == EMPTY) throw Exception("Empty Stack!")
return stack[size]
size -= 1
}
private fun checkSize() {
if(stack.size == size) stack = stack.copyOf(2 * size + 1)
}
companion object {
const val DEFAULT_SIZE = 8
const val EMPTY = 0
}
}

찾아내셨나요? 이 코드의 문제는 pop() 을 수행한 이후 반환한 값을 초기화시키지 않고 있다는 점입니다. 100개의 T를 push() 한 이후 100개를 pop() 해도 size만 줄어들뿐 실제 stack 속의 값들은 여전히 객체를 참조하고 있고 때문에 gc의 대상이 될 수 없습니다. 이런 코드를 최적화 시키기 위해선 아래와 같이 더이상 사용하지 않는 객체에 대해 null을 설정해주는것이 필요합니다.

fun pop(): T {
if(size == EMPTY) throw Exception("Empty Stack!")
val value = stack[size]
stack[size] = null
size -= 1
return value
}

약한 참조를 사용합니다.

시적 허용(詩的許容)이라는 말이 있습니다. 문법적으로는 틀린 표현이라도 시적인 표현을 위해 허용되는 문학적인 표현을 의미합니다. 메모리 측면에서도 이와 유사한 개념이 있습니다. 바로 Cache 입니다. Cache는 경우에 따라 한번도 사용되지 않을 수도 있는 값을 보유합니다. 성능에는 도움이 될 수 있지만 메모리 입장에서는 아무런 도움이 되지 않습니다. 이런 Cache는 WeakReference를 사용하기에 아주 적당한 예입니다. 메모리가 부족한 상황에서는 gc가 참조를 해제하고 메모리를 회수할 수 있고 또 메모리의 허용 범위안에서 어플리케이션이 동작할때는 Cache 본연의 임무에도 충실 할 수 있습니다. 이밖에도 화면에 보일때만 유효한 DialogToast 등을 객체로 보유해야 할 경우에도 WeakReference는 유용합니다.

gc 는 편리하고 강력하게 메모리를 관리해주지만 개발자는 객체를 다룰때 메모리 관리에 대한 생각을 잊어선 안됩니다. 항상 코드를 의심하며 어디선가 누수가 일어날 수 있지 않을까 의심하는 습관은 메모리가 건강한 코드를 만드는데 중요한 역할을 합니다. 그러기 위해서는 코드의 가독성이 매우 중요합니다. 일반적으로는 읽기 쉬운 코드가 메모리 측면에서도 좋습니다. 읽기 어려운 코드는 메모리 누수나 다른 퍼포먼스 낭비를 찾아내기 어렵습니다. 때로는 가독성과 성능이 상충되는 경우가 있을 수도 있지만 대부분의 경우 가독성이 우선이 되야합니다.

참고 자료:
https://d2.naver.com/helloworld/1329
https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.native.ref/-weak-reference/
https://leanpub.com/effectivekotlin

--

--

Jaeho Choe
Jaeho Choe

No responses yet