들어가기에 앞서
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과 정말 유사합니다. (더 복잡한 프로그래밍 엔티티 그래프를 제시하지 않는 이상)
이제 더 이상 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이 ViewModel
의 loadCountries()
를 호출하고, WebService
에 있는 데이터를 가져오게 됩니다. ViewModel
이 callback에 주어진 데이터를받아 @Published
변수인 countries
에 업데이트를 하게 되고, View
가 그 변화를 알아차리게 됩니다.
비록 이 글은 클린 아키텍처에 관한 내용이지만, 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 부분
저 분은 이미 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을 받을 수 없습니다. 대신 TabView
가 Binding
을 통해 일방적으로 값을 변경합니다. (“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에 클린 아키텍처의 요구사항을 적용시켜본다면 아래의 그림과 같을겁니다.
이 패턴을 적용한 데모 프로젝트가 있습니다. 데모 앱은 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가 인스턴스화되면 AppState
와 Interactor
를 받습니다. @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의 아키텍처에 대해서 심도있게 생각해 볼 수 있는 계기가 되었고, 제 프로젝트에 실제로 적용하면서 테스트 가능한 코드를 작성하기 위해서 어떻게 설계해야하는가에 대해 끊임없는 고민을 할 수 있었고, 스스로 발전이 있어서 만족스러웠습니다.
여러분들도 본인 프로젝트에 적용시켜보면서 동일한 경험을 느끼셨으면 좋겠습니다.
감사합니다.