사전 Jetpack Compose
구현체

Jetpack Compose

gabury1고친 사람 github-actions[bot]

Jetpack Compose 는 Android 앱의 화면을 Kotlin 함수로 짜게 해 주는 도구입니다. 개발자는 화면이 어떻게 생겨야 하는지만 함수로 적습니다. 보여 줄 값이 바뀌면 Compose 가 그 함수를 다시 불러 화면을 새로 맞춥니다.

쉽고 빠른 이해

Compose 는 값을 받아 화면을 내놓는 함수로 앱 화면을 짜게 합니다. 이름을 받으면 「안녕, 유미」 한 줄을 띄우는 함수 하나를 짜 두는 식입니다.

이게 없으면 값이 바뀔 때마다 화면 부품을 찾아 글자를 바꿔 넣는 코드를 손으로 짜야 합니다. 한 곳만 빠뜨려도 값과 화면이 서로 다른 말을 합니다.

어떻게 도는가:

  1. 화면을 그리는 함수에 @Composable 표시를 붙입니다
  2. 함수가 읽은 값이 바뀌면 Compose 가 알아챕니다
  3. 그 값을 읽은 함수만 다시 불러 화면을 새로 맞춥니다

대가도 있습니다. 함수가 언제 몇 번 다시 불릴지 개발자가 정하지 못합니다. 그래서 함수 안에서 서버를 부르면 그 호출도 여러 번 나갈 수 있습니다. 예전 방식으로 짠 화면과 섞어 쓰려면 둘을 잇는 코드가 따로 듭니다.

상세

Jetpack Compose 는 Android 앱의 UI(User Interface, 사용자 인터페이스)를 짜는 라이브러리입니다. UI 는 사용자가 보고 누르는 화면 전체를 말합니다. Google 이 Android 개발용으로 내놓는 라이브러리 묶음인 Jetpack 에 들어 있어서 이런 이름이 붙었습니다.

Compose 는 Android 운영체제에 들어 있지 않고 앱과 함께 묶여 배포됩니다. 그래서 휴대폰의 Android 판이 낡아도 앱은 새 Compose 를 쓸 수 있습니다. 대신 그만큼 앱 설치 파일이 커집니다.

예전 방식은 화면 모양을 XML(Extensible Markup Language) 파일에 적어 두었습니다. Compose 에는 그런 파일이 없습니다. 화면도 Kotlin 코드로만 적습니다.

Kotlin 은 Java 와 섞어 쓸 수 있는 언어입니다. 지금 Android 앱은 주로 이 언어로 짭니다.

이 절은 Compose 이전의 방식에서 출발합니다. 예전 방식과 견준 뒤, 화면을 그리는 함수와 값이 바뀔 때 다시 그리는 방식을 봅니다. 이어서 값을 어디에 둘지, 배치를 어떻게 적는지, 긴 목록을 어떻게 다루는지 봅니다. 끝으로 컴파일러가 뒤에서 하는 일과 이 방식이 치르는 값, 이 도구를 고르는 경우를 봅니다.

뷰로 짜던 화면

Compose 이전의 Android 화면은 뷰라는 객체로 짰습니다. 뷰는 글자 칸이나 버튼처럼 화면에 놓이는 부품 하나하나입니다. 화면 모양은 주로 XML 파일에 적어 둡니다. 앱이 뜰 때 그 파일을 읽어 뷰 객체들을 만듭니다.

값이 바뀌면 코드가 그 뷰를 찾아 직접 고칩니다. 장바구니 개수가 3 에서 4 가 되면, 개수를 적은 글자 칸을 찾아 글자를 4 로 바꾸는 줄을 개발자가 씁니다. 이렇게 무엇을 어떻게 바꾸라고 한 줄씩 시키는 방식을 명령형이라고 부릅니다.

명령형 화면에서는 값과 화면이 따로 삽니다. 개수를 보여 주는 곳이 다섯 군데면, 다섯 곳 모두에서 글자 칸을 고쳐야 합니다. 한 곳만 빠뜨려도 장바구니에는 4 개가 들었는데 화면은 3 을 보입니다.

선언형으로 바꾼 것

Compose 는 반대로 갑니다. 개발자는 개수가 n 일 때 화면이 어떻게 생기는지만 적습니다. 개수가 바뀐 뒤 화면을 고치는 일은 Compose 가 맡습니다. 결과의 모양만 적고 거기까지 가는 절차는 도구에 맡기는 방식을 선언형이라고 부릅니다.

백엔드 개발자에게는 Thymeleaf 같은 템플릿 엔진이 가까운 예입니다. 템플릿은 모델 데이터를 받아 웹 페이지 한 장을 만들어 냅니다. 데이터가 바뀌면 이미 보낸 페이지를 고치지 않고 페이지를 새로 만듭니다. Compose 의 함수도 값을 받아 화면을 내놓습니다.

두 방식을 나란히 놓으면 이렇습니다.

뷰 Compose
화면 모양을 적는 곳 XML 파일 Kotlin 함수
값이 바뀌면 코드가 뷰를 찾아 고친다 함수를 다시 불러 화면을 맞춘다
값과 화면을 맞추는 책임 개발자 Compose

둘째 줄이 두 방식을 가르는 차이입니다. 셋째 줄은 그 결과입니다. 한 곳을 빠뜨려 화면이 어긋나는 실수가 Compose 에서는 잘 생기지 않습니다.

컴포저블 함수

Compose 에서 화면 조각 하나는 함수 하나입니다. 함수 위에 @Composable 이라는 어노테이션을 붙입니다. 어노테이션은 @ 로 시작하는 표시입니다. 코드에 꼬리표처럼 붙여 두면 도구가 읽어 갑니다. 이 표시가 붙은 함수를 컴포저블 함수, 줄여서 컴포저블이라고 부릅니다.

아래는 이름을 받아 인사말 한 줄을 띄우는 컴포저블입니다. 주석은 이름으로 「유미」를 넘겼을 때 화면에 뜨는 글자입니다.

Kotlin
@Composable
fun Greeting(name: String) {
    Text("안녕, $name") // 안녕, 유미
}

Text 는 글자 한 줄을 화면에 놓는 컴포저블입니다. Compose 가 기본으로 내놓는 부품입니다. 버튼과 그림과 입력 칸도 이렇게 함수로 들어 있습니다.

컴포저블은 아무것도 돌려주지 않습니다. 뷰처럼 객체를 만들어 돌려주는 대신, 불리는 순간 여기에 이것을 놓으라고 Compose 에 알립니다. 그래서 컴포저블 안에서 다른 컴포저블을 부르기만 하면 화면이 짜입니다. 프로필 화면 함수가 Greeting 을 부르면 인사말이 그 화면 안에 들어갑니다.

상태와 리컴포지션

Compose 가 언제 다시 그릴지 알려면, 무엇이 바뀌는 값인지 알아야 합니다. 바뀌면 화면도 따라 바뀌어야 하는 값을 상태라고 부릅니다. 좋아요 수나 입력 칸에 친 글자가 상태입니다.

Compose 에서 상태는 mutableStateOf 로 만듭니다. 이렇게 만든 값은 자기를 읽은 컴포저블을 기억합니다. 값이 바뀌면 Compose 는 그 컴포저블들을 다시 부릅니다. 이 다시 부르기를 리컴포지션(recomposition)이라고 부릅니다.

아래는 누를 때마다 숫자가 하나씩 오르는 좋아요 버튼입니다. 주석은 버튼 글자가 누를 때마다 바뀌는 모습입니다.

Kotlin
@Composable
fun LikeButton() {
    var likes by remember { mutableStateOf(0) }
    Button(onClick = { likes++ }) {
        Text("$likes") // 0 → 1 → 2
    }
}

Button 은 누를 수 있는 버튼을 놓는 컴포저블입니다. 버튼을 누를 때 불릴 코드는 onClick 에 넘깁니다.

{ likes++ } 처럼 중괄호로 싼 코드 덩이가 그 코드입니다. 이렇게 이름 없이 넘기는 함수를 Kotlin 에서는 람다라고 합니다. Java 의 람다와 같은 구실을 합니다.

remember 는 리컴포지션 때문에 필요합니다. 리컴포지션은 함수를 처음부터 다시 부르는 일이라, 함수 안의 지역 변수가 새로 만들어집니다. remember 는 첫 호출 때 만든 값을 Compose 에 맡겨 두었다가 다음 호출 때 돌려줍니다. 그래서 다시 불려도 좋아요 수가 0 으로 돌아가지 않습니다.

by 는 likes 를 평범한 변수처럼 읽고 쓰게 해 주는 Kotlin 문법입니다. 이 문법을 안 쓰면 likes.value 처럼 값을 꺼내 적어야 합니다.

리컴포지션은 화면 전체를 다시 부르지 않습니다. 컴포저블이 다른 컴포저블을 부르면 호출이 나무 모양으로 이어집니다. Compose 는 이 나무에서 바뀐 값을 읽은 가지만 다시 부릅니다. 넘겨받은 값이 그대로인 컴포저블은 건너뜁니다.

프로필 화면 ProfileScreen 이 인사말과 좋아요 버튼을 부르는 경우를 그리면 아래와 같습니다. 좋아요를 누르면 상자로 묶은 가지만 다시 불립니다.

flowchart TD
    S["ProfileScreen"] --> G["Greeting"]
    S --> L
    G --> T1["Text · 안녕, 유미"]
    subgraph R["좋아요를 누르면 다시 불리는 가지"]
        L["LikeButton"] --> B["Button"]
        B --> T2["Text · 좋아요 수"]
    end

인사말 쪽은 좋아요 수를 읽지 않으므로 그대로 남습니다. 화면이 크고 값이 자주 바뀌어도 매번 전부 다시 그리지 않는 까닭입니다.

컴포저블 안에서 피하는 일

리컴포지션은 자주 일어납니다. 컴포저블이 몇 번 불릴지, 어떤 순서로 불릴지는 Compose 가 정합니다. 그래서 컴포저블 본문에 서버 호출이나 파일 쓰기를 넣으면 다시 불릴 때마다 그 일이 되풀이됩니다.

이처럼 화면 그리기 밖의 세상을 바꾸는 일을 부수 효과라고 부릅니다. Compose 는 부수 효과를 담는 전용 컴포저블을 둡니다.

자주 쓰는 것이 LaunchedEffect 입니다. 화면에 처음 들어올 때 안의 코드를 한 번 돌립니다. 그 뒤로는 LaunchedEffect(orderId) 처럼 괄호에 넘긴 값이 바뀔 때만 다시 돌립니다. 이 값을 키라고 합니다. 주문 번호가 키라면 번호가 바뀔 때 다시 돕니다. 리컴포지션이 몇 번 일어나도 번호가 그대로면 다시 돌지 않습니다.

LaunchedEffect 안의 코드는 코루틴에서 돕니다. 코루틴은 멈췄다가 멈춘 곳부터 이어 갈 수 있는 함수입니다. 그래서 서버 응답을 기다리는 동안에도 화면이 멈추지 않습니다.

그 컴포저블이 화면에서 빠지면 Compose 가 돌던 코루틴을 취소합니다. 떠난 화면을 위해 받아 온 응답이 뒤늦게 쓰이는 일을 막습니다.

상태 끌어올리기

앞의 LikeButton 은 좋아요 수를 자기 안에 품습니다. 그러면 바깥에서 그 수를 읽을 수 없습니다. 서버에서 받은 수로 채울 수도 없습니다. Compose 에서는 상태를 컴포저블 안에 두지 않고 부르는 쪽으로 올려 보내는 방식을 씁니다. 이것을 상태 끌어올리기(state hoisting)라고 부릅니다.

끌어올린 LikeButton 은 값과 함수 하나를 받습니다.

Kotlin
@Composable
fun LikeButton(likes: Int, onLike: () -> Unit) {
    Button(onClick = onLike) {
        Text("$likes")
    }
}

이제 LikeButton 은 받은 수를 보여 주기만 합니다. 눌렸다는 사실은 onLike 를 불러 위에 알립니다. 수를 올리는 일은 위에서 상태를 쥔 쪽이 합니다.

값은 위에서 아래로 내려갑니다. 눌림 같은 사건은 아래에서 위로 올라갑니다. 이 모양을 단방향 데이터 흐름이라고 부릅니다.

상태를 한 곳이 쥐면 같은 값이 두 곳에 따로 사는 일이 없어집니다. 뷰로 짜던 화면에서 값과 화면이 어긋나던 문제를 구조로 막는 셈입니다.

상태가 없는 컴포저블은 넘기는 값만 바꿔 가며 미리 보기와 테스트를 할 수 있습니다. 미리 보기는 앱을 띄우지 않고 편집기 안에서 컴포저블 하나를 그려 보는 기능입니다.

배치와 모디파이어

컴포저블을 어떻게 늘어놓을지는 배치용 컴포저블이 정합니다. 안에 부른 컴포저블들을 받아 배치를 잡습니다.

컴포저블 안의 것을 놓는 방향
Column 위에서 아래로 쌓는다
Row 왼쪽에서 오른쪽으로 늘어놓는다
Box 한곳에 겹쳐 놓는다

크기와 여백, 배경, 누름 처리 같은 꾸밈은 모디파이어(Modifier)로 붙입니다. 모디파이어는 점으로 사슬처럼 이어 적습니다. 앞에 적은 모디파이어가 바깥을 감쌉니다. 뒤에 적은 것은 그 안쪽에 붙습니다.

아래 코드는 여백을 두르고 누름 처리를 붙입니다. 16.dp 의 dp(density-independent pixel, 밀도 독립 픽셀)는 화면 밀도가 달라도 눈에 같은 크기로 보이게 맞춘 길이 단위입니다.

Kotlin
Modifier
    .padding(16.dp)
    .clickable { open() } // 안쪽만 눌린다

여백을 먼저 적었으니 여백이 바깥을 감쌉니다. 누름 처리는 그 안쪽에 붙습니다. 그래서 여백을 뺀 안쪽만 누를 수 있습니다.

Kotlin
Modifier
    .clickable { open() } // 여백까지 눌린다
    .padding(16.dp)

누름 처리를 먼저 적으면 누름 처리가 바깥을 감쌉니다. 그 안쪽에 두른 여백까지 누를 수 있는 넓이에 들어갑니다. 같은 두 줄이 순서만으로 다른 화면을 만듭니다.

긴 목록

Column 에 주문 천 건을 넣으면 천 건의 화면 조각을 모두 만듭니다. 화면에는 열 건 남짓만 보이는데도 그렇습니다. LazyColumn 은 화면에 보이는 항목만 만듭니다. 스크롤로 밀려난 항목은 버립니다.

Kotlin
LazyColumn {
    items(orders) { order ->
        OrderRow(order)
    }
}

items 는 목록을 받아 항목마다 안의 람다를 부릅니다. 그 람다는 보이게 된 항목에 대해서만 불립니다. 뷰로 짜던 화면에서는 같은 일을 RecyclerView 라는 뷰가 맡았습니다. 그때는 목록과 뷰를 잇는 연결 코드를 짜야 했습니다.

컴파일러 플러그인

@Composable 은 꼬리표일 뿐입니다. 꼬리표만으로는 다시 부르기가 되지 않습니다. 그 꼬리표를 읽고 코드를 고쳐 쓰는 것은 Kotlin 컴파일러에 붙는 컴파일러 플러그인입니다. 컴파일러 플러그인은 컴파일 도중에 끼어들어 코드를 바꾸는 확장 부품입니다.

플러그인은 컴포저블마다 숨은 매개변수를 하나 더합니다. Compose 가 호출 나무를 적어 두는 기록장을 넘겨받는 매개변수입니다. 그리고 상태를 읽은 곳과 다시 불러야 할 곳을 기록장에 적는 코드를 끼워 넣습니다.

그래서 컴포저블은 컴포저블 안에서만 부를 수 있습니다. 보통 함수에는 넘겨줄 기록장이 없기 때문입니다. 컴포저블을 Java 로 짤 수 없는 까닭도 이 플러그인에 있습니다. 이 고쳐 쓰기를 하는 플러그인이 Kotlin 컴파일러에만 붙습니다.

선언형 화면의 대가

뷰로 짠 화면은 무엇이 언제 바뀌는지가 코드에 적혀 있습니다. Compose 에서는 그 판단을 Compose 가 합니다. 화면이 버벅일 때 원인이 코드만 읽어서는 잘 드러나지 않습니다. 무엇이 몇 번 다시 불렸는지는 Android Studio 의 Layout Inspector 로 리컴포지션 횟수를 세어 봐야 보입니다.

생각하는 방식도 바뀝니다. 뷰에서는 화면 부품을 붙잡아 두고 고쳤지만, Compose 에서는 붙잡을 화면 객체가 없습니다. 화면을 바꾸고 싶으면 상태를 바꿔야 합니다. 이 전환에 익숙해지기 전까지는 상태를 어디에 둘지가 자주 헷갈립니다.

Compose 를 고르는 경우

새로 짜는 Android 화면은 대개 Compose 로 짭니다. 이미 뷰로 짠 큰 앱은 한 번에 옮기지 않습니다. 화면 하나씩 Compose 로 바꾸며 한동안 두 방식을 섞어 씁니다. 지도나 웹 페이지처럼 컴포저블로 된 부품이 없는 것은 뷰를 감싸 Compose 화면에 넣습니다. 섞는 방법은 아래 「맞물림」 절에 있습니다.

iPhone 앱이나 웹 페이지의 화면은 Jetpack Compose 로 짜지 않습니다. 같은 방식을 데스크톱과 iPhone 까지 넓힌 도구도 있습니다. JetBrains 가 만드는 Compose Multiplatform 입니다.

맞물림

Compose 는 혼자 앱을 이루지 않습니다. 같은 Jetpack 안의 다른 라이브러리와 Android 의 뷰에 붙어서 돕니다. 이 절은 Compose 를 쓰면 거의 반드시 만나는 네 가지 붙는 방식을 봅니다.

액티비티가 Compose 를 띄운다

액티비티는 Android 앱에서 화면 한 장을 맡는 부품입니다. Compose 화면도 액티비티 안에서 뜹니다. 액티비티는 setContent 를 불러 첫 컴포저블을 넘깁니다. 호출 나무는 거기서부터 자랍니다.

붙이는 대가는 화면을 돌릴 때 드러납니다. 휴대폰을 가로로 돌리면 액티비티가 새로 만들어집니다. 이것을 구성 변경이라고 부릅니다.

이때 remember 로 맡긴 값도 함께 사라집니다. 남겨야 하는 값은 rememberSaveable 로 맡깁니다. 그러면 액티비티가 다시 만들어질 때 값을 되살려 줍니다.

컴포저블이 ViewModel 에서 상태를 읽는다

ViewModel 은 화면에 쓸 데이터를 들고 있는 Jetpack 부품입니다. 구성 변경으로 액티비티가 새로 만들어져도 살아남습니다. 화면 맨 위 컴포저블이 viewModel() 을 불러 ViewModel 을 받아 옵니다.

ViewModel 은 상태를 StateFlow 로 내놓는 일이 많습니다. StateFlow 는 값이 바뀔 때마다 새 값을 흘려보내는 Kotlin 코루틴 도구입니다. 컴포저블은 collectAsState 를 불러 이 흐름을 Compose 상태로 바꿔 읽습니다. 사용자가 버튼을 누르면 컴포저블이 ViewModel 의 함수를 부릅니다. 앞에서 본 상태 끌어올리기를 ViewModel 까지 한 단계 더 올린 모양입니다.

대신 ViewModel 을 아래 컴포저블까지 넘기면, 그 컴포저블은 ViewModel 없이는 미리 보기도 테스트도 못 합니다. 그래서 ViewModel 은 맨 위에서만 받습니다. 아래로는 값과 람다만 내려보냅니다.

Navigation 이 화면을 바꿔 끼운다

Navigation Compose 는 여러 화면 사이를 오가게 해 주는 Jetpack 라이브러리입니다. NavHost 라는 컴포저블이 화면마다 이름을 붙여 둡니다. 이동을 부르면 NavHost 가 지금 화면의 컴포저블을 다른 컴포저블로 바꿔 끼웁니다. 뒤로 가기를 누르면 앞 화면으로 돌아옵니다.

화면 사이로 넘기는 값에는 제약이 따릅니다. 갈 곳을 order/42 처럼 웹 주소와 비슷한 글자로 적기 때문입니다. 넘기는 값도 이 글자에 실려야 해서 주문 번호 같은 작은 값만 넘깁니다.

주문 객체는 넘기지 않습니다. 받은 화면이 번호로 주문을 다시 읽어 옵니다. 읽어 오는 코드가 화면마다 하나씩 늘어납니다.

뷰와 Compose 가 서로를 품는다

Compose 이전에 짠 앱은 대개 뷰로 되어 있습니다. 한 번에 다 바꾸기 어려워서 둘을 섞어 씁니다. 뷰 화면 안에 Compose 를 넣을 때는 ComposeView 라는 뷰를 놓고 그 안에 컴포저블을 넘깁니다.

거꾸로 Compose 화면 안에 뷰를 넣을 때도 있습니다. 지도나 웹 페이지처럼 컴포저블로 된 부품이 없을 때입니다. 그때는 AndroidView 라는 컴포저블로 뷰를 감싸 Compose 화면에 놓습니다.

섞어 쓰는 대가도 있습니다. 두 방식이 한 화면에 있으면 상태도 두 곳에 삽니다. 뷰 쪽 값이 바뀌어도 Compose 는 모릅니다. 경계에서 값을 옮겨 주는 코드를 따로 짜야 합니다.

관련 항목

Jetpack Compose 가 속하는 상위 분류

Android · Jetpack · AndroidX · UI 툴킷 · 선언형 프로그래밍 · 모바일 개발

Jetpack Compose 를 짜고 빌드하는 언어와 도구

Kotlin · 컴파일러 플러그인 · 코루틴 · Gradle · Android Studio

Compose 화면을 이루는 구성 요소

컴포저블 함수 · 리컴포지션 · UI 상태 · 모디파이어 · LazyColumn · 부수 효과 · Material Design

Compose 화면 설계에 적용되는 원칙

상태 끌어올리기 · 단방향 데이터 흐름 · 단일 진실 공급원 · 불변 객체

Jetpack Compose 와 맞물리는 Jetpack 부품

액티비티 · 뷰모델 · Navigation Compose · StateFlow · 생명주기 · 구성 변경

Compose 가 대신하는 뷰 시스템 부품

뷰 · 뷰 그룹 · XML · 레이아웃 · RecyclerView · 명령형 프로그래밍

Jetpack Compose 와 같은 선언형 방식의 UI 도구

React · SwiftUI · Flutter · Compose Multiplatform · Vue.js

다른 이름: Compose · 컴포즈 · 젯팩 컴포즈