콘텐츠 대표 이미지 - 코틀린으로 시작하는 안드로이드 앱 개발, Jetpack Compose로 완성하는 최신 UI 디자인 🚀

코틀린으로 시작하는 안드로이드 앱 개발, Jetpack Compose로 완성하는 최신 UI 디자인 🚀

친구처럼 편하게 배우는 모던 안드로이드 개발의 모든 것

👋 안녕! 안드로이드 앱 개발에 관심 있는 친구야? 요즘 앱 개발 트렌드가 완전히 바뀌고 있어. 예전처럼 XML로 레이아웃 짜고, findViewById로 뷰 찾고 하던 시대는 이제 옛날 이야기가 됐어. 지금은 코틀린(Kotlin)과 Jetpack Compose가 대세지! 이 글에서는 코틀린 기반 안드로이드 앱 개발의 기초부터 최신 Compose UI 활용까지 친구에게 설명하듯 쉽고 재미있게 알려줄게. 📱✨

Kotlin Compose 안드로이드 앱 개발의 진화

🎯 왜 코틀린인가? 자바는 이제 안녕~

안드로이드 개발을 시작하려는 친구들이 가장 먼저 고민하는 게 바로 "자바로 할까, 코틀린으로 할까?"야. 결론부터 말하자면, 2024년 현재 코틀린이 압도적인 선택이야. 구글이 2019년에 코틀린을 안드로이드 개발의 공식 언어로 지정했고, 지금은 "Kotlin First" 정책을 펼치고 있거든.

코틀린의 매력 포인트 💜

✨ 1. 간결한 문법
자바로 10줄 짜야 할 코드를 코틀린은 3줄로 끝낼 수 있어. Null Safety, Data Class, Extension Function 같은 기능들이 개발자의 생산성을 엄청나게 높여줘.

자바 vs 코틀린 비교 예시

// 자바 코드
public class User {
    private String name;
    private int age;
    
    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public int getAge() { return age; }
    public void setAge(int age) { this.age = age; }
}

// 코틀린 코드
data class User(var name: String, var age: Int)

😲 봤어? 20줄이 1줄로! 이게 코틀린의 마법이야.

🛡️ 2. Null 안전성
자바에서 가장 악명 높은 NullPointerException을 코틀린은 컴파일 단계에서 잡아줘. 물음표(?) 하나로 nullable 타입을 명시하고, 엘비스 연산자(?:)로 기본값을 설정할 수 있어.

// Null 안전성 예시
var name: String = "홍길동"  // null 불가
var nickname: String? = null  // null 가능

val length = nickname?.length ?: 0  // null이면 0 반환
println(length)  // 0 출력

🚀 3. 코루틴(Coroutines)
비동기 프로그래밍을 동기 코드처럼 쉽게 작성할 수 있어. 콜백 지옥에서 벗어나 깔끔한 코드를 작성할 수 있지.

// 코루틴 예시
suspend fun fetchUserData(): User {
    return withContext(Dispatchers.IO) {
        // 네트워크 요청
        api.getUser()
    }
}

// 사용
lifecycleScope.launch {
    val user = fetchUserData()
    updateUI(user)
}

이런 장점들 때문에 요즘 신규 안드로이드 프로젝트의 95% 이상이 코틀린으로 개발되고 있어. 📊

🏗️ 안드로이드 개발 환경 세팅하기

자, 이제 본격적으로 개발 환경을 구축해보자! 걱정 마, 생각보다 어렵지 않아.

필수 준비물 체크리스트 ✅

항목 설명
Android Studio 구글이 제공하는 공식 IDE. 최신 버전(Hedgehog 이상) 다운로드 필수! IntelliJ 기반이라 정말 강력해.
JDK Java Development Kit. Android Studio 설치 시 함께 설치되지만, JDK 17 이상 권장.
Android SDK 안드로이드 개발 도구 모음. API Level 21(Android 5.0) 이상 설치 권장.
에뮬레이터 or 실기기 앱 테스트용. 에뮬레이터는 느릴 수 있으니 실제 안드로이드 폰이 있으면 더 좋아!

💡 프로 팁!
Android Studio 설치 후 첫 실행 시 "Standard" 설치 옵션을 선택하면 필요한 SDK와 에뮬레이터가 자동으로 설치돼. 초보자는 이 방법이 가장 편해!

첫 프로젝트 생성하기 🎨

Android Studio를 열고 "New Project"를 클릭하면 여러 템플릿이 나와. 여기서 중요한 선택이 있어:

Empty Activity vs Empty Compose Activity

📱 Empty Activity: 전통적인 XML 기반 UI
🎨 Empty Compose Activity: 최신 Jetpack Compose 기반 UI

우리는 당연히 Empty Compose Activity를 선택할 거야! 이게 바로 미래야. 🚀

프로젝트 설정에서 주의할 점:

Name: MyFirstApp (원하는 이름)
Package name: com.example.myfirstapp
Language: Kotlin (필수!)
Minimum SDK: API 21 (Android 5.0) 이상
Build configuration language: Kotlin DSL (build.gradle.kts)

프로젝트가 생성되면 Gradle 빌드가 자동으로 시작돼. 처음엔 시간이 좀 걸리니까 커피 한 잔 하고 와도 돼! ☕

🎨 Jetpack Compose, 게임 체인저의 등장!

자, 이제 진짜 핵심으로 들어가보자. Jetpack Compose는 2021년 7월에 정식 출시된 안드로이드의 최신 UI 툴킷이야. 이게 왜 혁명적이냐고?

XML 시대의 종말 📜➡️🎨

예전 안드로이드 개발은 이랬어:

전통적인 방식 (XML + Java/Kotlin)

<!-- activity_main.xml -->
<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">
    
    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World" />
        
    <Button
        android:id="@+id/button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Click Me" />
</LinearLayout>

// MainActivity.kt
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        
        val textView = findViewById<TextView>(R.id.textView)
        val button = findViewById<Button>(R.id.button)
        
        button.setOnClickListener {
            textView.text = "Button Clicked!"
        }
    }
}

XML 파일 따로, 코틀린 파일 따로... 왔다갔다 하면서 개발하는 게 정말 번거로웠어. 그런데 Compose는?

Jetpack Compose 방식

@Composable
fun MyScreen() {
    var text by remember { mutableStateOf("Hello World") }
    
    Column {
        Text(text = text)
        Button(onClick = { text = "Button Clicked!" }) {
            Text("Click Me")
        }
    }
}

🤯 와! 모든 게 코틀린 코드 안에서 해결돼! UI와 로직이 한 곳에!

Compose의 핵심 개념들 🧩

1️⃣ Composable 함수
@Composable 어노테이션이 붙은 함수가 UI를 그려. 마치 레고 블록처럼 작은 Composable들을 조합해서 복잡한 UI를 만들 수 있어.

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

@Composable
fun GreetingCard() {
    Card(
        modifier = Modifier.padding(16.dp),
        elevation = CardDefaults.cardElevation(4.dp)
    ) {
        Greeting(name = "코틀린")
    }
}

2️⃣ 선언형 UI (Declarative UI)
명령형(Imperative)이 아니라 선언형(Declarative)이야. "이렇게 해라"가 아니라 "이렇게 보여야 해"를 선언하는 거지.

명령형 vs 선언형

📝 명령형: "버튼을 찾아서, 텍스트를 변경하고, 색상을 바꿔"
✨ 선언형: "버튼은 이런 상태일 때 이렇게 보여"

@Composable
fun CounterButton() {
    var count by remember { mutableStateOf(0) }
    
    Button(
        onClick = { count++ },
        colors = ButtonDefaults.buttonColors(
            containerColor = if (count > 5) Color.Red else Color.Blue
        )
    ) {
        Text("클릭 횟수: $count")
    }
}
// count 값에 따라 UI가 자동으로 업데이트돼!

3️⃣ State와 Recomposition
Compose의 마법은 State 관리에 있어. State가 변경되면 해당 State를 사용하는 Composable만 자동으로 다시 그려져(Recomposition). 이게 진짜 효율적이야!

@Composable
fun LoginScreen() {
    var username by remember { mutableStateOf("") }
    var password by remember { mutableStateOf("") }
    
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.Center
    ) {
        TextField(
            value = username,
            onValueChange = { username = it },
            label = { Text("사용자명") }
        )
        
        Spacer(modifier = Modifier.height(8.dp))
        
        TextField(
            value = password,
            onValueChange = { password = it },
            label = { Text("비밀번호") },
            visualTransformation = PasswordVisualTransformation()
        )
        
        Spacer(modifier = Modifier.height(16.dp))
        
        Button(
            onClick = { /* 로그인 로직 */ },
            enabled = username.isNotEmpty() && password.isNotEmpty(),
            modifier = Modifier.fillMaxWidth()
        ) {
            Text("로그인")
        }
    }
}

이 코드에서 username이나 password가 변경될 때마다 Button의 enabled 상태가 자동으로 업데이트돼. 별도의 리스너나 업데이트 로직 없이! 🎯

Compose UI 구조 @Composable 함수 State remember mutable StateOf Layout Column Row Box Components Text Button TextField 상태 변경 → 자동 리컴포지션 → UI 업데이트

🛠️ Compose 핵심 컴포넌트 완전 정복

이제 실전에서 가장 많이 쓰이는 Compose 컴포넌트들을 하나씩 파헤쳐보자!

레이아웃 컴포넌트 📐

📦 Column - 세로 배치
자식 요소들을 세로로 쌓아. CSS의 flex-direction: column과 비슷해.

@Composable
fun VerticalLayout() {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.SpaceBetween,  // 공간 분배
        horizontalAlignment = Alignment.CenterHorizontally  // 가로 중앙 정렬
    ) {
        Text("첫 번째")
        Text("두 번째")
        Text("세 번째")
    }
}

↔️ Row - 가로 배치
자식 요소들을 가로로 나열해. flex-direction: row와 같아.

@Composable
fun HorizontalLayout() {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.SpaceEvenly,  // 균등 분배
        verticalAlignment = Alignment.CenterVertically
    ) {
        Icon(Icons.Default.Home, contentDescription = "홈")
        Icon(Icons.Default.Search, contentDescription = "검색")
        Icon(Icons.Default.Settings, contentDescription = "설정")
    }
}

📦 Box - 겹쳐서 배치
자식 요소들을 겹쳐서 배치할 수 있어. CSS의 position: absolute와 비슷한 느낌!

@Composable
fun OverlayLayout() {
    Box(
        modifier = Modifier.size(200.dp)
    ) {
        Image(
            painter = painterResource(id = R.drawable.background),
            contentDescription = "배경"
        )
        Text(
            text = "오버레이 텍스트",
            modifier = Modifier.align(Alignment.Center),
            color = Color.White
        )
    }
}

Material 3 컴포넌트 🎨

Compose는 구글의 최신 디자인 시스템인 Material 3(Material You)를 완벽하게 지원해. 이게 진짜 예쁘고 현대적이야!

Button 종류별 사용법

@Composable
fun ButtonShowcase() {
    Column(
        modifier = Modifier.padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        // 기본 Button
        Button(onClick = { /* 클릭 */ }) {
            Text("일반 버튼")
        }
        
        // Outlined Button
        OutlinedButton(onClick = { /* 클릭 */ }) {
            Text("아웃라인 버튼")
        }
        
        // Text Button
        TextButton(onClick = { /* 클릭 */ }) {
            Text("텍스트 버튼")
        }
        
        // Icon Button
        IconButton(onClick = { /* 클릭 */ }) {
            Icon(Icons.Default.Favorite, contentDescription = "좋아요")
        }
        
        // Floating Action Button
        FloatingActionButton(
            onClick = { /* 클릭 */ },
            containerColor = MaterialTheme.colorScheme.primary
        ) {
            Icon(Icons.Default.Add, contentDescription = "추가")
        }
    }
}

TextField - 입력 필드

@Composable
fun InputFields() {
    var text by remember { mutableStateOf("") }
    var searchQuery by remember { mutableStateOf("") }
    
    Column(modifier = Modifier.padding(16.dp)) {
        // 기본 TextField
        TextField(
            value = text,
            onValueChange = { text = it },
            label = { Text("이름을 입력하세요") },
            placeholder = { Text("홍길동") },
            leadingIcon = {
                Icon(Icons.Default.Person, contentDescription = null)
            }
        )
        
        Spacer(modifier = Modifier.height(16.dp))
        
        // OutlinedTextField (더 깔끔한 스타일)
        OutlinedTextField(
            value = searchQuery,
            onValueChange = { searchQuery = it },
            label = { Text("검색") },
            singleLine = true,
            trailingIcon = {
                if (searchQuery.isNotEmpty()) {
                    IconButton(onClick = { searchQuery = "" }) {
                        Icon(Icons.Default.Clear, contentDescription = "지우기")
                    }
                }
            }
        )
    }
}

Card - 카드 레이아웃

@Composable
fun ProductCard() {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp),
        elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
        colors = CardDefaults.cardColors(
            containerColor = MaterialTheme.colorScheme.surfaceVariant
        )
    ) {
        Column(
            modifier = Modifier.padding(16.dp)
        ) {
            Text(
                text = "상품명",
                style = MaterialTheme.typography.headlineSmall
            )
            Spacer(modifier = Modifier.height(8.dp))
            Text(
                text = "상품 설명이 들어갑니다.",
                style = MaterialTheme.typography.bodyMedium
            )
            Spacer(modifier = Modifier.height(16.dp))
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween
            ) {
                Text(
                    text = "₩29,900",
                    style = MaterialTheme.typography.titleLarge,
                    fontWeight = FontWeight.Bold
                )
                Button(onClick = { /* 구매 */ }) {
                    Text("구매하기")
                }
            }
        }
    }
}

리스트와 스크롤 📜

앱에서 가장 많이 쓰이는 게 리스트 표시지? Compose는 LazyColumn과 LazyRow로 효율적인 리스트를 만들 수 있어.

⚡ Lazy의 의미
"Lazy"는 게으르다는 뜻이 아니라 "필요할 때만 렌더링한다"는 의미야. 화면에 보이는 아이템만 그리기 때문에 수천 개의 아이템도 부드럽게 스크롤돼!

data class Message(val author: String, val body: String)

@Composable
fun MessageList(messages: List<Message>) {
    LazyColumn(
        modifier = Modifier.fillMaxSize(),
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        items(messages) { message ->
            MessageCard(message)
        }
    }
}

@Composable
fun MessageCard(message: Message) {
    Card(
        modifier = Modifier.fillMaxWidth()
    ) {
        Row(
            modifier = Modifier.padding(16.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Icon(
                Icons.Default.Person,
                contentDescription = null,
                modifier = Modifier.size(40.dp)
            )
            Spacer(modifier = Modifier.width(12.dp))
            Column {
                Text(
                    text = message.author,
                    style = MaterialTheme.typography.titleMedium,
                    fontWeight = FontWeight.Bold
                )
                Text(
                    text = message.body,
                    style = MaterialTheme.typography.bodyMedium
                )
            }
        }
    }
}

// 사용 예시
@Composable
fun ChatScreen() {
    val messages = remember {
        List(100) { index ->
            Message(
                author = "사용자 ${index + 1}",
                body = "메시지 내용 ${index + 1}"
            )
        }
    }
    MessageList(messages = messages)
}

LazyColumn은 RecyclerView를 대체하는 최신 방식이야. 코드가 훨씬 간결하고 이해하기 쉽지? 😊

🎭 State 관리의 예술

Compose에서 가장 중요한 개념이 바로 State 관리야. 이걸 제대로 이해하면 Compose의 90%를 마스터한 거라고 봐도 돼!

remember vs rememberSaveable 🧠

remember
Recomposition 동안 상태를 유지해. 하지만 화면 회전이나 프로세스 종료 시 사라져.

rememberSaveable
화면 회전이나 프로세스 종료 후에도 상태를 유지해. Bundle에 저장되는 방식이야.

@Composable
fun CounterScreen() {
    // 화면 회전 시 초기화됨
    var count1 by remember { mutableStateOf(0) }
    
    // 화면 회전 후에도 유지됨
    var count2 by rememberSaveable { mutableStateOf(0) }
    
    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("일반 카운터: $count1")
        Button(onClick = { count1++ }) {
            Text("증가")
        }
        
        Spacer(modifier = Modifier.height(32.dp))
        
        Text("저장되는 카운터: $count2")
        Button(onClick = { count2++ }) {
            Text("증가")
        }
    }
}

State Hoisting (상태 끌어올리기) ⬆️

Compose의 중요한 패턴 중 하나가 State Hoisting이야. 상태를 상위 Composable로 올려서 여러 자식 Composable이 공유하게 하는 거지.

💡 State Hoisting의 장점
1. 상태를 한 곳에서 관리 (Single Source of Truth)
2. 재사용 가능한 Composable 작성
3. 테스트하기 쉬운 코드

// ❌ 나쁜 예: 상태가 내부에 있음
@Composable
fun BadCounter() {
    var count by remember { mutableStateOf(0) }
    
    Button(onClick = { count++ }) {
        Text("카운트: $count")
    }
}

// ✅ 좋은 예: 상태를 외부에서 받음 (Stateless)
@Composable
fun GoodCounter(
    count: Int,
    onCountChange: (Int) -> Unit
) {
    Button(onClick = { onCountChange(count + 1) }) {
        Text("카운트: $count")
    }
}

// 사용
@Composable
fun CounterScreen() {
    var count by remember { mutableStateOf(0) }
    
    Column {
        Text("현재 카운트: $count")
        GoodCounter(
            count = count,
            onCountChange = { count = it }
        )
        // 다른 곳에서도 count를 사용 가능!
        if (count > 10) {
            Text("10번 이상 클릭했어요! 🎉")
        }
    }
}

ViewModel과 함께 사용하기 🏗️

실제 앱에서는 ViewModel과 함께 사용하는 게 베스트 프랙티스야. ViewModel은 화면 회전에도 살아남고, 비즈니스 로직을 분리할 수 있어.

// ViewModel
class CounterViewModel : ViewModel() {
    private val _count = MutableStateFlow(0)
    val count: StateFlow<Int> = _count.asStateFlow()
    
    fun increment() {
        _count.value++
    }
    
    fun decrement() {
        _count.value--
    }
}

// Composable
@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
    val count by viewModel.count.collectAsState()
    
    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(
            text = count.toString(),
            style = MaterialTheme.typography.displayLarge
        )
        
        Row(
            horizontalArrangement = Arrangement.spacedBy(16.dp)
        ) {
            Button(onClick = { viewModel.decrement() }) {
                Text("-")
            }
            Button(onClick = { viewModel.increment() }) {
                Text("+")
            }
        }
    }
}

🎯 실전 팁!
간단한 UI 상태는 remember로, 비즈니스 로직이 포함된 상태는 ViewModel로 관리하는 게 좋아. 예를 들어 TextField의 입력값은 remember, 서버에서 가져온 데이터는 ViewModel에!

🎨 테마와 스타일링

Compose는 Material 3 테마 시스템을 통해 일관된 디자인을 쉽게 적용할 수 있어. 다크 모드도 자동으로 지원하고!

Material 3 테마 설정 🌈

// ui/theme/Color.kt
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)

val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)

// ui/theme/Theme.kt
private val DarkColorScheme = darkColorScheme(
    primary = Purple80,
    secondary = PurpleGrey80,
    tertiary = Pink80
)

private val LightColorScheme = lightColorScheme(
    primary = Purple40,
    secondary = PurpleGrey40,
    tertiary = Pink40
)

@Composable
fun MyAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colorScheme = if (darkTheme) {
        DarkColorScheme
    } else {
        LightColorScheme
    }
    
    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography,
        content = content
    )
}

Modifier의 마법 ✨

Modifier는 Compose에서 스타일을 적용하는 핵심 도구야. 체이닝 방식으로 여러 스타일을 조합할 수 있어.

@Composable
fun StyledBox() {
    Box(
        modifier = Modifier
            .size(200.dp)  // 크기
            .background(
                brush = Brush.horizontalGradient(
                    colors = listOf(Color.Blue, Color.Cyan)
                )
            )  // 그라디언트 배경
            .border(2.dp, Color.White, RoundedCornerShape(16.dp))  // 테두리
            .padding(16.dp)  // 패딩
            .clickable { /* 클릭 */ }  // 클릭 가능
            .shadow(8.dp, RoundedCornerShape(16.dp))  // 그림자
    ) {
        Text(
            text = "스타일 박스",
            color = Color.White,
            modifier = Modifier.align(Alignment.Center)
        )
    }
}

⚠️ Modifier 순서 주의!
Modifier의 순서가 중요해! padding을 먼저 주고 background를 주면 패딩 영역에는 배경색이 안 들어가. background 먼저, padding 나중에!

// ❌ 패딩 영역에 배경색 없음
Modifier
    .padding(16.dp)
    .background(Color.Blue)

// ✅ 패딩 영역에도 배경색 있음
Modifier
    .background(Color.Blue)
    .padding(16.dp)

🚀 네비게이션과 화면 전환

앱은 보통 여러 화면으로 구성되잖아? Compose에서는 Navigation Compose 라이브러리로 화면 전환을 쉽게 구현할 수 있어.

Navigation Compose 설정 🗺️

// build.gradle.kts에 추가
dependencies {
    implementation("androidx.navigation:navigation-compose:2.7.6")
}

// 화면 정의
sealed class Screen(val route: String) {
    object Home : Screen("home")
    object Profile : Screen("profile/{userId}") {
        fun createRoute(userId: String) = "profile/$userId"
    }
    object Settings : Screen("settings")
}

@Composable
fun AppNavigation() {
    val navController = rememberNavController()
    
    NavHost(
        navController = navController,
        startDestination = Screen.Home.route
    ) {
        composable(Screen.Home.route) {
            HomeScreen(
                onNavigateToProfile = { userId ->
                    navController.navigate(Screen.Profile.createRoute(userId))
                }
            )
        }
        
        composable(
            route = Screen.Profile.route,
            arguments = listOf(
                navArgument("userId") { type = NavType.StringType }
            )
        ) { backStackEntry ->
            val userId = backStackEntry.arguments?.getString("userId")
            ProfileScreen(
                userId = userId,
                onNavigateBack = { navController.popBackStack() }
            )
        }
        
        composable(Screen.Settings.route) {
            SettingsScreen()
        }
    }
}

@Composable
fun HomeScreen(onNavigateToProfile: (String) -> Unit) {
    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("홈 화면", style = MaterialTheme.typography.headlineLarge)
        Spacer(modifier = Modifier.height(16.dp))
        Button(onClick = { onNavigateToProfile("user123") }) {
            Text("프로필 보기")
        }
    }
}

@Composable
fun ProfileScreen(userId: String?, onNavigateBack: () -> Unit) {
    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("프로필 화면", style = MaterialTheme.typography.headlineLarge)
        Text("사용자 ID: $userId")
        Spacer(modifier = Modifier.height(16.dp))
        Button(onClick = onNavigateBack) {
            Text("뒤로 가기")
        }
    }
}

Bottom Navigation 구현 📱

대부분의 앱에는 하단 네비게이션 바가 있지? 이것도 Compose로 쉽게 만들 수 있어.

@Composable
fun MainScreen() {
    val navController = rememberNavController()
    val items = listOf(
        BottomNavItem("홈", Icons.Default.Home, "home"),
        BottomNavItem("검색", Icons.Default.Search, "search"),
        BottomNavItem("알림", Icons.Default.Notifications, "notifications"),
        BottomNavItem("프로필", Icons.Default.Person, "profile")
    )
    
    Scaffold(
        bottomBar = {
            NavigationBar {
                val navBackStackEntry by navController.currentBackStackEntryAsState()
                val currentRoute = navBackStackEntry?.destination?.route
                
                items.forEach { item ->
                    NavigationBarItem(
                        icon = { Icon(item.icon, contentDescription = item.title) },
                        label = { Text(item.title) },
                        selected = currentRoute == item.route,
                        onClick = {
                            navController.navigate(item.route) {
                                popUpTo(navController.graph.findStartDestination().id) {
                                    saveState = true
                                }
                                launchSingleTop = true
                                restoreState = true
                            }
                        }
                    )
                }
            }
        }
    ) { paddingValues ->
        NavHost(
            navController = navController,
            startDestination = "home",
            modifier = Modifier.padding(paddingValues)
        ) {
            composable("home") { HomeContent() }
            composable("search") { SearchContent() }
            composable("notifications") { NotificationsContent() }
            composable("profile") { ProfileContent() }
        }
    }
}

data class BottomNavItem(
    val title: String,
    val icon: ImageVector,
    val route: String
)
Navigation 구조 NavHost 홈 화면 검색 화면 프로필 화면 NavController가 화면 전환을 관리합니다

🌐 네트워크 통신과 데이터 처리

실제 앱은 서버와 통신해야 하잖아? Retrofit과 Compose를 함께 사용하는 방법을 알아보자!

Retrofit 설정 🔌

// build.gradle.kts
dependencies {
    implementation("com.squareup.retrofit2:retrofit:2.9.0")
    implementation("com.squareup.retrofit2:converter-gson:2.9.0")
    implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
}

// API 인터페이스
interface ApiService {
    @GET("users/{id}")
    suspend fun getUser(@Path("id") userId: String): User
    
    @GET("posts")
    suspend fun getPosts(): List<Post>
    
    @POST("posts")
    suspend fun createPost(@Body post: Post): Post
}

// Retrofit 인스턴스
object RetrofitInstance {
    private const val BASE_URL = "https://api.example.com/"
    
    private val loggingInterceptor = HttpLoggingInterceptor().apply {
        level = HttpLoggingInterceptor.Level.BODY
    }
    
    private val client = OkHttpClient.Builder()
        .addInterceptor(loggingInterceptor)
        .build()
    
    val api: ApiService by lazy {
        Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(client)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(ApiService::class.java)
    }
}

// 데이터 클래스
data class User(
    val id: String,
    val name: String,
    val email: String,
    val avatar: String
)

data class Post(
    val id: String,
    val title: String,
    val content: String,
    val author: User
)

ViewModel에서 네트워크 요청 처리 📡

sealed class UiState<out T> {
    object Loading : UiState<Nothing>()
    data class Success<T>(val data: T) : UiState<T>()
    data class Error(val message: String) : UiState<Nothing>()
}

class PostsViewModel : ViewModel() {
    private val _postsState = MutableStateFlow<UiState<List<Post>>>(UiState.Loading)
    val postsState: StateFlow<UiState<List<Post>>> = _postsState.asStateFlow()
    
    init {
        loadPosts()
    }
    
    fun loadPosts() {
        viewModelScope.launch {
            _postsState.value = UiState.Loading
            try {
                val posts = RetrofitInstance.api.getPosts()
                _postsState.value = UiState.Success(posts)
            } catch (e: Exception) {
                _postsState.value = UiState.Error(
                    e.message ?: "알 수 없는 오류가 발생했습니다"
                )
            }
        }
    }
    
    fun createPost(title: String, content: String) {
        viewModelScope.launch {
            try {
                val newPost = Post(
                    id = "",
                    title = title,
                    content = content,
                    author = User("", "", "", "")
                )
                RetrofitInstance.api.createPost(newPost)
                loadPosts()  // 새로고침
            } catch (e: Exception) {
                _postsState.value = UiState.Error(e.message ?: "게시물 작성 실패")
            }
        }
    }
}

@Composable
fun PostsScreen(viewModel: PostsViewModel = viewModel()) {
    val postsState by viewModel.postsState.collectAsState()
    
    Column(modifier = Modifier.fillMaxSize()) {
        when (val state = postsState) {
            is UiState.Loading -> {
                Box(
                    modifier = Modifier.fillMaxSize(),
                    contentAlignment = Alignment.Center
                ) {
                    CircularProgressIndicator()
                }
            }
            
            is UiState.Success -> {
                LazyColumn(
                    contentPadding = PaddingValues(16.dp),
                    verticalArrangement = Arrangement.spacedBy(12.dp)
                ) {
                    items(state.data) { post ->
                        PostCard(post)
                    }
                }
            }
            
            is UiState.Error -> {
                Box(
                    modifier = Modifier.fillMaxSize(),
                    contentAlignment = Alignment.Center
                ) {
                    Column(
                        horizontalAlignment = Alignment.CenterHorizontally
                    ) {
                        Text(
                            text = "오류 발생",
                            style = MaterialTheme.typography.headlineSmall
                        )
                        Text(text = state.message)
                        Spacer(modifier = Modifier.height(16.dp))
                        Button(onClick = { viewModel.loadPosts() }) {
                            Text("다시 시도")
                        }
                    }
                }
            }
        }
    }
}

@Composable
fun PostCard(post: Post) {
    Card(
        modifier = Modifier.fillMaxWidth(),
        elevation = CardDefaults.cardElevation(2.dp)
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Text(
                text = post.title,
                style = MaterialTheme.typography.titleLarge,
                fontWeight = FontWeight.Bold
            )
            Spacer(modifier = Modifier.height(8.dp))
            Text(
                text = post.content,
                style = MaterialTheme.typography.bodyMedium,
                maxLines = 3,
                overflow = TextOverflow.Ellipsis
            )
            Spacer(modifier = Modifier.height(8.dp))
            Row(verticalAlignment = Alignment.CenterVertically) {
                Icon(
                    Icons.Default.Person,
                    contentDescription = null,
                    modifier = Modifier.size(20.dp)
                )
                Spacer(modifier = Modifier.width(4.dp))
                Text(
                    text = post.author.name,
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
        }
    }
}

🎯 실전 패턴!
UiState 패턴을 사용하면 로딩, 성공, 에러 상태를 명확하게 관리할 수 있어. 사용자 경험도 좋아지고 코드도 깔끔해져!

💾 로컬 데이터 저장 - Room Database

앱 내부에 데이터를 저장해야 할 때는 Room을 사용해. SQLite를 쉽게 사용할 수 있게 해주는 라이브러리야.

Room 설정 및 사용 🗄️

// build.gradle.kts
dependencies {
    implementation("androidx.room:room-runtime:2.6.1")
    implementation("androidx.room:room-ktx:2.6.1")
    ksp("androidx.room:room-compiler:2.6.1")
}

// Entity (테이블)
@Entity(tableName = "notes")
data class Note(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    val title: String,
    val content: String,
    val timestamp: Long = System.currentTimeMillis()
)

// DAO (Data Access Object)
@Dao
interface NoteDao {
    @Query("SELECT * FROM notes ORDER BY timestamp DESC")
    fun getAllNotes(): Flow<List<Note>>
    
    @Query("SELECT * FROM notes WHERE id = :id")
    suspend fun getNoteById(id: Int): Note?
    
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertNote(note: Note)
    
    @Update
    suspend fun updateNote(note: Note)
    
    @Delete
    suspend fun deleteNote(note: Note)
    
    @Query("DELETE FROM notes")
    suspend fun deleteAllNotes()
}

// Database
@Database(entities = [Note::class], version = 1, exportSchema = false)
abstract class NoteDatabase : RoomDatabase() {
    abstract fun noteDao(): NoteDao
    
    companion object {
        @Volatile
        private var INSTANCE: NoteDatabase? = null
        
        fun getDatabase(context: Context): NoteDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    NoteDatabase::class.java,
                    "note_database"
                ).build()
                INSTANCE = instance
                instance
            }
        }
    }
}

// Repository
class NoteRepository(private val noteDao: NoteDao) {
    val allNotes: Flow<List<Note>> = noteDao.getAllNotes()
    
    suspend fun insert(note: Note) {
        noteDao.insertNote(note)
    }
    
    suspend fun update(note: Note) {
        noteDao.updateNote(note)
    }
    
    suspend fun delete(note: Note) {
        noteDao.deleteNote(note)
    }
}

// ViewModel
class NoteViewModel(private val repository: NoteRepository) : ViewModel() {
    val allNotes: StateFlow<List<Note>> = repository.allNotes
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = emptyList()
        )
    
    fun addNote(title: String, content: String) {
        viewModelScope.launch {
            val note = Note(title = title, content = content)
            repository.insert(note)
        }
    }
    
    fun deleteNote(note: Note) {
        viewModelScope.launch {
            repository.delete(note)
        }
    }
}

// Compose UI
@Composable
fun NotesScreen(viewModel: NoteViewModel) {
    val notes by viewModel.allNotes.collectAsState()
    var showDialog by remember { mutableStateOf(false) }
    
    Scaffold(
        floatingActionButton = {
            FloatingActionButton(onClick = { showDialog = true }) {
                Icon(Icons.Default.Add, contentDescription = "노트 추가")
            }
        }
    ) { paddingValues ->
        if (notes.isEmpty()) {
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(paddingValues),
                contentAlignment = Alignment.Center
            ) {
                Column(horizontalAlignment = Alignment.CenterHorizontally) {
                    Icon(
                        Icons.Default.Edit,
                        contentDescription = null,
                        modifier = Modifier.size(64.dp),
                        tint = MaterialTheme.colorScheme.onSurfaceVariant
                    )
                    Spacer(modifier = Modifier.height(16.dp))
                    Text(
                        text = "노트가 없습니다",
                        style = MaterialTheme.typography.bodyLarge,
                        color = MaterialTheme.colorScheme.onSurfaceVariant
                    )
                }
            }
        } else {
            LazyColumn(
                modifier = Modifier.padding(paddingValues),
                contentPadding = PaddingValues(16.dp),
                verticalArrangement = Arrangement.spacedBy(12.dp)
            ) {
                items(notes, key = { it.id }) { note ->
                    NoteItem(
                        note = note,
                        onDelete = { viewModel.deleteNote(note) }
                    )
                }
            }
        }
    }
    
    if (showDialog) {
        AddNoteDialog(
            onDismiss = { showDialog = false },
            onConfirm = { title, content ->
                viewModel.addNote(title, content)
                showDialog = false
            }
        )
    }
}

@Composable
fun NoteItem(note: Note, onDelete: () -> Unit) {
    var showDeleteDialog by remember { mutableStateOf(false) }
    
    Card(
        modifier = Modifier.fillMaxWidth(),
        onClick = { /* 편집 화면으로 이동 */ }
    ) {
        Row(
            modifier = Modifier.padding(16.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Column(modifier = Modifier.weight(1f)) {
                Text(
                    text = note.title,
                    style = MaterialTheme.typography.titleMedium,
                    fontWeight = FontWeight.Bold
                )
                Spacer(modifier = Modifier.height(4.dp))
                Text(
                    text = note.content,
                    style = MaterialTheme.typography.bodyMedium,
                    maxLines = 2,
                    overflow = TextOverflow.Ellipsis
                )
            }
            IconButton(onClick = { showDeleteDialog = true }) {
                Icon(Icons.Default.Delete, contentDescription = "삭제")
            }
        }
    }
    
    if (showDeleteDialog) {
        AlertDialog(
            onDismissRequest = { showDeleteDialog = false },
            title = { Text("노트 삭제") },
            text = { Text("이 노트를 삭제하시겠습니까?") },
            confirmButton = {
                TextButton(onClick = {
                    onDelete()
                    showDeleteDialog = false
                }) {
                    Text("삭제")
                }
            },
            dismissButton = {
                TextButton(onClick = { showDeleteDialog = false }) {
                    Text("취소")
                }
            }
        )
    }
}

🎬 애니메이션으로 생동감 더하기

Compose의 애니메이션 API는 정말 강력해! 몇 줄의 코드로 부드러운 애니메이션을 만들 수 있어.

기본 애니메이션 🌟

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

@Composable
fun FadeInAnimation() {
    var visible by remember { mutableStateOf(false) }
    
    LaunchedEffect(Unit) {
        visible = true
    }
    
    AnimatedVisibility(
        visible = visible,
        enter = fadeIn(animationSpec = tween(1000)) + 
                slideInVertically(initialOffsetY = { -it }),
        exit = fadeOut() + slideOutVertically()
    ) {
        Card(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp)
        ) {
            Text(
                text = "안녕하세요! 👋",
                modifier = Modifier.padding(16.dp),
                style = MaterialTheme.typography.headlineMedium
            )
        }
    }
}

리스트 아이템 애니메이션 📋

@Composable
fun AnimatedList() {
    var items by remember { 
        mutableStateOf(listOf("아이템 1", "아이템 2", "아이템 3")) 
    }
    
    Column {
        Button(
            onClick = {
                items = items + "아이템 ${items.size + 1}"
            },
            modifier = Modifier.padding(16.dp)
        ) {
            Text("아이템 추가")
        }
        
        LazyColumn {
            items(
                items = items,
                key = { it }
            ) { item ->
                AnimatedListItem(
                    text = item,
                    onDelete = { items = items - item }
                )
            }
        }
    }
}

@Composable
fun AnimatedListItem(text: String, onDelete: () -> Unit) {
    var visible by remember { mutableStateOf(false) }
    
    LaunchedEffect(Unit) {
        visible = true
    }
    
    AnimatedVisibility(
        visible = visible,
        enter = expandVertically() + fadeIn(),
        exit = shrinkVertically() + fadeOut()
    ) {
        Card(
            modifier = Modifier
                .fillMaxWidth()
                .padding(horizontal = 16.dp, vertical = 8.dp)
        ) {
            Row(
                modifier = Modifier.padding(16.dp),
                verticalAlignment = Alignment.CenterVertically
            ) {
                Text(
                    text = text,
                    modifier = Modifier.weight(1f)
                )
                IconButton(onClick = {
                    visible = false
                    // 애니메이션 완료 후 삭제
                    kotlinx.coroutines.GlobalScope.launch {
                        delay(300)
                        onDelete()
                    }
                }) {
                    Icon(Icons.Default.Delete, contentDescription = "삭제")
                }
            }
        }
    }
}

✨ 애니메이션 팁!
1. spring(): 자연스러운 물리 기반 애니메이션
2. tween(): 시간 기반 선형 애니메이션
3. AnimatedVisibility: 나타나고 사라지는 애니메이션
4. Crossfade: 화면 전환 애니메이션

🔥 실전 프로젝트: 간단한 할 일 앱 만들기

지금까지 배운 내용을 종합해서 실제 동작하는 할 일 관리 앱을 만들어보자! 이 앱에는 CRUD(생성, 읽기, 수정, 삭제) 기능이 모두 들어가 있어.

완성된 Todo 앱 구조 📱

// 데이터 모델
@Entity(tableName = "todos")
data class Todo(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    val title: String,
    val description: String,
    val isCompleted: Boolean = false,
    val priority: Priority = Priority.MEDIUM,
    val createdAt: Long = System.currentTimeMillis()
)

enum class Priority {
    LOW, MEDIUM, HIGH
}

// DAO
@Dao
interface TodoDao {
    @Query("SELECT * FROM todos ORDER BY priority DESC, createdAt DESC")
    fun getAllTodos(): Flow<List<Todo>>
    
    @Query("SELECT * FROM todos WHERE isCompleted = 0")
    fun getActiveTodos(): Flow<List<Todo>>
    
    @Query("SELECT * FROM todos WHERE isCompleted = 1")
    fun getCompletedTodos(): Flow<List<Todo>>
    
    @Insert
    suspend fun insertTodo(todo: Todo)
    
    @Update
    suspend fun updateTodo(todo: Todo)
    
    @Delete
    suspend fun deleteTodo(todo: Todo)
}

// ViewModel
class TodoViewModel(private val dao: TodoDao) : ViewModel() {
    private val _filter = MutableStateFlow(TodoFilter.ALL)
    val filter: StateFlow<TodoFilter> = _filter.asStateFlow()
    
    val todos: StateFlow<List<Todo>> = _filter.flatMapLatest { filter ->
        when (filter) {
            TodoFilter.ALL -> dao.getAllTodos()
            TodoFilter.ACTIVE -> dao.getActiveTodos()
            TodoFilter.COMPLETED -> dao.getCompletedTodos()
        }
    }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = emptyList()
    )
    
    fun setFilter(filter: TodoFilter) {
        _filter.value = filter
    }
    
    fun addTodo(title: String, description: String, priority: Priority) {
        viewModelScope.launch {
            val todo = Todo(
                title = title,
                description = description,
                priority = priority
            )
            dao.insertTodo(todo)
        }
    }
    
    fun toggleTodo(todo: Todo) {
        viewModelScope.launch {
            dao.updateTodo(todo.copy(isCompleted = !todo.isCompleted))
        }
    }
    
    fun deleteTodo(todo: Todo) {
        viewModelScope.launch {
            dao.deleteTodo(todo)
        }
    }
}

enum class TodoFilter {
    ALL, ACTIVE, COMPLETED
}

// 메인 화면
@Composable
fun TodoScreen(viewModel: TodoViewModel) {
    val todos by viewModel.todos.collectAsState()
    val filter by viewModel.filter.collectAsState()
    var showAddDialog by remember { mutableStateOf(false) }
    
    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("할 일 관리") },
                colors = TopAppBarDefaults.topAppBarColors(
                    containerColor = MaterialTheme.colorScheme.primaryContainer
                )
            )
        },
        floatingActionButton = {
            FloatingActionButton(
                onClick = { showAddDialog = true },
                containerColor = MaterialTheme.colorScheme.primary
            ) {
                Icon(Icons.Default.Add, contentDescription = "할 일 추가")
            }
        }
    ) { paddingValues ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)
        ) {
            // 필터 탭
            FilterTabs(
                selectedFilter = filter,
                onFilterSelected = { viewModel.setFilter(it) }
            )
            
            // 할 일 리스트
            if (todos.isEmpty()) {
                EmptyState(filter = filter)
            } else {
                TodoList(
                    todos = todos,
                    onToggle = { viewModel.toggleTodo(it) },
                    onDelete = { viewModel.deleteTodo(it) }
                )
            }
        }
    }
    
    if (showAddDialog) {
        AddTodoDialog(
            onDismiss = { showAddDialog = false },
            onConfirm = { title, description, priority ->
                viewModel.addTodo(title, description, priority)
                showAddDialog = false
            }
        )
    }
}

@Composable
fun FilterTabs(
    selectedFilter: TodoFilter,
    onFilterSelected: (TodoFilter) -> Unit
) {
    TabRow(
        selectedTabIndex = selectedFilter.ordinal,
        containerColor = MaterialTheme.colorScheme.surface
    ) {
        TodoFilter.values().forEach { filter ->
            Tab(
                selected = selectedFilter == filter,
                onClick = { onFilterSelected(filter) },
                text = {
                    Text(
                        text = when (filter) {
                            TodoFilter.ALL -> "전체"
                            TodoFilter.ACTIVE -> "진행중"
                            TodoFilter.COMPLETED -> "완료"
                        }
                    )
                }
            )
        }
    }
}

@Composable
fun TodoList(
    todos: List<Todo>,
    onToggle: (Todo) -> Unit,
    onDelete: (Todo) -> Unit
) {
    LazyColumn(
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp)
    ) {
        items(todos, key = { it.id }) { todo ->
            TodoItem(
                todo = todo,
                onToggle = { onToggle(todo) },
                onDelete = { onDelete(todo) }
            )
        }
    }
}

@Composable
fun TodoItem(
    todo: Todo,
    onToggle: () -> Unit,
    onDelete: () -> Unit
) {
    var showDeleteDialog by remember { mutableStateOf(false) }
    
    Card(
        modifier = Modifier.fillMaxWidth(),
        colors = CardDefaults.cardColors(
            containerColor = when (todo.priority) {
                Priority.HIGH -> MaterialTheme.colorScheme.errorContainer
                Priority.MEDIUM -> MaterialTheme.colorScheme.secondaryContainer
                Priority.LOW -> MaterialTheme.colorScheme.tertiaryContainer
            }
        )
    ) {
        Row(
            modifier = Modifier.padding(16.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Checkbox(
                checked = todo.isCompleted,
                onCheckedChange = { onToggle() }
            )
            
            Spacer(modifier = Modifier.width(12.dp))
            
            Column(modifier = Modifier.weight(1f)) {
                Text(
                    text = todo.title,
                    style = MaterialTheme.typography.titleMedium,
                    fontWeight = FontWeight.Bold,
                    textDecoration = if (todo.isCompleted) {
                        TextDecoration.LineThrough
                    } else null
                )
                if (todo.description.isNotEmpty()) {
                    Spacer(modifier = Modifier.height(4.dp))
                    Text(
                        text = todo.description,
                        style = MaterialTheme.typography.bodyMedium,
                        maxLines = 2,
                        overflow = TextOverflow.Ellipsis
                    )
                }
                Spacer(modifier = Modifier.height(4.dp))
                Text(
                    text = when (todo.priority) {
                        Priority.HIGH -> "🔴 높음"
                        Priority.MEDIUM -> "🟡 보통"
                        Priority.LOW -> "🟢 낮음"
                    },
                    style = MaterialTheme.typography.labelSmall
                )
            }
            
            IconButton(onClick = { showDeleteDialog = true }) {
                Icon(
                    Icons.Default.Delete,
                    contentDescription = "삭제",
                    tint = MaterialTheme.colorScheme.error
                )
            }
        }
    }
    
    if (showDeleteDialog) {
        AlertDialog(
            onDismissRequest = { showDeleteDialog = false },
            title = { Text("할 일 삭제") },
            text = { Text("'${todo.title}'을(를) 삭제하시겠습니까?") },
            confirmButton = {
                TextButton(
                    onClick = {
                        onDelete()
                        showDeleteDialog = false
                    }
                ) {
                    Text("삭제")
                }
            },
            dismissButton = {
                TextButton(onClick = { showDeleteDialog = false }) {
                    Text("취소")
                }
            }
        )
    }
}

@Composable
fun EmptyState(filter: TodoFilter) {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Column(
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center
        ) {
            Icon(
                imageVector = when (filter) {
                    TodoFilter.ALL -> Icons.Default.CheckCircle
                    TodoFilter.ACTIVE -> Icons.Default.PlayArrow
                    TodoFilter.COMPLETED -> Icons.Default.Done
                },
                contentDescription = null,
                modifier = Modifier.size(80.dp),
                tint = MaterialTheme.colorScheme.onSurfaceVariant
            )
            Spacer(modifier = Modifier.height(16.dp))
            Text(
                text = when (filter) {
                    TodoFilter.ALL -> "할 일이 없습니다"
                    TodoFilter.ACTIVE -> "진행중인 할 일이 없습니다"
                    TodoFilter.COMPLETED -> "완료된 할 일이 없습니다"
                },
                style = MaterialTheme.typography.titleMedium,
                color = MaterialTheme.colorScheme.onSurfaceVariant
            )
            Spacer(modifier = Modifier.height(8.dp))
            Text(
                text = "+ 버튼을 눌러 새로운 할 일을 추가하세요",
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onSurfaceVariant
            )
        }
    }
}

@Composable
fun AddTodoDialog(
    onDismiss: () -> Unit,
    onConfirm: (String, String, Priority) -> Unit
) {
    var title by remember { mutableStateOf("") }
    var description by remember { mutableStateOf("") }
    var priority by remember { mutableStateOf(Priority.MEDIUM) }
    
    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text("새 할 일 추가") },
        text = {
            Column {
                OutlinedTextField(
                    value = title,
                    onValueChange = { title = it },
                    label = { Text("제목") },
                    singleLine = true,
                    modifier = Modifier.fillMaxWidth()
                )
                
                Spacer(modifier = Modifier.height(8.dp))
                
                OutlinedTextField(
                    value = description,
                    onValueChange = { description = it },
                    label = { Text("설명 (선택)") },
                    maxLines = 3,
                    modifier = Modifier.fillMaxWidth()
                )
                
                Spacer(modifier = Modifier.height(16.dp))
                
                Text("우선순위", style = MaterialTheme.typography.labelMedium)
                Spacer(modifier = Modifier.height(8.dp))
                
                Row(
                    modifier = Modifier.fillMaxWidth(),
                    horizontalArrangement = Arrangement.SpaceEvenly
                ) {
                    Priority.values().forEach { p ->
                        FilterChip(
                            selected = priority == p,
                            onClick = { priority = p },
                            label = {
                                Text(
                                    when (p) {
                                        Priority.HIGH -> "높음"
                                        Priority.MEDIUM -> "보통"
                                        Priority.LOW -> "낮음"
                                    }
                                )
                            }
                        )
                    }
                }
            }
        },
        confirmButton = {
            TextButton(
                onClick = {
                    if (title.isNotBlank()) {
                        onConfirm(title, description, priority)
                    }
                },
                enabled = title.isNotBlank()
            ) {
                Text("추가")
            }
        },
        dismissButton = {
            TextButton(onClick = onDismiss) {
                Text("취소")
            }
        }
    )
}

이 Todo 앱에는 우리가 배운 거의 모든 개념이 들어가 있어:
✅ Jetpack Compose UI
✅ Room Database
✅ ViewModel & StateFlow
✅ Material 3 디자인
✅ 다이얼로그와 상태 관리
✅ 리스트와 필터링

🚀 확장 아이디어!
이 앱을 더 발전시킬 수 있는 방법들:
1. 마감일 추가 기능
2. 카테고리별 분류
3. 검색 기능
4. 위젯 지원
5. 알림 기능
6. 클라우드 동기화

🎓 더 나아가기 - 고급 주제들

기본을 마스터했다면 이제 더 깊이 들어가볼 차례야!

의존성 주입 - Hilt 🔌

Hilt는 안드로이드에서 의존성 주입(DI)을 쉽게 해주는 라이브러리야. 코드의 결합도를 낮추고 테스트하기 쉽게 만들어줘.

// build.gradle.kts
plugins {
    id("com.google.dagger.hilt.android")
    id("com.google.devtools.ksp")
}

dependencies {
    implementation("com.google.dagger:hilt-android:2.48")
    ksp("com.google.dagger:hilt-compiler:2.48")
    implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
}

// Application 클래스
@HiltAndroidApp
class TodoApplication : Application()

// Module
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext context: Context): TodoDatabase {
        return Room.databaseBuilder(
            context,
            TodoDatabase::class.java,
            "todo_database"
        ).build()
    }
    
    @Provides
    fun provideTodoDao(database: TodoDatabase): TodoDao {
        return database.todoDao()
    }
}

// ViewModel with Hilt
@HiltViewModel
class TodoViewModel @Inject constructor(
    private val dao: TodoDao
) : ViewModel() {
    // ViewModel 코드...
}

// Compose에서 사용
@Composable
fun TodoScreen(viewModel: TodoViewModel = hiltViewModel()) {
    // UI 코드...
}

이미지 로딩 - Coil 🖼️

네트워크에서 이미지를 로드할 때는 Coil 라이브러리가 최고야. Compose와 완벽하게 통합돼 있어.

// build.gradle.kts
dependencies {
    implementation("io.coil-kt:coil-compose:2.5.0")
}

@Composable
fun ProfileImage(imageUrl: String) {
    AsyncImage(
        model = ImageRequest.Builder(LocalContext.current)
            .data(imageUrl)
            .crossfade(true)
            .build(),
        placeholder = painterResource(R.drawable.placeholder),
        error = painterResource(R.drawable.error),
        contentDescription = "프로필 이미지",
        contentScale = ContentScale.Crop,
        modifier = Modifier
            .size(100.dp)
            .clip(CircleShape)
    )
}

권한 요청 - Accompanist Permissions 🔐

// build.gradle.kts
dependencies {
    implementation("com.google.accompanist:accompanist-permissions:0.32.0")
}

@Composable
fun CameraScreen() {
    val cameraPermissionState = rememberPermissionState(
        android.Manifest.permission.CAMERA
    )
    
    when {
        cameraPermissionState.status.isGranted -> {
            // 권한이 허용됨
            CameraPreview()
        }
        cameraPermissionState.status.shouldShowRationale -> {
            // 권한 설명 필요
            Column {
                Text("카메라 권한이 필요합니다")
                Button(onClick = { cameraPermissionState.launchPermissionRequest() }) {
                    Text("권한 요청")
                }
            }
        }
        else -> {
            // 권한 요청
            LaunchedEffect(Unit) {
                cameraPermissionState.launchPermissionRequest()
            }
        }
    }
}

💡 실전 팁과 베스트 프랙티스

실제 프로젝트에서 유용한 팁들을 정리해봤어!

1️⃣ Preview를 적극 활용하자
Compose의 @Preview 어노테이션으로 실시간으로 UI를 확인할 수 있어. 에뮬레이터 없이도 빠르게 개발 가능!

@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun TodoItemPreview() {
    MyAppTheme {
        TodoItem(
            todo = Todo(
                id = 1,
                title = "Compose 공부하기",
                description = "Jetpack Compose 마스터하기",
                priority = Priority.HIGH
            ),
            onToggle = {},
            onDelete = {}
        )
    }
}

2️⃣ remember를 현명하게 사용하자
불필요한 remember는 메모리 낭비야. 정말 필요한 상태만 remember로 관리하고, 단순 계산은 매번 다시 해도 돼.

// ❌ 불필요한 remember
@Composable
fun BadExample(count: Int) {
    val doubled = remember { count * 2 }  // 불필요!
    Text("$doubled")
}

// ✅ 좋은 예
@Composable
fun GoodExample(count: Int) {
    val doubled = count * 2  // 간단한 계산은 그냥 해도 OK
    Text("$doubled")
}

3️⃣ LaunchedEffect vs SideEffect
- LaunchedEffect: 코루틴이 필요한 비동기 작업
- SideEffect: Compose 외부 상태 업데이트
- DisposableEffect: 리소스 정리가 필요한 경우

@Composable
fun TimerScreen() {
    var seconds by remember { mutableStateOf(0) }
    
    // LaunchedEffect: 비동기 작업
    LaunchedEffect(Unit) {
        while (true) {
            delay(1000)
            seconds++
        }
    }
    
    Text("경과 시간: $seconds초")
}

@Composable
fun AnalyticsScreen(screenName: String) {
    // SideEffect: Compose 외부 상태 업데이트
    SideEffect {
        Analytics.logScreenView(screenName)
    }
}

@Composable
fun MediaPlayerScreen() {
    val player = remember { MediaPlayer() }
    
    // DisposableEffect: 리소스 정리
    DisposableEffect(Unit) {
        onDispose {
            player.release()
        }
    }
}

4️⃣ 성능 최적화
- derivedStateOf: 계산 비용이 큰 상태 변환
- key: LazyColumn 아이템에 고유 키 지정
- Modifier.drawBehind: 커스텀 그리기 최적화

@Composable
fun OptimizedList(items: List<Item>) {
    // derivedStateOf로 불필요한 재계산 방지
    val expensiveItems by remember {
        derivedStateOf {
            items.filter { it.price > 10000 }
                .sortedByDescending { it.price }
        }
    }
    
    LazyColumn {
        items(
            items = expensiveItems,
            key = { it.id }  // 고유 키로 재사용 최적화
        ) { item ->
            ItemCard(item)
        }
    }
}

5️⃣ 테스트 작성하기
Compose는 테스트하기 정말 쉬워! UI 테스트를 작성하는 습관을 들이자.

// build.gradle.kts
dependencies {
    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
    debugImplementation("androidx.compose.ui:ui-test-manifest")
}

// 테스트 코드
@get:Rule
val composeTestRule = createComposeRule()

@Test
fun todoItem_whenClicked_togglesCompletion() {
    var isCompleted = false
    
    composeTestRule.setContent {
        TodoItem(
            todo = Todo(title = "테스트", isCompleted = isCompleted),
            onToggle = { isCompleted = !isCompleted },
            onDelete = {}
        )
    }
    
    // 체크박스 클릭
    composeTestRule.onNodeWithText("테스트").performClick()
    
    // 상태 확인
    assert(isCompleted)
}

🌟 재능넷에서 더 배우기

여기까지 읽었다면 정말 대단해! 코틀린과 Jetpack Compose의 기초부터 실전 활용까지 모두 다뤘어. 하지만 개발은 끝없는 학습의 연속이야. 📚

혼자 공부하다 막히는 부분이 있거나, 실제 프로젝트에 적용하면서 어려움을 겪는다면 재능넷(https://www.jaenung.net)을 활용해봐! 재능넷에는 안드로이드 개발 전문가들이 많이 활동하고 있어서, 1:1 멘토링이나 코드 리뷰를 받을 수 있어. 실무 경험이 풍부한 개발자들에게 직접 배우면 훨씬 빠르게 성장할 수 있지.

🎯 재능넷 활용 팁!
1. 멘토링: 프로젝트 진행 중 막히는 부분을 전문가에게 질문
2. 코드 리뷰: 내가 작성한 코드의 개선점 찾기
3. 포트폴리오 제작: 취업을 위한 앱 개발 도움받기
4. 최신 트렌드: 실무에서 사용하는 최신 기술 배우기

계속 성장하는 개발자 되기 🚀

안드로이드 개발은 계속 진화하고 있어. Compose도 계속 업데이트되면서 새로운 기능들이 추가되고 있지. 다음 단계로 나아가기 위한 학습 로드맵을 제시해줄게:

단계 학습 내용
초급 (1-3개월) 코틀린 문법, Compose 기본, 간단한 UI 구현, Room 데이터베이스
중급 (3-6개월) MVVM 아키텍처, Retrofit 네트워킹, Hilt DI, Navigation, 상태 관리
고급 (6-12개월) Clean Architecture, 멀티 모듈, 테스트 작성, CI/CD, 성능 최적화
전문가 (1년+) 커스텀 컴포넌트, 애니메이션 고급, 보안, 대규모 앱 설계

각 단계마다 실제 프로젝트를 만들어보는 게 중요해. 간단한 앱부터 시작해서 점점 복잡한 기능을 추가해가면서 실력을 쌓아가자!

📱 프로젝트 아이디어
1. 초급: 계산기, 메모장, 타이머
2. 중급: 날씨 앱, 뉴스 리더, 할 일 관리
3. 고급: SNS 클론, 쇼핑몰 앱, 음악 플레이어
4. 전문가: 실시간 채팅, 지도 기반 서비스, 금융 앱

🎬 마무리하며

와! 여기까지 정말 긴 여정이었어. 코틀린 기반 안드로이드 앱 개발과 Jetpack Compose에 대해 정말 많은 걸 다뤘지? 🎉

처음에는 어렵게 느껴질 수 있어. XML에서 Compose로 넘어오는 게 낯설 수도 있고, 선언형 UI 개념이 헷갈릴 수도 있어. 하지만 걱정하지 마! 조금씩 연습하다 보면 어느새 Compose가 훨씬 편하고 강력하다는 걸 느끼게 될 거야.

💪 성공적인 학습을 위한 마지막 조언

1. 매일 조금씩이라도 코딩하기
하루 30분이라도 꾸준히 하는 게 주말에 몰아서 하는 것보다 훨씬 효과적이야.

2. 공식 문서 읽기
구글의 공식 Compose 문서는 정말 잘 되어 있어. 영어가 부담스럽다면 번역기를 활용해서라도 읽어봐.

3. 커뮤니티 활용하기
Stack Overflow, Reddit, 한국 안드로이드 개발자 커뮤니티 등에서 질문하고 답변하면서 배워.

4. 실제 앱 만들어보기
튜토리얼만 따라하지 말고, 내가 정말 만들고 싶은 앱을 직접 개발해봐. 그게 가장 빠른 성장 방법이야.

5. 코드 리뷰 받기
다른 사람의 피드백을 받으면 내가 놓친 부분을 발견할 수 있어. 재능넷 같은 플랫폼을 활용해봐!

안드로이드 개발은 정말 매력적인 분야야. 내가 만든 앱이 수백만 명의 사람들에게 사용될 수 있다는 게 얼마나 멋진 일이야? 그리고 Jetpack Compose는 그 과정을 훨씬 즐겁고 효율적으로 만들어줘.

이 글이 너의 안드로이드 개발 여정에 좋은 출발점이 되었으면 좋겠어. 앞으로 멋진 앱들을 많이 만들어내길 응원할게! 화이팅! 🚀💻✨

Happy Coding! 🎨📱💜

안드로이드 개발자의 여정 Kotlin + Jetpack Compose 💻 📱 🚀 ✨ 🎉 계속 배우고, 계속 만들고, 계속 성장하자!
댓글 작성

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

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

아직 댓글이 없습니다.