[Android] THE Architecture

Daemon·2025년 11월 9일

Android

목록 보기
4/13
post-thumbnail

들어가며

이 글에서는 Android 앱 개발 과정에서 적용한 아키텍처를 돌아보며, 클린 아키텍처와 구글 앱 권장 아키텍처의 핵심 차이점과 선택 기준에 대해 이야기하고자 한다.


1. 클린 아키텍처의 본질: 관심사의 분리

아키텍처는 왜 필요한가?

초기 안드로이드 개발 시절 그러니까 지금처럼 모바일 디바이스에 램이 12GB나 들어갈 정도로 발전되지 않았던 시절에는 아키텍처 없이도 앱을 만들 수 있었다. Activity에 모든 로직을 넣어도 앱은 잘 동작했지만 시간이 지나며 다음과 같은 문제들을 마주하게 된다:

  • 기능 추가/수정 시 예상치 못한 버그 발생
  • 내가 작성한 코드조차 이해하기 어려워짐
  • 테스트 코드 작성의 어려움

이러한 문제들을 해결하기 위해 자연스럽게 코드를 구조화하고, 역할을 분리하게 된다. 이것이 바로 '아키텍처'의 시작이라고 볼 수 있다.

클린 아키텍처의 핵심

클린 아키텍처를 한 문장으로 정의한다면:

"관심사의 분리(Separation of Concerns)"

MVVM 패턴을 예로 들어보겠다:

  • ViewModel은 View를 모름
  • View는 Model을 모름
  • Model은 ViewModel만 알고 있음

여기서 '모른다'는 것은 '의존하지 않는다', '관심이 없다'는 의미이고, 이것이 바로 관심사의 분리인 것이다.


2. Robert C. Martin의 클린 아키텍처

의존성 규칙 (Dependency Rule)

클린 아키텍처의 가장 중요한 원칙:

"코드 의존성은 항상 안쪽으로만 향해야 한다"

Entities (가장 안쪽)
  ↑
Use Cases
  ↑
Interface Adapters
  ↑
Frameworks & Drivers (가장 바깥쪽)

핵심 규칙:

  • 내부 레이어는 외부 레이어를 절대 모름
  • 외부가 변경되어도 내부는 영향을 받지 않음
  • 외부 레이어의 데이터 형식이나 구현체를 내부에서 사용할 수 없음

각 레이어의 역할

Entities

  • 기업 전반의 비즈니스 규칙을 캡슐화
  • 외부 변화에 영향받지 않는 핵심 로직
  • 예: 은행 시스템의 계좌 이체 규칙

Use Cases

  • 애플리케이션의 구체적인 비즈니스 규칙
  • Entity를 활용하여 특정 목표 달성
  • 예: 영화 티켓 예매, 장바구니 담기

Interface Adapters

  • 내부와 외부 시스템 간 데이터 변환
  • DB, API 등의 외부 형식 ↔ 내부 처리 형식

Frameworks & Drivers

  • 구체적인 구현 세부사항 (DB, UI, 네트워크 등)
  • 비즈니스 로직에 영향을 주지 않음

의존성 역전 원칙 (Dependency Inversion)

위에서 언급한 핵심 규칙 중에 이러한 것이 있다.

"내부 레이어는 외부 레이어를 절대 모름"

안쪽 레이어가 바깥쪽 구현을 모르면서도 어떻게 동작할까요?

해답: 추상화 (Interface)

Domain Layer는 Interface만 정의하고, Data Layer에서 구현한다. 이를 가능하게 하는 도구가 Hilt, Koin 같은 DI 라이브러리이고 라이브러리 없이 직접 구현도 가능하다. 어렵다. 아니 재밌을 것이다.


3. Android Clean Architecture

레이어 구조

안드로이드 환경에 맞춰 적용한 클린 아키텍처는 다음과 같은 레이어로 구성된다:

UI Layer (Android)
  ↓
Presentation Layer (Android - ViewModel)
  ↓
Domain Layer (Pure Kotlin) ← 핵심!
  ↓
Data Layer (Kotlin/Android)
  ↓
Remote/Local Layer (Kotlin/Android)

Domain Layer: 순수한 비즈니스 로직의 중심

특징:

  • 가장 안쪽에 위치
  • 어떤 레이어도 의존하지 않음
  • 안드로이드 플랫폼 독립적 (Pure Kotlin)
  • 외부 변경에 영향받지 않음

설계 팁:
Domain Layer를 설계할 때는 이렇게 해야한다.

"기획자의 관점에서 생각할 수 있는가?"

기획 의도와 비즈니스 규칙을 표현하는 곳이 Domain Layer다. 만약 기획자가 sudo code를 작성한다면 어떻게 작성할지 상상하며 설계해야한다.

만약 기획자의 의도가 바뀐다면 어느 레이어가 바뀌어야할까? 라고 스스로한테 물어봤을 때 자신 있게 Domain이라고 말할 수 있을 정도로 설계해야한다.

Data Layer: 데이터 제공자

역할:

  • Domain Layer에 필요한 데이터 제공
  • 비즈니스 로직은 관심사가 아님 (데이터 전달에만 집중)
  • Repository 패턴을 통해 Domain과 연결

Presentation Layer: ViewModel의 영역

규칙:

  • Domain Layer의 UseCase만 의존
  • android.* 패키지 import 금지 (AAC 제외)
  • R.xxx 사용 불가
  • UI Layer를 모름

Data Flow:

User Input (UI)
  → ViewModel (Presentation)
  → UseCase (Domain)
  → Repository (Data)
  → DataSource (Remote/Local)

4. Google 앱 아키텍처

핵심 차이점

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)

가장 큰 차이:

  1. Domain Layer는 선택사항

    • 필요한 경우에만 UseCase 생성
    • Repository만으로 충분한 경우 UseCase 불필요
  2. 의존성 방향이 다름

[클린 아키텍처]
Presentation → Domain (UseCase) → Repository Interface
                                   ↑
                              Data (Impl)

[앱 아키텍처]
UI (+ ViewModel) → Domain (선택) → Data → Network

5. 핵심 차이점 비교

공통점

항목설명
관심사의 분리두 아키텍처 모두 핵심 원칙
의존성 주입Interface/Impl 구조, DI 활용
단방향 데이터 흐름데이터 일관성 보장
DTO & MapperLayer별 모델 분리 및 변환

차이점

1. Domain Layer의 위치

구분클린 아키텍처앱 아키텍처
Domain Layer필수선택
UseCase 사용모든 기능에 필수필요한 경우만
ViewModel 의존Domain만 의존Data 직접 의존 가능

2. 의존성 방향 가장 중요함!!!

클린 아키텍처:

Domain (안쪽) ← Data ← Remote
  • Domain은 아무것도 모름
  • Data는 Domain(Interface)만 의존
  • 의존성 역전 원칙 준수

앱 아키텍처:

UI → Domain → Data → Network
  • Domain이 Data를 의존
  • Data가 Network를 의존
  • 단방향 의존성

6. 실무 프로젝트 아키텍처 분석

프로젝트 구조 개요

실제 프로덕션에 적용한다면 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)             │
└─────────────────────────────────────┘

의존성 방향 검증: 클린 아키텍처 완벽 준수

1. Domain은 독립적

domain/
├── entity/              # 순수 비즈니스 모델
├── repository/          # Interface만 정의
└── usecase/             # 비즈니스 로직
  • Android SDK 의존성 없음 (Pure Kotlin)
  • 다른 레이어 의존 없음

2. Data는 Domain을 의존

실제 코드는 곧 회사의 자산이기에 앞으로 나오는 코드는 모두 가짜이고, 적절치 않은 예시일 가능성 있으니 주의바람.

// 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 변환
    }
}

3. Presentation은 Domain만 의존

@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 패턴 분석

프로젝트에서는 UseCase를 3가지 유형으로 활용한다:

(1) 단순 Repository 위임하는 UseCase

class FetchProductListUseCase @Inject constructor(
    private val repository: ProductRepository
) {
    suspend operator fun invoke(): List<Product> {
        return repository.fetchProducts()
    }
}

선택 이유:

  • 일관된 인터페이스 제공
  • 미래 확장성 확보 (비즈니스 규칙 추가 가능)
  • ViewModel이 Repository를 직접 의존하지 않도록

(2) 복잡한 비즈니스 로직을 포함하는 UseCase

예시 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에 배치했는가?

  • UI나 DB에 독립적인 순수 비즈니스 규칙
  • 여러 ViewModel에서 재사용 가능
  • 단위 테스트 용이 (Repository만 Mock)
  • "기획자가 정의한 가격 계산 로직"

예시 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) }
    }
}

핵심 가치:

  • 복잡한 분기 로직을 Domain에 격리
  • 기획 요구사항 변경 시 이 UseCase만 수정
  • ViewModel은 단순히 UseCase 호출만

(3) 여러 UseCase 조합하는 UseCase

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
        )
    }
}

3단계 Model 분리 전략

실제 프로젝트에서는 각 레이어별로 독립적인 모델을 정의한다:

┌──────────────────────────────────────────┐
│ 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               │
│   )                                      │
└──────────────────────────────────────────┘

Mapper 패턴 적용:

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
            )
        }
    }
}

장점:

  • API 스펙 변경사항 발생시→ DTO만 수정
  • UI 요구사항 변경사항 발생시 → UiModel만 수정
  • 비즈니스 규칙 변경 발생시→ Entity만 수정

7. 마치며

왜 클린 아키텍처를 선택했는가?

  1. 복잡한 비즈니스 로직

    • 가격 계산, 할인 정책, 재고 관리
    • 이러한 로직을 UI나 DB에서 분리해야 함
  2. 여러 화면에서 동일한 로직 재사용

    • 장바구니, 주문, 결제 화면에서 동일한 가격 계산 로직 사용
    • 상품 목록, 상세, 검색 화면에서 동일한 필터링 로직 사용
  3. 테스트 용이성

    • 비즈니스 로직을 독립적으로 테스트
    • Repository Mock을 통한 UseCase 단위 테스트
  4. 플랫폼 독립성

    • 향후 iOS 앱과 비즈니스 로직 공유 가능성
    • Domain Layer는 Pure Kotlin으로 작성

결론: 클린 아키텍처가 최적의 선택

앱 아키텍처를 사용하는 것이 틀린가?

Google의 앱 아키텍처도 훌륭한 선택이다.

그리고 사실 나 같은 주니어는 아키텍처에 집중하는 것보다 Android Internal에 집중하는 것이 더 맞고 Corountine 공부를 더 하는 것이 맞지 않을까 싶다.

다만 모든 것은 필요에 의해서 굴러가야하고 소프트웨어 개발 원칙 중에 YAGNI 원칙대로 필요한 것만, 필요할 때만 해야하는 것이 맞는 것 같다.

0개의 댓글