젯팩 컴포즈로 개발하는 안드로이드 UI을 공부하며 정리한 내용입니다.
저작권에 문제가 될 시, 글을 모두 내리겠습니다.
제가 공부한 내용이 더 많은 분들에게도 도움이 되었으면 좋겠습니다. 부족한 부분은 댓글을 통해서 피드백을 주신다면 언제나 반영하겠습니다. 감사합니다.
책에 대한 링크는 가장 아래에 있습니다.
핵심 주제는 다음과 같습니다:
- 컴포저블 함수 (Composable Functions)
- UI 구성과 재구성 (Composition & Recomposition)
- 컴포저블 함수의 행위 수정 (Modifier Functions)
컴포저블 함수 자세히 살펴보기
Jetpack Compose의 기본 단위는 @Composable 어노테이션이 붙은 컴포저블 함수(Composable Function)입니다. 이 함수는 데이터를 UI로 변환(transform)합니다.
컴포저블 함수의 구성 요소
어노테이션과 정의
@Composable
fun SampleComponent() {
Text("Hello Compose")
}
- @Composable: Compose Compiler에게 UI를 생성하는 함수임을 알림
- 일반적인 Kotlin 함수처럼 선언되며, 주요 구성요소는 다음과 같습니다:
요소 설명
| 가시성 변경자 | 기본은 public, 필요 시 internal, private 사용 |
| 함수 이름 | PascalCase 사용. 동사형보다 명사/형용사+명사 사용 권장 (ex. UserProfileCard, ErrorMessage) |
| 매개변수 목록 | 쉼표로 구분, 기본값 설정 가능 |
| 반환 타입 | 대부분 생략하며 Unit으로 간주됨 |
| 함수 본문 | 중괄호 {}로 묶이며, 단일 표현식이면 축약 가능 (= 표현식) |
매개변수 기본값과 활용
컴포저블 함수는 선언 시 매개변수에 기본값을 제공하여 사용성을 높일 수 있습니다.
@Composable
fun ColoredTextDemo(
text: String = "",
color: Color = Color.Black
) {
Text(
text = text,
style = TextStyle(color = color)
)
}
- 기본값을 사용하면 함수 호출 시 선택적으로 인자 생략 가능
- Text 컴포저블은 내부적으로 @Composable이며, 최적화된 UI 요소입니다
반환 타입: Unit
- Kotlin의 Unit은 Java의 void와 유사하나, 실제 객체(싱글턴)로 존재
- 컴포저블 함수는 대부분 UI만 그리기 때문에 반환값이 불필요하며 Unit으로 처리
축약 표현 (Single-expression function)
@Composable
fun ShortColoredTextDemo(
text: String = "",
color: Color = Color.Black
) = Text(
text = text,
style = TextStyle(color = color)
)
- = 뒤에 표현식을 바로 작성하면 함수 본문을 축약 가능
- = 표현식 형태는 간결한 UI 컴포넌트 정의에 유리
공식 문서 기준 추가 사항
Why PascalCase?
컴포저블 함수는 XML 뷰 대신 사용되는 UI 컴포넌트 역할이므로 PascalCase로 명확히 구분합니다. 이는 클래스, 객체처럼 시각적 구조로 이해되며, 함수가 아닌 UI 요소임을 표현합니다.
@Composable 의 내부 동작
- Compose Compiler는 @Composable 함수를 컴파일 시 Composition Function으로 변환
- 내부적으로는 UI의 상태를 추적하며, 필요할 때만 UI를 Recompose (부분 업데이트)
UI 요소 내보내기
Jetpack Compose에서 UI는 Composable 함수들을 중첩 호출하며 구성됩니다. 여기서는 Text() 컴포저블이 실제로 어떤 과정을 통해 화면에 그려지는지를 추적하며, Compose UI가 어떻게 "UI 요소를 내보내는지" 구조적으로 살펴봅니다.
Text() → BasicText() → CoreText() 호출 구조
ColoredTextDemo()와 같은 사용자 정의 컴포저블에서 androidx.compose.material.Text()를 호출하면, 내부적으로 다음과 같은 구조로 호출이 이어집니다:
- Text()
- 내부적으로 textColor, mergedStyle을 정의하고, BasicText()를 호출합니다.
- BasicText()
- 실제 UI 요소 생성을 담당하는 CoreText()로 위임합니다.
- CoreText()
- 텍스트를 화면에 렌더링하기 위한 수많은 속성을 초기화한 후 Layout()을 호출합니다.
Layout() 함수
Layout()은 핵심 UI 요소를 배치하는 컴포저블입니다. Compose는 모든 UI를 트리 구조의 Layout 노드로 구성하며, Layout()은 이 노드 구조를 만드는 핵심 함수입니다.
@Composable
fun Layout(
content: @Composable () -> Unit,
modifier: Modifier = Modifier,
measurePolicy: MeasurePolicy
)
이 함수는 내부적으로 ReusableComposeNode()를 호출하여 노드를 구성합니다.
ReusableComposeNode()
이 함수는 Compose UI의 핵심인 노드(컴포저블 구성 단위)를 생성하거나 재사용할지를 결정합니다.
@Composable
fun <T, E : Applier<*>> ReusableComposeNode(
factory: () -> T,
update: Updater<T>.() -> Unit,
skippableUpdate: @Composable SkippableUpdater<T>.() -> Unit,
content: @Composable () -> Unit
)
- factory: 노드를 어떻게 만들지를 결정
- update: 상태 변경 시 호출되는 업데이트 로직
- skippableUpdate: 상태가 변하지 않을 경우에만 실행되는 업데이트 로직
- content: 자식 컴포저블
이 함수가 실행되면 다음과 같은 일련의 과정이 이뤄집니다:
- 노드를 생성하거나 재사용
- 업데이트 수행
- 콘텐츠(UI 하위 요소) 렌더링
ComposeUiNode
Layout()에서 사용하는 노드는 ComposeUiNode 인터페이스를 기반으로 하며, 이 인터페이스는 Compose 내부에서 실제 UI 요소 배치를 담당합니다.
internal interface ComposeUiNode {
var measurePolicy: MeasurePolicy
var layoutDirection: LayoutDirection
var density: Density
var modifier: Modifier
...
}
이 노드는 사용자 코드에서 직접 다루지는 않지만, Compose 내부적으로는 다음의 역할을 수행합니다:
- MeasurePolicy: 자식 컴포저블을 어떻게 측정할지 정의
- LayoutDirection: 좌→우, 우→좌 레이아웃 방향 정의
- Density: dp ↔ px 변환에 사용
- Modifier: 배치, 크기, 제스처 등 UI 구성 요소에 부착되는 데코레이터
이 노드를 기반으로 Compose는 효율적인 재구성을 위해 노드를 재사용하거나 교체합니다.
Jetpack Compose에서 UI 요소는 단순히 Text()와 같은 컴포저블 함수 호출로 구성되지 않습니다. 내부적으로는 Text() → BasicText() → CoreText() → Layout() → ReusableComposeNode() → ComposeUiNode라는 체계를 거치며, UI 트리를 구성하고 화면에 그리게 됩니다.
이러한 구조는 Compose의 재조합(recomposition), 재사용성(reusability), **성능 최적화(performance)**의 기반이 되며, 개발자가 UI 구성에 집중할 수 있도록 설계되어 있습니다.
값 반환
컴포저블 함수 대부분은 별다른 값을 반환할 필요가 없기 때문에 반환 타입을 명시하지 않는다. 이는 컴포저블 함수의 주목적이 UI를 구성하는 것이기 때문이다.
앞서 살펴본 것처럼 컴포저블 함수는 UI 요소나 UI 계층 구조를 내보내는 일을 수행한다.
그렇다면 언제 Unit이 아닌 다른 값을 반환해야 할까?
일부 컴포저블 함수는 나중에 사용할 수 있도록 상태를 유지하거나, 리소스 파일(string.xml 등)에 접근해야 할 때 값을 반환한다. 예를 들어 remember {}나 stringResource() 함수가 이에 해당한다. 이들은 단순한 값을 반환하지만, 호출되는 과정은 여전히 컴포저블 흐름을 따라야 한다.
특히 stringResource() 함수를 살펴보자. 안드로이드 스튜디오에서 Ctrl 키를 누른 상태로 함수명을 클릭하면 소스 코드를 확인할 수 있다. 이 함수는 매우 짧고 다음과 같은 역할만 수행한다.
val resources = resources()
return resources.getString(id)
여기서 resources() 역시 컴포저블 함수이다. resources()는 LocalContext.current.resources를 반환하는데,
LocalContext는 androidx.compose.ui.platform 패키지에 정의된 컴포지션 로컬(CompositionLocal)이다.
- LocalContext는 현재 컴포지션에서 사용할 수 있는 Context를 제공한다.
- StaticProvidableCompositionLocal은 컴포즈 내에서 Context나 Theme 같은 공통 리소스를 전달하는 메커니즘이다.
이 덕분에 컴포저블 환경에서도 안전하게 Context와 Resources에 접근할 수 있다.
중요한 점은, 반환된 데이터가 컴포즈 자체와 직접 관련이 없더라도,
이를 제공하는 함수는 컴포저블 함수(@Composable 어노테이션 추가)로 작성되어야 한다는 것이다.
왜냐하면 이 데이터는 컴포지션(구성)과 재구성 과정 중에 호출되기 때문이다.
정리하면, 구성이나 재구성의 일부로서 어떤 값을 반환해야 한다면,
그 함수는 반드시 @Composable을 붙이고, 컴포저블 함수로 만들어야 한다.
추가로 이러한 함수들은 컴포저블 함수 특유의 명명 규칙을 따르지 않고, 일반 카멜 표기법과 동사구를 사용한다.
예시:
- stringResource()
- remember()
- resources()
UI 구성과 재구성
젯팩 컴포즈는 명령형 UI 프레임워크와 달리, 앱 데이터가 변경될 때 개발자가 직접 컴포넌트 트리를 수정할 필요 없이 변화를 스스로 감지하고, 필요한 부분만 갱신한다.
컴포즈는 현재 앱 상태를 기반으로 UI를 선언하고, 런타임 상태에 따라 어떤 컴포저블이 호출될지 동적으로 결정한다.
컴포즈는 개념적으로 전체 UI를 다시 생성하지만, 실제로는 프레임워크가 최적화를 통해 UI 요소 트리 중 필요한 부분만 갱신하도록 노력한다.
이를 위해 update와 skippableUpdate 같은 내부 최적화 기법을 활용한다.
컴포저블 함수를 빠르고 안정적으로 재구성하려면 몇 가지 규칙을 따라야 한다.
컴포저블 함수 간 상태 공유
여러 컴포저블 함수에서 하나의 상태를 공유하고 싶을 때는 MutableState를 사용한다.
아래는 ColorPicker 예제이다.
@Composable
fun ColorPicker(color: MutableState<Color>) {
val red = color.value.red
val green = color.value.green
val blue = color.value.blue
Column {
Slider(
value = red,
onValueChange = { color.value = Color(it, green, blue) })
Slider(
value = green,
onValueChange = { color.value = Color(red, it, blue) })
Slider(
value = blue,
onValueChange = { color.value = Color(red, green, it) })
}
}
ColorPicker는 MutableState<Color>를 매개변수로 받아 색상을 실시간으로 조정한다.
슬라이더 각각은 빨강, 초록, 파랑 값만 업데이트하며, 나머지 색상 성분은 유지된다.
왜 MutableState를 전달할까?
단순 Color만 전달하면 변경 사항을 호출자에게 알릴 수 없다. Kotlin 함수 매개변수는 기본적으로 불변(immutable)이기 때문이다.
따라서 상태를 직접 전달하여 ColorPicker 내부 변경을 외부로 반영할 수 있게 한다.
전역 변수 사용은 지양해야 한다.
컴포저블 함수의 모든 입력 데이터는 명시적으로 매개변수로 주입해야 한다. 이를 통해 컴포저블을 예측 가능하고 재사용성 높은 함수로 유지할 수 있다.
이런 방식을 상태 호이스팅(State Hoisting) 이라고 부른다.
대안으로, 상태 자체 대신 변경 함수(람다 표현식) 를 매개변수로 전달할 수도 있다.
중요한 원칙
컴포저블은 부수 효과(Side Effect)가 없어야 한다.
- 동일한 입력이 주어지면 항상 동일한 결과를 내야 한다.
- 전역 프로퍼티에 의존하거나, 예측 불가능한 결과를 반환하는 함수를 호출해서는 안 된다.
다만 일부 상황에서는 부수효과를 활용할 수도 있으며, 이는 별도로 다룬다.
컬럼 안에 ColorPicker와 텍스트 배치하기
Column(
modifier = Modifier.width(min(400.dp, maxWidth)),
horizontalAlignment = Alignment.CenterHorizontally
) {
val color = remember { mutableStateOf(Color.Magenta) }
ColorPicker(color)
Text(
modifier = Modifier
.fillMaxWidth()
.background(color.value),
text = "#${color.value.toArgb().toUInt().toString(16)}",
textAlign = TextAlign.Center,
style = MaterialTheme.typography.h4.merge(
TextStyle(
color = color.value.complementary()
)
)
)
}
- ColorPicker와 Text를 수직으로 배치하는 예제이다.
- 너비는 400.dp와 maxWidth 중 작은 값을 선택한다.
- color는 remember로 관리된다. 재구성 시에도 동일한 참조를 유지한다.
remember 블록 안 코드는 최초 한 번만 실행되며, 이후에는 같은 상태 객체를 재사용한다.
실제 색상 값은 color.value를 통해 읽거나 쓸 수 있다.
Color의 보색 계산
fun Color.complementary() = Color(
red = 1F - red,
green = 1F - green,
blue = 1F - blue
)
- complementary() 확장 함수는 주어진 색상의 보색을 계산한다.
- 이 함수를 사용해 텍스트가 배경색과 대비되도록 한다.
정리
- 컴포즈 UI는 컴포저블 함수 중첩 호출로 구성된다.
- 컴포저블 함수는 UI 요소나 요소 트리를 발행한다.
- UI를 처음 만드는 과정을 Composition이라 한다.
- 데이터 변경에 따라 UI를 다시 만드는 과정을 Recomposition이라 한다.
- 재구성은 자동으로 발생한다.
중요 주의사항
- 재구성은 언제, 얼마나 자주 일어날지 예측할 수 없다.
- 특히 애니메이션이 동작하는 경우 매 프레임마다 재구성이 발생할 수 있다.
- 컴포저블 함수는 반드시 빠르게 실행되어야 하며,
시간이 오래 걸리는 연산, 네트워크 통신, 파일 입출력 등은 컴포저블 바깥에서 처리해야 한다. - 재구성은 순서 보장도 없다. 예를 들어 Column의 첫 번째 자식보다 두 번째 자식이 먼저 재구성될 수도 있다.
- 따라서 컴포저블 내부에서 순서에 의존하거나 다른 컴포저블의 상태를 직접 참조하는 코드를 작성하면 안 된다.
크기 제어
Jetpack Compose에서 컴포저블의 크기를 제어할 수 있는 주요 Modifier는 다음과 같습니다:
- fillMaxSize(): 가용한 수직·수평 공간 모두를 차지
- fillMaxWidth(): 가로 방향으로만 최대한 확장
예를 들어 Slider에 fillMaxWidth()를 적용하면 화면 전체 너비를 사용하는데, 이는 사용자가 최소/최대 값으로 드래그할 때 지나치게 긴 경로를 이동해야 하므로 UX에 불편을 줄 수 있습니다.
해결 방법: width()와 BoxWithConstraints
슬라이더 너비를 제한하고 싶다면 다음과 같이 width()를 사용해 최대 크기를 설정하면 됩니다:
modifier = Modifier.width(min(400.dp, maxWidth))
maxWidth는 BoxWithConstraints의 범위 내에서 자동으로 제공되는 값입니다. 실제 적용 예시는 아래와 같습니다.
BoxWithConstraints(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Column(
modifier = Modifier.width(min(400.dp, maxWidth)),
horizontalAlignment = Alignment.CenterHorizontally
) {
// 내부에 Slider, Text 등 컴포저블 배치
}
}
이 구성에서 BoxWithConstraints는 자신의 크기를 자식에게 전달하는 특수한 컴포저블이며, maxWidth, minHeight 같은 제약 조건 값을 제공해 반응형 UI 구성에 유용합니다
액티비티 내에서 컴포저블 계층 구조 나타내기
Jetpack Compose에서는 XML을 사용하는 대신 컴포저블 함수를 사용해 UI 계층을 구성합니다. 그리고 이 UI를 Activity에 연결하기 위해 setContent를 사용합니다.
setContent 사용 조건
- setContent는 androidx.activity.ComponentActivity의 확장 함수
- AppCompatActivity는 ComponentActivity를 상속하므로 사용 가능
- 하지만 Compose 중심 앱에서는 ComponentActivity 확장을 권장함
setContent 함수 시그니처
public fun ComponentActivity.setContent(
parent: CompositionContext? = null,
content: @Composable () -> Unit
)
- parent: 컴포지션 계층 연결용 (일반적으로 생략 가능)
- content: 실제 컴포저블 UI를 담은 함수
내부 동작 구조 분석
findViewById<ComposeView>() ?: ComposeView(context).also {
setContentView(it)
it.setParentCompositionContext(parent)
it.setContent(content)
}
이 구조를 통해 다음과 같은 동작이 이루어집니다:
- findViewById()로 ComposeView가 존재하는지 확인
- 없다면 새 ComposeView 생성 및 setContentView()로 등록
- setParentCompositionContext() 호출 → 부모 컴포지션 설정
- setContent() 호출 → 실제 UI 그리기 시작
구성 요소 연결 흐름
- AbstractComposeView.ensureCompositionCreated() → 내부적으로 구성 트리 초기화
- resolveParentCompositionContext() → 상위 컴포지션 문맥 연결
- Wrapper.android.kt 내부 확장 함수들 → ComposeView 최종 콘텐츠 설정
이러한 내부 구현 덕분에 setContent는 선언적 UI를 빠르게 Activity에 반영할 수 있으며, 복잡한 Compose 계층도 문제없이 호환됩니다.
컴포저블 함수의 행위 수정
Jetpack Compose에서는 기존 명령형 UI 컴포넌트와 달리, 컴포저블 함수 간에 기본 프로퍼티 세트를 공유하지 않습니다.
기능도 자동 재사용되지 않고, 명시적으로 호출된 컴포저블만 UI로 구성됩니다.
UI의 시각적 형태나 동작은 매개변수, Modifier(변경자) 또는 둘 모두로 제어할 수 있습니다.
Modifier는 단순 속성보다 더 유연하며, 개발자가 선택적으로 체이닝할 수 있습니다.
대표 Modifier 예시
- width()
- fillMaxWidth()
- fillMaxSize()
- background()
- clickable { }
이러한 변경자들은 너비, 정렬, 시각 효과, 동작 등을 제어하며, 순서에 따라 동작 결과가 달라질 수 있습니다.
아래 예제는 Modifier 체이닝의 동작 순서를 보여줍니다.
@Composable
fun OrderDemo() {
var color by remember { mutableStateOf(Color.Blue) }
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp)
.border(BorderStroke(width = 2.dp, color = color))
.background(Color.LightGray)
.clickable {
color = if (color == Color.Blue)
Color.Red
else
Color.Blue
}
)
}
- 클릭 시 color가 파란색/빨간색으로 전환됩니다.
- 패딩 뒤에 clickable이 있어서 테두리 안쪽만 클릭 가능합니다.
- clickable을 .padding() 앞으로 이동하면 패딩 영역도 클릭이 됩니다.
Modifier는 선언된 순서대로 동작하므로, 체이닝 순서가 중요합니다.
변경자 동작 이해
컴포저블 함수에 Modifier를 적용하려면 modifier 매개변수를 아래처럼 정의해야 합니다:
@Composable
fun TextWithYellowBackground(
text: String,
modifier: Modifier = Modifier
) {
Text(
text = text,
modifier = modifier.background(Color.Yellow)
)
}
- modifier는 기본값을 Modifier로 두고, 마지막 매개변수로 선언합니다.
- 호출자가 Modifier를 넘기지 않으면 기본적으로 빈 체이닝이 적용됩니다.
- 컴포저블 내부에서 추가적으로 .background()와 같은 Modifier를 덧붙일 수 있습니다.
Modifier 내부 구조
Modifier는 실제로 인터페이스이며 then() 메서드를 통해 체이닝됩니다.
companion object : Modifier {
override infix fun then(other: Modifier): Modifier = other
}
- Modifier.Element는 체인에 들어가는 단일 요소를 의미합니다.
- then()은 두 Modifier를 연결하는 핵심 메서드입니다.
- 내부적으로 foldIn, foldOut 같은 고차 함수도 제공되지만 일반 사용자가 다룰 일은 드뭅니다.
background() 변경자 구현
Modifier.background(Color.Yellow)
- 실제로는 Modifier.background() 확장 함수가 내부적으로 then()을 호출해 Background 객체를 추가합니다.
- Background는 DrawModifier를 구현하고 있으며, 이는 UI의 그리기를 담당하는 인터페이스입니다.
커스텀 변경자 구현
drawYellowCross()는 노란색 십자선을 배경에 그리는 커스텀 Modifier입니다.
호출 예시
Text(
text = "Hello Compose",
modifier = Modifier
.fillMaxSize()
.drawYellowCross(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.h1
)
구현 코드
fun Modifier.drawYellowCross() = then(
object : DrawModifier {
override fun ContentDrawScope.draw() {
drawLine(
color = Color.Yellow,
start = Offset(0F, 0F),
end = Offset(size.width - 1, size.height - 1),
strokeWidth = 10F
)
drawLine(
color = Color.Yellow,
start = Offset(0F, size.height - 1),
end = Offset(size.width - 1, 0F),
strokeWidth = 10F
)
drawContent()
}
}
)
- DrawModifier의 draw()를 오버라이드해 두 개의 선을 그립니다.
- drawContent()는 실제 UI 요소를 그리는 호출입니다.
- 이 위치에 따라 UI 위/아래로 커스텀 드로잉이 결정됩니다.
Jetpack Compose는 커스텀 드로잉을 쉽게 하기 위해 drawBehind { }를 제공합니다.
Modifier.drawBehind {
drawCircle(Color.Red, radius = 50f, center = center)
}
- drawYellowCross()와 같은 효과를 더 간단히 구현할 수도 있습니다.
- 직접 구현된 소스를 보고 싶다면 drawBehind에 Ctrl+클릭하여 Jump To Source!
도서 링크 바로가기
https://www.yes24.com/Product/Goods/116413696
젯팩 컴포즈로 개발하는 안드로이드 UI - 예스24
젯팩 컴포즈는 안드로이드 UI 개발의 새로운 패러다임이다. 이 책은 젯팩 컴포즈를 통해 안드로이드 애플리케이션을 개발할 수 있도록 도와줄 것이다. 젯팩 컴포즈로 안드로이드를 처음 개발해
www.yes24.com
'Android > Study' 카테고리의 다른 글
| [Android] Android 앱 아키텍처 가이드 정리 (2) | 2025.07.03 |
|---|---|
| [Jetpack Compose로 개발하는 안드로이드 UI] 4장. UI 요소 배치 (0) | 2025.05.13 |
| [Jetpack Compose로 개발하는 안드로이드 UI] 2장. 선언적 패러다임 이해 (0) | 2025.04.18 |
| [Jetpack Compose로 개발하는 안드로이드 UI] 1장. Jetpack Compose 기본 요소 정리하기 (0) | 2025.04.15 |
| [Android] Gson vs Moshi vs kotlinx.serialization Deep Dive (0) | 2025.03.26 |