콘텐츠 대표 이미지 - 안드로이드 Jetpack Compose로 모던한 UI 개발하기: 초보자도 쉽게 따라할 수 있는 완벽 가이드 🚀

안드로이드 Jetpack Compose로 모던한 UI 개발하기: 초보자도 쉽게 따라할 수 있는 완벽 가이드 🚀

안녕? 오늘은 안드로이드 앱 개발의 판도를 완전히 바꿔놓은 Jetpack Compose에 대해 함께 알아볼 거야! 🎮 기존의 XML 레이아웃을 사용한 UI 개발에 지쳤다면, 이제 Kotlin으로 선언적 UI를 구현하는 새로운 세계로 함께 떠나보자고! 👨‍💻

이 글에서는 Compose의 기본 개념부터 실제 앱에 적용하는 방법까지 초보자도 이해하기 쉽게 설명할 거야. 특히 요즘 재능넷 같은 플랫폼에서도 Jetpack Compose 개발 능력을 가진 개발자들의 수요가 늘고 있어서, 이 기술을 익히면 너의 개발 커리어에도 큰 도움이 될 거야! 🌟

XML 레이아웃 Jetpack Compose 복잡한 중첩 레이아웃 여러 파일 관리 미리보기 어려움 간결한 코드 실시간 미리보기 코틀린 기반 UI

1. Jetpack Compose란 무엇인가? 🤔

Jetpack Compose는 구글이 안드로이드 앱 개발자들을 위해 만든 현대적인 UI 툴킷이야. 기존의 View 시스템을 대체하기 위해 만들어졌고, 코틀린 프로그래밍 언어를 기반으로 하고 있지. 2021년에 정식 버전이 출시된 이후로 많은 개발자들이 이 기술을 채택하고 있어!

1.1 Compose의 핵심 특징 ✨

  1. 선언적 UI: XML 대신 코틀린 코드로 UI를 직접 선언해. 이게 무슨 말이냐면, "이런 버튼을 만들고, 여기에 이런 텍스트를 넣고..."처럼 UI가 어떻게 보여야 하는지를 직접 코드로 표현한다는 거야.
  2. 반응형 프로그래밍: 데이터가 변경되면 UI가 자동으로 업데이트돼. 더 이상 findViewById()나 데이터 바인딩을 사용할 필요가 없어!
  3. 컴포저블 함수: UI 요소들을 재사용 가능한 함수로 만들 수 있어. 이렇게 하면 코드 중복을 줄이고 유지보수가 쉬워지지.
  4. 미리보기 기능: 앱을 실행하지 않고도 UI가 어떻게 보일지 Android Studio에서 바로 확인할 수 있어.
  5. 애니메이션 API: 복잡한 애니메이션도 간단한 코드로 구현할 수 있어.

1.2 왜 Jetpack Compose를 배워야 할까? 🧐

XML 레이아웃으로 UI를 만드는 것은 꽤 번거로운 작업이었어. 특히 복잡한 화면을 구현할 때는 여러 XML 파일을 관리해야 했고, 동적인 UI 변경도 쉽지 않았지. Compose는 이런 문제들을 해결하고, 더 직관적이고 효율적인 UI 개발 경험을 제공해.

또한, 재능넷 같은 플랫폼에서 모바일 앱 개발 프로젝트를 수주할 때 Jetpack Compose 경험이 있으면 큰 경쟁력이 될 수 있어. 최신 기술을 습득하는 것은 항상 좋은 투자니까! 💼

2. Jetpack Compose 시작하기 🚀

2.1 개발 환경 설정하기 🛠️

Compose를 사용하기 위해서는 다음과 같은 환경이 필요해:

  1. Android Studio Arctic Fox(2020.3.1) 이상
  2. Kotlin 1.5.0 이상
  3. Java 11 이상
  4. 최소 API 레벨 21 (Android 5.0) 이상의 타겟 디바이스

프로젝트의 build.gradle 파일에 다음과 같은 의존성을 추가해야 해:

dependencies {
    implementation 'androidx.compose.ui:ui:1.5.0'
    implementation 'androidx.compose.material:material:1.5.0'
    implementation 'androidx.compose.ui:ui-tooling-preview:1.5.0'
    debugImplementation 'androidx.compose.ui:ui-tooling:1.5.0'
}

그리고 Compose를 활성화하기 위해 app 수준의 build.gradle 파일에 다음 설정을 추가해:

android {
    ...
    buildFeatures {
        compose true
    }
    composeOptions {
        kotlinCompilerExtensionVersion '1.5.0'
    }
    ...
}

2.2 첫 번째 Composable 함수 만들기 🎨

Compose에서는 UI 요소를 Composable 함수로 만들어. 이 함수들은 @Composable 어노테이션을 사용해 정의하고, UI 계층 구조를 구성하는 데 사용돼. 간단한 텍스트를 표시하는 함수를 만들어 볼게:

import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview

@Composable
fun Greeting(name: String) {
    Text(text = "안녕, $name!")
}

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
    Greeting("Jetpack Compose")
}

이 코드에서 @Composable 어노테이션은 이 함수가 UI를 생성하는 함수임을 컴파일러에게 알려주고, @Preview 어노테이션은 Android Studio에서 이 UI를 미리 볼 수 있게 해줘. 정말 간단하지? 😊

안녕, Jetpack Compose! 첫 번째 Composable 함수의 결과 Text 컴포넌트를 사용한 간단한 인사말 @Preview 어노테이션으로 Android Studio에서 바로 확인 가능

3. Compose의 기본 구성 요소 📦

3.1 기본 UI 요소들 🧩

Jetpack Compose는 다양한 기본 UI 컴포넌트를 제공해. 가장 자주 사용하는 것들을 살펴볼게:

  1. Text: 텍스트를 표시하는 컴포넌트
  2. Button: 클릭 가능한 버튼 컴포넌트
  3. Image: 이미지를 표시하는 컴포넌트
  4. TextField: 사용자 입력을 받는 텍스트 필드
  5. Box: 자식 요소들을 겹쳐서 배치할 수 있는 레이아웃
  6. Row: 자식 요소들을 가로로 배치하는 레이아웃
  7. Column: 자식 요소들을 세로로 배치하는 레이아웃

간단한 예제로 이 컴포넌트들을 사용해볼게:

@Composable
fun SimpleUI() {
    Column(
        modifier = Modifier
            .padding(16.dp)
            .fillMaxWidth()
    ) {
        Text(
            text = "Jetpack Compose 예제",
            fontSize = 24.sp,
            fontWeight = FontWeight.Bold
        )
        
        Spacer(modifier = Modifier.height(16.dp))
        
        Row(verticalAlignment = Alignment.CenterVertically) {
            Image(
                painter = painterResource(id = R.drawable.compose_logo),
                contentDescription = "Compose 로고",
                modifier = Modifier.size(40.dp)
            )
            
            Spacer(modifier = Modifier.width(8.dp))
            
            Text(text = "Compose로 UI 만들기", fontSize = 18.sp)
        }
        
        Spacer(modifier = Modifier.height(16.dp))
        
        var text by remember { mutableStateOf("") }
        
        TextField(
            value = text,
            onValueChange = { text = it },
            label = { Text("여기에 입력하세요") },
            modifier = Modifier.fillMaxWidth()
        )
        
        Spacer(modifier = Modifier.height(16.dp))
        
        Button(
            onClick = { /* 버튼 클릭 시 동작 */ },
            modifier = Modifier.align(Alignment.End)
        ) {
            Text("확인")
        }
    }
}

3.2 상태 관리하기 (State) 🔄

Compose에서 상태(State)는 UI의 현재 모습을 결정하는 중요한 개념이야. 상태가 변경되면 Compose는 자동으로 UI를 다시 그려주지. 상태를 관리하는 방법은 다음과 같아:

@Composable
fun CounterExample() {
    // remember를 사용해 상태 변수 생성
    var count by remember { mutableStateOf(0) }
    
    Column(
        modifier = Modifier.padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            text = "현재 카운트: $count",
            fontSize = 20.sp
        )
        
        Spacer(modifier = Modifier.height(16.dp))
        
        Button(onClick = { count++ }) {
            Text("증가")
        }
    }
}

위 예제에서 mutableStateOf()는 변경 가능한 상태를 생성하고, remember는 컴포지션이 재구성될 때 상태를 기억하도록 해. 그리고 by 키워드는 Kotlin의 속성 위임을 사용해서 코드를 더 간결하게 만들어주지. 😎

3.3 Modifier로 UI 커스터마이징하기 🎭

Compose에서 Modifier는 UI 요소의 크기, 패딩, 마진, 배경색 등을 설정하는 데 사용돼. 여러 Modifier를 체인 형태로 연결해서 사용할 수 있어:

@Composable
fun ModifierExample() {
    Box(
        modifier = Modifier
            .size(100.dp)
            .background(Color.Blue)
            .padding(16.dp)
            .clip(RoundedCornerShape(8.dp))
            .background(Color.Red)
            .clickable { /* 클릭 시 동작 */ }
    ) {
        Text(
            text = "Hello",
            color = Color.White,
            modifier = Modifier.align(Alignment.Center)
        )
    }
}

이 예제에서는 크기가 100dp인 파란색 배경의 Box를 만들고, 16dp의 패딩을 주고, 모서리를 둥글게 만들고, 내부 배경을 빨간색으로 설정하고, 클릭 가능하게 만들었어. 그리고 그 안에 "Hello"라는 텍스트를 중앙에 배치했지. Modifier를 사용하면 이렇게 복잡한 스타일링도 간단하게 할 수 있어! 👍

4. Compose로 레이아웃 구성하기 📐

4.1 기본 레이아웃 컴포넌트 📏

Compose에서는 세 가지 기본 레이아웃 컴포넌트를 제공해:

  1. Box: 자식 요소들을 겹쳐서 배치 (FrameLayout과 유사)
  2. Row: 자식 요소들을 가로로 배치 (LinearLayout horizontal과 유사)
  3. Column: 자식 요소들을 세로로 배치 (LinearLayout vertical과 유사)

이 세 가지 레이아웃을 조합하면 거의 모든 UI 레이아웃을 만들 수 있어. 예를 들어, 간단한 프로필 카드를 만들어볼게:

@Composable
fun ProfileCard() {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp),
        elevation = 4.dp
    ) {
        Row(
            modifier = Modifier.padding(16.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            // 프로필 이미지
            Box(
                modifier = Modifier
                    .size(60.dp)
                    .clip(CircleShape)
                    .background(Color.Gray)
            ) {
                // 실제로는 여기에 Image 컴포넌트가 들어갈 수 있어
                Text(
                    text = "JP",
                    color = Color.White,
                    modifier = Modifier.align(Alignment.Center)
                )
            }
            
            // 프로필 정보
            Column(
                modifier = Modifier
                    .padding(start = 16.dp)
                    .weight(1f)
            ) {
                Text(
                    text = "Jetpack Compose",
                    fontSize = 18.sp,
                    fontWeight = FontWeight.Bold
                )
                Text(
                    text = "안드로이드 개발자",
                    fontSize = 14.sp,
                    color = Color.Gray
                )
            }
            
            // 팔로우 버튼
            Button(
                onClick = { /* 버튼 클릭 시 동작 */ },
                modifier = Modifier.wrapContentSize()
            ) {
                Text("팔로우")
            }
        }
    }
}

4.2 LazyColumn과 LazyRow로 리스트 만들기 📜

기존 안드로이드의 RecyclerView와 같은 역할을 하는 LazyColumn과 LazyRow를 사용하면 효율적인 리스트를 쉽게 만들 수 있어:

@Composable
fun SimpleList() {
    val items = List(100) { "Item #$it" }
    
    LazyColumn {
        items(items) { item ->
            Text(
                text = item,
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(16.dp)
            )
            Divider()
        }
    }
}

LazyColumn은 화면에 보이는 항목만 렌더링하기 때문에 메모리 효율이 좋아. 또한, 리스트 항목의 추가, 제거, 업데이트도 간단하게 처리할 수 있지. 😌

4.3 ConstraintLayout 사용하기 🧩

더 복잡한 레이아웃을 위해 Compose에서도 ConstraintLayout을 사용할 수 있어. 먼저 의존성을 추가해야 해:

implementation "androidx.constraintlayout:constraintlayout-compose:1.0.1"

그리고 다음과 같이 사용할 수 있어:

@Composable
fun ConstraintLayoutExample() {
    ConstraintLayout(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp)
    ) {
        // 참조 생성
        val (title, image, description, button) = createRefs()
        
        Text(
            text = "제목",
            fontSize = 20.sp,
            fontWeight = FontWeight.Bold,
            modifier = Modifier.constrainAs(title) {
                top.linkTo(parent.top)
                start.linkTo(parent.start)
            }
        )
        
        Box(
            modifier = Modifier
                .size(100.dp)
                .background(Color.Gray)
                .constrainAs(image) {
                    top.linkTo(title.bottom, margin = 16.dp)
                    start.linkTo(parent.start)
                }
        )
        
        Text(
            text = "이것은 설명 텍스트입니다. 여기에 자세한 내용이 들어갑니다.",
            modifier = Modifier
                .constrainAs(description) {
                    top.linkTo(image.top)
                    start.linkTo(image.end, margin = 16.dp)
                    end.linkTo(parent.end)
                    width = Dimension.fillToConstraints
                }
        )
        
        Button(
            onClick = { /* 버튼 클릭 시 동작 */ },
            modifier = Modifier.constrainAs(button) {
                top.linkTo(image.bottom, margin = 16.dp)
                end.linkTo(parent.end)
            }
        ) {
            Text("확인")
        }
    }
}

ConstraintLayout을 사용하면 UI 요소들 간의 관계를 정의해서 복잡한 레이아웃도 쉽게 만들 수 있어. 특히 반응형 UI를 구현할 때 유용하지! 🧠

5. Compose에서 테마 적용하기 🎨

5.1 Material Design 테마 🎭

Compose는 Material Design을 기본적으로 지원해. MaterialTheme 컴포저블을 사용해서 앱 전체의 색상, 타이포그래피, 모양 등을 일관되게 적용할 수 있어:

@Composable
fun MyApp() {
    MaterialTheme(
        colors = lightColors(
            primary = Purple500,
            primaryVariant = Purple700,
            secondary = Teal200
        ),
        typography = Typography(
            body1 = TextStyle(
                fontFamily = FontFamily.Default,
                fontWeight = FontWeight.Normal,
                fontSize = 16.sp
            )
            // 다른 텍스트 스타일도 여기서 정의할 수 있어
        ),
        shapes = Shapes(
            small = RoundedCornerShape(4.dp),
            medium = RoundedCornerShape(8.dp),
            large = RoundedCornerShape(12.dp)
        )
    ) {
        // 앱 콘텐츠
        Surface(color = MaterialTheme.colors.background) {
            Greeting("Android")
        }
    }
}

5.2 다크 테마 지원하기 🌙

Compose에서는 다크 테마를 쉽게 지원할 수 있어. isSystemInDarkTheme() 함수를 사용해서 시스템의 다크 모드 설정을 확인하고, 그에 맞는 테마를 적용할 수 있지:

@Composable
fun MyApp() {
    val isDarkTheme = isSystemInDarkTheme()
    
    MaterialTheme(
        colors = if (isDarkTheme) {
            darkColors(
                primary = Purple200,
                primaryVariant = Purple700,
                secondary = Teal200
            )
        } else {
            lightColors(
                primary = Purple500,
                primaryVariant = Purple700,
                secondary = Teal200
            )
        }
    ) {
        // 앱 콘텐츠
        Surface(color = MaterialTheme.colors.background) {
            Greeting("Android")
        }
    }
}

5.3 커스텀 테마 색상 사용하기 🌈

MaterialTheme에서 정의한 색상을 앱 전체에서 일관되게 사용하면 디자인의 일관성을 유지할 수 있어:

@Composable
fun ThemedButton() {
    Button(
        onClick = { /* 버튼 클릭 시 동작 */ },
        colors = ButtonDefaults.buttonColors(
            backgroundColor = MaterialTheme.colors.primary,
            contentColor = MaterialTheme.colors.onPrimary
        )
    ) {
        Text("테마 색상을 사용한 버튼")
    }
}

이렇게 하면 다크 테마로 전환해도 자동으로 적절한 색상이 적용돼. 또한, MaterialTheme.colors, MaterialTheme.typography, MaterialTheme.shapes 등을 통해 테마에서 정의한 스타일을 쉽게 가져와 사용할 수 있어! 🎯

라이트 테마 다크 테마 Primary Button Secondary Button Surface with Background Color Primary Button Secondary Button Surface with Background Color

6. Compose에서 애니메이션 구현하기 ✨

6.1 기본 애니메이션 🎬

Compose에서는 animateXxxAsState 함수들을 사용해 간단한 애니메이션을 쉽게 구현할 수 있어:

@Composable
fun AnimatedButton() {
    var expanded by remember { mutableStateOf(false) }
    
    // 크기 애니메이션
    val size by animateDpAsState(
        targetValue = if (expanded) 120.dp else 60.dp,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessLow
        )
    )
    
    // 색상 애니메이션
    val color by animateColorAsState(
        targetValue = if (expanded) Color.Red else Color.Blue,
        animationSpec = tween(durationMillis = 500)
    )
    
    Box(
        modifier = Modifier
            .size(size)
            .background(color, shape = CircleShape)
            .clickable { expanded = !expanded },
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = if (expanded) "축소" else "확장",
            color = Color.White
        )
    }
}

이 예제에서는 버튼을 클릭할 때마다 크기와 색상이 애니메이션되는 효과를 구현했어. animateDpAsState와 animateColorAsState를 사용해서 값의 변화를 부드럽게 애니메이션할 수 있지! 🎭

6.2 전환 애니메이션 (Transition) 🔄

여러 속성을 동시에 애니메이션하려면 Transition을 사용할 수 있어:

@Composable
fun TransitionExample() {
    var state by remember { mutableStateOf(false) }
    
    val transition = updateTransition(targetState = state, label = "transition")
    
    val size by transition.animateDp(label = "size") { targetState ->
        if (targetState) 100.dp else 50.dp
    }
    
    val color by transition.animateColor(label = "color") { targetState ->
        if (targetState) Color.Red else Color.Blue
    }
    
    val cornerRadius by transition.animateDp(label = "corner") { targetState ->
        if (targetState) 50.dp else 0.dp
    }
    
    Box(
        modifier = Modifier
            .size(size)
            .clip(RoundedCornerShape(cornerRadius))
            .background(color)
            .clickable { state = !state },
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = if (state) "활성" else "비활성",
            color = Color.White
        )
    }
}

6.3 애니메이션 가시성 (AnimatedVisibility) 👁️

AnimatedVisibility를 사용하면 컴포넌트의 표시/숨김 상태를 애니메이션할 수 있어:

@Composable
fun AnimatedVisibilityExample() {
    var visible by remember { mutableStateOf(true) }
    
    Column(
        modifier = Modifier.padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Button(onClick = { visible = !visible }) {
            Text(if (visible) "숨기기" else "보이기")
        }
        
        Spacer(modifier = Modifier.height(16.dp))
        
        AnimatedVisibility(
            visible = visible,
            enter = fadeIn() + expandVertically(),
            exit = fadeOut() + shrinkVertically()
        ) {
            Card(
                modifier = Modifier
                    .fillMaxWidth()
                    .height(200.dp),
                elevation = 4.dp
            ) {
                Box(
                    contentAlignment = Alignment.Center,
                    modifier = Modifier.fillMaxSize()
                ) {
                    Text("애니메이션 카드 콘텐츠")
                }
            }
        }
    }
}

이 예제에서는 버튼을 클릭할 때마다 카드가 페이드 인/아웃되면서 수직으로 확장/축소되는 애니메이션을 보여줘. enter와 exit 파라미터를 사용해 진입/퇴장 애니메이션을 지정할 수 있어. 정말 멋지지? 🌟

7. Compose에서 상태 관리 심화 🧠

7.1 ViewModel과 함께 사용하기 📊

실제 앱에서는 ViewModel을 사용해 UI 상태를 관리하는 것이 좋아. Compose와 ViewModel을 함께 사용하는 방법을 알아볼게:

// ViewModel 클래스
class CounterViewModel : ViewModel() {
    // UI 상태를 나타내는 StateFlow
    private val _count = MutableStateFlow(0)
    val count: StateFlow<Int> = _count
    
    // 상태를 변경하는 함수
    fun increment() {
        _count.value++
    }
    
    fun decrement() {
        if (_count.value > 0) {
            _count.value--
        }
    }
}

// Compose UI
@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
    // StateFlow를 State로 변환
    val count by viewModel.count.collectAsState()
    
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(
            text = "카운트: $count",
            fontSize = 24.sp,
            fontWeight = FontWeight.Bold
        )
        
        Spacer(modifier = Modifier.height(16.dp))
        
        Row {
            Button(
                onClick = { viewModel.decrement() },
                modifier = Modifier.padding(end = 8.dp)
            ) {
                Text("-")
            }
            
            Button(onClick = { viewModel.increment() }) {
                Text("+")
            }
        }
    }
}

이 예제에서는 StateFlow를 사용해 ViewModel에서 상태를 관리하고, collectAsState() 함수를 사용해 Compose에서 이 상태를 관찰해. 이렇게 하면 UI 로직과 비즈니스 로직을 분리할 수 있어서 코드 유지보수가 쉬워지지! 🧩

7.2 상태 호이스팅 (State Hoisting) 🏗️

상태 호이스팅은 상태를 컴포저블 함수의 매개변수로 전달하고 상태 변경은 콜백으로 처리하는 패턴이야. 이렇게 하면 컴포넌트를 재사용하기 쉬워지고, 테스트하기도 쉬워져:

// 상태를 매개변수로 받는 재사용 가능한 컴포넌트
@Composable
fun Counter(
    count: Int,
    onIncrement: () -> Unit,
    onDecrement: () -> Unit
) {
    Column(
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            text = "카운트: $count",
            fontSize = 24.sp
        )
        
        Spacer(modifier = Modifier.height(16.dp))
        
        Row {
            Button(
                onClick = onDecrement,
                modifier = Modifier.padding(end = 8.dp)
            ) {
                Text("-")
            }
            
            Button(onClick = onIncrement) {
                Text("+")
            }
        }
    }
}

// 상태를 관리하는 상위 컴포넌트
@Composable
fun CounterScreen() {
    var count by remember { mutableStateOf(0) }
    
    Counter(
        count = count,
        onIncrement = { count++ },
        onDecrement = { if (count > 0) count-- }
    )
}

이런 방식으로 Counter 컴포넌트는 자체적으로 상태를 관리하지 않고, 상위 컴포넌트에서 전달받은 상태와 콜백만 사용해. 이렇게 하면 같은 Counter 컴포넌트를 여러 곳에서 다양한 방식으로 재사용할 수 있어! 💡

7.3 remember와 rememberSaveable 🔄

remember는 컴포지션이 재구성될 때 상태를 유지하지만, 화면 회전 같은 구성 변경이나 프로세스 재생성 시에는 상태가 손실돼. 이런 경우에는 rememberSaveable을 사용하면 좋아:

@Composable
fun SaveableCounter() {
    // 화면 회전 등의 구성 변경에도 상태가 유지됨
    var count by rememberSaveable { mutableStateOf(0) }
    
    Column(
        modifier = Modifier.padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            text = "카운트: $count",
            fontSize = 24.sp
        )
        
        Spacer(modifier = Modifier.height(16.dp))
        
        Button(onClick = { count++ }) {
            Text("증가")
        }
    }
}

rememberSaveable은 내부적으로 Bundle을 사용해 상태를 저장하기 때문에, 기본 타입(Int, String 등)이나 Parcelable 객체만 저장할 수 있어. 복잡한 객체를 저장하려면 커스텀 Saver를 구현해야 할 수도 있지! 🔍

8. 실제 앱 개발에 Compose 적용하기 🚀

8.1 기존 앱에 Compose 통합하기 🔄

기존의 View 기반 앱에 Compose를 점진적으로 도입할 수 있어. ComposeView를 사용하면 XML 레이아웃 내에서 Compose UI를 사용할 수 있지:

// XML 레이아웃에서 ComposeView 사용
<androidx.compose.ui.platform.ComposeView
    android:id="@+id/compose_view"
    android:layout_width="match_parent"
    android:layout_height="wrap_content" />

// Activity나 Fragment에서 ComposeView 설정
findViewById<ComposeView>(R.id.compose_view).setContent {
    MaterialTheme {
        Surface {
            Greeting("Compose in XML")
        }
    }
}

반대로, Compose UI 내에서 기존 Android View를 사용하려면 AndroidView 컴포저블을 사용할 수 있어:

@Composable
fun WebViewExample() {
    AndroidView(
        factory = { context ->
            WebView(context).apply {
                loadUrl("https://www.jaenung.net")
            }
        },
        update = { webView ->
            // 필요한 경우 WebView 업데이트
        },
        modifier = Modifier.fillMaxWidth()
    )
}

8.2 Navigation Compose로 화면 전환 구현하기 🧭

Compose에서는 Navigation Compose 라이브러리를 사용해 화면 전환을 구현할 수 있어. 먼저 의존성을 추가해야 해:

implementation "androidx.navigation:navigation-compose:2.7.0"

그리고 다음과 같이 네비게이션을 구현할 수 있어:

@Composable
fun AppNavigation() {
    val navController = rememberNavController()
    
    NavHost(
        navController = navController,
        startDestination = "home"
    ) {
        composable("home") {
            HomeScreen(
                onNavigateToDetail = { itemId ->
                    navController.navigate("detail/$itemId")
                }
            )
        }
        
        composable(
            route = "detail/{itemId}",
            arguments = listOf(navArgument("itemId") { type = NavType.IntType })
        ) { backStackEntry ->
            val itemId = backStackEntry.arguments?.getInt("itemId") ?: 0
            DetailScreen(
                itemId = itemId,
                onNavigateBack = {
                    navController.popBackStack()
                }
            )
        }
    }
}

이 예제에서는 NavHost를 사용해 네비게이션 그래프를 정의하고, composable 함수를 사용해 각 화면을 등록했어. 화면 간 이동은 navController.navigate()와 navController.popBackStack() 함수를 사용해 처리할 수 있지! 🧩

8.3 Compose에서 권한 처리하기 🔒

Compose에서도 권한 요청을 처리할 수 있어. rememberLauncherForActivityResult를 사용해 권한 요청 런처를 만들 수 있지:

@Composable
fun PermissionExample() {
    val context = LocalContext.current
    var hasPermission by remember {
        mutableStateOf(
            ContextCompat.checkSelfPermission(
                context,
                Manifest.permission.CAMERA
            ) == PackageManager.PERMISSION_GRANTED
        )
    }
    
    val launcher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestPermission()
    ) { isGranted ->
        hasPermission = isGranted
    }
    
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(
            text = if (hasPermission) "카메라 권한이 있습니다" else "카메라 권한이 필요합니다",
            fontSize = 18.sp
        )
        
        Spacer(modifier = Modifier.height(16.dp))
        
        if (!hasPermission) {
            Button(
                onClick = {
                    launcher.launch(Manifest.permission.CAMERA)
                }
            ) {
                Text("권한 요청")
            }
        } else {
            Button(
                onClick = {
                    // 카메라 기능 사용
                }
            ) {
                Text("카메라 사용")
            }
        }
    }
}

이 예제에서는 ActivityResultContracts.RequestPermission()를 사용해 카메라 권한을 요청하고, 권한 상태에 따라 다른 UI를 보여주고 있어. Compose에서도 기존 안드로이드의 권한 처리 방식과 유사하게 구현할 수 있지! 🔐

9. Compose 성능 최적화 ⚡

9.1 불필요한 재구성 방지하기 🛑

Compose에서는 상태가 변경될 때마다 UI가 재구성돼. 하지만 모든 컴포넌트가 재구성될 필요는 없지. remember를 사용해 계산 비용이 큰 작업의 결과를 캐싱하고, 상태를 적절히 분리해서 불필요한 재구성을 방지할 수 있어:

@Composable
fun OptimizedList(items: List<Item>) {
    LazyColumn {
        items(
            items = items,
            // key를 사용해 아이템 식별
            key = { item -> item.id }
        ) { item ->
            // 각 아이템을 별도의 컴포저블로 분리
            ItemRow(item)
        }
    }
}

@Composable
fun ItemRow(item: Item) {
    // 이 컴포넌트는 item이 변경될 때만 재구성됨
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp)
    ) {
        Text(text = item.title)
    }
}

9.2 derivedStateOf 사용하기 🔄

다른 상태에 의존하는 계산된 상태가 필요할 때는 derivedStateOf를 사용하면 좋아. 이렇게 하면 원본 상태가 변경될 때만 계산이 다시 이루어져:

@Composable
fun FilteredList(items: List<Item>) {
    var searchQuery by remember { mutableStateOf("") }
    
    // 검색어에 따라 필터링된 아이템 목록
    val filteredItems by remember(searchQuery, items) {
        derivedStateOf {
            items.filter { it.title.contains(searchQuery, ignoreCase = true) }
        }
    }
    
    Column {
        TextField(
            value = searchQuery,
            onValueChange = { searchQuery = it },
            label = { Text("검색") },
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp)
        )
        
        LazyColumn {
            items(filteredItems) { item ->
                Text(
                    text = item.title,
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(16.dp)
                )
            }
        }
    }
}

9.3 LaunchedEffect와 DisposableEffect 사용하기 🚀

부수 효과(side effect)를 처리할 때는 LaunchedEffect와 DisposableEffect를 사용하면 좋아:

@Composable
fun TimerExample() {
    var seconds by remember { mutableStateOf(0) }
    
    // 컴포지션이 시작될 때 타이머 시작
    LaunchedEffect(key1 = Unit) {
        while (true) {
            delay(1000)
            seconds++
        }
    }
    
    Text(
        text = "타이머: $seconds초",
        fontSize = 24.sp,
        modifier = Modifier.padding(16.dp)
    )
}

@Composable
fun NetworkListenerExample() {
    val context = LocalContext.current
    var isNetworkAvailable by remember { mutableStateOf(false) }
    
    // 네트워크 상태 리스너 등록 및 해제
    DisposableEffect(key1 = Unit) {
        val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
        
        val networkCallback = object : ConnectivityManager.NetworkCallback() {
            override fun onAvailable(network: Network) {
                isNetworkAvailable = true
            }
            
            override fun onLost(network: Network) {
                isNetworkAvailable = false
            }
        }
        
        connectivityManager.registerDefaultNetworkCallback(networkCallback)
        
        // 컴포지션이 종료될 때 리스너 해제
        onDispose {
            connectivityManager.unregisterNetworkCallback(networkCallback)
        }
    }
    
    Text(
        text = if (isNetworkAvailable) "네트워크 연결됨" else "네트워크 연결 안됨",
        color = if (isNetworkAvailable) Color.Green else Color.Red,
        fontSize = 18.sp,
        modifier = Modifier.padding(16.dp)
    )
}

LaunchedEffect는 컴포지션이 시작될 때 코루틴을 실행하고, DisposableEffect는 컴포지션이 종료될 때 정리 작업을 수행해. 이렇게 하면 메모리 누수를 방지하고 리소스를 효율적으로 관리할 수 있어! 🧹

10. Compose UI 테스트하기 🧪

10.1 테스트 의존성 추가하기 📚

Compose UI를 테스트하려면 다음 의존성을 추가해야 해:

// build.gradle
androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.5.0"
debugImplementation "androidx.compose.ui:ui-test-manifest:1.5.0"

10.2 간단한 UI 테스트 작성하기 ✅

Compose UI 테스트는 ComposeTestRule을 사용해 작성할 수 있어:

@RunWith(AndroidJUnit4::class)
class GreetingTest {

    @get:Rule
    val composeTestRule = createComposeRule()
    
    @Test
    fun greetingTest() {
        // UI 설정
        composeTestRule.setContent {
            MaterialTheme {
                Greeting("Compose")
            }
        }
        
        // 텍스트가 화면에 표시되는지 확인
        composeTestRule.onNodeWithText("안녕, Compose!").assertIsDisplayed()
    }
}

10.3 상호작용 테스트하기 👆

버튼 클릭 같은 사용자 상호작용도 테스트할 수 있어:

@Test
fun counterTest() {
    // UI 설정
    composeTestRule.setContent {
        MaterialTheme {
            CounterScreen()
        }
    }
    
    // 초기 상태 확인
    composeTestRule.onNodeWithText("카운트: 0").assertIsDisplayed()
    
    // 버튼 클릭
    composeTestRule.onNodeWithText("+").performClick()
    
    // 상태 변경 확인
    composeTestRule.onNodeWithText("카운트: 1").assertIsDisplayed()
}

이런 식으로 Compose UI의 동작을 자동화된 테스트로 검증할 수 있어. 테스트를 작성하면 앱의 품질을 높이고 버그를 미리 발견할 수 있지! 🐛

11. 마무리: Jetpack Compose의 미래 🔮

지금까지 Jetpack Compose의 기본부터 실제 앱 개발에 적용하는 방법까지 알아봤어. Compose는 안드로이드 UI 개발의 미래라고 할 수 있어. 구글은 계속해서 Compose에 투자하고 있고, 점점 더 많은 기업들이 Compose를 도입하고 있지.

Compose를 배우면 다음과 같은 이점이 있어:

  1. 생산성 향상: 더 적은 코드로 더 많은 기능을 구현할 수 있어.
  2. 유지보수 용이성: 선언적 UI는 이해하기 쉽고 디버깅하기 쉬워.
  3. 최신 기술 스택: 코틀린 코루틴, Flow 등 최신 기술과 잘 어울려.
  4. 취업 경쟁력: Compose 경험은 이제 많은 안드로이드 개발자 채용 공고에서 우대사항이 되고 있어.

재능넷 같은 플랫폼에서도 Jetpack Compose를 활용한 모바일 앱 개발 프로젝트가 늘어나고 있어. 이 기술을 익혀두면 더 다양한 프로젝트에 참여할 수 있는 기회가 생길 거야! 🌟

Compose는 계속 발전하고 있어서, 공식 문서와 커뮤니티를 통해 최신 정보를 얻는 것이 중요해. 구글의 공식 Compose 문서와 샘플 프로젝트를 참고하면 더 많은 정보를 얻을 수 있을 거야.

이제 Jetpack Compose로 멋진 안드로이드 앱을 만들어볼 시간이야! 화이팅! 💪

Jetpack Compose 안드로이드 UI의 미래
댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0
댓글을 불러오는 중입니다.

아직 댓글이 없습니다.