Jetpack Compose 람다 최적화에 대한 고찰

람다 최적화, 제대로 알고 활용하자.

Ji Sungbin
성빈랜드
7 min readAug 7, 2022

--

Photo by Christian Regg on Unsplash

Jetpack Compose 에서 람다식은 굉장히 많이 사용됩니다. 따라서 기본적으로 람다에 대한 최적화가 아주 많이 적용돼 있습니다. 이 중 가장 대표적인 최적화가 도넛 홀 건너뛰기 입니다. 도넛홀 건너뛰기에 대해선 다음 글을 참고해 주세요.

컴포즈에서의 람다 최적화는 “Jetpack Compose 컴파일러가 부리는 마법 완전히 파헤치기” 에서도 다룬 적이 있습니다. (람다 최적화가 컴포즈 컴파일 단계에서 진행됩니다)

1/5 캡처

컴포즈의 람다 최적화를 이해하기 위해선 JVM 에서 람다가 어떻게 컴파일 되는지를 먼저 봐야 합니다.

JVM 에서의 람다

람다는 2가지 유형으로 나뉩니다.

  • 값을 캡처하지 않는 람다
  • 값을 캡처하는 람다

값을 캡처히지 않는 람다를 먼저 보겠습니다.

간단하게 값 캡처 없이 “bye, world!” 를 출력하는 람다가 있습니다. 이 람다는 아래와 같이 컴파일 됩니다.

인자와 반환 값이 없기에 Function0<Unit> 으로 래핑됐고, invoke 를 override 하는걸로 컴파일 됩니다. 값 캡처가 없는 람다는 항상 동일한 결과를 반환할 것이기 때문에 Function 인스턴스를 싱글턴으로 캐싱하고, 해당 값을 invoke 하는 것으로 구현됩니다.

값을 캡처하는 람다도 보겠습니다.

name 변수를 캡처해서 “[$name] bye, world!” 를 출력하는 람다가 있습니다. 이 람다는 아래와 같이 컴파일 됩니다.

역시 이번에도 인자와 반환 값이 없기에 Function0<Unit> 으로 래핑됐고, invoke 를 override 하는걸로 컴파일 됩니다. 하지만 값을 캡처하기 때문에 캡처하는 값을 CaptureLambda 클래스의 생성자로 받게 됩니다.

캡처한 값이 언제든지 변경될 수 있으므로 해당 람다를 사용할 때마다 CaptureLambda 클래스에 캡처하는 값을 생성자로 넣어서 새로운 인스턴스를 만들어 주고, 해당 인스턴스를 invoke 하는 것으로 구현됩니다.

JVM 에서 람다는 이렇게 작동됩니다. 값을 캡처하는 람다를 보면 매번 새로운 인스턴스를 만들어서 invoke 하고 있습니다. 이게 컴포저블에 그대로 반영됐다면 매번 리컴포지션 마다 값을 캡처하는 모든 람다의 인스턴스를 새로 만들고 invoke 하느라 성능 손실이 막대했을 것으로 예상됩니다. 컴포즈 개발팀에서는 당연히 이를 인지하고 있었고, 이를 방지하기 위해 도입한 개념이 컴포즈 컴파일러의 람다 최적화 입니다.

컴포즈에서의 람다 최적화

컴포즈에서 람다는 일반 람다와 컴포저블 람다 2가지로 나뉩니다. 컴포저블 람다에 적용되는 최적화는 컴포즈 컴파일러 아티클에서 상세히 다루고 있으니 이번 글에서는 일반 람다의 최적화만 다루겠습니다.

컴포즈의 람다 최적화는 안정성 시스템에 기반을 두고 있습니다. 안정성 시스템이 미숙하신 분은 이 아티클을 먼저 읽어 주세요. (해당 안정성 아티클은 오늘(8월 7일) 업데이트 됐습니다)

간단하게 불안정한 클래스(UnstableClass)와 안정한 클래스(StableClass), 그리고 람다식을 인자로 받는 LambdaComposable 컴포저블이 있습니다.

그리고 이것들을 사용하는 Content 컴포저블이 있습니다.

  • 불안정한 클래스를 캡처하는 람다
  • 안정한 클래스를 캡처하는 람다
  • 불안정한 클래스에서 메서드 레퍼런스 참조를 받는 람다
  • 안정한 클래스에서 메서드 레퍼런스 참조를 받는 람다

불안정한 클래스와 안정한 클래스를 캡처하는 람다를 먼저 보겠습니다. 해당 컴포저블들은 컴파일되면서 아래와 같이 바뀝니다.

불안정한 클래스를 캡처하는 람다는 JVM 의 컴파일을 그대로 받아 UnstableClassUnit 인스턴스를 생성하고 invoke 하는 것으로 넘겨주고 있음을 볼 수 있습니다. 반면에 안정한 클래스를 캡처하는 람다는 캡처하는 값이 key 로 remember 되어 캐싱된 걸 확인할 수 있습니다.

안정성 시스템은 이렇게 람다 최적화에도 영향을 끼칩니다. 불안정한 값을 캡처하는 경우는 해당 값이 변경되도 컴포저블이 알 수 없기에 값이 언제 변경될 지 몰라 매번 요청마다 새로운 인스턴스를 생성하는 반면, 안정한 값을 캡처하는 경우에는 해당 값이 변경되면 컴포저블이 알 수 있으므로 해당 값을 기준으로 값을 캐싱하고 값이 변경됐다고 알려진 순간만 인스턴스를 새로 만들게 됩니다.

따라서 안정한 값을 캡처하는 람다를 사용하는 컴포저블만 skippable 상태가 됩니다.

이어서 불안정한 클래스와 안정한 클래스에서 메서드 레퍼런스 참조를 받는 나머지 람다들도 보겠습니다. 해당 컴포저블들은 컴파일 되면서 아래와 같이 바뀝니다.

기존과 똑같게 보인다면 정답입니다. 사실 메서드 레퍼런스 전달과, 바로 함수 사용이 모두 같게 컴파일 됩니다. 사소한 차이는 클래스에서 해당 함수를 바로 사용하는게 아닌, 해당 함수로 receiver 를 받아서 함수를 실행합니다.

결국은 다 같은 원리로 실행됩니다. 하지만, 레퍼런스 참조를 통한 람다 사용은 skippable 상태로 간주됩니다.

왜 이렇게 되는지는 아직 파악하지 못 했습니다. 한 가치 유추해 보자면, 메서드 레퍼런스 참조는 리플렉션을 통해 해당 메서드의 위치를 바로 나타내기 때문이 아닐까 싶습니다.

클래스에서 메서드를 사용하는 람다는 해당 클래스에서 메서드의 위치를 찾아서 사용하는 반면, 클래스의 메서드를 (리플렉션을 통해) 레퍼런스로 참조하는 람다는 해당 메서드의 위치를 바로 넘기기 때문에 항상 고정적인 값이 반환되여 skippable 상태가 되는거 같습니다. 물론 클래스가 항상 동일한 인스턴스라는 가정 하에 가능합니다.

컴포즈에서 레퍼런스 메서드 참조에 대한 자료가 없어서 저도 정확하게 이게 가능한 이유를 모르겠습니다. 유일하게 찾은 정보는 아래 트윗이 전부입니다.

이 트윗에 답변으로 여전히 모르겠다는 말이 있었고, 이 말에 대한 답변이 아래 트윗과 같습니다.

위 트윗에서 제안한 방법대로 바이트 코드를 분석해 보려고 했지만… 바이트 코드 분석은 처음이라 시간이 꽤 오래 걸릴거 같아서 미리 이 글을 작성하게 됐습니다.

결론

결론을 얘기하자면 “람다 최적화를 제대로 활용하기 위해선 값 캡처를 없애거나, 캡처하는 값을 안정 상태로 만들어야 한다” 입니다.

값 캡처가 없는 람다는 JVM 의 기본 최적화를 그대로 받아서 항상 싱글턴으로(=> 항상 skippable 상태) 구현됩니다.

끝!

이번 글에서는 컴포즈에서 람다 최적화가 등장한 이유와 어떤 원리로 작동되는지 보았습니다. 혹시 컴포즈에서 메서드 레퍼런스 참조가 왜 skippable 상태가 되는지 아시는 분이 계신다면 댓글로 지식 공유 부탁드립니다.

감사합니다.

안드로이드 개발자 분들을 위한 카카오톡 오픈 채팅방을 운영하고 있습니다.

--

--

Ji Sungbin
성빈랜드

Experience Engineers for us. I love development that creates references.