안드로이드 클린 아키텍처 - YangJJune/U-Compass GitHub Wiki
- 목표 : 시스템을 만들고, 유지보수하는 비용을 줄이기 위함 !
- 클린 아키텍처는 사실 안드로이드 시스템을 위해 만들어진 것이 아니고, 다른 큰 시스템을 위해 만들어진 것임.
- 그래서 앱 수준보다 큰 시스템에 더 잘 어울리긴 함. ex) Kotlin MultiPlatform
- 하지만 알아두면 KMP 에서 재사용 가능!
- ex) 비즈니스 로직, 유즈케이스 등을 재사용해서 iOS 용, Android 용 둘 다 사용 가능
클린 아키텍처 by 로버트 마틴
소프트웨어의 구조를 설계할 때 지켜야 할 원칙과 방법을 정의한 개념
클린 아키텍처 다이어그램
의존성 규칙
- 반드시 바깥쪽에서 안쪽으로 흘려야 함
- 외부에서는 내부에 의존하고 있지만, 내부의 원은 외부에 대해 알지 못해야 함 !
- 외부원으로 인해서, 내부가 수정되면 안됨 !!
- ex) 파랑 Repository, SQLite → Room 으로 변경 ⇒ 내부의 엔티티가 변경 (x)
엔티티
- 가장 고수준
- 가장 핵심적인 규칙, 행동, 로직 등을 캡슐화
- 변경이 되지 않을 만한 것들이 들어가야 함
유즈케이스
- 애플리케이션이 갖는 기능, 비즈니스 로직들을 단일 책임 원칙을 준수하고 캡슐화 하는 계층
- 단일 기능을 갖는 것을 추상적으로 구현
- ex) getUserUseCase()
인터페이스 어댑터
- 유즈케이스에서 선언된 것들에 대한 상세 구현을 함.
- 동심원 계층을 넘나들 때, 계층이 가장 이해하기 쉬운 타입으로 변환
- use case ↔ entity , db ↔ ui
프레임워크 및 드라이버
- 안드로이드 프레임어크를 포함한 세부적인 내용이 포함
- 안드로이드 SDK, Fragment, Framework
동심원은 꼭 4개가 아니어도 됨.
- 위의 사진들은 예시일 뿐!
- 그보다 많아도 되고, 적어도 됨.
- 하지만 기본 원칙인, 밖에서 안으로 의존하는 원칙은 지켜져야 함!
계층을 횡단하기
- Flow of control
- example
- UI 에서 버튼을 누름
- Use case 를 통해 DB 에 접근
- Controller 에서 DB의 DTO 를 다른 data type 으로 매핑해줌
- 매핑된 데이터를 가지고 use case 를 통해 UI 에 접근
- Presenter 에서 UI 에 어울리는 data type 으로 매핑해줌
- 의존성 역전 원칙
interface Engine
class GasolineEngine: Engine
class DieselEngine: Engine
class Car(
val engine: Engine
// 의존성 역전 원칙
)
val engine = DieselEngine()
var car = Car(engine) // 의존성 역전 원칙
- 고수준 : Engine 인터페이스 , 저수준 : Engine 를 구현한 Diesel, Gasoline
- 그림과 코드에서 볼 수 있듯이, Car 라는 객체는 실제로는 DieselEngine() 를 쓰고 있지만,
- 설계 상으로는 Engine() 를 인자로 가지고 있음.
- 이는 의존성 역전 원칙을 지키는 것으로 의존성 역전 원칙이란,
- 구현되지않은, 추상화되어 있는 상태인, 고수준, 상위 수준에 의존하는 것을 의미한다 !
- 이 예시가 아니고, 클린 아키텍처 관점에서 예시를 들자면, 바깥의 동심원에 있는 클래스에서, 같은 계층에 있는 클래스를 의존하는 것이 아닌, 고수준의 계층에 있는 클래스를 의존하는 것!
- 과장된 ex)
val someUseCase : someEntity
- 과장된 ex)
안드로이드에서 클린아키텍처 적용하기
구글이 권장하는 앱 아키텍처
- 앱 아키텍처가 늦게 나온 이유
- 개발자들의 관심이 처음엔 View 시스템, Design 시스템, UI 에 집중되어 있었음
- UI 라는 것이 초기엔 완전 중요했기 때문.
- UI 는 많이 변경되는 요소이고, 그래서 이거를 어떻게 체계화 할 수 있을까 ~~ 하다가
- MVP, MVVM, MVI, 선언형 UI 등이 생겨났고,
- UI 에 대한 설계가 방향성이 잡히게 됨!
- 그래서 이제 개발자들의 관심이 방향성이 잡힌 UI 보다는 앱 아키텍처에 대해 관심이 생기기 시작함.
- 앱 아키텍처에 관한 고민도 많아지게 되었고, 그래서
- 구글이 권장하는 앱 아키텍처를 소개함.
- 개발자들의 관심이 처음엔 View 시스템, Design 시스템, UI 에 집중되어 있었음
- 의존성은 화살표 방향을 따라감
- 클린 아키텍처와 비슷함!
- 구글이 앱 아키텍처를 선택한 이유
- 공식문서에서 소개하는 가이드라인이기 때문에, 범용적으로 적용될 수 있는 아키텍처이어야 여러 시스템에 적용될 수 있음.
- 안드로이드 앱 프로젝트는 그렇게 큰 프로젝트가 아님.
- 구글의 입장에서 안드로이드 프로젝트가 그렇게 큰 프로젝트가 아니고, 범용적으로 적용될 수 있는 아키텍처를 소개해야 한다면, 복잡하게 생각할 필요 없이 개발자들이 많이 사용하고 공감할 만한 아키텍처(2~3개의 layer로도 구성가능한)를 소개하는게 좋았을 것 같다.
앱 아키텍처 vs 클린 아키텍처
- 앱 아키텍처의 경우에는 2개의 layer, 클린 아키텍처의 경우에는 일반적으로 4개의 layer
- 둘다 계층별로 나뉘어져 있고, 의존성이 한 방향으로 이어져 있음
- 그렇지만서도 가장 큰 차이점은, 계층 간의 의존성 방향임!
- 앱 아키텍처에서는 UI 가 Data 를 의존함.
- 클린 아키텍처에서는, DB → UI 로 data 를 이동시킬 때, DB→UseCase→Entity 까지는 연결이 되지만, Entity → UI 는 직접적으로 연결이 되지 않기 때문에, 인터페이스 분리 원칙과, 의존성 역전 원칙을 통해서 접근함.
- UI 와 Data 가 완벽히 분리되어 있음.
- 협업 시에도 유리하고, 외부 변화로부터도 유리함
클린 아키텍처를 위한 모듈화
- 구글이 권장하는 앱 아키텍처에서는 UI 를 테스트 할 때, domain, data layer 를 의존하고 있기 때문에 빌드시 모든 것들을 빌드해야 함.
- 클린 아키텍처에서는 테스트 시에 필요한 모듈만 빌드해서 시간을 줄일 수 있음 !
핵심 : “관심사의 분리”
- 프레임워크 독립성
- Android, iOS, Flutter, Web 등 특정 프레임워크에 종속되지 않고 도구로만 사용 (View 단편적인 예시)
- 테스트 용이성
- 관심사의 분리 덕분에 비즈니스 로직을 UI, DB 등 외부 요소와 상관없이 테스트 가능
- UI 독립성
- UI 가 변경되더라도 비즈니스 로직에 영향을 미치지 않음
- DB 독립성
- DB 를 교체해도 비즈니스 로직에 영향이 없음
- 외부 요소 독립성
- 외부요소에 대한 의존성이 없음. 내부는 외부를 몰라도 된다는 의미
의존성 규칙
- 코드의 의존성을 항상 안쪽으로만 향해야 함 → 내부는 외부를 모름!
- 외부 레이어의 데이터 형식이나 구현체는 내부 레이어에서 사용하면 안됨
- 외부가 변경된다고해서 내부가 변경될 일이 없음
안드로이드에서의 클린 아키텍처
- UI
- Presentation
- Domain
- Data
- Remote
- Local
의존성 역전 원칙
안쪽 원은 바깥쪽 구현을 모름!!
- → 추상화(Interface) 를 통해 사용하고 외부에서 이를 구현함
내부와 외부는 서로의 구체적인 구현사항을 모른 채 추상화된 interface 를 통해 통신함.
- 안쪽에서는 단지 추상화된 interface 만 알 뿐, 실 구현로직은 모르는 채로 통신하는 형태
클린 아키텍처 요약
- 관심사의 분리를 통해 SW 의 유연성과 유지보수성을 극대
- 의존성은 안쪽으로만 흐름.
- SW 가 특정 프레임워크나 기술에 종속되지 않음.
- 비즈니스 로직을 UI 나 DB 와 분리해 독립적인 테스트 가능
- 새로운 요구사항이나 기술 변경에도 최소한의 코드 수정으로 가능
- 오래도록 유지보수와 확장이 쉬운 시스템 구축
Domain
- 순수 비즈니스 로직과 앱의 핵심 규칙을 정의하는 계층
- 가장 안쪽에 존재하며 어떤 layer 도 의존해서는 안됨
- 안드로이드 플랫폼과도 독립적이어야 함
Entity
- 앱의 비즈니스 도메인을 표현하는 객체
- Domain Layer 의 dto
data class User(
val id: String,
val name: String,
val address: String,
)
UseCase
- 앱에서 실행되는 비즈니스 프로세스를 캡슐화하는 역할
- 하나의 Use Case 는 하나의 비즈니스 로직만을 처리해야 함 - SRP
- Repository 를 사용해서 비즈니스 로직을 수행
interface GetUserUseCase {
fun invoke(userId: String): User
}
class GetUserUseCaseImpl(private val userRepository: UserRepository): GetUserUserCase {
override fun invoke(userId: String): User {
return userRepository.getUserById(userId)
}
}
Repository (domain)
- Domain layer 와 Data layer (DataSource) 를 연결하는 인터페이스
- Domain layer 에서는 이 인터페이스의 구현을 알지 못함.
- DB, API, 캐시 등 어떤 DataSource 를 사용하는지 모름
interface UserRepository {
fun getUserById(id: String): User
}
테스트 코드
- 비즈니스 로직이 변경되었을 때 유닛 테스트를 통해 안정성을 검증할 수 있도록 테스트 코드를 작성해야 함
- UseCase 테스트 가능
- DB 나 API 에 상관없이 단위 테스트 가능
class CalculateOrderToTotalUseCaseTest {
private val orderRepository = mock(OrderRepository::class.java)
private val calculateOrderTotalUseCase = CalculateOrderTotalUseCase(orderRepository)
@Test
fun `주문의 전체 개수를 정확히 계산한다`() {
// given
val order = Order("1", listof(Item(...), Item(...), 0.0)
`when`(orderRepository.getOrderById("1")).thenReturn(order)
// when
val total = calculateOrderTotalUseCase.execute("1")
// then
assertEquals(25.0, total)
}
}
주의사항
- 외부에서 코드가 변경되더라도, domain 의 코드가 변경되면 안됨!!
- 서버 api 변경, UI 변경
- UI 나 서버를 고려하지 않고, 핵심 비즈니스 로직에만 집중
- Android 에 의존적이지 않은 순수한 Kotlin 플랫폼 코드여야 함.
Data
- Domain layer 에서 필요한 데이터를 제공
- 비즈니스 로직은 관심사가 아니며, 오직 데이터 전달에만 집중
- Repository 패턴을 사용해 Domain ↔ Data 를 연결
- CRUD 작업 수행
Repository (data)
- Domain layer 에 정의된 Repository(인터페이스) 의 구현체
- 여러 DataSource 를 통합해 Domain layer 에 필요한 데이터를 제공함
class UserRepositoryImple(
private val userRemoteDataSource: UserRemoteDataSource,
private val userMapper : UserMapper
): UserRepository {
override fun getUserById(userId: String): User {
val remoteUser = userRemoteDataSource.getUserById(userId)
return userMapper.mapDtoToDomain(remoteUser)
}
}
class UserRepositoryImple(
private val userRemoteDataSource: UserRemoteDataSource,
private val userLocalDataSource: UserLocalDataSource,
private val userMapper : UserMapper
): UserRepository {
override fun getUserById(userId: String): User {
val localUser = userLocalDataSource.getUserById(userId)
localUser?.let { return userMapper.mapEntityToDomain(localUser)
val remoteUser = userRemoteDataSource.getUserById(userId)
return userMapper.mapDtoToDomain(remoteUser)
}
}
DataSource Interface
- Local, Remote
interface UserRemoteDataSource {
fun getUserById(userId: String): UserEntity
}
interface UserLocalDataSource {
fun getUserById(userId: String): UserEntity
fun saveUser(user: UserEntity)
}
Model Mapper
- Data ↔ Domain 간 모델 변환
internal class UserMapper {
fun mapEntityToDomain(userEntity: UserEntity): User {
return User(
id = userEntity.id,
name = userEntity.name,
address = userEntity.address,
)
}
fun mapDomainToEntity(user: User): UserEntity {
return UserEntity(
id = user.id,
name = user.name,
address = user.address,
)
}
}
주의점
- Repository 는 1개의 책임만 가지도록 설계
- 반응형 프로그래밍: Flow 나 RxJava, RxKotlin 을 이용해 비동기 데이터 스트림을 처리
- Loading, Success, Error 에 관한 상태를 관리해서 처리되도록 설계
- Domain 과 마찬가지로 특정 플랫폼에 의존하지 않고 순수 kotlin 언어로 구현
Mapper
Model 의 정의
User 의 경우
- Domain : User
- Data : UserEntity
- Remote : UserResponse / UserRequest
- Local : UserLocal
- Presentation : UserModel
- UI : UserState (or UserModel 공유)
이 양식을 반드시 지킬 필요는 없고 헷갈리지 않게끔만 서로 약속하면 됨!!
클린 아키텍처의 장단점
- SOLID 원칙을 준수하는 아키텍처임.
- SRP
- OCP
- ISP
- LSP
- DIP
장점
- 유지 보수성 증가
- 각 계층의 역할이 명확함
- UI - 화면 표시
- Presentation - 화면에 표시되는 데이터 가공
- Domain - 코어한 비즈니스 로직, 엔티티 모델 관리
- Data(+Local, Remote) - 실제 데이터 접근, 데이터 전송
- ⇒ 코드 수정, 버그 발생 시 코드의 수정 및 추적 범위가 줄어들음
- 명확한 구조가 있기 때문에 미리 협의되고, 구조에 대해 잘 이해하고 있다면 장기적인 측면에서 유리함.
- 각 계층의 역할이 명확함
- 테스트 용이
- 비즈니스 로직, 데이터 로직, UI 로직이 각각 분리되어 있어서 단위 테스트 작성에 용이함.
- Data - Mock Api Server 를 구성해 테스트
- Domain - JUnit 테스트
- UI - Espresso UI 테스트
- 비즈니스 로직, 데이터 로직, UI 로직이 각각 분리되어 있어서 단위 테스트 작성에 용이함.
- 확장성, 유연성, 협업 효율성
- 새로운 기능 추가 시 기존 코드에 영향이 적음
- 다양한 플랫폼에서 비즈니스 로직 재사용 가능
- Android, iOS, Web
- 특히 Kotlin MultiPlatform 을 구축할 때 유용할 듯!
- 명확한 계층 분리로 인해 개발자 간의 역할 분리를 확실하게 할 수 있고, 작업 충돌 가능성이 적음
단점
- 초기 러닝 커브
- 클린 아키텍처에 대한 이해가 필요하고, 적용하는 데 코드 분석과 학습 등 시간과 노력이 필요
- 초기 개발 속도 감소
- 좋은 아키텍처를 위해 구조 설정, 계층 분리 등 초기 세팅에 시간이 많이 소요됨
- 빠른 프로토타입이 필요한 프로젝트에는 부적합.
- ⇒ 프로젝트의 특성과 요구사항에 맞게 아키텍처를 선택해야 함.
- 과도한 추상화
- 인터페이스 분리, 모듈 분리 이외에도 과도한 추상화가 발생 가능해 오히려 코드의 복잡도가 증가함.
- 이해하기 어려운 인터페이스와 패턴이 발생 가능
- ⇒ 코드의 단순함을 유지하는 것을 우선시 해야함!
중요한 점
- 명확한 관심사의 분리
- 의존성 역전 원칙
- 테스트 용이
개발팀으로서 중요한 점 ⇒ 기한 내에 프로덕트를 완성 하는 것! , 따라서 아키텍처에 집중한다고 프로덕트 마감 기한에 실패하면 안됨.