Kotlin Android 개발 | Activity, ViewModel, Jetpack
이 글의 핵심
Kotlin Android 개발에 대한 실전 가이드입니다. Activity, ViewModel, Jetpack 등을 예제와 함께 상세히 설명합니다.
들어가며
Android 공식 언어로 자리 잡은 뒤, Jetpack Compose 등과 함께 쓰는 사례가 늘었습니다. 이 글에서는 프로젝트 설정과 Kotlin 관용구를 앱 뼈대에 맞춰 정리합니다.
1. Android 프로젝트 설정
build.gradle.kts
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.example.myapp"
compileSdk = 34
defaultConfig {
applicationId = "com.example.myapp"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
}
2. Activity
기본 Activity
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// View 초기화
val button = findViewById<Button>(R.id.button)
button.setOnClickListener {
Toast.makeText(this, "클릭!", Toast.LENGTH_SHORT).show()
}
}
}
ViewBinding
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.button.setOnClickListener {
binding.textView.text = "클릭됨!"
}
}
}
3. ViewModel
ViewModel 정의
class MainViewModel : ViewModel() {
private val _count = MutableLiveData(0)
val count: LiveData<Int> = _count
fun increment() {
_count.value = (_count.value ?: 0) + 1
}
}
Activity에서 사용
class MainActivity : AppCompatActivity() {
private val viewModel: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// LiveData 관찰
viewModel.count.observe(this) { count ->
textView.text = "Count: $count"
}
button.setOnClickListener {
viewModel.increment()
}
}
}
4. Jetpack Compose
설정
// build.gradle.kts
android {
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.3"
}
}
dependencies {
implementation("androidx.compose.ui:ui:1.5.4")
implementation("androidx.compose.material3:material3:1.1.2")
implementation("androidx.activity:activity-compose:1.8.2")
}
기본 Composable
@Composable
fun Greeting(name: String) {
Text(
text = "Hello, $name!",
fontSize = 24.sp,
color = Color.Blue
)
}
@Preview
@Composable
fun PreviewGreeting() {
Greeting("Android")
}
State 관리
@Composable
fun CounterScreen() {
var count by remember { mutableStateOf(0) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Count: $count",
fontSize = 32.sp
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { count++ }) {
Text("증가")
}
}
}
5. 실전 예제
예제: TODO 앱
data class Todo(val id: Int, val text: String, val done: Boolean = false)
class TodoViewModel : ViewModel() {
private val _todos = MutableLiveData<List<Todo>>(emptyList())
val todos: LiveData<List<Todo>> = _todos
fun addTodo(text: String) {
val current = _todos.value ?: emptyList()
val newTodo = Todo(current.size + 1, text)
_todos.value = current + newTodo
}
fun toggleTodo(id: Int) {
val current = _todos.value ?: return
_todos.value = current.map { todo ->
if (todo.id == id) todo.copy(done = !todo.done)
else todo
}
}
}
@Composable
fun TodoApp(viewModel: TodoViewModel = viewModel()) {
val todos by viewModel.todos.observeAsState(emptyList())
var text by remember { mutableStateOf("") }
Column(modifier = Modifier.padding(16.dp)) {
Row {
TextField(
value = text,
onValueChange = { text = it },
modifier = Modifier.weight(1f)
)
Button(onClick = {
if (text.isNotBlank()) {
viewModel.addTodo(text)
text = ""
}
}) {
Text("추가")
}
}
LazyColumn {
items(todos) { todo ->
TodoItem(
todo = todo,
onToggle = { viewModel.toggleTodo(todo.id) }
)
}
}
}
}
@Composable
fun TodoItem(todo: Todo, onToggle: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onToggle)
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = todo.done,
onCheckedChange = { onToggle() }
)
Text(
text = todo.text,
textDecoration = if (todo.done) TextDecoration.LineThrough else null
)
}
}
6. Activity와 Fragment 생명주기
Activity는 보통 화면 하나(윈도우)를 담당하고, Fragment는 Activity 안에서 부분 UI·내비게이션 단위로 쓰입니다. Fragment는 호스트 Activity의 생명주기에 연동됩니다.
Activity 주요 콜백(요약)
onCreate: 레이아웃·초기 바인딩,ViewModel준비(한 번).onStart/onStop: 화면에 보이기 시작 / 안 보일 때.onResume/onPause: 포커스·입력 가능 여부(다이얼로그, 다른 Activity 위에 올라올 때 등).onDestroy: 정리.isFinishing으로 사용자가 뒤로 나간 경우와 설정 변경(회전)만인 경우를 구분할 수 있습니다.
Fragment 주요 콜백(요약)
onAttach/onDetach: Activity와 연결·해제.onCreateView/onDestroyView: 뷰 트리 생성·파괴(ViewBinding은 여기서 null 처리).onViewCreated: 뷰가 준비된 뒤findNavController(),observe등.onStart·onStop·onResume·onPause: Activity와 유사하게 화면 표시·포커스에 맞춰 호출.
실전 포인트
- 회전 등 구성 변경 시 Activity/Fragment는 다시 만들어질 수 있으므로, 일시적 상태는
ViewModel, 영구 데이터는 Repository/Room에 둡니다. - Fragment에서
view를 쓰는 코드는onDestroyView이후에는 null이 되도록 패턴을 통일합니다.
class SampleFragment : Fragment() {
private var _binding: FragmentSampleBinding? = null
private val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
_binding = FragmentSampleBinding.inflate(inflater, container, false)
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
7. ViewModel + LiveData 패턴
역할 분리: Activity/Fragment는 입력·표시, ViewModel은 UI 상태와 유즈케이스 호출 결과를 보관합니다. LiveData는 생명주기를 아는 관찰 가능 데이터라서, 화면이 백그라운드일 때 불필요한 UI 갱신을 줄입니다.
관용 패턴
- UI에 노출할 값은 **
LiveData또는StateFlow**로 읽기 전용 노출. - 내부 갱신은
MutableLiveData(또는MutableStateFlow)로만.
class UserViewModel(
private val repo: UserRepository
) : ViewModel() {
private val _user = MutableLiveData<User?>()
val user: LiveData<User?> = _user
private val _error = MutableLiveData<String?>()
val error: LiveData<String?> = _error
fun load(userId: String) {
viewModelScope.launch {
runCatching { repo.getUser(userId) }
.onSuccess { _user.value = it }
.onFailure { _error.value = it.message }
}
}
}
Fragment에서는 observe(viewLifecycleOwner) { ... }로 Fragment 전용 라이프사이클에 맞춰 관찰하는 것이 안전합니다.
8. Jetpack Compose 기본 (실전)
Compose는 @Composable 함수로 UI를 함수 합성합니다. 상태가 바뀌면 해당 구간만 재구성(recomposition) 됩니다.
핵심 개념
remember: 컴포지션 안에서 객체/상태를 재사용 (회전 시 유지하려면rememberSaveable).mutableStateOf: 상태 변경 시 리컴포지션 트리거.ViewModel: 화면 회전 후에도 유지할 비 UI 상태는 ViewModel +collectAsStateWithLifecycle()(Flow) 조합을 많이 씁니다(lifecycle-runtime-compose의존성).
@Composable
fun ProfileScreen(vm: ProfileViewModel = viewModel()) {
val uiState by vm.uiState.collectAsStateWithLifecycle()
when (val s = uiState) {
is ProfileUiState.Loading -> CircularProgressIndicator()
is ProfileUiState.Content -> Text(s.name)
is ProfileUiState.Error -> Text(s.message)
}
}
Material3·테마: MaterialTheme.colorScheme, typography로 다크 모드·접근성에 맞춘 스타일을 한곳에서 관리합니다.
9. 네트워크 통신 (Retrofit + Coroutines)
Retrofit은 HTTP API를 인터페이스로 선언하고, 코루틴에서는 suspend 함수로 응답을 받습니다.
build.gradle.kts 예:
dependencies {
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-moshi:2.9.0") // 또는 gson
}
interface GitHubApi {
@GET("users/{login}")
suspend fun user(@Path("login") login: String): UserDto
}
val retrofit = Retrofit.Builder()
.baseUrl("https://api.github.com/")
.addConverterFactory(MoshiConverterFactory.create())
.build()
val api = retrofit.create(GitHubApi::class.java)
// ViewModel
fun load(login: String) {
viewModelScope.launch {
runCatching { api.user(login) }
.onSuccess { /* UI 상태 갱신 */ }
.onFailure { /* 네트워크/HTTP 오류 */ }
}
}
타임아웃·재시도는 OkHttp Interceptor 또는 코루틴 withTimeout으로 정책화합니다.
10. Room 데이터베이스
Room은 SQLite 위에 DAO·엔티티·컴파일 타임 검증을 제공합니다. UI 스레드에서 디스크 I/O를 하지 않도록 **suspend / Flow**를 사용합니다.
@Entity(tableName = "todos")
data class TodoEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val text: String,
val done: Boolean = false
)
@Dao
interface TodoDao {
@Query("SELECT * FROM todos ORDER BY id DESC")
fun observeTodos(): Flow<List<TodoEntity>>
@Insert
suspend fun insert(item: TodoEntity)
}
@Database(entities = [TodoEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun todoDao(): TodoDao
}
Repository에서 DAO를 호출하고, ViewModel은 **stateIn / collect**로 UI에 연결하는 구성이 흔합니다.
11. 의존성 주입 (Hilt / Koin)
의존성 주입으로 Api, Database, Repository 생성 위치를 한곳에 모으면 테스트(가짜 구현 교체)와 생명주기 관리가 쉬워집니다.
Hilt (Android 공식 권장)
@HiltAndroidApplication,@AndroidEntryPointActivity/Fragment.@Module+@InstallIn(SingletonComponent::class)로 싱글톤 바인딩.- ViewModel은
@HiltViewModel+ 생성자@Inject.
@HiltAndroidApp
class MyApp : Application()
@AndroidEntryPoint
class MainActivity : AppCompatActivity()
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideApi(): GitHubApi = retrofit.create(GitHubApi::class.java)
}
@HiltViewModel
class MainViewModel @Inject constructor(
private val api: GitHubApi
) : ViewModel()
Koin
- 런타임 DSL(
module { single { } })로 가볍게 시작. viewModel { MyViewModel(get()) }형태로 ViewModel 등록.
팀 표준·새 프로젝트는 Hilt, 기존 코드베이스·단순한 모듈 구성에는 Koin을 쓰는 경우도 많습니다.
정리
핵심 요약
- Activity / Fragment: 생명주기·ViewBinding null; Fragment는
viewLifecycleOwner로 관찰 - ViewModel + LiveData: UI 상태·Repository 호출 분리
- Compose: 선언적 UI,
remember/ 상태, Material3 - Retrofit + suspend: REST API
- Room: 로컬 DB + Flow/suspend DAO
- Hilt / Koin: 의존성 주입
- ViewBinding: 타입 안전한 View 접근
다음 단계
- Kotlin 테스팅
- Kotlin Spring Boot
- Kotlin 고급 기능
관련 글
- Kotlin 시작하기 | Android 공식 언어 완벽 입문
- Kotlin 변수와 타입 | val, var, 기본 타입 완벽 정리
- Kotlin 함수 | 함수 정의, 람다, 고차 함수
- Kotlin 클래스와 객체 | 클래스, 상속, 인터페이스
- Kotlin 컬렉션 | List, Set, Map 완벽 정리