[코루틴 (7)] 안드로이드 실무 적용: Retrofit, Room, ViewModel
지난 6편에서는 Kotlin Flow와 StateFlow, SharedFlow 같은 확장 개념을 살펴보면서, 비동기 스트림을 효율적으로 관리하는 방법을 알아보았습니다. 이번 글에서는 안드로이드 실무에서 가장 자주 사용되는 라이브러리인 Retrofit, Room, 그리고 ViewModel과 코루틴을 어떻게 결합할 수 있는지 살펴보겠습니다.
핵심 키워드
• Retrofit + Coroutines
• Room + Coroutines
• ViewModelScope
• MVVM 아키텍처
• 예외 처리 & 상태 관리
1. Retrofit과의 결합
1.1 Retrofit 코루틴 지원
Retrofit은 코루틴을 공식적으로 지원해, Call 객체 대신 suspend 함수로 네트워크를 호출할 수 있습니다.
• Gradle 의존성에서 implementation("com.squareup.retrofit2:retrofit:x.x.x")와 **kotlinx-coroutines-core**를 함께 설정하면 됩니다.
예시(인터페이스 정의):
interface ApiService {
@GET("users/{id}")
suspend fun getUser(@Path("id") userId: String): UserResponse
}
• suspend fun getUser(...): 네트워크 콜이 일시 중단 함수로 선언됩니다.
• 호출 측에서는 다음과 같이 사용합니다.
suspend fun fetchUser(api: ApiService, userId: String): UserResponse {
return api.getUser(userId)
}
1.2 예외 처리
• 네트워크 에러나 예외 발생 시, try/catch를 통해 처리하거나, Result 래퍼 클래스를 사용해 성공/실패 상태를 구분할 수 있습니다.
• “Response가 성공이면 그 데이터 사용, 실패면 에러 메시지 처리” 같은 로직을 간결하게 작성할 수 있습니다.
suspend fun safeFetchUser(api: ApiService, userId: String): Result<UserResponse> {
return try {
val response = api.getUser(userId)
Result.success(response)
} catch (e: Exception) {
Result.failure(e)
}
}
2. Room과의 결합
2.1 Room 코루틴 DAO
Room 라이브러리 역시 코루틴을 지원해, suspend 함수를 통해 DB 쿼리를 수행할 수 있습니다.
@Dao
interface UserDao {
@Query("SELECT * FROM user WHERE id = :id")
suspend fun getUser(id: String): UserEntity
@Insert
suspend fun insertUser(user: UserEntity)
}
• 기존에 LiveData<...>나 Flow<...>로도 반환할 수 있지만, 단순 suspend 함수로 일회성 쿼리를 처리하는 것도 가능합니다.
2.2 Flow + Room (Observable queries)
• Room과 Flow를 함께 사용하면, DB가 변경될 때마다 자동으로 새 데이터를 흘려보낼 수 있습니다.
@Dao
interface UserDao {
@Query("SELECT * FROM user")
fun getAllUsersFlow(): Flow<List<UserEntity>>
}
• getAllUsersFlow()를 수집(collect())하고 있으면, User 테이블이 변경될 때마다 최신 리스트를 받을 수 있습니다.
3. ViewModel과 ViewModelScope
3.1 ViewModelScope의 장점
ViewModelScope는 ViewModel이 Cleared 될 때 자동으로 코루틴을 취소하여, 메모리 누수와 불필요한 작업을 방지합니다.
• 안드로이드 Lifecycle을 크게 신경 쓰지 않아도, ViewModel 생명주기에 맞춰 작업이 정리되므로 편리합니다.
class MyViewModel(
private val api: ApiService,
private val userDao: UserDao
) : ViewModel() {
fun loadUser(userId: String) {
viewModelScope.launch {
try {
val response = api.getUser(userId)
userDao.insertUser(response.toEntity())
// UI 상태 갱신 로직
} catch (e: Exception) {
// 에러 처리
}
}
}
}
• viewModelScope.launch로 네트워크 호출 + DB 작업을 순서대로 수행합니다.
• 예외나 취소가 발생하면, 상위 스코프(ViewModelScope)가 책임을 지고 자동으로 정리됩니다.
3.2 상태 관리(StateFlow, LiveData 등)
• 코루틴 + Flow 조합을 사용하면, ViewModel이 MutableStateFlow로 상태를 관리하고, UI(Activity/Fragment/Compose)가 collect()로 구독할 수 있습니다.
• 기존 LiveData와도 잘 동작하지만, 코루틴 친화적인 구조에서는 Flow/StateFlow를 사용하는 것이 좀 더 자연스러운 경우가 많습니다.
4. 실전 예제: 네트워크 + DB 결합 아키텍처
아래 예시는 “사용자 정보를 불러와 로컬 DB에 캐싱한 뒤, UI에 표시”하는 전형적인 예시입니다.
class UserRepository(
private val api: ApiService,
private val userDao: UserDao
) {
suspend fun refreshUser(userId: String): Result<UserEntity> {
return try {
val response = api.getUser(userId)
val entity = response.toEntity()
userDao.insertUser(entity)
Result.success(entity)
} catch (e: Exception) {
Result.failure(e)
}
}
fun getLocalUserFlow(userId: String): Flow<UserEntity?> {
return userDao.getUserFlow(userId)
}
}
class MyViewModel(
private val userRepository: UserRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<UserUiState>(UserUiState.Loading)
val uiState: StateFlow<UserUiState> = _uiState
fun loadUser(userId: String) {
viewModelScope.launch {
// 1) 네트워크 + DB 저장
val result = userRepository.refreshUser(userId)
if (result.isSuccess) {
// 2) DB Flow 구독
userRepository.getLocalUserFlow(userId).collect { userEntity ->
if (userEntity != null) {
_uiState.value = UserUiState.Success(userEntity)
} else {
_uiState.value = UserUiState.Empty
}
}
} else {
_uiState.value = UserUiState.Error(result.exceptionOrNull()?.message ?: "Unknown")
}
}
}
}
1. refreshUser를 통해 서버 데이터를 받아오고, DB에 저장한다.
2. getLocalUserFlow를 수집(collect)하면, DB 업데이트에 따라 실시간으로 UI 상태를 갱신할 수 있다.
3. ViewModelScope가 종료되면, Flow 수집도 자동으로 중단된다.
4.1 UI 코드(Compose 예시)
@Composable
fun UserScreen(viewModel: MyViewModel) {
val uiState by viewModel.uiState.collectAsState()
when (uiState) {
is UserUiState.Loading -> { /* 로딩 스피너 */ }
is UserUiState.Success -> { /* user 데이터 표시 */ }
is UserUiState.Empty -> { /* 유저 없음 안내 */ }
is UserUiState.Error -> { /* 에러 메시지 */ }
}
}
• collectAsState()로 StateFlow를 Compose에서 쉽게 구독할 수 있다.
• LiveData라면 observeAsState(), Fragment/Activity에서라면 collect { ... }와 LifecycleScope 등을 사용해 유사하게 처리할 수 있다.
5. 예외 처리와 테스트 전략
5.1 예외 처리
• 네트워크, DB, 변환 과정에서 발생할 수 있는 예외를 최대한 도메인 레벨(Result<T>, Sealed Class 등)에서 포착해, ViewModel로 넘길 때는 명확한 성공/실패 신호를 전달하는 것이 좋습니다.
• Flow 체인에 catch { ... } 연산자를 추가해 특정 구간에서만 에러를 처리하는 방법도 있습니다.
5.2 테스트
• Unit Test: runTest나 Dispatchers.Unconfined를 사용해 코루틴 코드를 빠르게 검증할 수 있습니다.
• Instrumented Test: 실제 DB(Room)나 API를 모의(Mock)로 교체하고, ViewModelScope에서 로직이 기대대로 동작하는지 확인합니다.
• Flow Test: turbine(kotlinx.coroutines.test) 라이브러리 등을 사용하면, Flow가 방출하는 아이템들을 손쉽게 테스트할 수 있습니다.
마무리 및 다음 예고
이번 [7편]에서는 안드로이드 실무에서 자주 사용하는 Retrofit, Room, ViewModel과 코루틴을 결합하는 방식을 살펴보았습니다. 결론적으로,
1. Retrofit: suspend 함수를 통해 간결하게 네트워크 코드를 작성할 수 있다.
2. Room: DAO에서 suspend 함수를 사용하거나, Flow로 변경 사항을 관찰할 수 있다.
3. ViewModelScope: ViewModel이 사라질 때 코루틴을 자동으로 취소해주며, UI 상태 관리(StateFlow 등)와 결합하기 좋다.
다음 8편에서는 동시성 문제와 스레드 안전성(Mutex, Semaphore) 주제를 다룰 예정입니다. 동시에 여러 코루틴이 공유 자원에 접근할 때 발생할 수 있는 충돌을 어떻게 관리하는지, 코틀린에서 제공하는 동시성 툴을 예제를 통해 살펴보겠습니다.
시리즈 전체 목차
1. 코루틴 기초(suspend, CoroutineScope, Dispatcher)
2. Structured Concurrency와 코루틴 스코프 관리
5. Flow 심화(Flow, StateFlow, SharedFlow)
6. 안드로이드 실무 적용(Retrofit, Room, ViewModel)
7. 동시성 문제 해결(Mutex, Semaphore)