Post

Android 클린 아키텍처(작성중)

Android 클린 아키텍처에 대해 공부해봅시다.

Android 클린 아키텍처(작성중)

1. 클린 아키텍처란?

  • 코드를 읽기 쉽고 변경하기 쉬운 구조
  • 관심사 분리(각 모듈의 독립성)를 통해 실현
  • 안드로이드에서만 사용가능한 구조는 아님

엉클밥의 클린아키텍처

clean_architecture Robert C. Martin

클린 아키텍처를 찾아보면 가장 많이 볼 수 있는 구조이다. 위 그림이 의미하는게 뭘까?

  • 클린 아키텍처는 대표적으로 Presenters, Use Cases, Entities 세 레이어로 나뉜다 (의존성 규칙만 지킨다면 개수의 제한은 없다)
  • 외부에서 내부로만 의존성을 갖는다 (그 반대는 존재하지 않는다)
    • Use Cases는 Presenters의 존재를 모르고, Entities에 의존한다
    • Entities는 자신 외에 어떤 존재도 모른다
    • 이를 이용해 외부가 변경되더라도 내부가 변경되지 않는 구조를 만들 수 있다

 

(예시) 쇼핑몰 결제방식 추가

  • 쇼핑몰에 신규 결제 방식(ex. 애플페이)가 추가되었을 때 시스템의 핵심 로직이 함께 바뀌면 위험할 수 있다
  • 클린 아키텍처가 적용된 시스템에서는 새로운 결제방식(Presenters)가 추가 되더라도 내부의 Use Cases와 Entities에 영향이 없다
  • 단순히 새로운 결제 화면을 추가하고, 기존의 결제 로직을 가져와 사용하면 된다

 

내부는 외부를 모르는데 어떻게 시스템이 동작할까?

  • 화면에서 결제 요청을 보내고, 실제 결제가 이뤄진 뒤 다시 화면에 표시를 해주려면 내부가 외부에 대한 정보를 알아야 하는게 아닌가?
    • 추상화를 이용한다
  • 로직에 해당하는 영역을 인터페이스 형태로 설계하고, 외부에서 사용하는 시점에 구현하도록 한다
    • 더 상위 영역이 재료를 제공해주면 하위에선 가져다 사용하는 느낌
  • 이를 통해 내부는 외부를 모르고, 외부또한 내부를 호출하지만 내부가 어떻게 동작하는지 관심을 가질 필요가 없게된다
    • 각 레이어가 독립적이게 된다

2. 안드로이드 클린 아키텍처

android_clean_architecture Android Clean Architecture

위 사진은 엉클밥의 클린 아키텍처 개념을 안드로이드 프로그램에 적용하는 방법이다.

  • 크게 UI(App Inputs), Presentation, Domain, Data, Data Source 레이어가 존재한다
  • 화살표가 나타내는 의존성 규칙에 따라 Domain 레이어가 가장 내부에 위치한다
    • Preesntation과 Data 레이어의 경우 Domain 레이어에 의존성을 갖는다

Domain Layer

  • 순수 비즈니스 로직 (은행의 입출금, 쇼핑몰 주문처리, 음악 스트리밍 재생.. 등 가장 core한 기능)
  • 가장 안쪽이 있는 layer이기 때문에 다른 어떤 layer와도 의존성이 없어야함
  • Domain layer의 변경은 비즈니스 로직 자체가 바뀔때만 (다른 layer에 의한 변경은 존재할 수 없음)
  • 엉클밥의 클린 아키텍처에서 Use Cases와 Entity에 해당
  • 순수 Kotlin 코드 (다른 layer와 의존성이 없기 때문에)

Domain layer는 또 다시 Model, UseCase, Repository로 나뉜다

  • Model: 비즈니스의 객체 (비즈니스를 구성하는 대상들)
1
2
3
4
// 쇼핑몰에선 상품, 고객, 주문건 등이 될 수 있다
data Class Product(val productId:Int, val name:String, val price:Double)
data Class Customer(val id:Int, val name:String)
data Class Order(val orderId:Int, val orderItems:ArrayList<Product>)
  • UseCase: 앱에서 실행되는 비즈니스 로직
    • Repository를 사용하여 비즈니스 로직을 수행
1
2
3
4
5
6
7
8
9
interface GetProductUseCase {
    fun invoke(productId: Int): Product
}

class GetProductUseCaseImpl(private val productRepository: ProductRepository): GetProductUseCase {
    override fun invoke(productId: Int): Product {
        return productRepository.getProductById(productId)
    }
}
  • Repository: Domain과 Data layer를 연결하는 추상화
    • 인터페이스를 설계해놓으면 data layer에서 구현하여 사용하는 방식이기 때문에 domain은 data가 실제 어떻게 구현되었는지 알 지 못함
    • Domain는 data layer가 DB를 local, remote, 캐시 어디서 가져오는지 알 수 없음

Domain layer는 마치 수도코드와 비슷하다.

  • 로직(논리)은 모두 담고 있지만 디테일하게 구현되지는 않는다는 점에서 수도코드와 비슷하다.
  • 또는 기획자와 역할이 비슷하다고 볼 수도 있다.

Data Layer

  • 비즈니스에서 필요한 데이터를 제공하는 layer
  • 비즈니스 로직과 무관하게 데이터 전달에만 집중
  • Repository를 구현하여 domain layer와 연결

Data layer는 또 다시 Repository Impl, Data Source, Model, Model Mapper로 나뉜다

  • Repository Impl: Domain layer와 연결하기 위해서 domain layer의 추상화 클래스를 구현한 클래스다
  • Data Source: 실제 데이터와 연결을 위한 추상화 클래스다
    • remote db, local db, 캐시 등에서 이를 구현하여 데이터를 전달한다
    • LocalDataSource, RemoteDataSource 등으로 나뉠 수 있다
  • Model: 데이터를 나타내는 객체
  • Model Mapper: Data layer의 모델을 domain 모델로 변환 (반대도 수행)
1
2
3
4
5
6
7
8
9
10
11
Class ProductRepositoryImpl(
    private val productRemoteDataSource: ProductRemoteDataSource,
    private val productLocalDataSource: ProductLocalDataSource,
    private val productMapper: ProductMapper    
) : ProductRepository {

    override fun getProductById(productId: Int): Product {
        val product = productLocalDataSource.getProductById(productId) ?: productRemoteDataSource.getProductById(productId)
        return productMapper.mapEntityToDomain(product)
    }
}

(Bottom-Up 관점에선) data는 domian과 연결을 위해 domain 추상화를 구현하고, data source는 data와 연결을 위해 data 추상화를 구현한다

(Top-Down 관점에선) 서비스의 비즈니스 로직을 담당하는 domain은 다른 모든 영역에 의존성을 갖지 않기 위해서 추상 클래스로 큰 흐름의 설계만 해놓고(마치 수도코드와 같이), 그 아래 영역 data, dataSouce에서 상위의 추상 클래스를 구현하여 세부적인 동작을 정의하는 방식이다


Remote

  • 원격 서버와 통신하면서 데이터를 주고 받는 layer
  • 데이터 요청, 응답 처리, 네트워크 오류 처리 등 포함
    • Retrofit(OkHttp)와 같은 라이브러리 활용

Remote layer는 DataSource Impl, API Interface, Model로 나뉜다

1
2
3
4
5
6
7
8
9
10
class ProductRemoteDataSoruceImpl(
    private val apiService: ApiService,
    private val productMapper: ProductMapper
) : ProductRemoteDataSoruce {

    override fun getProductById(productId: Int): ProductEntity {
        val remoteProduct = apiService.getProductById(productId)
        return remoteProduct.mapResponseToEntity(remoteProduct)
    }
}
1
2
3
4
5
// retrofit
interface ApiService {
    @GET("product/{productId}")
    suspend fun getProductById(@Path("productId") productId: Int): ProductResponse
}

Local

  • 로컬 데이터를 저장하고 읽어오는 layer
  • 주로 DB(Room), SharedPreferences, 파일 시스템 등으로 관리
  • 삭제될 수 있다는 가정으로 설계
    • 대부분 Nullable 리턴

Presentation

  • UI와 상호작용 하면서, 주로 MVVM 패턴의 ViewModel이 사용
  • Domain에서 제공하는 UseCase를 사용하여 데이터를 가져오고 UI에 전달
  • UI에 액션을 처리해서 domain layer에 전파하는 역할도 수행

ViewModel

  • 상태관리UseCase사용이 ViewModel 핵심
  • LiveData, StateFlow로 UI의 상태를 관리
    • 이러한 상태값을 이용해 기존 안드로이드 라이프사이클과는 별개의 UI 작업을 할 수 있게된다
    • 기존에 OnCreate, onResume, onRestart 등에서 하던 작업을 할 필요가 없고, 오로지 상태값이 변경됨에 따라 작업을 처리
    • Android 플랫폼에 독립적이기 위해선 LiveDate 보다는 Kotlin 자체의 상태관리 수단인 StateFlow를 사용하는 것이 더 적합하다
  • Android의 ViewModel과는 별개지만 실제 구현에서는 Android 라이프사이클 관리 측면 등의 이유로 Android ViewModel을 사용할 수 밖에 없음
  • 단, ViewModel에는 android 패키지가 없어야한다
    • import android.* (x)
    • R.* (x)
  • 단방향 데이터 흐름
    • 데이터는 ViewModel -> UI
    • 액션은 UI -> ViewModel
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class ProductViewModel(
    private val getProductUseCase: GetProductUseCase
): ViewModel() {
    
    // Names for backing properties (Kotlin convention)
    // 클래스에 개념적으로 동일하지만 하나는 공개 API의 일부이고 다른 하나는 구현 세부 사항인 두 개의 속성이 있는 경우 
    // 비공개 속성의 이름에 대한 접두사로 _을 사용
    private val _product = MutableLiveData<Product>()
    val product: LiveData<Product> 
        get() = _product

    fun loadProduct(productId: Int) {
        viewModelScope.launch {
            _product.value = getProductUseCase.invoke(productId)
        }
    }
}

activity_lifecycle 안드로이드 라이프사이클(ViewModel을 사용하여 상태값을 관리하면 위 라이프사이클에 따라 작업을 할 필요가 없음)


UI

  • XML, Compose
  • 사용자의 액션을 받아 Use Case를 사용
  • 단 Domain의 존재는 모른다

SOLID

  • 객체 지향 프로그래밍에서 견고하고 유지보수 가능한 개발을 위한 원칙
  • 클린 아케틱처를 만드는 기반이 되며, 모듈화 계층화를 통해 코드의 유연성과 확장성을 높임
  • S: 단일책임원칙(Single Reponsibility Principle)
    • 클래스나 모듈은 오직 하나의 책임만 가져야 하며, 오직 하나의 이유로 변경되야 한다
    • 복잡성을 줄이기 위한 핵심 원칙으로, 클래스가 여러 가지 일을 할 때 발생할 수 있는 변경의 부담을 줄임
    • 안드로이드 클린 아키텍처에서는 각 layer간 자신의 책임만 갖도록 함
    • Domain은 비즈니스 로직, Data는 데이터 처리, Presentation은 UI와의 상호작용
  • O: 개방/폐쇄 원칙(Open/Closed Principle)
    • 확장에는 열려있고, 수정에는 닫혀 있어야 한다
    • 즉, 새로운 기능을 추가할 때는 기존 코드를 변경하지 않고, 코드를 확장하는 방식으로 처리
    • Use Case 설계 시 매우중요
      • Use Case나 기능의 변경 없이도 새로운 요구사항에 대응할 수 있도록 함
  • L: 리스코프 치환 원칙(Liskov Substitution Principle)
    • 서브클래스는 언제나 자산의 기반 클래스(상위클래스)를 대체할 수 있어야한다
  • I: 인터페이스 분리 원칙(Interface Segregation Principle)
    • 인터페이스는 최소한의 기능만 갖도록 한다
    • 인터페이스를 구현하는 클래스가 불필요한 기능을 갖지 않도록 하기 위함
  • D: 의존성 역전 원칙(Dependency Inversion Principle)
    • 고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 한다

Model

  • 각 layer마다 그에 맞는 모델이 존재한다
  • 클린 아키텍처의 각 layer간 독립성을 확보하기 위함이다
    • 모든 layer가 동일한 객체를 사용하면 의존성이 발생하기 때문
  • Layer별 Product 모델의 정의
    • Domain: Product
    • Data: ProductEntity
    • Remote: ProductResponse / ProductRequest
    • Local: ProductLocal
    • Presentation: ProductModel
    • UI: ProductState
  • 모델에 따라 각 layer마다 Model Mapper 또한 존재한다

DataResource

3. 의존성 주입(DI)

  • 특정 클래스가 다른 클래스를 필요로하는 경우가 많음
  • 이와 같이 다른 클래스가 필요할때 이를 의존성(Dependency)이라고 함
  • 예를들어 Car 클래스는 Engine 클래스가 필요함

의존성 주입의 다양한 방법

  • 직접생성: 클래스 내에서 필요한 의존성을 직접 생성
    • 두 클래스 사이 결합이 강해 확장성이 낮음
    • Gasoline, Deisel 엔진으로 나누기 어려움
1
2
3
4
5
6
7
8
9
10
11
12
13
class Car {
    // 클래스 내부에서 필요한 의존성 클래스를 직접생성
    private val engine = Engine()

    fun start() {
        engine.start()
    }
}

fun main(args: Array) {
    val car = Car()
    car.start()
}
  • 주입받기: 매개변수를 통해 외부에서 주입을 받는 방식
    • 직접생성 방식과 달리 다양한 엔진을 사용가능
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Car(private val engine: Engine) {
    fun start() {
        engine.start()
    }
}

fun main(args: Array) {
    val engine = Engine()
    val car = Car(engine)
    car.start()

    // 다른 엔진을 사용하고 싶을땐?
    val gasoline = GasolineEngine()
    val car = Car(gasoline)
    car.start()
}

수동 DI

di_ex_1

  • 필요시 의존성 인스턴스를 생성하여 주입하는 방법
    • 의존성이 필요할 때마다 매번 생성
1
2
3
4
5
6
7
8
9
class UserRepository(
    private val localDataSource: UserLocalDataSource,
    private val remoteDataSource: UserRemoteDataSource
) { ... }

class UserLocalDataSource { ... }
class UserRemoteDataSource(
    private val loginService: LoginRetrofitService
) { ... }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class LoginActivity: Activity() {

    private lateinit var loginViewModel: LoginViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val retrofit = Retrofit.Builder()
            .baseUrl("https://example.com")
            .build()
            .create(LoginService::class.java)

        // 레트로핏 인스턴스를 생성하여 주입
        val remoteDataSource = UserRemoteDataSource(retrofit)
        val localDataSource = UserLocalDataSource()

        // DataSource 인스턴스를 생성하여 주입
        val userRepository = UserRepository(localDataSource, remoteDataSource)

        // Repository 인스턴스를 생성하여 주입
        loginViewModel = LoginViewModel(userRepository)
    }
}

위 코드와 같이 뷰모델에서 필요한 레파지토리(or 유즈케이스)를 생성할 때, 필요한 모든 의존성을 인스턴스를 생성하여 주입이 가능

하지만 뷰모델이 많아질수록 동일한 초기화 코드가 반복되고, 의존성의 순서에 따라 코드 작성이 필요해 관리가 어려워진다

  • AppContainer를 활용한 개선된 DI 구조는
    • 의존성을 한 곳에서 관리하고 필요한 곳에 주입하여 코드 중복을 줄임
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class AppContainer {
    private val retrofit = Retrofit.Builder()
                            .baseUrl("https://example.com")
                            .build()
                            .create(LoginService::class.java)

    private val remoteDataSource = UserRemoteDataSource(retrofit)
    private val localDataSource = UserLocalDataSource()

    // 어디서든 필요할때 사용가능한 레파지토리
    val userRepository = UserRepository(localDataSource, remoteDataSource)
}

class MyApplication : Application() {
    // 어플리케이션 클래스에서 사용하여 앱전체에 AppContainer 인스턴스를 공유
    val appContainer = AppContainer()
}
1
2
3
4
5
6
7
8
9
10
11
12
class LoginActivity: Activity() {

    private lateinit var loginViewModel: LoginViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState) 
        val appContainer = (application as MyApplication).appContainer

        // 별도 의존성 인스턴스 생성 과정이 필요없음
        loginViewModel = LoginViewModel(appContainer.userRepository)
    }
}

    



99. References

  1. 테드박의 안드로이드 앱 개발 : 확장성과 유지보수에 용이한 클린 아키텍처
  2. 안드로이드 클린 아키텍처
This post is licensed under CC BY 4.0 by the author.

Trending Tags