Compose 이해
참고: https://developer.android.com/develop/ui/compose/mental-model?hl=ko
들어가며
과거 프로젝트에서 Jetpack Compose를 활용한 경험이 있지만,
현 프로젝트 환경 제약으로 인해 잠시 멀리하게 되었습니다.
다시금 Compose를 더욱 깊이 있게 이해하고 사용하기 위해,
이번 기회에 공식 문서 기반으로 핵심 개념을 재정립하는 학습 내용을 공유하고자 합니다.
Compose는 선언형 프로그래밍 패러다임을 안드로이드 UI 개발에 도입한 프레임워크입니다. 기존 XML 기반의 명령형 UI 방식이 가졌던 상태 관리 및 유지보수의 복잡성을 해소하고자 등장했으며, 개발자가 '어떻게' 가 아닌 '무엇을' 보여줄지에 집중하게 하여 생산성을 높이는 것이 핵심 목표입니다
선언형 UI(Declarative UI) 란?
명령형 UI의 이해 (기존 방식)
명령형 UI (Imperative UI) 방식에서는, 앱의 상태(데이터) 가 사용자 상호작용 등의 이유로 변경될 때마다 개발자가 직접 UI를 업데이트해야 했습니다.
이는 다음과 같은 메소드 호출을 통해 이루어졌습니다.
- textView.setText(String)
- container.addChild(View)
이러한 메소드들은 뷰 자체의 상태를 변경하며, 개발자는 "어떻게(How)" UI 요소를 조작하여 화면을 갱신할지 상세하게 명령해야 했습니다.
상태(State)의 정의 (핵심 키워드)
여기서 말하는 상태(State) 는 선언형 UI를 이해하는 데 있어 가장 중요한 키워드입니다.
상태란 시간이 지남에 따라 변할 수 있는 값을 의미하며, 이는 곧 앱의 UI에 표시되어야 하는 데이터를 나타냅니다.
예시:
- 사용자가 입력한 텍스트
- 버튼의 활성화 여부 (true/false)
- 리스트에 포함된 아이템 목록
선언형 UI의 등장과 차이점
기존의 명령형 UI에서는 상태가 변경될 때마다 개발자가 직접 UI 업데이트 코드를 작성해야 하는 부담이 있었습니다.
그러나 선언형 UI에서는 개발자가 직접 뷰를 조작하지 않습니다.
대신, 특정 상태(데이터)가 주어졌을 때 화면이 "어떤 모습"이어야 하는지를 선언합니다.
선언형 UI 환경(예: Compose, React)에서는 상태가 변경되면 자동으로 UI가 다시 그려지므로, 개발자는 "어떤" UI를 보여줄지에만 집중할 수 있어 개발의 복잡도가 크게 줄어들고 생산성이 향상됩니다.
간단한 기본 예제
@Composable
fun Greeting(name: Stirng) {
Test("Hello $name")
}이 예제는 화면에 name 이라는 매개변수를 받아서 Text 를 UI에 노출시킵니다.
주목할 점은
@Composable어노테이션을 지정합니다. 모든 컴포저블 함수는 이 주석이 있어합니다.- 매견변수를 받을 수 있다.
- Text 컴포저블 함수를 호출하여 텍스트를 노출 시킨다.
- 함수는 아무것도 반환하지 않는다.
- 이 함수는 빠르고 멱등성을 가지며 부작용이 없다.
💡 멱등성(Idempotence):
시스템, 연산 또는 함수가 여러 번 적용되어도 한 번 적용한 것과 같은 결과를 내는 속성(property)을 의미합니다.
동적 콘텐츠 (Dynamic content)
컴포즈는 XML 대신 코틀린을 사용하므로 코틀린 코드처럼 동적으로 동작할 수 있다.(if, for, when 등을 사용하여 컨텐츠 구성)
재구성 (Recomposition)
컴포저블의 가장 핵심적인 개념으로
재구성(Recomposition)이란 데이터(입력)가 변경될 때 업데이트된 UI를 생성하기 위해
컴포저블 함수가 다시 실행되는 프로세스를 말합니다.
먼저 명령형 UI 모델에의 UI 업데이트 방식을 살펴보면,
명령형 UI 모델은 위젯을 변경하기 위해서 setter를 호출합니다.
하지만 컴포저블에서는 새 데이터를 사용하여 컴포저블 함수를 다시 호출합니다.
이렇게 되면 함수가 재구성(recomposition)되면서 Compose 프레임워크는 필요한 컴포저블을 다시 그려줍니다.
@Composable
fun ClickCounter(clicks: Int, onClick: () -> Unit) {
Button(onClick = onClick) {
Text("I've been clicked $clicks times")
}
}버튼이 클릭 될 때마다 clicks 값이 증가하며 clicks 을 사용하고 잇는 Text 컴포저블이 재구성 됩니다.
재구성은 매우 효율적으로 동작합니다.
새 입력 값에 따라 실제로 변경된 데이터에 의존하는 컴포저블 함수나 람다만 선택적으로 다시 실행하고,
변경되지 않은 부분은 건너뛰어(Skip) 불필요한 연산을 방지합니다.
재구성은 언제든지 '건너뛸 수 있는' 특성이 있으므로 컴포저블 함수의 실행 여부를 예측하기 어렵기 때문에,
컴포저블 함수 내에서 **부작용(Side Effect)**을 직접적으로 발생시키면 사용자가 예측할 수 없는 동작을 경험할 수 있습니다.
그렇다면 재구성 중에 피해야 할 **부작용(Side Effect)**이란 정확히 무엇일까요?
부작용은 컴포저블 함수가 자신의 범위를 벗어나 앱의 나머지 부분, 혹은 외부 환경에까지 영향을 미치는
모든 변경 사항을 의미합니다.
(예: "네트워크 호출, 데이터베이스 쓰기, 전역 변수 변경, 외부 상태 업데이트 등이 대표적인 부작용입니다.")
공식 문서에는 아래와 같은 행위를 하면 안 된다고 나와 있는데 번역된 내용으로 보면 이해가 잘 안 되어서 추가 설명해 보겠습니다.
- 공유 객체의 속성에 쓰기 (영문: Writing to a property of a shared object)
-> 싱글톤 객체, 전역 변수 등 Compose가 그 변경을 추적하지 못하는 외부 객체의 속성을 직접 변경하지 말라. ViewModel에서 식별 가능한 요소 업데이트 (영문: Updating an observable in ViewModel)
-> @Composable 함수의 '렌더링 단계'**에서 ViewModel이 노출하는 상태(StateFlow, LiveData, State)를 직접 변경하지 말라.- 공유 환경설정 업데이트 (영문: Updating shared preferences)
-> SharedPreferences 쓰기와 같이 비용이 많이 드는 파일 I/O 작업이나, DB 및 Network I/O와 같은 비동기 작업을 @Composable 함수의 렌더링 단계에서 직접 실행하지 말라.
간단하게 코드로 보면
@Composable
fun BadComposable(viewModel: ViewModel) {
// 1. 공유 객체 속성에 직접 쓰기 (UI 불일치 위험)
MySingleton.count++
// 2. ViewModel의 상태를 렌더링 단계에서 직접 업데이트 (예측 불가능한 반복 실행 위험)
viewModel.updateRenderCount()
// 3. 비용이 큰 I/O 작업(네트워크/DB/SharedPreferences)을 렌더링 단계에서 직접 호출 (성능 저하 위험)
// 💡 참고: 이 함수가 동기적으로 실행된다고 가정 (실제로는 비동기 처리가 필요)
NetworkClient.sendEventLog()
Text(test = "I am bad! ${MySingleton.count}")
}그래서 이런 작업이 필요하면 어떻게 하면 될까요?
위에서 언급된 세 가지 행위(공유 객체 쓰기, I/O 작업, ViewModel의 업데이트)는
@Composable 함수의 **'렌더링 단계'**에서 실행될 때만 위험합니다.
실제 앱에서는 DB 저장, API 호출 등 부작용이 필수적입니다.
Compose는 이러한 부작용을 안전하게 처리하기 위해 전용 API를 제공합니다.
핵심 해결책:
-
사용자 상호작용에 의한 상태 변경은 $\text{Button}$의 onClick 또는 $\text{TextField}$의 onValueChange와 같은 이벤트 핸들러 스코프에서 ViewModel 함수를 호출하여 처리합니다.
-
생명주기나 특정 상태 변화에 반응해야 하는 DB 접근, API 호출, Listener 등록/해제와 같은 복잡하고 비동기적인 부작용은
Side Effect API인 LaunchedEffect, DisposableEffect 등을 사용하여 통제된 시점에만 실행해야 합니다.
Compose 를 사용할 때 알아야할 사항 다섯 가지
공식 문서에는 설명된 다섯 가지 사항이 나와있는데 내용을 보면 위에 설명한 것과 일맥상통한 내용이므로 간단하게 정리해 보겠습니다.
- 재구성은 최대한 많은 수의 구성 가능한 함수 및 람다를 건너뜁니다.
- 재구성은 낙관적이며 취소될 수 있습니다.
- 구성 가능한 함수는 애니메이션의 모든 프레임에서와 같은 빈도로 매우 자주 실행될 수 있습니다.
- 구성 가능한 함수는 동시에 실행할 수 있습니다.
- 구성 가능한 함수는 순서와 관계없이 실행할 수 있습니다.
1. 재구성은 최대한 많은 수의 구성 가능한 함수 및 람다를 건너뜁니다.
이는 불필요한 작업 최소화를 의미합니다.
데이터가 실제로 변경된 컴포저블만 다시 실행하여 성능을 최적화합니다.
따라서 개발자는 상태 변경에 반응하도록 컴포넌트를 설계하고, 함수에 불필요한 매개변수를 넣지 않도록 주의해야 합니다.
2. 재구성은 낙관적이며 취소될 수 있습니다.
Compose는 변경 사항을 감지하는 즉시 재구성을 시작합니다(낙관적). 하지만 그 과정에서 상태가 다시 변경되거나, 더 중요한 작업이 발생하면 진행 중인 재구성이 취소될 수 있습니다. 따라서 재구성 가능한 함수는 부수 효과(Side Effect, UI 외의 작업)를 가지지 않도록 작성해야 합니다.
3. 구성 가능한 함수는 애니메이션의 모든 프레임에서와 같은 빈도로 매우 자주 실행될 수 있습니다.
이는 컴포저블 함수가 매우 빠르게 실행되어야 함을 강조합니다. 함수 내부에서 시간이 오래 걸리는 작업(예: 데이터베이스 접근, 네트워크 요청)을 직접 수행해서는 안 되며, 이는 LaunchedEffect와 같은 Side Effect API를 통해 처리해야 합니다.
4. 구성 가능한 함수는 동시에 실행할 수 있습니다.
Compose는 성능 향상을 위해 여러 컴포저블을 병렬로(다른 스레드에서) 실행할 수 있습니다. 이는 컴포저블 함수가 스레드 안전해야 하며, 공유 상태에 접근할 때 주의해야 함을 의미합니다. 경쟁 상태(Race Condition)가 발생합니다.
5. 구성 가능한 함수는 순서와 관계없이 실행할 수 있습니다.
컴포저블은 항상 결과가 같아야 합니다. 특정 순서에 의존하는 로직을 작성하면 안 됩니다. 즉, 컴포저블은 순수 함수와 비슷하게, 입력(상태/매개변수)만으로 출력(UI)이 결정되도록 작성해야 합니다.
마치며
공식 문서를 저만의 언어로 정리하는 과정을 통해,
Compose의 핵심 철학인 선언형 패러다임과 재구성(Recomposition)의 멱등성을 다시 한번 깊이 있게 이해할 수 있었습니다.
(처음 읽었을 때와 다르게 Compose 개발 경험이 쌓이고 읽으니 새롭네요.)
특히, 재구성 중 발생하는 부작용(Side Effect)의 위험성과 이를 LaunchedEffect 등의
전용 API로 안전하게 관리해야 하는 이유를 명확히 구분할 수 있게 되었습니다.
이제 이 기본 지식을 바탕으로, 실제 앱 개발 시 상태 관리(State Management) 패턴(ViewModel, Flow)을
어떻게 재구성의 효율성과 연결지을 수 있을지에 대해 학습을 진행할 계획입니다.
