안드로이드 클린 아키텍처 - YangJJune/U-Compass GitHub Wiki

  • 목표 : 시스템을 만들고, 유지보수하는 비용을 줄이기 위함 !
  • 클린 아키텍처는 사실 안드로이드 시스템을 위해 만들어진 것이 아니고, 다른 큰 시스템을 위해 만들어진 것임.
  • 그래서 앱 수준보다 큰 시스템에 더 잘 어울리긴 함. ex) Kotlin MultiPlatform
  • 하지만 알아두면 KMP 에서 재사용 가능!
    • ex) 비즈니스 로직, 유즈케이스 등을 재사용해서 iOS 용, Android 용 둘 다 사용 가능

클린 아키텍처 by 로버트 마틴

소프트웨어의 구조를 설계할 때 지켜야 할 원칙과 방법을 정의한 개념

클린 아키텍처 다이어그램

image

의존성 규칙

image

  • 반드시 바깥쪽에서 안쪽으로 흘려야 함
  • 외부에서는 내부에 의존하고 있지만, 내부의 원은 외부에 대해 알지 못해야 함 !
  • 외부원으로 인해서, 내부가 수정되면 안됨 !!
    • ex) 파랑 Repository, SQLite → Room 으로 변경 ⇒ 내부의 엔티티가 변경 (x)

엔티티

image

  • 가장 고수준
  • 가장 핵심적인 규칙, 행동, 로직 등을 캡슐화
  • 변경이 되지 않을 만한 것들이 들어가야 함

유즈케이스

image

  • 애플리케이션이 갖는 기능, 비즈니스 로직들을 단일 책임 원칙을 준수하고 캡슐화 하는 계층
  • 단일 기능을 갖는 것을 추상적으로 구현
  • ex) getUserUseCase()

인터페이스 어댑터

image

  • 유즈케이스에서 선언된 것들에 대한 상세 구현을 함.
  • 동심원 계층을 넘나들 때, 계층이 가장 이해하기 쉬운 타입으로 변환
  • use case ↔ entity , db ↔ ui

프레임워크 및 드라이버

image

  • 안드로이드 프레임어크를 포함한 세부적인 내용이 포함
  • 안드로이드 SDK, Fragment, Framework

동심원은 꼭 4개가 아니어도 됨.

  • 위의 사진들은 예시일 뿐!
  • 그보다 많아도 되고, 적어도 됨.
  • 하지만 기본 원칙인, 밖에서 안으로 의존하는 원칙은 지켜져야 함!

계층을 횡단하기

image

  • Flow of control
  • example
    1. UI 에서 버튼을 누름
    2. Use case 를 통해 DB 에 접근
    3. Controller 에서 DB의 DTO 를 다른 data type 으로 매핑해줌
    4. 매핑된 데이터를 가지고 use case 를 통해 UI 에 접근
    5. Presenter 에서 UI 에 어울리는 data type 으로 매핑해줌
  • 의존성 역전 원칙 image
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

안드로이드에서 클린아키텍처 적용하기

구글이 권장하는 앱 아키텍처

image

  • 앱 아키텍처가 늦게 나온 이유
    • 개발자들의 관심이 처음엔 View 시스템, Design 시스템, UI 에 집중되어 있었음
      • UI 라는 것이 초기엔 완전 중요했기 때문.
      • UI 는 많이 변경되는 요소이고, 그래서 이거를 어떻게 체계화 할 수 있을까 ~~ 하다가
        • MVP, MVVM, MVI, 선언형 UI 등이 생겨났고,
        • UI 에 대한 설계가 방향성이 잡히게 됨!
    • 그래서 이제 개발자들의 관심이 방향성이 잡힌 UI 보다는 앱 아키텍처에 대해 관심이 생기기 시작함.
      • 앱 아키텍처에 관한 고민도 많아지게 되었고, 그래서
      • 구글이 권장하는 앱 아키텍처를 소개함.
  • 의존성은 화살표 방향을 따라감
  • 클린 아키텍처와 비슷함!
  • 구글이 앱 아키텍처를 선택한 이유
    1. 공식문서에서 소개하는 가이드라인이기 때문에, 범용적으로 적용될 수 있는 아키텍처이어야 여러 시스템에 적용될 수 있음.
    2. 안드로이드 앱 프로젝트는 그렇게 큰 프로젝트가 아님.
    • 구글의 입장에서 안드로이드 프로젝트가 그렇게 큰 프로젝트가 아니고, 범용적으로 적용될 수 있는 아키텍처를 소개해야 한다면, 복잡하게 생각할 필요 없이 개발자들이 많이 사용하고 공감할 만한 아키텍처(2~3개의 layer로도 구성가능한)를 소개하는게 좋았을 것 같다.

앱 아키텍처 vs 클린 아키텍처

  1. 앱 아키텍처의 경우에는 2개의 layer, 클린 아키텍처의 경우에는 일반적으로 4개의 layer
  2. 둘다 계층별로 나뉘어져 있고, 의존성이 한 방향으로 이어져 있음
  3. 그렇지만서도 가장 큰 차이점은, 계층 간의 의존성 방향임!
    1. 앱 아키텍처에서는 UI 가 Data 를 의존함.
    2. 클린 아키텍처에서는, DB → UI 로 data 를 이동시킬 때, DB→UseCase→Entity 까지는 연결이 되지만, Entity → UI 는 직접적으로 연결이 되지 않기 때문에, 인터페이스 분리 원칙과, 의존성 역전 원칙을 통해서 접근함.
      1. UI 와 Data 가 완벽히 분리되어 있음.
      2. 협업 시에도 유리하고, 외부 변화로부터도 유리함

클린 아키텍처를 위한 모듈화

image image

  • 구글이 권장하는 앱 아키텍처에서는 UI 를 테스트 할 때, domain, data layer 를 의존하고 있기 때문에 빌드시 모든 것들을 빌드해야 함.
  • 클린 아키텍처에서는 테스트 시에 필요한 모듈만 빌드해서 시간을 줄일 수 있음 !

image

핵심 : “관심사의 분리”

  1. 프레임워크 독립성
    • Android, iOS, Flutter, Web 등 특정 프레임워크에 종속되지 않고 도구로만 사용 (View 단편적인 예시)
  2. 테스트 용이성
    • 관심사의 분리 덕분에 비즈니스 로직을 UI, DB 등 외부 요소와 상관없이 테스트 가능
  3. UI 독립성
    • UI 가 변경되더라도 비즈니스 로직에 영향을 미치지 않음
  4. DB 독립성
    • DB 를 교체해도 비즈니스 로직에 영향이 없음
  5. 외부 요소 독립성
    • 외부요소에 대한 의존성이 없음. 내부는 외부를 몰라도 된다는 의미

의존성 규칙

  • 코드의 의존성을 항상 안쪽으로만 향해야 함 → 내부는 외부를 모름!
  • 외부 레이어의 데이터 형식이나 구현체는 내부 레이어에서 사용하면 안됨
  • 외부가 변경된다고해서 내부가 변경될 일이 없음

안드로이드에서의 클린 아키텍처

image

  • 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

장점

  1. 유지 보수성 증가
    • 각 계층의 역할이 명확함
      • UI - 화면 표시
      • Presentation - 화면에 표시되는 데이터 가공
      • Domain - 코어한 비즈니스 로직, 엔티티 모델 관리
      • Data(+Local, Remote) - 실제 데이터 접근, 데이터 전송
    • ⇒ 코드 수정, 버그 발생 시 코드의 수정 및 추적 범위가 줄어들음
    • 명확한 구조가 있기 때문에 미리 협의되고, 구조에 대해 잘 이해하고 있다면 장기적인 측면에서 유리함.
  2. 테스트 용이
    • 비즈니스 로직, 데이터 로직, UI 로직이 각각 분리되어 있어서 단위 테스트 작성에 용이함.
      • Data - Mock Api Server 를 구성해 테스트
      • Domain - JUnit 테스트
      • UI - Espresso UI 테스트
  3. 확장성, 유연성, 협업 효율성
    • 새로운 기능 추가 시 기존 코드에 영향이 적음
    • 다양한 플랫폼에서 비즈니스 로직 재사용 가능
      • Android, iOS, Web
      • 특히 Kotlin MultiPlatform 을 구축할 때 유용할 듯!
    • 명확한 계층 분리로 인해 개발자 간의 역할 분리를 확실하게 할 수 있고, 작업 충돌 가능성이 적음

단점

  1. 초기 러닝 커브
    • 클린 아키텍처에 대한 이해가 필요하고, 적용하는 데 코드 분석과 학습 등 시간과 노력이 필요
  2. 초기 개발 속도 감소
    • 좋은 아키텍처를 위해 구조 설정, 계층 분리 등 초기 세팅에 시간이 많이 소요됨
    • 빠른 프로토타입이 필요한 프로젝트에는 부적합.
    • ⇒ 프로젝트의 특성과 요구사항에 맞게 아키텍처를 선택해야 함.
  3. 과도한 추상화
    • 인터페이스 분리, 모듈 분리 이외에도 과도한 추상화가 발생 가능해 오히려 코드의 복잡도가 증가함.
    • 이해하기 어려운 인터페이스와 패턴이 발생 가능
    • ⇒ 코드의 단순함을 유지하는 것을 우선시 해야함!

중요한 점

  • 명확한 관심사의 분리
  • 의존성 역전 원칙
  • 테스트 용이

개발팀으로서 중요한 점 ⇒ 기한 내에 프로덕트를 완성 하는 것! , 따라서 아키텍처에 집중한다고 프로덕트 마감 기한에 실패하면 안됨.