이번 게시글은 Effective Kotlin — Chapter 7. Efficiency에 대한 내용입니다.

오늘 날에는 코드의 효율성을 관대하게 바라봅니다. 안드로이드 디바이스만 보더라도 하드웨어 스펙이 높아졌습니다.

하지만 그렇다고 해서 마냥 효율성을 배제할 순 없습니다. 여전히 우리는 저사양 디바이스를 지원해야 하며, 안드로이드 디바이스는 아주 괴랄한(?) 것들이 많기 때문입니다.

1. 불필요한 객체 생성을 피하라

객체 생성은 언제나 비용이 들어갑니다. 상황에 따라서는 굉장히 큰 비용이 들어갈 수도 있습니다. 따라서 불필요한 객체 생성을 피하는 것이 최적화의 관점에서 좋습니다.

JVM에서는 동일한 String을 처리하는 코드가 여러 개 있다면, 기존 String을 재사용합니다.

val str1 = “godgod”
val str2 = “godgod”
println(str1 == str2) // true
println(str1 === str2) // true

IntegerLong 과 같은 기본 자료형도 작은 경우에는 재 사용됩니다. (Integer는 -128 ~ 127 범위를 캐시 합니다.)

// Int? 타입은 컴파일 후 Integer로 변환된다.
val i1 : Int? = 1
val i2 : Int? = 1
println(i1 == i2) //true
println(i1 === i2) //true
val i3 : Int? = 129
val i4 : Int? = 129
println(i3 == i4) //true
println(i3 === i4) //false

Int?Integer로 컴파일 됩니다. Int기본 자료형 int로 컴파일 됩니다. 이런 것들도 아주 작은 부분이지만, 객체 생성에 대한 비용이 더 들어간다고 할 수 있습니다.

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

객체에 정의되어 있는 함수나 프로퍼티에 따라 달라 지지만, 객체는 기본자료형보다 비쌉니다.

물론 큰 비용은 아니지만 Integerint 보다 5배 비쌉니다.

싱글톤

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

하지만 이는 스레드 세이프 하지 않은 상황이 발생할 수 있어 사용에 유의를 해야합니다.

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

5장에서 팩토리 함수에 대해서 다뤘습니다. 이 팩토리 함수를 가지고 캐시를 전달 하게끔 만들 수 있습니다.

실제로 Kotlin Collections의 emptyList()도 싱글턴 객체를 전달해줍니다.

이뿐만 아니라, 특정 자료구조에 데이터를 캐시 하는 것도 다 성능을 위한 것입니다. 하지만 데이터를 캐시 하는 것에는 유의할 것이 있습니다.

메모리가 부족할 때는 GC로 부터 제거 되게끔 만드는 것까지 처리해야, 캐시로 인한 성능 향상과 OOM을 방지할 수 있습니다.

대표적으로 WeakReference, SoftReference를 사용합니다.

  • SoftReference : 메모리가 부족해서 GC가 돌 때, 제거됩니다. — 캐시에 적합
  • WeakReference : GC가 돌 때, WeakReference에 참조된 객체가 제거됩니다. 하지만 WeakReference가 참조한 객체를 JVM 상 stack, static 영역에서 참조를 한다면 제거되지 않습니다.

자세한 내용은 https://d2.naver.com/helloworld/329631를 참조하세요

지연 초기화

객체 생성하는 시점을 객체 사용 시점으로 미룰 수 있다면, 성능적인 도움이 될 것입니다. 마치 한번에 왕창 다 만드는게 아니라, 필요할 때 마다 조금씩 생성하는 개념입니다.

그래서 Kotlin에선 lazy() 를 제공합니다.

기본 자료형 사용하기

코틀린은 모든 것이 객체이고, 기본 자료형까지도 객체로 알려져 있습니다. 하지만 조금 더 세밀하게 들여다본다면, 기본 자료형은 경우에 따라 기본 자료형일 수도 객체 일 수도 있습니다.

  1. nullable한 타입으로 정의할 때 (기본 자료형은 null 일 수 없기에)
    : Int? -> Integer
  2. 기본 자료형 타입으로 제네릭 타입으로 선언 했을 때
    : List<Int> -> List<Integer>

일반적으로는 이렇게 까지 성능을 생각할 필요는 없습니다. 하지만 특정 라이브러리 개발자라면 주의할 필요가 있습니다.

2. 함수 타입 파라미터를 갖는 함수에 inline을 붙여라

코틀린 고차 함수를 살펴보면 대부분 inline 이 붙어 있는 것을 확인 할 수 있습니다.

inline을 사용함으로써 람다 객체 생성에 대한 비용을 제거합니다.

아래의 예와 같이 컴파일 시점에 inline에 정의된 함수를 호출부에 인라이닝 시키기 때문에 람다 객체 생성에 대한 비용이 들지 않습니다.

이것 뿐만이 아니라 inline의 장점은 더 있습니다.

타입 파라미터는 reified로 사용할 수 있다.

제네릭은 컴파일 이후로는 타입 이레이징이 발생합니다. 이것은 메모리를 줄이기 위한 Java의 정책으로 Kotlin 역시 동일합니다.

하지만 inline함수에서 타입 파라미터를 reified하면 컴파일 이후에도 타입이 살아 있게 됩니다.

이게 가능한 이유는 앞서 말씀드렸듯이, 함수 선언부를 호출부에 인라이닝 할 때, 타입까지 같이 인라이닝 시키기 때문입니다.

람다를 파라미터로 받는 inline 함수와 noinline 함수의 속도차이

앞서 말씀드린데로 람다를 파라미터로 받는 함수에 대해서는 inline을 붙여야 람다 객체를 생성하지 않고, 그대로 코드를 인라이닝 시키기 때문에 객체 생성의 비용이 적다고 말씀드렸습니다.

그렇다면 벤치마크 상의 속도 차이는 어느정도 일까요?

위의 코드로, 2018 맥북 프로(i7, 16G, 256G)로 테스트한 결과 noinline함수와 inline함수의 속도 차이는 평균 3~4배 정도 났습니다.

crossinline과 noinline

함수를 inline으로 만들고 싶지만, 일부 람다 파라미터에 대해서 선택적인 inline을 적용하고 싶을 수 있습니다.

  • crossinline : 람다, 익명 클래스 안에 인라이닝 하고자 할 때 사용합니다
  • noinline : 인라인 처리를 하지 않고 객체 생성을 하게끔 만듭니다. 람다를 변수로 들고 있게 하고자 하거나, inline 함수가 아닌 다른 함수에 람다를 전달 하고자 할 때 사용합니다.

crossinline, noinline, inline 사용 예

  • inline 람다는 함수 정의 부 혹은 또 다른 inline 함수에서만 인라이닝 될 수 있습니다.
  • crossinline 람다는 inline 람다가 사용될 수 있는 곳 + 람다, 익명 클래스 안에서 인라이닝 될 수 있습니다.
  • noinline 람다는 객체 생성을 하기 때문에 제약이 없습니다.

inline 사용 주의점

inline은 함수 호출부에 코드를 인라이닝 시켜준다는 점에서 매우 좋은 기능을 가졌습니다. 하지만 반대로 함수 호출부에 코드가 인라이닝 된다는 것은 하나의 함수의 볼륨이 매우 커질 수 있다는 것입니다.

inline함수에서 또 다른 inline 함수를 호출하고 또 다른 inline 함수를 호출하는 행위는 최대한 지양하는 것이 좋습니다.

3. value 클래스의 사용을 고려하라

인라인으로 만들 수 있는 것은 함수 뿐만이 아닙니다. 하나의 값을 보유하는 객체도 inline으로 만들 수 있습니다. value class를 이용해 객체 생성의 비용 없이 호출부에 인라이닝 시킬 수 있습니다.

value class가 v.1.5에 나온 뒤 inline class는 deprecated 되었습니다.

그리고 value class에 정의된 메서드 들은 모두 static method로 변경됩니다.

이걸 어디에 사용해야 할까?

value class는 아쉽게도 단 하나의 프로퍼티 밖에 갖지 못합니다. 그렇기에 data class를 완벽하게 대체 할 수 없습니다.

그런 속성을 이용해 value class는 ID와 같이 타입이 햇갈리면 문제가 발생할 수 있는 부분에 대해서 타입 안정성을 위해 사용합니다.

위의 예제와 같이 타입이 동일한 경우엔 햇갈릴 여지가 매우 높습니다. 이런 경우에 개발자의 실수를 발생시키지 않기 위해, 또한 Int라는 모호한 타입 말고, ID라는 명확한 타입으로 명시하기 위해 사용할 수 있습니다.

또한 여기에 ID만이 가져야할 고유한 행동이 존재한다면 그것까지 같이 캡슐화해서 value class에서 처리할 수 있습니다.

value 클래스와 인터페이스

value class는 인터페이스 상속도 가능합니다.

하지만 인터페이스를 상속하는 순간부터는 인라이닝 되지 않고 일반 객체로 나타나게 됩니다.

typealias

typealias는 단순하게 타입에 대한 이름을 붙이는 것입니다. value class와 달리 타입 안정성을 보장하지 못하며, 단순하게 이름만 붙이는 것입니다.

4. 더 이상 사용하지 않는 객체의 레퍼런스를 제거해라

자바를 사용하면 GC가 알아서 메모리 해제 작업을 해주니깐 객체 메모리 해제를 해주는 것에 대해서 관과 하게 됩니다.

물론 비용이 크지 않다면 GC가 해주게끔 하는 것이 코드를 깔끔하게 유지하는 면에서 좋을 것입니다. 하지만 그 비용이 크다면, 성능을 위해서라도 메모리로부터 해제 해주는 것이 좋습니다.

안드로이드를 처음 시작하는 많은 개발자가 흔히 실수로, static 변수로 Context를 들고 있게 하는 실수를 범합니다.

이는 JVM Method 영역으로 부터 참조 되고 있어, GC의 제거 대상에 포함되지 못해 프로세스 생명주기 동안 메모리에 계속 머물게 될 것 입니다.

메모리 문제는 굉장히 미묘한 곳에서 발생하는 경우가 많습니다. 다음과 같은 간단한 스택 구현을 살펴봅시다.

이 부분에서의 문제점은 무엇이라고 생각하시나요??

여기서의 문제는 pop() 호출 할 때가 문제입니다.

내부적으로 index만 감소하고 있고 해당 인덱스에 있는 데이터를 삭제하고 있지 않습니다.

물론 push()로 해당 index에 도달했을 때 새로운 값으로 채워지다 보니 Stack이 옛날값을 pop하거나 하진 않을 것입니다. 하지만 불필요한 값을 들고 있음으로써, 메모리는 점점 부족해져 갈 것입니다.

마지막으로 하나 더 예시를 보도록 하겠습니다.

이 코드의 문제점은 무엇 일까요?

바로 initializer를 제거 하지 않는 다는 것입니다. initializer는 최초에 한번만 사용 되고 그 다음부터는 사용 되지 않는 객체 임에도 이것을 계속 유지하고 있습니다.

그래서 이 코드의 최적화를 위해 initializer를 nullable로 만들어서 필요 없는 시점이 됬을 때 참조를 끊어줌으로써, GC가 Heap에 있는 initializer 객체를 제거하도록 해야합니다.

물론 “이렇게 까지 해야하냐?”에 대해서는 논란의 여지가 있습니다. 사실 이정도는 들고 있다고 해서 큰 문제는 발생하지 않습니다.

하지만 앞서 Stack 예제는 큰 문제가 발생할 여지가 있습니다. Array의 최대 사이즈만큼 담기게 된다면 이것은 큰 문제가 발생합니다.

그렇기 때문에 메모리 최적화 활동은 좋지만, 너무나 사소한 부분까지는 하지 않는게 오히려 좋습니다.

메모리 누수가 발생하는 케이스

  • 데이터 캐시

앞서 Stack의 예시처럼 데이터 캐시할 때 OOM이 발생할 가능성이 매우 높습니다. 그래서 메모리가 부족할 때 GC가 캐시를 다 없앨 수 있게 SoftReference를 활용하는 게 좋습니다.

  • Context

모종의 이유로 Context를 다른 객체에서 가지고 있어야 할 경우가 있습니다. 그럴 땐, 별도의 Strong한 참조가 없을 경우에 GC에 의해 제거될 수 있는 WeakReference를 사용하는 게 좋습니다.

사실 메모리 누수를 매번 예측하기란 쉽지 않습니다. 너무나 많은 코드 속에서 많은 고민을 담다보면 놓치기 쉽습니다. 그래서 툴을 활용하는 게 좋습니다.

Leak Canary, Heap Profiler를 활용해서 메모리 누수에 대한 트래킹을 하는 것이 좋습니다.

Effective Kotlin Chapter 7. Efficiency에 대한 요약은 여기까지 입니다.

다음 게시글에서 본 책의 마지막 장 Chapter 8. 효울적인 컬렉션 처리에 대한 요약으로 작성하도록 하겠습니다.

--

--