ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Android] Compose 상태 관리 심화
    개발/Android 2025. 2. 15. 09:14

    들어가며

    [Android] Compose 상태 관리의 기본 개념
    *이전 포스팅과 이어집니다!

    지난 포스팅 요약
    Compose : Composable, 너는 이렇게 생겼어! 여기서 상태가 그려지면 돼.
    Composable : 알겠어! 이대로 구성하면 되는거지?
    Compose : 맞아! 그리고 상태는 네가 들고 있어!
    Composable : 응. 근데 혹시, 나 계속 여기 있어야해? 나 다른 화면에도 가봐야하는데…
    Compose : 응…? 너가 상태를 들고있는데, 너가 가면 여기는 누가 그려줘? 안돼, 못 가.
    Composable : 🙁
     

    [Android] Compose 상태 관리의 기본 개념

    들어가며이번 포스트는 Compose의 상태에 대한 전반적인 이해와 이를 관리하는 기초적인 방법을 설명합니다.Composable을 선언하는 방법이나, 더욱 효율적인 상태 관리 방법(상태 호이스팅 등)에 관

    walnut-dev.tistory.com

     

    이전 포스팅에서는 Compose의 UI 변경 방식과 상태를 관리하는 기본적인 방법을 알아보았습니다.

    • Compose는 선언형 패러다임이 적용된 UI 프레임워크이며, Composable의 조합으로 UI를 구성할 수 있습니다.
    • 상태의 변화가 일어나면, 이를 감지하여 Composable의 재구성이 일어나고 UI가 업데이트됩니다.
    • 상태 변화를 감지하기 위해 MutableState 을 사용하고, 재구성이 일어나도 이전의 상태를 저장하기 위해 remember, rememberSaveable 을 활용합니다.

    하지만 상태를 Composable이 직접 가지고 있을 경우, Composable의 재사용과 유지 보수가 어렵다는 문제점이 있습니다.

    이번 포스팅에서는 상태를 어디서 관리하는 것이 좋을지에 대해서 알아보겠습니다!

     

    목차

    상태를 어디서, 어떻게 관리할까?

    • 예제: 로그인 화면 구현하기

    Stateful vs Stateless

    • Stateful Composable의 장단점
    • Stateless Composable의 장단점

    상태 호이스팅

    • 상태 호이스팅이란?
    • UDF(Unidirectional Data Flow)
    • 상태 호이스팅의 특성
    • 어떻게 구현할까?
      상태 호이스팅을 구현하는 방법
    • 상태를 어디서 관리할까?
      상태 호이스팅을 구현할 때의 3가지 규칙
    • 무조건 상태 호이스팅이 좋은걸까? Nope!

     


     

    상태를 어디서, 어떻게 관리할까?

    상태는 사용자가 알고자 하는 정보, 또는 사용자가 알아야하는 정보를 나타내는 중요한 요소입니다.

    상태를 올바르게 나타내는 것 만큼이나 중요한 것은 상태를 잘 관리하는 것입니다.

     

    단순한 기능을 제공하는 서비스나 초기 단계의 서비스라면 상태의 수가 많지 않을 것입니다.

    하지만 기능이 점차 추가되고 앱의 복잡성이 증가하면 관리해야하는 상태의 수도 증가할 것입니다.

    새로운 기능을 개발하거나 UI 개선, 리팩터링 등이 이루어져야 한다면 코드의 구조가 크게 바뀔지도 모릅니다.

    Composable이 상태를 직접 들고 있다면, 위와 같은 변경사항에 대처하기 어려워집니다.

     

    예제) 로그인 화면 구현

    예시 상황을 들어서 이해해보겠습니다.

    닉네임, 이메일, 비밀번호를 입력하여 로그인을 하는 기능이 필요하다고 합니다.

    이전 포스팅에서 사용했던, 닉네임을 입력받는 Composable을 활용하여 로그인 기능을 구현한다고 가정합시다.

    @Composable
    private fun NicknameTextField() {
        Column(
            modifier = Modifier.padding(16.dp),
        ) {
            var nickname: MutableState<String> by rememberSaveable { mutableStateOf("") }
    
            Text(
                text = "닉네임을 입력하세요.",
                modifier = Modifier.padding(bottom = 8.dp),
                style = MaterialTheme.typography.bodyMedium,
            )
            OutlinedTextField(
                value = nickname,
                onValueChange = { nickname = it },
                label = { Text("닉네임") },
            )
        }
    }

    상태에 관한 포스팅이니, UI와 관련된 구현과 개선은 넘어가도록 하겠습니다.

     

    이메일과 비밀번호를 입력하는 입력 란도 코드를 참고하여 아래처럼 구현해보았습니다.

    @Composable
    private fun EmailTextField() {
        Column(
            modifier = Modifier.padding(16.dp),
        ) {
            var email: MutableState<String> by rememberSaveable { mutableStateOf("") }
    
            Text(
                text = "이메일을 입력하세요.",
                modifier = Modifier.padding(bottom = 8.dp),
                style = MaterialTheme.typography.bodyMedium,
            )
            OutlinedTextField(
                value = email,
                onValueChange = { email = it },
                label = { Text("이메일") },
            )
        }
    }
    
    @Composable
    private fun PasswordTextField() {
        Column(
            modifier = Modifier.padding(16.dp),
        ) {
            var password: MutableState<String> by rememberSaveable { mutableStateOf("") }
    
            Text(
                text = "비밀번호를 입력하세요.",
                modifier = Modifier.padding(bottom = 8.dp),
                style = MaterialTheme.typography.bodyMedium,
            )
            OutlinedTextField(
                value = password,
                onValueChange = { password = it },
                label = { Text("비밀번호") },
            )
        }
    }

    각각의 입력을 받는 Composable을 만들었으니, 이들을 조합하여 화면을 구성하고, 각 입력 값을 가져와 서버로 로그인 요청을 보내도록 구현하면 될 것입니다. 빠르게 구현을 마친 당신은 빠른 퇴근을 기대합니다.

     

    하지만 큰 문제가 있습니다. 각각의 입력 값을 어떻게 가져올까요?

    저희는 입력 값을 모두 모아 서버로 요청을 보내야합니다. 하지만 각 입력 Composable이 모두 각자의 입력 값, 즉 상태를 직접 가지고 있습니다. 입력이 모두 각자 다른 Composable로 분리되어있기 때문에, 이 경우 각각의 상태를 가져와 서버로 요청을 보낼 수 없습니다.

    @Composable
    private fun LoginScreen() {
        // 제목
        Text(text = "로그인이 필요합니다.")
        
        // 각 입력 레이아웃에서 입력된 값(상태)를 알 수 없습니다.
        NicknameTextField()
        
        EmailTextField()
        
        PasswordTextField()
        
        // 로그인 버튼
        Button(
            modifier = Modifier.padding(16.dp),
            onClick = { requestLogin() },
        ) {
            Text(text = "로그인")
        }
    }

     

    저희가 원하는 동작을 위해서는, 어느 한 Composable에서 각 레이아웃에 입력된 모든 상태 값을 알 수 있어야 합니다.

    이번 시간에도 위의 코드를 개선시키며, 상태를 좀 더 효율적으로 관리하는 방법을 알아보겠습니다.

    한번 더 실수로부터 성장해봅시다!

     


    Stateful vs Stateless

    방법을 알아보기 전에, 먼저 두 개념과 서로 간의 차이점을 먼저 이해해야 합니다.

    Stateful

    Stateful versus Stateless 설명 중

    A composable that uses remember to store an object creates internal state, making the composable stateful. (중략) This can be useful in situations where a caller doesn't need to control the state and can use it without having to manage the state themselves. However, composables with internal state tend to be less reusable and harder to test.

     

    공식문서의 Stateful에 대한 설명입니다. 위 설명을 바탕으로 Stateful의 특징을 나열하면 아래와 같습니다.

    • remember 를 사용하여 내부 상태를 저장하는 Composable을 Stateful하다고 합니다.
    • 호출자가 상태를 제어할 필요가 없고 상태를 직접 관리하지 않아도 되는 상황에서 유용합니다.
    • 재사용성이 떨어지고 테스트가 어려운 경향이 있습니다.

    Stateful 이라는 단어에서 알 수 있듯, 상태를 직접 가지고 있음을 의미합니다.

     

    장점

    상태의 사용이 쉽고 간단합니다.

    Composable이 상태를 직접 가지고 있다면, 해당 Composable을 호출하는 곳에서는 상태가 어떻게 바뀌는지 알 수 없습니다.

    상위의 Composable에서 상태를 알지 않아도 되는 상황에서는 Composable 간의 복잡성을 줄일 수 있고 상태의 관리가 단순해진다는 장점이 있습니다.

    단점

    재사용성이 떨어집니다.

    내부의 상태를 외부에서 알 수 없다는 특징 때문에, 해당 Composable은 다양한 상황에서 활용하기 매우 어려워집니다.

    예를 들어 각 입력 값을 검증해야하는 경우, 입력 요소마다 형식이 다르므로 각각 다른 검증 로직이 필요합니다.

    • 닉네임 형식 : 5자 이내 / 영문, 한글 가능
    • 이메일 형식
    • 비밀번호 형식 : 8~12자 / 영문자, 숫자, 특수문자를 1자 이상 조합

    이를 Stateful한 Composable로 구현해야 한다면, 각 요소를 입력받는 3개의 Composable을 따로 만들어주어야 합니다.

    사실상 입력 요소가 다르다는 차이점 외에는, 문자열을 입력받아 화면에 나타낸다는 동일한 동작을 합니다. 그러나 각 내부 상태를 검증하는 기능으로 인해, 다른 곳에서는 Composable을 재사용하기 어렵습니다. UI의 재사용이 용이하다는 Compose의 장점을 충분히 활용하지 못하게 됩니다.

    테스트가 어렵습니다.

    상태를 내부적으로 들고 있으므로, 상태 값에 대한 테스트가 어렵습니다. 상태의 변경에 따라 UI의 변화를 테스트하여 간접적으로 테스트를 할 수는 있겠지만, 이는 한계가 있고 테스트의 신뢰도가 떨어집니다.

     

    위의 로그인 기능을 구현하는 예제에서는 어떨까요?

    위에서 구현했던 각각의 TextField들은 remember를 사용하여 내부에 상태를 저장하여, 입력 값이라는 상태를 직접 관리하고 있습니다. 그래서 Stateful합니다.

    각 TextField을 호출하는 LoginScreen가 호출자입니다. LoginScreen은 상태를 제어하지 않고 관리하지도 않고 있습니다.

    저희는 모든 입력 값을 확인하여 로그인을 요청해야 하므로, 각 TextField의 상태를 알 필요가 있습니다. 이는 TextField의 외부에서 상태를 관리해야 한다는 것을 의미합니다. 그러므로 TextField을 Stateful Composable로 구현하는 것은 구현 목적에 적합하지 않습니다.

     

    Stateless

    A stateless composable is a composable that doesn't hold any state. An easy way to achieve stateless is by using state hoisting. (중략) The stateful version is convenient for callers that don't care about the state, and the stateless version is necessary for callers that need to control or hoist the state.

     

    반대로 Stateless는 상태를 가지고있지 않음을 의미합니다.

    • Composable이 내부적으로 상태를 들고 있지 않습니다.
    • 필요한 경우, Composable이 사용하는 상태를 외부에서 주입 받습니다. (의존성 주입)
    • Composable의 호출자가 상태를 관리합니다.

    Stateless Composable은 해당 Composable의 호출자가 Composable에게 상태를 주입해주는 방식이 됩니다. 호출자가 상태를 제어 및 관리하고, Composable은 상태의 값을 활용하여 UI를 구성합니다.

     

    장점

    Composable이 외부에서 상태를 주입받기 때문에 생기는 이점으로는 재사용이 편리합니다.

    Composable이 직접 상태를 관리하고있지 않으므로, Stateful한 Composable보다 다른 곳에서 사용하기 수월합니다. 주로 범용적인 Composable(TextField, Text, Button, Image 등)을 구현할 때 Stateless하게 만듭니다. UI의 재사용이 유리하다는 Compose의 장점을 살릴 수 있는 구현 방식입니다.

     

    또한 테스트가 용이합니다.

    Composable은 상태를 외부에서 주입받습니다. 이 상태를 제어하고 관리하는 주체가 Composable이 아니라 외부에 있으므로, 테스트 환경에서도 적합합니다. 테스트 코드에서 상태의 값을 직접 확인하고 관리할 수 있으므로, 보다 신뢰도 있는 테스트를 작성할 수 있습니다.

     

    단점

    복잡성이 증가할 수 있습니다.

    외부에서 상태를 주입받아야만 하므로, Composable 간에 상태를 전달하는 과정이 필요합니다. 만약 상태의 수가 증가하면 관리가 복잡해지고, 인자로 전달해주어야 하는 상태의 수가 많아져 UI 구성이 복잡해질 수 있습니다.

     

    또한 상태를 어디에서 관리해야하는지는 개발자의 몫입니다.

    개발자의 구현에 따라 상태의 관리가 복잡해질수도, 단순해질 수도 있습니다. 이는 View의 성능에도 영향을 줄 수 있기 때문에, 구현에 주의를 기울여야 합니다.

     

    The stateful version is convenient for callers that don't care about the state, and the stateless version is necessary for callers that need to control or hoist the state.

     

    Stateful한 Composable은 호출자가 상태를 몰라도 되는 상황에서 편리하지만, Stateless한 Composable은 호출자가 상태를 제어하거나 끌어올리는데 필요하다고 합니다. 이는 상태 호이스팅 패턴에 관한 설명입니다. Stateless는 상태 호이스팅 패턴과 아주 깊은 연관이 있는데, 상태 호이스팅에 대해서는 잠시 뒤 자세히 설명하겠습니다!

     

    그렇다면, 자세한 방법은 아직 모르지만, 예제의 상황에서는 TextField를 Stateless Composable로 구현하고, 입력 값 상태를 상위의 호출자인 LoginScreen에서 확인할 수 있는 구현이 필요할 것입니다.

    문제를 어떻게 접근해야할지 알았으니, 이제 그 해결 방법을 알아보겠습니다!

     


    상태 호이스팅

    사실 앞서 Stateless Composable에서 설명한 상태의 관리 방식이 바로 상태 호이스팅(State Hoisting)입니다.

    • Composable은 외부에서 상태를 주입받는다.
    • Composable의 호출자가 상태를 관리 및 제어한다.

     

    상태 호이스팅이란?

    State Hoisting, 상태를 Hoist 한다? 무슨 뜻일까요?

    Hoist : 들어올리다, 끌어올리다, 게양하다

     

    Hoist는 ‘끌어올리다’, ‘게양하다’ 라는 뜻을 가진 단어입니다. 즉, 상태를 끌어올린다는 이야기입니다.

    앞서 Composable이 Stateless한 경우에는 상위 호출자가 상태를 주입하고, 호출자가 상태를 관리한다고 했습니다. Composable이 나타내고 가지고있던 상태를 상위 호출자로 끌어올린다고 하여, 상태 호이스팅이라고 불립니다.

     

    UDF(Unidirectional Data Flow)

    상태 호이스팅은 단방향 데이터 흐름을 따릅니다. 단방향 데이터 흐름은 상태는 내려가고, 이벤트는 올라가는 흐름의 패턴을 말합니다.

    단방향 데이터 흐름

     

    Composable에 필요한 상태를 위로 끌어 올렸으므로, 호출자는 상태를 확인하고 제어할 수 있습니다. 즉, 값(value)에 접근하여 변경할 수 있습니다. 호출자는 변경한 값(State)을 의존성 주입으로 하위 Composable에게 전달합니다.

     

    상태 값을 변경하는 것은 호출자의 역할이지만, 값의 변경을 위한 이벤트를 전달받는 주체는 Composable입니다.

    버튼을 클릭하거나, 텍스트를 입력하는 등의 이벤트가 일어나면, 이를 감지하는 것은 Composable입니다. 이벤트에 따라 상태가 변화해야하므로, Composable은 감지한 이벤트를 상위 호출자에게 전달해야합니다.

     

    이러한 단방향 데이터 흐름은 UI에 상태를 표시하는 Composable과 앱의 상태를 저장하고 변경하는 부분을 분리할 수 있다는 장점이 있습니다.

     

    상태 호이스팅의 특성

    상태 호이스팅을 적용하면 아래와 같은 특성을 갖습니다.

    • 단 하나의 상태만 존재합니다.
      • 같은 상태를 Composable 곳곳에 여러 개 두는 대신, 상위로 이동시켜 관리합니다. 따라서, Composable 내에 참된 데이터가 하나만 존재하도록 보장합니다.
      • 버그를 방지할 수 있습니다. 동일한 데이터를 가리키는 상태를 여러 개 둔다면, Composable마다 동기화가 이루어지지 않았을 때 데이터의 일관성이 사라집니다.
    • Composable을 캡슐화합니다.
      • 상태를 저장하고있는 Stateful Composable만이 상태를 수정할 수 있습니다.
      • 하위 컴포넌트와 외부에서는 수정이 불가능하기 때문에, 책임의 분리가 이루어지고 객체간 의존성을 떨어뜨려줍니다.
    • 내부의 다른 Composable에게 상태를 공유할 수 있습니다.
      • 호이스팅된한 상태를 하위의 여러 Composable과 공유할 수 있습니다.
      • 단 하나만 존재하는 상태를 여러 Composable이 주입 받으므로, 데이터의 일관성을 유지할 수 있습니다.
    • 이벤트를 가로채어 원하는 동작을 수행할 수 있습니다.
      • Stateless Composable의 호출자는 상태를 변경하기 전에 이벤트를 무시하거나 수정할 수 있어, 원하는 커스텀 구현이 가능합니다.
    • 데이터를 독립적으로 관리할 수 있습니다.
      • Stateless Composable의 상태를 ViewModel 등 어디에나 저장할 수 있습니다.
      • UI와 Data 레이어를 분리할 수 있습니다.

     

    어떻게 구현할까?

    상태 호이스팅이 무엇인지 개념을 살펴보았으니, 로그인 화면 예제를 직접 개선하면서 구현 방법을 알아보겠습니다.

     

    상태 호이스팅 전

    우선 nickname 상태를 먼저 호이스팅 해보겠습니다.

    아래 NicknameTextField Composable의 상태가 내부에 위치해있습니다. Stateful한 이 Composable을 Stateless하게 만들기 위해서 상태를 위로 호이스팅할 것입니다.

    @Composable
    private fun LoginScreen() {
        Text(text = "로그인이 필요합니다.")
        
        NicknameTextField()
        
        EmailTextField()
        
        PasswordTextField()
        
        Button(
            modifier = Modifier.padding(16.dp),
            onClick = { requestLogin() },
        ) {
            Text(text = "로그인")
        }
    }
    
    @Composable
    private fun NicknameTextField() {
        Column(
            modifier = Modifier.padding(16.dp),
        ) {
            var nickname: MutableState<String> by rememberSaveable { mutableStateOf("") }
    
            Text(
                text = "닉네임을 입력하세요.",
                modifier = Modifier.padding(bottom = 8.dp),
                style = MaterialTheme.typography.bodyMedium,
            )
            OutlinedTextField(
                value = nickname,
                onValueChange = { nickname = it },
                label = { Text("닉네임") },
            )
        }
    }
    
    /* 
     * EmailTextField, PasswordTextField Composable도
     * 자신의 입력 상태 값을 내부에 가지고 있습니다.
     */

     

     

    상태 호이스팅 후

    상위 호출자인 LoginScreen 위치로 nickname 상태를 호이스팅합니다.

    @Composable
    private fun LoginScreen() {
        // nickname 상태를 LoginScreen으로 호이스팅(위로 끌어올림)했습니다.
    		var nickname: MutableState<String> by rememberSaveable { mutableStateOf("") }
    
        Text(text = "로그인이 필요합니다.")
        
        // 의존성 주입으로 상태 값을 하위 Composable에게 전달합니다.
        NicknameTextField(nickname = nickname)
        
        EmailTextField()
        
        PasswordTextField()
        
        Button(
            modifier = Modifier.padding(16.dp),
            onClick = { requestLogin() },
        ) {
            Text(text = "로그인")
        }
    }
    
    // 의존성 주입을 이용해 상위 호출자(LoginScreen)로부터 nickname을 받습니다.
    @Composable
    private fun NicknameTextField(nickname: String) {
        Column(
            modifier = Modifier.padding(16.dp),
        ) {
            Text(
                text = "닉네임을 입력하세요.",
                modifier = Modifier.padding(bottom = 8.dp),
                style = MaterialTheme.typography.bodyMedium,
            )
            OutlinedTextField(
                value = nickname,
                onValueChange = { nickname = it },
                label = { Text("닉네임") },
            )
        }
    }

     

     

    이벤트를 상위로 전달

    아직 완전한 상태 호이스팅이 이루어지지 않았습니다!

    사용자의 키보드 입력 이벤트를 받는 주체는 NicknameTextField의 OutlinedTextField입니다. 현재 nickname을 들고있는 LoginScreen이 nickname의 값을 바꿀 수 있도록, OutlinedTextField의 이벤트를 LoginScreen에게 전달해주어야 합니다.

    상위 Composable로 이벤트를 전달하는 것은 람다 함수(콜백)를 이용하여 간단히 구현할 수 있습니다.

     

    @Composable
    private fun LoginScreen() {
    		var nickname: MutableState<String> by rememberSaveable { mutableStateOf("") }
    
        Text(text = "로그인이 필요합니다.")
        
        // 이벤트의 변화에 따라 nickname을 수정하고, 수정된 값을 전달합니다.
        NicknameTextField(
            nickname = nickname,
            onNicknameChange = { nickname = it }, // 람다 함수를 이용해 이벤트를 전달받습니다.
        )
        
        EmailTextField()
        
        PasswordTextField()
        
        Button(
            modifier = Modifier.padding(16.dp),
            onClick = { requestLogin() },
        ) {
            Text(text = "로그인")
        }
    }
    
    // 의존성 주입을 이용해 상위 호출자(LoginScreen)로부터 nickname을 받고,
    // 람다 함수를 이용하여 발생한 이벤트를 호출자에게 전달합니다.
    @Composable
    private fun NicknameTextField(
        nickname: String,
        onNicknameChange: (String) -> Unit,
    ) {
        Column(
            modifier = Modifier.padding(16.dp),
        ) {
            Text(
                text = "닉네임을 입력하세요.",
                modifier = Modifier.padding(bottom = 8.dp),
                style = MaterialTheme.typography.bodyMedium,
            )
            OutlinedTextField(
                value = nickname,
                onValueChange = onNicknameChange,
                label = { Text("닉네임") },
            )
        }
    }

     

    이제 nickname을 들고있는 LoginScreen이 닉네임 입력에 따라 nickname을 수정할 수 있게 되었습니다.

     

    상태 호이스팅 완료

    나머지 다른 상태들도 호이스팅을 진행합니다.

    @Composable
    private fun LoginScreen() {
    		var nickname: MutableState<String> by rememberSaveable { mutableStateOf("") }
    		var email: MutableState<String> by rememberSaveable { mutableStateOf("") }
    		var password: MutableState<String> by rememberSaveable { mutableStateOf("") }
    
        Text(text = "로그인이 필요합니다.")
        
        NicknameTextField(
            nickname = nickname,
            onNicknameChange = { nickname = it },
        )
        
        EmailTextField(
            email = email,
            onEmailChange = { email = it },
        )
        
        PasswordTextField(
            password = password,
            onPasswordChange = { password = it },
        )
        
        Button(
            modifier = Modifier.padding(16.dp),
            // 이제 각 입력 값을 가지고 로그인 요청을 보낼 수 있습니다!
            onClick = { requestLogin(nickname, email, password) },
        ) {
            Text(text = "로그인")
        }
    }

    이로써 상태는 위에서 아래로, 이벤트는 아래에서 위로 전달되는, 단방향 데이터 흐름 패턴을 구현하는 상태 호이스팅이 성공적으로 이루어졌습니다!

     

     

    상태를 어디에서 관리할까?

    그런데 한가지 의문이 생깁니다. 상위 Composable로 끌어올릴 상태를 어디에 두어야 할까요?

    다시 말해서, 얼마나 위로 올리는 것이 좋을까요?

     

    다행히 공식 문서 - State in Jetpack Compose 코드랩에서, 상태를 어디로 호이스팅 할 것인지에 대한 규칙을 설명하고 있습니다.

    Key Point: When hoisting state, there are three rules to help you figure out where state should go:

    1. State should be hoisted to at least the lowest common parent of all composables that use the state (read).
    2. State should be hoisted to at least the highest level it may be changed (write).
    3. If two states change in response to the same events they should be hoisted to the same level.

    You can hoist the state higher than these rules require, but if you don't hoist the state high enough, it might be difficult or impossible to follow unidirectional data flow.

     

    그냥 읽어서는 이해가 어려울 수 있으니, 로그인 화면 예제를 함께 살펴보며 위 규칙이 의미하는 바를 알아보겠습니다.

     

     

    Rule 1: 상태는 적어도 상태를 사용하는 모든 Composable의 최소 공통 부모에 위치해야 한다. (상태 읽기)

    첫번째 규칙은 상태를 읽는 Composable을 고려한 규칙입니다. '최소 공통 부모'라는 표현이 사용되었습니다.

    경우에 따라서는 하나의 상태를 여러 Composable이 필요로 할 수도 있습니다. 상태 값이 필요한 Composable들은 상태 값을 의존성 주입으로 확인한다고 했습니다.

    따라서 상태를 위로 끌어올리려면, 적어도 상태를 구독하는 모든 Composable에게 상태를 주입시킬 수 있는 위치여야 합니다. 해당 위치의 Composable이 바로 ‘최소 공통 부모’를 의미합니다.

     

    nickname이라는 상태를 사용하는 Composable은 NicknameInputLayout만 존재하므로, 현재 구현에서는 상위 Composable인 LoginScreen이 최소 공통 부모 Composable입니다. 이는 다른 상태도 마찬가지입니다.

    @Composable
    private fun LoginScreen() {
        var nickname: MutableState<String> by rememberSaveable { mutableStateOf("") }
        var email: MutableState<String> by rememberSaveable { mutableStateOf("") }
        var password: MutableState<String> by rememberSaveable { mutableStateOf("") }
    
        Text(text = "로그인이 필요합니다.")
        
        // nickname을 읽는 Composable이 NicknameTextField 외에는 없습니다.
        NicknameTextField(
            nickname = nickname,
            onNicknameChange = { nickname = it },
        )
        
        EmailTextField(
            email = email,
            onEmailChange = { email = it },
        )
        
        PasswordTextField(
            password = password,
            onPasswordChange = { password = it },
        )
        
        Button(
            modifier = Modifier.padding(16.dp),
            onClick = { requestLogin(nickname, email, password) },
        ) {
            Text(text = "로그인")
        }
    }

     

     

    만약 입력한 닉네임이 LoginScreen 외에 다른 Composable에서도 사용된다면?

    로그인을 위해 입력한 닉네임이, 로그인 화면 위의 사용자 정보 화면(UserInfoScreen)에서 바로 반영되어 보여지는 Composable이 추가된다고 가정해봅시다.

    그렇다면 UserInfoScreen도 nickname의 값을 읽어야 하므로, nickname은 LoginScreen보다 더 위로 호이스팅되어야 할 것입니다.

    @Composable
    private fun UpperScreen() {
        // UserInfoScreen도 nickname을 알아야하기 때문에, 상태를 이곳으로 호이스팅했습니다.
        var nickname: MutableState<String> by rememberSaveable { mutableStateOf("") }
    
        UserInfoScreen(nickname = nickname)
    
        LoginScreen(
            nickname = nickname,
            onNicknameChange = { nickname = it },
        )
    }

     

    이 경우는 ‘최소 공통 부모’가 UpperScreen이 되는 것입니다.

     

     

    Rule 2: 상태는 적어도 상태가 변경될 수 있는 수준까지 위치해야 한다. (상태 변경)

    두번째 규칙은 상태를 수정하는 주체를 고려한 규칙입니다.

    상태를 수정하는 것은 해당 상태를 들고있는 Stateful Composable만이 가능하다고 했습니다. 따라서 상태 변경을 할 수 있도록, 해당 위치까지 상태를 호이스팅하는 것이 적절합니다.

     

    현재 nickname 및 email, password의 변경은 LoginScreen에서 이루어지고 있으며, 이는 적절한 상태 호이스팅입니다. 만약 이보다 더 상위의 Composable에서 상태를 변경하는 로직이 구현되어야 한다면, 해당 위치까지 상태를 끌어올리는 것이 적합할 것입니다.

    @Composable
    private fun LoginScreen() {
        // LoginScreen 이외의 Composable에서 상태가 변경될 이유가 없다면, 해당 위치의 호이스팅이 적절합니다.
        var nickname: MutableState<String> by rememberSaveable { mutableStateOf("") }
        var email: MutableState<String> by rememberSaveable { mutableStateOf("") }
        var password: MutableState<String> by rememberSaveable { mutableStateOf("") }
    
        Text(text = "로그인이 필요합니다.")
        
        NicknameTextField(
            nickname = nickname,
            onNicknameChange = { nickname = it },
        )
        
        EmailTextField(
            email = email,
            onEmailChange = { email = it },
        )
        
        PasswordTextField(
            password = password,
            onPasswordChange = { password = it },
        )
        
        Button(
            modifier = Modifier.padding(16.dp),
            onClick = { requestLogin(nickname, email, password) },
        ) {
            Text(text = "로그인")
        }
    }

     

     

    Rule 3: 만약 두 개(혹은 그 이상)의 상태가 동일한 이벤트에 반응하여 변경되는 경우, 동일한 레벨에 위치해야 한다.

    세번째 규칙은 특정 이벤트에 따라 여러 상태들이 함께 변경되는 경우입니다. 이 규칙은 두번째 규칙과 유사합니다.

     

    이 역시도 예를 들어보겠습니다.

    만약 입력한 닉네임이 이미 사용 중인 닉네임인지 확인하고 사용 가능 여부를 나타내는 기능을 새로 추가한다고 가정해보겠습니다. 이를 구현하기 위해, 사용 가능한 닉네임인지 나타내는 isAvailableNickname 이라는 상태가 추가되었습니다.

     

    nickname의 변경 이벤트에 따라 nickname과 isAvailableNickname을 함께 변경하고 있습니다. 이 경우, nickname과 isAvailableNickname은 아래 코드처럼 동일한 Composable에 위치해야만 합니다.

    그리고 두번째 규칙에 의해서도, isAvailableNickname은 nickname의 변경에 따라 수정되어야 하므로, 이 곳 LoginScreen에 위치해야합니다.

    @Composable
    private fun LoginScreen() {
        var nickname: MutableState<String> by rememberSaveable { mutableStateOf("") }
        var email: MutableState<String> by rememberSaveable { mutableStateOf("") }
        var password: MutableState<String> by rememberSaveable { mutableStateOf("") }
    
        // 닉네임이 사용 가능한지 나타내는 상태 값
        var isAvailableNickname: MutableState<Boolean> by remember { mutableStateOf(false) }
    
        Text(text = "로그인이 필요합니다.")
    
        NicknameTextField(
            nickname = nickname,
            onNicknameChange = { newNickname ->
                nickname = newNickname
                // 닉네임 입력 이벤트에 따라 isAvailableNickname의 값을 함께 변경하고 있습니다.
                isAvailableNickname = checkDuplicatedNickname(newNickname)
            },
        )
        
        EmailTextField(
            email = email,
            onEmailChange = { email = it },
        )
        
        PasswordTextField(
            password = password,
            onPasswordChange = { password = it },
        )
        
        Button(
            modifier = Modifier.padding(16.dp),
            onClick = { requestLogin(nickname, email, password) },
        ) {
            Text(text = "로그인")
        }
    }

     

     

    무조건 상태 호이스팅이 좋은걸까? Nope!

    상태 호이스팅의 특성과 장점을 살펴보았습니다. 그렇다면 무조건 상태 호이스팅을 적용하는게 좋을까요?

    caeser_no
    오랜만에 다시 등장한 '시저'가 아니라고 합니다!

     

    아닙니다! 이는 잘못된 구현입니다. 무분별한 상태 호이스팅은 때로 상위 호출자에게 불필요한 상태 관리를 위임시키는 구현이 될 수도 있습니다.

     

    예를 들어보겠습니다. 로그인 화면에서 도움말 기능을 제공한다고 합시다.

    도움말 기능은 닉네임, 이메일, 비밀번호 입력 형식을 알려주며, 화면의 하단에 ‘토글’로 나타낸다고 합니다. 이 경우 도움말 Composable은 토글 상태에 따라 도움말 문구가 나타나고, 사라지게 됩니다.

    이 때, 도움말의 토글 상태를 상위 호출자인 LoginScreen이 알 필요가 있을까요? LoginScreen이 관리해야할 이유가 있을까요?

     

    (적어도) 제 생각에는 그렇지 않습니다. 도움말 Composable의 토글 상태는 해당 Composable만 알고있어도 됩니다. 단순히 사용자의 터치로 토글 상태가 변경되며, 그에 따라 적절히 문구를 보여주기만 하면 됩니다. 이는 도움말 Composable의 책임이지, LoginScreen이 책임져야할 영역은 아닐 것입니다.

     

    Stateful과 Stateless의 장단점에서도 알 수 있듯, 무분별한 상태 호이스팅은 상태 관리를 복잡하게 만들고 Composable 간의 의존성을 증가시키며, 적절한 책임 분리를 어렵게 만들 수 있습니다.

    따라서 항상 상태 호이스팅을 하는 것이 좋은 것은 아닙니다. 개발하는 기능과 상황에 따라 유연하게 적용하는 것이 좋습니다.

     


     

    마치며

    지금까지 상태를 가지고 있는 Composable의 특징과, 상태 호이스팅으로 상태를 효율적으로 관리하는 방법을 알아보았습니다.

    • Stateful Composable은 상태를 내부에서 직접 관리하는 Composable, Stateless Composable은 상태를 외부 호출자로부터 주입받는 Composable을 의미합니다.
    • Stateless Composable은 Stateful Composable에 비해 상대적으로 재사용과 테스트에 용이하다는 장점이 있습니다.
    • 상태 호이스팅은 Composable을 Stateless하게 만들어, 상태를 호출자가 관리하여 주입하고 이벤트는 해당 Composable이 전달하는 단방향 데이트 흐름을 갖도록 만들어줍니다.
    • 단, 무분별한 상태 호이스팅은 상태 관리를 복잡하게 만들수 있으므로, 상황에 따라 유연하게 적용하는 것이 좋습니다.

    그리고 로그인 기능을 구현하는 예제를 활용하여 각각의 설명에 대해 알아보았습니다.

    이제 여러분은 상태를 효율적으로 관리하는 방법을 알게되었고, 기능도 잘 구현하였으니 가벼운 발걸음으로 퇴근할 수 있습니다!

     

    Compose 시리즈의 다음 포스팅은 상태를 더욱 효율적으로 관리할 수 있는 더욱 심화된 기술들에 대해 다뤄볼 예정입니다.

    긴 글 읽어주셔서 감사드리며, 오타 수정이나 설명한 부분에 있어 잘못된 것이 있다면 언제든 지적해주세요!

     

    참고 레퍼런스

Designed by Tistory.