Android MVVM 아키텍처 실전 가이드 — Repository, StateFlow, LiveData와 UI 상태 관리

Android 앱을 처음 개발할 때는 Activity나 Fragment에서 대부분의 기능을 처리하는 경우가 많습니다.

예를 들어 조류 도감 앱을 만든다고 가정하겠습니다.

사용자가 새 이름을 검색하면 SQLite에서 데이터를 조회하고, RecyclerView에 결과를 표시합니다. 좋아요 버튼을 누르면 데이터베이스를 수정하고 화면을 갱신합니다.

처음에는 이러한 기능을 Fragment 안에서 직접 구현해도 큰 문제가 없어 보입니다.

하지만 앱의 기능이 늘어나면 상황이 달라집니다.

검색, 필터, 좋아요, 메모, 네트워크 통신, 오류 처리, 로딩 상태까지 하나의 Fragment에 들어가면서 코드가 복잡해집니다.

특히 다음과 같은 문제가 발생하기 쉽습니다.

  • Fragment에 SQLite 쿼리가 직접 작성되어 있음

  • ViewModel이 Context와 View를 과도하게 참조함

  • Retrofit 응답 처리가 여러 화면에 중복됨

  • LiveData와 StateFlow가 혼재되어 상태 관리가 어려움

  • 로딩·성공·오류 상태를 각각 다른 변수로 관리함

  • 화면 회전 후 검색 조건이나 선택 상태가 초기화됨

  • Coroutine 취소와 예외 처리가 일관되지 않음

  • 단위 테스트에서 Android 환경에 대한 의존성이 큼

이러한 문제를 개선하는 데 도움이 되는 구조가 **MVVM(Model–View–ViewModel)**입니다.

이번 글에서는 단순히 MVVM의 개념을 설명하는 데 그치지 않고, 기존 Kotlin + XML 프로젝트에 적용할 수 있는 실전 구조를 살펴보겠습니다.

개발 환경: Kotlin, XML ViewBinding, AndroidX ViewModel, Lifecycle, Kotlin Coroutines, StateFlow, Retrofit2, SQLiteOpenHelper, minSdk 24 이상을 기준으로 합니다. 코드는 구조를 설명하기 위한 예제로, 실제 프로젝트에 맞는 import와 의존성 구성이 필요합니다.


1. MVVM이란?

MVVM은 앱의 역할을 크게 세 부분으로 나누는 아키텍처 패턴입니다.

구성 요소주요 역할
Model데이터 및 비즈니스 규칙
View화면 표시와 사용자 입력
ViewModelUI 상태 관리와 사용자 이벤트 처리

Android에서는 일반적으로 Activity와 Fragment가 View 역할을 담당합니다.

ViewModel은 화면에 필요한 상태를 만들고, Repository를 통해 데이터를 가져옵니다.

┌─────────────────────┐
│ Activity / Fragment │
│        View         │
└──────────┬──────────┘
           │ 사용자 이벤트
           ▼
┌─────────────────────┐
│      ViewModel      │
│     UI 상태 관리    │
└──────────┬──────────┘
           │ 데이터 요청
           ▼
┌─────────────────────┐
│      Repository     │
└──────────┬──────────┘
           │
     ┌─────┴─────┐
     ▼           ▼
   SQLite     Retrofit

이 구조에서 중요한 원칙은 ViewModel이 화면을 직접 조작하지 않고, View는 데이터 처리 방법을 몰라도 되도록 만드는 것입니다.


기존 코드의 문제점

2. Fragment에 모든 기능을 구현한 경우

다음은 흔히 볼 수 있는 구조입니다.

class BirdListFragment : Fragment() {

    private fun searchBirds(keyword: String) {

        val helper = BirdDatabaseHelper(
            requireContext()
        )

        val db = helper.readableDatabase

        val cursor = db.rawQuery(
            """
            SELECT *
            FROM korean_birds
            WHERE KoreanName LIKE ?
            """.trimIndent(),
            arrayOf("%$keyword%")
        )

        // Cursor를 읽어 목록 생성
        // RecyclerView 갱신
        // 로딩 표시
        // 오류 처리

        cursor.close()
    }
}

이 예제는 여러 문제를 가지고 있습니다.

첫째, Fragment가 데이터베이스 구조를 알고 있습니다.

둘째, SQLite 조회가 메인 스레드에서 실행될 가능성이 있습니다.

셋째, 다른 화면에서도 같은 검색 기능을 사용하려면 코드를 중복 작성해야 합니다.

넷째, 데이터베이스 조회를 테스트하려면 Fragment까지 실행해야 할 수 있습니다.

이러한 문제를 해결하기 위해 역할을 분리합니다.


3. 개선할 프로젝트 구조

다음과 같은 디렉터리 구조를 사용할 수 있습니다.

com.example.birdapp
│
├── data/
│   ├── local/
│   │   ├── BirdDatabaseHelper.kt
│   │   └── BirdLocalDataSource.kt
│   │
│   ├── remote/
│   │   ├── BirdApiService.kt
│   │   └── BirdRemoteDataSource.kt
│   │
│   └── repository/
│       └── BirdRepositoryImpl.kt
│
├── domain/
│   ├── model/
│   │   └── Bird.kt
│   │
│   └── repository/
│       └── BirdRepository.kt
│
└── ui/
    └── birdlist/
        ├── BirdListFragment.kt
        ├── BirdListViewModel.kt
        ├── BirdListUiState.kt
        └── BirdListAdapter.kt

이 구조가 반드시 정답인 것은 아닙니다.

작은 앱에서는 domain 패키지를 별도로 만들지 않아도 됩니다.

중요한 것은 패키지 개수가 아니라 각 계층의 책임이 명확한지입니다.


Model 설계

4. 데이터베이스 모델과 UI 모델 구분하기

SQLite 테이블의 모든 컬럼을 화면에 그대로 전달할 필요는 없습니다.

예를 들어 조류 데이터베이스에 다음 정보가 있다고 가정하겠습니다.

CREATE TABLE korean_birds (
    KTSN TEXT PRIMARY KEY,
    KoreanName TEXT,
    ScientificName TEXT,
    FamilyKor TEXT
);

화면에서 사용할 모델은 다음과 같이 정의할 수 있습니다.

data class Bird(
    val id: String,
    val koreanName: String,
    val scientificName: String,
    val familyName: String,
    val isFavorite: Boolean = false
)

이렇게 하면 화면에서는 SQLite 컬럼 이름이나 Cursor를 직접 다룰 필요가 없습니다.

단, 실제 데이터베이스의 컬럼 이름은 프로젝트마다 다를 수 있으므로 SQL과 매핑 코드를 기존 스키마에 맞춰 작성해야 합니다.


Repository 패턴

5. Repository란?

Repository는 앱에서 데이터를 가져오거나 변경하는 방법을 추상화하는 계층입니다.

예를 들어 ViewModel은 다음과 같은 사실만 알면 됩니다.

repository.searchBirds("참새")

실제로 SQLite에서 조회하는지, 서버 API를 호출하는지, 캐시를 사용하는지는 Repository가 결정합니다.

이렇게 하면 데이터 저장 방식이 변경되어도 ViewModel을 수정할 필요가 줄어듭니다.

6. Repository 인터페이스 작성

interface BirdRepository {

    suspend fun searchBirds(
        keyword: String
    ): List<Bird>

    suspend fun setFavorite(
        birdId: String,
        favorite: Boolean
    )
}

인터페이스를 사용하는 이유는 구현체를 교체하거나 테스트용 가짜 Repository를 주입하기 쉽게 만들기 위해서입니다.

단, 모든 Repository에 반드시 인터페이스가 필요한 것은 아닙니다.

구현체가 하나이고 테스트 대체도 필요 없다면 단순한 클래스로 시작해도 됩니다.


7. SQLite Repository 구현

기존 SQLiteOpenHelper를 그대로 사용할 수 있습니다.

Room으로 전환해야만 MVVM을 적용할 수 있는 것은 아닙니다.

class BirdRepositoryImpl(
    private val dbHelper: BirdDatabaseHelper,
    private val ioDispatcher: CoroutineDispatcher =
        Dispatchers.IO
) : BirdRepository {

    override suspend fun searchBirds(
        keyword: String
    ): List<Bird> =
        withContext(ioDispatcher) {

            val result = mutableListOf<Bird>()

            val sql = """
                SELECT
                    KTSN,
                    KoreanName,
                    ScientificName,
                    FamilyKor
                FROM korean_birds
                WHERE KoreanName LIKE ?
                ORDER BY KoreanName
                LIMIT 100
            """.trimIndent()

            dbHelper.readableDatabase.rawQuery(
                sql,
                arrayOf("%$keyword%")
            ).use { cursor ->

                val idIndex =
                    cursor.getColumnIndexOrThrow("KTSN")

                val nameIndex =
                    cursor.getColumnIndexOrThrow("KoreanName")

                val scientificIndex =
                    cursor.getColumnIndexOrThrow("ScientificName")

                val familyIndex =
                    cursor.getColumnIndexOrThrow("FamilyKor")

                while (cursor.moveToNext()) {

                    result.add(
                        Bird(
                            id = cursor.getString(idIndex),
                            koreanName = cursor.getString(nameIndex),
                            scientificName = cursor.getString(scientificIndex),
                            familyName = cursor.getString(familyIndex)
                        )
                    )
                }
            }

            result
        }

    override suspend fun setFavorite(
        birdId: String,
        favorite: Boolean
    ) = withContext(ioDispatcher) {

        // 실제 bird_memos 테이블 구조에 맞게
        // INSERT 또는 UPDATE 처리
        Unit
    }
}

이 코드에서 주목할 부분은 다음과 같습니다.

  • withContext(Dispatchers.IO)로 SQLite 작업 분리

  • Cursor.use로 리소스 정리

  • SQL 매개변수 바인딩

  • 조회 결과를 Bird 모델로 변환

  • ViewModel과 데이터베이스 구현 분리

실제 프로젝트에서는 좋아요 정보가 별도의 bird_memos 테이블에 있다면 JOIN을 사용하거나 별도 조회 결과를 결합할 수 있습니다.


ViewModel 설계

8. ViewModel의 역할

ViewModel은 화면에 필요한 데이터를 준비하고 UI 상태를 관리합니다.

하지만 ViewModel이 모든 비즈니스 로직을 직접 구현하는 것은 바람직하지 않습니다.

예를 들어 다음과 같은 작업은 Repository에 위임하는 것이 좋습니다.

  • SQLite 조회

  • 서버 API 호출

  • 데이터 저장

  • 캐시 정책

  • 데이터 동기화

ViewModel은 다음 사항에 집중합니다.

  • 검색어 상태

  • 로딩 상태

  • 검색 결과

  • 오류 상태

  • 사용자 이벤트 처리

  • 화면에 표시할 데이터 구성


UI 상태 모델링

9. 여러 LiveData를 따로 관리할 때의 문제

다음과 같은 ViewModel을 생각해 보겠습니다.

val birds = MutableLiveData<List<Bird>>()

val loading = MutableLiveData<Boolean>()

val error = MutableLiveData<String?>()

이 방식도 사용할 수 있습니다.

하지만 세 상태가 서로 독립적으로 변경되면 모순된 상태가 발생할 수 있습니다.

예를 들어 다음과 같은 상황입니다.

loading = true
birds = 이전 검색 결과
error = "네트워크 오류"

화면에서는 로딩 표시, 기존 목록, 오류 메시지가 동시에 나타날 수 있습니다.

이것이 의도한 동작이라면 괜찮지만, 그렇지 않다면 상태 관리가 복잡해집니다.

10. 하나의 UiState로 관리하기

다음과 같이 화면 상태를 하나의 데이터 클래스로 정의할 수 있습니다.

data class BirdListUiState(
    val query: String = "",
    val birds: List<Bird> = emptyList(),
    val isLoading: Boolean = false,
    val errorMessage: String? = null
)

이 구조는 검색 결과를 유지하면서 로딩 상태를 표시하는 화면에 적합합니다.

예를 들어 사용자가 새로운 검색어를 입력할 때 기존 목록을 잠시 유지하고 로딩 표시를 보여줄 수 있습니다.

반대로 로딩·성공·실패가 완전히 배타적인 화면이라면 sealed interface를 사용하는 것도 좋습니다.


11. sealed interface로 상태 구분하기

sealed interface BirdSearchState {

    data object Idle : BirdSearchState

    data object Loading : BirdSearchState

    data class Success(
        val birds: List<Bird>
    ) : BirdSearchState

    data class Error(
        val message: String
    ) : BirdSearchState
}

이 방식은 화면 상태가 명확하게 구분될 때 유용합니다.

하지만 검색어, 선택된 필터, 스크롤 상태 등 여러 독립적인 값까지 모두 sealed 상태에 넣으면 오히려 복잡해질 수 있습니다.

따라서 앱의 UI 특성에 맞게 선택해야 합니다.

이번 예제에서는 data class BirdListUiState를 사용하겠습니다.


StateFlow 활용

12. StateFlow란?

StateFlow는 현재 상태값을 보유하고 새로운 구독자에게 최신 값을 전달하는 Kotlin Flow의 한 종류입니다.

UI 상태 관리에 적합한 특징이 있습니다.

  • 항상 현재 값을 가짐

  • 새로운 구독자에게 최신 상태 전달

  • Coroutine과 자연스럽게 연동

  • update()를 이용한 상태 변경

  • XML 기반 Fragment에서도 사용 가능

StateFlow를 사용하기 위해 Jetpack Compose로 전환할 필요는 없습니다.

13. ViewModel 구현

class BirdListViewModel(
    private val repository: BirdRepository
) : ViewModel() {

    private val _uiState =
        MutableStateFlow(BirdListUiState())

    val uiState: StateFlow<BirdListUiState> =
        _uiState.asStateFlow()

    fun search(keyword: String) {

        viewModelScope.launch {

            _uiState.update {
                it.copy(
                    query = keyword,
                    isLoading = true,
                    errorMessage = null
                )
            }

            try {

                val birds =
                    repository.searchBirds(keyword)

                _uiState.update {
                    it.copy(
                        birds = birds,
                        isLoading = false
                    )
                }

            } catch (e: CancellationException) {

                throw e

            } catch (e: Exception) {

                _uiState.update {
                    it.copy(
                        isLoading = false,
                        errorMessage =
                            "검색 중 오류가 발생했습니다."
                    )
                }
            }
        }
    }
}

여기서는 Coroutine의 취소 예외를 다시 전달합니다.

CancellationException까지 일반 오류로 처리하면 취소된 작업이 정상적으로 종료되지 않거나 잘못된 오류 상태를 만들 수 있기 때문입니다.

다만 이 구현은 검색을 호출할 때마다 새로운 Coroutine을 생성합니다.

사용자가 빠르게 검색어를 변경하는 상황에서는 이전 검색 결과가 나중에 도착하는 문제가 발생할 수 있습니다.

이를 개선하는 방법을 이어서 살펴보겠습니다.


검색 기능 개선

14. 빠른 검색 입력에서 발생하는 경쟁 상태

사용자가 다음과 같이 검색어를 입력한다고 가정하겠습니다.

ㅊ
차
참
참새

검색 요청이 연속으로 발생하면 이전 요청이 나중에 완료될 수 있습니다.

검색 A 시작 ───────────── 완료
검색 B 시작 ─── 완료

이 경우 최신 검색어가 B인데 A의 결과가 화면에 표시되는 문제가 생길 수 있습니다.

이 문제를 해결하기 위해 collectLatest나 flatMapLatest 등을 활용할 수 있습니다.

15. StateFlow + debounce + collectLatest

class BirdListViewModel(
    private val repository: BirdRepository
) : ViewModel() {

    private val query =
        MutableStateFlow("")

    private val _uiState =
        MutableStateFlow(BirdListUiState())

    val uiState = _uiState.asStateFlow()

    init {

        viewModelScope.launch {

            query
                .debounce(300)
                .distinctUntilChanged()
                .collectLatest { keyword ->

                    _uiState.update {
                        it.copy(
                            query = keyword,
                            isLoading = true,
                            errorMessage = null
                        )
                    }

                    try {

                        val birds =
                            repository.searchBirds(keyword)

                        _uiState.update {
                            it.copy(
                                birds = birds,
                                isLoading = false
                            )
                        }

                    } catch (e: CancellationException) {

                        throw e

                    } catch (e: Exception) {

                        _uiState.update {
                            it.copy(
                                isLoading = false,
                                errorMessage =
                                    "검색 중 오류가 발생했습니다."
                            )
                        }
                    }
                }
        }
    }

    fun updateQuery(value: String) {
        query.value = value
    }
}

이 구조에서는 검색어가 변경될 때 이전 collectLatest 블록이 취소됩니다.

단, 실제 SQLite 작업이 취소에 얼마나 빠르게 반응하는지는 구현에 따라 다릅니다.

특히 오래 실행되는 SQLite 쿼리는 Android의 CancellationSignal 등 별도의 취소 지원을 고려해야 할 수 있습니다.

또한 debounce를 사용하는 API 버전에서는 실험적 API 사용 여부를 확인해야 합니다.


Fragment와 StateFlow 연결

16. repeatOnLifecycle 사용하기

Fragment에서 StateFlow를 수집할 때는 생명주기를 고려해야 합니다.

viewLifecycleOwner.lifecycleScope.launch {

    viewLifecycleOwner.repeatOnLifecycle(
        Lifecycle.State.STARTED
    ) {

        viewModel.uiState.collect { state ->

            render(state)
        }
    }
}

이렇게 하면 Fragment의 View가 STARTED 상태일 때 UI 상태를 수집하고, 화면이 중지되면 수집을 중단합니다.

다시 STARTED 상태가 되면 최신 상태를 수집합니다.

특히 Fragment에서는 lifecycleScope보다 viewLifecycleOwner.lifecycleScope를 사용하는 것이 안전한 경우가 많습니다.

Fragment 객체의 생명주기와 View의 생명주기가 다르기 때문입니다.


17. render 함수 분리하기

화면을 갱신하는 코드를 별도 함수로 분리합니다.

private fun render(
    state: BirdListUiState
) {

    binding.progressBar.isVisible =
        state.isLoading

    adapter.submitList(state.birds)

    binding.textEmpty.isVisible =
        !state.isLoading &&
        state.birds.isEmpty() &&
        state.errorMessage == null

    binding.textError.isVisible =
        state.errorMessage != null

    binding.textError.text =
        state.errorMessage
}

이렇게 하면 Fragment는 UI 상태를 화면에 반영하는 역할에 집중할 수 있습니다.

다만 render()가 호출될 때마다 모든 UI를 무조건 다시 그리는 것은 피하는 것이 좋습니다.

RecyclerView에서는 ListAdapter와 DiffUtil을 사용하면 변경된 항목을 효율적으로 반영할 수 있습니다.


LiveData와 StateFlow 비교

18. LiveData를 계속 사용해도 될까?

LiveData는 여전히 Android XML 기반 앱에서 사용할 수 있는 상태 관찰 도구입니다.

기존 프로젝트가 LiveData를 안정적으로 사용하고 있다면 무조건 StateFlow로 전환할 필요는 없습니다.

두 방식의 차이를 비교해 보겠습니다.

구분LiveDataStateFlow
Android Lifecycle 연동기본 지원repeatOnLifecycle 등 사용
Coroutine 연동가능자연스러움
초기값없어도 됨필요
현재 값 접근valuevalue
Flow 연산자변환 필요직접 활용
XML Data Binding지원직접 연결 설계 필요
신규 Kotlin 프로젝트사용 가능적극 고려

StateFlow는 Coroutine 기반 데이터 처리와 잘 어울립니다.

하지만 기존 LiveData 코드를 한 번에 전부 교체하기보다 새로운 화면부터 점진적으로 적용하는 방법이 현실적입니다.


19. LiveData를 StateFlow로 변환하기

기존 Repository에서 LiveData를 반환한다면 Flow로 변환하는 방법을 검토할 수 있습니다.

반대로 StateFlow를 LiveData로 노출할 수도 있습니다.

val uiStateLiveData =
    uiState.asLiveData()

이렇게 하면 기존 XML Data Binding이나 LiveData 관찰 코드를 유지하면서 내부 상태 관리 방식을 점진적으로 변경할 수 있습니다.

단, 변환 과정에서 관찰 시점과 구독 생명주기가 달라질 수 있으므로 테스트가 필요합니다.


Retrofit과 Repository

20. 네트워크 통신도 Repository로 분리하기

SQLite뿐 아니라 Retrofit도 Repository에서 관리할 수 있습니다.

예를 들어 서버에서 조류 정보를 가져오는 API가 있다고 가정하겠습니다.

interface BirdApiService {

    @GET("birds")
    suspend fun getBirds(
        @Query("keyword") keyword: String
    ): List<BirdDto>
}

Repository에서는 네트워크 결과를 앱 모델로 변환합니다.

class RemoteBirdRepository(
    private val api: BirdApiService
) {

    suspend fun searchBirds(
        keyword: String
    ): List<Bird> {

        return api.getBirds(keyword).map { dto ->

            Bird(
                id = dto.id,
                koreanName = dto.koreanName,
                scientificName = dto.scientificName,
                familyName = dto.familyName
            )
        }
    }
}

이렇게 하면 ViewModel은 Retrofit의 DTO 구조를 직접 알 필요가 없습니다.

실제 앱에서는 네트워크 오류, HTTP 응답 코드, 데이터 변환 오류 등을 별도로 처리해야 합니다.


Result 모델과 오류 처리

21. 예외를 그대로 UI에 노출하지 않기

다음과 같은 코드는 피하는 것이 좋습니다.

errorMessage = e.message

예외 메시지에는 사용자에게 불필요한 기술적 정보가 포함될 수 있습니다.

또한 메시지가 null이거나 영어로 표시될 수도 있습니다.

따라서 오류를 앱에서 정의한 형태로 변환하는 것이 좋습니다.

22. 도메인 오류 정의하기

sealed interface AppError {

    data object Network : AppError

    data object Database : AppError

    data object NotFound : AppError

    data object Unknown : AppError
}

Repository 또는 별도의 오류 변환 계층에서 예외를 적절한 오류 유형으로 분류할 수 있습니다.

ViewModel에서는 오류 유형을 UI 상태로 전달하고, Fragment에서 문자열 리소스를 이용해 메시지를 표시할 수 있습니다.

이렇게 하면 사용자 메시지와 내부 오류 정보를 분리할 수 있습니다.


단발성 이벤트 처리

23. UI 상태와 이벤트의 차이

검색 결과나 로딩 여부는 UI 상태입니다.

하지만 다음과 같은 동작은 단발성 이벤트에 가깝습니다.

  • Toast 표시

  • Snackbar 표시

  • 상세 화면 이동

  • 외부 브라우저 열기

이러한 이벤트를 StateFlow에 단순히 저장하면 화면 회전 후 다시 실행되는 문제가 발생할 수 있습니다.

예를 들어 상세 화면 이동 이벤트가 최신 상태로 남아 있다면 Fragment가 다시 생성될 때 중복 이동이 발생할 수 있습니다.

24. SharedFlow를 이용한 이벤트 전달

sealed interface BirdListEvent {

    data class OpenDetail(
        val birdId: String
    ) : BirdListEvent

    data class ShowMessage(
        val message: String
    ) : BirdListEvent
}

ViewModel에서는 다음과 같이 이벤트를 전달할 수 있습니다.

private val _events =
    MutableSharedFlow<BirdListEvent>(
        extraBufferCapacity = 1
    )

val events = _events.asSharedFlow()

fun onBirdClicked(birdId: String) {

    viewModelScope.launch {

        _events.emit(
            BirdListEvent.OpenDetail(birdId)
        )
    }
}

Fragment에서는 이벤트를 수집합니다.

viewLifecycleOwner.lifecycleScope.launch {

    viewLifecycleOwner.repeatOnLifecycle(
        Lifecycle.State.STARTED
    ) {

        viewModel.events.collect { event ->

            when (event) {

                is BirdListEvent.OpenDetail -> {
                    openDetail(event.birdId)
                }

                is BirdListEvent.ShowMessage -> {
                    showMessage(event.message)
                }
            }
        }
    }
}

다만 SharedFlow는 화면이 구독하지 않는 동안 발생한 이벤트를 보존하지 않을 수 있습니다.

따라서 반드시 처리되어야 하는 내비게이션이나 사용자 작업 결과는 이벤트 손실 가능성을 고려해 UI 상태로 모델링하는 방법도 검토해야 합니다.

SharedFlow가 모든 단발성 이벤트 문제를 자동으로 해결하는 것은 아닙니다.


의존성 주입

25. ViewModel에서 Repository를 직접 생성하지 않기

다음과 같은 코드는 피하는 것이 좋습니다.

class BirdListViewModel : ViewModel() {

    private val repository =
        BirdRepositoryImpl(
            BirdDatabaseHelper(...)
        )
}

이 방식은 ViewModel이 데이터 계층의 생성 방법까지 알아야 한다는 문제가 있습니다.

또한 테스트에서 가짜 Repository로 교체하기 어렵습니다.

Repository를 생성자에서 전달받는 구조가 좋습니다.

class BirdListViewModel(
    private val repository: BirdRepository
) : ViewModel()

26. ViewModel Factory 사용하기

Hilt를 사용하지 않는 프로젝트라면 ViewModel Factory를 직접 작성할 수 있습니다.

class BirdListViewModelFactory(
    private val repository: BirdRepository
) : ViewModelProvider.Factory {

    override fun <T : ViewModel> create(
        modelClass: Class<T>
    ): T {

        if (
            modelClass.isAssignableFrom(
                BirdListViewModel::class.java
            )
        ) {

            @Suppress("UNCHECKED_CAST")
            return BirdListViewModel(
                repository
            ) as T
        }

        throw IllegalArgumentException(
            "Unknown ViewModel class"
        )
    }
}

Fragment에서는 다음과 같이 사용할 수 있습니다.

private val viewModel: BirdListViewModel by viewModels {

    BirdListViewModelFactory(
        repository = appContainer.birdRepository
    )
}

여기서 appContainer는 Application 범위에서 Repository 등을 관리하는 객체라고 가정합니다.

실제 프로젝트에서는 Application 클래스 또는 별도의 DI 컨테이너를 통해 의존성을 제공하면 됩니다.


ViewModel과 Context

27. ViewModel에서 Activity Context를 보관하지 않기

ViewModel은 Activity나 Fragment보다 오래 유지될 수 있습니다.

따라서 Activity Context를 필드로 저장하면 메모리 누수의 원인이 될 수 있습니다.

다음과 같은 구조는 피하는 것이 좋습니다.

class BirdListViewModel(
    private val activity: Activity
) : ViewModel()

ViewModel에는 가능하면 Repository와 같은 데이터 계층의 의존성을 전달합니다.

Application Context가 필요한 경우에도 정말 필요한지 먼저 검토해야 합니다.

또한 ViewModel에서 직접 Toast나 Dialog를 표시하기보다 UI 상태 또는 이벤트를 통해 Fragment가 처리하도록 하는 것이 좋습니다.


화면 회전과 상태 복원

28. ViewModel만으로 충분하지 않은 경우

ViewModel은 일반적인 구성 변경에서 상태를 유지할 수 있습니다.

하지만 시스템이 앱 프로세스를 종료하면 ViewModel 인스턴스는 사라집니다.

따라서 검색어처럼 복원할 가치가 있는 상태는 SavedStateHandle을 사용할 수 있습니다.

class BirdListViewModel(
    private val repository: BirdRepository,
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {

    val query =
        savedStateHandle.getStateFlow(
            "query",
            ""
        )

    fun updateQuery(value: String) {

        savedStateHandle["query"] = value
    }
}

이렇게 하면 Android가 저장된 인스턴스 상태를 복원하는 상황에서 검색어를 복원할 수 있습니다.

다만 SavedStateHandle은 영구 저장소가 아닙니다.

사용자가 작성한 메모나 좋아요 상태는 SQLite에 저장해야 합니다.


Coroutine 관리

29. viewModelScope 사용하기

ViewModel에서 비동기 작업을 실행할 때는 viewModelScope를 사용할 수 있습니다.

viewModelScope.launch {

    val birds =
        repository.searchBirds("참새")

    _uiState.update {
        it.copy(birds = birds)
    }
}

ViewModel이 제거되면 viewModelScope의 Coroutine도 취소됩니다.

하지만 Repository에서 취소를 무시하거나 별도의 전역 Coroutine을 실행한다면 기대한 동작과 달라질 수 있습니다.

따라서 데이터 계층에서도 구조화된 동시성을 유지하는 것이 중요합니다.

30. Dispatchers.IO는 어디에서 사용할까?

일반적으로 SQLite 파일 접근이나 블로킹 I/O 작업은 Repository 또는 DataSource에서 적절한 Dispatcher로 전환하는 것이 좋습니다.

override suspend fun searchBirds(
    keyword: String
): List<Bird> =
    withContext(ioDispatcher) {

        // SQLite 조회
    }

이렇게 하면 ViewModel은 호출하는 함수가 메인 스레드에서 안전한지 매번 고민할 필요가 줄어듭니다.

단, Retrofit의 suspend API처럼 자체적으로 비동기 처리를 지원하는 경우에는 무조건 Dispatchers.IO로 감쌀 필요는 없습니다.


테스트 가능한 구조

31. Fake Repository 작성하기

MVVM 구조의 장점 중 하나는 ViewModel을 실제 SQLite 없이 테스트할 수 있다는 점입니다.

class FakeBirdRepository : BirdRepository {

    override suspend fun searchBirds(
        keyword: String
    ): List<Bird> {

        return listOf(
            Bird(
                id = "1",
                koreanName = "참새",
                scientificName = "Passer montanus",
                familyName = "참새과"
            )
        )
    }

    override suspend fun setFavorite(
        birdId: String,
        favorite: Boolean
    ) {
        // 테스트용 구현
    }
}

이 Fake Repository를 ViewModel에 주입하면 실제 데이터베이스 파일 없이 검색 상태를 테스트할 수 있습니다.


32. ViewModel 테스트 예제

Coroutine 테스트 라이브러리를 이용하면 비동기 상태 변화를 검증할 수 있습니다.

@Test
fun searchBirds_returnsResult() = runTest {

    val repository = FakeBirdRepository()

    val viewModel = BirdListViewModel(
        repository
    )

    viewModel.search("참새")

    advanceUntilIdle()

    val state = viewModel.uiState.value

    assertEquals(
        1,
        state.birds.size
    )

    assertEquals(
        "참새",
        state.birds.first().koreanName
    )
}

이 코드는 앞서 소개한 search() 메서드를 사용하는 ViewModel 버전을 기준으로 한 예제입니다.

debounce 기반 ViewModel을 테스트한다면 가상 시간 진행과 Dispatcher 설정을 추가로 고려해야 합니다.

또한 ViewModel이 Dispatchers.Main을 사용하므로 JVM 단위 테스트에서는 테스트 Dispatcher를 Main으로 설정하는 규칙이 필요할 수 있습니다.


기존 앱의 점진적 마이그레이션

33. 모든 코드를 한 번에 바꿀 필요는 없다

기존 앱에 Activity, Fragment, LiveData, SQLite 코드가 이미 많이 작성되어 있다면 전면적인 아키텍처 변경은 부담이 될 수 있습니다.

이 경우 다음 순서로 개선하는 것이 좋습니다.

1단계: SQLite 코드를 Repository로 이동

Fragment에 직접 작성한 SQL을 Repository로 분리합니다.

2단계: ViewModel에 UI 상태 집중

검색어, 로딩 상태, 검색 결과를 ViewModel에서 관리합니다.

3단계: Fragment의 역할 축소

Fragment는 사용자 이벤트 전달과 화면 렌더링에 집중합니다.

4단계: 신규 화면부터 StateFlow 적용

기존 LiveData를 무조건 제거하지 않고 새로운 화면부터 StateFlow를 사용합니다.

5단계: 테스트 가능한 구조 확보

Repository 인터페이스 또는 대체 가능한 구현을 활용해 ViewModel 테스트를 작성합니다.

6단계: 공통 오류 처리 정리

SQLite 및 네트워크 오류를 일관된 방식으로 UI에 전달합니다.

이러한 방식은 이미 출시된 앱의 기능을 유지하면서 구조를 개선하는 데 적합합니다.


실무에서 자주 발생하는 실수

34. ViewModel이 너무 커지는 문제

MVVM을 적용했다고 해서 모든 로직을 ViewModel에 넣으면 안 됩니다.

다음과 같은 코드가 하나의 ViewModel에 몰려 있다면 다시 분리할 필요가 있습니다.

  • SQLite SQL 쿼리

  • Retrofit API 호출

  • JSON 파싱

  • 이미지 파일 저장

  • 데이터베이스 마이그레이션

  • UI 상태 관리

  • 화면 이동 처리

ViewModel은 화면에 필요한 상태와 이벤트를 중심으로 관리하고, 데이터 처리와 복잡한 규칙은 적절한 계층으로 분리하는 것이 좋습니다.

35. Repository를 무조건 많이 만드는 문제

반대로 작은 앱에서 모든 기능마다 Repository와 UseCase를 만들면 구조가 지나치게 복잡해질 수 있습니다.

예를 들어 단순한 설정값 하나를 읽기 위해 여러 계층을 거치는 구조는 유지보수에 도움이 되지 않을 수 있습니다.

앱의 복잡도에 맞게 계층을 구성하는 것이 중요합니다.

36. UI 상태와 데이터베이스 상태를 혼동하는 문제

SQLite에 저장된 좋아요 상태와 화면에서 표시하는 로딩 상태는 성격이 다릅니다.

좋아요는 영구 저장해야 하는 데이터입니다.

반면 로딩 상태는 일시적인 UI 상태입니다.

따라서 모든 상태를 DataStore나 SQLite에 저장할 필요는 없습니다.

반대로 사용자가 작성한 메모를 ViewModel에만 저장해서도 안 됩니다.


추천 아키텍처

37. 조류 도감 앱에 적용할 구조

다음과 같은 구조를 추천합니다.

BirdListFragment
       │
       │ 사용자 입력
       ▼
BirdListViewModel
       │
       │ 검색 조건 및 UI 상태
       ▼
BirdRepository
       │
       ├── BirdLocalDataSource
       │       │
       │       └── SQLite
       │
       └── BirdRemoteDataSource
               │
               └── Retrofit

이 구조에서는 검색 기능을 다음과 같이 처리합니다.

  1. 사용자가 검색어를 입력합니다.

  2. Fragment가 ViewModel에 검색어를 전달합니다.

  3. ViewModel이 검색어 변경을 관찰합니다.

  4. Repository가 SQLite 또는 서버에서 데이터를 가져옵니다.

  5. ViewModel이 검색 결과를 UI 상태에 반영합니다.

  6. Fragment가 StateFlow를 수집해 RecyclerView를 갱신합니다.

좋아요나 메모 기능도 같은 원칙으로 확장할 수 있습니다.


38. MVVM 적용 체크리스트

  • Activity와 Fragment에서 데이터베이스 접근 분리

  • Repository의 책임 명확화

  • 데이터 모델과 UI 모델 구분

  • ViewModel에서 Activity Context 보관하지 않음

  • UI 상태를 일관된 형태로 관리

  • StateFlow와 LiveData의 역할 정리

  • StateFlow 수집 시 repeatOnLifecycle 사용

  • Fragment View 생명주기 고려

  • 검색어 입력에 debounce 적용 검토

  • 이전 검색 요청 취소 및 경쟁 상태 처리

  • Coroutine CancellationException 재전달

  • SQLite 블로킹 작업을 백그라운드로 분리

  • Retrofit DTO를 앱 모델로 변환

  • 내부 오류 메시지를 사용자에게 그대로 노출하지 않음

  • 단발성 이벤트의 손실 및 중복 처리 고려

  • SavedStateHandle로 필요한 UI 상태 복원

  • 사용자 데이터는 영구 저장소에 저장

  • ViewModel 생성자에 의존성 주입

  • Fake Repository로 ViewModel 테스트

  • 기존 앱은 점진적으로 마이그레이션


39. 마무리

MVVM은 Android 앱을 무조건 복잡하게 만드는 설계 패턴이 아닙니다.

오히려 Activity와 Fragment에 집중된 역할을 분리해 기능 추가와 유지보수를 쉽게 만드는 데 목적이 있습니다.

특히 Kotlin + XML 기반 앱에서도 MVVM은 충분히 활용할 수 있습니다.

Jetpack Compose나 Room으로 전환해야만 적용할 수 있는 구조가 아닙니다.

기존 SQLiteOpenHelper와 Retrofit을 유지하면서도 Repository와 ViewModel을 분리할 수 있습니다.

이번 글의 핵심은 다음과 같습니다.

  1. View는 화면 표시와 사용자 입력에 집중합니다.

  2. ViewModel은 UI 상태를 관리합니다.

  3. Repository는 데이터 접근 방법을 추상화합니다.

  4. SQLite와 Retrofit의 구현 세부사항을 Fragment에서 분리합니다.

  5. StateFlow를 이용해 일관된 UI 상태를 관리할 수 있습니다.

  6. 기존 LiveData를 무조건 제거할 필요는 없습니다.

  7. Coroutine 취소와 검색 경쟁 상태를 고려해야 합니다.

  8. SavedStateHandle은 UI 상태 복원에 활용합니다.

  9. Repository를 분리하면 단위 테스트가 쉬워집니다.

  10. 기존 앱은 단계적으로 아키텍처를 개선하는 것이 좋습니다.

이미 운영 중인 Android 앱이라면 우선 가장 복잡한 Fragment 하나를 선택해 SQLite 접근 코드를 Repository로 옮겨보는 것부터 시작할 수 있습니다.

이후 UI 상태를 ViewModel로 옮기고, 화면에서는 상태를 관찰해 렌더링하도록 변경하면 됩니다.

좋은 Android 아키텍처의 핵심은 계층을 많이 만드는 것이 아니라, 각 코드가 맡아야 할 책임을 명확하게 나누는 것입니다.


참고 자료

태그: Android, Kotlin, MVVM, Repository, ViewModel, StateFlow, LiveData, Kotlin Coroutines, Flow, SQLite, Retrofit2, ViewBinding, SavedStateHandle, Android Architecture, Clean Architecture, Android 앱 개발