필자는 안드로이드의 클린 아키텍처를 아래 그림으로 나타내는 것이 가장 이해가 빨랐다. 직접 적용해본 결과, 클린 아키텍처는 적당한 관심사분리를 가능하게 하고 유지보수 범위를 깔끔히 쪼개준다는 느낌을 받았다. 클린 아키텍처 포스팅은 아래의 그림의 하위항목에 기반해서 설명한다.
의존성 주입은 Hilt라이브러리를 이용해서 진행하였다.
안드로이드 문서에 따르면 권장 앱 아키텍처는 다음과 같다. 도메인 레이어가 선택인 이유는 도메인은 결합도를 낮추고 관심사를 분리할 수 있지만, 그만큼 보일러 플레이트가 증가시키기 때문이라고 생각한다. 이 포스팅은 필수 계층인 UI Layer, Data Layer만 다루고 있으며 Domain Layer는 다음 포스팅을 참고하길 바란다.
쉽게 말하면 사용자와 상호작용하는 화면이다. 데이터 레이어에서 가져온 앱의 상태를 시각적으로 나타내는 것이다.
UI 레이어는 아래 그림과 같이 2가지로 구성된다. UI elements와 State holders이다. UI elements는 Activity,Fragment,composeScreen로 구현하고,State holders는 받은 데이터를 UI에 노출하기 위해 로직을 처리하는 부분이므로 ViewModel로 구현한다.
sunflower 앱 구조를 차용해서 adapter도 따로 두었다.
- adapter
- compose
- ui.theme
- view
- viewmodel
- di
- utils
adapter: viewpagerAdapter, RecycleAdapter 등
compose: compose 스크린
ui.theme: compose 프로젝트 생성 시 자동생성된 파일
view: Activity, Fragment, View 등
viewmodel: ViewModel 상속 클래스
di: dependency injection
utils: 자주 쓰이는 함수
hilt나 dagger를 사용할 경우 di 폴더가 필요하다. 'Dependency Injection'의 약자이며 아래와 같이 모듈을 주입한다. DataSourceImpleModulde, RepositoryImpleModule, NetworkModule 의존성 주입을 하기 위해 필요하다.
@Module
@InstallIn(SingletonComponent::class)
object DataSourceImplModule {
@Provides
@Singleton
fun provideRemoteDataSource() = UserRemoteDataSourceImpl()
}
앱에서 처리하는 유형의 데이터별로 저장소 클래스가 있는 계층이다.사용자 관련 데이터가 필요하다면 User 데이터와 이에 진입할 수 있는 User 레포지토리가 주를 이룬다. 밑의 그림과 같이 데이터소스에 접근할 수 있는 진입점은 레포지토리 뿐이다.
- api
- dto
- dao
- mapper
- repository
-local
-datasource
-remote
-datasource
레포지토리는 데이터의 출처와 관계없이 동일한 인터페이스로 데이터에 접근할 수 있도록 하는 패턴이다. 뷰모델은 Datasources가 local인지, remote인지 모르고 레포지토리에만 접근가능하고, 이것을 Data layer의 캡슐화라고 한다. 따라서 결합도가 낮아지는 효과가 있다.
domain layer를 추가한다면, data layer의 repository는 domain layer의 repository 인터페이스를 구현하는 repositoryImpl이 되어야 한다. 결국 repositoryImpl은 데이터를 불러오는 것에 집중하고, domain layer의 useCase는 비즈니스 로직에만 집중함으로써 관심사 분리가 된다는 걸 알 수 있다.
1.Repository 이름짓기
'데이터 유형 + Repository'이다.UserRepository, SubjectRepository...이런 식으로 짓는다.
2. Datasource 이름짓기
'데이터 유형 + 소스 유형 + DataSource' 이다. UserRemoteDatasource,
SubjectLocalDatasource를 예로 들 수 있다. UserSharedPreferencesDataSource 처럼 구현 세부정보를 이름에 넣으면 데이터 저장방법을 알 수 없으니 주의해야 한다.
data layer에는 entity가 있고, domain에는 model이 있다. entity는 서버 데이터 구조를 그대로 가져온 데이터 클래스이고, model은 UI에 표시될 수 있는 정보를 담은 데이터 클래스이다. api 응답을 받으면 entity를 model로 바꾸는 작업이 필요한데, 이 역할을 mapper가 해준다. 뷰모델 쪽에서 서버 응답데이터 구조를 그대로 받아쓰는 것보다 mapper를 이용하는 것이 UI계층과 Data 계층의 결합도를 낮출 수 있다. 또한 서버 응답이 바뀔 경우 고쳐야하는 부분도 data layer에 한정시킬 수 있다.
object UserMapper {
fun mapperToUser(userResponse: UserResponse): User {
return User(
id = userResponse.idUser,
pw = userResponse.pwUser,
name = userResponse.nameUser,
type = userResponse.typeUser,
gender = "미정",
department = userResponse.departmentUser,
registerYear = getRegisterYear(userResponse.idUser),
email = userResponse.emailUser,
thisYear = LocalDate.now().year.toString(),
thisSemester = getSemester(),
dayOfSemester = getDayOfSemester()
)
}
}
위의 코드는 data layer의 UserResponse를 domain layer의 User로 매핑시켜주는 예제이다. 아직 서버에서 제공하지 않는 날짜계산 관련 필드는 따로 함수를 만들어서 매핑시켜놓았다.
API는 Application Programming Interface의 약자로 응용프로그램에서 사용할 수 있도록 기능을 제어할 수 있는 인터페이스다. 서버에 웹 api를 만들어주면, 안드로이드나 프론트단에서 api를 이용해서 요청을 하고 응답도 받으면서 데이터를 주고받는다.
interface UserApi {
@GET("/api/admin/show")
suspend fun getUser(): UserResponse
}
data tranfer object의 줄임말이다.
서버로부터 받아오는 데이터를 담는 데이터 클래스이다. 비슷한 용어들은 entity, Model이 있는데, 정확한 설명은 아래와 같다.
api에 종속적인 데이터들을 받아온다면 dto가 적절한 명칭이다.(혹시 아니라면 말해주세요 ㅇㅅㅇ)
DTO: 클라이언트의 데이터를 받는 역할
Model: 비즈니스 데이터를 담는 역할
Entity: 데이터베이스의 테이블과 스키마를 표현하는 역할
이렇게 retrofit 어노테이션을 이용해서 json을 데이터클래스로 직렬화하는 것이 일반적이다.
data class UserResponse(
@SerializedName("id") val id: Long,
@SerializedName("typeUser") val typeUser: String,
@SerializedName("idUser") val idUser: String,
@SerializedName("pwUser") val pwUser: String,
@SerializedName("nameUser") val nameUser: String,
@SerializedName("phoneUser") val phoneUser: String,
@SerializedName("emailUser") val emailUser: String,
@SerializedName("dptUser") val departmentUser: String,
)
data access object의 줄임말이다.
api에 종속적인 정보 외의 로컬데이터들을 저장할 필요가 있을 때, 안드로이드 Room이나 Realm을 이용하게 된다. 아래의 코드는 구글 안드로이드의 sunflower 앱 코드이다.
interface GardenPlantingDao {
@Query("SELECT * FROM garden_plantings")
fun getGardenPlantings(): Flow<List<GardenPlanting>>
@Query("SELECT EXISTS(SELECT 1 FROM garden_plantings WHERE plant_id = :plantId LIMIT 1)")
fun isPlanted(plantId: String): Flow<Boolean>
/**
* This query will tell Room to query both the [Plant] and [GardenPlanting] tables and handle
* the object mapping.
*/
@Transaction
@Query("SELECT * FROM plants WHERE id IN (SELECT DISTINCT(plant_id) FROM garden_plantings)")
fun getPlantedGardens(): Flow<List<PlantAndGardenPlantings>>
@Insert
suspend fun insertGardenPlanting(gardenPlanting: GardenPlanting): Long
@Delete
suspend fun deleteGardenPlanting(gardenPlanting: GardenPlanting)
}
해당 게시글에 사용한 예제는 졸업 프로젝트에 적용되고 있다. 규모가 크지 않아서 domain layer까지 추가하는 건 불필요한 코드 생산일까 싶기도 했지만, 추후 창업으로도 확장될 가능성을 논의하고 있어서 아키텍처 이론을 적극 활용할 예정이다.
domain layer에 대한 내용은 다음 포스팅에서 이어진다.
Android developers 문서
repository, datasource, business logic
repository pattern
reository with mapper pattern