Kotlin Android 개발 | Activity, ViewModel, Jetpack

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, @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을 쓰는 경우도 많습니다.


정리

핵심 요약

  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 접근

다음 단계

  • Kotlin 테스팅
  • Kotlin Spring Boot
  • Kotlin 고급 기능

관련 글

  • Kotlin 시작하기 | Android 공식 언어 완벽 입문
  • Kotlin 변수와 타입 | val, var, 기본 타입 완벽 정리
  • Kotlin 함수 | 함수 정의, 람다, 고차 함수
  • Kotlin 클래스와 객체 | 클래스, 상속, 인터페이스
  • Kotlin 컬렉션 | List, Set, Map 완벽 정리