코틀린과 Jetpack Compose로 Android 앱을 개발할 때 View와 ViewModel을 구성하는 방법을 정리해 보았습니다.
단일 책임 원칙(SRP)과 의존성 주입(DI) 를 활용하여 테스트 가능하고 유지보수가 쉬운 구조를 목표로 합니다.
ViewModel은 주로 UI 데이터를 준비하고 UI 상태를 관리하는 역할을 담당합니다. UI 로직이나 비즈니스 로직을 ViewModel에 두고 네트워크 호출, 데이터 저장 등의 처리는 별도의 레이어(예: Repository)로 분리합니다. 이 방식은 단일 책임 원칙(SRP)을 지키는 데 도움이 됩니다.
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class MyViewModel(private val repository: MyRepository) : ViewModel() {
private val _data = MutableStateFlow<String>("")
val data: StateFlow<String> = _data
fun fetchData() {
viewModelScope.launch {
_data.value = repository.getData()
}
}
}
ViewModel은 일반적으로 Hilt 또는 Koin과 같은 DI 프레임워크를 통해 생성하여 필요한 의존성을 쉽게 주입받을 수 있도록 합니다. 이렇게 하면 테스트할 때도 실제 의존성을 사용하지 않고 대체 의존성을 주입할 수 있어 유연성이 높아집니다.
예를 들어 Hilt를 사용한다면 @HiltViewModel 주석과 생성자 주입을 활용할 수 있습니다.
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class MyViewModel @Inject constructor(
private val repository: MyRepository
) : ViewModel() {
// ViewModel logic here
}
Compose에서는 상태를 효율적으로 관리하는 것이 중요합니다. MutableStateFlow와 StateFlow를 사용해 UI에서 수신할 수 있는 상태를 만듭니다.
(LiveData도 사용 가능하지만 StateFlow는 Jetpack Compose와 더 적합하게 통합됩니다.)
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState
여기서 UiState는 UI의 여러 상태를 나타내는 데이터 클래스일 수 있습니다.
data class UiState(
val data: String = "",
val isLoading: Boolean = false,
val error: String? = null
)
Compose 화면에서는 collectAsState를 사용하여 StateFlow 상태를 구독합니다. 이렇게 하면 ViewModel의 상태 변화에 따라 UI가 자동으로 업데이트됩니다.
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.flow.collectAsState
@Composable
fun MyScreen(viewModel: MyViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsState()
when {
uiState.isLoading -> CircularProgressIndicator()
uiState.error != null -> Text("Error: ${uiState.error}")
else -> Text("Data: ${uiState.data}")
}
Button(onClick = { viewModel.fetchData() }) {
Text("Fetch Data")
}
}
ViewModel에서 테스트하기 어려운 부분은 별도 클래스로 분리하거나 인터페이스로 추상화하여 테스트 환경에서 Mock 객체를 사용할 수 있도록 합니다. 또한 StateFlow나 LiveData와 같은 비동기 데이터 흐름을 테스트할 때는 TestCoroutineDispatcher와 runBlockingTest와 같은 코루틴 테스트 유틸을 활용할 수 있습니다.
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runBlockingTest
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
@ExperimentalCoroutinesApi
class MyViewModelTest {
private lateinit var viewModel: MyViewModel
private val repository = FakeRepository() // Test 용으로 Fake 객체를 주입
@Before
fun setup() {
viewModel = MyViewModel(repository)
}
@Test
fun fetchData_updatesDataState() = runBlockingTest {
viewModel.fetchData()
val uiState = viewModel.uiState.first()
assertEquals("Expected Data", uiState.data)
}
}
SwiftUI설명에서 있었던 별도 라이브러리를 사용하는 상황에서도 Hilt를 사용하여 ViewModel에 의존성을 주입 하고 빌드 환경에 따라 실제 서비스와 Stub 서비스를 주입하는 방식으로 해결 할 수 있습니다.
예를 들어 라이브러리를 사용하는 경우 라이브러리가 테스트나 미리보기 환경에서 제대로 동작하지 않는 경우가 있을 수 있습니다.
// 서비스 인터페이스 정의
interface SomeService {
fun fetchData(): String
}
// 실제 상용 라이브러리를 사용하는 서비스
class RealService : SomeService {
override fun fetchData(): String {
// 상용 라이브러리의 실제 데이터 로직
return "Real Data from Commercial Library"
}
}
// 테스트 및 미리보기에서 사용할 Stub 서비스
class StubService : SomeService {
override fun fetchData(): String {
return "Stub Data for Testing/Preview"
}
}
서비스 정의
// 서비스 인터페이스 정의
interface SomeService {
fun fetchData(): String
}
// 실제 상용 라이브러리를 사용하는 서비스
class RealService : SomeService {
override fun fetchData(): String {
// 상용 라이브러리의 실제 데이터 로직
return "Real Data from Commercial Library"
}
}
// 테스트 및 미리보기에서 사용할 Stub 서비스
class StubService : SomeService {
override fun fetchData(): String {
return "Stub Data for Testing/Preview"
}
}
ViewModel 정의
DI를 통해 SomeService 구현체를 주입받아 사용하는 ViewModel을 정의합니다. 여기서는 Hilt를 사용하여 DI 설정을 추가합니다.
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class MyViewModel @Inject constructor(
private val service: SomeService
) : ViewModel() {
val data: String
get() = service.fetchData()
fun fetchData(): String {
return service.fetchData()
}
}
Hilt 모듈로 DI 설정
빌드 환경에 따라 RealService와 StubService를 조건부로 주입할 수 있도록 Hilt 모듈을 구성합니다. BuildConfig.DEBUG 값을 활용하여 StubService를 미리보기 및 디버그 환경에서 사용할 수 있게 합니다.
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object ServiceModule {
@Singleton
@Provides
fun provideSomeService(): SomeService {
return if (BuildConfig.DEBUG) {
StubService()
} else {
RealService()
}
}
}
Jetpack Compose 화면정의
ViewModel을 주입받아 Jetpack Compose 화면에서 사용하는 코드입니다. data를 화면에 표시하고, 버튼을 통해 fetchData 메서드를 호출하여 데이터를 업데이트합니다.
import androidx.compose.runtime.Composable
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.material3.*
@Composable
fun MyScreen(viewModel: MyViewModel = hiltViewModel()) {
Column {
Text(text = viewModel.data)
Button(onClick = {
viewModel.fetchData()
}) {
Text("Fetch Data")
}
}
}
Preview에서 DI 적용
미리보기에서는 실제 데이터 대신 StubService가 ViewModel에 주입되도록 설정하여, 상용 라이브러리가 없더라도 미리보기가 동작할 수 있게 합니다.
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.runtime.Composable
import androidx.compose.material3.*
@Preview(showBackground = true)
@Composable
fun MyScreenPreview() {
// Stub 데이터를 사용하는 ViewModel을 Preview에서 주입
MyScreen(viewModel = MyViewModel(service = StubService()))
}