도대체 이놈의 클린 아키텍처가 뭐길래 이렇게 저를 괴롭히는지 모르겠습니다. 클린 아키텍처를 처음 접하고, 이것이 뭔지 이해하기 까지도 시간이 많이 걸렸습니다. 그럼에도 아직 잘 모르는 것이 너무나도 많습니다.
그래서 클린 아키텍처를 다시 한번 정리하고 안드로이드가 권장하는 아키텍처를 실제로 도입한 간단한 로그인 앱을 만들어보려고 합니다.
주의!!!! 이 모든 것은 공부하는 학생의 입장에서 작성한 내용입니다. 틀린 내용이 있을 수 있으므로 혹시 발견하신다면 댓글로 알려주세요
클린 아키텍처란?
그럼 도대체 클린 아키텍처란 뭘까요? 검색으로 가장 먼저 알 수 있는 것은 다음과 같습니다.
클린 아키텍처는 『클린 코드(Clean Code)』를 저술한 로버트 마틴(Robert C. Martin)이 제안한 시스템 아키텍처로, 기존의 계층형 아키텍처가 가지던 의존성에서 벗어나도록 하는 설계를 제공합니다.
그리고 아래의 그림이 우리를 계속 따라다닙니다.
위의 그림이 중요하긴 하지만, 처음 이 그림을 본다면 이해하기가 매우 어렵습니다.
그러니 위의 그림에서 가장 중요한 내용인 내부의 원은 외부의 원에 대해 알지 못해야 하는 의존성 규칙을 지켜야 한다는 것을 기억하고 일단 넘어가 보겠습니다.
Entity
실제, 객체라는 의미로 업무에 필요하고 유용한 정보를 저장하고 관리하기 위한 집합
UseCase
비즈니스 규칙
Controller
애플리케이션 API 엔드포인트
Gateway
API 게이트웨이는 실제 백엔드 서비스 또는 데이터와 접속하고 API 호출에 대한 정책,
인증 및 일반 액세스 제어를 적용하여 중요한 데이터를 보호하는 트래픽 관리자
Presenter
Container에서 처리한 상태를 props로 전달받아 상태를 화면에 출력하는 컴포넌트. 주로 Container - Presenter 패턴을 사용하는 react에서 주로 사용
의존성 규칙이란?
사실 거의 대부분의 아키텍처가 계층을 분리하고 각각의 관심사를 분리하기 위해, 의존성 규칙을 지켜야한다고 말하고 있습니다.
🤔 근데 의존성 규칙을 지켜 각각의 관심사가 분리되면 좋은 점이 뭐가 있을까요??
바로 개발팀들이 시스템을 쉽게 개발할 수 있게 됩니다. 각자의 팀원들은 분리된 관심사를 하나씩 맡아서 처리할 수 있게 되고 병합 시 충돌할 확률이 낮아지게 됩니다.
이 외에도 많은 좋은 점이 있긴합니다.
클린 아키텍처에 대한 이해의 어려움
생각보다 단순합니다. 클린 아키텍처는 결과적으로 시스템을 쉽게 개발할 수 있도록 만드는 방법인 것입니다.
그렇다면 왜 이렇게 이해하기 어려운 것일까요?? 사실 이것은 클린 아키텍처에 대해 이해하지 못하거나 알지 못한 채로, 클린 아키텍처를 구현할 수 있는 `수단`을 먼저 배웠기 때문입니다.
아키텍처 패턴과 클린 아키텍처
MVVM, MVP, MVC에 대해서는 다들 들어보셨을 겁니다. 그렇다면 이러한 아키텍처 패턴과 클린 아키텍처는 어떤 연관성이 있을까요??
사실은 크게 연관성이 존재하지 않습니다.😲
클린 아키텍처를 안드로이드에 대입하기
그럼 클린 아키텍처를 안드로이드에 대입하려면 어떻게 해야 할까요?? 이를 구분한 그림은 아래와 같습니다.
이 그림 대로라면 각 레이어의 의존성은 Presentation ▶ Domain ◀ Data 가 됩니다.
애플리케이션의 실제 흐름
그럼 애플리케이션에서 실제 데이터 흐름이 어떻게 이뤄지는지 보겠습니다.
먼저 Fragment에서 ViewModel은 경계를 넘어 도메인 레이어로 들어가 Usecase에게 데이터를 요청합니다.
Usecase는 Data 레이어에 속한 Repository에 또 다른 경계를 넘으면서 데이터를 요청합니다. Repository는 실제로 데이터를 가져오기 위해, 네트워크 로컬 데이터베이스 또는 기타 데이터 소스에 액세스 하는 일을 담당합니다.
런타임에 파란색 흐름을 보면 Presentation에서 Domain 그리고 Data로 흐릅니다.
그럼 이게 Clean 할까요?? 지금은 Domain이 Data 계층에 직접적으로 의존하고 있습니다.
의존성 역전 원칙을 사용하는 것으로 매우 효율적이고 간단하게 이러한 문제를 해결할 수 있습니다. 요점은 런타임에서 종속성이 한 방향으로 흐르더라도, 컴파일 타임에서 종속성을 제어할 수 있다는 것입니다.
Domain 계층에 Respository Interface를 배치하고 이를 Data 레이어에서 구현합니다. 이것으로 인해 우리는 본질적으로 컴파일 타임에 종속성을 반전시키지만 런타임에는 적절한 흐름을 유지할 수 있습니다.
안드로이드가 권장하는 아키텍처
그럼 이제 안드로이드가 권장하는 아키텍처를 보겠습니다.
아래의 그림은 안드로이드가 권장하는 아키텍처의 그림입니다.
안드로이드는 애플리케이션을 위와 같이 3개의 Layer로 나눕니다. 간단하게만 설명하자면
UI 레이어(프레젠테이션)는 화면에 애플리케이션 데이터를 표시하는 데 사용됩니다.
Domain 레이어는 데이터 레이어 간의 상호작용을 간소화하고 재사용하기 위해 사용됩니다.
Data 레이어는 앱의 비즈니스 로직을 포함하고 애플리케이션 데이터를 노출합니다.
안드로이드가 권장하는 아키텍처가 클린 아키텍처인가?
여기서 잠시 생각해야 할 것이 있습니다. 안드로이드가 권장하고 있는 아키텍처를 자세히 보면, 우리가 위쪽에서 살펴보았던 클린 아키텍처의 정의와는 다른 부분이 있습니다.
클린 아키텍처 대로라면, 분명 Domain 레이어는 Data 레이어에 대해 알지 못해야 합니다. Domain 레이어와 Data 레이어와의 의존성이 반대가 되어 있습니다.
또한 안드로이드에서는 Data 레이어에 비즈니스 로직을 작성하라고 말하며, Domain 레이어는 옵션이라고 말합니다.
그럼 이런 결론이 나옵니다.
사실 안드로이드가 권장하는 아키텍처는 클린 아키텍처가 아니다!
차이점
기본적으로 클린 아키텍처의 핵심은 Domain은 데이터가 어디서 오는지 알 필요가 없다는 것입니다. Domain 그룹에 속하는 클래스는 Presentation이나 Data 계층의 클래스에 의존해서는 안됩니다.
또한 클린 아키텍처에서 비즈니스 핵심 기능은 Domain에 있고, 다른 모든 클래스는 여기에 연결되지만, 안드로이드가 권장하는 아키텍처에서는 Domain 레이어는 옵션입니다. 그렇기 때문에 Data 레이어에 비즈니스 로직이 있습니다.
안드로이드 권장 아키텍처에서 도메인 레이어를 추가하는 데에는 단일 책임 원칙을 지길 수 있게 도와주는 역할을 수행합니다.
안드로이드 아키텍처 구조
클린 아키텍처와 안드로이드 권장 아키텍처에 대해 어느 정도 이해했으니 안드로이드가 권장하는 아키텍처를 좀 더 세분화해서 알아보겠습니다.
안드로이드 프로젝트에서 각각의 레이어는 모듈로 나눠집니다. 각각의 모듈은 위에서 설명했던 대로의 역할을 수행합니다.
위의 그림이 안드로이드 권장 아키텍처를 구현하는데 절대적인 그림은 아닙니다. 그렇기에 이를 정확히 구분 짓는데 집착할 필요는 없습니다.
모듈의 형태
Now in android를 보면 모듈들이 엄청나게 나눠져 있습니다. 저는 이것을 기반으로 안드로이드 권장 아키텍처를 구현해보고자 합니다. 다만 다시 한번 말씀드립니다. 이게 정답이 아닙니다.
로컬 데이터베이스를 사용하지 않는다면, database와 datastore 모듈은 필요하지 않습니다. 굳이 모듈화 할 필요성을 느끼지 못하는 부분이 있다면, 억지로 모듈화 하지 않아도 됩니다.
제가 생각하는 Now in android의 모듈의 핵심은 5가지입니다.
1. Data 모듈
Data 모듈에는 일반적으로 Repository, Datasource, Model 클래스가 포함되어 있습니다. Data 모듈의 세 가지 주된 역할은 다음과 같습니다.
- 특정 도메인의 모든 데이터 및 비즈니스 로직 캡슐화: 각 데이터 모듈은 특정 도메인을 나타내는 데이터를 처리해야 합니다. 관련이 있는 데이터라면 다양한 유형의 데이터를 처리할 수 있습니다.
- 저장소를 외부 API로 노출: 데이터 모듈의 공개 API는 데이터를 앱의 나머지 부분에 노출하는 일을 담당하기 때문에 저장소여야 합니다.
- 외부로부터 모든 구현 세부정보 및 데이터 소스 숨기기: 데이터 소스는 같은 모듈의 저장소에서만 액세스 가능해야 합니다. 외부에는 공개되지 않습니다. Kotlin의 private 또는 internal 공개 상태 키워드를 사용하여 데이터 소스를 숨길 수 있습니다.
2. feature 모듈
feature 모듈은 일반적으로 사용자에게 표시되는 화면에 해당하는 독립적인 앱 기능을 의미합니다. feature 모듈은 Data 모듈에 종속됩니다.
각 feature 모듈에는 로직과 상태를 처리하기 위한 UI와 ViewModel이 있을 수 있습니다.
3. App 모듈
App 모듈은 애플리케이션의 진입점입니다. App 모듈은 feature 모듈에 종속되며 일반적으로 루트 탐색을 제공합니다.
4. Core 폴더
앱에서 활용할 모듈들이 들어가 있는 폴더입니다. data, domain, model 등 핵심 기능이 위치합니다.
5. Build-Logic
빌드 로직을 구현합니다.
멀티 모듈 적용
멀티 모듈을 적용한 간단한 로그인 앱을 만들어보려고 합니다. 모듈의 전체 구조는 아래 그림과 같습니다.
이 게시글에서는 각 모듈에 대한 자세한 설명은 다루지 않고 전체적인 설정만 다룹니다.
Build-Logic
가장 먼저 해야 할 일은 앱의 빌드 로직을 구현해 라이브러리의 의존성을 한 번에 관리해야 합니다. 모듈마다 Gradle이 존재하기에 만일 이러한 빌드 로직이 없다면, 각각의 Gradle 마다 의존성을 관리해야 합니다.
그런데 만일 특정 라이브러리의 버전이 바뀌었다면 어떻게 될까요?? 해당 라이브러리에 의존성을 가지고 있는 모듈을 모두 방문해서 버전을 올려줘야 합니다. 이는 매우 귀찮고 불편한 일입니다.
그러니 이제부터 이를 설정하는 방법을 알아보도록 하겠습니다.
Version Catalog
먼저 Gradle의 버전들을 관리하는 파일을 만들어야 합니다. 이 과정을 "Version Catalog"이라 합니다. 아래의 `gradle` 파일에 `libs.version.toml` 파일을 추가하면 됩니다.
TOML은 구성 파일을 위한 파일 형식입니다. TOML의 문법은 주로 `[섹션 이름]` 그리고 `키 = 값` 쌍으로 이루어져 있습니다.
`libs.version.toml` 파일의 양식은 기본적으로 크게 다르지 않습니다. 특정 프로젝트에서 사용해야 할 라이브러리 버전이 있다면 그것만 수정해 주면 됩니다.
`libs.version.toml`파일 - https://github.com/android/nowinandroid/blob/main/gradle/libs.versions.toml
위는 `now in android`의 `libs.version.toml` 파일입니다. 필요하시다면 여기서 가져오시면 됩니다.
모듈 생성
이제 빌드 로직을 넣을 모듈을 생성해야 합니다. 다른 모듈에서도 공통적으로 사용할 것이기에 이번에 알아두는 것이 좋습니다.
모듈을 생성하는 법은 간단합니다. 루트 프로젝트에 마우스 오른쪽 클릭을 하고 아래와 같이 진행하면 Module을 생성할 수 있습니다.
여기까지 설명하고 넘어간 게시글이 너무 많았기에 각 모듈의 Templates를 간략하게 보고 넘어가겠습니다.
이것들은 그냥 Templates입니다. 무조건 이 Templates를 사용해야 한다는 규칙은 없습니다.
위의 모듈의 Templates에서 우리가 보통 사용하는 Template는 `Phone & Tablet`, `Android Library`, `Java or Kotlin Library`입니다. 이것들은 각각의 구현에 따라 자동으로 아래와 같이 아이콘이 매겨집니다.
아래의 그림에서는 `Java or Kotlin Library`, `Android Library`, `Phone & Tablet` 순입니다.
아 아이콘은 절대적이 아닙니다. 언제든지 내부의 구성요소가 변경되면 다른 아이콘으로 변경될 수 있습니다.
빌드 로직에는 manifest가 필요 없기에 `Java or Kotlin Library`로 한번 모듈을 만들어 보겠습니다. 모듈을 생성하면 아래와 같은 구조를 가집니다. 여기서 불필요한 파일을 제거하고 좀 더 깔끔하게 만들면 됩니다.
다만 여기에는 문제가 있습니다. 초기화 단계에 필요한 `settings.gradle` 파일이 없습니다. 결국은 루트 Gradle에 현재 작업 중인 빌드 로직 모듈을 포함시켜야 합니다. 이렇게 해서 여러 프로젝트 간에 빌드 로직을 공유할 수 있게 되는 것입니다. 그렇기에 `settings.gradle` 파일도 추가해 줍니다.
정리한 모듈의 형태는 아래와 같습니다.
`settings.gradle` 파일
먼저 `settings.gradle` 파일을 작성합니다. 이 파일에서 Version Catalog 한 파일을 Gradle에서 접근할 수 있게 설정합니다.
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
위의 기능은 Gradle 빌드 스크립트에서 사용되는 기능 중 하나로 아직은 실험적인 기능입니다. 다만 매우 편리하기에 이를 사용하고자 합니다.
일반적으로 Gradle 빌드 스크립트에서 프로젝트 객체의 속성에 접근할 때, Groovy 스크립트 언어의 동적 타입 시스템으로 인해 컴파일 타임에는 타입 검사가 이루어지지 않습니다. 이것은 오타나 잘못된 속성 이름을 사용할 경우 런타임 오류가 발생할 수 있다는 의미입니다.
이 기능을 사용한다면 기존의 ':'를 사용한 project(":client")와 같은 방식이 아닌 implementation projects.client처럼 '.'을 사용해 객체의 속성에 접근할 수 있습니다.
`build.gradle` 파일
빌드 파일에서는 Gradle과 Kotlin DSL을 사용하기 위한 플러그인과 의존성을 주입해 줍니다.
그리고는 Custom Gradle Plugin을 만듭니다. Custom Gradle Plugin을 사용하면 의존성을 Grouping 하여 Custom Gradle Plugin을 사용하는 것 만으로 필요한 의존성을 한 번에 설정할 수 있게 해 줍니다.
아래와 같이 Custom Gradle Plugin을 설정하면 id로 이 플러그인을 사용할 수 있습니다.
plugins {
`kotlin-dsl`
`kotlin-dsl-precompiled-script-plugins`
}
dependencies {
implementation(libs.android.gradlePlugin)
implementation(libs.kotlin.gradlePlugin)
}
gradlePlugin {
plugins {
register("androidHilt") {
id = "jun.android.hilt"
implementationClass = "com.jun.loginCAApp.HiltAndroidPlugin"
}
register("kotlinHilt") {
id = "jun.kotlin.hilt"
implementationClass = "com.jun.loginCAApp.HiltKotlinPlugin"
}
register("androidRoom") {
id = "jun.android.room"
implementationClass = "com.jun.loginCAApp.AndroidRoomPlugin"
}
}
}
아니면 별도의 Gradle 파일을 만들어서 Custom Gradle Plugin을 구현할 수도 있습니다. 이렇게 하면 Custom Gradle Plugin 간에도 중복되는 부분을 확장 함수로 따로 선언할 수 있습니다.
import com.jun.loginCAApp.configureCoroutineAndroid
import com.jun.loginCAApp.configureHiltAndroid
import com.jun.loginCAApp.configureKotest
import com.jun.loginCAApp.configureKotlinAndroid
plugins {
id("com.android.library")
}
configureKotlinAndroid()
configureKotest()
configureCoroutineAndroid()
configureHiltAndroid()
아래와 같은 방식으로 말이죠.
@file:Suppress("UnstableApiUsage")
package com.jun.loginCAApp
import org.gradle.api.JavaVersion
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.provideDelegate
import org.gradle.kotlin.dsl.withType
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
/**
* https://github.com/android/nowinandroid/blob/main/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt
*/
internal fun Project.configureKotlinAndroid() {
// Plugins
pluginManager.apply("org.jetbrains.kotlin.android")
// Android settings
androidExtension.apply {
compileSdk = 33
defaultConfig {
minSdk = 24
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
configureKotlin()
val libs = extensions.libs
dependencies {
add("coreLibraryDesugaring", libs.findLibrary("android.desugarJdkLibs").get())
}
}
internal fun Project.configureKotlin() {
tasks.withType<KotlinCompile>().configureEach {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
// Treat all Kotlin warnings as errors (disabled by default)
// Override by setting warningsAsErrors=true in your ~/.gradle/gradle.properties
val warningsAsErrors: String? by project
allWarningsAsErrors = warningsAsErrors.toBoolean()
freeCompilerArgs = freeCompilerArgs + listOf(
"-opt-in=kotlin.RequiresOptIn",
)
}
}
}
그리고 나중에 구현할 feature 모듈은 기본적으로 UI를 다루는 부분이고 각자의 기능으로 구분 지었기 때문에 각각의 모듈이 유사합니다. 고로 Gradle도 유사하기에 이를 아래와 같이 Custom Gradle Plugin을 구현할 수 있습니다.
import com.jun.loginCAApp.configureHiltAndroid
import com.jun.loginCAApp.libs
plugins {
id("jun.android.library")
id("jun.android.compose")
}
android {
defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
}
configureHiltAndroid()
dependencies {
implementation(project(":core:model"))
implementation(project(":core:data"))
implementation(project(":core:designsystem"))
implementation(project(":core:domain"))
val libs = project.extensions.libs
implementation(libs.findLibrary("hilt.navigation.compose").get())
implementation(libs.findLibrary("androidx.compose.navigation").get())
androidTestImplementation(libs.findLibrary("androidx.compose.navigation.test").get())
implementation(libs.findLibrary("androidx.lifecycle.viewModelCompose").get())
implementation(libs.findLibrary("androidx.lifecycle.runtimeCompose").get())
}
이렇게 해서 만들어진 빌드 로직은 다음과 같습니다.
빌드 로직과 관련된 내용은 DroidKnight 앱에서 참고하였습니다. 참고한 코드는 아래의 주소에서 확인하실 수 있습니다.
https://github.com/droidknights/DroidKnights2023_App/tree/main/build-logic
루트 프로젝트 Gradle 설정
이제 남은 건 루트 프로젝트의 Gradle을 설정하는 것입니다.
먼저 `build.gradle` 파일입니다. 여기서는 전체 모듈에 공통적으로 적용되는 사항을 설정합니다.
@file:Suppress("DSL_SCOPE_VIOLATION")
buildscript {
repositories {
google()
mavenCentral()
}
}
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.hilt) apply false
alias(libs.plugins.android.library) apply false
}
다음은 `setting.gradle` 파일입니다. 이곳에서는 지금까지 만든 빌드 로직을 추가하고 관련 설정과 모듈을 추가합니다. (다른 모듈은 아직 추가하지 않기에 참고만 하시면 됩니다.)
pluginManagement {
includeBuild("build-logic")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "LoginCAApp"
include(
":app",
":core:data",
":core:database",
":core:designsystem",
":core:domain",
":core:model",
":feature:main",
":feature:login",
":feature:signin"
)
1편 마무리
양이 너무 길어져서 여러 편에 나눠서 적어야 할 것 같습니다. 이번 게시글에서는 클린 아키텍처에 대해 알아보고 안드로이드에 이를 적용했을 때의 전체적인 구조에 대해 알아보았습니다. 마지막으로는 실제로 빌드 로직까지 구현해 봤습니다.
다음 게시글에서 이어서 core 폴더에 들어갈 모듈을 다뤄보고자 합니다.
전체 코드
Reference
'Android' 카테고리의 다른 글
클린 아키텍처와 안드로이드 권장 멀티 모듈 적용하기 (3) (0) | 2023.10.08 |
---|---|
클린 아키텍처와 안드로이드 권장 멀티 모듈 적용하기 (2) (1) | 2023.10.05 |
Gradle의 동작원리 이해하기 (1) | 2023.09.27 |
Android의 의존성 주입 (0) | 2023.09.14 |
안드로이드에서 Context가 존재하는 이유 (0) | 2023.09.10 |