Better Kotlin — 객체 생성 비용 줄이기
분야마다 차이는 있겠지만 요즘 앱 개발에서는 코드의 효율성에 대해 다소 관대하게 바라보는 경향이 있습니다. 메모리는 저렴하고 기기 리소스는 점점 발달한 반면 개발자들은 비싸기 때문에 코드의 효율을 극대화 시키는데 들어가는 비용보다는 기기의 에너지를 좀 더 사용하는 편이 효율적이기 때문이죠. 하지만 우리가 개발하는 앱이 수백만대의 기기에서 동작한다고 생각했을때 프로그램을 최적화하여 메모리와 배터리 사용을 줄일 수 있다면 지구적 관점으로는 많은 에너지를 절약할 수 있습니다. 백엔드 개발의 관점에서 본다면 서버와 회선 비용을 크게 절감할 수도 있겠죠. 따라서 우리는 장기적인 관점에서코드의 효율성을 높이기 위한 노력을 계속해야 합니다. 이 글에서는 Kotlin의 객체 생성과 관련된 비용을 줄이는 방법에 대해서 알아보려고 합니다.
불필요한 객체 생성 피하기
객체 생성은 비싸고 비용이 많이 드는 작업이기 때문에 불필요한 객체 생성을 피하는 것은 중요한 최적화가 될 수 있습니다. 그렇다면 구체적으로 객체 생성에 사용되는 자원은 얼마나 될까요? 먼저 객체는 메모리를 점유합니다. 64비트 JDK 에서 객체는 12바이트의 헤더를 가지고 8바이트 배수로 이뤄지기 때문에 최소 16바이트를 소비합니다. 32비트의 경우엔 8바이트 해더에 4바이트 배수이기 때문에 최소 8바이트겠죠? 또한 객체 참조에도 메모리를 소비합니다. 일반적으로 32비트 플랫폼이나 -Xmx32g 이하의 64비트 플랫폼에서는 4바이트를, -Xmx32g 이상의 64비트 플랫폼에서는 8바이트를 소비합니다. 상대적으로 적은 비용이지만 전체 프로그램을 생각한다면 무시할 수 없는 비용이 발생할 수 있습니다. Int를 예로들어 primitive Int는 4바이트에 불과하지만 우리가 주로 사용하는 64비트 JDK의 Integer 객체를 사용할땐 16바이트(객체) + 8바이트(참조) 즉, 다섯배나 되는 메모리를 사용하게 됩니다. 메모리 뿐 아닙니다. 객체를 생성하는데는 객체를 생성하고, 메모리를 할당하고, 참조를 생성해야하는 작업이 필요합니다. 당연하게도 이런 작업에는 CPU를 비롯한 기기 리소스가 소비됩니다. 아주 적은 비용이지만 프로그램 전체적으로 봤을땐 큰 비용으로 증가 할 수 있습니다. 간단한 테스트로 객체 생성에 대한 비용을 확인해보겠습니다.
Class Candy
private val candy = Candy()// 184346 ns/op
fun eatCandy1(consumer: Consumer) {
for (i in 0..1000)
consumer.eat(candy)
}// 265969 ns/op
fun eatCandy2(consumer: Consumer) {
for (i in 0..1000)
consumer.eat(Candy())
}
단순한 테스트 결과이지만 유의미한 차이를 보인다는 것을 확인 할 수 있습니다. 그렇다면 불필요한 객체 생성을 줄이는 방법은 어떤것들이 있을까요?
먼저 생성한 객체를 최대한 재사용 하는 방법이 있습니다. 예를 들어 JVM 에서 Integer 의 경우 -128에서 127 사이의 값을 캐싱하고 있습니다. 만약 상수로써equality 비교를 위해 Int
를 사용해야할 필요성이 있을때 -128~127 범위의 값을 사용한다면 불필요한 객체 생성을 줄일 수 있습니다.
val value1: Int? = 127
val value2: Int? = 127
println(value1 === value2) // trueval value3: Int? = 1277
val value4: Int? = 1277
println(value1 === value2) // false
위 예에서 살펴본 바와 같이 객체의 재사용을 위해서는 캐싱을 사용하는 방법이 있습니다. 이미 우리는 그런 솔루션을 빈번하게 사용하고 있는데 대표적인것이 캐싱 값을 가지고 있는 팩토리 함수입니다. 간단히 말해 팩토리 함수가 특정 값을 미리 가지고 있고 조건에 만족할 경우 새로운 객체를 생성하지 않고 생성해둔 객체를 반환하는 경우죠. stdlib 의 emptyList() 함수가 그런 방법으로 동작합니다.
fun <T> List<T> emptyList() {
return EMPTY_LIST
}
불필요한 함수 호출 줄이기
무거운 작업을 상위 수준으로 들어내는 것으로 불필요한 함수 호출을 줄이는 것은 성능 향상에 도움이 됩니다. 예를 들어 List<Int>
의 최대값이 몇개 포함되어있는 다음 함수를 살펴봅시다.
fun List<Int>.maxCount() = count { it == this.maxOrNull() }
이 함수는 List의 모든 값을 순회하며 maxOrNull()
을 호출하고 있습니다. maxOrNull()
함수를 상위 수준으로 옮기면 어떨까요?
fun List<Int>.maxCount() {
val max = this.maxOrNull()
return count { it == max }
}
이 함수는 한번 maxOrNull()
호출 이후 그 값을 계속 사용함으로 성능에 더 좋습니다. 또한 max
의 값이 반복문이 수행되는 동안 동일하다는걸 코드상에서 확인 할 수 있어 가독성을 향상시킬 수 있습니다. maxOrNull()
함수가 상대적으로 적은 비용을 소비하기 때문에 이런 처리가 과연 필요할까? 라는 의문이 생길 수도 있습니다. 그럼 다른 예를 하나 더 살펴볼까요?
fun List<String>.ipCount() = count {
it.matches("((\\d|[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])([.](?!$)|$)){4}".toRegex())
}
정규식 패턴을 컴파일 하는 작업은 List의 가장 큰 수를 찾는 일에 비하여 무거운 작업입니다. 이함수는 모든 리스트를 돌며 toRegex()
함수를 호출하며 성능을 떨어뜨리고 있습니다. 위 예제에서 했던것과 동일하게 toRegex()
함수를 추출하여 성능을 개선해 봅시다.
fun List<String>.ipCount(): Int {
val regex = "((\\d|[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])([.](?!$)|$)){4}".toRegex()
return count() { it.matches(regex) }
}
반복적인 함수의 호출을 피하기위해 무거운 작업을 상위 수준으로 추출하는것은 성능을 향상시키기 위해 항상 기억해두면 좋은 트릭입니다.
지연된 초기화 사용하기
클래스의 프로퍼티를 by lazy
로 선언하는 것은 객체 생성 비용을 줄이는데 도움이 됩니다.
class Heavy {
val p1 = P1()
val p2 = P2()
val p3 = P3()
}
Heavy
클래스는 객체가 생성되며 p1
, p2
, p3
를 함께 생성해야 합니다. 만약 프로퍼티의 클래스들도 프로퍼티에 다른 객체생성을 하고 있다면 객체 생성 비용은 계속 누적되게 됩니다. 이 경우 프로퍼티의 초기화를 지연시키는것이 도움이 됩니다.
class Light {
val p1 by lazy { P1() }
val p2 by lazy { P2() }
val p3 by lazy { P3() }
}
Light
클래스는 각각의 프로퍼티가 사용하는 시점에 초기화 됩니다. Heavy
클래스에 비하여 객체 생성 비용이 분산되어 객체 생성시 성능이 향상 될 수 있습니다.
원시 자료형(Primitive Types) 사용하기
Java에는 숫자나 문자와 같은 기본적인 요소에 사용되는 Primitive Type이 있습니다. Primitive Type은 Reference Type(참조형) 과는 다르게 객체를 생성하는 비용이 들지 않아 성능에 유리합니다.
Boolean Type: boolean
Numberic Type: short, int, long, float, double
Character Type: char
JVM 환경의 Kotlin 에서도 가능한 경우 Primitive Type을 사용하도록 컴파일을 시도 하지만 다음과 같은 경우는 Reference Type으로 처리됩니다.
- nullable 한 유형 > Primitive Type은 null이 될 수 없습니다.
- 제네릭으로 선언된 유형일때
위 내용을 바탕으로 숫자에 대한 작업이 여러번 반복되는 코드의 성능을 향상시킬 수 있습니다. 위에 소개했던 “불필요한 함수 호출 줄이기” 에서 살펴봤던 Iterable<Int>.maxOrNull()
함수를 예로 살펴보겠습니다.
fun Iterable<Int>.maxOrNull(): Int? {
var max: Int? = null
for (i in this) {
max = if(i > (max ?: 0)) i else max
}
return max
}
위 함수는 두가지 개선할 부분을 가지고 있습니다. 첫번째로는 max가 null일 경우를 처리하기 위한 elvis 연산자를 반복문에서 매번 처리해야하는 점이고 두번째로는 max가 nullable 값이기 때문에 Reference Type으로 컴파일 된다는 점입니다. 이 부분을 염두에 두고 아래와 같이 코드를 수정해봅니다.
fun Iterable<Int>.maxOrNull(): Int? {
var max: Int = 0
for (i in this) {
max = if(i > max) i else max
}
return max
}
전자의 함수와 개선된 함수에 0 부터 1000000까지의 Int를 넣고 수행 시간을 비교했을때 대략 951ms / 423ms 의 차이를 보여줍니다. (Kotlin Playground 기준) nullable을 제거하는것 만으로도 두 배 가까운 성능이 차이 나는것을 확인 할 수 있습니다.
눈치 빠르신 분들은 이미아셨겠지만 위에 소개했던 “불필요한 객체 생성 줄이기" 의
Integer
캐시를 설명하기 위해 예제에서Int?
를 사용했던 이유는 Reference Type인Integer
를 확인하기 위해서 였습니다.
모든 선택에는 기회 비용이 있습니다. 지금껏 살펴본 코드의 효율성을 높이기 위한 방법들을 적용할때도 마찬가지입니다. 때로는 가독성을 또는 시간을 잃을 수 있기 때문에 이러한 상충되는 요소가 충돌했을때 각각 상황에 맞춰 필요한 선택을 할 수 있는게 중요합니다. 다만 불필요한 함수 호출을 줄이기 위해 반복문에서 값을 추출하여 사용하거나 Primitive Type을 활용하기 위해 불필요한 Int?
을 지양하는 것은 적은 비용으로 효율성을 높일 수 있어 항상 염두에 두는것이 좋을 것 같습니다.