컴포즈의 이해

2021년 GDG에서 주최하는 컴포즈 캠프를 수료하고 한번도 컴포즈로 프로젝트를 진행한 적이 없었다. 이번 졸업 프로젝트가 볼륨도 작고 하니 처음부터 컴포즈를 공부하면서 컴포즈를 활요해 앱을 만들어보려고 한다.

구글 공식 가이드에서 제공하는 튜토리얼 컴포즈의 이해을 확인하며 간단하게 선언형 UI의 개념 공부 내용을 정리해보자.

선언형 UI의 핵심을 이해할 수 있는 짧은 코드 블럭을 가져와봤다.

아니 이게 왜 안돼?..로이드

// in Composable function..
var isChecked by remember { mutableStateOf(false)  }
CheckBox(checked = isChecked,
	onCheckedChange = { checked ->
	// 만약 아래 줄을 주석처리하면 체크박스를 클릭해도 아무 변화가 안생김
	// isChecked = checked
})


만약 컴포즈로 작성된 UI에 CheckBox를 클릭했을 때 위 코드와 같은 주석 처리를 했다면 아무리 체크박스를 눌러도 반응이 없다.

CheckBox() 함수에 들어온 매개변수 값이 변경되지 않는다면 코드는 실행되지 않기 때문이다.

이것이 직관적이지 않다고 생각들 수 있지만 이것이 선언형 UI의 핵심 개념이다.

바로 함수가 받은 매개변수를 통해 UI를 통제함으로써 동기화할 코드를 없에는 것이다(Single Source Of Truth).


기존 View를 활용해 만든 앱도 충분히 훌륭하지만 코드가 xml으로 위젯을 만들고 내적인 상태를 갖으며 kotlin 코드를 통해 위젯의 상태를 동기화 해야한다.

이 과정에서 많은 예외 상황들이 발생하고 동기화할 부분이 많아질 수록 코드는 복잡해지고 변경하기 힘들어진다.

이를 보완하기 위해서 MVVM, MVI 등 아키택쳐를 채택해 사용했지만 공부하면서 느낀 것은 이 아키택쳐들은 컴포즈 UI와 찰떡이란는 거다.

어차피 단방향으로만 데이터를 받아 소비할거라면 xml로 짜여진 UI 위젯을 데이터에 맞게 상태를 변경하기 보단 멱등원을 만족하는 1급 시민 함수를 만들고 그 안에 매개변수로 집어 넣는게 훨씬 좋기 때문이다.(UI 위젯 변경에 따른 예외 상황을 미연에 방지)

상태의 변경이 아니다

가령 위 에제 코드와 같이 체크박스를 누르더라도 UI 위젯의 상태가 변경 되는 것이 아니라 새로운 매개변수(데이터)가 입력된 함수의 재실행을 통해 UI가 다시 그려지게 된다.

컴포저블 함수는 1급 시민 함수로써 멱등원의 성질을 갖는다.

따라서 같은 매개변수를 넣었을 땐 몇번을 실행해도 같은 결과를 내보낸다(외부로부터 간섭이 없다, ex global variable).

같은 매개변수가 입력된다면 함수는 실행되지 않게 되기 때문에 위 예제코드에서 처럼 매개변수를 변경시켜줘야한다.(remember 변수의 상태는 메모리에 저장되며 데이터가 변경되면 해당 변수를 매개변수로 갖는 함수가 재실행 됨)

내부적으로 상태를 갖고 변경하고 관리하는 것이 아닌 완전히 새로운 상태를 UI로 그려 표현하게 된다.


단방향 데이터 흐름

이런 방식의 선언형 UI를 만들면 아래 그림과 같은 이벤트, 데이터 흐름이 발생하고 화면 구성을 하게 된다.

  1. 이벤트 발생

스크린샷 2023-04-14 오후 10 34 23


UI를 통해 사용자와 상호작용하게 된다면 이벤트가 앱 로직을 변경하게 된다.


  1. 데이터 흐름

스크린샷 2023-04-14 오후 10 32 59


앱 로직으로 최상위 컴포저블 함수에게 데이터가 전달된다.

상위 컴포저블 함수는 데이터를 하위 컴포저블 함수에게 전달하며 실행한다.

이때 매개변수가 같은 경우에는 Recomposition(재구성)을 하지 않는다. 매개변수가 다른 경우만 함수를 재실행한다.


위와 같은 데이터, 이벤트의 흐름을 만들고 선언형 UI를 구성할 경우 UI와 로직에 따른 동기화를 없에 에외, 에러 상황을 줄이고 의도하지 않는 상황을 줄인다.


Compose 프로그래밍 특징

  • 구성 가능한 함수는 순서와 관계없이 실행할 수 있습니다.
@Composable
fun ButtonRow() {
    MyFancyNavigation {
        StartScreen() // 먼저 실행 X
        MiddleScreen()
        EndScreen()
    }
}

기존 View 관점에서 작성된 코드에서는 위와 같이 StartScreen() 에서 전역변수(부작용)를 초기화하고 아래 코드를 실행했다면 컴포즈에서는 그것이 불가능(어떤 함수가 먼저 실행될지 모름). 따라서 각 함수는 완전 독립적이어야 한다!


  • 구성 가능한 함수는 동시에 실행할 수 있습니다.

Compose는 구성 가능한 함수를 동시에 실행하여 재구성을 최적화할 수 있습니다.

이를 통해 Compose는 다중 코어를 활용하고 화면에 없는 구성 가능한 함수를 낮은 우선순위로 실행할 수 있습니다.

이 최적화는 구성 가능한 함수가 백그라운드 스레드 풀 내에서 실행될 수 있음을 의미합니다.

구성 가능한 함수가 ViewModel에서 함수를 호출하면 Compose는 동시에 여러 스레드에서 이 함수를 호출할 수 있습니다.

애플리케이션이 올바르게 작동하도록 하려면 모든 구성 가능한 함수에 부작용이 없어야 합니다. 대신 UI 스레드에서 항상 실행되는 onClick과 같은 콜백에서 부작용을 트리거합니다.


  • 재구성은 최대한 많은 수의 구성 가능한 함수 및 람다를 건너뜁니다.

상위 컴포저블 함수를 통해 들어온 매개변수를 통해 여러 하위 컴포저블 함수를 실행한다고 했을 때 매개변수가 변경되지 않은 함수는 실행되지 않습니다.(완전 독립, side-effect free)


  • 재구성은 낙관적이며 취소될 수 있습니다.

Compose는 매개변수가 다시 변경되기 전에 재구성을 완료할 것으로 예상합니다.

재구성이 완료되기 전에 매개변수가 변경되면 Compose는 재구성을 취소하고 새 매개변수를 사용하여 재구성을 다시 시작할 수 있습니다.

재구성이 취소되면 Compose는 재구성에서 UI 트리를 삭제합니다.

표시되는 UI에 종속되는 부작용이 있다면 구성이 취소된 경우에도 부작용이 적용됩니다.

이로 인해 일관되지 않은 앱 상태가 발생할 수 있습니다.


  • 구성 가능한 함수는 애니메이션의 모든 프레임에서와 같은 빈도로 매우 자주 실행될 수 있습니다.

경우에 따라 구성 가능한 함수는 UI 애니메이션의 모든 프레임에서 실행될 수 있습니다.

함수가 기기 저장소에서 읽기와 같이 비용이 많이 드는 작업을 실행하면 이 함수로 인해 UI 버벅거림이 발생할 수 있습니다.

구성 가능한 함수에 데이터가 필요하다면 데이터의 매개변수를 정의해야 합니다.

그런 다음, 비용이 많이 드는 작업을 구성 외부의 다른 스레드로 이동하고 mutableStateOf 또는 LiveData 를 사용하여 Compose에 데이터를 전달할 수 있습니다.