2024. 7. 28. 11:44ㆍArchitecture, Design Pattern
⚠️ 해당 글은 Robert C. Martin의 너무도 유명한 <Clean Architecture>책과 Oleh Kudinov라는 분의 Medium 글 Clean Architecture and MVVM on iOS를 참고해서 작성했습니다.
번역 또는 프로젝트의 목적, 그리고 저의 부족한 이해력 때문에 일부 잘못된 내용이 있을 수 있다는 점. 전제하고 읽어주시면 감사하겠습니다 (잘못된 개념 제보 및 질문 댓글로 얼마든지 환영입니다:) ^__^ + 아 그리고 글이 조금 깁니다...어쩌다보니..
1️⃣ Clean Architecture가 뭐야?
클린 아키텍처가 무엇인지 본격적으로 알아보기 전에, 먼저 아래 질문에 대한 정의를 내리고 넘어가 보겠다.
아키텍처(Architecture)가 뭐야?
로버트 C. 마틴 <Clean Architecture> 책에 따르면, 우리가 개발하는 소프트웨어에는 두 가지 가치가 있다고 한다.
하나는 집단 외부의 행위(Behavior)적 가치이고 또 다른 하나는 집단 내부의 구조(Structure)적 가치이다.
- 행위 (Behavior) : 집단 내 여러 이해 관계자의 기능 명세서나 요구사항 문서를 실제 프로덕트로 구현하는 Programmer의 가치
- 구조 (Structure) : Software 단어에서 Soft라는 본연의 목적에 맞춰 기존 행위를 쉽게 변경할 수 있어야 한다는 Developer의 가치
아키텍처는 이 두 가지 가치 중 구조적 가치를 위한 개념이다.
아키텍처의 정확한 정의를 이 흐름에서 설명해 보면 "소프트웨어 시스템의 형태 또는 구조"라고 내릴 수 있다.
조금 더 자세하게는 전체 시스템을 컴포넌트로 분할하는 방법, 그리고 그 분할된 컴포넌트를 배치하는 방법, 각 컴포넌트 사이의 상호작용 방법 등을 정하는 것이 아키텍처라고 말할 수 있다.
즉, 개발자에게 "소프트웨어 시스템을 만들고, 수정하고, 유지하는 것을 쉽게 할 수 있는 구조", 클린한 아키텍처를 위해 노력한다는 말은 소프트웨어의 구조적 가치를 지키고자 한다는 말과 같다.
아키텍처 고민할 시간에 기능 개발부터 빨리 해야 하는 거 아니야?
"굳이" 구조적 가치를 지킬 필요가 있어? 클린한 아키텍처(Clean Architectire) "왜" 써야 해? (취업할 때 필요하다고 하니까...?)
이 글을 읽는 당신도 혹시 위와 같은 의문들이 머릿속에 떠오르지 않았는가?
<Clean Architecture>는 이 의문들에 대한 대답으로, 구조적 가치가 소프트웨어 개발에서 갖는 중요성을 아래와 같이 설명하고 있다.
- "빨리 가는 유일한 방법은 제대로 가는 것이다." -> 경쟁자가 뒤쫓고 있는 빠른 시장 환경에서 새로운 기능 개발의 긴급성을 저자도 인정한다.
(기능 개발보다 설계가 중요하다는 말을 하지 않는다는 것!)
하지만, 빠른 기능 개발은 명확한 시스템의 설계가 뒷받침되어 있을 때 가능하기 때문에 구조적 가치는 곧 행위적 가치와 이어진다고 판단한다. - "완벽하게 동작하지만 수정이 아예 불가능한 프로그램 vs 동작은 하지 않지만 수정이 쉬운 프로그램" 극단적인 양자택일 상황에서 전자를 선택할 사람은 없을 것이다. -> '수정이 아예 불가능한 프로그램이 어디 있어'라고 반론을 제시하는 경우에 대해서 잘못된 설계에 의해 '변경에 드는 비용 > 변경으로 창출되는 수익'의 상황을 제시한다.
- 전 미국 대통령 아이젠하워가 제시한 업무의 우선순위를 판단하는 기준(Eisenhower Matrix)에 따르면,
행위적 가치는 긴급하지만 매번 높은 중요도를 가지지 않고, 구조적 가치는 중요하지만 즉각적인 긴급성을 요구하는 경우는 거의 없다. (아래 이미지 참조)
위의 내용을 보면 알겠지만, 구조적 가치를 지킨다는 것은 결코 행위적 가치의 중요성을 무시하는 것이 아니다.
좋은 아키텍처를 설계하는 것이 단기적/장기적인 기능 개발에 있어서 결국 중요한 영향을 끼치기 때문에,
소프트웨어의 두 가치(행위+구조)를 모두 지키기 위해서 그리고 소프트웨어 시스템을 만들고 유지보수하는 데 있어 용이하게 하기 위해서 우리는 좋은 아키텍처를 설계하는 방법을 고민해야 하고 공부해야 한다는 것이 저자의 의견이다.
클린 아키텍처(Clean Architecture)는 "소프트웨어 시스템의 좋은 설계"라는 고민 아래 나오게 된 하나의 방법이자 제안이다.
*착각하면 안 될 것이 클린 아키텍처는 절대적인 규칙이 아니라는 점이다.
**한 소프트웨어 시스템에 있는 컴포넌트를 관심사에 따라 분리(Separation of concerns)함으로써 쉽게 테스트하고 유지보수할 수 있도록 하는 의존 관계를 정하는 것이 클린 아키텍처의 최종적인 목표이다.
관심사의 분리(Separation of concerns)를 바탕으로, 클린 아키텍처는 4가지 계층(Layer)을 설정한다.
참고로 Use Cases 부분을 Application Buisness Rule로, Entities 부분을 Enterprise Business Rule 계층으로 설명할 수도 있다.
💡 클린 아키텍처 4가지 계층 개념 (Layer of Clean Architecture)
1. Frameworks and Drivers : UI, DB 등 애플리케이션의 "외부 세계"와 관련된 내용을 포함하고 있는 곳
2. Interface Adapters : 애플리케이션과 내부와 외부 간의 "상호 작용(즉, 데이터 변환)"을 담당하는 곳
3. Use Cases : 애플리케이션의 특정 작업을 수행하는데 필요한 "비즈니스 로직"을 포함하는 곳
4. Entities : 핵심 비즈니스 규칙과 "핵심 데이터"를 포함하고 있는 곳 (데이터 구조일 수도, 구조와 함수의 집합일 수도 있다.)
각 계층이 어떤 역할을 하는지는 아직 이해가 되지 않아도 괜찮다.
지금 알아야 할 클린 아키텍처의 핵심은 "의존성이 1번에서 4번으로, 아래 그림 기준 원 외부에서 내부 방향으로만 이루어진다"는 점이다.
위의 Clean Architecture 계층을 원으로 표현한 그림을 보게 되면
다른 말로 원 바깥쪽으로 갈수록 저수준의 정책이 / 원 안쪽으로 갈수록 고수준의 정책이 위치한다고 말하기도 하는데 이는 곧 Clean Architecture의 의존성 방향이 저수준에서 고수준으로 간다고 표현할 수도 있다.
여기서 잠깐! 저수준? 고수준? 이게 무슨 말이지?
저수준 정책 (Low-Level Policy)은 애플리케이션에서 세부적인 구현 사항을 담당하는 것을 의미한다. -> 특정 세부 사항과 연관되어 있기 때문에 변화가 자주 일어난다.
고수준 정책 (High-Level Policy)은 단일 애플리케이션에 특화된 기능을 수행한다는 것을 의미한다. -> 해당 시스템의 목적을 달성하는 데 핵심 역할을 하기 때문에 시스템 전반에 걸쳐 재사용될 수 있도록 일반화되어 있고, 변화가 일어날 가능성이 적다.
아하!
그러면 클린 아키텍처는 세부적인 구현 사항이 전체 시스템에서 재사용 가능한 일반화된 구현 사항을 의존하는 방향으로 구성하는 거구나!
딱 이 정도 이해했으면 아주 훌륭하다고 볼 수 있다.
여기서 조금 더 가서
변화가 많은 부분이 변화가 적은 부분을 의존하도록 구성했으니까,
만약 나중에 변경 사항이 발생하더라도 전체 애플리케이션 시스템에 끼치는 영향은 많지 않겠구나! = 아 이것이 바로 수정하기 쉬운 프로그램이군! 까지 사고과정이 이어지면 매우 훌륭하다고 칭찬해 주겠다. 잘 따라오고 있다는 뜻이다!
2️⃣ Clean Architecture 개념 날씨앱에 적용시켜보기 : Role Definition, Dependency Direction, Data Flow
이제 iOS 앱을 기준으로 클린 아키텍처의 각 계층을 대입해 보자.
나는 33기 솝트 iOS 파트에서 과제로 했던 아이폰 기본 날씨앱을 기준으로 Clean Architecture를 적용시켜 봤다.
아래 화면과 같이 새로고침 버튼을 누르면 설정해 둔 여러 지역들의 현재 날씨 정보를 가져오는 화면의 구조를 구성한다고 생각해 보겠다.
날씨 데이터는 OpenWeather-Current weather data API를 통해 받아온다고 한다면,
애플리케이션의 데이터 흐름은 어떻게 되고, 클린 아키텍처의 4가지 계층은 각각 어떻게 대입되고, 의존 관계는 어떻게 형성되어야 할까?
화면을 내리기 전에 먼저 스스로 생각해 보길 바란다.
우선, 이 날씨 앱의 핵심 비즈니스 로직은 "원하는 지역의 날씨 정보를 얻는 것"이라고 정의할 수 있다. (UseCase에 정의되어야 할 부분)
그래서 Entity 계층에는 이 핵심 비즈니스 로직에서 사용되는 데이터 = 다시 말해, 화면에 보여줄 데이터(지역의 날씨 정보 데이터)의 구조를 정의해 줄 것이다. (이것을 클린 아키텍처에서는 "핵심 데이터"라고 일컫는다.)
이 애플리케이션에서 가장 저수준, 애플리케이션 외부와 관련된 계층은 2가지이다.
사용자-애플리케이션 간 인터렉션이 일어나는 UI(User Interface) 부분과 API/DB-애플리케이션 간 인터렉션이 일어나는 Service 부분 (API 호출 부분)
그럼 이 모든 부분을 합쳐서 프로그램의 흐름을 아래와 같이 정리해 볼 수 있다.
사용자가 애플리케이션 UI를 통해 정보를 요청 (외부) -> 비즈니스 로직에서 View에 보여줄 데이터 요청 (내부) -> API 호출해서 정보 받아옴 (외부)
그리고 위 흐름에 따라 iOS의 Clean Architecture는 크게 세 계층으로 나누어서 보게 된다.
- 사용자가 애플리케이션 UI를 통해 정보를 요청 (외부) => Presentation Layer : 사용자에게 보이는 UI 요소와 View와 관련된 Logic을 다루는 계층
- 비즈니스 로직에서 View에 보여줄 데이터 요청 (내부) => Domain Layer : iOS 애플리케이션에서 사용하는 비즈니스 로직과 핵심 데이터를 포함하고 있는 계층
- API 호출해서 정보 받아옴 (외부) => Data Layer : 앱에서 사용되는 데이터 접근과 저장을 담당하고 있는 계층 (외부 DB 또는 내부 DB 등 모두 포함하고 있다)
하지만 여기서 발생하는 문제가 하나 있다.
위에서 설명했던 User Flow대로 계층을 구성하고 의존관계를 설정하면,
Domain Layer와 Data Layer 간의 의존 관계가 저수준에서 고수준으로 가는 것이 아니라, 고수준에서 저수준으로 향하게 되는 잘못된 방향으로 설정된다는 문제다. (비즈니스 로직이 자주 변경사항이 발생할 수 있는 API 호출 로직을 의존하게 되어, 시스템 외부의 변경 사항이 비즈니스 로직에도 영향을 끼치게 되는 것)
결론부터 말하자면, 해당 문제는 DI(Dependency Inversion), Repository의 프로토콜과 구현부를 분리함으로써 해결한다.
-> 자세한 내용은 이 글 아래에서 소개할 Domain Layer와 Data Layer의 코드 부분과 함께 설명하도록 하겠다.
DI에 대한 설명은 아래 SOLID 원칙을 설명했던 글에서 5번째 DIP(의존성 역전 원칙) 부분에 설명되어 있으니 참고하길 바란다.
아무튼 의존성을 역전시키며, User Flow와 반대되는 Data에서 Domain 방향의 의존성을 갖도록 만들 수 있었고
이를 아래에서 보게 되면 Repository Protocol과 Repository Implements를 분리함으로써 구현되었다고 이해하면 되겠다.
💡 iOS Clean Architecture의 의존성과 계층별 파일의 구성
의존성 : Presentation Layer -> Domain Layer <- Data Layer
- Presentation Layer (MVVM) = ViewModel (Presenter) + View (UI, ViewController)
- Domain Layer = Entity + Use case + Repository Protocol (Dependency Injection을 위해 사용)
- Data Layer = Repository Implements + API, DB (Network Code * Service, DTO)
그럼 마지막으로 코드로 넘어가기 전에 복습하는 차원에서 각 계층에 속해있는 개별 파일들의 역할이 무엇인지, 처음 Clean Architecture에서 제시했던 네 가지 계층과 User Flow를 기반으로 확실하게 이해해 보도록 하자.
- Frameworks and Drivers (외부 세계) : View, ViewController (사용자-시스템), Service (DB-시스템)
- Interface Adapters (외부 내부 간 상호작용 도움) : ViewModel (사용자의 input, output 전달), Repository (DTO-Model로 변환)
- Use Cases (비즈니스 로직) : UseCase (비즈니스 로직)
- Entities (핵심 데이터) : Entity (핵심 데이터, 뷰로 보여지는 내용물이 되겠다.)
그럼 이렇게 확실하게 역할이 정의된 계층별 파일들을 기반으로 간단했던 프로그램의 작동 흐름을 구체화시켜 봤다.
[Input Direction]
1. 사용자가 ViewController에서 버튼을 클릭한다. (VC에서는 addTarget을 통한 인식이 들어오고 -> action 메서드 호출)
2. ViewController의 action 메서드에서 ViewModel에게 들어온 Event를 전달한다.
3. ViewModel은 UseCase에게 데이터를 요청한다.
4. UseCase는 Repository를 통해 "원하는 지역의 날씨 데이터를 가져오는" 비즈니스 로직을 수행한다.
5. Repository는 Service에게 외부 API에 데이터 요청을 보낸다.
6. Service는 API를 호출해서 디코딩된 데이터를 DTO 구조체의 형태로 가져온다.
[Output Direction]
7. Repository는 Service에서 받아온 DTO(API Response의 맞춰 정의된 데이터 구조)를 Entity(애플리케이션 내부에서 사용할 데이터 구조)로 변환한다.
8. ViewModel은 가져온 데이터를 Observable 객체에 할당한다. (View가 해당 데이터를 observe 하고 변경 사항을 알아차릴 수 있게 하기 위함)
9. Observable은 didSet 구문으로 데이터의 변화를 감지하고, 바인딩된 ViewController에게 변경사항을 알린다.
10. ViewController는 전달받은 변경사항을 반영하여 UI를 업데이트한다.
3️⃣ Presentation Layer : Input Output을 사용한 MVVM 구조
Presentation Layer는 Input Output을 사용한 MVVM 아키텍처 패턴을 사용해서 구성했다.
MVVM 패턴은 Model, View, ViewModel의 첫 글자를 따서 만들어진, MVC와 더불어 iOS 개발의 대표적인 소프트웨어 아키텍처 패턴이다.
- Model : 데이터, 말 그대로 화면에 "보여질 내용"만을 담당하는 계층이다. (MVC의 Model의 역할과 동일하다.)
- View(= ViewController) : MVC에서 VC의 역할을 모두 맡았던 ViewController를 MVVM에서는 View로 바라본다는 점이 가장 큰 차이점이다. -> UI를 그리고, 액션을 ViewModel에 전달하고, ViewModel에서 제공하는 데이터를 바탕으로 UI를 업데이트하는 역할 등을 담당한다.
- ViewModel : Model과 View 사이에서 데이터에 대한 "비즈니스 로직"을 담당하는 계층이다. -> View에서 보여줄 데이터를 갖고 있으며, View에 들어온 사용자의 액션에 따라 반응해 주거나 Model에서 전달받은 변화를 View에 알려주는 중재자 역할을 수행한다고 보면 되겠다.
🤔 왜 MVC가 아니라 MVVM 패턴을 Presentation Layer에서 사용한 거야?
Apple의 MVC 패턴은 View와 Controller가 별도로 분리되어 있지 않고, 하나로 합쳐져 있는 ViewController를 사용하는 형태였다. (M(Model)-V(View)-C(Controller)의 느낌보다 M(Model)-VC(ViewController)의 느낌!)
그러다 보니 하나의 ViewController 안에서 데이터에 대한 비즈니스 로직과 View와 관련된 프레젠테이션 로직이 혼재되어 무거워지는 Massive ViewController 상황이 발생하게 되었는데,
관심사의 분리로 각 컴포넌트의 역할을 명확하게 설정하는 것을 목표로 하고 있는 클린 아키텍처에서 비즈니스 로직과 프레젠테이션(UI) 로직을 보다 효율적으로 분리해서 사용하는 MVVM이 더 적합하다고 생각해서 사용했다.
MVVM을 설명하고 있는 위 이미지의 화살표 구조(의존 관계)에 따르면,
ViewController가 ViewModel을 알고, ViewModel에서는 Model은 알지만 ViewController를 알지 못하는 최소한의 의존관계가 설정되어 있는 것을 볼 수 있다.
Model을 직접 의존하고 있었던 MVC의 VC가,
ViewModel이라는 것에 의존하기 시작하면서 VC는 그저 View 역할만 + 비즈니스 데이터와 로직을 직접 관리하지 않아도 되는 관심사의 분리(Separation of concerns)가 적절하게 이루어진 것이다!
// VC
final class WeatherListViewController: UIViewController {
private let viewModel: WeatherListViewModel // ViewModel
private let weatherListView = WeatherListView() // View
...
// ViewModel
final class DefaultWeatherListViewModel: WeatherListViewModel {
// Model
var weathers: Observable<[CityWeather]> = Observable([])
...
"나는 ViewModel의 Input과 Output을 한번 더 각각의 프로토콜로 분리함으로써 ViewModel의 역할을 더욱 명확하게 구분했다."
이게 무슨 말이냐고?
Presentation Layer에서 ViewModel이 갖는 역할이 "View와 Model 사이의 인터렉션을 담당하는 녀석"
더 넓게 클린 아키텍처 관점에서 ViewModel이 갖는 역할은 "View와 UseCase 사이의 Adapter 역할을 담당하는 녀석"이었다.
즉, 인터렉션 또는 Adapter로서 필요한 ViewModel의 역할을
"1) View에서 들어온 뭔가를 Model (UseCase)로 전달하는 것 + 2) Model (UseCase)로부터 비즈니스 로직 처리가 된 데이터를 View로 전달하는 것"으로 다시 나눌 수 있었기에 -> 이 부분을 분리해서 ViewModel의 사용을 더욱 용이하게 만들었다는 의미다.
💡 ViewModel의 Input이란?
- ViewModel이 외부로부터 받는 모든 입력 (주로, 사용자의 액션 시 호출하는 메서드 - 뷰 진입, 버튼 클릭, 텍스트 입력 등)
💡 ViewModel의 Output이란?
- ViewModel이 외부에 제공할 수 있는 모든 출력 (주로, 비즈니스 로직을 거친 데이터 - Observable을 사용해서 데이터의 변화를 알릴 수 있다.)
내가 구현하고 있는 날씨앱에서 Input과 Output 구조를 분리해 보면 아래처럼 나눌 수 있다.
- Input : 날씨 정보를 받아오고 싶은 지역 정보 (String 타입), 여러 개를 받아올 수 있도록 Array로 받는 메서드
- Output : Observable 타입의 날씨 정보 Model과 정보를 받아오지 못했을 때 대신 받아오는 Error 정보 (String 타입)
protocol WeatherListViewModelInput {
func didSearch(cities: [String])
}
protocol WeatherListViewModelOutput {
var weathers: Observable<[CityWeather]> { get }
var errors: Observable<String> { get }
}
protocol WeatherListViewModel: WeatherListViewModelInput, WeatherListViewModelOutput {}
VC에서는 두 가지 상황에서 ViewModel의 Input을 호출한다.
*ViewModel의 Input 메서드를 호출한다는 것? -> "지금 사용자에게 어떤 상황이 일어나서 뷰 그려야 되니까 데이터 주세요~"라는 뜻.
하나는 사용자가 해당 화면에 처음 진입했을 때 (viewDidLoad), 또 다른 하나는 사용자가 새로고침 버튼을 눌렀을 때 (reloadButtonTapped) 뷰를 그리기 위한 데이터가 필요했다.
final class WeatherListViewController: UIViewController {
// MARK: - UI Properties
private let viewModel: WeatherListViewModel
private let searchController = UISearchController()
private let weatherListView = WeatherListView()
private let activityIndicator = UIActivityIndicatorView(style: .large)
// MARK: - Life Cycle
override func loadView() {
self.view = weatherListView
}
override func viewDidLoad() {
super.viewDidLoad()
...
viewModel.didSearch(cities: ["seoul", "cheonan", "busan", "daegu", "jeju"])
}
...
@objc
func reloadButtonTapped() {
activityIndicator.startAnimating()
viewModel.didSearch(cities: ["seoul", "cheonan", "busan", "daegu", "jeju"])
}
}
Input 부분의 메서드 구현부는 단순하다.
just 의존성을 갖고 있는 UseCase에게 요청이 들어왔다고 전달 (= 메서드를 호출)하기만 하면 된다.
// MARK: - INPUT
extension DefaultWeatherListViewModel {
func didSearch(cities: [String]) {
load(for: cities)
}
}
Output은 UseCase로부터 (아직 설명하지는 않았지만 API를 호출해서 받아온 데이터를 UseCase에서 비즈니스 로직을 거쳐) 가져온 데이터가 해당된다.
코드 내용을 보자면,
여러 개의 지역을 한 번에 받아오기 위해 DispatchGroup으로 묶어 Usecase로부터 날씨 정보 데이터(weatherList)를 받아오는 부분.
모든 요청이 끝나면 받아온 데이터를 관찰할 수 있도록 Observable에 할당하는 부분 등을 볼 수 있다. (Observable은 바로 아래에서 설명 갑니다요~)
final class DefaultWeatherListViewModel: WeatherListViewModel {
// MARK: - Dependency
private let useCase: WeatherListUseCase
// MARK: - OUTPUT
var weathers: Observable<[CityWeather]> = Observable([])
var errors: Observable<String> = Observable("")
init(fetchWeatherUsecase: WeatherListUseCase) {
self.useCase = fetchWeatherUsecase
}
private func load(for cities: [String]) {
let group = DispatchGroup()
var weatherList = [CityWeather]()
var errorList = [Error]()
for city in cities {
group.enter()
useCase.execute(location: city) { result in
switch result {
case .success(let model):
weatherList.append(model)
case .failure(let error):
errorList.append(error)
}
group.leave()
}
}
group.notify(queue: .main) {
if !errorList.isEmpty {
self.errors.value = NSLocalizedString("Failed loading data", comment: "")
} else {
self.weathers.value = weatherList
}
}
}
}
Output으로 전달받은 데이터는 뷰로 보이게 된다.
날씨앱에서는 TableView를 사용했기 때문에 TableView의 DataSource를 채택한 Extension 부분에서 ViewModel의 Output에 해당했던 데이터를 바인딩해주는 식으로 사용한 것을 확인할 수 있다.
// MARK: - TableView DataSource
extension WeatherListViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return viewModel.weathers.value.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(
withIdentifier: WeatherListTableViewCell.className,
for: indexPath) as? WeatherListTableViewCell else { return UITableViewCell() }
cell.selectionStyle = .none
let data = viewModel.weathers.value[indexPath.row]
cell.bindData(data: data, row: indexPath.row)
return cell
}
}
그런데 잠깐...
만약 ViewModel에서 넘어오는 data가 변경되면 테이블뷰를 다시 그리는 reloadData() 처리를 해줘야 할 텐데...
데이터가 변경될 때마다 테이블 뷰를 일일이 리로드하지 않고, 알아서 변경되었다고 ViewModel에서 VC에게 알려주는 방법은 없을까..?
그니까 ViewModel이 갖고 있는 데이터가 단순히 데이터만 전달해 주는 것이 아니라 변경 사항도 추적해 줬으면 좋겠다는 생각인데...
이 생각을 구현하도록 만들어준 것은 다름 아닌 Observable이다.
4️⃣ Observable Pattern을 사용해서 데이터의 변화를 자동으로 감지해 보자
Observer Pattern에 대한 자세한 내용은 Notification Center에 대입해서 예전에 정리한 글이 있다. 참고하도록!
위의 Observer Pattern을 요약하면 "한 객체에서 수정사항이나 이벤트 같은 정보를 다른 객체들에게 전달하는 패턴"이라고 할 수 있다.
오잉? 갑자기 Observer 얘기를 왜 하냐고?
내가 위에서 정의했던 날씨앱 프로그램의 작동 흐름에 Observer Pattern이 포함되어 있었기 때문이다.
어느 부분에 활용되고 있는지 확인해 볼까?
8. ViewModel은 가져온 데이터를 Observable 객체에 할당한다. (View가 해당 데이터를 observe 하고 변경 사항을 알아차릴 수 있게 하기 위함)
9. Observable은 didSet 구문으로 데이터의 변화를 감지하고, 바인딩된 ViewController에게 변경사항을 알린다.
10. ViewController는 전달받은 변경사항을 반영하여 UI를 업데이트한다.
정확한 활용 상황은 "ViewModel의 데이터에서 수정사항이 발생했을 때 이 데이터를 사용하고 있는 ViewController에게 알려주기 위해" 디자인 패턴이 쓰였다고 볼 수 있다.
이것을 어떻게 가능하게 했는지 코드로 차근차근 살펴보자!
"ViewModel은 가져온 데이터를 Observable 객체에 할당한다."
화면에서 사용되는 데이터의 형태는 CityWeather라는 struct 엔티티의 형태로 되어있다.
(아직 설명하지는 않았지만) 아마 이 CityWeather는 API를 통해 DTO 데이터를 받아와서 -> 화면에 띄울 때 사용하는 데이터의 형태로 변환하고 -> 비즈니스 로직(UseCase)을 거쳐서 가져온 날씨 데이터일 거다.
이 데이터를 Observable이라는 객체에 할당해 줬다.
Observable이라는 객체가 뭐길래 여기에 할당을 해주는 거지? 할당하면 어떤 일이 일어나는 거지?
final class DefaultWeatherListViewModel: WeatherListViewModel {
// MARK: - OUTPUT
var weathers: Observable<[CityWeather]> = Observable([])
...
Observable은 제네릭 타입(Generic Type)으로 만들어진 클래스다.
Observable이 "관찰 가능한"이라는 말로 해석되는데,
이 객체를 제네릭으로 선언했다는 것은 "타입 파라미터로 들어오는 어떤 제약 없는 특정한 타입 = 곧 데이터 = T"의 변화를 어디서든지 관찰 가능하도록 만들겠다!라고 선언했다는 것과 같은 의미다.
결론적으로, Observable<[CityWeather]>은 여러 개의 CityWeather 데이터가 담겨있는 Array의 변화를 관찰하겠다는 뜻!
Observable 데이터의 값은 T 타입의 value에 저장한다.
아래의 경우 T 타입은 Observable 객체를 처음 생성할 때 선언했던 CityWeather Entity 타입이 해당할 것이다.
self.weathers.value = weatherList
값의 변화는 didSet에 의해 알 수 있다.
didSet은 프로퍼티 감시자라고 불리며, 프로퍼티의 값이 수정된 이후 호출되는 블록이다. (value 값이 변하면 didSet 블록이 호출)
didSet 구문에 들어있는 listener는 T 타입의 값을 받아서 아무것도 반환하지 않는 클로저다.
listener?(value) 코드 부분을 볼 때, 이 클로저에 새로운 value를 지정해서 listener 클로저를 호출하는 모습이다.
지금까지 내용은 "Observable로 래핑된 데이터의 값은 T 타입의 value라는 프로퍼티에 저장되고 -> 이 value가 변경된 직후에는 didSet이 호출 -> didSet 구문에서는 T 타입을 받아 아무것도 반환하지 않는 클로저에 변경된 value를 저장하고 호출"까지 했다.
bind는 UI와 데이터 모델을 연결, 동기화해주는 데이터 바인딩을 수행하는 메서드다. (VC와 VM을 연결 담당)
listener 클로저에 값을 대입하고, 새로 설정된 listener를 즉시 호출하여 현재 value를 전달받는 작업을 수행한다. 자세한 사용법은 아래 VC에서 다시!
final class Observable<T> {
typealias Listener = (T) -> Void
var listener: Listener?
var value: T {
didSet {
listener?(value)
}
}
init(_ value: T) {
self.value = value
}
func bind(_ listener: @escaping Listener) {
self.listener = listener
listener(value)
}
}
ViewController에서 Observable의 bind 메서드를 사용하는 상황을 살펴보자.
viewModel.weather.bind 블록은 viewModel의 Observable<[WeatherList]> 타입이었던 weathers 데이터의 값이 변경될 때마다 호출된다.
즉 데이터가 변경된 직후 수행해야 할 Indicator 뷰의 애니메이션이 멈추고, tableView의 데이터를 다시 업데이트하는 reloadData 호출을 여기서 수행할 수 있는 것!
(파라미터로 넘어오는 value 값을 받아서 사용할 수도 있지만, 나는 value와 관련된 작업을 VC에서 수행할 필요가 없기 때문에 _ 처리를 해줌)
💡 Observable의 호출 흐름을 총정리해보면?
weathers.value를 통해 값 추가 및 수정 -> didSet 블록 호출 -> listener가 설정되어 있는지 확인 후, 설정되어 있으면 listener 클로저를 호출하고 value를 전달 -> VC에서 bind 메서드를 사용해서 listener 클로저 설정 -> 새로운 value가 전달될 때마다 VC bind 클로저의 내부 구문 수행 및 UI 업데이트
bind 블록에서 / 즉, 데이터의 값이 변경되면 우리가 수행하고 싶은 작업은 모두 UI 업데이트와 관련된 작업이다. 그렇기 때문에 이 부분에서 빼먹으면 안 되는 중요한 개념 두 가지가 있다.
- UI 업데이트 작업은 메인 스레드에서 수행되어야 한다는 점 -> DispatchQueue.main.async 블록 수행
- 수행되는 블록이 탈출 클로저(Escaping Closure)라는 점 -> [weak self] 약한 참조로 강한 참조 순환 문제를 방지, 이 부분에 대한 자세한 내용은 예전 글 - [weak self] 이젠 제대로 알고 사용하자!에 친절하게 설명했으니 참조하길 바란다.
// ViewController
func setupBindings() {
viewModel.weathers.bind { [weak self] _ in
DispatchQueue.main.async {
self?.activityIndicator.stopAnimating()
self?.weatherListView.tableView.reloadData()
}
}
viewModel.errors.bind { error in
print("🚨Error Occurred: \(error)")
}
}
5️⃣ Domain Layer : UseCase가 비즈니스 로직을 담당할 수 있도록!
Observable을 설명하면서 잠깐 다른 길로 빠졌었는데,
다시 클린 아키텍처에 대한 지금까지의 흐름을 다시 짚어보면 "View에서 사용자의 입력이 들어와 ViewModel에 전달 -> ViewModel이 View를 그리기 위한 데이터를 UseCase에게 요청"까지 온 상황이다.
💡 Clean Architecture에서 UseCase는 "비즈니스 로직을 수행하는 계층"이다.
이 글 위에서 말했던 날씨 앱에서의 비즈니스 로직은 "원하는 지역의 날씨 정보를 얻는 것"이었기 때문에 해당 계층에서는 딱 이 작업만 수행하면 된다.
*데이터를 외부 API에서 가져오는지, 내부 DB에서 가져오는지, 어느 화면에서 해당 데이터를 어떻게 표출시키는지 UseCase가 알 필요가 없다는 의미
**UseCase는 핵심 비즈니스 로직을 처리하는 과정에서 필요한 핵심 데이터, 즉 더 내부에 위치해 있는 Entity를 참조해서 로직을 처리한다.
import Foundation
protocol WeatherListUseCase {
func execute(location: String,
completion: @escaping (Result<CityWeather, Error>) -> Void)
}
final class DefaultWeatherUseCase: WeatherListUseCase {
private let repository: WeatherRepository
init(repository: WeatherRepository) {
self.repository = repository
}
func execute(location: String, completion: @escaping (Result<CityWeather, Error>) -> Void) {
repository.fetchWeather(location: location, completion: completion)
}
}
6️⃣ Domain Layer-Data Layer: User Flow와 반대되는 의존성 (DI: Dependency Inversion)
UseCase를 위에서 설명했고, 이제 Data Layer에서 API나 내부 DB로부터 데이터를 받아오면 되는데...
위에서도 말했듯이 해결해야 하는 문제 하나가 있다.
User Flow에 따르면 UseCase(고수준) -> Repository(저수준) 방향으로 계층이 형성된다.
하지만, 클린 아키텍처에 따르면 UseCase(고수준, 내부) <- Repository(저수준, 외부) 방향으로 형성된 의존성을 추구하기 때문에 의존 관계를 뒤집어야 하는 상황, 즉 "의존성 역전(DI)"이 필요한 상황이 발생한 것이다.
이 상황을 Repository의 추상화 프로토콜(protocol)을 만들면서 해결할 수 있다.
import Foundation
protocol WeatherRepository {
func fetchWeather(location: String,
completion: @escaping (Result<CityWeather, Error>) -> Void)
}
즉, UseCase가 Repository를 Flow대로 바로 의존시키는 것이 아니라,
UseCase는 추상화된 Repository Protocol을 의존하도록 하고 Repository에서는 이 Protocol을 구현하도록 하며 직접적인 의존 관계를 분리시켰다.
// Use Case
final class DefaultWeatherUseCase: WeatherListUseCase {
private let repository: WeatherRepository
...
// Repository
extension DefaultWeatherRepository: WeatherRepository {
...
Repository는 Service(API)에서 데이터를 받아오고, 받아온 DTO를 Domain Layer에 해당하는 Entity로 매핑하는 작업을 수행한다.
*API에서 내려주는 데이터를 애플리케이션 화면에 맞는 데이터의 형태로 변경하는 작업도 이 계층에서 수행!
**Service는 API를 불러오는 코드가 담겨있다. (Alamofire, Moya, URLSession 어떤 코드를 사용해도 상관은 없다! 여기서 설명하지는 않는다.)
import Foundation
final class DefaultWeatherRepository {
private let weatherService: WeatherServiceProtocol
init(weatherService: WeatherServiceProtocol) {
self.weatherService = weatherService
}
}
extension DefaultWeatherRepository: WeatherRepository {
func fetchWeather(location: String,
completion: @escaping (Result<CityWeather, Error>) -> Void) {
weatherService.getWeatherLocation(location: location) { result in
switch result {
case .success(let entity):
let model = CityWeather(location: entity.name,
time: entity.timezone,
weather: entity.weather.first?.main ?? "",
temp: Int(entity.main.temp),
maxTemp: Int(entity.main.tempMax),
minTemp: Int(entity.main.tempMin))
completion(.success(model))
case .failure(let error):
completion(.failure(error))
}
}
}
}
7️⃣ 클린 아키텍처 마무리 & Factory Method Pattern으로 객체 생성 책임을 분리하기
이번 부분에서 설명할 Factory Pattern에 대한 자세한 이론 내용이 궁금하다면, 역시 예전에 내가 정리한 아래 글을 참고하도록 하자!
지금까지 클린 아키텍처를 설명하기 위해 분리된 많은 파일들을 보며 어떤 생각이 들었나?
"관리해야 할 파일이 너무 많다."
"지금은 겨우 하나의 VC를 관리하기 위한 하나의 API와 Entity를 다룬 건데도 이렇게 복잡한데..."
"나중에 프로젝트 구조가 더 복잡해지면 이 많은 파일들의 의존성과 화면 전환, 파일 관리를 어떻게 다 할 수 있을까...?"
나는 이런 생각이 먼저 들었던 것 같다.
그래서 이런 고민들을 해결하기 위해 팩토리 메서드(Factory Method) 패턴을 사용하는 방법을 떠올렸다.
말 그대로 객체(VC, VM, UseCase, Repository 등..)를 찍어내는 공장(Factory)을 하나 만들어서, 이 공장에서 의존성을 주입하고 인스턴스까지 다 만들어서 사용할 수 있도록 내보내겠다는 의미다.
import UIKit
protocol ViewControllerFactoryProtocol {
func makeWeatherListVC() -> WeatherListViewController
}
final class ViewControllerFactory: ViewControllerFactoryProtocol {
// Singleton - 공장의 인스턴스는 단 한개만 사용하도록 한다
static let shared = ViewControllerFactory()
private init() { }
func makeWeatherListVC() -> WeatherListViewController {
let repository = DefaultWeatherRepository(weatherService: WeatherService.shared)
let usecase = DefaultWeatherUseCase(repository: repository)
let viewModel = DefaultWeatherListViewModel(fetchWeatherUsecase: usecase)
let vc = WeatherListViewController(viewModel: viewModel)
return vc
}
}
실제로 팩토리 메서드에서 사용한 의존성 주입 과정을 보게 되면,
직접적으로 필요한 인스턴스를 생성하는 것이 아니라 필요한 인스턴스를 외부에서 주입받아 클래스 간의 결합도를 낮추고 테스트를 용이하게 만들었다.
또한, 각 의존성은 각 계층을 추상화한 프로토콜에 의존하도록 하여, 새로운 의존성이 추가되거나 변경되더라도 팩토리 클래스 내부 선에서 해결되도록 만들었다.
실제 객체를 생성할 때는 아래와 같이 팩토리 클래스의 싱글턴 인스턴스만으로 해결할 수 있다는 것!
let nextVC = ViewControllerFactory.shared.makeWeatherListVC()
Clean Architecture를 공부하며 내가 느꼈던 점은 "아키텍처의 절대적인 정답은 없는 것 같다"였다.
나는 항상 개발 공부를 하는 데 있어서 해당 기술을 도입하기 위한 정당성과 '왜'라는 의문을 갖는 것이 필수라고 생각한다.
클린 아키텍처는 변경 사항에 유연하게 대응할 수 있고, 단일 책임 원칙(SRP)을 준수하고 있는 각 모듈을 테스트하기 용이하며, 계층 간 낮은 결합도, 명확한 구조와 책임 등 많은 장점을 갖고 있는 설계임에는 분명했다.
하지만, 화면이 10개 미만 되는 간단한 애플리케이션이 있다고 할 때 무작정 이런 아키텍처를 적용하는 것이 과연 옳은 일일까?
오히려 이런 상황에서는 화면을 하나 만들기 위해 여러 계층을 추가하고 + 지나치게 단일 책임 원칙에 집착해 매번 모듈을 분리하는 것이 오히려 복잡도를 높이고 유지보수를 어렵게 만드는 "오버 엔지니어링"에 지나지 않을지도 모른다.
결국 이 계층에 지나치게 집착하지 않고 본인이 진행하고 있는 프로젝트의 구조와 변경 가능성에 따른 "정당성 기반 아키텍처 (Justification based on Architecture)"를 소신 있게 적용하는 것이 가장 중요한 것 같다.
*당신이 그렇게 생각했으면, 그 길이 맞는 거다. 단, 그 정당성을 다른 이해 관계자들에게도 부여할 수 있어야 한다.
길고 길었던 이번 글은 여기까지!