과거에는 메모리가 비쌌고 하드웨어적인 제약이 컸기 때문에 효율성(efficiency) 및 최적화에 많은 노력을 기울였다. (1985년에 출시한 게임, 마리오 브라더스 용량이 약 40kB이였던 것에 대해 생각해보자)

하지만 오늘날에는 상대적으로 하드웨어보다 개발자의 몸값이 비싸기 때문에 과거보다는 효율성에 대해 상대적으로 관대하게 바라본다.하지만 여러가지 측면에서 최적화는 여전히 중요하다. 조금만 최적화에 신경을 써도 사용자 및 기업이 얻는 이익은 크게 달라 질 수 있다. 다음의 예시를 보자.

  • 서버 인프라에 지불하는 비용 감소. 사용자 한명당 발생하는 비용 1원만 절약해도 사용자가 천만명이면 천만원 비용 절약.
  • 모바일에서 CPU사용량을 줄여 퍼포먼스 개선 및 베터리 사용 시간 증대

이번 장에서는 비용이 크게 들어가지 않으면서 효율적으로 프로그램을 만들 수 있는 최적화 방법에 대해서 알아본다.

불필요한 객체 생성을 피하자

객체 생성에는 언제나 비용이 든다. 상황에 따라 굉장히 큰 비용이 들기도 한다. 그렇기 때문에 객체 생성을 줄이는 것이 최적화에 도움이 된다. JVM에서는 동일한 문자열을 처리한다면 기존의 문자열을 재사용한다. 다음 예제코드를 살펴보자.

url1과 url2는 동일한 레퍼런스다. url3는 객체를 새로 생성하므로 메모리를 할당받는다. 그러므로 url2 또는 url1과는 다른 레퍼런스를 갖는다.

Note: String.intern()은 JVM 내 String 풀에서 동등한 문자열이 있는 경우 이를 반환한다.

Integer 및 Long처럼 박스화한 기본 자료형도 작은 값은 재사용 된다. 기본적으로 Int는 -128~127 범위를 캐시해둔다. 다음 예제코드를 확인해보자

객채 생성 비용

  • 64비트 JDK에서 객체는 8바이트의 배수만큼 공간을 차지하고 헤더는 12바이트를 차지한다. 그러므로 객체 생성시 최소 크기는 16바이트다.
  • 기본 자료형 int는 4바이트지만 Integer는 16바이트이며, 추가로 이에 대한 레퍼런스로 인해 8바이트가 더 필요하다.
  • JVM객체의 구체적인 필드 크기를 확인하려면, Java Object Layout을 사용하면 된다.
  • 캡슐화 되어 있는 어떤 요소에 접근하기 위해선 함수 호출이 필요하다. 이것은 작은 비용이지만, 티클 모아 태산이다.
  • 위에서 언급하였듯 객체 생성자체가 비용이므로, 불필요한 객체 생성은 피하도록 하자

객체 선언

싱글톤 또는 링크드리스트를 통해 생성한 객체를 재사용하면 객체 생성 비용을 줄일 수 있다.

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

팩토리 함수는 캐시를 가질 수 있다. 그래서 항상 같은 객체를 반환하게 만들 수 있다. 객체 생성이 무겁거나 동시에 여러 mutable 객체를 사용해야 하는 경우에는 객체 풀을 사용하는 것이 좋다.

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

무거운 연산은 바깥쪽으로 빼서 중복작업을 하지 않아야 한다. 리스트에 최대값이 몇개 포함되는지 계산하는 확장함수를 만들고 비교해보자.

지연 초기화

무거운 객체를 생성할 때는 lazy키워드를 사용하여 초기화를 지연시키는 것이 좋다. 하지만 호출에 대한 응답 시간이 길어 질 수 있으므로 항상 지연 초기화가 좋은 것은 아니다. 상황에 맞게 사용하자.

기본 자료형 사용하기

기본 자료형은 다음과 같은 특징을 갖는다

  • 가볍다. 일반적인 객체와 다르게 추가적으로 포함되는 것이 없음.
  • 빠르다. 값에 접근할 때 추가 비용이 들지 않는다.

기본 자료형을 사용하는 최적화는 코틀린/JVM 및 코틀린/Native 버전에서만 의미가 있다. 또한 숫자에 대한 작업이 여러 번 반복 될 때만 의미가 있다. 따라서 굉장히 큰 컬렉션을 처리할 때 차이를 확인할 수 있다.

함수 타입 파라미터를 갖는 함수에 inline 한정자를 붙이자.

코틀린 표준 라이브러리에 포함된 repeat 함수

inline 한정자의 역할은 컴파일 타임에 함수를 호출하는 부분을 함수의 본문으로 집어 넣는 것이다. 예를들어 다음과 같이 repeat 함수를 호출하는 코드가 있다면 컴파일 타임에는 for문으로 대체 된다.

컴파일 전
컴파일 된 바이트코드를 자바로 디컴파일

inline 한정자를 사용하면 다음과 같은 장점을 갖는다.

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

fun <T> printTypeName() {
    print(T::class.simpleName) // 컴파일 오류
}
inline fun <reified T> printTypeName() {
    print(T::class.simpleName)
}

printTypeName<Int>() // Int
printTypeName<Char>() // Char
printTypeName<String>() // String

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

모든 함수는 inline 한정자를 붙이면 조금 더 빠르게 동작한다. 함수 호출과 리턴을 위해 점프하는 과정과 백스택을 추적하는 과정이 없기 때문이다. 그렇기 때문에 표준 라이브러리에 있는 대부분의 함수가 inline이 붙어있다.

사실 함수 파라미터를 가지지 않는 함수에서는 큰 성능 차이가 없다.

함수를 인자로 갖는 경우 함수는 다음의 타입으로 컴파일 된다.

  • ()->Unit은 Fuction0<Unit>으로 컴파일
  • (Int)->Int는 Fuction1<Int, Int>로 컴파일
  • (Int, Int)->Int는 Fuction2<Int, Int, Int> 로 컴파일

매개변수 갯수에 따라 FuctionN의 타입으로 변경된다고 볼 수 있다. 이제 inline 함수와 inline 함수를 사용하지 않을 때 성능을 비교해보자

inlineRepeat함수는 main 함수내로 삽입되어 action인 doNothing을 직접적으로 호출하지만,

noinlineRepeat함수는 action인자 타입이 Fuction1<Int, Unit>으로 컴파일 되어 action.invoke(index) 형태로 호출한다. 이러한 부분 때문에 인라인함수보다 비용이 더 든다.

3. 비지역(non-local) 리턴을 사용할 수 있다.

위 예제코드에서 선언한 noinlineRepeat는 내부에서 리턴을 사용할 수 없다.

이는 함수가 객체로 래핑되어서 발생하는 문제로, 함수가 다른 클래스에 위치(non-local)하므로 return을 사용해서 main으로 돌아올 수가 없는 것이다.

인라인 함수는 main 함수내에 포함되므로 return을 포함할 수 있다.

inline 한정자의 한계와 비용

  • public 인라인 함수 내부에서 private과 internal 가시성을 가진 함수 및 프로퍼티를 사용할 수 없다.
  • 인라인 한정자를 남용하면 코드의 크기가 기하 급수적으로 커질 수 있다.

crossinline과 noinline

crossinline: 인라인 함수를 매개변수로 받았지만, 비-지역적(non-local) 반환은 불가하다. 인라인으로 만들지 않은 다른 람다 표현식과 조합해서 사용할 때 문제가 발생하는 경우 활용할 수 있다.

noinline: 인자로 인라인 함수를 받을 수 없게 만든다. 인라인 함수가 아닌 함수를 인자로 사용하고 싶을 때 활용한다.

인라인 클래스의 사용을 고려하자

때때로 비즈니스 로직을 위한 래퍼클래스 생성이 필요하다. 그러나 추가 힙 할당으로 인해 런타임 오버헤드가 발생하고, 래핑된 타입이 원시 타입인 경우 성능이 크게 저하된다.

이러한 문제를 해결하기 위해 코틀린에서는 특별한 종류의 클래스를 제공한다. 바로 인라인 클래스다. 인라인클래스는 값 기반 클래스들의 부분집합으로 독자성(identity)을 갖지 못하고, 값만 갖게 된다.

인라인 클래스를 선언하는 방법은 클래스에 value 키워드를 붙이는 것이다.

value class Name(private val value:String)

JVM 백엔드에 대한 인라인 클래스를 선언하려면 클래스에 @JvmInline 애노테이션을 붙여야 한다.

@JvmInline
value class Name(private val value:String)

인라인 클래스는 주 생성자에서 반드시 단 하나의 프로퍼티를 갖는다. 런타임에 인라인 클래스의 인스턴스들은 인라인 클래스의 객체가 아닌 단일 프로퍼티로 사용된다.

// 작성한 코드
val name: Name = Name("Charles")

// 컴파일 타임에 바뀌는 코드
val name: String = "Charles"

다음과 같은 상황에서 인라인 클래스가 사용된다.

1. 측정 단위를 표현할 때

흔히 초(seconds)와 밀리초(milliseconds)를 구분하기 위해 변수 또는 함수 인자에 ms 또는 ~millis와 같은 이름을 붙이곤 한다. 하지만 더 좋은 해결 방법은 다음과 같이 타입에 제한을 거는 것이다.

@JvmInline
value class Minutes(val min: Int) {
    fun toMillis(): Millis = Millis(min * 60 * 1000)
}

@JvmInline
value class Millis(val ms: Int) {

}

interface Timer {
    fun callAfter(ms: Millis, callback: () -> Unit)
}

fun setTimer(min: Minutes, timer: Timer) {
    timer.callAfter(min.toMillis()) {
        println("Wake Up!")
    }
}

안드로이드에서 px, dp 등의 다양한 단위를 제한할 때 활용할 수 있다.

2. 타입 오용으로 발생하는 문제를 막을 때

Room을 사용할 때 다음과 같이 동일한 타입의 컬럼을 테이블을 다루는 경우 실수로 잘못된 값을 넣을 수도 있다.

@Entity(tableName = "grades")
class Grades(
    @ColumnInfo(name = "studentId")
    val studentId: Int,

    @ColumnInfo(name = "teachertId")
    val teacherId: Int,

    @ColumnInfo(name = "schoolId")
    val schoolId: Int,
    ...
)

이러한 문제를 미리 막으려면 다음과 같이 Int 자료형을 인라인 클래스로 래핑하면 된다.

@JvmInline
value class StudentId(val id:Int)

@JvmInline
value class TeacherId(val id:Int)

@JvmInline
value class SchoolId(val id:Int)

@Entity(tableName = "grades")
class Grades(
    @ColumnInfo(name = "studentId")
    val studentId: StudentId,

    @ColumnInfo(name = "teachertId")
    val teacherId: TeacherId,

    @ColumnInfo(name = "schoolId")
    val schoolId: SchoolId,
    ...
)

이렇게 하면 안전해지며, 컴파일 타임에도 타입이 Int로 대체되므로 문제없이 동작하게 된다.

인라인 클래스를 사용하면 안전을 위해 새로운 타입을 도입해도, 오버헤드는 발생하지 않는다.

인라인 클래스와 typealias

인라인 클래스는 typealias와 매우 유사해 보인다. 그러나 중요한 차이점은 typealias는 근본적인 타입(underlying type)과 호환이 되지만 인라인 클래스는 그렇지 않다는 점이다. 다음 예제 코드를 통해 통찰을 얻자.

더 이상 사용하지 않는 객체의 레퍼런스를 제거하자

자바나 코틀린 처럼 메모리 관리를 자동으로 해주는데 익숙한 개발자는 객체 해제(free)를 고려하지 않는다. 가비지 콜렉터(GC:Garbege Collector) 대신 알아서 해주기 때문이다. 그렇다고 메모리 관리에 신경을 전혀 안쓰면 OOM(Out Of Memory) 에러에 직면하기도 한다.

가장 기억하기 쉬운 원칙은 ‘불필요한 객체의 레퍼런스는 유지하지 않는다‘ 이다. 이를 기억하고 다음의 내용을 유의하자.

  • 흔히 자바의 static 키워드 또는 코틀린의 companion 으로 유지해 버리면 해당 객체에 대한 메모리가 정적으로 고정되기 때문에 메모리를 해제 할 수 없다.
  • 서로 다른 스코프 또는 생명주기를 갖는 객체를 레퍼런싱 하지 않도록 유의하자. 예를 들어 ViewModel에서 Activity를 레퍼런싱 GC 되지 않기 때문에 메모리 누수가 발생한다.
  • 사용하지 않는 객체는 null로 설정하자. GC 대상으로 만드는 것이다.
  • SoftReference 또는 WeakReference를 활용하자.
  • 힙 프로파일러, Leak Canary 등 별도의 도구를 활용하자
  • 톱레벨 프로퍼티 또는 객체 선언으로 큰 데이터를 저장하지 않도록 하자.
  • 자주 사용하지 않을 데이터를 캐시하지 말자.
카테고리: Kotlin

0개의 댓글

답글 남기기

Avatar placeholder

이메일은 공개되지 않습니다. 필수 입력창은 * 로 표시되어 있습니다.