Compose

Jetpack Compose 입문 : 선언형 UI와 State 관리

devfeijoa 2026. 1. 25. 14:02

 

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)
            }
        }
    }
}

 

이 방식은

  1. findViewById로 UI 요소를 찾아서 참조 저장
  2. 상태(count)가 변경되면 직접 UI 업데이트 명령 실행
  3. 어떻게(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
        )
    }
}

 

이 방식은,

  1. 상태(count)만 변경합니다.
  2. UI가 어떻게 보여야 하는지 선언합니다.
  3. 무엇을(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가 재생성되면서 다음과 같은 상황이 발생합니다:

  1. 전역 변수는 메모리에 남아있음 → globalCounter 값 유지
  2. 하지만 Compose 상태(trigger)는 초기화됨 → 0으로 리셋
  3. 결과: 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("이렇게 하면 안 됩니다")
}

❌ 이렇게 하면

  1. Recomposition 될 때마다 incrementCounter() 호출
  2. Compose는 최적화를 위해 여러 번 Recomposition 할 수 있음
  3. 예상과 다르게 카운터가 수십 번 증가할 수 있음

 

따라서 올바른 방법은,

@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는

  1. Recomposition 사이에 유지되어야 합니다 (remember)
  2. 변경을 추적할 수 있어야 합니다 (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)’가 무엇인지 직접 코드로 확인해 봤습니다.

다시 정리하면:

  1. 선언형 UI의 본질
    • 명령형: UI를 ‘어떻게’ 변경할지 명령
    • 선언형: UI가 ‘무엇’이어야 하는지 선언
    • 상태 변경 → UI 자동 업데이트
  2. UI = f(state)의 세 가지 조건
    • 같은 입력 → 같은 출력
    • 외부 상태에 의존 금지
    • 외부 상태 변경 금지
  3. 실전에서 지켜야 할 것
    • State는 remember + mutableStateOf
    • Side Effect는 LaunchedEffect에서
    • State Hoisting으로 적절한 위치에 상태 배치

 

 

처음엔 왜 remember를 써야 하는지, 왜 일반 변수는 안 되는지 이해가 안 갔습니다.

그런데 직접 실험해 보니 Recomposition이 일어날 때마다 함수가 다시 호출되는 거였고, 그래서 상태를 기억해야 했던 거죠.

XML에선 “이 버튼 누르면 저 텍스트 찾아서 바꾸고...” 이런 식으로 했다면,

Compose는 “상태가 A면 UI는 B다” 이렇게 선언만 하면 됩니다.

상태만 바꾸면 UI는 알아서 따라옵니다.

 

 

그런데 여기서 한 가지 의문이 생깁니다.

상태 바뀔 때마다 UI를 다시 그린다는데, 그럼 느리지 않나요?

화면이 복잡하면 버벅거릴 것 같은데요?

다음 편에서는 Recomposition이 어떻게 효율적으로 작동하는지 알아보겠습니다.

 

 

이 글이 저처럼 Compose를 처음 접하는 분들에게 도움이 되었으면 좋겠습니다.

틀린 부분이나 더 나은 설명 방법이 있다면 댓글로 알려주세요. 같이 배워가면 좋겠습니다!

다음 편에서 만나요 🚀🚀

 

 

 

 

출처

참고자료

이 글은 다음 공식 문서를 기반으로 작성되었습니다.

Jetpack Compose 공식 문서

관련 Codelab

추가 학습 자료