MVVM + Clean Architecture
UI부터 RepositoryImpl까지 설계 가이드
단방향 데이터 흐름의 각 계층 역할과 실전 Kotlin 코드까지
MVVM을 처음 접할 때 View와 ViewModel 분리까지는 쉽게 이해하지만, 프로젝트가 커지면서 UseCase, Repository, RepositoryImpl이 왜 필요한지 헷갈리는 경우가 많습니다. 이 글에서는 UI → ViewModel → UseCase → Repository → RepositoryImpl로 이어지는 단방향 데이터 흐름의 구조와 각 계층의 명확한 역할, 실전 코드까지 한번에 정리합니다.
1 전체 아키텍처 구조와 데이터 흐름
사용자가 버튼을 클릭했을 때 데이터가 어떻게 이동하는지 흐름도로 확인하세요.
↑ 응답은 반대 방향으로 흘러 최종적으로 UI State가 갱신됩니다.
2 각 계층별 역할 한눈에 보기
| 계층 | 소속 | 핵심 역할 | 절대 하면 안 되는 것 |
|---|---|---|---|
| UI (View) | Presentation | State 관찰 → 화면 렌더링, 사용자 입력 전달 | DB/네트워크 접근, 데이터 가공 로직 |
| ViewModel | Presentation | UI State 관리, UseCase 호출 위임 | 직접 API 호출, 비즈니스 로직 구현 |
| UseCase | Domain | 단일 비즈니스 규칙 캡슐화, 재사용 | 네트워크/DB 직접 접근, UI 상태 관리 |
| Repository (IF) | Domain | 데이터 조달 계약서(Interface) 정의 | 실제 구현 코드 작성 |
| RepositoryImpl | Data | API/DB 통신, DTO → Domain Model 매핑 | 비즈니스 로직 포함, UI State 관여 |
3 계층별 상세 가이드
StateFlow / LiveData로 UI State를 캡슐화하여 노출합니다. 비즈니스 로직은 직접 수행하지 않고 UseCase에 위임하여 God Object를 방지합니다.GetUserProfileUseCase처럼 명확한 목적을 갖고, 여러 ViewModel에서 재사용할 수 있습니다.4 실전 예제 코드 (Kotlin)
사용자 프로필을 가져오는 기능을 Data → Domain → Presentation 순으로 구현합니다.
① Data Layer — DTO & API Interface
data class UserDto(
val id: String,
val first_name: String,
val last_name: String,
val profile_url: String?
) {
fun toDomainModel(): User = User(
id = id,
fullName = "$first_name $last_name",
imageUrl = profile_url ?: "default_url"
)
}
interface UserApi {
@GET("/users/{userId}")
suspend fun getUserProfile(@Path("userId") userId: String): UserDto
}
② Domain Layer — Model, Repository (Interface), UseCase
// 순수 비즈니스 Domain Model
data class User(val id: String, val fullName: String, val imageUrl: String)
// 계약서 (Interface) — 구현체 없음
interface UserRepository {
suspend fun getUserProfile(userId: String): Result<User>
}
// 단일 책임 UseCase
class GetUserProfileUseCase(
private val userRepository: UserRepository // 인터페이스에만 의존
) {
suspend operator fun invoke(userId: String): Result<User> {
if (userId.isBlank())
return Result.failure(IllegalArgumentException("유효하지 않은 유저 ID"))
return userRepository.getUserProfile(userId)
}
}
③ Data Layer — RepositoryImpl (구현체)
class UserRepositoryImpl(
private val userApi: UserApi
) : UserRepository {
override suspend fun getUserProfile(userId: String): Result<User> {
return try {
val dto = userApi.getUserProfile(userId)
Result.success(dto.toDomainModel()) // DTO → Domain Model 매핑
} catch (e: Exception) {
Result.failure(e)
}
}
}
④ Presentation — ViewModel & UI State
sealed class UserUiState {
object Loading : UserUiState()
data class Success(val user: User) : UserUiState()
data class Error(val message: String) : UserUiState()
}
@HiltViewModel
class UserViewModel @Inject constructor(
private val getUserProfileUseCase: GetUserProfileUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow<UserUiState>(UserUiState.Loading)
val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
fun fetchUser(userId: String) {
viewModelScope.launch {
_uiState.value = UserUiState.Loading
getUserProfileUseCase(userId)
.onSuccess { _uiState.value = UserUiState.Success(it) }
.onFailure { _uiState.value = UserUiState.Error(it.message ?: "에러") }
}
}
}
⑤ Presentation — UI (Jetpack Compose)
@Composable
fun UserProfileScreen(viewModel: UserViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsState()
when (uiState) {
is UserUiState.Loading -> CircularProgressIndicator()
is UserUiState.Success -> Text("안녕하세요, ${(uiState as UserUiState.Success).user.fullName}님!")
is UserUiState.Error -> Text("에러: ${(uiState as UserUiState.Error).message}")
}
}
5 왜 이렇게까지 나누어야 할까?
"그냥 ViewModel에서 Retrofit API를 직접 호출하면 훨씬 짧지 않나?"라는 생각이 드는 건 자연스럽습니다. 하지만 앱 규모가 커질수록 아래 세 가지 이점이 엄청난 차이를 만들어냅니다.
"네트워크 실패 시 캐시 데이터를 보여줘"라는 요구사항이 추가돼도 RepositoryImpl 내부만 수정하면 됩니다. UI, ViewModel, UseCase는 단 1줄도 건드릴 필요가 없습니다.
Repository가 Interface이므로 테스트 시 진짜 RepositoryImpl 대신 FakeRepository를 주입할 수 있습니다. 서버 상태와 무관하게 안정적인 독립 테스트가 가능합니다.
GetUserProfileUseCase 하나로 마이페이지, 친구 상세, 결제 페이지 등 여러 ViewModel에서 재사용 가능합니다. 서버 API가 미완성이더라도 FakeRepository로 UI 개발을 멈춤 없이 진행할 수 있습니다.
🏁 핵심 정리
- UI: 사용자 입력 전달 + State 관찰 → 화면 렌더링만 담당
- ViewModel: UI State 관리, UseCase 호출 위임 (God Object 방지)
- UseCase: 단일 비즈니스 로직 캡슐화 — 재사용 가능한 순수 영역
- Repository (Interface): Domain이 요구하는 데이터 조달 계약서
- RepositoryImpl: 외부 API/DB 통신 + DTO → Domain Model 매핑
- 의존성 방향: UI · Data → Domain (단방향, 역방향 불가)
처음에는 복잡하게 느껴지더라도, 지금 진행 중인 프로젝트에 UseCase 하나부터 천천히 도입해 보세요. 구조가 쌓일수록 코드베이스가 얼마나 건강해지는지 직접 체감하게 될 것입니다.
'🚀 Android' 카테고리의 다른 글
| [뉴스] 안드로이드 개발자 인증 의무화에 대해 알아보자. (0) | 2026.04.01 |
|---|