(Android/안드로이드) 클린 아키텍처(Clean Architecture) 완전 정리

개요


1. 클린 아키텍처란

Robert C. Martin(Uncle Bob)이 제안한 설계 원칙으로, 코드를 동심원(레이어) 으로 나눠 바깥쪽이 안쪽에만 의존 하도록 강제합니다.

┌─────────────────────────────────────────┐
│           Presentation Layer            │  ← UI, ViewModel, State
│  ┌───────────────────────────────────┐  │
│  │          Domain Layer             │  │  ← UseCase, Entity, Repository 인터페이스
│  │  ┌─────────────────────────────┐  │  │
│  │  │        Data Layer           │  │  │  ← Repository 구현, API, DB
│  │  └─────────────────────────────┘  │  │
│  └───────────────────────────────────┘  │
└─────────────────────────────────────────┘

의존 방향: Presentation → Domain ← Data
                   (Data는 Domain에 의존, Presentation도 Domain에 의존)

핵심 규칙

규칙 설명
의존성 규칙 안쪽 레이어는 바깥쪽을 몰라야 한다
Domain 독립성 Domain은 Android 프레임워크에 의존하지 않는다
경계(Boundary) 레이어 간 통신은 인터페이스(추상화)를 통해 이루어진다

2. 레이어별 역할

Presentation Layer

Domain Layer

Data Layer


3. 프로젝트 패키지 구조

app/
├── presentation/
│   ├── product/
│   │   ├── ProductFragment.kt
│   │   ├── ProductViewModel.kt
│   │   └── ProductUiState.kt
│   └── cart/
│       ├── CartFragment.kt
│       └── CartViewModel.kt
│
├── domain/
│   ├── model/
│   │   ├── Product.kt          ← 도메인 엔티티
│   │   └── CartItem.kt
│   ├── repository/
│   │   ├── ProductRepository.kt  ← 인터페이스
│   │   └── CartRepository.kt
│   └── usecase/
│       ├── GetProductsUseCase.kt
│       ├── AddToCartUseCase.kt
│       └── GetCartItemsUseCase.kt
│
└── data/
    ├── remote/
    │   ├── ProductApi.kt
    │   └── dto/
    │       └── ProductDto.kt
    ├── local/
    │   ├── ProductDao.kt
    │   └── entity/
    │       └── ProductEntity.kt
    ├── mapper/
    │   └── ProductMapper.kt
    └── repository/
        ├── ProductRepositoryImpl.kt
        └── CartRepositoryImpl.kt

4. Domain Layer 구현

도메인 엔티티 — 순수 Kotlin 데이터 클래스

// domain/model/Product.kt
data class Product(
    val id: Long,
    val name: String,
    val price: Int,
    val imageUrl: String,
    val category: String,
    val stock: Int,
    val isFavorite: Boolean = false
) {
    // 비즈니스 규칙을 엔티티 안에 캡슐화
    val isInStock: Boolean get() = stock > 0
    val discountedPrice: Int get() = if (price >= 10_000) (price * 0.9).toInt() else price
}

Repository 인터페이스 — Domain이 계약을 정의

// domain/repository/ProductRepository.kt
interface ProductRepository {
    suspend fun getProducts(category: String? = null): Result<List<Product>>
    suspend fun getProduct(productId: Long): Result<Product>
    suspend fun toggleFavorite(productId: Long): Result<Unit>
    fun observeProducts(): Flow<List<Product>>  // 실시간 관찰
}

UseCase — 단일 비즈니스 로직 단위

// domain/usecase/GetProductsUseCase.kt
class GetProductsUseCase(
    private val productRepository: ProductRepository
) {
    // 'operator fun invoke' — useCase() 형태로 호출 가능
    suspend operator fun invoke(category: String? = null): Result<List<Product>> {
        return productRepository.getProducts(category)
            .map { products ->
                // 비즈니스 규칙: 재고 있는 상품 먼저, 이름 순 정렬
                products
                    .sortedWith(compareByDescending<Product> { it.isInStock }.thenBy { it.name })
            }
    }
}
// domain/usecase/AddToCartUseCase.kt
class AddToCartUseCase(
    private val cartRepository: CartRepository,
    private val productRepository: ProductRepository
) {
    suspend operator fun invoke(productId: Long, quantity: Int = 1): Result<Unit> {
        // 비즈니스 규칙: 재고 확인 후 장바구니 추가
        val productResult = productRepository.getProduct(productId)

        return productResult.fold(
            onSuccess = { product ->
                if (!product.isInStock) {
                    Result.failure(IllegalStateException("품절된 상품입니다"))
                } else {
                    cartRepository.addItem(productId, quantity)
                }
            },
            onFailure = { Result.failure(it) }
        )
    }
}

5. Data Layer 구현

DTO — API 응답 모델

// data/remote/dto/ProductDto.kt
data class ProductDto(
    @SerializedName("product_id") val productId: Long,
    @SerializedName("product_name") val productName: String,
    @SerializedName("product_price") val productPrice: Int,
    @SerializedName("image_url") val imageUrl: String,
    @SerializedName("category_name") val categoryName: String,
    @SerializedName("stock_count") val stockCount: Int
)

Mapper — DTO ↔ 도메인 엔티티 변환

// data/mapper/ProductMapper.kt
fun ProductDto.toDomain(): Product = Product(
    id = productId,
    name = productName,
    price = productPrice,
    imageUrl = imageUrl,
    category = categoryName,
    stock = stockCount
)

fun ProductEntity.toDomain(): Product = Product(
    id = id,
    name = name,
    price = price,
    imageUrl = imageUrl,
    category = category,
    stock = stock,
    isFavorite = isFavorite
)

fun Product.toEntity(): ProductEntity = ProductEntity(
    id = id,
    name = name,
    price = price,
    imageUrl = imageUrl,
    category = category,
    stock = stock,
    isFavorite = isFavorite
)

Repository 구현체

// data/repository/ProductRepositoryImpl.kt
class ProductRepositoryImpl(
    private val productApi: ProductApi,       // Remote DataSource
    private val productDao: ProductDao        // Local DataSource
) : ProductRepository {

    override suspend fun getProducts(category: String?): Result<List<Product>> {
        return runCatching {
            // 네트워크 우선, 실패 시 로컬 캐시 반환
            try {
                val remoteProducts = productApi.getProducts(category)
                    .map { it.toDomain() }

                // 로컬 캐시 갱신
                productDao.deleteAll()
                productDao.insertAll(remoteProducts.map { it.toEntity() })

                remoteProducts
            } catch (e: Exception) {
                // 오프라인: 로컬 캐시 반환
                productDao.getAll().map { it.toDomain() }
                    .takeIf { it.isNotEmpty() }
                    ?: throw e
            }
        }
    }

    override suspend fun getProduct(productId: Long): Result<Product> {
        return runCatching {
            productDao.getProduct(productId)?.toDomain()
                ?: productApi.getProduct(productId).toDomain()
        }
    }

    override suspend fun toggleFavorite(productId: Long): Result<Unit> {
        return runCatching {
            productDao.toggleFavorite(productId)
        }
    }

    override fun observeProducts(): Flow<List<Product>> {
        return productDao.observeAll().map { entities ->
            entities.map { it.toDomain() }
        }
    }
}

6. Presentation Layer 구현 (MVVM + 클린 아키텍처)

// presentation/product/ProductUiState.kt
data class ProductUiState(
    val products: List<Product> = emptyList(),
    val isLoading: Boolean = false,
    val errorMessage: String? = null,
    val selectedCategory: String? = null
)

sealed class ProductUiEvent {
    data class ShowToast(val message: String) : ProductUiEvent()
    data class NavigateToDetail(val productId: Long) : ProductUiEvent()
}
// presentation/product/ProductViewModel.kt
@HiltViewModel
class ProductViewModel @Inject constructor(
    private val getProductsUseCase: GetProductsUseCase,     // UseCase 주입
    private val addToCartUseCase: AddToCartUseCase
) : ViewModel() {

    private val _uiState = MutableStateFlow(ProductUiState())
    val uiState: StateFlow<ProductUiState> = _uiState.asStateFlow()

    private val _uiEvent = Channel<ProductUiEvent>(Channel.BUFFERED)
    val uiEvent = _uiEvent.receiveAsFlow()

    init {
        loadProducts()
    }

    fun loadProducts(category: String? = null) {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true, errorMessage = null) }

            // UseCase 호출 — ViewModel은 비즈니스 규칙을 몰라도 됨
            getProductsUseCase(category)
                .onSuccess { products ->
                    _uiState.update {
                        it.copy(isLoading = false, products = products, selectedCategory = category)
                    }
                }
                .onFailure { error ->
                    _uiState.update { it.copy(isLoading = false, errorMessage = error.message) }
                    _uiEvent.send(ProductUiEvent.ShowToast("상품을 불러오지 못했습니다"))
                }
        }
    }

    fun addToCart(productId: Long) {
        viewModelScope.launch {
            addToCartUseCase(productId)
                .onSuccess {
                    _uiEvent.send(ProductUiEvent.ShowToast("장바구니에 담았습니다"))
                }
                .onFailure { error ->
                    _uiEvent.send(ProductUiEvent.ShowToast(error.message ?: "오류가 발생했습니다"))
                }
        }
    }

    fun onProductClick(productId: Long) {
        viewModelScope.launch {
            _uiEvent.send(ProductUiEvent.NavigateToDetail(productId))
        }
    }
}

7. Hilt 의존성 주입 설정

// di/DomainModule.kt
@Module
@InstallIn(ViewModelComponent::class)
object DomainModule {

    @Provides
    fun provideGetProductsUseCase(
        productRepository: ProductRepository
    ): GetProductsUseCase = GetProductsUseCase(productRepository)

    @Provides
    fun provideAddToCartUseCase(
        cartRepository: CartRepository,
        productRepository: ProductRepository
    ): AddToCartUseCase = AddToCartUseCase(cartRepository, productRepository)
}

// di/DataModule.kt
@Module
@InstallIn(SingletonComponent::class)
object DataModule {

    @Provides
    @Singleton
    fun provideProductRepository(
        productApi: ProductApi,
        productDao: ProductDao
    ): ProductRepository = ProductRepositoryImpl(productApi, productDao)

    @Provides
    @Singleton
    fun provideCartRepository(
        cartDao: CartDao
    ): CartRepository = CartRepositoryImpl(cartDao)
}

8. 멀티 모듈 구성 (고급)

규모가 커지면 레이어를 독립 Gradle 모듈 로 분리해 빌드 속도와 의존성 방향을 명확히 할 수 있습니다.

:app                    ← 진입점 (모든 모듈 조합)
:presentation           ← Fragment, ViewModel, UI
:domain                 ← UseCase, Entity, Repository 인터페이스
:data                   ← Repository 구현체, API, DB
:core:network           ← Retrofit, OkHttp 공통 설정
:core:database          ← Room 공통 설정
:core:ui                ← 공통 UI 컴포넌트
// domain/build.gradle.kts — Android 의존성 없음
plugins {
    id("java-library")          // 순수 Kotlin 모듈
    alias(libs.plugins.kotlin.jvm)
}

dependencies {
    implementation(libs.kotlinx.coroutines.core)
    // Retrofit, Room, Android SDK 의존 없음
}
// data/build.gradle.kts
plugins {
    alias(libs.plugins.android.library)
    alias(libs.plugins.kotlin.android)
}

dependencies {
    implementation(project(":domain"))  // Domain에 의존
    implementation(libs.retrofit)
    implementation(libs.room.runtime)
}

9. 테스트

UseCase 단위 테스트 — 순수 Kotlin, Android 불필요

class GetProductsUseCaseTest {

    private val fakeProductRepository = FakeProductRepository()
    private val useCase = GetProductsUseCase(fakeProductRepository)

    @Test
    fun `재고 있는 상품이 먼저 정렬된다`() = runTest {
        // given
        fakeProductRepository.products = listOf(
            Product(1L, "품절 상품", 5000, "", "", stock = 0),
            Product(2L, "재고 상품 B", 3000, "", "", stock = 5),
            Product(3L, "재고 상품 A", 4000, "", "", stock = 3)
        )

        // when
        val result = useCase()

        // then
        val products = result.getOrThrow()
        assert(products[0].name == "재고 상품 A")  // 재고 있는 것 중 이름 순
        assert(products[1].name == "재고 상품 B")
        assert(products[2].name == "품절 상품")    // 품절은 마지막
    }

    @Test
    fun `AddToCartUseCase  품절 상품은 실패를 반환한다`() = runTest {
        // given
        val outOfStockProduct = Product(1L, "품절 상품", 5000, "", "", stock = 0)
        fakeProductRepository.productDetail = outOfStockProduct
        val addToCartUseCase = AddToCartUseCase(FakeCartRepository(), fakeProductRepository)

        // when
        val result = addToCartUseCase(productId = 1L)

        // then
        assert(result.isFailure)
        assert(result.exceptionOrNull()?.message == "품절된 상품입니다")
    }
}

Repository 테스트 — Fake DataSource 활용

class ProductRepositoryImplTest {

    private val fakeApi = FakeProductApi()
    private val fakeDao = FakeProductDao()
    private val repository = ProductRepositoryImpl(fakeApi, fakeDao)

    @Test
    fun `API 성공  로컬 DB 캐싱한다`() = runTest {
        // given
        val dtos = listOf(ProductDto(1L, "상품 A", 5000, "", "", 10))
        fakeApi.productsResponse = dtos

        // when
        repository.getProducts()

        // then
        assert(fakeDao.insertedEntities.size == 1)
        assert(fakeDao.insertedEntities[0].name == "상품 A")
    }

    @Test
    fun `API 실패  로컬 캐시를 반환한다`() = runTest {
        // given
        fakeApi.shouldThrow = true
        fakeDao.cachedEntities = listOf(ProductEntity(1L, "캐시 상품", 3000, "", "", 5))

        // when
        val result = repository.getProducts()

        // then
        assert(result.isSuccess)
        assert(result.getOrThrow()[0].name == "캐시 상품")
    }
}

10. 클린 아키텍처 체크리스트

항목 확인
Domain이 Android SDK를 import하지 않는가
Repository가 인터페이스(Domain)로 정의되는가
ViewModel이 UseCase만 알고 Repository 구현을 모르는가
DTO와 도메인 엔티티가 분리되어 있는가
UseCase가 단일 책임을 갖는가
각 레이어가 단독으로 테스트 가능한가

11. 클린 아키텍처 vs 일반 MVVM 비교

항목 일반 MVVM 클린 아키텍처 + MVVM
ViewModel이 Repository를 직접 호출 O X (UseCase 경유)
비즈니스 규칙 위치 ViewModel 혼재 Domain UseCase에 집중
Domain 독립성 없음 Android SDK 의존 없음
테스트 속도 Android 에뮬레이터 필요 순수 JVM 테스트
초기 구조 복잡도 낮음 높음
규모 확장성 중소규모 적합 중대규모 적합

12. 정리

레이어 핵심 구성 요소 의존 방향
Presentation ViewModel, UiState, Fragment → Domain
Domain UseCase, Entity, Repository 인터페이스 없음 (독립)
Data Repository 구현체, DTO, Mapper, API, DAO → Domain

참고



Related Posts