ANDROID · iOS 아키텍처

MVVM + Clean Architecture
UI부터 RepositoryImpl까지 설계 가이드

단방향 데이터 흐름의 각 계층 역할과 실전 Kotlin 코드까지

MVVM을 처음 접할 때 View와 ViewModel 분리까지는 쉽게 이해하지만, 프로젝트가 커지면서 UseCase, Repository, RepositoryImpl이 왜 필요한지 헷갈리는 경우가 많습니다. 이 글에서는 UI → ViewModel → UseCase → Repository → RepositoryImpl로 이어지는 단방향 데이터 흐름의 구조와 각 계층의 명확한 역할, 실전 코드까지 한번에 정리합니다.

핵심 원칙: 의존성은 항상 바깥(UI · Data) → 안쪽(Domain) 방향으로만 흐릅니다. 안쪽 계층은 바깥쪽 계층이 어떻게 생겼는지 절대 알지 못해야 합니다.

1 전체 아키텍처 구조와 데이터 흐름

사용자가 버튼을 클릭했을 때 데이터가 어떻게 이동하는지 흐름도로 확인하세요.

👆 UI (View)
🧠 ViewModel
⚙️ UseCase
📋 Repository (IF)
🗄️ RepositoryImpl
🌐 Data Sources

↑ 응답은 반대 방향으로 흘러 최종적으로 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 계층별 상세 가이드

LAYER 01 · Presentation
👁️ UI (View)
ViewModel이 노출하는 State를 구독하다가 상태 변경 시 화면을 다시 그립니다. Activity, Fragment, Compose, SwiftUI 등 플랫폼 UI 컴포넌트가 여기에 해당합니다.
LAYER 02 · Presentation
🧠 ViewModel
StateFlow / LiveData로 UI State를 캡슐화하여 노출합니다. 비즈니스 로직은 직접 수행하지 않고 UseCase에 위임하여 God Object를 방지합니다.
LAYER 03 · Domain
⚙️ UseCase
앱의 특정 행동 하나만 담당합니다. GetUserProfileUseCase처럼 명확한 목적을 갖고, 여러 ViewModel에서 재사용할 수 있습니다.
LAYER 04 · Domain
📋 Repository (Interface)
구현체가 없는 계약서(Interface)만 존재합니다. "User 데이터를 가져오는 함수가 필요하다"는 계약만 정의하고, 어떻게 가져올지는 관심 없습니다. (DIP 원칙)
LAYER 05 · Data
🗄️ RepositoryImpl
Repository Interface를 실제로 구현합니다. Remote API와 Local DB 중 어디서 데이터를 가져올지 결정하고, DTO를 Domain Model로 변환(Mapping)합니다.
LAYER 06 · Data
🌐 Data Sources
Retrofit, Room DB, SharedPreferences 등 실제 데이터 저장소입니다. RepositoryImpl이 이들을 조합하여 최적의 데이터를 상위 계층에 전달합니다.

4 실전 예제 코드 (Kotlin)

사용자 프로필을 가져오는 기능을 Data → Domain → Presentation 순으로 구현합니다.

① Data Layer — DTO & API Interface

Kotlin · UserDto.kt
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

Kotlin · Domain.kt
// 순수 비즈니스 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 (구현체)

Kotlin · UserRepositoryImpl.kt
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

Kotlin · UserViewModel.kt
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)

Kotlin · UserProfileScreen.kt
@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를 직접 호출하면 훨씬 짧지 않나?"라는 생각이 드는 건 자연스럽습니다. 하지만 앱 규모가 커질수록 아래 세 가지 이점이 엄청난 차이를 만들어냅니다.

🔀
완벽한 관심사 분리 (SoC)

"네트워크 실패 시 캐시 데이터를 보여줘"라는 요구사항이 추가돼도 RepositoryImpl 내부만 수정하면 됩니다. UI, ViewModel, UseCase는 단 1줄도 건드릴 필요가 없습니다.

🧪
강력한 단위 테스트 (Unit Test)

Repository가 Interface이므로 테스트 시 진짜 RepositoryImpl 대신 FakeRepository를 주입할 수 있습니다. 서버 상태와 무관하게 안정적인 독립 테스트가 가능합니다.

♻️
재사용성과 병렬 개발

GetUserProfileUseCase 하나로 마이페이지, 친구 상세, 결제 페이지 등 여러 ViewModel에서 재사용 가능합니다. 서버 API가 미완성이더라도 FakeRepository로 UI 개발을 멈춤 없이 진행할 수 있습니다.

⚠️ 초기 비용 vs 장기 이익: 보일러플레이트 코드가 늘어나 초기에는 느려 보일 수 있습니다. 그러나 수십 명의 개발자가 협업하고 코드가 쌓일수록 이 구조의 견고함이 빛을 발합니다.

🏁 핵심 정리

  • UI: 사용자 입력 전달 + State 관찰 → 화면 렌더링만 담당
  • ViewModel: UI State 관리, UseCase 호출 위임 (God Object 방지)
  • UseCase: 단일 비즈니스 로직 캡슐화 — 재사용 가능한 순수 영역
  • Repository (Interface): Domain이 요구하는 데이터 조달 계약서
  • RepositoryImpl: 외부 API/DB 통신 + DTO → Domain Model 매핑
  • 의존성 방향: UI · Data → Domain (단방향, 역방향 불가)

처음에는 복잡하게 느껴지더라도, 지금 진행 중인 프로젝트에 UseCase 하나부터 천천히 도입해 보세요. 구조가 쌓일수록 코드베이스가 얼마나 건강해지는지 직접 체감하게 될 것입니다.

+ Recent posts