Jetpack Compose 공식 문서를 보면 계속 나오는 문구가 있습니다.
UI = f(state)
처음엔 이게 무슨 소린지 몰랐습니다.
‘상태를 함수에 넣으면 UI가 나온다’는 건 알겠는데, 그래서 뭐가 달라지는 건지?
왜 이게 중요한지 와닿지가 않았기 때문입니다.
기존 XML + Activity 방식과 비교해 보면 이렇습니다.
// XML + Activity 방식 (명령형 방식)
val textView = findViewById<TextView>(R.id.textView)
val button = findViewById<Button>(R.id.button)
button.setOnClickListener {
textView.text = "클릭됨"
textView.setTextColor(Color.RED)
textView.visibility = View.VISIBLE
}
반면 Compose에서는,
// Compose 방식 (선언형 방식)
var isClicked by remember { mutableStateOf(false) }
Button(onClick = { isClicked = true }) {
Text("클릭")
}
if (isClicked) {
Text(
text = "클릭됨",
color = Color.Red
)
}
첫 번째는 UI 요소를 찾아서 직접 변경하고, 두 번째는 상태만 바꾸면 UI가 알아서 바뀝니다.
그런데 왜 이런 차이가 생기는걸까요? 🤔
이 글에서는 UI = f(state) 라는 공식이 정확히 무엇을 의미하는지, 그리고 실제로 어떻게 작동하는지 직접 코드로 실험하며 알아보겠습니다.
1. 명령형 vs 선언형 : 무엇이 다를까?
1-1. 명령형 UI의 특징
기존 Android 개발 방식을 다시 보면,
class MainActivity : AppCompatActivity() {
private lateinit var counterText: TextView
private var count = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
counterText = findViewById(R.id.counterText)
val button = findViewById<Button>(R.id.incrementButton)
// 초기 UI 설정
counterText.text = "Count: $count"
button.setOnClickListener {
count++
// UI 업데이트 명령
counterText.text = "Count: $count"
if (count > 5) {
counterText.setTextColor(Color.RED)
}
}
}
}
이 방식은
- findViewById로 UI 요소를 찾아서 참조 저장
- 상태(count)가 변경되면 직접 UI 업데이트 명령 실행
- 어떻게(How) 업데이트할지 단계별로 명시
여기서 문제는 뭘까요? 만약 상태가 여러 곳에서 변경된다면?
// 여기서도 count 변경
fun onServerResponse(newCount: Int) {
count = newCount
counterText.text = "Count: $count" // 업데이트 잊으면 버그!
if (count > 5) {
counterText.setTextColor(Color.RED) // 이것도 잊으면 버그!
}
}
// 저기서도 count 변경
fun onUserAction() {
count = 0
counterText.text = "Count: $count" // 또 써야 함
counterText.setTextColor(Color.BLACK) // 색도 초기화해야 함
}
→ 상태와 UI의 동기화를 개발자가 수동으로 관리해야 합니다.
1-2. 선언형 UI의 접근
Compose에서는 어떻게 할까요?
@Composable
fun CounterScreen() {
var count by remember { mutableStateOf(0) }
Column {
Button(onClick = { count++ }) {
Text("증가")
}
Text(
text = "Count: $count",
color = if (count > 5) Color.Red else Color.Black
)
}
}
이 방식은,
- 상태(count)만 변경합니다.
- UI가 어떻게 보여야 하는지 선언합니다.
- 무엇을(What) 보여줄지만 정의합니다.
상태가 어디서 바뀌든 상관 없습니다!
@Composable
fun CounterScreen() {
var count by remember { mutableStateOf(0) }
// 여기서 바꾸든
Button(onClick = { count++ }) { Text("증가") }
// 저기서 바꾸든
Button(onClick = { count = 0 }) { Text("초기화") }
// 함수 호출로 바꾸든
LaunchedEffect(Unit) {
delay(3000)
count = 100
}
// UI 선언은 한 번만
Text(
text = "Count: $count",
color = if (count > 5) Color.Red else Color.Black
)
}
→ 상태만 바꾸면 UI는 자동으로 업데이트됩니다.
2. UI = f(state)의 의미
2-1. 함수형 프로그래밍의 순수 함수
UI = f(state)는 수학의 함수 표기법입니다. 이게 성립하려면 세 가지 조건이 필요합니다.
첫 번째 조건: 같은 입력(state)이면 항상 같은 출력(UI)
// 순수 함수
fun f(x: Int): Int {
return x * 2
}
f(5) // 항상 10
f(5) // 항상 10
f(5) // 항상 10
Compose도 마찬가지여야 합니다.
@Composable
fun UserProfile(name: String) {
Text("안녕하세요, $name님")
}
// 같은 입력이면
UserProfile("이든") // "안녕하세요, 이든님"
UserProfile("이든") // "안녕하세요, 이든님"
UserProfile("이든") // "안녕하세요, 이든님"
두 번째 조건: 외부 상태에 의존하지 않아야 합니다(No Side Effect)
// ❌ 순수 함수가 아님
var externalValue = 0
fun impure(x: Int): Int {
return x + externalValue // 외부 변수 의존
}
Compose에서의 예시
// ❌ 나쁜 예시
var globalCount = 0
@Composable
fun BadCounter() {
Text("Count: $globalCount") // 외부 상태 의존
}
세 번째 조건: 외부 상태를 변경하지 않아야 합니다.
// ❌ 순수 함수 아님
var counter = 0
fun impure2(x: Int): Int {
counter++ // 외부 변수 변경!
return x * 2
}
Compose에서의 예시
// ❌ 나쁜 예시
@Composable
fun BadComponent() {
// Composable 안에서 외부 상태 변경 금지
viewModel.updateData() // ❌
database.insert() // ❌
}
이 세 가지 조건이 지켜져야 UI = f(state)가 성립합니다.
2-2. 실제로는 어떨까?
이론을 알겠는데, 그래서 실제로도 그럴까요?
직접 확인해 봅시다!
Q1. 같은 state면 정말 같은 UI일까?
@Composable
fun ExperimentUI(count: Int) {
// 호출될 때마다 로그 출력
println("ExperimentUI 호출: count = $count")
Text(
text = "Count: $count",
modifier = Modifier.padding(16.dp)
)
}
@Composable
fun TestScreen() {
var count by remember { mutableStateOf(0) }
Column {
Button(onClick = { count++ }) { Text("증가") }
ExperimentUI(count)
ExperimentUI(count) // 같은 count 전달
}
}
실행결과 :
// count = 0일 때
ExperimentUI 호출: count = 0
ExperimentUI 호출: count = 0
// 버튼 클릭 (count = 1)
ExperimentUI 호출: count = 1
ExperimentUI 호출: count = 1
// 버튼 클릭 (count = 2)
ExperimentUI 호출: count = 2
ExperimentUI 호출: count = 2
✅ 같은 state면 항상 같은 UI가 생성됩니다.
Q2. 외부 상태에 의존하면 어떻게 될까?
// 전역 변수
var globalCounter = 0
@Composable
fun ImpureComponent() {
Text("Global: $globalCounter")
}
@Composable
fun TestScreen2() {
var trigger by remember { mutableStateOf(0) }
Column {
Button(onClick = {
globalCounter++ // 전역 변수 변경
trigger++ // Recomposition 트리거
}) {
Text("증가")
}
ImpureComponent()
}
}
🚨문제 발생:
// 첫 실행
Global: 0
// 버튼 클릭
Global: 1
// 앱 종료 후 재시작
Global: 0 // 상태 복구 안 됨
// 화면 회전
Global: ??? // 예측 불가능
왜 예측 불가능할까요?
화면 회전 시 Activity가 재생성되면서 다음과 같은 상황이 발생합니다:
- 전역 변수는 메모리에 남아있음 → globalCounter 값 유지
- 하지만 Compose 상태(trigger)는 초기화됨 → 0으로 리셋
- 결과: UI는 0을 보여주지만, 실제 전역 변수는 다른 값
실행 결과 :
회전 전: globalCounter = 5, trigger = 5
UI 표시: "Global: 5"
[화면 회전]
회전 후: globalCounter = 5 (유지), trigger = 0 (초기화)
UI 표시: "Global: 5" (하지만 Recomposition 안 됨)
버튼을 누르기 전까진 이전 값이 화면에 남아있을 수도 있음
이처럼 상태의 생명주기가 다르기 때문에 UI와 데이터가 불일치하게 됩니다.
Q3. Composable 안에서 외부 상태를 바꾸면?
@Composable
fun DangerousComponent(viewModel: MyViewModel) {
// ❌ 매우 위험한 코드
viewModel.incrementCounter()
Text("이렇게 하면 안 됩니다")
}
❌ 이렇게 하면
- Recomposition 될 때마다 incrementCounter() 호출
- Compose는 최적화를 위해 여러 번 Recomposition 할 수 있음
- 예상과 다르게 카운터가 수십 번 증가할 수 있음
따라서 올바른 방법은,
@Composable
fun CorrectComponent(viewModel: MyViewModel) {
Button(onClick = {
viewModel.incrementCounter() // 이벤트 핸들러에서만
}) {
Text("증가")
}
}
✅ Side Effect는 이벤트 핸들러나 Effect 안에서만!
3. 내가 겪은 실수와 깨달음
3-1. 첫 번째 실수 : remember 빼먹기
이런 코드를 작성했었습니다.
@Composable
fun MyCounter() {
var count = 0 // remember 없이!
Button(onClick = { count++ }) {
Text("Count: $count")
}
}
어떤 문제가 생겼었을까요?
이렇게 했더니 버튼을 아무리 눌러도 count가 증가하지 않았습니다.
왜일까요?
Recomposition이 일어날 때마다 MyCounter() 함수가 다시 호출되고,
var count = 0이 다시 실행되기 때문입니다.
클릭 → count++ → Recomposition
→ MyCounter() 다시 호출
→ var count = 0 (초기화!)
→ 결과: 0
알게 된 점: remember는 Recomposition 사이에 값을 ‘기억’하게 합니다.
@Composable
fun MyCounter() {
var count by remember { mutableStateOf(0) }
// Recomposition 되어도 count 값 유지
Button(onClick = { count++ }) {
Text("Count: $count")
}
}
이것이 state의 조건입니다. UI = f(state)에서 state는
- Recomposition 사이에 유지되어야 합니다 (remember)
- 변경을 추적할 수 있어야 합니다 (mutableStateOf)
3-2. 두 번째 실수 : Composable 안에서 API 호출
이런 코드를 작성했었습니다.
@Composable
fun UserProfile(userId: String) {
val user = apiClient.getUser(userId) // ❌ 절대 금지
Text("이름: ${user.name}")
}
어떤 문제가 생겼었을까요?
- Recomposition 될 때마다 API 호출
- 1초에 수십 번 호출될 수도 있음
- 앱이 느려지고 서버에 부하
알게 된 점: Side Effect(API 호출, DB 접근 등)는 LaunchedEffect에서!
@Composable
fun UserProfile(userId: String) {
var user by remember { mutableStateOf<User?>(null) }
LaunchedEffect(userId) {
user = apiClient.getUser(userId) // ✅ 한 번만 호출
}
user?.let {
Text("이름: ${it.name}")
}
}
📌 Composable 함수는 순수해야 하고, Side Effect는 따로 관리해야 합니다.
3-3. 세 번째 실수 : State를 너무 많은 곳에 두기
모든 Composable마다 state를 만드는 코드를 작성했었습니다.
@Composable
fun ParentScreen() {
Column {
ChildA() // 내부에 state
ChildB() // 내부에 state
ChildC() // 내부에 state
}
}
어떤 문제가 생겼었을까요?
ChildA와 ChildB가 같은 데이터를 공유해야 할 때 방법이 없었습니다!
알게 된 점: State Hoisting(상태 호이스팅) - 상태를 위로 올려라
@Composable
fun ParentScreen() {
var sharedState by remember { mutableStateOf("") }
Column {
ChildA(
value = sharedState,
onValueChange = { sharedState = it }
)
ChildB(
value = sharedState,
onValueChange = { sharedState = it }
)
}
}
@Composable
fun ChildA(
value: String,
onValueChange: (String) -> Unit
) {
// State를 받아서 사용만
}
이것이 단방향 데이터 플로우입니다
- State는 위에서 아래로 (value prop)
- Event는 아래에서 위로 (onValueChange callback)
- UI = f(state)의 state는 적절한 위치'에 있어야 함
<이해를 위한 공식문서의 예시 자료>

글을 마무리하며,
이 글에서 저는 ‘UI = f(state)’가 무엇인지 직접 코드로 확인해 봤습니다.
다시 정리하면:
- 선언형 UI의 본질
- 명령형: UI를 ‘어떻게’ 변경할지 명령
- 선언형: UI가 ‘무엇’이어야 하는지 선언
- 상태 변경 → UI 자동 업데이트
- UI = f(state)의 세 가지 조건
- 같은 입력 → 같은 출력
- 외부 상태에 의존 금지
- 외부 상태 변경 금지
- 실전에서 지켜야 할 것
- State는 remember + mutableStateOf
- Side Effect는 LaunchedEffect에서
- State Hoisting으로 적절한 위치에 상태 배치
처음엔 왜 remember를 써야 하는지, 왜 일반 변수는 안 되는지 이해가 안 갔습니다.
그런데 직접 실험해 보니 Recomposition이 일어날 때마다 함수가 다시 호출되는 거였고, 그래서 상태를 기억해야 했던 거죠.
XML에선 “이 버튼 누르면 저 텍스트 찾아서 바꾸고...” 이런 식으로 했다면,
Compose는 “상태가 A면 UI는 B다” 이렇게 선언만 하면 됩니다.
상태만 바꾸면 UI는 알아서 따라옵니다.
그런데 여기서 한 가지 의문이 생깁니다.
상태 바뀔 때마다 UI를 다시 그린다는데, 그럼 느리지 않나요?
화면이 복잡하면 버벅거릴 것 같은데요?
다음 편에서는 Recomposition이 어떻게 효율적으로 작동하는지 알아보겠습니다.
이 글이 저처럼 Compose를 처음 접하는 분들에게 도움이 되었으면 좋겠습니다.
틀린 부분이나 더 나은 설명 방법이 있다면 댓글로 알려주세요. 같이 배워가면 좋겠습니다!
다음 편에서 만나요 🚀🚀
출처
참고자료
이 글은 다음 공식 문서를 기반으로 작성되었습니다.
Jetpack Compose 공식 문서
관련 Codelab
추가 학습 자료