Kotlin Android 개발 | Activity, ViewModel, Jetpack

이 글의 핵심

화면을 회전했더니 입력한 데이터가 사라지는 문제는 Activity 생명주기를 모르고 상태를 Activity에 둘 때 흔히 생깁니다. ViewModel로 상태를 분리하고 MutableLiveData를 감춰 LiveData로만 노출하는 이유를 짚은 뒤, Compose와 Retrofit, Room을 연결해 구조가 잡힌 Android 앱의 기본 뼈대를 만들게 합니다.

들어가며

Android 공식 언어로 자리 잡은 뒤, Jetpack Compose 등과 함께 쓰는 사례가 늘었습니다. 이 글에서는 프로젝트 설정과 Kotlin 관용구를 앱 뼈대에 맞춰 정리합니다.


build.gradle.kts로 프로젝트 설정

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")
}

설정에서 헷갈리기 쉬운 값은 세 가지 SDK 버전입니다. compileSdk는 컴파일할 때 참조하는 Android API 버전이라 새 API를 쓰려면 올려야 하고, minSdk는 설치할 수 있는 가장 낮은 기기 버전, targetSdk는 “이 버전의 동작 변경까지 대응했다”는 선언입니다. targetSdk를 올리면 백그라운드 실행 제한이나 권한 동작 같은 새 규칙이 적용되므로, 단순히 숫자만 바꾸면 런타임에 동작이 달라질 수 있습니다. Google Play는 새 앱과 업데이트에 최소 targetSdk를 요구하므로 주기적으로 올리는 작업이 필요합니다. 라이브러리 버전은 새 프로젝트라면 gradle/libs.versions.toml 버전 카탈로그에 모아 두는 것이 최근 Android Studio 템플릿의 기본 방식입니다.


Activity와 ViewBinding

기본 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 = "클릭됨!"
        }
    }
}

ViewBinding은 build.gradle.kts의 android { buildFeatures { viewBinding = true } }로 켜야 ActivityMainBinding 클래스가 생성됩니다. 켜지 않으면 Unresolved reference: ActivityMainBinding이 나는데, 레이아웃 파일 이름(activity_main.xml)에서 클래스 이름이 만들어지므로 파일 이름을 바꾸면 클래스 이름도 바뀐다는 점도 기억해 둘 만합니다. findViewById와 비교하면 ID 오타나 잘못된 타입 캐스팅이 컴파일 에러로 바뀌는 것이 가장 큰 이점입니다. 오래된 예제에서 binding 없이 textView.text = ...처럼 ID를 바로 쓰는 코드는 kotlin-android-extensions(synthetic) 플러그인을 쓰던 방식인데, 이 플러그인은 Kotlin 1.8에서 제거되었으므로 지금은 동작하지 않습니다.


ViewModel을 Activity에서 쓰기

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()
    private lateinit var binding: ActivityMainBinding
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        
        // LiveData 관찰
        viewModel.count.observe(this) { count ->
            binding.textView.text = "Count: $count"
        }
        
        binding.button.setOnClickListener {
            viewModel.increment()
        }
    }
}

by viewModels()는 ViewModel을 직접 생성하지 않고 Activity 범위의 저장소에서 꺼내 오는 위임입니다. 화면을 회전하면 Activity 객체는 새로 만들어지지만 ViewModel 저장소는 유지되므로, 새 Activity가 같은 MainViewModel 인스턴스를 받아 카운트가 그대로 남습니다. 흔한 실수는 val viewModel = MainViewModel()처럼 생성자를 직접 호출하는 것입니다. 이렇게 하면 회전할 때마다 새 ViewModel이 생겨 상태가 초기화되고, viewModelScope의 코루틴도 Activity와 함께 정리되지 않습니다. 또 ViewModel이 Activity나 View, Context를 필드로 들고 있으면 회전 후에도 이전 Activity가 해제되지 않는 메모리 누수가 생기므로, Context가 필요하면 AndroidViewModel의 Application을 쓰는 것이 원칙입니다.


Jetpack Compose 설정과 State

설정

// 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")
}

위 composeOptions { kotlinCompilerExtensionVersion } 방식은 Kotlin 1.x 시절 설정입니다. Compose 컴파일러 버전을 Kotlin 버전에 정확히 맞춰야 해서, Kotlin만 올리면 This version of the Compose Compiler requires Kotlin version X but you appear to be using Kotlin version Y 에러가 자주 났습니다. Kotlin 2.0부터는 Compose 컴파일러가 Kotlin 저장소로 합쳐져 plugins { id("org.jetbrains.kotlin.plugin.compose") }를 추가하면 Kotlin 버전과 자동으로 맞춰지므로 composeOptions 블록을 지우면 됩니다. Compose 라이브러리 버전은 개별로 적기보다 platform("androidx.compose:compose-bom:...")을 추가하고 각 라이브러리의 버전을 생략하는 BOM 방식이 서로 호환되는 조합을 보장해 줍니다.

기본 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("증가")
        }
    }
}

remember는 재구성 사이에서 값을 유지하지만 구성 변경에서는 유지하지 않습니다. 이 카운터를 올린 뒤 화면을 회전하면 Activity가 다시 만들어지면서 컴포지션도 처음부터 시작되어 0으로 돌아갑니다. rememberSaveable로 바꾸면 값이 Bundle에 저장되어 회전과 프로세스 재생성 후에도 복원됩니다. remember 없이 var count by mutableStateOf(0)만 쓰는 것도 흔한 실수인데, 재구성될 때마다 새 상태 객체가 만들어져 버튼을 눌러도 숫자가 바뀌지 않습니다. Android Studio의 린트가 Creating a state object during composition without using remember 경고로 알려 주므로 경고를 무시하지 않는 것이 중요합니다.


Compose로 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
        )
    }
}

이 예제를 확장할 때 부딪히는 문제를 몇 가지 짚어 두겠습니다. 첫째, Todo(current.size + 1, text)로 id를 매기면 삭제 기능을 추가하는 순간 id가 중복됩니다. 3개 중 하나를 지우고 새로 추가하면 새 항목이 기존 3번과 같은 id를 받아, toggleTodo가 두 항목을 동시에 바꿉니다. 증가만 하는 카운터나 Room의 autoGenerate 키를 써야 합니다. 둘째, items(todos)에 key를 주지 않으면 Compose는 위치로 항목을 식별하므로, 목록 중간에 삽입·삭제가 일어날 때 각 행의 remember 상태가 엉뚱한 항목으로 옮겨 가고 애니메이션도 어색해집니다. items(todos, key = { it.id })로 안정적인 키를 주는 것이 기본입니다. 셋째, viewModel()과 observeAsState()는 각각 lifecycle-viewmodel-compose, compose-runtime-livedata 의존성이 있어야 하고, 없으면 Unresolved reference: viewModel이 납니다. 위 의존성 목록에는 이 두 줄이 빠져 있으므로 직접 추가해야 합니다.


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
    }
}

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 전용 라이프사이클에 맞춰 관찰하는 것이 안전합니다.


remember, ViewModel 상태와 재구성

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로 다크 모드·접근성에 맞춘 스타일을 한곳에서 관리합니다.


Retrofit과 코루틴으로 네트워크 통신

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으로 정책화합니다.

runCatching을 코루틴 안에서 쓸 때는 함정이 하나 있습니다. runCatching은 모든 Throwable을 잡기 때문에, 사용자가 화면을 나가 viewModelScope가 취소될 때 던져지는 CancellationException까지 삼켜 버립니다. 그러면 취소되어야 할 코루틴이 onFailure로 넘어가 “요청이 취소되었습니다” 같은 오류 메시지를 띄우거나, 취소 이후에도 뒤따르는 코드를 계속 실행합니다. 네트워크 오류만 처리하려면 try { ... } catch (e: IOException) { ... } catch (e: HttpException) { ... }처럼 예외 타입을 좁히거나, runCatching을 쓰더라도 CancellationException은 다시 던지는 것이 안전합니다. Retrofit의 suspend 함수는 HTTP 404나 500 응답에서 HttpException을 던지므로, 응답 코드를 직접 보고 싶다면 반환 타입을 Response<UserDto>로 선언하면 됩니다. 또 매니페스트에 <uses-permission android:name="android.permission.INTERNET" />을 빠뜨리면 SecurityException: Permission denied (missing INTERNET permission?)이 나는데, 처음 네트워크 코드를 붙일 때 가장 흔히 겪는 에러입니다.


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에 연결하는 구성이 흔합니다.

Room은 어노테이션 처리기로 DAO 구현을 생성하므로 room-runtime과 함께 room-compiler를 KSP(ksp("androidx.room:room-compiler:..."))로 추가해야 합니다. 이를 빠뜨리면 빌드는 되는데 실행 시 Cannot find implementation for AppDatabase. AppDatabase_Impl does not exist 예외가 납니다. Flow를 반환하는 DAO 함수는 테이블이 바뀔 때마다 새 목록을 자동으로 흘려보내므로, 위 TODO 앱의 LiveData 목록을 수동으로 갱신하던 코드를 대체할 수 있습니다. 엔티티에 필드를 추가하고 version을 올리지 않으면 Room cannot verify the data integrity 에러로 앱이 시작되지 않는데, 개발 중에 fallbackToDestructiveMigration()으로 넘어가면 운영에서는 사용자 데이터가 모두 지워진다는 점에 주의해야 합니다. 배포된 앱이라면 Migration 객체나 자동 마이그레이션(@AutoMigration)을 작성하는 것이 원칙입니다.


Hilt와 Koin으로 의존성 주입

의존성 주입으로 Api, Database, Repository 생성 위치를 한곳에 모으면 테스트(가짜 구현 교체)와 생명주기 관리가 쉬워집니다. Hilt (Android 공식 권장)

  • @HiltAndroidApp(Application 클래스), @AndroidEntryPoint(Activity/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을 쓰는 경우도 많습니다.

Android 개발 요약

  1. Activity / Fragment: 생명주기·ViewBinding null; Fragment는 viewLifecycleOwner로 관찰
  2. ViewModel + LiveData: UI 상태·Repository 호출 분리
  3. Compose: 선언적 UI, remember / 상태, Material3
  4. Retrofit + suspend: REST API
  5. Room: 로컬 DB + Flow/suspend DAO
  6. Hilt / Koin: 의존성 주입
  7. ViewBinding: 타입 안전한 View 접근

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. ViewModel에서 MutableLiveData를 private으로 두고 LiveData로 노출하는 이유는 무엇인가요?

A. Activity나 Fragment가 상태를 직접 바꾸지 못하게 하고, 상태 변경은 ViewModel의 함수를 통해서만 일어나도록 하기 위해서입니다. _count는 내부에서만 쓰는 MutableLiveData, count는 읽기 전용 LiveData로 공개하면 UI는 관찰만 하고 값을 바꾸는 로직은 ViewModel 한곳에 모입니다. Compose와 코루틴 기반 코드에서는 같은 이유로 MutableStateFlow를 private으로 두고 StateFlow로 노출하는 패턴을 씁니다.