안드로이드와 클린 아키텍처

안드로이드 앱 아키텍처에 관심을 갖게 되면 필히 접하는 키워드, 클린 아키텍처에 대해서 알아보려고 한다.

클린 아키텍처

“If you want to go fast, if you want to get done quickly, if you want your code to be easy to write, make it easy to read”


“빠르고 신속하게 코드작성을 하고 싶다면, 코드를 읽기 쉽게 만들어라”

– Robert C. Martin

좋은 아키텍처는 유즈케이스를 그 중심에 두기 때문에, 프레임워크나 도구, 환경에 전혀 구애 받지 않으면서 유즈케이스를 지원해야 한다. 그렇기 때문에 안드로이드 프레임워크와 거리를 두고, 모든 유즈케이스에 대해 유닛 테스트를 할 수 있으면 좋은 아키텍처라고 볼 수 있다.

지난 수년간 안드로이드 진영에서 여러가지 아키텍처 또는 디자인패턴을 도입하려는 시도가 있었고, 이들은 세부적인 면에서는 다소 차이가 있더라도 그 핵심적인 내용은 같았다. 바로 관심사의 분리(Separation of concerns)다.

아키텍처가 추구하는 내용들은 다음과 같은 특징을 갖는다.

  • UI, 데이터베이스, 프레임워크 등의 독립성 (결합도 down)
  • 기능 변경 및 확장의 용이성 (유지보수의 용이성)
  • 테스트 용이성

클린 아키텍처는 미국의 소프트웨어 엔지니어 Robert C. Martin이 고안하였다. 이는 위에서 언급한 내용들을 종합적으로 포함하면서, 계층별로 관심사를 분리하고 비즈니스 로직을 캡슐화하는 소프트웨어 설계 철학이다. 다이어그램을 살펴보면서 좀 더 자세히 알아보도록 하자.

클린 아키텍처 다이어그램

[그림1] 클린 아키텍처 다이어그램

이 그림은 클린 아키텍처의 철학을 함축하여 나타내고 있다. 크기가 다른 4개의 동심원으로 구성되어 있는 것을 확인할 수 있다. 각 계층에 대해서 간단히 살펴보자.

  • 엔티티(노랑색) : 엔티티는 전사적인 핵심 업무 규칙을 캡슐화 한다. 즉, 모든 플랫폼 애플리케이션에서 재사용 가능해야 한다.  외부의 무언가가 변경되어도 엔티티가 변경될 가능성은 낮다. 
  • 유즈케이스(빨강색) : 애플리케이션에 특화된 업무 규칙을 포함한다. 유즈케이스는 엔티티로 들어오고 나가는 데이터의 흐름을 조정하며, 엔티티가 자신의 핵심 업무 규칙을 사용해서 유즈케이스의 목적을 달성하도록 한다.  변경사항이 엔터티에 영향을 줘서는 안되며, UI 또는 프레임워크 등과 같은 외부요소에서 발생한 변경이 이 계층에 영향을 줘서도 안된다. 
  • 인터페이스 어댑터(녹색) : 인터페이스 어댑터 계층은 일련의 어댑터들로 구성된다. 여기서 어댑터가 하는 역할은 상대적으로 상위 계층인 유즈케이스 그리고 프레임워크와 같은 하위 계층간의 중간다리 역할을 하며 서로가 가장 편리한 형식으로 변환을 한다. 흔히 MVP, MVVM 같은 아키텍처가 여기에 속한다.
  • 프레임워크(파랑색) : 안드로이드 프레임워크를 의미 한다. 일반적인 앱 개발자는 이 영역에서 작성할 코드는 거의 없다. 하지만, 구글이 새로운 안드로이드 프레임워크를 배포할 때 변경사항이 발생하므로 우리는 안드로이드 프레임워크를 가장 외부에 위치시켜서 피해를 최소화해야 한다.

    계층간 의존성 규칙

    각각의 동심원은 서로 다른 영역을 표현하고 있다. 바깥원은 메커니즘이고 안쪽원은 정책이다. 그림에 있는 동심원 내부의 화살표 방향으로 알 수 있듯이 바깥원에서 안쪽원으로 의존하는 규칙을 갖는다. 즉, 안쪽원은 바깥쪽원에 대한 정보를 몰라야 한다. 우리는 외부원에 위치한 어떤 것도 내부의 원에 영향을 주지 않기를 바란다.

    안드로이드 프로젝트에서 이러한 원칙을 지키기 위해서는 계층별로 모듈을 분리하고 의존관계를 설정할 수 있다.

    일반적으로 다음과 같이 모듈을 나눌 수 있다.

    • :domain
    • :data
    • :presentation

    클린아키텍처 다이어그램을 안드로이드 프로젝트에 맞게 바꾸자면 다음과 같은 형태를 띈다.

    [그림2] 다이어그램에 안드로이드 모듈별로 구획 긋기

    [그림3] 클린 아키텍처에 맞게 구성한 안드로이드 프로젝트

    안드로이드 프로젝트에서 엔티티를 다른 플랫폼과 사실상 공유하기가 힘들다. 그러므로 :domain이라는 모듈 아래에 유즈케이스와 엔티티를 통합하였다.

    클린아키텍처의 동심원이 꼭 4개일 필요도 없다. 그저 개념을 설명하기 위한 예시일 뿐이다. 상황에 따라 더 많은 계층을 만들고 세분화할 수 있다. 다만 계층간 의존성 규칙은 반드시 지켜야 한다. 동심원 안쪽으로 이동할수록 추상화가 높아진다.

    각 모듈이 하는 역할에 대해서 살펴보자.

    :domain 모듈

    프로젝트 전체에 비즈니스 로직이 산재해 있다면 아마 다음과 같은 일이 벌어질 것 같다.

    • 기능 추가 또는 수정해야 할 코드를 찾기 힘들어 진다.
    • 앱이 확장됨에 따라 코드들이 점점 더 복잡해진다.
    • 기존 코드를 확장 및 재사용하기가 어렵다. (어쩌면 새로 짜는게 더 빠를지도..)

    비즈니스 로직을 격리하기 위한 좋은 방법이 바로 domain 모듈을 구현하는 것이다.

    domain 모듈은 앱의 중심부로써 이 계층에 포함된 비즈니스 로직은 앱을 구성하고 있는 것 중 가장 중요한 부분이다. 그래서 비즈니스 로직을 망쳐서는 안되기 때문에 이 계층은 어떠한 계층에도 의존하지 않는다. domain 모듈은 다음과 같은 코드를 포함한다.

    • Entity  : 특정 영역을 표현하는 객체. ex) Pojo, VO, DTO 등
    • UseCase : Entity와 함께 비즈니스 로직을 수행한다.
    • Repository 인터페이스 : 데이터베이스, 원격 서버와 같은 데이터 소스에 접근한다.

    domain 모듈은 비즈니스 로직들을 한 계층에서 관리하는데 초점을 맞춘다. 이를 통해 코드를 깨끗하게 관리하고, 단일 책임 원칙(SRP;Single Responsibility Principle)에 부합하는 코드를 작성하기가 쉬워진다.

    UI 또는 프레임워크 코드는 빈번히 변경될 수 있고, 비즈니스 로직과 관련이 없는 내용이므로 domain 모듈 분리는 매우 중요하다.  코드의 가독성도 좋아지므로, 팀 프로젝트에 누군가 새로 온보딩 할 때 앱이 동작하는 방식에 대해 이해하기도 쉬울 것이다.

    아마 작은 기능만을 수행하는 프로젝트에서는 이러한 계층분리가 시간만 잡아먹고, 복잡하다고 느낄지도 모른다. 하지만 앱의 규모가 커짐에 따라 여러가지 비용 측면에서 많은 이득을 볼 수 있다.

    :data 모듈

    data 모듈은 데이터 소스(DB, 서버 등)와 상호작용을 담당하는 코드가 포함되는 곳이다. data 모듈은 ㅇdomain 모듈에 의존한다.

    앱은 아마 여러가지 데이터 소스를 사용할 텐데, 데이터 소스 또한 시간이 지남에 따라 변경될 수 있다. 예를 들면 REST 서버를 GraphQL 서버로 마이그레이션 하거나 또는 Realm DB를 RoomDB로 변경해야하는 경우를 말한다. 이러한 변경사항은 오로지 데이터를 처리하는데 관련된 로직일 뿐 데이터를 필요로 하는 코드에는 영향을 미치지 않는다.

    data 모듈은 다음과 같은 두가지 책임을 갖는다.

    • 데이터 입출력 코드를 하나의 계층에서 관리한다.
    • 데이터 소스들과 데이터를 소비하는 다른 계층과의 경계를 둔다.

    data 모듈에서는 domain 모듈에서 정의한 Repository 인터페이스를 구현한다. 이게 구글에서 추천하는 Repository 패턴이다.

    [그림4] 리포지토리 패턴

    그림을 보면 domain과 data간의 분리가 이루어져 있기 때문에, 혹시나 데이터 소스를 변경해도 domain 모듈에는 영향이 없기 때문에 비즈니스 로직은 피해없이 안전하다.

    :presentation 모듈

    domain, data 모듈에 의존한다. 프레젠테이션 계층은 UI와 관련된 코드를 캡슐화한다. 모든 UI와 관련된 컴포넌트 또는 안드로이드 프레임워크와 관련된 코드들을 이 계층에서 다루게 된다.

    아무래도 UI / UX는 비즈니스 로직에 비해 상대적으로 변경할 일이 많다. 또한 UI와 관련된 유닛테스트는 어렵기 때문에 UI와 관련된 내용은 다른 코드에서 의존성이 없도록 최대한 독립적으로 만들어야 한다. UI 코드를 한 곳에서 관리함으로써 비즈니스 로직을 보호하고, 테스트도 쉽게 만든다.

    제어의 흐름

    [그림1]을 보면 오른쪽 한켠에 제어의 흐름(flow of control)을 나타내고 있는데, 동심원간의 경계를 횡단하는 방법을 보여준다. 안드로이드로 말하자면 시스템 이벤트 또는 사용자의 입력을 받아 유즈케이스를 통과한 후 처리한 결과를 View에 렌더링하며 마무리 한다고 볼 수 있다.

    [그림3]에 제어의 흐름을 덧붙여 나타낸다면 다음과 같다.

    [그림5] [그림3]위에 제어의 흐름을 매핑한 결과

    유즈케이스에서 프레젠터와 같은 하위 계층의 코드를 직접 참조하여 호출해버리면 클린 아키텍처의 계층간 의존성 규칙을 위반하게 된다. 제어의 흐름과 동심원들의 의존성 방향이 반대가 되는 경우는 인터페이스와 함께 의존성 역전 원칙을 사용하여 해결한다. 클린 아키텍처에서 동심원 경계를 횡단할 때 언제라도 동일한 기법을 사용할 수 있다. 

    계층을 횡단 하면서 데이터를 전달할 때, 데이터는 항상 내부의 원에서 사용하기에 가장 편리한 형태를 가져야만 한다. 예를 들어 서버에 저장된 사용자 정보를 갱신하기 위해 다음과 같이 가정해보자.

    • 사용자 정보를 갱신하기 위해 API를 호출한다.
    • :domain에서 사용자 정보는 User라는 형태로 표현한다.
    • :presentation에서 View에 사용자 정보를 나타낼 때는 UserUiModel로 표현한다.
    • :data에서 서버로 부터 응답받은 사용자 정보는 UserDTO로 표현한다.

    사용자 정보 갱신 API 호출을 위해 Activity에서 유즈케이스를 호출할 때 UserUiModel은 User로 변환되어야 한다. 또한 Retrofit을 통해 API요청에 따른 응답 UserDTO를 받았고, 이를 다시 유즈케이스로 넘기기 위해서는 User로 변경해야 한다. 흐름이 반대 방향일 때도 마찬가지로 변경이 필요하다.

    UserUiModel <-> User <-> UserDTO

    이렇게 경계를 횡단할 때 데이터의 모습이 변경될 수 있으며, 데이터 구조가 어떤 의존성을 가져서 의존성 규칙을 위배하게 되면 안된다. 일반적으로 데이터의 형태를 변경할 때 접미어로 Mapper라고 하는 객체를 임의로 만들어 사용한다.

    결론 

    아키텍처나 디자인패턴을 처음 접하는 개발자에게 클린 아키텍처는 쉬운 내용은 아니다. 하지만, 클린 아키텍처의 규칙을 지키는 것은 어렵지 않으며, 추후 겪게 될 많은 고통거리들은 클린 아키텍처 도입으로 인해 감소할 것이다.

    관심사 분리 및 의존성 규칙을 준수하면 단위 테스트하기 매우 쉬워질 것이며, 외부 변경사항에 유연하게 대처할 수 있게 될 것이다.

    클린 아키텍처를 준수한 소스코드는 github에서 확인할 수 있다.


    본문에 클린 아키텍처와 관련해서 틀린 내용 또는 다른 의견이 있으시다면 댓글로 남겨 주시기 바랍니다.

     

    Buy me a coffeeBuy me a coffee
    카테고리: Android

    4개의 댓글

    297 · 2021년 9월 24일 6:45 오후

    잘 읽었습니다.

      Charlezz · 2021년 9월 28일 10:05 오전

      읽어주셔서 감사합니다:)

    Cropo · 2021년 9월 30일 3:29 오후

    좋은 글과 코드 감사합니다.
    코드 분석을 하던 중 onPostCreate 에서 observe를 해주는 부분을 보았습니다.
    onPostCreate는 일반적인 앱 제작에선 많이 사용하지 않는다고 알고 있었는데 사용하는데 특별한 이유가 있을까요?

      Charlezz · 2021년 10월 1일 11:06 오전

      특별한 이유는 없습니다. onCreate()에서 observe해도 됩니다.

    답글 남기기

    Avatar placeholder

    이메일은 공개되지 않습니다. 필수 입력창은 * 로 표시되어 있습니다.