명령형 UI는 기존에 안드로이드 개발에서 사용된 방식으로, 어떻게 UI를 그릴지 개발자가 직접 단계별로 지시하는 방식이다.
안드로이드의 XML 레이아웃 파일에 버튼, 텍스트 필드와 같은 컴포넌트들을 정의해두면, Activity, Fragment에서는 findViewById()로 UI 요소에 접근할 수 있다. 그리고 이벤트(버튼 클릭 등)가 발생하면 UI 요소를 수정하거나 갱신하는 명령을 명시적으로 작성해 UI를 업데이트한다.
<!-- activity_main.xml -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:id="@+id/my_text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello, World!" />
<Button
android:id="@+id/my_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Click me" />
</LinearLayout>
// MainActivity.kt
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val textView = findViewById<TextView>(R.id.my_text_view)
val button = findViewById<Button>(R.id.my_button)
button.setOnClickListener {
// 명령형으로 UI 요소 변경
textView.text = "Button clicked!"
}
}
}
선언형 UI는 무엇이 보여져야 하는지를 선언적으로 정의하는 방식이다. 상태가 변화하면 UI는 자동으로 갱신되며, UI의 변경 흐름을 명령하지 않아도 된다.

위젯 또는 함수(F)의 인자로 상태(STATE)를 넘겨주면 그의 맞는 뷰(VIEW)를 생성해주는 것이다.
// MainActivity.kt
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApp()
}
}
}
@Composable
fun MyApp() {
var text by remember { mutableStateOf("Hello, World!") }
Column {
Text(text = text)
Button(onClick = { text = "Button clicked!" }) {
Text("Click me")
}
}
}
Compose에서 말하는 상태(state)는 시간이 지나면서 변할 수 있는 값을 말한다.
ex)
채팅 앱에서 가장 최근에 수신된 메시지
사용자의 프로필 사진
recyclerView와 같은 list의 스크롤 위치
그리고 이 상태에 따라 UI에 표시되는 항목이 결정된다.
상태가 시간이 지남에 따라 변하는 값이라면, 변하는 이유는 무엇일까? 안드로이드 앱에서는 이벤트에 대한 응답으로 상태를 업데이트한다.
ex)
버튼 누르기 등으로 UI와 상호작용하는 사용자
기타 요인(예: 새 값을 전송하는 센서 또는 네트워크 응답)
즉, 뷰에는 상태가 존재하고 상태가 변경되면 새로운 화면을 생성함으로써, 업데이트된 화면이 사용자에게 보여지게 된다.

Composable은 컴포즈로 화면을 만들기 위한 핵심 함수로, @Composable 어노테이션을 선언해 만들 수 있다. 하나의 화면을 만들기 위해 여러개의 컴포저블 함수를 만들어 사용할 수 있고, 컴포저블 함수 내에서 필요한 구성 요소들(ex 텍스트, 버튼, 이미지)을 원하는 형태로 배치하거나 구성할 수 있다. Composable 함수는 데이터를 수신할 수 있고, UI를 만들기 위해 수신받은 데이터를 사용하며, 사용자가 화면에서 볼 수 있는 UI 구성요소를 내보낸다.
Composable 함수는 3가지 단계를 거쳐서 UI를 랜더링한다.

Composition - Layout - Draw 3단계로 나누어져 있으며, 어떤 화면을 그릴지?(What To Show) 레이아웃의 크기와 위치는 어떻게 할지?(How To Place) 어떻게 그릴지?(How To Render It)를 각각 결정한다.
Composable은 Activity나 Fragment와 같이 생명주기를 갖는다.

Recomposition이 일어나면 UI를 재구성한다. 예를 들어 A라는 값을 가지고 있는 TextView가 버튼을 누르면 B로 바뀐다고 한다면, 상태가 바뀌기 때문에 Recomposition이 발생하고 UI를 다시 그리게 된다.
이때 Recomposition이 일어날 때 변경된 상태를 저장하기 위해 사용하는 것이 remember이다. Recomposition이 발생하면 Composable 함수가 다시 호출되기 때문에 내부의 상태가 초기화된다. 하지만 기존의 상태를 Composable이 알고 있어야 하는 경우가 있다. 이런 경우에 remember를 사용할 수 있다.
remember를 호출하면 UI 전체를 다시 그리는 게 아니라 변경이 필요한 부분만 변경한다.
remeber에는 MutableState가 저장된다.
MutableState는 mutableStateOf 함수를 이용해 생성할 수 있다.
@Composable
fun rememberExample() {
var state = remember { mutableStateOf("") }
TextField(
value = state.value,
onValueChange = {text:String -> state.value = text},
modifier = Modifier.wrapContentSize()
)
}
위 예시에서는 TextField의 onValueChange 메소드가 호출되면서 상태가 변경된다. 그리고 변경된 상태가 TextField의 value로 출력된다.
만약 여기서 remember를 사용하지 않는다면 문제가 발생한다.
@Composable
fun rememberExample() {
var state = mutableStateOf<String>("")
TextField(
value = state.value,
onValueChange = {text:String -> state.value = text},
modifier = Modifier.wrapContentSize()
)
}
위 코드에서는 onValueChange에서 state 값이 변경 되어 Recomposition이 발생하지만, state 값을 "" 값으로 초기화 하기 때문에 TextField에 값을 입력해도 값이 변경되지 않는다.
Compose에서 MutableState를 선언하는 방식은 3가지가 있고 이 방식들은 모두 동일한 결과를 나타낸다.
val mutableState = remember { mutableStateOf()}
var value by remeber { mutableStateOf() }
val (value, setValue) = remember { mutableStateOf() }
remember를 통해 Recomposition이 될 때 상태를 유지할 수 있지만 화면 회전과 같은 configuration change가 발생하게 되면 상태가 유지되지 않는다.
이러한 경우에는 rememberSaveable 을 사용할 수 있다. rememberSaveable은 Bundle에 상태 값을 저장하여 앱을 강제로 종료하지 않은 이상 상태 값이 저장된다.
State Hoisting을 이해하기 위해선느 Stateful과 Stateless에 대한 개념을 먼저 이해해야한다.
@Composable
fun StatefulCounter(modifier: Modifier = Modifier) {
var count by rememberSaveable { mutableStateOf(0) }
StatelessCounter(count, { count++ }, modifier)
}
@Composable
fun StatelessCounter(count: Int, onIncrement: () -> Unit, modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
if (count > 0) {
Text("You've had $count glasses.")
}
Button(onClick = onIncrement, Modifier.padding(top = 8.dp), enabled = count < 10) {
Text("Add one")
}
}
}
State Hoisting은 Stateful한 Composable을 Stateless하도록 만들기 위한 디자인 패턴이다. tate Hoisting를 직역해보면, "상태(State)를 끌어올리기(Hoisting)" 라는 뜻을 가진다. State Hoisting을 통해 자식 Composable의 State를 해당 Composable을 호출하는 Composable 쪽으로 끌어올림으로써 자식 Composable을 Stateless하게 만드는 것이다. 즉 State Hoisting은 자식 Composable의 State를 호출부로 끌어올리는 것을 뜻한다.
1. 재사용성 향상
Stateless Composable은 특정 상태에 의존하지 않기 때문에 여러 곳에서 재사용할 수 있다. 상태는 부모에서 관리하며, Composable 자체는 입력과 출력을 명확히 정의한 함수처럼 동작한다.
2. 테스트 용이성
내부에 상태가 없기 때문에 함수형 프로그래밍 스타일로 테스트할 수 있다. 입력(상태)과 출력(UI)이 명확해져서, 다양한 상태를 주입하여 Composable의 동작을 쉽게 테스트할 수 있다.
3. 단일 책임 원칙 준수
상태 관리와 UI 로직을 분리함으로써, Composable은 UI 렌더링만을 담당하고, 상태는 외부에서 처리하게 되어 SRP를 따르게 된다. 이를 통해 코드 구조가 더 깔끔하고 유지보수가 쉬워진다.
4. 상태 관리의 중앙화
상태를 부모나 상위 레벨에서 관리하게 되면, 상태 변화에 따른 로직을 중앙에서 처리할 수 있어 코드의 일관성을 유지할 수 있다. 상태가 여러 곳에 분산되지 않으므로 관리가 용이해진다.
5. Recomposition 최적화
상태를 외부에서 전달받으면, 상태 변경 시에만 해당 Composable이 리컴포지션되므로 성능이 향상된다. 불필요한 상태 변화를 방지하고, 필요한 부분만 리렌더링할 수 있다.