GGURUPiOS
Swift Architecture - Clean Architecture + MVVM (예제) 본문
Clean Architecture + MVVM
Clean Architecture + MVVM 에 대해 알아보자 아래는 원문 링크출처임
https://tech.olx.com/clean-architecture-and-mvvm-on-ios-c9d167d9f5b3
기본규칙
기본적인 규칙은 내부 레이어에서 외부 레이어로의 의존성을 가지지 않는다는 것임 (Clean Architecture 의 기본 룰 Dependency Rule 과 동일)
기본설계
Clean Architecture + MVVM 에서는 크게 3개의 레이어가 있음
- Domain Layer : 가장 안쪽의 원 ( Use Cases, Entity 를 포함 )
- Presentation Layer : UI(View) + ViewModel ( UI에는 UIViewController 가 포함 ) 으로 구성되어 있으며, View는 ViewModel(Presenter)에 의해 조정됨 또한, 이 레이어는 Domain 레이어 에만 의존함
- Data Layer : Repository 구현과 하나 이상의 Data Source 를 포함하며 서로 다른 데이터 소스의 데이터 조정을 담당 또한, Presentation 레이어 와 마찬가지로 Domain 레이어에만 의존 함
새로운 용어들이 등장해서 이해가 잘 안 갈수도 있지만 데이터 흐름으로 이해 해보자
데이터 흐름
- View(UI)는 ViewModel 에서 메서드를 호출
- ViewModel 은 Use Case를 실행
- Use Case 는 User 와 Repositoies 의 데이터를 결합
- 각 Repository 는 원격 데이터 (네트워크), 영구 DB 스토리지 소스 또는 메모리 내 데이터(원격 OR 캐시) 에서 데이터 반환
- 항목 목록을 표시하는 보기 (UI)로 다시 돌아감
의존성 방향 ( Dependency Direction )
- Presentation Layer → Domian Layer ← Data Repositories Layer
- Presentation Layer (MVVM) = ViewModels + Views (UI)
- Domain Layer = Entities + Use Cases + Repositories Interfaces
- Data Layer = Repositories Implementations + API(Network) + Persistence DB
예제 프로젝트로 알아보기: “Movies App”
기본적인 것은 다 알아봤다. 그럼 결국에 실제 코드에서 어떤식으로 레이어간 통신을 하며, 해당 레이어에는 무슨 내용이 들어가야 하는지 그런 것들을 어떻게 설계해야 하는지에 대한 어려움을 예제를 통해 해결해보자..
프로젝트 구조
Domain Layer (Entities + Use Cases + Repositories Interfaces)
구조를 보며 생각을 해보자. 만약 영화 검색 앱이면 무엇이 필요할까?
- Entities: Movie(영화 구조체), MovieQuery(검색용)
- Use Cases: 영화 검색어, 영화 목록 가져오기 비즈니스 로직을 담당하는 (SearchMovie…, FetchRecent…)UseCases들
- Repositories: 영화 데이터를 다룰 MovieRepository, 영화 검색어를 다룰 QueriesRepository 로 구성되어 있음
구현 코드를 보자
protocol SearchMoviesUseCase {
func execute(requestValue: SearchMoviesUseCaseRequestValue,
completion: @escaping (Result<MoviesPage, Error>) -> Void) -> Cancellable?
}
final class DefaultSearchMoviesUseCase: SearchMoviesUseCase {
private let moviesRepository: MoviesRepository
private let moviesQueriesRepository: MoviesQueriesRepository
init(moviesRepository: MoviesRepository, moviesQueriesRepository: MoviesQueriesRepository) {
self.moviesRepository = moviesRepository
self.moviesQueriesRepository = moviesQueriesRepository
}
func execute(requestValue: SearchMoviesUseCaseRequestValue,
completion: @escaping (Result<MoviesPage, Error>) -> Void) -> Cancellable? {
return moviesRepository.fetchMoviesList(query: requestValue.query, page: requestValue.page) { result in
if case .success = result {
self.moviesQueriesRepository.saveRecentQuery(query: requestValue.query) { _ in }
}
completion(result)
}
}
}
// Repository Interfaces
protocol MoviesRepository {
func fetchMoviesList(query: MovieQuery, page: Int, completion: @escaping (Result<MoviesPage, Error>) -> Void) -> Cancellable?
}
protocol MoviesQueriesRepository {
func fetchRecentsQueries(maxCount: Int, completion: @escaping (Result<[MovieQuery], Error>) -> Void)
func saveRecentQuery(query: MovieQuery, completion: @escaping (Result<MovieQuery, Error>) -> Void)
}
일단 UseCase가 다른 UseCase에 의존할 수 있다는 점을 알아두자.
딱히 순서는 상관없고 제가 코드를 이해한대로 써보겠습니다.
- SearchMoviesUseCase로 추상화된 UseCases 인터페이스(프로토콜) 선언
- DefaultSearchMoviesUseCase에서 SearchMoviesUseCase 채택 후 구현 (execute 함수)
- DefaultSearchMoviesUseCase에서는 필요한 레포지토리(MoviesRepository, MoviesQueriesRepository)의 추상화된 프로토콜을 의존하도록 선언
- init에서 추상화된 프로토콜에 의존성 주입 (Use Case가 다른 클래스를 의존하는 것이 아닌 추상화 된 프로토콜에 의존) (Dependency Inversion)
- execute 구현 안에서 Repository의 메소드를 호출 추상화 된 인터페이스를 통해 외부 모듈에 접근 (실제 구현은 프로토콜을 채택한 클래스에서 되어있기 때문에 내부 원의 입장 (Use Cases)에서 외부 원의 (Repository) 구현이 어떻게 되어있는 지 알수는 없음)
Presentation Layer (ViewModel + View)
ViewModel은 UIKit를 가져오지 않음
- ViewModel: ViewModel은 View와 데이터 바인딩을 통해 뷰를 업데이트하거나 Use Cases에 접근
- View: 비즈니스 모델이나 로직에 접근 할 수 없음. 단순 View의 역할 + ViewModel에 이벤트를 전달하거나, ViewModel로 부터 온 데이터를 업데이트 하는 역할
ViewModel
// Note: We cannot have any UI frameworks(like UIKit or SwiftUI) imports here.
protocol MoviesListViewModelInput {
func didSearch(query: String)
func didSelect(at indexPath: IndexPath)
}
protocol MoviesListViewModelOutput {
var items: Observable<[MoviesListItemViewModel]> { get }
var error: Observable<String> { get }
}
protocol MoviesListViewModel: MoviesListViewModelInput, MoviesListViewModelOutput { }
struct MoviesListViewModelActions {
// Note: if you would need to edit movie inside Details screen and update this
// MoviesList screen with Updated movie then you would need this closure:
// showMovieDetails: (Movie, @escaping (_ updated: Movie) -> Void) -> Void
let showMovieDetails: (Movie) -> Void
}
final class DefaultMoviesListViewModel: MoviesListViewModel {
private let searchMoviesUseCase: SearchMoviesUseCase
private let actions: MoviesListViewModelActions?
private var movies: [Movie] = []
// MARK: - OUTPUT
let items: Observable<[MoviesListItemViewModel]> = Observable([])
let error: Observable<String> = Observable("")
init(searchMoviesUseCase: SearchMoviesUseCase,
actions: MoviesListViewModelActions) {
self.searchMoviesUseCase = searchMoviesUseCase
self.actions = actions
}
private func load(movieQuery: MovieQuery) {
searchMoviesUseCase.execute(movieQuery: movieQuery) { result in
switch result {
case .success(let moviesPage):
// Note: We must map here from Domain Entities into Item View Models. Separation of Domain and View
self.items.value += moviesPage.movies.map(MoviesListItemViewModel.init)
self.movies += moviesPage.movies
case .failure:
self.error.value = NSLocalizedString("Failed loading movies", comment: "")
}
}
}
}
// MARK: - INPUT. View event methods
extension MoviesListViewModel {
func didSearch(query: String) {
load(movieQuery: MovieQuery(query: query))
}
func didSelect(at indexPath: IndexPath) {
actions?.showMovieDetails(movies[indexPath.row])
}
}
// Note: This item view model is to display data and does not contain any domain model to prevent views accessing it
struct MoviesListItemViewModel: Equatable {
let title: String
}
extension MoviesListItemViewModel {
init(movie: Movie) {
self.title = movie.title ?? ""
}
}
마찬가지로 이해한대로 작성해보자
- 일단 ViewModel로 오는 Input(사용자 상호작용 등) 과 Output(들어온 데이터를 뷰에 맞는 형태로 가공) 을 프로토콜로 추상화하고, 그 두 프로토콜을 따르는 하나의 뷰모델 프로토콜을 만듬 (MoviesListViewModel)
- MoviesListViewModelActions 구조체로 클로저 작성 (위의 코드에는 없지만 MoviesSearchFlowCoordinator 가 다른 뷰를 표시해야할 시기를 알려줌) (Coordinator 부분은 View다음 내용에서 참고)
- Input과 Output을 정의해줌
- 직접 비즈니스로직을 실행시키지 않고 추상화된 usecases 인터페이스의 메소드를 호출하는 식으로 작성
- 돌아온 데이터를 핸들링 해주는 코드를 switch문으로 작성
- 또한 비즈니스 모델 (Entity나 useCase에서 사용하는model)을 뷰에 직접전달 할 수 없으므로 MoviesListItemViewModel 이라는 View에서 사용할 구조체로 정의 (매핑)
View (ViewController, UIKit 등)
import UIKit
final class MoviesListViewController: UIViewController, StoryboardInstantiable, UISearchBarDelegate {
private var viewModel: MoviesListViewModel!
final class func create(with viewModel: MoviesListViewModel) -> MoviesListViewController {
let vc = MoviesListViewController.instantiateViewController()
vc.viewModel = viewModel
return vc
}
override func viewDidLoad() {
super.viewDidLoad()
bind(to: viewModel)
}
private func bind(to viewModel: MoviesListViewModel) {
viewModel.items.observe(on: self) { [weak self] items in
self?.moviesTableViewController?.items = items
}
viewModel.error.observe(on: self) { [weak self] error in
self?.showError(error)
}
}
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
guard let searchText = searchBar.text, !searchText.isEmpty else { return }
viewModel.didSearch(query: searchText)
}
}
- VC(ViewController)에서는 UIViewController를 상속받고 StoryboardInstantiable, UISearchBarDelegate를 채택 StoryBoardInstatiable은 작성자가 만든 프로토콜임 스토리보드와 아이덴티파이어를 파악해서 vc.instantiateViewController(withIdentifier:) 함수 반환 값(vc) 리턴함
- 해당 뷰컨인스턴스를 생성하고 추상화된 뷰모델 인터페이스에 의존성 주입하는 class 함수 작성
- bind 함수를 구현하고 viewDidLoad 에서 데이터 바인딩
- searchbardelegate 구현 (searchBarSearchButtonClicked), 이 때 viewModel에게 이벤트 전달 viewModel.didSearch(query: searchText)
Coordinator? → Coordinator 패턴은 VC에게 지시를 내리는 객체임 ( 그 중에서도, view의 화면전환 및 계층에 대한 흐름을 제어) : 화면전환 관련 코드를 VC와 분리해서 VC의 부담을 줄여줌
protocol MoviesSearchFlowCoordinatorDependencies {
func makeMoviesListViewController() -> UIViewController
func makeMoviesDetailsViewController(movie: Movie) -> UIViewController
}
final class MoviesSearchFlowCoordinator {
private weak var navigationController: UINavigationController?
private let dependencies: MoviesSearchFlowCoordinatorDependencies
init(navigationController: UINavigationController,
dependencies: MoviesSearchFlowCoordinatorDependencies) {
self.navigationController = navigationController
self.dependencies = dependencies
}
func start() {
// Note: here we keep strong reference with actions closures, this way this flow do not need to be strong referenced
let actions = MoviesListViewModelActions(showMovieDetails: showMovieDetails)
let vc = dependencies.makeMoviesListViewController(actions: actions)
navigationController?.pushViewController(vc, animated: false)
}
private func showMovieDetails(movie: Movie) {
let vc = dependencies.makeMoviesDetailsViewController(movie: movie)
navigationController?.pushViewController(vc, animated: true)
}
이 접근 방식은 동일한 ViewModel을 수정하지 않고 다른 뷰를 쉽게 사용할 수 있음 이 디자인 패턴은 다음에 더 자세히 다뤄보자.
Data Layer
Data Layer에는 DefaultMoviesRepository 가 포함되어 있음 Domain Layer 내부에 정의된 인터페이스(MovieRepository 프로토콜)를 준수함 또한 여기에 JSON 데이터 및 CoreData 엔터티를 도메인 모델에 매핑하는 것을 추가함 (결국 디코딩 로직을 이 부분에서 정의한다는 말인듯 함)
final class DefaultMoviesRepository {
private let dataTransferService: DataTransfer
init(dataTransferService: DataTransfer) {
self.dataTransferService = dataTransferService
}
}
extension DefaultMoviesRepository: MoviesRepository {
public func fetchMoviesList(query: MovieQuery, page: Int, completion: @escaping (Result<MoviesPage, Error>) -> Void) -> Cancellable? {
let endpoint = APIEndpoints.getMovies(with: MoviesRequestDTO(query: query.query,
page: page))
return dataTransferService.request(with: endpoint) { (response: Result<MoviesResponseDTO, Error>) in
switch response {
case .success(let moviesResponseDTO):
completion(.success(moviesResponseDTO.toDomain()))
case .failure(let error):
completion(.failure(error))
}
}
}
}
// MARK: - Data Transfer Object (DTO)
// It is used as intermediate object to encode/decode JSON response into domain, inside DataTransferService
struct MoviesRequestDTO: Encodable {
let query: String
let page: Int
}
struct MoviesResponseDTO: Decodable {
private enum CodingKeys: String, CodingKey {
case page
case totalPages = "total_pages"
case movies = "results"
}
let page: Int
let totalPages: Int
let movies: [MovieDTO]
}
...
// MARK: - Mappings to Domain
extension MoviesResponseDTO {
func toDomain() -> MoviesPage {
return .init(page: page,
totalPages: totalPages,
movies: movies.map { $0.toDomain() })
}
}
ㄷ
DTO는 JSON response 에서 도메인으로 매핑하기 위한 중간 개체로 사용 됨 (지금은 그냥 서버에서 내려주는 데이터 구조 그 자체라고 생각하면 될 듯함)
일반적으로 Data Repositories 는 API Data Service 및 영구 데이터 저장소 (DB: Core Data 등)와 함께 주입될 수 있음. 이 두 의존성과 함께 작동하여 데이터를 반환함. 먼저 캐시된 데이터 출력에 대한 영구 데이터 저장소를 요청함
DTO 개체 → Domain 매핑 → 캐시된 데이터 클로저에서 검색 → API 호출 → DB 업데이트 → DTO가 Domain에 매핑되고 업데이트된 data/completion 클로저 에서 검색됨
Infrastructure Layer (Network)
네트워크 프레임워크를 둘러싼 래퍼 :Alamofire 등
struct APIEndpoints {
static func getMovies(with moviesRequestDTO: MoviesRequestDTO) -> Endpoint<MoviesResponseDTO> {
return Endpoint(path: "search/movie/",
method: .get,
queryParametersEncodable: moviesRequestDTO)
}
}
let config = ApiDataNetworkConfig(baseURL: URL(string: appConfigurations.apiBaseURL)!,
queryParameters: ["api_key": appConfigurations.apiKey])
let apiDataNetwork = DefaultNetworkService(session: URLSession.shared,
config: config)
let endpoint = APIEndpoints.getMovies(with: MoviesRequestDTO(query: query.query,
page: page))
dataTransferService.request(with: endpoint) { (response: Result<MoviesResponseDTO, Error>) in
let moviesPage = try? response.get()
}
프레임워크(모듈)로 레이어 분리
각각의 레이어를 별도의 프레임워크로 쉽게 분리할 수 있다고 함
New Project -> Create Project… -> Cocoa Touch Framework
.xcworkspace 를 삭제하고 다시 pod install 하면 된다고 함
나중에 프로젝트 만들 때 해보자
클린 아키텍처 + MVVM 에 대해 알아보았다.
구조는 처음 볼 때는 복잡하지만 배워갈수록 이 아키텍처에 대한 장점을 느낌 일단 각 모듈이 분리되어 있어서 테스트하기 쉽고, 유지 보수에 강점이 있을 것 같음 (원문 글쓴이도 클린아키텍처 자체가 TDD와 정말 잘 어울린다고 말함)
결국 이 아키텍처에서 가장 중요한 부분은 ‘추상화’ 같음. 추상화를 통해 의존성 주입, 역전 하며 모듈간의 강한 의존성을 떨구는 것도 가능하고, 후에 기능을 추가할 때도 OCP 원칙을 지키며 추가가 가능하기 때문에~
결국 나중에 구조를 어떤식으로 어디서 부터 설계해야 할지는 의문 (생각이 날까) 도메인? 프레젠테이션? 데이터? 물론 전체적인 구조를 짜면서 해야겠지만 아직 헷갈
간단한 앱 예제로 직접 적용해 조만간 만들어 볼 예정
'Swift > Architecture' 카테고리의 다른 글
[Swift/Coordinator] coordinator 패턴에서의 childCoordinator 할당 해제 (0) | 2024.03.13 |
---|---|
Swift Architecture - Clean Architecture (0) | 2023.04.27 |
Swift Architecture - MVVM (0) | 2023.04.25 |
Swift Architecture - MVC (0) | 2023.04.25 |