JetPack Compose는 뭘까?

오늘은 JetPack Compose에 대해서 알아보려고 한다.
기존 안드로이드는 XML 이라는 파일에 UI 정보를 작성해 사용자 View를 구성했다.

하지만, 이런 XML 방식은 많은 양의 코드와 개발자의 실수로 오류가 발생할 확률이 높은 등의 단점이 존재했다.


위 코드를 살펴보자. Column , Image, Text 등 우리가 흔히 접할 수 있는 단어들이 확인된다.

JetPack Compose는 위와 같이 직관적으로 UI를 선언할 수 있는 Jetpack의 UI Tool kit 이다.


일단 한번 따라해보자. ⭐️

JetPack Compose를 사용해 간단한 버튼 + 텍스트 예제를 살펴보자.

결과물

위 결과물을 보면 UI + 더하기 로직이 포함된 내용이 사진 한장에 전부 포함된다.
만약 우리가 XML로 위와 같은 코드를 구현한다면 어떤 작업이 필요할까?

  1. Android XML에서 버튼을 추가한다.
  2. Android XML에서 텍스트 뷰를 추가한다.
  3. 버튼 Text를 더하기로 변경한다.
  4. MainActivity에서 XML에 정의한 버튼을 연결한다.
  5. MainActivity에서 XML에 정의한 텍스트 뷰를 연결한다.
  6. setOnClickLiSiner를 생성해, 버튼이 눌리면 TextView의 Text가 1씩 저장되도록 로직생성.

물론, Compose에서도 크게 보면 XML과 비슷한 흐름을 따라가고 있지만,
Compose에서는 코드로 바로 선언하여 바로 사용할 수 있다는 메리트를 느낄 수 있다.


코드를 분석해보자. 📝

지금은 간단하게 코드를 분석하고, 용어를 알아보며 JetPack Compose와 조금씩 친해져보자.

class HelloJetPack {
    @Composable
    @Preview
    fun ButtonTestScreen() {
        val (addNumber, setNumber) = remember { mutableStateOf(1) }
        val onClickEvent = {
            setNumber(addNumber + 1)
        }

        Column(
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ){
            ComposeText(addNumber)
            AddText(onClickEvent)
        }
    }

    @Composable
    fun ComposeText(number : Int) {
        Text(text = number.toString())
    }

    @Composable
    fun AddText(onClickEvent: () -> Unit) {
        Button(onClick = onClickEvent) {
            Text(text = "더하기")
        }
    }
}

우선 Annotation에 대해 알아보자.
@Composable

Compose에서 사용하는 UI의 단위로써 사용자에게 보여지는 프로퍼티, 데이터의 집합체이다.

@Preview

PreView는 우리가 XML에서 사용하는 미리보기와 같이 Composable 함수를 추가해 구성된 View를 미리 볼 수 있게 해주는 Annotation이다.


상태?, 재구성? 🤔

우리는 위 코드에서 Composable 어노테이션은 사용자에게 보여지는 데이터의 집합체인 것을 알았다.
그렇다면 이제 Composable 어노테이션을 선언하고, 해당 함수안에 데이터를 기록 하면서 View를 만들면 되는걸까?

위 과정을 진행하기 전 우리는 Compose의 State에 대해서 알아보아야한다.

State ?

Compose에서 State는 '시간에 따라 변경될 수 있는 값' 이라고 불린다.
이렇게 본다면, 그저 우리가 사용하는 변수들의 데이터와 다를게 없지 않은가?

좀 더 내용을 살펴보자.

Compose에서 State는 아래 2가지 특징을 가진다.
1. 컴포저블 함수에서 상태 변수에 할당된 값은 기억되야한다. 즉, 컴포저블 함수가 재호출 되더라도 기존 상태 값은 기억하고 있어야한다.
2. 상태 변수의 변경은 사용자 인터페이스를 구성하는 컴포저블 함수 계층 트리 전체에 영향을 미친다.

이게 어떤 내용일까?
아래 이미지를 참고해보자

분명 우리는 더하기 버튼을 누르면 addNumber에 += 1 연산자가 실행되어 TextView의 값도 1씩 증가해야했다.
하지만, 지금 사진에서는 값이 증가하지 않는다.

그 이유는 Compose State와 관련되어있다.
이전 코드에서 우리는 remember 라는 키워드를 사용해 View가 다시 생성 되더라도 이전 값을 기억하도록 코드를 구성했다.

지금 로그를 살펴보면, "이벤트 실행됨." -> "View가 다시 생성됨" 이 반복되고 있다.
이는, Compose State 2번 째 문항 "상태 변수의 변경은 사용자 인터페이스를 구성하는 컴포저블 함수 계층 트리 전체에 영향을 미친다" 때문에
일어나는 현상이다.

이를 보다 Compose에 친화적으로 말하면 "Recomposition(리컴포지션)" 이라 칭한다.
리컴포지션이 발생하면 remember와 같은 키워드를 사용해 변수를 기록하지 않으면, View가 재구성되는 과정에서 이전 값이 사라지게된다.


mutableState? remember? rememberSaveable? 🔥

우리는 위와 같이 리컴포지션이 일어나면 기존 화면이 재구성되면서 가지고 있던 상태가 사라지는 것을 알았다.
또한, 이를 막기 위해서 remember와 같은 키워드를 사용해 상태를 기록할 수 있음을 알았다.

그렇다면 remember 안에 있는 mutableState는 뭘까?

mutableState?

MutableState는 Compose에서 상태를 선언하기 위해 사용되는
옵저버블 타입(observable type)으로 참조되는 컴포즈 클래스이다.

상태 변수를 읽는 모든 함수는 이 옵저버블 상태를 구독하며, 상태값이 변경되면 모든 구독 함수에 재구성이 트리거된다.
일종의 LiveData를 구독하는것과 같은 효과를 누릴 수 있다.

그럼 remember 이란 mutableState를 기억하기 위해 사용됨을 예상할 수 있다.
remember 사용하려면 어떻게 해야할까?

remember 사용하기

1. 일반적인 선언

@Composable
fun DemoScreen() {
    var myState = remember { mutableStateOf("") }
    
    Text(text = myState.value)
}

위와 같이 일반적인 방법으로는 "=" 연산자를 사용해 remember 키워드를 사용해 mutableStateOf를 감싸서 사용할 수 있다.
또한, 해당 State 접근은 .value를 사용해 접근할 수 있다.

2. by 키워드 사용

@Composable
fun DemoScreen() {
    var myState by remember { mutableStateOf("") }
    
    Text(text = myState)
}

위와 같이 by 키워드를 사용해 remember에 접근하면 .value를 사용해서가 아닌 바로 값에 접근하여 보다 간결하게
코드를 사용할 수 있다.

2. getter, setter 함수로 사용

@Composable
fun DemoScreen() {
    var (switchState, setSwitch) = remember { mutableStateOf(false) }
    val onSwitchChangeEvent = { isOn : Boolean -> setSwitch(isOn) }
}

위 방법을 사용하면 보다 직관적으로 remember 키워드에서 MutableState에 직관적으로 접근할 수 있다.

그럼 이제 remember를 사용하면 상태를 관리하는 컴포저블 UI를 구성할 수 있는 것인가?
반은 맞고 반은 틀리다.

rememberSaveable 에 대해 알아보자.

우리는 Android에서 다양한 상황에서 앱이 작동하도록 구성해야한다.
가령 예를 들어, 사용자의 핸드폰이 회전해서 Activitiy가 다시 생성된다면 어떤 일이 일어날까?

결과

위 결과를 보면 숫자가 증가하고 화면이 방향을 전환하는 순간 값이 초기화 되는 것을 확인할 수 있다.
이는 remember 키워드도 Activity가 다시 생성되면 올바르게 역할을 하지 못함을 확인할 수 있다.

기존에는 savedInstanceState 와 같은 방법을 활용해 Bundle과 같은 형식으로 화면이 다시 생성 됐을 때
데이터를 확인해 지정해주는 방식으로 View의 상태를 기록할 수 있었다.

그럼 JetPack Compose에서도 위와 같은 방법을 사용해서 각 컴포즈들의 상태를 기록해야할까?
정답은 아니다!

아래 내용을 살펴보자.

이번에는 값이 정상적으로 화면이 회전 했을 때도 유지됨을 확인할 수 있다.
하지만, 코드는 크게 변한 부분이 없다.

해당 상황이 가능한 이유는 "rememberSaveable" 덕분이다.
rememberSaveable은 Activity가 파괴되고 재생성되는 부분에서도 우리가 원하는 상태를 유지할 수 있도록 도와준다.

단, rememberSaveable이 Bundle을 사용하는 방식과 다르게 작동하는 것은 아니다. Bundle에 데이터를 저장할 수 있는 remember 코드의 래퍼이다.


상태 호이스팅(hoist)?

우리는 앞전에 ButtonTestScreen() 이라는 컴포저블 함수안에 있는 addNumber, setNumber, onClickEvent 등을 다른 컴포저블 함수에 넘겨줘서 사용했다.

	@Composable
    @Preview()
    fun ButtonTestScreen() {
        val (addNumber, setNumber) = rememberSaveable { mutableStateOf(1) }
        val onClickEvent = {
            setNumber(addNumber + 1)
        }

        Column(
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ){
            ComposeText(addNumber)
            AddText(onClickEvent)
        }
    }

    @Composable
    @Preview
    fun ComposeText(number : Int = 1) {
        Text(text = number.toString())
    }

    @Composable
    fun AddText(onClickEvent: () -> Unit) {
        Button(onClick = onClickEvent) {
            Text(text = "더하기")
        }
    }

위와 같이 계층을 그려보면

-------ButtonTestScreen()------
----------------|---------------
----------------|---------------
----------------|---------------
---------------/ \ --------------
ComposeText()---AddText()

위와 같이 ButtonTestScreen() 이라는 컴포저블 함수가 최상에서
ComposeText()와 AddText()를 자식 컴포저블 함수로 두고있다.

또한, ButtonTestScreen()은 각 컴포저블의 상태를 가지고 있다.
이 처럼 한 단계 높은 컴포저블이 자식 컴포저블의 상태를 가지고 있는 (위로 올린)
구조를 상태 호이스팅이라한다.


CompositionLocal?

그렇다면 여기서 궁금한 점이 생긴다.
부모가 1단계 밑의 자식이 아닌 AddText()나 ComposeText() 함수의 자식에게 상태를 넘겨주거나
또는, 자식에 자식에게 상태를 넘겨주려면 상태를 아래 그림 처럼 계속 넘겨줘야 할까?

생각만해도 힘든 코드이다.
Composable 1에서 Composable 5까지 Color State를 넘겨주려면 계속 타고타고 내려갈 것이다.

하지만, 너무 걱정하지 않아도 된다.
JetPack Compose에서는 이런 일을 개선하기 위해 CompositionLocal 이라는 방법이 존재한다.

코드를 통해 내용을 알아보고, 하나씩 분석해보자.

지금 보면 숫자 12쪽은 파란색, 더하기 버튼의 Text는 빨간색임을 확인할 수 있다.
똑같이 localColor.current를 사용 했는데 왜 위와 같은 차이가 발생할까?

그 이유는 "CompositionLocalProvider" 에 차이가 있다.
CompositionLocalProvider은 중괄호 안에 선언된 컴포저블 함수들에 대해서 compostionLocalOf()로 생성된
ProvidableCompositionLocal 인스턴스에 할당한 값을 전달한다.

즉, 위 코드에서 CompositionLocalProvider안에 provides(Color.Blue) 로 ComposeText() 컴포저블 함수는
localColor.current의 값이 Color.Blue로 접근
된 것이다.

하지만 CompositionLocalProvider안에 없는 AddText()는 localColor의 default 값인 Color.Red가 선언되어,
더하기가 빨간색으로 나온 것이다.


오늘은 간단하게 컴포즈에 대해 알아보고, 상태와 재구성
CompositionLocal 및 상태 호이스팅에 대해 알아봤다.

다음 시간에는 다양한 컴포즈 컴포넌트 or Slot API에 대해 알아보자.

긴 글 읽어주셔서 감사합니다.
틀린 내용은 언제든지 댓글에 편히 남겨주세요

감사합니다.


Reference

profile
안녕하세요😁 안드로이드 개발자 이정환 입니다~⭐️

2개의 댓글

comment-user-thumbnail
2025년 4월 7일

가상DOM트리의 변경을 확인하고 DOM을 재렌더링하는 요즘 웹 프론트와 비슷한 개념이네요~

1개의 답글