Clean Architecture
Clean Architecture
주변 개발자들의 이야기와 유튜브를 통해 여러 개발 관련 영상을 접하면서 Clean Architecture에 대한 중요성을 강조하고 있다. SW 개발 과정에서 항상 고려되어야 할 요소인 유지보수성을 높이는 것에 대해 모든 개발자들의 숙명과도 같은 과제이기에, 소위 고급 개발자들은 개발 과정에서 코드의 재사용성과 유지보수성을 극대화하기 위한 목표를 실현하려고 노력하고 있다. 곰곰히 생각해보니 SW는 항상 개선 작업을 통해 사용자에게 더 나은 서비스 경험을 제공할 수 있기에 유지보수성을 높이기 위해 항상 고민해야 한다는 것을 깨달았다. 이러한 유지보수성을 높이기 위한 고민을 해결하기 위한 목적으로 나타난 개념인 Clean Architecture에 대해 알아보고 이를 Android에서 구현하는 방법에 대해서 알아보고자 한다.
Clean Architecture란?
클린 아키텍처(Clean Architecture)는 복잡한 애플리케이션의 로직을 단순화하여 코드의 복잡도를 낮추고, 테스트 가능한 구조를 가지면서, 유지보수성을 높여 변경이 용이한 코드를 작성하기 위한 목적 등으로 등장하게 된 여러 아키텍처의 장점을 집대성하여 등장하게 된 아키텍처이다. 간단하게 표현하자면, 결국 의존성 규칙을 준수하여 계층 분리를 통해 애플리케이션의 복잡도를 낮추는 아키텍처라고 볼 수 있다.
여기서 의존성 규칙이 가지고 있는 의미는 아래와 같다.
- 모든 소스 코드의 반드시 외부에서 내부로, 고수준 정책을 향해야 한다.
위의 클린 아키텍처 구성도를 예를 들면, 바깥쪽 레이어인 매커니즘(Mechanism)에서에 안쪽 레이어인 정책(Policy)을 향하여 규칙을 준수해야 한다는 의미이다. 이해하기 쉽게 접근하자면 UI, DB, Web 등이 내부 비즈니스 로직에 의존하지만, 반대로 비즈니스 로직은 바깥쪽 레이어(UI, DB, Web 등)에 의존하지 않아야 한다는 의미이다. 결국엔 의존성 규칙이 비즈니스 로직과 같은 고수준의 정책이 UI와 DB 등 세부 사항인 저수준 정책에 의존하지 말아야 한다는 규칙으로 이해하면 될 듯 하다.
의존성 규칙을 준수하기 위해 클린 아키텍처는 계층을 명확히 분리하여 구성되어 있다.
- Entities
- Use Cases
- Interface Adapters
- Frameworks & Drivers
Entities
엔티티(Entities)는 일반적이고 고수준의 비즈니스 규칙을 캡슐화하기 때문에 바깥쪽에서 변경사항이 발생해도 어떠한 영향도 미치지 않는다. 엔티티는 메소드를 갖는 객체, 데이터 구조, 함수들의 집합 등을 의미한다.
Use Cases
유스케이스(Use Cases)는 애플리케이션의 고유한 비즈니스 규칙을 캡슐화하며, 엔티티로부터의 데이터의 흐름을 조합하는 역할을 수행한다. 엔티티에 영향을 주지 않으며 데이터베이스, 공통 프레임워크 및 UI 변경으로부터 독립적이다.
Interface Adapters
인터페이스 어댑터(Interface Adatpters)는 엔티티 및 유스케이스의 편리한 형식에서 웹 또는 DB 등 외부 기능이 쉽게 사용할 수 있는 형식으로 변환해주는 역할을 수행한다. 이 계층에는 MVC 패턴의 모든 요소들이 포함될 수 있고, MVP 패턴의 Presenters와 MVVM 패턴의 ViewModel이 포함된다. 쉽게 말해 순수한 비즈니스 로직을 담당한다.
Framworks & Drivers
가장 바깥쪽에 위치한 계층이며 데이터베이스, 웹 프레임워크, UI, HTTP client 등으로 구성된다. 내부와 통신할 연결 코드 외에는 다른 코드를 포함하지 않는다.
Clean Architecture in Android
그러면 클린 아키텍처를 안드로이드에서 구현하려면 어떻게 될까? 먼저 아래와 같이 3개의 Layer로 분리하여 구성한다.
Presentation
화면을 처리하는 로직이나 사용자에게 보여주는 UI를 포함하고, Activity, Fragment, View, Presenter 및 ViewModel을 포함한다. Domain 계층의 Use Case를 직접 주입하여 비즈니스 모델을 만든다. 결국 Presentation 계층은 Domain 계층에 대한 의존성을 가지고 있으며, UI와 상호작용하는 것이 주요 역할이기에 MVP나 MVVM 같은 디자인 패턴 적용이 가능하다.
Domain
애플리케이션의 비즈니스 로직을 포함하며, 이에 필요한 Use Case와 Entity(Model)도 포함하고 있다. Use Case는 개별 기능 혹은 논리적 단위로써, 어느 계층과도 의존성을 가지지 않는 특징을 가지고 있다. 이로 인해 Domain 계층은 그 어떠한 계층과도 의존성을 가지지 않는 독립적인 계층이다. 안드로이드에 의존적이지 않는 순수 Java 혹은 Kotlin 모듈이며, 사용자와 관련된 행동을 정의하는 Repository Interface를 생성하여 Use Case를 정의할 때 DB와 연관된 고민을 하지 않고 Repository를 주입시킨다.
Data
Domain 계층에 의존성을 가지고 있다. Domain 계층의 Repository 구현체, Cache, DB, DAO, 서버 API를 포함하고 있다. DB 및 서버와의 통신으로 인해 안드로이드에 대한 의존성을 가지고 있다. Mapper 클래스를 통해 Data 계층의 Model을 Domain 계층에 맞는 Model로 Mapping해주는 역할을 수행한다.
Clean Architecture 구현
- Domain Layer
build.gradle
Domain Layer는 어떠한 Layer와도 의존성 관계를 가지지 않는다.
model 패키지
package com.example.domain.model
data class Book(
val title: String,
val contents: String,
val price: Int,
val sale_price: Int
)
Domain Layer의 model 패키지는 앱을 구현하는데 필요한 데이터 클래스를 정의하고 관리하기 위한 목적인 패키지이다. model 패키지 내에서는 api 호출을 통해 결과값을 정리하기 위한 목적이면서 Data 클래스인 Book 클래스를 정의했다.
repository 패키지
package com.example.domain.repository
import com.example.domain.model.Book
interface BookRepository {
fun getBookInfo(title: String) : Book
}
repository 패키지에선 DB나 서버에 요청하여 데이터를 얻을 수 있는 함수를 정의한 클래스이다. 책의 정보를 가져오는 함수를 정의하기 위한 목적으로 추상화하여 interface를 정의하였다.
usecase 패키지
package com.example.domain.usecase
import com.example.domain.model.Book
import com.example.domain.repository.BookRepository
class GetBookInfoUseCase(private val repository: BookRepository) {
fun getBook(title: String): Book {
return repository.getBookInfo(title)
}
}
usecase 패키지에서는 책 정보를 가져오는 함수를 가진 BookRepository를 parameter로 받아 getBook 함수 호출 시 BookRepository의 getBookInfo 함수의 결과를 그대로 리턴하도록 구성했다.
- Data Layer
build.gradle
dependencies {
implementation project(':domain')
implementation 'androidx.core:core-ktx:1.8.0'
implementation 'androidx.appcompat:appcompat:1.4.2'
implementation 'com.google.android.material:material:1.6.1'
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.1"
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}
Data는 Domain에 대한 의존성을 가지기에 app 수준의 build.gradle에 domain 의존성을 추가하고, Data Layer 구현 간에 필요한 각종 의존성들을 추가한다.
api 패키지
package com.example.data.api
import com.example.data.model.BookResponse
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.Query
interface BookApi {
@Headers("Authorization: KakaoAK XXXXXXXXXXXXXXXXXXXXX")
@GET("/v3/search/book")
fun getBookInfo(@Query("query") title: String): Call<BookResponse>
}
Data Layer의 api 패키지에는 api와 관련된 클래스나 기능을 정의하는 패키지이다. api는 retrofit 라이브러리를 활용할 것이기에, 패키지 내에 retrofit을 활용한 interface를 생성했다.
package com.example.data.api
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
object BookSearchServiceImpl {
private const val BASE_URL = "https://dapi.kakao.com"
private val retrofit: Retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
val service = retrofit.create(BookApi::class.java)
}
retrofit를 사용하는 api 클래스를 사용하기 위해서 이에 필요한 BookSearchServiceImpl 객체도 정의했다.
datasource 패키지
package com.example.data.datasource
import com.example.data.model.BookResponse
import retrofit2.Call
interface BookDataSource {
fun getBook(title: String): Call<BookResponse>
}
package com.example.data.datasource
import android.util.Log
import com.example.data.api.BookSearchServiceImpl
import com.example.data.model.BookResponse
import retrofit2.Call
class BookDataSourceImpl: BookDataSource {
override fun getBook(title: String): Call<BookResponse> {
return BookSearchServiceImpl.service.getBookInfo(title)
Log.d("datasourceImpl test", BookSearchServiceImpl.service.getBookInfo(title).toString())
}
}
datasource 패키지에는 api 인터페이스를 통해 얻어오는 정보를 리턴하는 용도로 간단하게 정의한 interface와 이러한 interface를 상속받아 함수가 어떠한 기능을 수행하는지 정의하는 클래스를 생성했다.
model 패키지
package com.example.data.model
import com.google.gson.annotations.SerializedName
data class BookResponse(
@SerializedName("title")
val title: String,
@SerializedName("contents")
val contents: String,
@SerializedName("price")
val price: Int,
@SerializedName("sale_price")
val sale_price: Int
)
model 패키지에서는 retrofit을 통해 http 통신을 수행하고 받은 응답 결과를 객체로 parsing하여 BookDataSource를 통해 ViewModel에 전달할 목적으로 data class를 정의했다.
repository 패키지
package com.example.data.repository
import com.example.data.datasource.BookDataSource
import com.example.data.mapper.BookMapper
import com.example.domain.model.Book
import com.example.domain.repository.BookRepository
class BookRepositoryImpl(private val dataSource: BookDataSource): BookRepository {
override fun getBookInfo(title: String): Book {
return BookMapper.mapperToBook(dataSource.getBook(title))
}
}
repository 패키지에서는 Domain Layer에서 정의한 BookRepository interface를 상속받아 구체적인 동작을 정의하여 기능을 수행할 수 있도록 BookRepositoryImpl 클래스를 생성하였다. 여기서 Book 객체로 반환해야 하는데, 여기에 필요한 Mapper 객체를 사용하여 BookResponse 객체를 Book 객체로 변환해야 한다. 그래서 아래와 같이 Mapper 객체를 생성했다.
mapper 패키지
package com.example.data.mapper
import android.util.Log
import com.example.data.model.BookResponse
import com.example.domain.model.Book
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
object BookMapper {
fun mapperToBook(bookResponse: Call<BookResponse>): Book {
var title: String = ""
var contents: String = ""
var price: Int = 0
var sale_price: Int = 0
bookResponse.enqueue(object: Callback<BookResponse> {
override fun onResponse(call: Call<BookResponse>, response: Response<BookResponse>) {
if (response.isSuccessful) {
val body = response.body()
Log.d("retrofit check", response.toString())
if (body != null) {
title = body.title
contents = body.contents
price = body.price
sale_price = body.sale_price
}
}
}
override fun onFailure(call: Call<BookResponse>, t: Throwable) {
Log.d("Response Error", t.message.toString())
}
})
return Book(
title = title,
contents = contents,
price = price,
sale_price = sale_price
)
}
}
BookRepositoryImpl 클래스에서 retrofit을 사용한 api에 의해 반환된 응답값인 BookResponse 객체를 getBookInfo 함수의 반환값인 Book 객체로 반환될 수 있도록 변환하기 위한 목적으로 BookMapper 객체를 정의했다. 생성한 Mapper 객체를 통해 일반적인 data class 형태로 parsing 과정을 통해 화면에 출력되도록 코드를 작성했다.
- Presentation Layer
Presentation Layer에서도 복잡한 기능과 화면 구성이 목적이라면, 각 목적과 의도에 맞게 패키지를 분리해야 하지만, 간단한 Clean Architecture 실습을 위한 안드로이드 프로젝트이기에 Presentation Layer에서는 ViewModel과 Activity 정도만 구현했다.
package com.example.cleanarchitecture
import android.app.Application
import androidx.lifecycle.*
import com.example.data.datasource.BookDataSourceImpl
import com.example.data.repository.BookRepositoryImpl
import com.example.domain.model.Book
import com.example.domain.usecase.GetBookInfoUseCase
class BookViewModel(application: Application) : AndroidViewModel(application) {
private val _book: MutableLiveData<Book> = MutableLiveData()
val book: LiveData<Book> = _book
private val bookDataSourceImpl: BookDataSourceImpl = BookDataSourceImpl()
private val bookRepositoryImpl: BookRepositoryImpl = BookRepositoryImpl(bookDataSourceImpl)
private val bookInfoUseCase: GetBookInfoUseCase = GetBookInfoUseCase(bookRepositoryImpl)
fun getBook(title: String) {
_book.value = bookInfoUseCase.getBook(title)
}
}
View와 Model 사이의 커뮤니케이션 역할을 수행하기 위해 ViewModel을 정의했다. Book data class를 통해 정의했던 책에 대한 검색 결과의 내용을 화면에 표시하기 위한 목적으로 Domain Layer와 Data Layer에서 정의한 repository와 usecase를 각각 객체로 생성하여 usecase를 통해 책에 대한 검색 결과를 화면에 보여줄 수 있도록 코드를 작성했다.
package com.example.cleanarchitecture
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.activity.viewModels
import androidx.databinding.DataBindingUtil
import com.example.cleanarchitecture.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val bookViewModel: BookViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this,R.layout.activity_main)
binding.mainActivity = this
binding.btnSearch.setOnClickListener {
bookViewModel.getBook(binding.titleTextField.text.toString())
val bookInfo = bookViewModel.book.value
if (bookInfo != null) {
binding.bookContents.text = bookInfo.contents
binding.bookName.text = bookInfo.title
binding.bookPrice.text = bookInfo.price.toString()
binding.bookSalePrice.text = bookInfo.sale_price.toString()
}
}
}
}
MainActivity에서는 검색 버튼을 누르면, api를 통해 반환받은 응답 결과를 화면에 보여줄 수 있도록 BookViewModel를 활용한 데이터 호출과 DataBinding을 통해 화면에 책의 검색 결과를 보여줄 수 있도록 구성했다.
사실 아직도 Clean Architecture에 대한 개념을 완전히 이해한 수준이 아니다. Clean Architecture의 개념과 구현에 대해서 이해하려고 정말 많은 시간을 소비했다. 적지 않은 시간을 투입한 것 치곤 Clean Architecture에 대한 이해도가 높지 않다. 앞으로도 계속 여러 안드로이드 프로젝트를 진행하면서 많이 익숙해져야 할 필요가 있는 것 같다. 이번에 안드로이드 Clean Architecture 실습을 위한 프로젝트를 만들어가면서 긴가민가한 부분이 많았다. 솔직히 위에 올려놓은 소스 코드에서 분명 틀린 부분이 존재할 것이다. 사실상 Clean Architecture를 처음 구현한 것이기에 실제 프로젝트에 적용시키면서 점차 Clean Architecture에 대해서 완전히 이해하려고 한다. 위 예시 코드에 문제가 있는 부분을 발견한다면 댓글을 통해서 필자에게 알려주길 개인적인 소망(?)을 이 글을 읽는 독자들에게 바라본다.
- 참고
https://leveloper.tistory.com/205
[Android] Clean Architecture in Android
Clean Architecture란? 고객들에게 제공하는 애플리케이션 같은 경우에는 수많은 기능들이 있기에 복잡도가 굉장히 높습니다. 복잡도가 높은 애플리케이션을 개발할 때 어떻게 하면 유지 보수하기
leveloper.tistory.com
https://velog.io/@ashwon1218/Android-Clean-Architecture
[Android] Clean Architecture
YAPP동아리 내의 스터디에서 클린 아키텍처 라는 주제에 대해 개인적으로 공부하는 시간을 가졌다. 나는 클린 아키텍처에 대해 따로 공부해본 적도 없었고, 책도 가지고 있는게 아니여서 구글에
velog.io
https://youngest-programming.tistory.com/484
[안드로이드] 클린 아키텍처(Clean Architecture) 정리 및 구현
[2021-04-28 업데이트] [2022-02-01 업데이트] Hilt 사용한 프로젝트 링크 하단에 추가 [프로젝트] github.com/mtjin/mtjin-android-clean-architecture-movieapp mtjin/mtjin-android-clean-architecture-movieap..
youngest-programming.tistory.com
https://jungwoon.github.io/android/2021/04/12/Android-CleanArchitecture.html
안드로이드에서 클린 아키텍처 구현하기 | Jungwoon Blog
지난 3월부터 새로운 회사로 이직을 하게 되었고 해당 회사에서는 Clean Architecture를 사용하고 있었습니다. 처음 접하는 Clean Architecture라 해당 부분 학습하면서 정리한 내용입니다. 클린 아키텍처
jungwoon.github.io
https://rubygarage.org/blog/clean-android-architecture
Clean Architecture of Android Apps with Practical Examples
In this article, we review the concept of Clean Architecture for Android applications. In addition to code examples, we also give our verdict on the vital question: Is Clean Architecture the silver bullet for all development challenges?
rubygarage.org