Jetpack Compose
고친 사람 github-actions[bot]
Jetpack Compose 는 Android 앱의 화면을 Kotlin 함수로 짜게 해 주는 도구입니다. 개발자는 화면이 어떻게 생겨야 하는지만 함수로 적습니다. 보여 줄 값이 바뀌면 Compose 가 그 함수를 다시 불러 화면을 새로 맞춥니다.
쉽고 빠른 이해
Compose 는 값을 받아 화면을 내놓는 함수로 앱 화면을 짜게 합니다. 이름을 받으면 「안녕, 유미」 한 줄을 띄우는 함수 하나를 짜 두는 식입니다.
이게 없으면 값이 바뀔 때마다 화면 부품을 찾아 글자를 바꿔 넣는 코드를 손으로 짜야 합니다. 한 곳만 빠뜨려도 값과 화면이 서로 다른 말을 합니다.
어떻게 도는가:
- 화면을 그리는 함수에
@Composable표시를 붙입니다 - 함수가 읽은 값이 바뀌면 Compose 가 알아챕니다
- 그 값을 읽은 함수만 다시 불러 화면을 새로 맞춥니다
대가도 있습니다. 함수가 언제 몇 번 다시 불릴지 개발자가 정하지 못합니다. 그래서 함수 안에서 서버를 부르면 그 호출도 여러 번 나갈 수 있습니다. 예전 방식으로 짠 화면과 섞어 쓰려면 둘을 잇는 코드가 따로 듭니다.
상세
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 이라는 어노테이션을 붙입니다.
어노테이션은 @ 로 시작하는 표시입니다. 코드에 꼬리표처럼 붙여 두면 도구가 읽어 갑니다.
이 표시가 붙은 함수를 컴포저블 함수, 줄여서 컴포저블이라고 부릅니다.
아래는 이름을 받아 인사말 한 줄을 띄우는 컴포저블입니다. 주석은 이름으로 「유미」를 넘겼을 때 화면에 뜨는 글자입니다.
@Composable
fun Greeting(name: String) {
Text("안녕, $name") // 안녕, 유미
}
Text 는 글자 한 줄을 화면에 놓는 컴포저블입니다. Compose 가 기본으로 내놓는 부품입니다. 버튼과 그림과 입력 칸도 이렇게 함수로 들어 있습니다.
컴포저블은 아무것도 돌려주지 않습니다. 뷰처럼 객체를 만들어 돌려주는 대신, 불리는 순간 여기에 이것을 놓으라고 Compose 에 알립니다.
그래서 컴포저블 안에서 다른 컴포저블을 부르기만 하면 화면이 짜입니다. 프로필 화면 함수가 Greeting 을 부르면 인사말이 그 화면 안에 들어갑니다.
상태와 리컴포지션
Compose 가 언제 다시 그릴지 알려면, 무엇이 바뀌는 값인지 알아야 합니다. 바뀌면 화면도 따라 바뀌어야 하는 값을 상태라고 부릅니다. 좋아요 수나 입력 칸에 친 글자가 상태입니다.
Compose 에서 상태는 mutableStateOf 로 만듭니다. 이렇게 만든 값은 자기를 읽은 컴포저블을 기억합니다.
값이 바뀌면 Compose 는 그 컴포저블들을 다시 부릅니다. 이 다시 부르기를 리컴포지션(recomposition)이라고 부릅니다.
아래는 누를 때마다 숫자가 하나씩 오르는 좋아요 버튼입니다. 주석은 버튼 글자가 누를 때마다 바뀌는 모습입니다.
@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 은 값과 함수 하나를 받습니다.
@Composable
fun LikeButton(likes: Int, onLike: () -> Unit) {
Button(onClick = onLike) {
Text("$likes")
}
}
이제 LikeButton 은 받은 수를 보여 주기만 합니다. 눌렸다는 사실은 onLike 를 불러 위에 알립니다. 수를 올리는 일은 위에서 상태를 쥔 쪽이 합니다.
값은 위에서 아래로 내려갑니다. 눌림 같은 사건은 아래에서 위로 올라갑니다. 이 모양을 단방향 데이터 흐름이라고 부릅니다.
상태를 한 곳이 쥐면 같은 값이 두 곳에 따로 사는 일이 없어집니다. 뷰로 짜던 화면에서 값과 화면이 어긋나던 문제를 구조로 막는 셈입니다.
상태가 없는 컴포저블은 넘기는 값만 바꿔 가며 미리 보기와 테스트를 할 수 있습니다. 미리 보기는 앱을 띄우지 않고 편집기 안에서 컴포저블 하나를 그려 보는 기능입니다.
배치와 모디파이어
컴포저블을 어떻게 늘어놓을지는 배치용 컴포저블이 정합니다. 안에 부른 컴포저블들을 받아 배치를 잡습니다.
| 컴포저블 | 안의 것을 놓는 방향 |
|---|---|
Column |
위에서 아래로 쌓는다 |
Row |
왼쪽에서 오른쪽으로 늘어놓는다 |
Box |
한곳에 겹쳐 놓는다 |
크기와 여백, 배경, 누름 처리 같은 꾸밈은 모디파이어(Modifier)로 붙입니다. 모디파이어는 점으로 사슬처럼 이어 적습니다.
앞에 적은 모디파이어가 바깥을 감쌉니다. 뒤에 적은 것은 그 안쪽에 붙습니다.
아래 코드는 여백을 두르고 누름 처리를 붙입니다. 16.dp 의 dp(density-independent pixel, 밀도 독립 픽셀)는 화면 밀도가 달라도 눈에 같은 크기로 보이게 맞춘 길이 단위입니다.
Modifier
.padding(16.dp)
.clickable { open() } // 안쪽만 눌린다
여백을 먼저 적었으니 여백이 바깥을 감쌉니다. 누름 처리는 그 안쪽에 붙습니다. 그래서 여백을 뺀 안쪽만 누를 수 있습니다.
Modifier
.clickable { open() } // 여백까지 눌린다
.padding(16.dp)
누름 처리를 먼저 적으면 누름 처리가 바깥을 감쌉니다. 그 안쪽에 두른 여백까지 누를 수 있는 넓이에 들어갑니다. 같은 두 줄이 순서만으로 다른 화면을 만듭니다.
긴 목록
Column 에 주문 천 건을 넣으면 천 건의 화면 조각을 모두 만듭니다. 화면에는 열 건 남짓만 보이는데도 그렇습니다.
LazyColumn 은 화면에 보이는 항목만 만듭니다. 스크롤로 밀려난 항목은 버립니다.
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 도구
다른 이름: Compose · 컴포즈 · 젯팩 컴포즈