remember 도장깨기 - Jetpack Compose

서론

Kotlin을 쓰고 싶어서 안드로이드 앱 개발을 시작했었는데, 코틀린만 사용해서 UI를 구성할 수 있는 프레임워크인 Jetpack Compose가 만들어지고 있다는 소식을 듣고 곧장 달려가서 알파 버전을 사용해봤었다.

내 첫인상은 매우 호감이었다. 프레임워크가 개발 단계여서 그런지 안드로이드 스튜디오 카나리 버전에서만 지원을 했었는데, 실시간 렌더링이 엄청 느리다는 것만 빼고는 정말 좋았다. 일단 안드로이드 레이아웃을 다룰 때 거의 필수적이었던 XML을 더 이상 안 봐도 된다는 게 좋았고, 코드도 엄청 짧아진 데다가 무엇보다도 UI 코드와 다른 코드를 코틀린으로 모두 작성할 수 있다는 점이 가장 인상 깊었다. for문 같은 것도 마음대로 사용할 수 있고, 데이터 바인딩 같이 XML 파일 안에 잘 녹아들지 않는 부자연스러운 자바 비스무리한 코드를 넣지 않아도 되게 되었다! Binding Adapter도 더 이상 작성을 하지 않아도 되기 때문에 한 곳에서만 사용할 코드가 여러 파일로 분리되는 일이 적어졌다. findViewById()를 사용할 일도 없어졌다 (사실 사용할 수도 없다.)

물론 그렇다고 좋은 점만 있는 것도 아니었는데, 컴파일러 플러그인을 이용하는 라이브러리들이 대개 그렇듯이 이상한 오류가 뜨면 디버깅이 매우 어렵다. 내가  겪었던 오류 중에서 제일 황당했던 건 아무런 설명도 없이 "Start/end imbalance"라는 메시지만 보여주는 오류였다. 구글 이슈 트래커까지 뒤져가며 알아낸 문제의 원인은 다음과 같은 코드였다:

@Composable
fun MyComposable(isError: Boolean) {
    Hello()

    if(!isError) return
    Error()
}

들여쓰기를 줄이기 위해서 if문의 블록 안에 해야 할 것들을 정의하지 않고 조건문을 반전시켜서 return하는 건 상당히 흔하게 사용되는 기법이다. 근데 저 오류 메시지를 봤을 때 저 return문이 원인이라는 걸 누가 짐작이나 할 수 있을까??

당연하게도(?) 그 오류는 컴파일러 플러그인 때문이었다. 간단하게 원인을 설명하자면 Jetpack Compose의 컴파일러 플러그인은 @Composable 함수를 하나의 그룹으로 묶는다. 근데 이때 함수의 시작 부분에서 그룹을 시작하는 함수는 호출이 되고, return문 때문에 그룹을 끝내는 함수는 호출이 안 돼서 그룹이 제대로 닫히지 않았다는 오류를 뿜은 것이다 (이 글을 쓰는 시점의 최신 버전인 1.0.0-beta05에서도 아직 해결이 되지 않았다.)

Jetpack Compose를 배울 때 나를 가장 어렵게 만들었던 건 이 컴파일러 플러그인 때문이었다. 어디까지가 컴파일러 플러그인이 담당하는 부분이고 아닌지를 알기가 어려워서 내가 기존에 알고 있던 바닐라 코틀린의 작동 방식과 뭔가 다르게 동작하나?라는 의문이 항상 남아있었기 때문이다.

remember 함수도 그 중 하나였다. 내가 설명을 잘하는 건 아니지만 그래도 미래에 혹시라도 이게 뭔지 까먹었을 나를 위해서 최대한 알기 쉽게 적어두려고 한다. 이 녀석은 보기보다 꽤 복잡한데, 더 자세한 걸 알고 싶은 분은 아래 링크를 참고하기 바란다.

Under the hood of Jetpack Compose — part 2 of 2
Under the hood of Compose

@Composable의 생명주기

우선 remember가 무엇인지 제대로 이해하기 위해서는 @Composable 함수의 생명주기에 대해서 이해할 필요가 있다. 안드로이드의 Activity, View, Fragment 같이 사용자에게 무언가를 보여주는 것들이 항상 그렇듯 composable 함수도 마찬가지로 생명주기를 가지고 있는데, 다행스러운 건 전통적인 안드로이드의 생명주기보다는 훨씬 간단해서 크게 3가지 상태밖에 없다!

  • Initial Composition - Composable이 처음 생성될 때
  • Recomposition - UI를 구성하는 데이터가 변경되었을 때 (주로 State<T>가 바뀌거나 Composable 함수의 매개변수의 값이 변화할 때 실행된다)
  • Decomposition - Composable이 파괴될 때

위 사진에서 보이듯 Composable은 initial composition 후에 필요에 따라 recomposition을 몇 번 한 다음 decomposition을 한다 (recomposition은 일어나지 않을 수도 있고, 여러 번 일어날 수도 있다.)

@Composable
fun MyComposable(str: String) {
	Text(text = str)
}

위의 코드에서 MyComposable은 str을 사용하고 있기 때문에 str이 변경되어 들어오면 MyComposable은 recomposition을 하게 된다.

그래서 remember이 뭔데요?

다음과 같은 코드를 생각해보자. 1.0.0-beta05 버전 기준으로 실행되는 코드이기 때문에 직접 실험해볼 수도 있다.

// ...
setContent {
	MainScreen()
}
// ...

@Composable
fun MainScreen() { // 이 함수가 뭘 하는지 지금은 **몰라도 됨**
    val transition = rememberInfiniteTransition()
    val value by transition.animateFloat(
        initialValue = 0f,
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            animation = tween(1000, easing = { round(it) }),
            repeatMode = RepeatMode.Reverse
        )
    )

    WhatIsRemember(value)
}

@Composable
fun WhatIsRemember(value: Float) { // 이 함수에만 집중!!
    Column {
        Text(text = value.toString())
    }
}

MainScreen 함수는 일단 지금은 무시하도록 하자. 우리가 집중해야 할 부분은 WhatIsRemember composable 함수이다. 다시 한 번 위에서 배운 생명주기라는 녀석을 떠올려보자. 우선 처음에 WhatIsRemember composable이 생성될 때가 initial composition이고, 이 함수가 사용하는 데이터(value)에 변화가 생기면 recomposition이 일어난다. 위 코드에서 value 변수는 1초에 한 번씩 값이 변화해서 들어오는데 Text가 이 value의 값을 사용하기 때문에 1초마다 한 번씩 화면에 보이는 글씨가 바뀐다.

이번에는 WhatIsRemember 함수를 다음과 같이 바꿔보자:

@Composable
fun WhatIsRemember(value: Float) {
    Column {
        val rememberedValue = remember { value }
        Text(text = rememberedValue.toString())
        Text(text = value.toString())
    }
}

위에 배치한 Text는 계속 0.0만 표시되고 아래 Text는 값이 계속 바뀌는 것이 보일 것이다. 대충 감이 오겠지만 remember composable 함수는 initial composition 때 저장한 값을 앞으로의 recomposition에서 사용할 수 있도록 해준다. 즉 recomposition을 할 때는 값을 새롭게 생성하지 않고 기존에 저장되어 있던 데이터를 반환한다는 의미이다.

참고로 다른 곳에 배치된  WhatIsRemember은 아예 관련이 없기 때문에 initial composition이 따로 실행되므로 remember 함수도 관련이 없는 값을 내놓는다. MainScreen의 코드를 다음과 같이 변경해보자:

@Composable
fun MainScreen() {
    Column {
        val transition = rememberInfiniteTransition()
        val value1 by transition.animateFloat(
            initialValue = 0f,
            targetValue = 1f,
            animationSpec = infiniteRepeatable(
                animation = tween(1000, easing = { round(it) }),
                repeatMode = RepeatMode.Reverse
            )
        )
        val value2 by transition.animateFloat(
            initialValue = 2f,
            targetValue = 3f,
            animationSpec = infiniteRepeatable(
                animation = tween(1000, easing = { round(it) }),
                repeatMode = RepeatMode.Reverse
            )
        )

        Text("WhatIsRemember 1", fontWeight = FontWeight.Bold)
        WhatIsRemember(value1) // <-- 별도의 생명주기를 가짐

        Text("WhatIsRemember 2", fontWeight = FontWeight.Bold)
        WhatIsRemember(value2) // <-- 별도의 생명주기를 가짐
    }
}

간단하게 이 코드를 설명하자면 WhatIsRemember 두 개를 세로로 배치하는 코드이다. 위에 배치된 WhatIsRemember는 0과 1 사이를 왔다갔다 하고 아래에 배치된 건 2와 3 사이를 왔다갔다 한다. 안드로이드의 전통적인 View 시스템에서 두 개의 WhatIsRemember 뷰 인스턴스를 생성했다고 생각하면 된다. 따라서 각 WhatIsRemember composable은 각자 별개의 생명주기를 가지게 되고, 그에 따라 remember 함수도 별개의 값을 반환한다.

실행해보면 위와 같은 결과가 나온다. 기존에는 코드를 짤 때 같은 인자로 전역 함수를 호출하면 보통 같은 결과가 나올 거라고 생각하는 게 보통인데, remember 함수는  Composable이 어디에 위치했느냐에 따라서 다른 결과를 반환한다. 이는 Positional Memoization이라는 기법을 사용했기 때문에 가능한 결과인데, 이 글에서는 자세히 다루지 않겠다.

remember를 언제 사용해야 할까? 한 문장으로 설명하면 다시 하고 싶지 않은 비싼 작업을 수행해야 할 때 혹은 recomposition이 되어도 데이터를 보존해야 할 때사용하는 것이 좋다. 예를 들어 텍스트 포매팅 같은 작업들 말이다. 전통적인 안드로이드 View 시스템에서 onDraw() 함수를 한 번씩 써본 사람들은 알겠지만 이 메서드는 매우 자주 호출이 돼서 Paint를 전역 변수로 저장해서 사용하라는 말을 들은 적이 있을 것이다. Composable 함수도 마찬가지로 recomposition이 매우 자주 실행될 수 있기 때문에 생성하는 데 오래 걸릴 만한 것들은 remember을 사용해서, 값을 recomposition을 할 때마다 새로 생성하지 않고 원래 있던 것을 쓰고 싶을 때 쓰면 된다.

그런데 만약 value 값이 변했을 때 remember에 넣었던 값을 바꾸고 싶다면 어떻게 하는 게 좋을까? 그럴 때를 위한 remember(key: Any?, calculation: () -> T) 함수도 준비되어 있다. 이 함수는 key에 들어온 값이 변경되었을 때 calculation을 다시 수행한다. key를 여러 개 제넣을 수 있는 함수도 있으니 관심 있으면 확인해보면 좋다.

@Composable
fun WhatIsRemember(value: Float, somethingElse: Int) {
	val text = remember(key = value) { "Your value is: %f".format(value) }
	Text(text = text)
	// ...
}
recomposition 할 때 value 값이 변경됐다면 이전에 저장된 값을 "잊어버리고" 문자열 포맷을 다시 수행한다.

여기까지 알았다면 이제 remember과 거의 함께 쓰이는 MutableState<T>에 대해서 알아보도록 하자.

MutableState<T>

Jetpack Compose에서는 State<T>의 값이 변화하면 recomposition이 일어난다.

MutableState<T>의 이름에서 알 수 있듯 이 클래스는 State<T>의 값을 우리가 직접 변경할 수 있게 해준다 (기본적으로 State<T>는 immutable이다.) 그렇다면 우리가 MutableState<T>의 데이터를 수정하면 recomposition이 일어난다는 의미가 된다!

@Composable
fun YourComposable() {
    Column {
        var clicks by remember { mutableStateOf(0) }

        Button(
            onClick = { ++clicks }
        ) {
            Text("Click here!")
        }

        Text(text = "Clicked $clicks times")
    }
}

MutableState<T>를 생성하는 가장 간단한 방법은 위와 같이 mutableStateOf() 함수를 사용하는 것이다. 아마 위 코드와 비슷한 걸 다른 곳에서 많이 봤을 것이다. 단순하게 버튼이 몇 번 클릭되었는지를 보여주는 코드이고 그 이상도 이하도 아니다. 혹여나 위의 코드에서 빨간줄이 안 없어질 수도 있는데, 이때는 import androidx.compose.runtime.getValueimport androidx.compose.runtime.setValue를 추가해주면 된다.

여기서 질문: 위 코드에서 remember이 왜 사용될까? 답을 알겠다면 지금까지 잘 따라와준 것이고, 아직 이해가 잘 안 된다 해도 지금부터 이해하면 문제 없다.

참고로 var clicks by ~~에서 by 키워드는 property delegate라는 코틀린의 기능이다. 위 코드는 아래 코드와 동일하다.

@Composable
fun YourComposable() {
    Column {
        var clicks = remember { mutableStateOf(0) }

        Button(
            onClick = { ++clicks.value }
        ) {
            Text("Click here!")
        }

        Text(text = "Clicked ${clicks.value} times")
    }
}

무언가가 왜 필요한지 알기 위해서는 그걸 없애보는 게 가장 효과 좋은 방법이다. 다음 코드를 한 번 실행해보면 뭔가 이상한 걸 느낄 수 있을 것이다:

@Composable
fun YourComposable() {
    Column {
        var clicks by mutableStateOf(0) // <-- remember가 빠짐

        Button(
            onClick = { ++clicks }
        ) {
            Text("Click here!")
        }

        Text(text = "Clicked $clicks times")
    }
}

안드로이드 스튜디오가 빨간 줄을 띄우기는 하지만 실행하는 데에는 문제가 없으니 실행을 시켜보자. 그러고서 클릭을 해본다면 숫자가 올라가지 않는 것을 확인해볼 수 있다. 그 이유는 YourComposable이 recompose 되면서 clicks가 다시 초기화 되기 때문이다. 다시 말하자면 원래는 initial composition 때만 0으로 초기화 한 후 recomposition 단계에서는 이전에 올라간 숫자 데이터를 사용해야 하는데, remember을 사용하지 않으니 계속 0으로 초기화를 해서 올라가는 숫자를 볼 수 없는 것이다.

사실 remember를 사용하지 않고도 clicks 값을 늘리는 방법이 없지는 않다. Jetpack Compose의 최적화 기능을 악용(?)하는 방법이다. 물론 이건 특수한 조건이 만족돼야 사용할 수 있는 방법이기도 하고 이번 주제와는 관련이 없는 것 같아서 여기서 이만 줄이고 끝내도록 하겠다.

마무리

Jetpack Compose는 지금까지 프로그래밍 언어를 사용했던 느낌과는 미묘한 차이가 있어서 진입장벽이 낮다고는 못하겠다. 하지만 그렇다고는 해도 한 번 배워서 익숙해진다면 이렇게 강력하게 사용할 수 있는 라이브러리가 또 어디 있을까?? 최근에는 JetBrains가 compose-jb라고 해서 다른 플랫폼(웹이나 데스크탑 등)에서도 사용할 수 있도록 만들고 있는데, JetBrains 자신들도 Jetpack Compose를 제품들에 적용하고 있으니 당장은 점유율이 크게 높아지지 않는다고 해도 개발은 꾸준히 진행될 것으로 보인다. 지금 이 글을 쓰고 있는 시점 기준으로 베타 버전인데, 정식 릴리즈가 됐을 때 많은 사람들이 사용하게 되었으면 좋겠다. 그리고 나중에 React의 점유율을 Jetpack Compose가 좀 많이 가져왔으면 좋겠다는 게 내 자그마한 희망 사항이다. 웹 개발도 코틀린으로 할 수 있게 되니까 말이다 ㅎㅎ;; 앞으로 Jetpack Compose가 웹에서도 점유율을 꾸준히 얻게 만들기 위해서라도 조금씩 글을 써보려고 한다. 프레임워크를 잘 아는 사람이 많아지면 자연스럽게 점유율은 따라서 올라가기 때문이다.

그나저나 이 글에서 영어로 된 용어를 좀 많이 썼는데, 괜히 한국어로 번역하면 오히려 글이 비직관적이 되는 경우가 많아서 일부러 그대로 뒀다. 안드로이드 공식 문서가 Activity를 활동으로 번역하고 MainActivity를 주활동으로 번역해놨던데 나는 그런 꼴을 못 보겠어서 말이다...

참고

Lifecycle of composables | Jetpack Compose | Android Developers
State and Jetpack Compose | Android Developers
Under the hood of Jetpack Compose — part 2 of 2
Under the hood of Compose