GGURUPiOS

Swift Architecture - Clean Architecture + MVVM (예제) 본문

Swift/Architecture

Swift Architecture - Clean Architecture + MVVM (예제)

꾸럽 2023. 4. 27. 20:23

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 레이어에만 의존 함

새로운 용어들이 등장해서 이해가 잘 안 갈수도 있지만 데이터 흐름으로 이해 해보자

데이터 흐름

  1. View(UI)는 ViewModel 에서 메서드를 호출
  2. ViewModel 은 Use Case를 실행
  3. Use Case 는 User 와 Repositoies 의 데이터를 결합
  4. 각 Repository 는 원격 데이터 (네트워크), 영구 DB 스토리지 소스 또는 메모리 내 데이터(원격 OR 캐시) 에서 데이터 반환
  5. 항목 목록을 표시하는 보기 (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 원칙을 지키며 추가가 가능하기 때문에~

결국 나중에 구조를 어떤식으로 어디서 부터 설계해야 할지는 의문 (생각이 날까) 도메인? 프레젠테이션? 데이터? 물론 전체적인 구조를 짜면서 해야겠지만 아직 헷갈

간단한 앱 예제로 직접 적용해 조만간 만들어 볼 예정