상세 컨텐츠

본문 제목

안드로이드 MVVM + 클린아키텍처는 어떻게 쓰는걸까?

Android

by Android_박동민 2023. 12. 28. 01:50

본문

안녕하세요 고잉고잉 Android 팀 박동민입니다.

 

저희팀이 적용한 안드로이드 클린아키텍처를 공부하며 생긴 궁금증을 해결하고, 이론을 학습하는 과정에서 저 나름대로 이해해 보고 적용해본 과정을 나타냈습니다. 그리고 고민해야할 점이 무엇인지 생각해보았습니다. 또한 클린아키텍처의 모든 부분을 다루는 것이 아닌, 저희 팀에서 적용한 클린아키텍처만 설명하고 있습니다. 코드는 SOPT 33rd Android 과제 코드를 활용한 코드입니다.

 

제가 생각하며 내린 결론들이기 때문에 틀린 부분이 있을 수 있습니다. 혹시 다른 의견이 있으시거나, 틀린 내용이 있다면 댓글을 통해 공유한다면 더욱 좋을 것 같습니다!!

 

 

🫧 클린 아키텍처란?

Robert C. Martin이 작성한 Clean Code는 소프트웨어 시스템의 구조를 설계할 때 지켜야 할 원칙과 어떻게 지킬 수 있는지 방법을 정의한 내용이다. 

 

핵심은 복잡한 소프트웨어를 유지보수 가능한 형태로 구축할 수 있게 해주는 내용이 담겨있다.

이를 통해서 모듈간 분리를 통해 유지보수, 테스트 용이성 등을 높여줘서 안드로이드 애플리케이션을 보다 구조화 된 방식으로 개발 할 수 있도록 돕는다. 또한 관심사 분리에 도움을 준다.

 

 

🤔 왜 고잉고잉에서는 클린 아키텍처를 사용했을까?

클린 아키텍처의 장점 중 "유지 보수가 편하다" 라는 부분에 집중했다. 우리 팀은 새로 만들어진 팀이고, 많은 수정이 필연적으로 따라오게 되어있다. 또한 초기 개발단계에서는 오류가 자주 발생할 수 있고, 급하게 수정해야 하는 상황이 올 것이다. 그때마다 담당자를 기다리는 것 보다 시간이 되는 사람이 바로바로 수정하는 것이 더욱 효율적이라고 생각이 든다. 그렇기 때문에 위와같은 사항의 불편함을 해소할 수 있는 클린 아키텍처를 선택했다.

 

클린 아키텍처는 모두가 같은 아키텍처로 개발을 하고 있기 때문에 누가 읽더라도 이해하기가 쉽다. 그렇기 때문에 자신이 작성하지 않았더라도, 다른 사람이 보고 이해 후 수정하기가 쉬워진다.

또한 모듈간 분리가 되어있기 때문에 하나를 수정한다고 하더라도 모두 다 수정할 필요가 없다. 즉 특정 레이어를 수정할 때 해당 레이어만 수정을 하고 다른 레이어는 수정을 하지 않아도 된다.

 

 

 

😲 그러면 클린 아키텍처는 무조건 좋을까?

물론 장점이 강력하다. 하지만, 그에 따른 단점이 반드시 존재한다.

 

내가 생각하는 제일 큰 단점은 학습난이도 이다. 

 

클린 아키텍처는 모듈간 분리를 진행해서 여러 장점을 가져온다. 돌려서 말한다면 클린 아키텍처는 모듈간 분리가 필수다.

그럼 모듈간 분리는 누가할까? 코드 작성하는 사람이 해야한다.

 

정해진 틀에 맞춰서 분리를 하려고 해도 이해가 어렵다.

애초에 왜 이렇게 나누는지도 이해가 잘 안되고, 어떤 순서로 로직이 작동되는지도 이해가 잘 안된다.

 

그렇기 때문에 숙련된 팀에게는 완벽한 도구가 되겠지만 그게 아니라면 초반 장벽을 넘기기가 힘들다.

 

 

 

✏️ 그래서 어떻게 쓰는데?

안드로이드 권장 앱 아키텍처

 

하지만, 안드로이드 권장 아키텍처의 참조 흐름이 아닌, 클린아키텍처의 참조 흐름을 사용했다.

 

UI -> Domain -> Data가 아닌,

UI -> Domain <- Data 구조를 사용했다.

 

어려운 코드에 들어가기 전에 각각 Layer에 대한 설명과, 역할을 활용한 설명을 먼저 진행할 예정이다.

이론에 대한 이해가 있어야 "Coder"가 아닌 "Developer"가 된다고 생각한다. 그래서 이론에 대한 설명을 먼저 진행한 후 코드로 넘어가겠다.

 

UI Layer

말 그대로 화면에 보여지는 것과 관련이 있는 계층이다.

Activity, Fragment, ViewModel 등이 담겨있다.

* AAC ViewModel과 클린아키텍처에서의 ViewModel은 다릅니다!!

* 하지만 현재 본문에서는 AAC ViewModel을 통해 ViewModel을 구현해두었습니다.

* 혼동 없으시길 바랍니다.

 

Data Layer

서버와 통신하며 정보를 가져오는 역할을 한다.

Service, DataSource, Repository, Model 등이 담겨있다.

 

Service는 서버와 통신하기 위한 기능이 담겨있다.

DataSource는 Service에 담겨있는 기능을 실행시켜주는 역할을 한다.

Repository는 DataSource가 가져온 정보를 Domain Layer가 요청한 형식에 맞게 가공하는 역할을 한다.

Model은 서버와 통신할 때 가져오는 정보를 담는 클래스다.

 

Domain Layer

가장 큰 특징으로 Kotlin만 들어갈 수 있다. 즉, Android에 종속성을 가지는 코드는 들어가면 안된다.

Repository와 Model이 존재한다.

 

Data Layer에서 가져온 정보를 가공하여 사용할 수 있게 해주는 역할을 한다. 또한 더욱 확실한 관심사 분리를 할 수 있게 해준다.

 

Data Layer에서 가져온 정보를 본다면 우리가 화면에 나타내고자 하는 정보  뿐만 아니라 여러 정보들이 다 담겨있다.

그렇기 때문에 이 정보들을 모두 가지고 UI Layer로 이동하게 된다면 불필요한 정보를 들고 움직이게 된다.

이러한 상황을 막고자 Domain Layer에서 새로운 Model을 만들고 필요한 정보만 담아서 움직일 수 있게 해준다.

 

또한 Domain Layer가 존재하게 된다면 하나의 새로운 계층이 생기기 때문에 기존에서 흐름이 변화되게 된다.

UI Layer가 Data Layer에 요청하여 정보를 가져오던 것이 UI Layer가 Domain Layer에 요청을 하게 되고 Domain Layer에 Data Layer가 연결되어 작업을 수행 후 UI Layer에 정보를 전달해주게 된다.

 

UI -> Data 에서 UI -> Domain <- Data 와 같은 형식으로 변화하게 된다.

 

 

요청 순서

간략하게 말하면 아래와 같다.

 

UI Layer에서 데이터를 요구하려고 함

-> Domain Layer의 Repository에 요청함

-> Domain Layer의 Repository를 구현한 Data Layer의 Repository가 실행됨

-> DataSource를 통해 Service를 실행시켜 Server와 통신하여 정보를 가져옴

-> 가져온 정보를 Data Layer의 Repository에서 Domain Layer의 Model에 맞게 가공하여 정보를 넘겨줌

-> Domain Layer에서 UI Layer에 정보를 전달해줌

 

대충 뭔소린지 이해가 잘 안갈것이다

그래서 간단한 예시를 준비했다.

 

 

UI : 손님

Domain : 종업원

Data : 주방장

Service : 업자

 

이렇게 생각을 해보자

 

손님이 음식을 주문하려고 함

-> 종업원에게 음식을 주문함

-> 종업원이 주방장에게 주문을 전달함

-> 주방장이 요리를 시작함

-> 업자를 시켜 재료를 받아옴

-> 재료를 손질하고 요리 해서 종업원에게 넘겨줌

-> 종업원이 손님에게 음식을 제공함

 

하나도 어색하지 않다.

실제로 하는 역할도 동일하다.

 

그러면 실제 코드와 함께 알아보자

 

 

 

 

🤜 코드로 알아보자

UI Layer

// UI Layer

suspend fun loadUserList() {
    userRepository.loadUser(2).onSuccess {
        _loadListResult.value = it.otherUserList
        _loadListSuccess.value = true
    }.onFailure {
        _loadListSuccess.value = false
    }
}

손님(UI)이 종업원(Domain)에 주문을 하는 상황이다.

 

 

Domain Layer

// Entity

data class OtherUserList(
    val otherUserList: List<OtherUser>,
) {
    data class OtherUser(
        val email: String,
        val first_name: String,
        val last_name: String,
        val avatar: String,
    )
}

음식의 정보다. 재료가 아닌, 가공된 정보를 의미한다.

 

// Repository

interface UserRepository {
    suspend fun loadUser(page: Int): Result<OtherUserList>
}

Domain은 종업원이라고 위에서 설명했다.

그래서 종업원은 메뉴(entity)를 알고있어야하고, 실질적인 요리(data 가져오기 및 가공)은 하지 않는다.

단순히 손님(UI)에서 요청이 왔다는 것을 알리고, 주방장(Data Layer)가 전달해주는 요리를 전달하는 역할만 한다.

 

앞서 말했던 확실한 관심사 분리가 이루어진다.

 

 

Data Layer

// Service

interface UserService {
    @GET("api/users")
    suspend fun getUserList(
        @Query("page") page: Int,
    ): ResponseListUserDto
}

업자(Service)에 재료(Data)를 가져오는 기능만 구현이 되어있다. 즉, 요청 방법만 존재하고 실행시키지는 않는다.

 

// DataSource

interface UserDataSource {
    suspend fun getUserList(page: Int): ResponseListUserDto
}

class UserDataSourceImpl @Inject constructor(
    private val userService: UserService,
) : UserDataSource {
    override suspend fun getUserList(page: Int): ResponseListUserDto = userService.getUserList(page)
}

업자(Service)를 시켜 재료(Data)를 받아오는 과정이다.

 

// Repository

class UserRepositoryImpl @Inject constructor(
    private val userDataSource: UserDataSource,
) : UserRepository {
    override suspend fun loadUser(page: Int): Result<OtherUserList> =
        runCatching {
            userDataSource.getUserList(page).toOtherUserList()
        }
}

재료(Data)를 가공 및 요리하여 음식(Domain의 Entity)로 만드는 과정이다.

 

// Model

@Serializable
data class ResponseListUserDto(
    @SerialName("page")
    val page: Int,
    @SerialName("per_page")
    val per_page: Int,
    @SerialName("total")
    val total: Int,
    @SerialName("total_pages")
    val total_pages: Int,
    @SerialName("data")
    val data: List<UserDto>,
    @SerialName("support")
    val support: SupportDto,
) {
    @Serializable
    data class SupportDto(
        @SerialName("url")
        val url: String,
        @SerialName("text")
        val text: String,
    )

    @Serializable
    data class UserDto(
        @SerialName("id")
        val id: Int,
        @SerialName("email")
        val email: String,
        @SerialName("first_name")
        val first_name: String,
        @SerialName("last_name")
        val last_name: String,
        @SerialName("avatar")
        val avatar: String,
    )
}

fun ResponseListUserDto.UserDto.toOtherUser() = OtherUserList.OtherUser(
    email = email,
    first_name = first_name,
    last_name = last_name,
    avatar = avatar,
)

fun ResponseListUserDto.toOtherUserList() = OtherUserList(
    otherUserList = data.map { it.toOtherUser() },
)

재료(DTO)를 음식(Domain의 Entity)로 바꾸는 내용이 담겨있다.

 

이때, Domain Layer에 있는 Entity로 바꾸는 코드가 Data Layer에 있걸까?

 

Domain Layer는 Data Layer를 모른다. 

종업원이 주방장의 요리 방법 및 재료를 알 필요가 있는가?

전혀 없다.

 

주방장이 바뀌더라도 종업원이 요구하는 요리만 잘 만들어주면 그만이다.

또한 종업원이 다 알아야 한다면 종업원이 바뀔 때 마다 요리법을 전부 알려줘야 한다.

 

코드도 똑같다.

Data Layer 내부 코드가 바뀌더라도, Domain Layer에서 요구하는 결과값만 잘 전달해준다면 Domain Layer는 변화가 없어도 된다.

또한 Domain Layer가 바뀌더라도 자기 자신과 관련된 내용만 변경하면 되지 Data Layer에 해당하는 내용까지 거기서 바꿀 필요는 없다.

 

그렇기 때문에 Domain Layer에서 DTO를 Domain Layer의 Entity로 변환하는 작업을 담당한다.

 

 

 

이러한 과정을 통해서 클린 아키텍처를 구현했다.

 

좋은 아키텍처를 설계하기 위해서는 정말 많은 부분을 고려해야 한다는 것을 다시 한번 느꼈다.

지금 이 아키텍쳐에서는 UseCase도 빠져있다. 현재 단계에서는 불필요하다는 생각에 적용하지 않았지만, 추후 적용하게 된다면 이 또한 공부해야하는 내용이다.

 

지금까지의 과정을 통해서 클린 아키텍처가 추구하는 것을 어느정도 느끼게 되었다. 모듈로 나누면서 최대한 유지보수가 좋아지도록. 이전 코드가 이후 코드에 영향을 미치지 않도록. 누가 봐도 같은 틀의 코드를 작성하도록. 이러한 방식을 권장한다는 느낌을 받았다.

앞서 말한 단점인 이해하기 어렵다는 것은 부정하지 않는다. 하지만, 이 단계를 넘어서게 된다면 협업에 정말 편리하고 유지보수에 엄청난 강점을 가진 코드를 만들 수 있을 것이라는 확신이 들었다.

 

클린 아키텍처의 핵심은 사실 어디에서나 적용된다고 생각한다. 과한 의존성을 가진 코드는 하나를 수정한다면 연쇄적으로 모든 코드를 수정해야하는 참사를 낳는다. 그렇기 때문에 이 내용을 다른 코드에도 적용하여 유지보수에 좋은 코드를 작성하기 위해 한번 더 생각해봐야 한다는 생각을 가지게 되었다.

 


 

💪 추가적인 깨달음 - 2024.01.14

위 글을 작성할 때 까지만 해도 MVVM + 클린아키텍쳐를 배운지 얼마 안되고, 처음 적용하는 과정이었다. 그래서 "구조적으로 나눠져있어 협업에 편리할 것 같다" 라는 추상적인 생각만 가지고 장점이라 느끼고 있었다. 하지만, 계속해서 코드로 활용을 하다보니 "왜 굳이 통신 과정을 나눠야할까" 라는 생각이 들었고 이를 해결하기 위해 이론 공부 및 다양한 코드들을 작성하고 읽어보았다. 이 과정에서 나만의 생각과 느낀점을 기록하려고 한다.

 

 

서버통신이 진행되는 과정을 본다면 presentation -> repository -> datasource -> service의 순서로 진행된다.

이때 각각의 역할을 보고 왜 굳이 나눴는지 생각해보자.

 

service

서버통신을 할 때 사용해야하는 api들의 통신방식, request값, response값 등 사용 방식을 적어둔다.

 

datasource

service에 적혀있는 api를 통해서 정보를 가져오는 역할을 한다.

 

repository

datasource가 가져온 정보를 presentation이 원하는 방식으로 가공하는 역할을 한다.

 

presentation

화면에 나타내는 부분이다. 즉 서버통신을 시작해달라고 요청하는 부분이다.

 

 

윗부분 글에서 썼던 비유방식을 조금 더 구체화해서 보자.

 

service는 레시피북이다. 요리를 하기 위해서 어떤 재료가 필요한지 적혀있다.

datasource는 재료를 가져오기만 해주는 주방 조수다. 요구한 재료를 그대로 가져와준다.

repository는 재료로 요리를 해주는 주방장이다. 

 

 

여기까지만 봤을때는 각각의 역할은 이해가 가지만 굳이 왜 나눠야하는진 모르겠었다. 이렇게까지 나눠야 할까 싶었다.

하지만 이 역할을 바탕으로 코드의 정보를 추론하게 된다면 엄청난 장점이 보일 것이다.

 

 

서버통신을 하다보면 자잘한 실수를 많이 하곤 한다. 이럴때 우리는 ERROR코드와 인터셉터를 통해서 가져온 정보를 바탕으로 잘못된 부분을 찾곤 한다. 

 

이때 여러가지 경우를 따져보자.

1. 서버와 통신 자체에 실패한 경우

2. 통신을 했는데 서버에서 값을 가져오는 과정에서 문제가 생긴 경우

3. 통신을 했는데 서버에서 정상값을 가져왔지만, presentaion에 내가 원하는 정보가 없는 경우

4. 서버 잘못

 

 

서버와 통신 자체에 실패했다는 것은 애초에 명세서가 틀렸다는 것을 의미한다. 즉 service가 잘못 명세되어있다는 것이다.

통신은 했는데 값을 가져오는 과정에서 문제가 생겼다는 것은 값을 가져오는 dataSource가 문제가 있다는 것이다.

통신에서는 정상값을 가져왔지만, 내가 원하는 정보가 presentation에 도착하지 않았다repository잘못이다.

서버잘못은 위 모든 사항이 정상이라면 그때 서버 잘못이라는 결론을 도출시킬 수 있다.

 

 

각 역할을 생각해보면 너무나 간단한 추론이지만, 이를 깨닫기까지 너무 오래걸렸었다.

생각해보면 간단하다. 자기 역할이 나눠져있기 때문에 문제가 생겼을때 어디가 원인인지 찾기가 너무 수월하다.

 

개발자도 사람이다. 어쩔수 없이 실수를 한다. 이때 빠르게 실수를 바로잡는 것이 중요하다고 생각한다.

이에 도움이 되는 것이 아키텍쳐 구조라는 것을 몸으로 느끼게 되었다.

관련글 더보기