SwiftUI를 위한 Clean 아키텍처
원작자의 깃허브 : Alexey Naumov
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: 데이터 컨테이너
struct Country {
let name: String
}
View: SwiftUI 뷰
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
에 대한 변화를 관찰할 수 있도록 허용합니다.
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
예시입니다.
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
에 전달됩니다.
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 입니다 :
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 입니다 :
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 스폰서 역시 저자에게 도움이 됩니다.
읽어주셔서 감사합니다🤟