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

코틀린으로 시작하는 안드로이드 앱 개발, Jetpack Compose로 완성하는 최신 UI 디자인 🚀
친구처럼 편하게 배우는 모던 안드로이드 개발의 모든 것
👋 안녕! 안드로이드 앱 개발에 관심 있는 친구야? 요즘 앱 개발 트렌드가 완전히 바뀌고 있어. 예전처럼 XML로 레이아웃 짜고, findViewById로 뷰 찾고 하던 시대는 이제 옛날 이야기가 됐어. 지금은 코틀린(Kotlin)과 Jetpack Compose가 대세지! 이 글에서는 코틀린 기반 안드로이드 앱 개발의 기초부터 최신 Compose UI 활용까지 친구에게 설명하듯 쉽고 재미있게 알려줄게. 📱✨
🎯 왜 코틀린인가? 자바는 이제 안녕~
안드로이드 개발을 시작하려는 친구들이 가장 먼저 고민하는 게 바로 "자바로 할까, 코틀린으로 할까?"야. 결론부터 말하자면, 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 핵심 컴포넌트 완전 정복
이제 실전에서 가장 많이 쓰이는 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
)
🌐 네트워크 통신과 데이터 처리
실제 앱은 서버와 통신해야 하잖아? 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! 🎨📱💜
댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.
댓글 0
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.
아직 댓글이 없습니다.