본문 바로가기

Android Architecture

클린아키텍처를 지향하는 아키텍처

👩‍💻 오늘의 할 일

앱 스쿨에서 최종 프로젝트를 진행하며 너무 좋은 팀원분들을 만나 최종 목표를 클린 아키텍처 적용 및 앱출시를 목표로 하게 되었습니다. 프로젝트를 진행하기 앞서 아키텍처를 먼저 구성하게 되었는데요, 클린 아키텍처를 적용하기 앞서 저를 포함한 모든 팀원분들이 아키텍처에 대한 경험이 많이 없었습니다. 그래서 Repository Pattern부터 하나씩 연습하기 위한 아키텍처를 만들게 되었습니다. 이 과정에서 느낀 경험들을 공유하고자 하며 의존성 주입에 관한 내용은 이전 글을 참조해 주세요.

👩‍🏫 나의 아키텍처 설계 원칙

모든 구성 요소들, 작은 변수명 하나라도 설계에 대한 이유가 있어야 한다.
 앱 초기 설계인 만큼 미래 확장에 대한 가능성을 열어둔다.

RetrofitIntance

 ✅ okHttpClient  

싱글톤 클래스이기 때문에 인스턴스가 생성될 때 프로퍼티 또한 메모리에 올라가기 때문에 최소한의 Memory Leak을 방지하고자 실제 프로퍼티 사용 시점으로 지연 초기화해 주었습니다.

 

 ✅ provideRetrofit

 Retrofit Instance를 생성하기 위한 메서드입니다. 본래 Retrofit Instance는 생성 비용, 동기화 등의 문제로 싱글톤으로 생성해야 하나 이 프로젝트에선 다수의 API를 사용할 예정이기 때문에 매번 BaseUrl과 Service Interface가 달라집니다.

 

이 때문에 싱글톤으로 생성이 불가해 추후 Hilt를 사용해 싱글톤으로 만들 수 있도록 리팩토링할 예정입니다. 또한 추후 Hilt의 Provide를 사용해 바인딩할 예정이기 때문에 그에 맞는 이름으로 메서드명을 지었습니다.

 

 ✅ provideService

다수의 API를 사용할 예정이기 때문에 API에 맞는 API 서비스 인터페이스의 구현체를 생성하는 메서드입니다. 마찬가지로 Hilt의 Provide를 사용해 바인딩할 예정이기 때문에 아래와 같이 네이밍 하였습니다.

object RetrofitInstance {

    private val okHttpClient: OkHttpClient by lazy {
        OkHttpClient.Builder()
            .addInterceptor(HttpLoggingInterceptor { message ->
                Log.e("MyOkHttpClient :", message + "")
            }.setLevel(HttpLoggingInterceptor.Level.BODY))
            .build()
    }

    private fun provideRetrofit(baseUrl: String): Retrofit = Retrofit.Builder()
        .baseUrl(baseUrl)
        .addConverterFactory(MoshiConverterFactory.create())
        .client(okHttpClient)
        .build()

    fun <T> provideService(baseUrl: String, apiService: Class<T>): T {
        return provideRetrofit(baseUrl).create(apiService)
    }
}

 

2024.05.17 수정

Retrofit Instance를 만드는 코드를 리팩토링 했습니다. 하나의 base_url 당 하나의 Service를 가지는게 맞다고 생각해 변경하였습니다. 다른 API를 사용할 경우도 Service를 다음과 같이 만들 수 있습니다.

object RetrofitInstance {

    private val okHttpClient: OkHttpClient by lazy {
        OkHttpClient.Builder()
            .addInterceptor(HttpLoggingInterceptor { message ->
                Log.e("MyOkHttpClient :", message + "")
            }.setLevel(HttpLoggingInterceptor.Level.BODY))
            .build()
    }

    val korWithService: KorWithService by lazy {
        Retrofit.Builder()
            .baseUrl("https://apis.data.go.kr/B551011/KorWithService1/")
            .addConverterFactory(MoshiConverterFactory.create().asLenient())
            .client(okHttpClient)
            .build()
            .create()
    }

	// 만약 다른 baseUrl 을 가지는 Service 도 사용할 계획이라면
    val anotherService: KorWithService by lazy {
        Retrofit.Builder()
            .baseUrl("https://another.data.api.com") //
            .addConverterFactory(MoshiConverterFactory.create())
            .client(okHttpClient)
            .build()
            .create()
    }
}

Retrofit Service Interface & DTO & Model

 ✅ KorWithService

API에서 제공하는 지역 코드를 조회하기 위한 메서드입니다. API 요청에 필요한 파라미터를 한 번에 맡기 위해 QueryMap을 사용했습니다. 또한 API에 명시된 이름과 똑같이 메서드명을 네이밍 했습니다.

interface KorWithService {
    @GET("B551011/KorWithService1/areaCode1")
    suspend fun getAreaCode(
        @QueryMap params: Map<String, String>
    ): AreaCodeResponse
}

 

 ✅ SearchKeywordRequest

API 요청을 위한 data Class입니다. 고정으로 사용할 프로퍼티는 디폴트 파라미터를 지정해 주었고 이외의 프로퍼티는 클래스 생성 시 입력받습니다. Service Interface Method의 요청 파라미터 타입이 QueryMap이기 때문에 Map으로 변환하는 함수를 선언해 주었습니다.

data class SearchKeywordRequest(
    val keyword: String
){
    fun toRequestModel(): Map<String, String>{
        return mapOf(
            "numOfRows" to API_RESULT_MAX_NUM_OF_ROWS,
            "MobileOS" to API_MOBILE_OS,
            "MobileApp" to APP_NAME,
            "_type" to API_TYPE,
            "serviceKey" to SERVICE_KEY,
            "listYN" to API_LIST_YN,
            "arrange" to API_ARRANGE,
            "keyword" to keyword
        )
    }
}

 

 ✅ SearchKeywordResponse & SearchKeyword

SearchKeywordResponse는 API 응답을 받아올 DTO입니다. 이 클래스는 DTO이기 때문에 클린 아키텍처 관점에선 Data Layer에 해당합니다. 이를 그대로 사용하면 Domain Layer와 Presertation Later에서 Data Layer에 대한 의존성이 생기게 됩니다.

@JsonClass(generateAdapter = true)
data class SearchKeywordResponse(
    val response: Response
){
    fun toDomainModel(): List<KeywordSearch> {
        return response.body.items.item.map {
            KeywordSearch(
                //생략
            )
        }
    }
}

 

이를 방지하기 위해 필요한 정보만 ViewModel에서 사용하도록 SearchKeyword Class 를 만들었고 이는 Domain에 위치하기 때문에 SearchKeywordResponse를 도메인에 위치한 SearchKeyword Class로 매핑하는 toDomainModel( ) 함수를 선언하였습니다. 

data class KeywordSearch(
    val title: String,
    val address: String,
    val detailAddress: String,
    val contentId:String,
    val contentTypeId: String,
    val firstImageUrl: String,
    val latitude: String,
    val longitude: String,
)

DataSource

 ✅ SearchKeywordDataSource

   서버에서 검색어에 대한 결과를 가저오는 DataSource입니다. 검색 결과를 디바운싱 하기 위해 flow를 반환합니다. 디바운싱이란 일정 시간 이벤트를 그룹화해 일정 시간 동안 이벤트가 발생하지 않으면 가장 마지막 이벤트를 전달합니다.

 

예를 들어 검색어를 입력하는 이벤트가 있을 때, 검색어를 입력할 때마다 API를 호출하게 되면 불필요한 트래픽을 소모시킬 수 있습니다. 이때 디바운스를 할 경우 일정 시간 검색을 하지 않을 경우 자동으로 검색어를 서버에 전송하게 됩니다. 디바운스에 대한 내용은 이 글을 참조해 주세요

class SearchKeywordDataSource(
    private val korWithService: KorWithService
) {
    suspend fun getSearchByKeywordResult(keyword: String): Flow<SearchKeywordResponse> {
        return flow {
            emit(
                korWithService.getSearchByKeywordResult(
                    SearchKeywordRequest(keyword).toRequestModel()
                )
            )
        }
    }
}

Repository

 ✅ SearchKeywordRequest

Repository에 관한 내용은 이 글에 자세히 나와있습니다. Repository는 클린 아키텍처의 Data Layer이기 때문에 DTO를 사용할 수 있습니다. getAreaCode( ) 메서드에서 API 요청을 위한 인스턴스를 만들어 DataSource에 전달합니다.

interface SearchKeywordRepository {
    suspend fun searchByKeyword(keyword: String): Flow<List<KeywordSearch>>

    companion object {
        fun create(): SearchKeywordRepositoryImpl {
            return SearchKeywordRepositoryImpl(
                SearchKeywordDataSource(RetrofitInstance.korWithService)
            )
        }
    }
}

class SearchKeywordRepositoryImpl(
    private val searchKeywordDataSource: SearchKeywordDataSource
) : SearchKeywordRepository {
    override suspend fun searchByKeyword(keyword: String): Flow<List<KeywordSearch>> {
        return searchKeywordDataSource.getSearchByKeywordResult(keyword).map {
                it.toDomainModel()
        }
    }
}

UseCase

 ✅ SearchByKeywordUseCase

키워드로 검색을 하기 위한 UseCase를 선언해 주었습니다. UseCase는 하나의 작업 단위로 하나의 비즈니스 로직을 표현합니다. operator 키워드는 코틀린의 연산자 오버로딩을 허용하는 키워드로 operator 키워드가 붙은 함수는 특정 연산자와 연결되어 해당 연산자가 호출될 때 자동으로 실행됩니다.

 

여기서 사용된 특정 연산자는 invoke 함수로 invoke 함수는 코틀린에서 객체를 함수처럼 호출하는 데 사용됩니다. 즉, 클래스의 인스턴스가 생성되면 자동으로 호출됩니다. 

class SearchByKeywordUseCase(
    private val searchKeywordRepository: SearchKeywordRepository
){
    suspend operator fun invoke(keyword: String): Flow<List<KeywordSearch>>{
         return searchKeywordRepository.searchByKeyword(keyword)
    }
}

ViewModel

SearchViewModel & SearchViewModelFactory

  viewModel에선 사용자가 검색한 입력 데이터를 StateFlow로 전달받아 800ms 마다 검색어 API를 호출하고 있습니다. 

class SearchViewModel(
    private val searchByKeywordUseCase: SearchByKeywordUseCase,
): ViewModel() {
    private val _searchKeyword = MutableStateFlow("")

    @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
    val searchResult = _searchKeyword
        .debounce(800)
        .flatMapLatest { query ->
            if (query.isNotBlank()) {
                searchByKeywordUseCase(query)
            } else {
                flowOf()
            }
        }
        .flowOn(Dispatchers.IO)
        .catch { e: Throwable ->
            e.printStackTrace()
        }

    fun onCompleteTextChanged(searchKeyword: String){
        _searchKeyword.value = searchKeyword
        viewModelScope.launch {
            addRecentSearchKeywordUseCase(searchKeyword)
        }

    }
}

class SearchViewModelFactory(context: Context) : ViewModelProvider.Factory{

    private val searchByKeywordUseCase = SearchByKeywordUseCase(SearchKeywordRepository.create())

    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(SearchViewModel::class.java)){
            return SearchViewModel(searchByKeywordUseCase) as T
        }
        throw IllegalArgumentException("unknown ViewModel class")
    }
}

📑 후기

스터디분들과 진행하는 프로젝트에서 기반한 내용이 많았지만 설계하다 보니 수정할 사항이 많이 보였습니다. 역시 아키텍처엔 정답이 없는 거 같아요. 그래서 더 재미있는 것 같습니다. 더 개선할 부분이 있다면 언제든 댓글 남겨주시면 감사히 듣겠습니다!