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(단방향 데이터 흐름( 패턴으로 데이터 일관성을 강화하고, 오류가 발생 확률을 줄이며 디버그하기 쉽게 만들어줍니다.

  • 어플리케이션 화면에서 데이터를 표시하는 UI 레이어
  • 앱의 비즈니스 로직을 포함하고 어플리케이 데이터를 노출하는 데이터 레이어

앱 권장 아키텍처 기법

  • 반응형 및 계층형 아키텍처
  • 앱의 모든 레이어에서의 단방향 데이터 흐름 (UDF)
    • 사용자 상호작용(버튼 누르기, 이벤트) → 데이터 변경으로 변경사항을 UI에 반영
    • UI Layer → Data Layer (이벤트) ⇒ 사용자의 버튼 클릭과 같은 이벤트 발생
    • Data Layer → UI layer (상태) ⇒ 변경된 상태를 반영하기 위해 UI 업데이트
  • 상태 홀더가 있는 UI 레이어로 UI 의 복잡성 관리
    • ViewModel 클래스
  • 코루틴 및 Flow
    • 비동기 처리
  • 종속 항목 삽입 권장사항 (DI)

Data Layer

앱 데이터 레이어는 비즈니스 로직을 포함하고 있으며, 앱 데이터 생성, 저장, 변경 방식을 결정하는 규칙으로 구성된다.

data/api

  • RetrofitManage.kt
    • HttpLogginInterceptor 를 사용해서 api 로그를 트래킹할 수 있다.
    • 서비스 통신을 위한 Retrofit 객체를 만들고 관리한다. 다른 패캐지에서는 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>()
}
  • ApiService.kt
    • HTTP 통신을 위한 인터페이스를 만든다.
    • 동시성 프로그래밍을 위해서 suspend 함수를 만들어 코루틴을 사용할 수 있게 한다.
interface CarrotService {
    @POST("api/clubs/profile")
    suspend fun postProfile(
        @Body profileRequest: ProfileRequest
    ): BaseResponse<String>
}

data/dataSource

  • remote/SampleRemoteDataSource.kt
    • 데이터 변경사항에 대한 비즈니스 로직을 작성하게 된다. API와 연결되므로 비동기 처리 해주기 위해 suspend 함수를 사용해 코루틴을 사용한다.
class ProfileRemoteDatasource(
    private val carrotService: CarrotService
) {
    suspend fun postProfile(id: Int, nickname: String, information: String): BaseResponse<String> =
        carrotService.postProfile(id, ProfileRequest(nickname, information))
}

data/repository

Repository 를 두어서 진행하는 방법은 로컬 데이터와 네트워크 데이터를 관리하는데 관심사 분리를 해줄 뿐만 아니라 계층 간에 데이터 레이어 접근을 위해서 다른 레이어가 데이터 소스에 접근하지 않고 저장소(Repository) 클래스를 주입 받아 실행하게 된다.

  • SampleRepository.kt
    • 계층 구조에서 다른 레이어는 데이터 소스에 직접 액세스해서는 안된다. 그러므로 데이터 영역의 진입점으로 이는 항상 저장소(Repository) 클래스여야 한다. 데이터 소스가 직접 종속 항목이 있으면 안되므로, 저장소 클래스를 두어 따로 종속 항목을 주입 받는다.
class ProfileRepository(
    private val profileRemoteDatasource: ProfileRemoteDatasource
) {
    suspend fun postProfile(id: Int, nickname: String, information: String): BaseResponse<String> =
        profileRemoteDatasource.postProfile(id, nickname, information)
}

data/model

  • request/SampleRequest.kt
    @Serializable
    data class ProfileRequest(
        @SerialName("nickname")
        val nickname: String,
        @SerialName("information")
        val information: String,
    )
  • response/SampleResponse.kt
    @Serializable
    data class BaseResponse<T> (
        @SerialName("code")
        val code: Int,
        @SerialName("message")
        val message: String,
        @SerialName("data")
        val data: T?
    )
  • 서버로 요청하는 Request Data
{
    "nickname": "조돌이",
    "information": "정보 정보 정보 정보"
}
  • 서버에서 응답하는 Response Data
{
  "code": 201,
  "message": "모임 프로필 생성 성공",
  "data": null
}

UI Layer

UI 레이어는 데이터의 상태를 받아와 어플리케이션 화면에 데이터를 표시하는 레이어입니다.

UI elements : 화면에 보이는 UI 요소 (Activity, Fragment)

State holders : 데이터를 보유하고 이를 UI에 노출하며 로직을 처리하는 상태 홀더 (ViewModel 클래스)

ui/ViewModel

  • ProfileViewModel.kt
    • ViewModel 에서는 데이터를 관리하고 Configuration Change 에 대응할 수 있다. 여기서 viewModelScope 를 사용하면서 코루틴 스코프를 사용하고 있다. 이 스코프는 ViewModel이 clear 될 때 취소될 수 있다. viewModelScope : “This scope will be canceled when ViewModel will be cleared.”
    • Kotlin Result 클래스를 사용해서 runCatching 으로 결과에 대한 성공과 실패에 대한 분기처리를 해주었다.
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())
            }
        }
    }
}
  • ProfileViewModelProvider.kt
    • ViewModel 객체를 만들어주고 생성자의 인자를 주입해주기 위해서는 별도의 ViewModelProvider 클래스를 만들었고, 이는 ViewModelProvider.Factory 를 반환하여 뷰(Activity, Fragment) 에서 사용되는 viewModel 객체에 할당할 수 있다.
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) 주입이 존재한다.

ui/Activity

  • ProfileActivity.kt

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
    }
}

Application

  • Application.kt
    • 앱이 처음 시작될 때, 서버 통신을 위해서 사용되는 Respository 객체를 전역으로 사용할 수 있게 넣어 주어야 했다. 이유는 뷰모델(레포지토리(데이터소스(API 서비스))) 와 같이 뷰모델에서 레포지토리 객체를 만들어주는 것은 올바르지 못하다. 왜냐하면 서버 통신 객체를 뷰모델이 만들어질때마다 매번 만들어줄 것인가? 그건 아니다. 그래서 우리는 서비스 로케이터 개념을 사용할 수 있다.
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 개발을 해보고 싶다. 아키텍처 패턴으로 설계하면 좋은 장점은 테스트 용이성이다. 그래서 테스트 케이스를 작성하고 테스트 후 코드 구현을 해보고 싶다.

profile
Allright!

0개의 댓글

관련 채용 정보

Powered by GraphCDN, the GraphQL CDN