컴포즈 공식 가이드 읽고 분석하기 — (1)

김종식
14 min readFeb 24, 2022

--

(목차로 돌아가기)

Thinking in Compose

젯팩 컴포즈는 안드로이드 모던한 선언형 UI 툴킷입니다. 컴포즈는 프론트엔드의 뷰를 반드시 수정하지 않아도 앱에서 UI 를 렌더링 할 수 있는 선언형 API를 제공함으로써 앱 UI를 보다 쉽게 작성하고 유지보수 하도록 만들어줍니다. 몇 가지 용어들은 설명이 필요한데요, 앱 설계에서 그 의미가 중요하기 때문입니다.

The declarative programming paradigm

전통적으로, 안드로이드 뷰 계층은 UI 위젯의 트리 형태로 표현되어 왔습니다. 사용자 인터렉션 등 앱의 상태 변경에 따라, 표시되고있는 UI 계층구조는 현재 데이터로 갱신이 필요했습니다. 가장 일반적인 방법은 findViewById()를 통해 뷰 계층을 탐색하여 setText(String), addChild(View) 또는 setImageBitmap(Bitmap) 와 같은 함수를 호출하여 갱신하는 것이었습니다. 이러한 함수들은 위젯의 내부 상태를 변경합니다.

뷰를 수동으로 조작하는 것은 오류 가능성을 증가시킵니다. 어떤 데이터가 여러 뷰에서 렌더링 되면, 데이터가 표시된 뷰 중 하나를 업데이트하는 것을 잊기 쉽습니다. 또한 두 가지 업데이트가 충돌날 경우 예기치 않게 오류 상태가 발생되기 쉽습니다. 예를 들면, 방금 UI 에서 제거된 노드(=UI 요소)에 값을 설정하는 경우입니다. 일반적으로 소프트웨어의 유지보수 복잡성은 업데이트가 필요한 뷰 수에 따라 증가합니다.

지난 몇 년간 산업 전반적으로 선언적 UI 모델로 전환하기 시작했고, 이는 사용자 인터페이스를 구현하는 엔지니어링과 관련하여 크게 간소화 되었습니다. 이 기술은 개념적으로 전체 화면을 생성한 후, 변경 사항만 적용하는 방식으로 동작합니다. 이러한 방법은 뷰 계층에 수동적으로 접근하여 상태를 업데이트하는 복잡성을 방지합니다. 컴포즈는 선언적 UI 프레임워크 입니다.

화면을 재생성하는것은 잠재적으로 시간적, 컴퓨팅 성능, 베터리 소모에 많은 비용이 발생됩니다. 이러한 비용을 줄이기 위해, 컴포즈는 주어진 시간 내 다시 그려야 하는 UI 부분을 지능적으로 선택합니다. 이는 재작성(Recomposition) 에서 설명한 것 처럼 UI 구성 요소를 설계하는 방법에 영향을 미칩니다.

A simple composable function

컴포즈의 컴포저블 함수를 활용하여 데이터를 가져와서 UI 요소를 복합적으로 표현할 수 있습니다. 간단한 예로 Greeting 위젯은 String을 취하고, Text 요소를 표현하여 인사 메시지를 표시하는 예제입니다.

Figure 1. 데이터를 전달하고 그것을 이용하여 화면에 텍스트 위젯을 그리는 단순한 컴포저블 함수입니다.

이 함수에서 몇 가지 주목 할 만한 것이 있습니다 :

  • 함수는 @Composable 어노테이션으로 지정되어 있습니다. 모든 컴포저블 함수들은 이 어노테이션이 필요합니다; 이 어노테이션은 컴포즈 컴파일러에 이 함수가 데이터를 UI로 변환하는 것임을 알립니다.
  • 함수는 데이터를 취합니다. 컴포저블 함수는 파라미터를 허용함으로써 앱 로직에서 UI 를 구성하도록 합니다. 이 예제에서는 String 타입의 name 을 허용하고 있습니다.
  • 이 함수는 UI 에 텍스트를 표시합니다. Text() 컴포저블 함수를 호출하여 실제로 텍스트 UI 요소를 만들 수 있습니다. 컴포저블 함수는 다른 컴포저블 함수를 호출하여 UI 계층을 내보냅니다.
  • 함수는 아무것도 리턴하지 않습니다. (컴포저블 함수가) 화면의 상태를 표시하기 때문에 UI 를 표시하는 컴포즈 함수는 UI 어떤것도 리턴할 필요가 없습니다.
  • 이 함수는 빠르고, 멱등성이 있고, 부가 작용(side-effect) 에 자유롭습니다.
    - 함수는 동일한 인수를 사용하여 호출될 경우 동일한 방식으로 동작하며, 전역 변수나 random() 과 같은 다른 값을 사용하지 않습니다.
    - 함수는 프로퍼티나 전역변수 수정과 같은 부가 작용과 무관하게 UI를 작성되어야 합니다.
    일반적으로 모든 컴포저블 함수들은 Recomposition 에서 논의되었던 이유로 이러한 프로퍼티들로 작성되어야 합니다.

(* Side-effect 는 함수 호출로 상태를 변경하는 것을 의미합니다. 예를들어 = 연산자, 설정자 함수들은 부가 작용을 갖는 연산입니다. 자세한 내용은 Side-effects 문서를 참조하세요.)

멱등성(idempotent) — 연산을 여러 번 실행하여도 결과가 달라지지 않는 성질

The declarative paradigm shift

객체지향을 지원하는 명령형 UI 툴킷을 사용하면 UI를 위젯의 트리형태로 인스턴스화 하여 초기화할 수 있습니다. 종종 이것을 xml 레이아웃 파일 인플레이팅(inflating) 함으로써 실행합니다. 각 위젯은 내부 상태를 갖고 유지되며, 위젯과의 상호작용을 할 수 있도록 getter / setter 를 제공합니다.

컴포즈에서 선언적 접근 방식은 위젯은 상태를 저장하지 않으며 getter / setter 를 제공하지 않습니다. 사실상 위젯은 객체로서 노출되지 않습니다. 동일한 컴포저블 함수를 다른 인자를 이용하여 호출함으로써 UI 를 업데이트 합니다. 이것은 앱 아키텍처 가이드라인에서 소개하고 있는 ViewModel 과 같은 아키텍처 패턴에서 상태를 쉽게 제공할 수 있습니다. 컴포저블 함수는 관찰 가능한 데이터가 업데이트 될 때마다 현재 어플리케이션 상태를 UI로 변환하는 역할을 합니다.

Figure 2. 어플리케이션 로직은 최상위 컴포저블 함수에 데이터를 제공합니다. 이 함수는 데이터를 사용하여 다른 합성 가능 함수를 호출하고, 해당 컴포넌트 및 계층의 아래로 해당 데이터를 전달하게 됩니다.

사용자가 UI와 상호작용을 할 때, UI는 onClick 과 같은 이벤트를 올려줍니다. 이러한 이벤트들은 앱의 상태를 변경하는 로직을 호출할 수 있습니다. 상태가 변경되면, 컴포저블 함수는 새로운 데이터로 다시 호출됩니다. 이렇게 UI 요소가 다시 그리는 실행 프로세스를 재구성(Recomposition) 이라고 합니다.

Figure 3. 유저가 UI 요소와 상호작용을 하면, 이벤트는 트리거가 됩니다. 앱 로직은 이 이벤트에 응답한 다음, 필요한 경우 컴포저블 함수는 자동적으로 새로운 파라미터를 가지고 자동으로 다시 호출됩니다.

Dynamic content

컴포저블 함수는 XML 대신 코틀린으로 작성되기 때문에, 다른 코틀린 코드처럼 다이나믹하게 작성할 수 있습니다. 예를들어, 사용자 목록을 표시하는 UI를 작성한다고 가정해 봅시다:

이 함수는 names 목록에서 각 사용자에 대한 인사말을 생성합니다. 컴포저블 함수는 상당히 정교하며 복잡할 수 있습니다. if 문을 사용하여 특정 UI 요소를 표시할 것인지 결정할 수 있습니다. 반복문을 사용할 수도 있습니다. 다른 헬퍼 함수를 사용할 수도 있습니다. 개발 언어에 대한 완전한 유연성을 가지고 있습니다. 이러한 유연성과 강점이 젯팩 컴포즈의 주요 장점 중 하나입니다.

Recomposition

명령형 UI 모델에서 위젯을 변경하려면, 위젯의 setter 를 호출하여 뷰 내부 상태를 변경해야 합니다. 컴포즈에서는 새로운 데이터를 이용하여 컴포저블 함수를 다시 호출하면 됩니다. 그렇게 하면 함수는 새로운 데이터를 가지고, 필요하다면, 함수에 의해 그려진 것을 뷰로 재구성하게(recomposed) 됩니다. 컴포즈 프레임워크는 변경된 구성요소만 지능적으로 재구성 할 수 있습니다.

예를들어, 버튼을 표시하는 컴포저블 함수가 있다고 가정해 봅시다.

버튼이 클릭될 때 마다, 호출자는 clicks 값을 변경합니다. 컴포즈는 새로운 값을 계속 보여주기 위하여 람다로 구성된 Text 를 호출합니다; 이 프로세스를 재구성(Recomposition) 이라고 합니다. 변경값과 관련없는 다른 함수들은 재구성되지 않습니다.

앞서 논의되었던 것 처럼 전체 UI트리를 재구성하는 것은 컴퓨팅 성능 및 베터리 수명 비용이 많이 들 수 있습니다. 컴포즈는 이 문제를 지능적 재구성(Intelligent recomposition)을 통하여 해결합니다.

재구성(Recomposition)은 입력이 변경되었을 때 컴포저블 함수를 다시 호출하는 프로세스입니다. 이것은 함수의 입력이 변경되었을 때 발생됩니다. 컴포즈는 새로운 입력을 기준으로, 변경되었을 수 있는 함수 또는 람다만 호출하고 나머지는 생략합니다. 파라미터가 변경되지 않은 함수 또는 람다를 생략함으로써, 컴포즈는 효율적으로 재구성을 할 수 있습니다.

함수의 재구성이 생략될 수 있기 때문에, 컴포저블 함수를 실행하는데 있어 부가 작용이 의존적이지 않도록 하십시오. 이렇게 하면(=부가 작용이 발생하게끔 하면), 사용자는 당신의 앱에서 이상하고 예측하지 못한 경험을 하게 될 것입니다. 부가 작용는 앱의 나머지 부분에서 발생 가능한 모든 부분입니다. 예를들면, 이러한 행위는 모든 위험한 부가 작용입니다:

  • 공유 객체 프로퍼티를 작성 (Writing to a property of a shared object)
  • 뷰모델의 관찰 객체를 직접 업데이트 (Updating an observable in ViewModel)
  • 공유되고 있는 설정 업데이트 (Updating shared preferences)

컴포저블 함수는 애니메이션이 매 프래임으로 렌더링하는 것과 같이 자주 재실행 될 수 있습니다. 컴포저블 함수는 애니메이션 중 충돌을 피하기 위하여 더 빨라야 합니다. 만약 공유자원을 읽어오는 것(SharedPreferences) 처럼 큰 비용이 발생되는 작업을 수행할 때, 백그라운드 코루틴에서 실행되며 컴포저블 함수에 결과 값을 파라미터로 전달합니다.

예를들어, 이 코드는 SharedPreferences 값을 이용하여 컴포저블을 업데이트 합니다. 이 컴포저블 함수는 스스로 shared preferences를 읽거나 쓰지 않습니다. 대신, 읽기 및 쓰기 코드는 ViewModel 의 백그라운드 코루틴으로 이동합니다. 앱 로직은 현재 값을 콜백과 함께 전달하여 화면 업데이트를 트리거 합니다.

컴포즈로 프로그래밍 시 몇 가지 유의사항이 있습니다:

  • 컴포저블 함수는 임의의 순서로 실행될 수 있습니다.
    (Composable functions can execute in any order.)
  • 컴포저블 함수는 병렬로 실행될 수 있습니다.
    (Composable functions can execute in parallel.)
  • 재구성 작업은 컴포저블 함수와 람다를 가능한 많이 생략합니다.
    (Recomposition skips as many composable functions and lambdas as possible.)
  • 재구성은 낙관적으로, 취소할 수 있습니다.
    (Recomposition is optimistic and may be canceled.)
  • 컴포저블 함수는 애니메이션의 모든 프레임 만큼 자주 실행될 수 있습니다.
    (A Composable function might be run quite frequently, as often as every frame of an animation.)

다음 섹션에서 재구성을 지원하기 위해 어떻게 컴포저블 함수가 빌드 되는지 확인할 수 있습니다. 모든 경우에 있어, 컴포저블 함수를 빠르고 멱동적(idempotent)이며, 부가 작용 없이 유지하는 것이 좋습니다.

Composable functions can execute in any order.

컴포저블 함수를 보면, 아마 코드의 순서대로 실행될 것으로 추측할 수 있습니다. 하지만 이것은 반드시 사실이 아닙니다. 만약 어떤 컴포저블 함수가 다른 컴포저블 함수를 호출한다면, 해당 함수는 임의의 순서로 실행될 수 있습니다. 컴포즈에는 일부 UI 요소가 다른것보다 우선순위가 높아 그것들을 먼저 그리는 옵션이 있습니다.

예를들어, 아래와 같이 탭 레이아웃 안에 3개의 화면을 그리는 코드가 있다고 가정해 봅시다.

StartScreen, MiddleScreen, EndScreen 은 어떤 순서로든 호출될 수 있습니다. 이것은, 예를들어 StartScreen() 에서 일부 전역변수를 설정하고 MiddleScreen() 이 그 변경요소를 취할 수 없다는 것을 의미합니다. 대신, 각 함수는 자체적으로 필요 요소를 포함하고 있어야 합니다.

Composable functions can run in parallel

컴포즈는 재구성 작업 실행 시 컴포저블 함수를 병렬로 실행하여 최적화 할 수 있습니다. 이것은 멀티코어에서 장점이 발휘되며, 낮은 우선순위로 화면에 표시되지 않는 합성 가능 함수를 실행시킬 수 있습니다.

이 최적화는 컴포저블 함수가 백그라운드 스레드풀에서 실행될 수 있다는 것을 의미합니다. 만약 컴포저블 함수가 ViewModel 의 함수를 호출하면, 컴포즈는 동시에 몇몇 스레드에서 해당 함수를 호출할 수 있습니다.

애플리케이션이 정상적으로 동작하게 하려면, 모든 컴포저블 함수는 부가 작용이 없어야 합니다. UI스레드에서 항상 실행되는 onClick 과 같은 콜백에서 부가 작용이 트리거 될 수 있습니다.

컴포저블 함수가 호출될 때, 호출자와 다른 스레드에서 호출이 발생될 수 있습니다. 즉, 합성 람다에서 변수를 변경하는 것은 피해야 합니다. 이러한 코드는 스레드에 안전하지도 않고, 컴포저블 람다에서도 허용할 수 없는 부가 작용이기 때문입니다.

여기 리스트와 그 갯수를 표시하는 합성 함수 예제가 있습니다.

이 코드는 부가 작용 없이 리스트를 UI로 변환됩니다. 이것은 적은 목록을 표시하는데 좋은 코드입니다. 하지만, 만약 함수가 로컬 변수를 사용한다면 이 코드는 스레드로부터 안전하지 않거나 올바르지 않은 코드가 됩니다.

이 예제에서 items 은 매번 재구성 될때마다 변경됩니다. 이러한 변경은 애니메이션의 매 프레임마다 혹은 리스트가 업데이트 될 때마다 발생될 수 있습니다. 이러한 이유로, 컴포즈 내에서 이런 작성방법은 지원하지 않습니다; 이러한 쓰기를 금지함으로써 프레임워크가 컴포저블의 람다를 실행하기 위한 스레드를 변경할 수 있습니다.

Recomposition skips as much as possible

UI 부분 중 일부가 올바르지 않다면, 컴포즈는 최적의 방식으로 업데이트가 필요한 부분만 재구성 됩니다. 즉, UI 단일 버튼 컴포저블 함수는 트리 상위 혹은 하위의 컴포저블 함수를 실행하지 않고 재 실행되는 것을 생략할 수 있습니다.

모든 컴포저블 함수와 람다는 스스로 재구성될 수 있습니다. 이 예제는 리스트를 렌더링할 때 재구성 동작이 몇몇 요소를 생략하는 방법을 보여주는 예시입니다.

각 범위는 재구성을 실행할 때 때 유일한 범위일 수 있습니다. 컴포즈는 header 가 변경될 때 부모 람다를 실행하지 않고, Column 람다 실행을 생략할 수 있습니다. 그리고 Column 이 실행될 때, Compose는 names 가 변경되지 않았다면 LazyColumnItems 의 재구성을 생략할 수 있습니다.

다시 말하면, 모든 컴포저블 함수 또는 람다식이 실행되는 것은 부가 작용이 없어야 합니다. 콜백에서 트리거 되었을 때 부가 작용이 수행되어야 합니다.

Recomposition is optimistic

재구성(Recomposition)은 컴포저블 함수의 파라미터가 변경되었다고 컴포즈가 판단하면 시작합니다. 재구성은 낙관적이며(optimistic), 이는 파라미터가 다시 변경되기 전에 재구성 작업이 완료되는 것이 예상할 수 있다는 것을 의미합니다. 만약, 재구성이 완료되기 전에 파라미터가 변경된다면 컴포즈는 재구성 작업을 취소하고 새로운 파라미터를 활용하여 다시 시작됩니다.

재구성이 취소되면, 컴포즈는 UI 트리를 재구성에서 삭제합니다. 이미 표시되고 있는 UI 에서 부가 작용이 있을 경우, 구성 작업이 취소되어도 부가 작용이 적용됩니다. 이로 인하여 앱의 상태가 일치되지 않을 수 있습니다.

모든 컴포저블 함수와 람다는 멱등성(idempotent)이 보장되어 재구성 시 부가 작용으로부터 안전하게 처리되어야 합니다.

Composable functions might run quite frequently

경우에 따라, 컴포저블 함수는 UI 애니메이션의 매 프레임마다 실행될 수도 있습니다. 만약 함수에서 디바이스 저장소에서 값을 읽는것 처럼 고 비용의 연산을 처리한다면, 함수는 UI 정지를 발생시킬 수 있습니다.

예를들어, 만약 위젯에서 디바이스 설정을 읽는것을 시도한다면 잠재적으로 수백번을 호출할 수 있고 앱 성능에 심각한 영향을 미칠 수 있습니다.

만약 컴포저블 함수에 데이터가 필요하다면, 데이터만을 목적으로 파라미터를 정의해야 합니다. 그리고 나서 고비용의 연산 작업을 다른 스레드의, UI 구성 작업(composition) 의 외부에서 되도록 이동시키고, 컴포즈로 데이터를 전달하는 것은 mutableStateOf 또는 LiveData를 사용하여 전달할 수 있습니다.

--

--

김종식
김종식

Written by 김종식

앱 개발자 / 꿈은 축구선수 / 쌍둥이 아빠

No responses yet