코틀린의 인라인에 대해 한 번에 알아보자

inline, noinline, crossinline, reified, mangling

아마 이 글의 제목을 보고 들어온 사람들이라면 인라인에 대해 아직 헷갈리거나, 지식을 보충하기 위해서 들어왔을 것이다. 하지만 우리는 인라인에 대해 잘 모르지만, 이미 많은 곳에서 사용하고 있다. 필자 또한 그래서, 이를 공부하고 공유하고자 해당 글을 작성한다.

인라인?

우리가 인라인을 어디서 쓰고 있을까? 필자의 경우 프로젝트를 진행하면서 가장 많이 쓰게 되는 forEach에 인라인이 적용돼 있었다. forEach는 다음과 같이 정의돼 있다.

forEach 함수가 인라인으로 선언돼 있다. forEach 외, repeat, Collection.filter, Collection.map 등등 내부에서 반복문이 돌아가는 함수들은 대부분 인라인으로 선언돼 있는걸 확인할 수 있다.

그렇다면 이토록 많이 쓰인 inline이 무엇을 하는 걸까? 또 왜 사용되고 있었던 걸까? 우선 인라인으로 선언하지 않은 함수와, 인라인으로 선언한 함수의 바이트코드를 비교해 보자.

보기 쉽게 위 코드의 바이트코드를 자바로 디컴파일하여 간단하게 한 후 비교하겠다.

위 결과를 보면, 인라인으로 선언한 printWithInline 함수는 main 함수에 바로 인라이닝 되었다. 하지만 인라인 하지 않은 printWithoutInline 함수는 함수가 호출되어 사용되는걸 확인할 수 있다.

원시타입 (Primitive type)

원시타입은 런타임에서 최적화가 매우 잘 이루어진다. 코틀린에서 String, Int, Boolean과 같이 원시타입으로 사용되는 것들을 디컴파일 해보면 어떻게 될까?

위 코드를 자바로 디컴파일 해보았다.

그 결과로, year 변수가 코틀린의 Int가 자바의 원시타입인 int로 변경돼 최적화가 되었다. birthday 변수 역시 자바의 원시타입인 int로 바뀌었고, 또한 값이 사용되지 않았으므로 true로 바뀌었다.

이처럼 원시타입은 런타임에서 굉장이 많은 최적화가 이루어진다. 코틀린의 Int가 자바의 원시타입인 int로 최적화가 이뤄지지 않고 Int 클래스 그 자체로 동작하게 된다면 엄청난 성능 손실이 발생했을 것이다.

박싱/언박싱 (Boxing/Unboxing)

박싱과 언박싱에 대해 알아보자. 인라인을 설명하는데 왜 갑자기 원시타입과 박싱/언박싱이 나왔는지 의아해 할 수도 있다. 하지만 인라인은 앞으로 말할 박싱 문제를 해결하기 위해 등장했다고 말해도 과언이 아닐거 같다.

원시타입을 설명하면서, 코틀린의 Int 클래스가 자바의 원시타입인 int로 최적화가 되는걸 확인하였다. 하지만 이 Int를 박싱해버린다면 어떻게 될까?

위와 같이 Int를 박싱하였고, 이를 디컴파일 해 보았다.

그럼 위와 같이, Int가 다른 클래스로 박싱이 되었으므로 int 최적화를 받지 못하고, 매번 새로운 인스턴스를 선언하여 런타임에 성능 손실이 발생한다.

이럴때 해당 IntBoxing 클래스를 인라이닝 함으로써, 이를 해결할 수 있다. 단순히 IntBoxing 클래스만 inline으로 만들고 다시 디컴파일을 해 보았다.

결과값을 보게 되면, 매번 인스턴스를 생성하는것이 아닌 int 처럼 동작하는걸 볼 수 있다.

번외로, 왜 멀쩡한 Int 클래스를 다른 클래스로 박싱을 하지?? 생각하시는 분들도 계실거 같아(나도 그랬다) 추가로 말하자면, 보통 좀 더 도메인적인 의미를 나타내고 싶을때 사용한다. 아래 코드를 보자.

그냥 바로 String, Int 로 타입을 줄 수 있는데, String과 Int를 각각의 도메인 클래스로 래핑하여 도메인적인 의미로 사용하였다.

이러면 그냥 typealias로 하면 되지 않나? 싶은 의문점이 또 생긴다. typealias와 inline의 차이점은 뭘까? 아래 코드를 보자.

typealias는 원래 있던 타입에 별칭을 새로 붙여주는 것이고, inline은 아예 새로운 타입을 만드는거라고 볼 수 있다. 따라서 PersonName이라는 인라인 클래스에 getNameLength 함수를 추가할 수 있다.

인라인 (inline)

어느정도 알겠으니 이제 다시 주제로 돌아와, 인라인의 특징에 대해 알아보자. 인라인은 크게 5가지의 특징을 갖는다.

  • 프로퍼티는 하나만 허용된다. 객체의 래핑타입을 지원하기 위한 것이니 당연한 얘기다. 이해가 잘 되지 않는다면 자바의 Integer 클래스의 필드가 2개라고 생각해보자.
  • 프로퍼티는 backing field를 갖지 않는다.
  • 프로퍼티는 불변이다.
  • 인터페이스를 구현할 수 있다.
  • 항상 final이다. 즉, 다른 클래스가 inline 클래스를 상속할 수 없다.

인라인은 언제 가장 큰 힘을 발휘 할 수 있을까?

위 코드와 같이 고차함수가 쓰일 때 인라인을 더해주게 되면 강력한 힘을 볼 수 있다. 인라인이 없는 위 코드를 디컴파일 해보자.

디컴파일이 위처럼 되고, Function.invoke를 하기 위해서 결국엔

이렇게 바뀔 것이다. 보면 고차함수로 받은 println("Bye, world!")를 실행하기 위해 println("Bye, world!")의 Function을 매번 인스턴스화 시켜 실행시킨다. 이제 원래 코드의 doIt 함수에 inline을 붙여서 다시 디컴파일을 해 보자.

println("Bye, world!")를 Function으로 만들지 않고, 바로 함수 내에서 System.out.println으로 받아서 실행되는걸 볼 수 있다. 이처럼 인라인은 고차함수에서 가장 강력하게 힘을 발휘할 수 있다. 맨 처음에 소개했던 forEach가 인라인으로 작성되지 않았더라면 매 반복마다 Function을 인스턴스화 시키느라 성능 손실이 엄청났을 것이다.

원래 이쯤에서 글을 끊고, 2편에 이어서 쓸려고 했으나 2편을 언제 쓸 지 몰라 그냥 한 번에 다 쓰려고 한다.

noinline

지금까지 봐왔듯이 인라인은 함수를 인스턴스화 하지 않고 바로 실행시킨다.

위 코드를 보면, inlineFunction이 inline으로 선언됐고 actionWithOtherFunction 인자를 otherFunction 함수의 인자로 넣어주고 있다. 눈치 채신 분들도 계시겠지만, 이는 actionWithOtherFunction 인자가 인라인되어 인스턴스 생성이 불가능하기 때문에 불가능한 코드이다.

이를 해결해주기 위해선 actionWithOtherFunction 인자에 noinline 키워드를 추가해줄 수 있다.

이를 자바로 디컴파일해보면 다음과 같이 noinline를 붙여준 actionWithOtherFunction 인자만 인라인이 되지 않은걸 볼 수 있다.

crossinline

이제 알아볼 인라인 키워드는 crossinline이다. 이는 간단히 말해서 해당 람다가 넌로컬 리턴을 허용하지 않는다는 것을 명시적으로 나타내주는 역할을 한다. 이게 무슨 뜻인지 이해하기 위해 아래 코드를 디컴파일 해보자.

이 코드를 자바로 디컴파일 하게 되면 아래와 같이 된다.

위 디컴파일된 자바 코드를 보면, executor에서 넌로컬 리턴을 허용했다면 main 함수가 종료되어 “Bye, world!” 가 출력되지 않았을 것이다. 이처럼 crossinline는 인라인으로 받은 고차함수가, 다른 컨텍스트에서 실행될 때 넌로컬 리턴을 허용하지 않는다고 명시해줄 때 사용된다.

reified

사실 reified는 인라인을 위한 기능이라기보단, 제네릭을 좀 더 효율적으로 쓰기 위해 존재하는 기능이라고 말하는게 나을거 같다. 제네릭은 런타임 과정에서 type erasure에 의해 타입 인자가 사라진다. 따라서 타입을 유지시키려면 아래와 같이 명시적으로 인자로 타입을 넘어주어야 한다.

하지만 이때 타입 인자 T에 reified를 넣어주고, 인라인으로 함수를 만들게 되면 명시적으로 타입을 인자로 넘겨줄 필요 없이, 타입 인자 T에 접근할 수 있게 된다(또한 함수의 가독성도 더 좋아진다). 이게 어떻게 가능할까?

reified된 타입 인자와 함께 함수가 인라이닝 되면, 컴파일러는 타입 인자로 사용된 실제 타입을 알고, 만들어진 바이트코드를 직접 클래스에 대응되도록 바꿔준다. 따라서 is Tis String 처럼 사용될 수 있다.

맹글링 (Mangling)

위 코드를 보면, Int를 래핑한 IntWrapper 클래스가 인라인으로 선언돼 있고, IntWrapperInterface를 보면 Int와 IntWrapper를 인자로 받는 hi 함수가 생성돼 있다. IntWrapper가 인라인으로 돼있으므로, 결국엔 Int로 바뀔것이고 이러면 최종적으론 똑같은 hi 함수가 2개로 생성될 것이다. 이것이 어떻게 오류 없이 가능한 것일까? 정답은 맹글링에 있다. 위 코드를 자바로 디컴파일 해보자.

IntWrapper가 인라인에 의해 int로 바뀌었고, 함수 이름이 맹글링에 의해 hi_해쉬코드 형태로 변경된걸 확인할 수 있다. 이로써 함수가 겹치더라도 오류가 발생하지 않는 것이다.

따라서 아래 코드를 실행시켜 보면

결과 값으로, 2와 1이 나오게 된다.

마무리

이렇게 해서 코틀린의 인라인에 대해 한 번에 정리해 보았다. 인라인은 어떤 상황에 쓰면 좋을지 정리하면서 글을 끝내도록 하겠다. 2편으로 넘어가지 않게 한 편으로 글을 다 쓰다 보니깐, 너무 글이 길어졌지만 지금 이 마무리를 보고 있다면 이렇게 긴 글을 끝가지 봐 주어서 고맙다고 말하고 싶다.

  • 모든 고차함수가 직접 호출되거나, 다른 인라인 함수로 전달될 때 인라인을 써야 한다.
  • 함수의 매개변수가 함수 내부의 변수에 할당되는 구조라면 인라인을 하면 안된다.
  • 로직이 복잡한(내용이 많은) 함수는 인라인 하면 안된다. 인라인은 모든 바이트코드를 호출된 위치에 복사하는데, 많은 양의 바이트 코드를 복사하는것은 오히려 성능을 더 해칠 수 있다.
  • reified를 사용할 때 인라인을 해야 한다.

iOS 개발자를 꿈꾸는 안드로이드 개발자 / github.com/jisungbin

iOS 개발자를 꿈꾸는 안드로이드 개발자 / github.com/jisungbin