Android Jetpack Compose 한 번 써봤습니다

Compose를 사용하면 코드가 줄어든다기에 얼마나 편할 지 궁금했습니다.

최예나
2022.03.14

안녕하세요. 안드로이드 개발자 최예나입니다. 얼마 전 저는 안드로이드 팀원들과 함께 Jetpack Compose에 대해 스터디하는 시간을 가졌습니다.

이전까지는 신상마켓 안드로이드 앱에서는 화면을 그릴 때 전통적인 형태의 xml layout을 사용했습니다. 이번 스터디를 기회 삼아서 저를 포함한 몇몇의 동료들은 새로운 기능을 개발하면서 시범적으로 Compose를 적용했습니다. 그리고 그 중 제가 개발을 맡았던 신상캐시 송금 서비스가 먼저 오픈하게 되었는데요. Compose를 도입하며 사용한 코드를 짧은 경험담과 함께 보여드리려 합니다.

글의 예제는 Compose 1.0.5 버전을 기준으로 작성되었습니다.


Jetpack Compose, 이게 뭔가요?

구글에서는 개발자들이 쉽게 모범적인 코드를 작성하고 보일러 플레이트 코드는 줄일 수 있도록 라이브러리 모음을 제공하는데 이것을 Jetpack 이라고 합니다. 그리고 Compose는 선언형 UI 개발을 가능하게 하는 안드로이드 프레임워크입니다. 안드로이드 개발자 문서에는 Compose를 왜 사용해야 하는가에 대해 이유를 설명한 페이지가 있습니다.

  • 코드 감소
  • 직관적
  • 빠른 개발 과정
  • 강력한 성능

한 마디로 Compose는 개발자들이 편하게 UI 개발하라고 만든 툴킷입니다!


이전까지는 Kotlinxml을 오가며 작업했지만, Compose를 사용하면서 Kotlin으로만 개발할 수 있게 되었습니다. 코드가 줄어드는 것은 당연하고, 기존에 뷰를 직접 조작했던 데에서 왔던 번거로움과 버그 위험성을 줄일 수 있습니다. 그리고 기존 코드와의 호환이 되어 모든 코드를 바꾸지 않고 조금씩 점진적으로 적용할 수 있다는 점도 Compose를 시도해보는 계기가 되었습니다.


신상마켓에는 어떻게 적용했나요?

Compose는 Text, Image 같이 기본적이고 간단한 것부터 LazyColumn, ConstraintLayout 처럼 복잡도가 높은 Composable 까지도 손쉽게 사용할 수 있도록 만들어져 있습니다. 기존의 View 시스템에서 필요했던 대부분의 기능이 미리 구현되어 있어서 코드가 간결했고 컨버팅할 때 어려움이 거의 없었습니다.

살짝 손이 갔던 건 Button이나 TextField와 같은 Composable에 안드로이드가 사전 정의해 둔 padding이나 elevation 등의 원치 않는 디폴트 값이 있었다는 점입니다. 이 수치를 재조정해서 디자이너분들이 제시한 시안과 똑같게 만드는 작업이 필요했습니다. 버튼 같이 지속적으로 쓰이는 컴포넌트들은 따로 정의해놓고 사용하면 되니 문제될 것은 없었습니다.

다만, 아래에 이야기할 Composable은 신상마켓에 적용할 때에 비교적 커스텀이 더 많이 필요했습니다.


TextField

Compose를 적용하기 전에 개발 컨퍼런스에서 Compose를 먼저 써 본 체험기를 들을 수 있었는데, 그 중 기존 EditText의 대체 포지션인 TextField를 사용하기가 번거로웠다는 이야기를 종종 들을 수 있었습니다. 뭐가 문제일까 궁금했었는데 직접 써 보니 성가신 부분들이 있었습니다.

1) Focus 처리

많은 사용자들이 앱에서 텍스트를 타이핑을 한 후 키보드가 아닌 다른 곳을 터치하여 키보드를 닫는 UX에 익숙할 것입니다. 이전까지는 이 처리를 위해 Activity에서 currentFocus를 이용해 Focus를 해제하는 방식을 사용했습니다.

override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
    if (currentFocus is EditText) {
        SoftInputUtils.hideSoftInput(currentFocus)
        currentFocus!!.clearFocus()
    }
    return takeDispatchTouchEvent(ev)
}


그렇지만 TextField는 Composable 함수이기 때문에 위와 같이 특정 객체인지 판단할 수 없었습니다. 그래서 TextField의 포커싱 여부를 판단하는 변수를 하나 두어 처리해야 했습니다. 어렵지 않은 작업이었지만 이 처리를 하면서 명령형 UI와 선언형 UI의 차이가 조금씩 체감이 되었습니다.

var isTextFieldFocused = false

@Composable
fun MainContent(vm: MainViewModel) {
    val focusRequester by remember { mutableStateOf(FocusRequester()) }

    BasicTextField(
        value = ...,
        modifier = Modifier
            .focusRequester(focusRequester = focusRequester)
            .onFocusChanged {
                isTextFieldFocused = it.isFocused
            }
    )
}

override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
    if (isTextFieldFocused) {
        SoftInputUtils.hideSoftInput(currentFocus)
        currentFocus!!.clearFocus()
    }
    return takeDispatchTouchEvent(ev)
}


2) Number Formatting

신상캐시를 송금하기 위해서는 금액을 입력해야 합니다. 이전까지의 EditText에서는 숫자의 천 단위 콤마를 찍어줄 때에 TextWatcher를 추가하고 afterTextChanged를 override 하여 텍스트를 재정의할 수 있었습니다. 그리고 이 방식대로 Compose TextField에서는 입력값이 바뀌면 onValueChange를 통해 콤마 후처리가 가능합니다? 가능할 거라 생각했습니다.

처음 코드를 작성했을 때 저는 ViewModel에 mldAmountString이라는 LiveData 변수를 만들고 observe 해서 TextField의 값을 관리하도록 했습니다.

val amountString by vm.mldAmountString.observeAsState("")

BasicTextField(
    value = amountString,
    onValueChange = {
        if (it == "") {
            vm.setAmountString("")
            return@BasicTextField
        }

        it.replace(",", "")
            .replace(".", "")
            .parseSafeLong(0L) //붙여넣기 등 invalid Long값 입력 시 0L로 초기화되도록 Long extension 함수 추가해서 사용

        vm.setAmountString(amount.getCommaNumber())
    },
    keyboardOptions = KeyboardOptions.Default.copy(
        keyboardType = KeyboardType.Number
    ),
    ...
)

위에서처럼 keyboardType을 Number로 주어 숫자 형식의 키보드를 사용하도록 하고, 마지막에는 파싱한 값을 최종적으로 콤마를 가진 String으로 viewModel에 있는 값을 업데이트 해주는 방식을 사용했습니다. 하지만 버그는 생각치 못한 곳에서 나타났습니다.


커서 위치를 계산할 때에 콤마는 고려되지 않은 듯했습니다. 하지만 커서 위치를 수동으로 세팅할 때 사용하던 EditText의 setSelection()을 대체할 만한 함수를 Compose에서는 찾지 못했습니다. 대신 VisualTransformation을 사용하는 방법을 발견했습니다.

VisualTransformation은 말 그대로 시각적으로 보이는 출력 값을 바꾸는 인터페이스로, 비밀번호 *표 마스킹 표시나 천 단위 콤마 처리, 핸드폰 번호 포맷팅 등에 자주 사용되는 기능입니다. 우선은 텍스트를 추가할 때와 지울 때 커서의 위치를 얼마만큼 수정해야 할 지 처리해주는 NumberCommaVisualTransformation 클래스를 만들어주었습니다.

class NumberCommaVisualTransformation : VisualTransformation {
    override fun filter(text: AnnotatedString): TransformedText {
        val amount = text.text.getCommaNumber()

        return TransformedText(
            text = AnnotatedString(if (text.isEmpty()) "" else amount),
            offsetMapping = object : OffsetMapping {
                override fun originalToTransformed(offset: Int): Int {
                    val commas = amount.count { it == ',' }
                    return offset + commas
                }

                override fun transformedToOriginal(offset: Int): Int {
                    val commas = amount.count { it == ',' }

                    return when (offset) {
                        8, 7 -> offset - 2
                        6 -> if (commas == 1) 5 else 4
                        5 -> if (commas == 1) 4 else if (commas == 2) 3 else offset
                        4, 3 -> if (commas >= 1) offset - 1 else offset
                        2 -> if (commas == 2) 1 else offset
                        else -> offset
                    }
                }
            }
        )
    }
}


위의 예제에서는 onValueChange에서 뷰모델의 값을 콤마가 포함된 값으로 넣어주었지만, 변경 후에는 콤마를 제외한 숫자만 들고 있게 했습니다. 그리고 visualTransformation을 지정해주면 유저가 보는 숫자에는 콤마가 표시됩니다.

BasicTextField(
    value = amountString,
    onValueChange = {
        if (it == "") {
            vm.setAmountString("")
            return@BasicTextField
        }

        val amount = it.replace(",", "")
            .replace(".", "")
            .parseSafeLong(0L)

        vm.setAmountString(amount.toString())
    },
    ...
    visualTransformation = NumberCommaVisualTransformation(),
    keyboardOptions = KeyboardOptions.Default.copy(
        keyboardType = KeyboardType.Number
    ),
)

VisualTransformation 을 사용한 후 아래와 같이 수정할 수 있었습니다.


Infinite Scrolling LazyColumn

신상마켓 앱에서 아이템 세로로 무한히 스크롤되는 리스트를 다룰 때에는 다음과 같은 예외 상황들도 염두에 두고 처리합니다.


화면에 처음 진입 했을 때에는 첫 데이터부터 보여줄 수 없는 상황일테니 전체화면으로 처리하고, 스크롤 후 두 번째 로딩부터는 이미 불러온 아이템 아래에 Loading Indicator나 실패 메세지 등을 추가해줍니다.

LazyColumn에서도 예전 RecyclerView를 사용했을 때와 같이 특정 index까지 스크롤했을 때에 네트워크 통신을 시도합니다. 통신 성공 후에는 서버로부터 가져온 아이템을 리스트에 추가해줄 것이고, 더 이상 없거나 실패한 경우에는 미리 정의된 에러 아이템을 추가해줄 것입니다. 이 과정을 처리하기 위해 현재의 로딩 상태를 판단할 STATUS enum을 만들었습니다.

enum class STATUS {
    NONE, EMPTY, FAIL_ALL, FAIL_BOTTOM, LOADING_BOTTOM, SUCCESS_MORE, SUCCESS_NO_MORE
}


그리고 이 값에 따라 구성할 Composable 함수는 이렇게 정리할 수 있었습니다.


스크롤 시에도 상단에 고정되어야 하는 Header 부분을 제외하고는 화면에 나타나야 하는 컴포저블은 LazyColumn 안에서 상태(STATUS) 값에 따라 그려질 것입니다.

그리고 가장 중요한 기능인 스크롤 시 통신을 하기 위해서는 스크롤 상태를 감지하는 객체가 필요합니다. LazyListState를 이용하면 스크롤을 관찰하거나 제어할 수 있으며, 화면에 처음 혹은 마지막으로 보이는 아이템의 인덱스도 구할 수 있습니다. 인덱스로 아이템이 몇 개 남았는지, 숫자가 크고 작은 지에 대한 로직은 LazyListState의 Extension 함수를 만들어서 정리했습니다. 추가적으로 스크롤바가 땅에 닿기 전에 미리 통신하기 위해 buffer를 추가했습니다.

@Composable
fun LazyListState.OnBottomReached(buffer: Int = 3, fetchMore: () -> Unit) {
    //buffer : 리스트의 마지막 아이템으로부터 몇 개 남았을 때 로드할 지 판단
    require(buffer >= 0) { "buffer가 0보다 작습니다 - $buffer" }

    val shouldLoadMore = remember {
        derivedStateOf {
            val lastVisibleItem = layoutInfo.visibleItemsInfo.lastOrNull() ?: return@derivedStateOf true
            lastVisibleItem.index == layoutInfo.totalItemsCount - 1 - buffer
        }
    }

    LaunchedEffect(shouldLoadMore) {
        snapshotFlow { shouldLoadMore.value }
            .collect {
                if (it) fetchMore()
            }
    }
}


그럼 이제 위의 그림을 코드로 옮길 준비가 되었습니다.

@Composable
fun InfiniteLazyColumn(
    modifier: Modifier = Modifier,
    status: STATUS,
    onFetch: () -> Unit,
    header: @Composable () -> Unit = {},
    items: LazyListScope.() -> Unit,
    emptyMessage: String = "",
    errorMessage: String = "",
    onFullScreenButtonClick: () -> Unit = onFetch,
) {
    val listState = rememberLazyListState()

    Column {
        header()

        LazyColumn(
            modifier = modifier.fillMaxSize(),
            state = listState
        ) {
            item { //전체화면 error/empty 처리
                FullScreenStatusHandle(
                    status = status,
                    emptyMessage = emptyMessage,
                    errorMessage = errorMessage,
                    onFullScreenButtonClick = { onFullScreenButtonClick() }
                )
            }

            items() //아이템 리스트

            item { //Bottom error/end 처리
                BottomStatusHandle(
                    status = status,
                    onFetch = { onFetch() },
                )
            }
        }

        listState.OnBottomReached { //스크롤 시 fetch 처리
            onFetch()
        }
    }
}


FullScreenStatusHandle과 BottomStatusHandle Composable을 만들어서 각 내부에서 status에 따라 필요한 UI를 그려주었습니다.

@Composable
fun BottomStatusHandle(
    status: STATUS,
    errorMessage: String = "",
    onFetch: () -> Unit,
    horizontalPaddingDp: Dp = 16.dp
) {
    when (status) {
        STATUS.LOADING_BOTTOM -> {
            CircularProgressIndicator(...)
        }
        STATUS.FAIL_BOTTOM -> {
            BottomFailRetryText(...)
        }
        STATUS.SUCCESS_NO_MORE -> {
            BottomNoMoreDataText(...)
        }
        else -> {}
    }
}


모든 재료 준비가 끝나면 액티비티에서 호출해서 구현합니다.

setContent {
    ...

    InfiniteLazyColumn(
        status = status,
        onFetch = { vm.fetch() },
        items = {
            item {
                TopItem()
            }

            items(tradeItemList) { item ->
                MyTrade(item) { // onClick()
                    vm.setEventOnClickTradeItem(item)
                }
            }
        },
        emptyMessage = if (vm.searchText.isNotBlank()) Message.STATUS_NO_RESULT_SEARCH else Message.STATUS_NO_TRADE_SINSANG_CASH_TRANSFER,
        errorMessage = vm.tradeListFailMessage
    )    
}



Compose 써보니까 어때요?

처음 화면 두어 개 쯤 만들 때에는 스스로의 작업 속도도 너무 느리고 머리가 터질 것 같았습니다. 그러나 위에서처럼 시행착오를 겪고 명령형 UI와 선언형 UI의 차이에 익숙해지고 나니 정말정말 편했습니다.

또, 코드가 많이 줄었다는 것을 알 수 있었습니다. layout 도 따로 그릴 필요가 없고, 도형이나 selector를 위해 여러 개의 xml 파일을 만들어야 했던 번거로운 작업도 Composable 코드 몇 줄로 처리가 됩니다. 실무자 뿐 아니라 안드로이드 개발 입문자에게도 Jetpack Compose는 좋은 경험이 될 수 있을거라고 생각되었습니다.

그리고 DataBinding 처음 쓸 때에도 혁신적이었지만, 그 이상으로 ViewModel과의 상호적 사용에 최적화되어 있기 때문에 예전 위젯을 사용할 때처럼 View 따로, Data 따로 노는 일이 거의 없을 것 같습니다.

조심해야 할 점은, 아직 정식 출시가 안 된 Composable도 많이 있기 때문에 안정적으로 사용하기 전엔 형태가 바뀔 수도 있으며, 이전에 사용하던 컴포넌트와 차이가 생길 수 있습니다. 가령 저는 Compose를 위한 SwipeRefresh를 사용했는데, 기존 위젯인 SwipeRefreshLayout와 생김새, 동작 등이 달랐습니다. 이전과 동일한 UX, UI를 사용하여 사용자에게 이질감을 주지 않도록 주의해서 커스텀해야 할 필요가 있었습니다.

이제 겨우 한 프로젝트에 적용했지만 충분히 장점이 와닿기 때문에 앞으로도 Compose를 자주 보지 않을까 기대가 됩니다.

최예나

딜리셔스 안드로이드 개발자

"이 글을 읽는다면 허리를 펴고 자세를 바르게 해 주세요. 건강한 신체에 건강한 코드가 깃듭니다."