Android 클린 아키텍처(작성중)
Android 클린 아키텍처에 대해 공부해봅시다.
1. 클린 아키텍처란?
- 코드를 읽기 쉽고 변경하기 쉬운 구조
- 관심사 분리(각 모듈의 독립성)를 통해 실현
- 안드로이드에서만 사용가능한 구조는 아님
엉클밥의 클린아키텍처
클린 아키텍처를 찾아보면 가장 많이 볼 수 있는 구조이다. 위 그림이 의미하는게 뭘까?
- 클린 아키텍처는 대표적으로
Presenters,Use Cases,Entities세 레이어로 나뉜다 (의존성 규칙만 지킨다면 개수의 제한은 없다) - 외부에서 내부로만 의존성을 갖는다 (그 반대는 존재하지 않는다)
- Use Cases는 Presenters의 존재를 모르고, Entities에 의존한다
- Entities는 자신 외에 어떤 존재도 모른다
- 이를 이용해 외부가 변경되더라도 내부가 변경되지 않는 구조를 만들 수 있다
(예시) 쇼핑몰 결제방식 추가
- 쇼핑몰에 신규 결제 방식(ex. 애플페이)가 추가되었을 때 시스템의 핵심 로직이 함께 바뀌면 위험할 수 있다
- 클린 아키텍처가 적용된 시스템에서는 새로운 결제방식(Presenters)가 추가 되더라도 내부의 Use Cases와 Entities에 영향이 없다
- 단순히 새로운 결제 화면을 추가하고, 기존의 결제 로직을 가져와 사용하면 된다
내부는 외부를 모르는데 어떻게 시스템이 동작할까?
- 화면에서 결제 요청을 보내고, 실제 결제가 이뤄진 뒤 다시 화면에 표시를 해주려면 내부가 외부에 대한 정보를 알아야 하는게 아닌가?
추상화를 이용한다
- 로직에 해당하는 영역을 인터페이스 형태로 설계하고, 외부에서 사용하는 시점에 구현하도록 한다
- 더 상위 영역이 재료를 제공해주면 하위에선 가져다 사용하는 느낌
- 이를 통해 내부는 외부를 모르고, 외부또한 내부를 호출하지만 내부가 어떻게 동작하는지 관심을 가질 필요가 없게된다
- 각 레이어가
독립적이게 된다
- 각 레이어가
2. 안드로이드 클린 아키텍처
위 사진은 엉클밥의 클린 아키텍처 개념을 안드로이드 프로그램에 적용하는 방법이다.
- 크게
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)
}
}
}
안드로이드 라이프사이클(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
- 필요시 의존성 인스턴스를 생성하여 주입하는 방법
- 의존성이 필요할 때마다 매번 생성
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)
}
}


