SOPT 세미나를 진행하고, 세미나에서 배운 지식을 디자인과 서버와 협업하면서 적용할 수 있는 기회가 있습니다. 그것이 Client, Design, Server 합동세미나 CDS 합동세미나입니다. 저는 합동세미나를 하면서 해보고 싶은 스스로의 도전과제는 아키텍처 패턴을 적용해보고 싶었습니다. 구글에서 권장하고 있는 UI Layer와 Data Layer를 나누고 UDF 흐름을 갖는 패턴을 적용해보았습니다.
앱의 UI를 보고 뷰를 나누기위해서 초기 설정을 진행하던 도중에 뷰의 흐름을 고민 없이 Activity 와 Fragment를 자유롭게 사용했습니다. 생각해보니 단일 Activity 를 사용해서 Fragment를 붙이고 사용할 지 여러 Activity를 사용할 지 초기에 뷰의 흐름을 보고 장단점을 고려해서 초기 설정을 다루는 것도 중요하다고 생각합니다.
그래서 합동세미나의 화면 플로우를 구성하기 위해서 네비게이터 셋팅을 진행했다.
기본 Main 액티비티는 Fragment 와 BottomNavigation 으로 구성되어있고,
4개의 뷰를 나타내면 끝이다.
(뷰 : 동네생활 / 모임 더 둘러보기 / 모임 가입하기 / 모임 프로필 만들기)
[동네생활 > 모임 더 둘러보기 > 모임 가입하기 ] 의 흐름을 가진다.
액티비티를 사용할까? 프래그먼트를 사용할까? 고민했다.
Jetpack Navigation 사용했더라면 그냥 프래그먼트를 사용했을 것 같다. 이유는 그저 Fragment back stack 관리가 쉬울 것 같아서?
하지만 Jetpack Navigation 은 사용하지 않는다.
그렇다고 해서 액티비티를 사용하냐? 그건 아니지만 개인적으로 동네 생활 페이지는 Fragment 로 작성되지만 다음 뷰는 액티비티로 가져가야겠다 생각했다. 이유는 액티비티의 linux 기반 IPC 데이터 통신의 문제점? 액티비티 메모리 측면에서 무거운데? 등 프래그먼트를 사용하는 관점이 더 좋아보인다.
하지만 우리 합동세미나에서 4개의 뷰를 나타내는데, 액티비티를 사용해도 상관없을 듯하다. 큰 데이터를 전달하지도 않고 인도에서 사용하는 어플아니면 메모리 측면에서도 크게 문제될 것 같지 않다.
참고 자료 : https://www.charlezz.com/?p=44128
안드로이드에서 권장하는 구글 권장 아키텍처에 근거하여 이번 프로젝트를 진행해보았다. 서버통신이 있어서 수동 DI 와 서비스 로케이터 개념도 추가해서 진행하고, 코루틴을 사용하여 동시성 프로그래밍을 진행했다.
프로젝트 폴더 & 파일 (하나의 app 모듈을 사용)
├─ 📁 app
│ ├─ 📁 data
│ │ ├─ 📁 api
│ │ ├─ 📄 ApiService.kt
│ │ └─ 📄 RetrofitManager.kt
│ │ ├─ 📁 dataSource
│ │ └─ 📁 remote // 서버통신만 진행하므로 local 폴더는 다루지 않는다.
│ │ └─ 📄 SampleRemoteDataSource.kt
│ │ ├─ 📁 repository
│ │ └─ 📄 SampleRepository.kt
│ │ └─ 📁 model
│ │ ├─ 📁 request
│ │ └─ 📄 SampleRequest.kt
│ │ └─ 📁 response
│ │ └─ 📄 SampleResponse.kt
│ ├─ 📁 ui
│ ├─ 📄 SampleActivity.kt
│ ├─ 📄 SampleViewModel.kt
│ └─ 📄 SampleViewModelProvider.kt
└─ └─ 📄 Application.kt
구글에서는 앱의 규모가 커질 것을 생각해서 앱을 확장하고 견고하게 관리하기 위해서 아래와 같은 아키텍처를 권장하고 있습니다. 이는 관심사 분리와 UDF(단방향 데이터 흐름( 패턴으로 데이터 일관성을 강화하고, 오류가 발생 확률을 줄이며 디버그하기 쉽게 만들어줍니다.
앱 데이터 레이어는 비즈니스 로직을 포함하고 있으며, 앱 데이터 생성, 저장, 변경 방식을 결정하는 규칙으로 구성된다.
RetrofitServicePool.carrotService
를 참조해서 사용할 수 있다.object RetrofitManager {
private const val BASE_URL = BuildConfig.BASE_URL
private val httpLoggingInterceptor = HttpLoggingInterceptor()
.setLevel(HttpLoggingInterceptor.Level.BODY)
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(httpLoggingInterceptor)
.build()
val retrofit: Retrofit =
Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
.build()
inline fun <reified T> create(): T = retrofit.create<T>(T::class.java)
}
object RetrofitServicePool {
val carrotService = RetrofitManager.create<CarrotService>()
}
interface CarrotService {
@POST("api/clubs/profile")
suspend fun postProfile(
@Body profileRequest: ProfileRequest
): BaseResponse<String>
}
class ProfileRemoteDatasource(
private val carrotService: CarrotService
) {
suspend fun postProfile(id: Int, nickname: String, information: String): BaseResponse<String> =
carrotService.postProfile(id, ProfileRequest(nickname, information))
}
Repository 를 두어서 진행하는 방법은 로컬 데이터와 네트워크 데이터를 관리하는데 관심사 분리를 해줄 뿐만 아니라 계층 간에 데이터 레이어 접근을 위해서 다른 레이어가 데이터 소스에 접근하지 않고 저장소(Repository) 클래스를 주입 받아 실행하게 된다.
class ProfileRepository(
private val profileRemoteDatasource: ProfileRemoteDatasource
) {
suspend fun postProfile(id: Int, nickname: String, information: String): BaseResponse<String> =
profileRemoteDatasource.postProfile(id, nickname, information)
}
@Serializable
data class ProfileRequest(
@SerialName("nickname")
val nickname: String,
@SerialName("information")
val information: String,
)
@Serializable
data class BaseResponse<T> (
@SerialName("code")
val code: Int,
@SerialName("message")
val message: String,
@SerialName("data")
val data: T?
)
{
"nickname": "조돌이",
"information": "정보 정보 정보 정보"
}
{
"code": 201,
"message": "모임 프로필 생성 성공",
"data": null
}
UI 레이어는 데이터의 상태를 받아와 어플리케이션 화면에 데이터를 표시하는 레이어입니다.
UI elements : 화면에 보이는 UI 요소 (Activity, Fragment)
State holders : 데이터를 보유하고 이를 UI에 노출하며 로직을 처리하는 상태 홀더 (ViewModel 클래스)
class ProfileViewModel(
private val profileRepository: ProfileRepository
): ViewModel() {
private val _responseSuccess = MutableLiveData<BaseResponse<String>>()
val responseSuccess: LiveData<BaseResponse<String>> = _responseSuccess
fun postProfile(id: Int = 1, nickname: String, information: String, throwMessage: (String) -> Unit) {
viewModelScope.launch {
runCatching {
profileRepository.postProfile(id, nickname, information)
}.onSuccess {
_responseSuccess.value = it
}.onFailure {
throwMessage(it.message.toString())
}
}
}
}
class ProfileViewModelProvider(
private val profileRepository: ProfileRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.getConstructor(ProfileRepository::class.java)
.newInstance(profileRepository)
}
}
여기서 사용된 것이 DI 이다. 의존성 주입으로 방법은 위처럼 생성자 주입과 필드(setter) 주입이 존재한다.
viewModel 객체를 전역으로 선언한다.
private lateinit var profileViewModel: ProfileViewModel
ViewModel 객체를 할당하기 위해서 생성자 주입을 사용하기 때문에 ViewModelProvider 를 사용할 수 있다. 여기서 DI 를 수동으로 처리하지만 해당 레포지터로에 대한 인스턴스는 서비스 로케이터를사용한다. 이 부분은 Application 에서 좀 더 다루어볼 예정이다.
profileViewModel = ProfileViewModelProvider(
CarrotApp.getProfileRepositoryInstance()
).create(ProfileViewModel::class.java)
버튼을 클릭하면 postProfile()
함수가 호출되면서 API 통신이 이루어진다.
binding.btnJoinMeeting.setOnClickListener {
profileViewModel.postProfile(
nickname = binding.carrotInputLayoutNickname.getEditText(),
information = binding.carrotInputLayoutSelfIntroduce.getEditText()
) {
snackBar(binding.root) { it }
}
}
이제 API 통신으로 Response 값을 받으면, 해당 값을 옵저빙하여 UI 를 나타낸다.
profileViewModel.responseSuccess.observe(this) {
snackBar(binding.root) {
it.message
}
}
class CarrotApp : Application() {
//...
companion object {
private lateinit var profileRepository: ProfileRepository
@Synchronized
fun getProfileRepositoryInstance(): ProfileRepository {
if (!::profileRepository.isInitialized) {
try {
profileRepository = ProfileRepository(
ProfileRemoteDatasource(RetrofitServicePool.carrotService)
)
} catch (e: ExceptionInInitializerError) {
Log.e("로그", "${e.message}")
}
}
return profileRepository
}
}
}
안드로이드에서는 Activity 보다 상위 모듈인 Application 에서 객체를 제어하고 사전에 삽입합니다. 특히, UI → Data 간 통신을 위해서 서버 API 객체를 전역으로 선언할 때 사용됩니다.
DI 와 차이점
참조 : https://developer.android.com/topic/architecture?hl=ko
Activity와 Fragment 에 대해서 사용하는 방법이 아닌 기능적으로 어떻게 적용해서 좀 더 좋은 앱을 만들 수 있는 지 고민해보는 시간을 가질 수 있었고, 구글 권장 아키텍처를 적용해보면서 Hilt 라이브러리를 사용하지 않는 DI와 서비스 로케이터의 개념을 알아보고 ViewModelProvider 를 사용해서 수동 DI를 진행해보는 과정을 배웠다. 로컬 데이터베이스를 다루는 DataSource 가 있었다면 Repository의 기능이 더 확장되어서 좋을 듯 하다.
추후에는 TDD 개발을 해보고 싶다. 아키텍처 패턴으로 설계하면 좋은 장점은 테스트 용이성이다. 그래서 테스트 케이스를 작성하고 테스트 후 코드 구현을 해보고 싶다.