Posts SwiftUI를 위한 클린 아키텍처
Post
Cancel

SwiftUI를 위한 클린 아키텍처

들어가기에 앞서

This is the translated article of “Clean Architecture for SwiftUI” in https://nalexn.github.io/clean-architecture-swiftui/.

Thank Alexey Naumov for allowing me to post this article.

이 글은 원문 “Clean Architecture for SwiftUI” 의 번역본입니다. https://nalexn.github.io/clean-architecture-swiftui/.

이 글을 번역하도록 허락해 주신 Alexey Naumov께 감사의 말씀 드립니다.

서론

왜 이 글을 번역하게 되었는가

이번에 학교 졸업 프로젝트에서 iOS 개발을 할 일이 있었는데, 프로그램 설계를 많이 고민하면서 찾아보다가 해당 글을 발견하게 되었고, 이 글과 깃 허브를 참고하여 SwiftUI 프로젝트 설계를 깔끔하게 할 수 있었습니다.

다른 분들에게도 많은 도움이 될 것 같아서 저자에게 부탁하여 번역을 하게 되었습니다.

제가 했던 프로젝트 코드는 기업체와 함께 한 것이어서 공개할 수 없고, 차후에 제가 개인 프로젝트에 적용하게 된다면 링크를 다시 남겨드리겠습니다.

시작합니다

하늘을 찌를 듯 높은 빌딩을 아래서 쳐다본 사진

UIkit이 나온 지 벌써 11년이 되었습니다. iOS SDK 가 2008년에 출시되었을 때부터 우리는 UIKit으로 앱 개발을 해왔습니다. 그 긴 시간 동안 개발자들은 각자의 앱에 적용할 최고의 아키텍처를 고민했습니다. MVC로 시작해서 MVP, MVVM, VIPER, RIBs, VIP에 이르기까지 다양합니다.

하지만 이제 큰 변화가 찾아왔습니다. SwiftUI의 등장으로 지금까지 iOS를 위해 주로 쓰이던 아키텍처 패턴들은 역사 속으로 사라질 것입니다.

좋던 싫던 SwiftUI는 iOS 개발의 미래가 될 것입니다. 그리고 우리가 아키텍처를 설계할 때 직면하는 문제들을 해결할 때 새로운 방향을 제시할 것입니다.

SwiftUI의 개념적인 변화?

기존 UIKit

UIKit은 명령형(선언형 반대)이며 이벤트 중심의 프레임워크입니다. UIkit에서는 View hierarchy에서 각 View를 참조할 수 있고, View의 로딩 혹은 이벤트 발생(버튼 누르기 혹은 UITableView에 표시 가능해진 새 데이터 등장 등)에 대한 반응으로 View를 업데이트할 수 있습니다. 이러한 이벤트들을 핸들링하기 위해서 callbacks, delegates, target-action 등을 사용해왔습니다.

현재의 SwiftUI

이제 그런 것들은 다 필요 없습니다. SwiftUI는 선언적, 상태(State) 중심의 프레임워크이기 때문입니다. SwiftUI에서는 View hierarchy를 통해 View를 참조할 수 없고, 이벤트에 대한 반응으로 직접적으로 View를 변경할 수도 없습니다. 대신 View에 바인딩 되어 있는 상태(State)를 변화시킵니다. Delegates, target-actions, responder chain, KVO 등의 모든 callback 기술들은 closure와 binding으로 대체되었습니다.

SwiftUI의 모든 View는 기본적으로 struct 타입입니다. 기존의 UIView class를 상속한 하위 class 들에 비해서 생성 속도가 훨씬 빠릅니다. SwiftUI 컴포넌트, 즉, 각 struct들은 UI를 렌더링 하는데 사용되는 body 함수를 가지고 있고, body 함수에 영향을 미치는 state 들에 대한 참조를 유지합니다.

따라서 SwiftUI의 View는 하나의 함수에 불과합니다. 입력으로 state를 주면 출력으로 화면을 그리는 것입니다. 출력을 바꾸는 유일한 방법은 입력을 바꾸는 것이 됩니다. View에 하위 View들을 추가하거나 제거하는 방식으로는 함수의 알고리즘(body 함수)을 변경할 수 없는 것입니다. 따라서 UI에 표현되는 모든 컴포넌트들은 body 함수 내부에 선언되어야 하고, runtime에 변경될 수 없습니다.

다시 말해, SwiftUI에서는 하위 View들을 추가하고 제거하는 방식이 아니라 이미 만들어진 flowchart 알고리즘(body 함수)에서 UI 컴포넌트를 활성화하고 비활성화하는 방식으로 화면을 그리는 것입니다.

MVVM은 새로운 표준 아키텍처이다

SwiftUI는 MVVM 아키텍처가 내재되어 있습니다.

가장 간단한 예로 로컬 @State 변수를 생각할 수 있습니다. @State 변수는 ViewModel의 역할을 하며 View가 외부의 State에 의존적이지 않게 해줍니다. 또한 @State 변수는 State가 변할 때마다 UI가 리프레시 될 수 있도록 구독 메커니즘(Binding)을 제공합니다.

조금 더 복잡한 상황의 경우 View는 별개의 ViewModel인 외부의 ObservableObject를 참조할 수 있습니다.

어찌 되었든, SwiftUI의 View가 State와 함께 작동하는 방식은 기존 MVVM과 정말 유사합니다. (더 복잡한 프로그래밍 엔티티 그래프를 제시하지 않는 이상)

UIKit에서 SwiftUI로의 변화를 설명하는 강연 사진

이제 더 이상 ViewController는 필요하지 않습니다.

SwiftUI 앱에 MVVM 모듈이 적용되는 간단한 예를 들어봅시다.

Model: 데이터 컨테이너

1
2
3
struct Country {
    let name: String
}

View: SwiftUI 뷰

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct CountriesList: View {
    
    @ObservedObject var viewModel: ViewModel
    
    var body: some View {
        List(viewModel.countries) { country in
            Text(country.name)
        }
        .onAppear {
            self.viewModel.loadCountires()
        }
    }
    
}

ViewModel: 비즈니스 로직(도메인)을 캡슐화한 ObservableObject입니다. View가 state에 대한 변화를 관찰할 수 있도록 허용합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
extension CountriesList {
    class ViewModel: ObservableObject {
        @Published private(set) var countries: [Country] = []
        
        private let service: WebService
        
        func loadCountires() {
            service.getCountries { [weak self] result in
                self?.countries = result.value ?? []
            }
        }
    }
}

위의 예시에서 View가 화면에 나타나게 되면 onAppear callback이 ViewModelloadCountries()를 호출하고, WebService에 있는 데이터를 가져오게 됩니다. ViewModel이 callback에 주어진 데이터를받아 @Published 변수인 countries에 업데이트를 하게 되고, View가 그 변화를 알아차리게 됩니다.

전체적인 클린 아키텍처의 다이어그램. 총 3개의 레이어가 프레젠테이션, 비즈니스 로직, 데이터 액세스로 이루어져 있다. 프레젠테이션에는 뷰가, 비즈니스 로직에는 뷰 모델, 서비스, 앱 스테이트, 데이터 엑세스에는 레파지토리가 자리 잡고 있다.

비록 이 글은 클린 아키텍처에 관한 내용이지만, SwiftUI에 MVVM을 어떻게 적용하는지에 대한 질문을 많이 받았습니다. 그래서 기존의 샘플 프로젝트에서 MVVM 브랜치를 내었습니다. 저의 샘플 프로젝트에서 MVVM과 클린 아키텍처를 비교하면서 본인에게 맞는 것을 고르시면 됩니다.

프로젝트의 주요 기능들은 다음과 같습니다.

  • 온전히 SwiftUI + Combine로 구현함
  • 프레젠테이션, 비즈니스 로직, 데이터 접근 영역을 구분
  • UI를 포함한 전체 테스트가 가능(ViewInspector 덕분에)
  • Redux와 비슷한 AppState 중심의 Single source of truth 구현
  • 프로그램화된 네비게이션 (딥 링크 지원)
  • 간단하고 유연한 네트워킹 레이어(Generic 으로 구현)
  • 시스템 이벤트 핸들링(앱이 비활성화 상태일 경우 화면 블러 처리)

내부적으로 SwiftUI는 ELM기반으로 되어있다

잠시 아래의 영상 일부를 봅시다.

“MCE 2017: Yasuhiro Inami, Elm Architecture in Swift” 28:26 부분

https://youtu.be/U805TqsDIV8

저 분은 이미 2017년도에 SwiftUI의 프로토타입을 시연한 것입니다!

마치 아버지 없이 자랐던 SwiftUI가 드디어 아버지가 누군지 알게되는 드라마 같은 상황이네요. ㅎㅎ

하여튼, 우리에게 중요한 것은 ELM의 개념을 우리의 SwiftUI 앱을 더 좋게 만드는데 활용할 수 있느냐 하는 것입니다.

저는 ELM 언어 홈페이지의 ELM 아키텍처 설명 페이지를 읽어봤지만 새로운 내용은 하나도 없었습니다. SwiftUI는 ELM과 근본적인 개념을 공유하는 것입니다.

  • Model - 앱의 상태
  • View - 상태를 HTML로 변환하는 방법
  • Update - 메세지들에 따라 상태를 업데이트 하는 방법

어디선가 많이 본 내용이지 않나요?

사용자의 작용이 액션을 만들고 상태를 변화시키면 뷰가 업데이트되고 다시 사용자에게 렌더링되는 순환을 표현한 그림

우리는 이미 Model과 그로 부터 자동적으로 생성되는 View를 가지고 있고, Update를 어떻게 구현할 것인지만 생각해보면 되겠네요. Redux 방식대로 Command패턴으로 상태를 변경할 수 있을 것 같습니다. SwiftUI의 View 혹은 다른 모듈들이 State를 직접적으로 바꾸는 것 대신에 말입니다. 비록 UIKit 프로젝트에서 Redux를 사용하는 것을 정말 선호하지만(ReSwift ❤), SwiftUI 앱에서도 필요한지는 의문이었습니다. - 데이터의 흐름이 이미 쉽게 추적 가능하기 때문이죠.

Coordinator야 이젠 안녕

Coordinator (aka Router)는 VIPER, RIBs, MVVM-R 아키텍처에서 필수적인 부분이었습니다. UIKit 앱에서는 스크린 네비게이션 별로 독립된 모듈을 할당하는 것이 잘 정립되어 있었습니다. – 하나의 ViewController에서 다른 ViewController로의 직접적인 라우팅은 긴밀한 결합(tight coupling)을 만들고, ViewController의 계층 안에서 스크린과 깊게 연결되는 코딩 지옥에 빠지게 됩니다…

UIKit에서의 View들과는 다르게 SwiftUI의 View에게 subview들을 배치하라고 하거나 이미지 컨텍스트에 렌더링하게 할 수 없습니다. SwiftUI View는 (State를 가지는) 렌더링 엔진 없이는 쓸모가 없고, View들은 렌더링 타임에만 State를 참조할 수 있습니다. 로컬@State를 쓴다고 하더라도 말이죠.

SwiftUI의 View는 그저 그리기 알고리즘일 뿐입니다. 그래서 라우팅을 SwiftUI View에서 추출해내기가 굉장히 힘듭니다.

이미 주어진 것을 바꾸려 해서는 안 되겠지요. 대신 프로그램을 구조화합시다. 그리기 알고리즘을 적절히 쪼개 다른 View로 분리하고, 테스트가 쉽도록 비즈니스 로직을 일반 구조 모듈에 분리하는 형태로 말입니다.

저는 SwiftUI에서는 라우터(coordinator)가 불필요하다고 믿습니다.

화면에 표시되는 구조를 변경하는 NavigationView, TabView, .sheet() 와 같은 모든 View들은 이제 Binding을 사용하여 회면에 표시되는 항목을 제어합니다.

Binding 은 소유되지 않은 형태의 state 변수입니다. 읽고 쓸 수 있지만, 실제 값은 다른 모듈에 속해있습니다.

유저가 TabView에서 탭을 선택하면 callback을 받을 수 없습니다. 대신 TabViewBinding을 통해 일방적으로 값을 변경합니다. (“displayedTab = .userFavorites”로)

프로그래머는 언제든지 Binding에 값을 할당할 수 있고 TabView는 값의 변화를 즉시 수용할 것입니다.

SwiftUI에서의 프로그램화된 네비게이션은 온전히 Binding을 통한 State에 의해 제어됩니다. 이와 관련된 문제에 대한 글도 작성했습니다.

VIPER, RIBs, VIP는 SwiftUI에 적용할 수 없는가?

위의 아키텍처에서 가져올 수 있는 좋은 아이디어와 개념이 많이 있지만, 궁극적으로 각 아키텍처의 표준 구현은 SwiftUI 앱에 적합하지 않습니다.

첫째, 위에서 보았듯이 SwiftUI는 Router가 필요 없습니다.

둘째, SwiftUI의 데이터 흐름 설계는 view-state binding의 네이티브 지원과 합쳐져 Presenter가 필요할 이유가 없을 정도로 setup 코드를 축소시켰습니다.

디자인 패턴의 모듈의 수가 줄어들었기 때문에 Builder 역시 필요 없다는 것을 알 수 있습니다. 따라서 위의 디자인 패턴들이 해결하려고 했던 문제들이 SwiftUI에 존재조차 하지 않으니 기존의 패턴이 맞지 않는 것입니다.

SwiftUI는 시스템 설계단에서 부터 자체 과제를 도입했기 때문에 UIKit에서 사용하던 패턴들을 기초부더 재 설계할 필요가 있습니다.

이 링크는 이전 아키텍처들을 그대로 이용하려는 시도를 보여주지만, 제발 이제 그만… 합시다.

클린 아키텍처

VIP의 시조격인 밥 삼촌의 클린 아키텍처을 참조해봅시다.

소프트웨어를 레이어별로 나누고, Dependency Rule을 준수함으로써 본질적인 테스트가 가능한 시스템을 만들 수 있습니다. 이에 따른 이점들은 덤이죠.

클린 아키텍처는 레이어 개수에 대해서 자유로운 편입니다. 어플리케이션의 도메인에 따라 다르기 때문입니다.

하지만 대부분의 모바일 앱에서는 아래의 세가지 레이어가 필요합니다.

  • Presentation layer
  • Business Logic layer
  • Data Access layer

따라서 SwiftUI에 클린 아키텍처의 요구사항을 적용시켜본다면 아래의 그림과 같을겁니다.

프레젠테이션 레이어에는 뷰, 비지니스 로직에는 인터렉터 앱스테이트, 데이타 엑세스 레이어에는 레파지토리가 있습니다. 뷰는 인터렉터에 액션을 전달하고 인터랙터는 레파지토리에서 데이터를 받거나 AppState를 업데이트 합니다. 앱스테이트는 뷰에 바인딩되어 있습니다.

이 패턴을 적용한 데모 프로젝트가 있습니다. 데모 앱은 restcountries.eu REST API로 부터 정보를 받아와서 국가들의 목록과 세부내용을 보여줍니다.

AppState

AppState는 이 패턴에서 유일하게 object인 entity입니다. 정확히 말하자면 ObservableObject죠. 이것 대신 CurrentValueSubject(Combine 프레임워크에 속함)에 래핑 된 struct로 구성할 수도 있습니다.

Redux 에서처럼 AppState는 single source of truth로 앱의 전역 state를 유지합니다. 유저의 데이터, 인증 토큰, 스크린 내비게이션 상태(선택된 탭, 보이는 시트 등), 시스템 상태(활성, 백그라운드 등)와 같은 state들이 이에 해당합니다.

AppState는 다른 레이어에 대해 일체 아는 것이 없고, 그 어떠한 비즈니스 로직도 포함하고 있지 않습니다.

아래는 Countires 데모 프로젝트에서의 AppState 예시입니다.

1
2
3
4
5
class AppState: ObservableObject, Equatable {
    @Published var userData = UserData()
    @Published var routing = ViewRouting()
    @Published var system = System()
}

View

SwiftUI의 일반적인 view입니다. 상태를 가지지 않거나 지역 @State 변수를 가질 수 있습니다.

다른 모든 레이어들을 View 레이어의 존재를 알지 못하므로 프로토콜 뒤에 숨길 필요가 없습니다.

view가 인스턴스화되면 AppStateInteractor를 받습니다. @Environment, @EnvironmentObject, @ObservedObject등의 속성으로 지정된 변수에 SwiftUI 표준 종속성 주입(DI)을 해서 말이죠.

사이드 이펙트는 사용자의 액션(버튼 클릭과 같은) 혹은 view 라이프사이클 이벤트 onAppear에 의해 트리거 되고 Interactor에 전달됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
struct CountriesList: View {

    @EnvironmentObject var appState: AppState
    @Environment(\.interactors) var interactors: InteractorsContainer

    var body: some View {
        ...
        .onAppear {
            self.interactors.countriesInteractor.loadCountries()
        }
    }
}

Interactor

Interactor는 특정 View 혹은 view 그룹을 위한 비즈니스 로직을 캡슐화합니다. AppState와 함께 비즈니스 로직 레이어를 이루고 있고, 비즈니스 로직 레이어는 presentation과 외부 리소스에 완전히 독립적인 레이어입니다.

Interactor는 상태가 없고 오로지 AppState object만 참조합니다. (AppState는 생성자 파라미터로 넘겨줍니다)

Interactor들은 프로토콜을 통해 ‘파사드’화 되어야 합니다. 그렇게 하면 View는 목업 Interactor와 소통하여 테스트를 할 수도 있습니다.

Interactor들은 외부 소스로부터 데이터를 가져오거나 계산을 하는 등의 작업을 수행하라는 요청을 받지만 결과를 직접적으로 반환하지 않습니다. 클로저 처럼 말이죠.

대신에 결과를 AppState혹은 View에게서 제공받은 Binding에 전달합니다.

Binding은 작업수행의 결과가 하나의 View에만 내부적으로 쓰이고 전역 AppState에 속하지 않을 때 사용합니다. 앱 내에서 유지할 필요가 없거나, 다른 화면과 공유할 필요가 없을 때 말이죠.

데모 프로젝트의 CountriesInteractor 입니다 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
    protocol CountriesInteractor {
        func loadCountries()
        func load(countryDetails: Binding<Loadable<Country.Details>>, country: Country)
    }

    // MARK: - Implementation

    struct RealCountriesInteractor: CountriesInteractor {
        let webRepository: CountriesWebRepository
        let appState: AppState

        init(webRepository: CountriesWebRepository, appState: AppState) {
            self.webRepository = webRepository
            self.appState = appState
        }

        func loadCountries() {
            appState.userData.countries = .isLoading(last: appState.userData.countries.value)
            weak var weakAppState = appState
            _ = webRepository.loadCountries()
                .sinkToLoadable { weakAppState?.userData.countries = $0 }
        }

        func load(countryDetails: Binding<Loadable<Country.Details>>, country: Country) {
            countryDetails.wrappedValue = .isLoading(last: countryDetails.wrappedValue.value)
            _ = webRepository.loadCountryDetails(country: country)
                .sinkToLoadable { countryDetails.wrappedValue = $0 }
        }
    }

Repository

Repository는 데이터를 읽고 쓰는 추상 게이트웨이입니다. 웹 서버 또는 로컬 데이터베이스와 같은 단일 데이터 서비스에 대한 액세스를 제공합니다.

Repository를 따로 추출해내는 것이 왜 필수적인지는 이 글에서 자세히 이야기해 봤습니다.

예를 들어 앱이 자체 백엔드, Google Maps API, 로컬 데이터베이스를 쓰는 경우 총 3개의 Repository가 존재합니다: 웹 API 제공용 2개, 데이터베이스 IO용 하나.

Repository는 상태가 없고 AppState에 대한 쓰기 권한이 없습니다. 데이터 작업과 관련된 로직만 포함하고 있기 때문에 View, Interactor에 대해서는 전혀 모릅니다.

실제 Repository는 프로토콜 뒤에 숨겨 Interactor가 목업 Repository와도 소통하여 테스트가 가능하도록 구성해야 합니다.

데모 프로젝트의 CountriesWebRepository 입니다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
    protocol CountriesWebRepository: WebRepository {
        func loadCountries() -> AnyPublisher<[Country], Error>
        func loadCountryDetails(country: Country) -> AnyPublisher<Country.Details.Intermediate, Error>
    }

    // MARK - Implementation

    struct RealCountriesWebRepository: CountriesWebRepository {
        let session: URLSession
        let baseURL: String
        let bgQueue = DispatchQueue(label: "bg_parse_queue")

        init(session: URLSession, baseURL: String) {
            self.session = session
            self.baseURL = baseURL
        }

        func loadCountries() -> AnyPublisher<[Country], Error> {
            return call(endpoint: API.allCountries)
        }

        func loadCoutryDetails(country: Country) -> AnyPublisher<Coutry.Details, Error> {
            return call(endpoint: API.countryDetails(country))
        }
    }

    // MARK: - API

    extension RealCountriesWebRepository {
        enum API: APICall {
            case allCountries
            case countryDetails(Country)

            var path: String { ... }
            var httpMethod: String { ... }
            var headers: [String: String]? { ... }
        }
    }

WebRepository 가 생성자 파라미터로 URLSession을 받기 때문에 커스텀 URLProtocol네트워크 콜을 목업하여 테스트하기가 쉽습니다.

마무리

데모 프로젝트97% 테스트 커버리지를 자랑합니다. 클린 아키텍처의 “의존성 규칙”과 여러 레이어로 앱을 나눈 덕분입니다.

CoreData, 푸시 알림으로부터의 딥 링킹, 그 밖에 여러 실용적인 기능을 포함한 지속성 계층도 제공합니다.

데모 프로젝트의 예시 이미지. 화면은 이탈리아의 세부내용을 보여줌

원 저자의 RSS feed 혹은 Twitter를 통해 새로운 글 알림을 받거나, LinkedIn을 통해 소통하실 수도 있습니다. - 언제나 도움이 필요한 이를 위해 열려있답니다.

원 저자에게 venmo로 감사함을 표할 수도 있습니다. Github 스폰서 역시 저자에게 도움이 됩니다.

역자 왈

이 글을 번역하도록 허락해 주신 Alexey Naumov께 다시 감사의 말씀 드립니다. 저자의 다른 글도 읽어보시면 좋을 것 같습니다.

SwiftUI의 아키텍처에 대해서 심도있게 생각해 볼 수 있는 계기가 되었고, 제 프로젝트에 실제로 적용하면서 테스트 가능한 코드를 작성하기 위해서 어떻게 설계해야하는가에 대해 끊임없는 고민을 할 수 있었고, 스스로 발전이 있어서 만족스러웠습니다.

여러분들도 본인 프로젝트에 적용시켜보면서 동일한 경험을 느끼셨으면 좋겠습니다.

감사합니다.

This post is licensed under CC BY 4.0 by the author.