이 글에서는 Android 앱 개발 과정에서 적용한 아키텍처를 돌아보며, 클린 아키텍처와 구글 앱 권장 아키텍처의 핵심 차이점과 선택 기준에 대해 이야기하고자 한다.
초기 안드로이드 개발 시절 그러니까 지금처럼 모바일 디바이스에 램이 12GB나 들어갈 정도로 발전되지 않았던 시절에는 아키텍처 없이도 앱을 만들 수 있었다. Activity에 모든 로직을 넣어도 앱은 잘 동작했지만 시간이 지나며 다음과 같은 문제들을 마주하게 된다:
이러한 문제들을 해결하기 위해 자연스럽게 코드를 구조화하고, 역할을 분리하게 된다. 이것이 바로 '아키텍처'의 시작이라고 볼 수 있다.
클린 아키텍처를 한 문장으로 정의한다면:
"관심사의 분리(Separation of Concerns)"
MVVM 패턴을 예로 들어보겠다:
여기서 '모른다'는 것은 '의존하지 않는다', '관심이 없다'는 의미이고, 이것이 바로 관심사의 분리인 것이다.

클린 아키텍처의 가장 중요한 원칙:
"코드 의존성은 항상 안쪽으로만 향해야 한다"
Entities (가장 안쪽)
↑
Use Cases
↑
Interface Adapters
↑
Frameworks & Drivers (가장 바깥쪽)
핵심 규칙:
위에서 언급한 핵심 규칙 중에 이러한 것이 있다.
"내부 레이어는 외부 레이어를 절대 모름"
안쪽 레이어가 바깥쪽 구현을 모르면서도 어떻게 동작할까요?
해답: 추상화 (Interface)
Domain Layer는 Interface만 정의하고, Data Layer에서 구현한다. 이를 가능하게 하는 도구가 Hilt, Koin 같은 DI 라이브러리이고 라이브러리 없이 직접 구현도 가능하다. 어렵다. 아니 재밌을 것이다.

안드로이드 환경에 맞춰 적용한 클린 아키텍처는 다음과 같은 레이어로 구성된다:
UI Layer (Android)
↓
Presentation Layer (Android - ViewModel)
↓
Domain Layer (Pure Kotlin) ← 핵심!
↓
Data Layer (Kotlin/Android)
↓
Remote/Local Layer (Kotlin/Android)
특징:
설계 팁:
Domain Layer를 설계할 때는 이렇게 해야한다.
"기획자의 관점에서 생각할 수 있는가?"
기획 의도와 비즈니스 규칙을 표현하는 곳이 Domain Layer다. 만약 기획자가 sudo code를 작성한다면 어떻게 작성할지 상상하며 설계해야한다.
만약 기획자의 의도가 바뀐다면 어느 레이어가 바뀌어야할까? 라고 스스로한테 물어봤을 때 자신 있게 Domain이라고 말할 수 있을 정도로 설계해야한다.
역할:
규칙:
android.* 패키지 import 금지 (AAC 제외)R.xxx 사용 불가Data Flow:
User Input (UI)
→ ViewModel (Presentation)
→ UseCase (Domain)
→ Repository (Data)
→ DataSource (Remote/Local)

Google의 공식 앱 아키텍처는 클린 아키텍처가 아니다.
이와 관한 논쟁이 Now In Android 라는 안드로이드 공식 샘플 레포지토리에서 일어났다.
Now in Android 프로젝트 문서에서도 명시하고 있다:
"The official Android architecture is different from other architectures, such as 'Clean Architecture'."
UI Layer (UI + ViewModel)
↓
Domain Layer (선택사항!)
↓
Data Layer (Repository + DataSource)
가장 큰 차이:
Domain Layer는 선택사항
의존성 방향이 다름
[클린 아키텍처]
Presentation → Domain (UseCase) → Repository Interface
↑
Data (Impl)
[앱 아키텍처]
UI (+ ViewModel) → Domain (선택) → Data → Network
| 항목 | 설명 |
|---|---|
| 관심사의 분리 | 두 아키텍처 모두 핵심 원칙 |
| 의존성 주입 | Interface/Impl 구조, DI 활용 |
| 단방향 데이터 흐름 | 데이터 일관성 보장 |
| DTO & Mapper | Layer별 모델 분리 및 변환 |
| 구분 | 클린 아키텍처 | 앱 아키텍처 |
|---|---|---|
| Domain Layer | 필수 | 선택 |
| UseCase 사용 | 모든 기능에 필수 | 필요한 경우만 |
| ViewModel 의존 | Domain만 의존 | Data 직접 의존 가능 |
클린 아키텍처:
Domain (안쪽) ← Data ← Remote
앱 아키텍처:
UI → Domain → Data → Network
실제 프로덕션에 적용한다면 Android 앱에서는 다음과 같은 3계층 구조를 채택할 수 있다:
┌─────────────────────────────────────┐
│ Presentation Layer │
│ - ViewModel (@HiltViewModel) │
│ - UI (Jetpack Compose) │
│ - UiState (Immutable Data Class) │
└──────────────┬──────────────────────┘
│ depends on
┌──────────────▼──────────────────────┐
│ Domain Layer (Pure Kotlin) │
│ - Entity │
│ - UseCase │
│ - Repository Interface │
└──────────────┬──────────────────────┘
│ implements
┌──────────────▼──────────────────────┐
│ Data Layer │
│ - Repository Implementation │
│ - DataSource (Retrofit/Ktor) │
│ - DTO (@Serializable) │
└─────────────────────────────────────┘
domain/
├── entity/ # 순수 비즈니스 모델
├── repository/ # Interface만 정의
└── usecase/ # 비즈니스 로직
실제 코드는 곧 회사의 자산이기에 앞으로 나오는 코드는 모두 가짜이고, 적절치 않은 예시일 가능성 있으니 주의바람.
// Domain Layer (Interface)
package com.example.domain.repository
interface PaymentRepository {
suspend fun processPayment(amount: Int): PaymentResult
}
// Data Layer (Implementation)
package com.example.data.repository
@Singleton
class PaymentRepositoryImpl @Inject constructor(
private val apiService: PaymentApiService
) : PaymentRepository {
override suspend fun processPayment(amount: Int): PaymentResult {
val response = apiService.processPayment(request)
return response.data.toDomain() // DTO → Entity 변환
}
}
@HiltViewModel
class CheckoutViewModel @Inject constructor(
private val processPaymentUseCase: ProcessPaymentUseCase,
private val fetchUserBalanceUseCase: FetchUserBalanceUseCase,
private val validateCouponUseCase: ValidateCouponUseCase
) : ViewModel() {
// Repository를 직접 주입받지 않음!
// 오직 UseCase만 의존
}
상황에 따라서 usecase를 쓰지 않고 repository를 직접 주입받을 때도 있고, 혹은 usecase들을 묶어서 또 하나의 usecase를 만드는 경우도 있고 상황마다 다른 것 같지만 재사용성을 좀 더 따져서 usecase로만 주입했다.
프로젝트에서는 UseCase를 3가지 유형으로 활용한다:
class FetchProductListUseCase @Inject constructor(
private val repository: ProductRepository
) {
suspend operator fun invoke(): List<Product> {
return repository.fetchProducts()
}
}
선택 이유:
예시 1: 쇼핑 카트 총액 계산
class CalculateCartTotalUseCase @Inject constructor(
private val cartRepository: CartRepository
) {
suspend operator fun invoke(): CartSummary {
val cartItems = cartRepository.fetchCartItems()
// 비즈니스 로직 1: 상품 총액 계산
val subtotal = cartItems.sumOf { it.price * it.quantity }
// 비즈니스 로직 2: 할인 적용
val discount = cartItems
.filter { it.hasDiscount }
.sumOf { (it.price * it.quantity * it.discountRate) }
// 비즈니스 로직 3: 배송비 계산
val shippingFee = if (subtotal >= 30000) 0 else 3000
// 비즈니스 로직 4: 최종 금액 계산
val total = subtotal - discount + shippingFee
return CartSummary(
subtotal = subtotal,
discount = discount,
shippingFee = shippingFee,
total = total
)
}
}
왜 UseCase에 배치했는가?
예시 2: 주문 완료 후 보상 결정
class DetermineOrderRewardUseCase @Inject constructor(
private val evaluateMembershipTierUseCase: EvaluateMembershipTierUseCase,
private val fetchOrderHistoryUseCase: FetchOrderHistoryUseCase
) {
suspend operator fun invoke(
orderAmount: Int,
isFirstOrder: Boolean,
usedCoupon: Boolean
): List<OrderReward> {
val rewards = mutableListOf<OrderReward>()
// 비즈니스 규칙 1: 포인트 적립
val pointRate = if (usedCoupon) 0.01 else 0.03
val earnedPoints = (orderAmount * pointRate).toInt()
if (earnedPoints > 0) {
rewards += OrderReward.Points(earnedPoints)
}
// 비즈니스 규칙 2: 첫 주문 보상
if (isFirstOrder) {
rewards += OrderReward.WelcomeCoupon(5000)
}
// 비즈니스 규칙 3: 멤버십 등급 업그레이드 체크
val newTier = evaluateMembershipTierUseCase()
if (newTier != null) {
rewards += OrderReward.TierUpgrade(newTier)
}
return rewards.ifEmpty { listOf(OrderReward.None) }
}
}
핵심 가치:
class ExecuteOrderUseCase @Inject constructor(
private val orderRepository: OrderRepository,
private val validateStockUseCase: ValidateStockUseCase,
private val applyDiscountUseCase: ApplyDiscountUseCase
) {
suspend operator fun invoke(
productId: String,
quantity: Int,
couponCode: String?
): OrderResult {
// 1. 재고 확인
validateStockUseCase(productId, quantity)
// 2. 할인 적용
val discount = couponCode?.let {
applyDiscountUseCase(it)
}
// 3. 주문 생성
return orderRepository.createOrder(
productId = productId,
quantity = quantity,
discount = discount
)
}
}
실제 프로젝트에서는 각 레이어별로 독립적인 모델을 정의한다:
┌──────────────────────────────────────────┐
│ Presentation Layer │
│ @Stable │
│ data class ProductUiModel( │
│ val priceText: String, │ ← UI 표시용
│ val discountBadge: String, │
│ val isAvailable: Boolean │
│ ) │
└──────────────┬───────────────────────────┘
│ fromDomain()
┌──────────────▼───────────────────────────┐
│ Domain Layer │
│ data class Product( │
│ val price: Int, │ ← 비즈니스 로직용
│ val discountRate: Double, │
│ val stock: Int │
│ ) │
└──────────────┬───────────────────────────┘
│ toDomain()
┌──────────────▼───────────────────────────┐
│ Data Layer │
│ @Serializable │
│ data class ProductDTO( │ ← API 통신용
│ val price: Int, │
│ val discount_rate: Double, │
│ val stock_count: Int │
│ ) │
└──────────────────────────────────────────┘
DTO → Domain Entity
// Data Layer
@Serializable
data class ProductDTO(
val id: String,
val price: Int,
val discount_rate: Double
) {
fun toDomain(): Product {
return Product(
id = id,
price = price,
discountRate = discount_rate
)
}
}
Domain Entity → UiModel
// Presentation Layer
@Stable
data class ProductUiModel(
val id: String,
val priceText: String,
val discountBadge: String,
val hasDiscount: Boolean
) {
companion object {
fun fromDomain(product: Product): ProductUiModel {
val formatter = DecimalFormat("#,###")
val hasDiscount = product.discountRate > 0
return ProductUiModel(
id = product.id,
priceText = "${formatter.format(product.price)}원",
discountBadge = if (hasDiscount) "${(product.discountRate * 100).toInt()}%" else "",
hasDiscount = hasDiscount
)
}
}
}
장점:
복잡한 비즈니스 로직
여러 화면에서 동일한 로직 재사용
테스트 용이성
플랫폼 독립성
결론: 클린 아키텍처가 최적의 선택
Google의 앱 아키텍처도 훌륭한 선택이다.
그리고 사실 나 같은 주니어는 아키텍처에 집중하는 것보다 Android Internal에 집중하는 것이 더 맞고 Corountine 공부를 더 하는 것이 맞지 않을까 싶다.
다만 모든 것은 필요에 의해서 굴러가야하고 소프트웨어 개발 원칙 중에 YAGNI 원칙대로 필요한 것만, 필요할 때만 해야하는 것이 맞는 것 같다.