ios

12. Clean Architecture (iOS)

코코넛딩 2023. 12. 24. 14:40

이글은  챗지피티를 사용하였고 아래의 블로그를 참고 했습니다.

https://ios-development.tistory.com/559

 

[iOS - swift] clean architecture를 적용한 MVVM 코드 맛보기

Domain Layer : 영화 검색 결과 성공한 쿼리를 저장하는 Entities, SearchMoviesUseCase, DIP를 위한 프로토콜 Repository Protocol위치가 UseCase에 존재 UseCase에 주입: 비즈니스로직에 필요한 Repository UseCase끼리는 서

ios-development.tistory.com

 

https://yoojin99.github.io/app/%ED%81%B4%EB%A6%B0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98/

 

[iOS] - Clean Architecture, 직접 해보기

Clean architecture가 무엇인지 알아보자.

yoojin99.github.io

https://zeddios.tistory.com/1065

 

Clean Architecture

안녕하세요 :) Zedd입니다. Zedd신곡 나왔는데...엄청 좋아요 갓제드ㅠ 이거들으면서 쓰는 중 오늘은..클린 아키텍쳐 + MVVM에 대해서 공부를 해보려고 합니다! 클린 아키텍쳐는 한번쯤은 들어보셨을

zeddios.tistory.com

zedd님 블로그에 있던 클린 아키텍쳐의 예제

  • 클린 아키텍쳐를 도입한 앱 예제
  • 영화를 검색하는 앱
  • 영화를 검색하면 해당 단어가 들어가는 영화를 보여주고, 스크롤하면 더 불러오기까지 된다.
  • 특정 영화를 누르면 해당 영화의 사진, 제목, 설명을 보여주는 화면으로 이동하게 된다.

 

 

Clean Architecture

  • 안쪽 레이어에 있는 것들은 바깥 레이어에 있는 것들을 몰라야 한다.
  • 즉, 안쪽 레이어에서 바깥쪽에 의존하지 말아야 한다.
  • 예를 들어 안쪽 레이어의 내부에서 바깥쪽 레이어의 객체를 생성하면 안된다.

 

Domain, Presentation, Data Layer

 

첫번째 그림과 색깔로 매칭

 

  • Data Layer - 제일 바깥쪽 레이어
  • Presentation Layer
    • Data Layer 보다 1단계 안에 있는 레이어 + UI + ViewModel
    • UI 
      • UI는 사용자에게 데이터를 표시하는데(Presentation) 직접 관여하므로 프레젠테이션 레이어의 일부로 또는 프레젠 테이션 레이어와 함께 표시될 수 있습니다.
      • UI는 아키텍쳐에서의 역할 측면에서 프레젠테이션 계층의 일부지만 인프라의 일부인 프레임 워크에 의존하는 외부 구성요소 임을 나타내기 위해 data layer에 표시 되었다.
  • Domain Layer - 가장안에 있는 레이어

 

Domain layer(Business logic)

 

  • 가장 안쪽에 있으므로 완전히 고립되어 있는 상태로 바깥 Layer를 아무것도 모른다.
  • Entity(Business Model), Use Case, Repository Interface를 포함한다.
  • 이 레이어는 다른 프로젝트에서도 재사용될 수 있다. 
  • 레이어가 분리 되어 다른 의존성이나 3rd party가 필요하지 않기 때문에 앱을 테스트 할 때 환경에 구애 받지 않도록하고, 따라서 도메인 Use case 테스트를 몇초 내에 할 수 있다.
  • 도메인 레이어의 핵심은 다른 레이어의 어떤 것도 포함시키면 안된다.

Entity

  • Enterprise wide business rules를 캡슐화하는 친구이다.
  • 외부 변화가 있을 때 변경될 가능성이 가장 적다.
  • 일반적으로 가장 높은 수준의 규칙을 캡슐화한다.
  • 위의 영화 검색앱에서의 Entity는 "Move"라는 데이터 구조이다.
  • 간단하게 Entity는 데이터구조 및 함수 집합이라고 생각하자
    • 함수 집합(비즈니스 로직)
      • Entity는 해당 데이터에 대한 비즈니스 로직을 포함합니다.
      • 이 로직은 Entity의 데이터를 처리하고 유효성을 검사하며, Entity의 상태를 변경하는데 사용된다.
  • 영화 검색앱 에서는 무조건 영화에 대한 정보를 보여줄 것이니 Movie라는 데이터 구조가 가장 높은 수준의 규칙이라고 말할 수 있다.

 

struct Movie: Equatable, Identifiable {
              typealias Identifier = String
              enum Genre {
                  case adventure
                  case scienceFiction
              }
              let id: Identifier
              let title: String?
              let genre: Genre?
              let posterPath: String?
              let overview: String?
              let releaseDate: Date?
          }
        
          struct MoviesPage: Equatable {
              let page: Int
              let totalPages: Int
              let movies: [Movie]
          }

Use cases

  • 시스템의 동작을 사용자의 입장에서 표현한 시나리오이다.
  • 이 Use cases circle은 시스템의 모든 Use cases를 캡슐화하고 구현한다.
  • Use cases는 엔티티와의 데이터 흐름을 조정하고 해당 엔티티가 Use cases의 목표를 달성하도록 지시하는 역할을 합니다.
  • 영화 검색앱에서는 Use cases는 뭘까?
  • 영화 검색앱에서 사용자는 영화를 검색한다.
  • Use cases는 사용자에게 보여줄 출력을 위해 해당 출력을 생성하기 위한 처리 단계를 기술하는 곳이라고 보면된다.
  • Entities는 이 Uses cases에 대해 전혀 알지 못한다. 
  • 하지만 Uses cases는 Entities를 알고 있다.
  • 하지만 이 Use cases 계층의 변경이 Entities에 영향을 미쳐서는 안된다.
  • 또한 Uses cases가 DB, UI같은 외부 환경의 변경으로 인해 영향을 받지 않아야한다
  • 하지만 앱 정체의 작동 방식 변경은 Use cases에 영향을 미칠 수 있다. 사용자의 시나리오가 바뀌기 때문이다.

 

Presentation layer

  • UI(UIViewController/SwiftUI View)를 포함한다.
  • 각 View는 하나 이상의 Use Case를 실행시키는 ViewModel(Presenters)와 대응된다.
  • 즉 한 view에 대응되는 하나의 viewModel이 있다.
  • 도메인 레이어에만 의존하고 있다.
  • 이 계층이 하는 일은 
    • 이 계층에 데이터가 딱 들어오면 Entities, Use cases에 가장 편리한 format에서 DB등과 같은 외부 프레임 워크에 가장 편리한 format으로 변환되는 곳입니다.
    • 즉 DB는 이 원까지만 알아야하고 Use cases, Entities에 있는 코드들은 DB에 대해 아는 것이 1도 없어야한다.

 

Data layer

  • Repository Implementation, 그리고 하나 이상의 Data Source를 포함한다.
    • Repository 
      • 도메인 계층과 데이터 계층 사이에 있는 추상화 계층
      • 이는 데이터의 출처나 지속 방식을 알 필요 없이 애플리케이션의 비즈니스 로직이 필요한 데이터에 액세스할 수 있도록 하는 중개자 역할을 한다.
  • Repository Interface는 도메인 레이어에 속했다.
  • Data Source는 원격이나 로컬일 수 있다.
    • Data Source 
      • 데이터 검색 및 저장을 직접 관리하는 코드를 나타낸다.
  • 원격일 경우는 API 호출을 통해 Json 데이터를 내려 받는 경우가 되고, 로컬 일때는 데이터 베이스 일 것이다.
  • Data Layer는 오로지 도메인 레이어에만 의존하고 있다.

 

Data Flow

Data Flow

Dependency Direction

Presentation 레이어 ➡ Domain 레이어 ⬅️ Data Repository 레이어

  • Presentation layer(MVVM) = ViewModels(Presenters) + Views(UI)
  • Domain layer = Entities + Use Cases + Repositories Interfaces
  • Data Repositories layer = Repositories Implementations + API(NW) + Persistence DB

Data Flow

  1. View(UI) ViewModel(Presenter)의 메서드를 호출한다.
  2. ViewModel UseCase를 실행시킨다.
  3. Use Case User Repositories에서 데이터를 취합한다.
  4.  Repository Remote Data(NW), Persistent DB저장소나 메모리 데이터를 반환한다.
  5. 반환된 데이터가 우리가 아이템들을 화면에 출력할 View에 전달된다.

의존성 역전

  • 시스템의 각 계층이 서로에게 직접 의존하지 않고, 추상화된 인터페이스를 통해 상호작용하도록 하는 아키텍처 원칙입니다.
  • 이 원칙은 고수준의 비즈니스 로직이 하위수준의 구현 세부사항(데이터 베이스 엑세스, 네트워크 호출 등)에 의존하지 않음으로써 각 계층의 결합도를 낮추고 유지보수성과 확장성을 향상시키는데 목적이 있다.
  • 방식
    • 계층 간 추상화를 통한 의존성 관리
      • 가장 핵심적인 도메인 계층은 비즈니스 규칙과 엔티티를 정의합니다.
      • 데이터 계층과 프레젠테에션 계층은 도메인 계층에서 정의한 인터페이스(Use Cases, Repositories)에 의존해야하며, 도메인 계층은 이들 계층의 구체적인 구현에 의존해서는 안된다.
      • 즉, 구체적인 데이터 베이스 구현이나 UI구현은 도메인 계층의 추상화된 인터페이스를 구현하는 방식으로 연결된다. 
    • 세부 사항이 추상화에 의존
      • 데이터 베이스 엑세스 로직이나 UI로직 같은 구체적인 세부사항은 도메인 계층의 추상화된 인터페이스를 구현하게 되며, 이로 인해 비즈니스 로직이 데이터 베이스나 UI의 구체적인 구현 세부사항으로부터 독립된다.
  • 이 원칙을 적용합으로써 앱의 비즈니스 로직은 UI의 변경이나 데이터 베이스의 변경에 영향을 받지 않게 된다.
  • 이는 테스트의 용이성과 시스템의 유연성을 크게 향상시킨다.
  • 예를 들어, 데이터 베이스 시스템을 교체하거나 다른 유형의 프론트 엔드로 전환해도 도메인 로직은 변경할 필요가 없으며, 이는 장기적으로 시스템의 유지보수와 확장을 용이하게 만든다.

  • 다이어 그램의 우하단에 있는 원 경계를 교차하는 방법의 예가 있다.
  • 분홍색 선을 보면 Controller에서 시작해서 Use case를 거쳐 Present에서 실행되는 모양이다.
  • 결국 Use case가 Presenter를 호출할 수 도 있다.
  • 그러면 clean architecture의 규칙이 깨진다.
  • 그래서 Output Port라는 인터페이스를 둔다.
  • Use cases는 Output port에 있는 인터페이스를 호출한다.
  • Presenter는 이 Output port 인터페이스를 구현한다.
  • 인터페이스를 통해 접근한다.

 

MVVM 코드 예제

Domain Layer

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)
}
  • Repository Protocol 위치가 UseCase에 존재
  • UseCase 끼리는 서로 의존 가능
  • SearchMoviesUseCase 프로토콜을 만든 이유는 의존성의 역전을 적용하기 위함
  • 의존성의 역전을 적용하여 클래스에 직접 의존하지 않고 프로토콜에 의존하여 결합도를 낮출수 있다.
    • 결합도 
      • 서로 다른 클래스나 모듈간의 의존성 정도
      • 낮을 수록 각 부분을 독립적으로 변경하거나 재사용하기 쉽다.
  • 왜 생성자에 repository의 프로토콜을 받는지?
    • 의존성 역전 원칙과 의존성 주입 원칙을 적용하기 위함이다.
      • 의존성 역전 원칙(DIP)
        • 고수준 모듈이 저수준 모듈에 의존하지 않도록 하며, 둘 다 추상화에 의존하게 만드는 원칙이다.
      • 의존성 주입(DI)
        • 객체의 의존성(moviesRepository, moviesQueriesRepository)를 외부에서 주입하는 기법이다.
    • 이 두 원칙을 적용함으로써, 높은 모듈성, 낮을 결합도, 더 나은 테스트 가능성을 달성할 수 있다.
      • 모듈성과 재사용성 
        • moviesRepository와 MoviesQueriesRepository의 구현체를 교체하기 쉬워져 다른 데이터 소스 사용할 때도 DefaultSearchMoviesUseCase를 재사용할 수 있다.
      • 테스트 용이성
        • 테스트 중에 moviesRepository와 MoviesQueriesRepository의 실제 구현 대신 Mock이나 Stub을 주입할수 있어 유닛 테스트가 용이해진다.
      • 결합도 감소
        • DefaultSearchMoviesUseCase는  moviesRepository와 MoviesQueriesRepository의  구체적인 구현을 알필요가 없으므로 시스템에 결합도가 낮아진다.

 

 

 

Presentation Layer

// 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 ?? ""
    }
}
  • View는 ViewModel에 의존하고 있지만 ViewModel은 View에 의존하지 않는 형태이므로 View가 변경되어도 ViewModel은 영향받지 않는 형태
  • MovieListViewModel 프로토콜 
    • View에 영향 받지 않고 ViewModel만 가지고 테스트할 수 있는 구조
    • Mock, stub을 사용해서 테스트 가능
  • MoviesListViewModelActions
    • Coordinator에는 ViewModel 간의 delegate 통신을 설정 
    • action?.showMovieDetails(movies[indexPath.row]))
    • action
  • Observable
  • Obervable 안에 MovieListItemViewModel 왜 String으로 안넣고 객체로 넣는가?

 

 

Coordinator

import UIKit

protocol MoviesSearchFlowCoordinatorDependencies  {
    func makeMoviesListViewController(actions: MoviesListViewModelActions) -> MoviesListViewController
    func makeMoviesDetailsViewController(movie: Movie) -> UIViewController
    func makeMoviesQueriesSuggestionsListViewController(didSelect: @escaping MoviesQueryListViewModelDidSelectAction) -> UIViewController
}

final class MoviesSearchFlowCoordinator {
    
    private weak var navigationController: UINavigationController?
    private let dependencies: MoviesSearchFlowCoordinatorDependencies

    private weak var moviesListVC: MoviesListViewController?
    private weak var moviesQueriesSuggestionsVC: UIViewController?

    init(navigationController: UINavigationController,
         dependencies: MoviesSearchFlowCoordinatorDependencies) {
        self.navigationController = navigationController
        self.dependencies = dependencies
    }
    
    func start() {
        // Note: here we keep strong reference with actions, this way this flow do not need to be strong referenced
        let actions = MoviesListViewModelActions(showMovieDetails: showMovieDetails,
                                                 showMovieQueriesSuggestions: showMovieQueriesSuggestions,
                                                 closeMovieQueriesSuggestions: closeMovieQueriesSuggestions)
        let vc = dependencies.makeMoviesListViewController(actions: actions)

        navigationController?.pushViewController(vc, animated: false)
        moviesListVC = vc
    }

    private func showMovieDetails(movie: Movie) {
        let vc = dependencies.makeMoviesDetailsViewController(movie: movie)
        navigationController?.pushViewController(vc, animated: true)
    }

    private func showMovieQueriesSuggestions(didSelect: @escaping (MovieQuery) -> Void) {
        guard let moviesListViewController = moviesListVC, moviesQueriesSuggestionsVC == nil,
            let container = moviesListViewController.suggestionsListContainer else { return }

        let vc = dependencies.makeMoviesQueriesSuggestionsListViewController(didSelect: didSelect)

        moviesListViewController.add(child: vc, container: container)
        moviesQueriesSuggestionsVC = vc
        container.isHidden = false
    }

    private func closeMovieQueriesSuggestions() {
        moviesQueriesSuggestionsVC?.remove()
        moviesQueriesSuggestionsVC = nil
        moviesListVC?.suggestionsListContainer.isHidden = true
    }
}

 

  • Coordinator에서 MoviesListViewModelActions를 구현하고 특정 ViewModel에서 실행하면 동작하도록 설정
  • MoviesSearchFlowCoordinator의 역할
    • MoviesSearchFlowCoordinator는 앱 내에서 영화 검색과 관련된 화면 흐름을 관리한다.
    • 즉 화면간의 전환을 책임진다.
    • 화면 전환 로직을 뷰 컨트롤러로부터 분리하여 뷰컨트롤러가 사용자 인터페이스 관리에만 집중할 수 있도록 도와준다.
  • MoviewSearchFlowCoordinator를 사용하지 않고 구현된 함수들을 MoviesListViewController에 직접 구현할 때의 단점
    • 결합도 증가
      • 유지보수의 어려움
      • 재사용성 감소
      • 테스트 어려움
      • 유연성 저하
    • 재사용성 감소
    • 단일 책임 원칙 위반
  • MoviesSearchFlowCoordinatorDependencies 프로토콜 주입 이유
    • 의존성 역전
      • 확장성, 유지보수성 향상
    • 유연성과 테스트 용이성
    • 재사용성 증가

 

View

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)
    }
}
  • viewModel을 가지고 있는 형태
  • viewModel.output을 obserbing하는 형태

 

Data Layer

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() })
    }
}
...
  • repository는 데이터를 가져와서 Usecase에게 completion으로 넘겨준다.
  • DTO 역할
    • Request에 필요한 Encodable: struct -> JSON
    • Response에 필요한 Decodable: JSON -> struct
    • Response로 부터 받은 데이터를 실제 domain에서 사용하는 모델로 변경하는 메소드 toDomain()
  • 네이밍
    • -Service: 네트워크 관련
    • -cache: Endpoint 응답을 캐시하기 위하여 DTO를 NSManagedObject에 매핑하여 CoreData 영구저장소에 저장하는 방법
  • cache를 사용하는 이유
    • 상용자가 데이터를 즉시 볼 수 있는 장점, 인터넷에 연결되어 있지 않아도 CoreData에서 데이터를 볼 수 있는 장점
    • cache 사용 방법 
      • 네트워크 API 결과값을 반환하기 전에, CoreData에서 출력을 요청 -> API로 부터 데이터가 오면 CoreData를 최신 데이터로 업데이트

 

final class DefaultMoviesRepository {

    private let dataTransferService: DataTransferService
    private let cache: MoviesResponseStorage

    init(dataTransferService: DataTransferService, cache: MoviesResponseStorage) {
        self.dataTransferService = dataTransferService
        self.cache = cache
    }
}

extension DefaultMoviesRepository: MoviesRepository {

    public func fetchMoviesList(query: MovieQuery, page: Int,
                                cached: @escaping (MoviesPage) -> Void,
                                completion: @escaping (Result<MoviesPage, Error>) -> Void) -> Cancellable? {

        let requestDTO = MoviesRequestDTO(query: query.query, page: page)
        let task = RepositoryTask()

        cache.getResponse(for: requestDTO) { result in

            if case let .success(responseDTO?) = result {
                cached(responseDTO.toDomain())
            }
            guard !task.isCancelled else { return }

            let endpoint = APIEndpoints.getMovies(with: requestDTO)
            task.networkTask = self.dataTransferService.request(with: endpoint) { result in
                switch result {
                case .success(let responseDTO):
                    self.cache.save(response: responseDTO, for: requestDTO)
                    completion(.success(responseDTO.toDomain()))
                case .failure(let error):
                    completion(.failure(error))
                }
            }
        }
        return task
    }
}

 

 

인프라 계층(network)

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()
}
  • 기초
    • endPoint
      • GET/POST/PUT/DELETE를 설정하여 request하기 전 마지막 설정

 

 

public final class Observable<Value> {
    
    struct Observer<Value> {
        weak var observer: AnyObject?
        let block: (Value) -> Void
    }
    
    private var observers = [Observer<Value>]()
    
    public var value: Value {
        didSet { notifyObservers() }
    }
    
    public init(_ value: Value) {
        self.value = value
    }
    
    public func observe(on observer: AnyObject, observerBlock: @escaping (Value) -> Void) {
        observers.append(Observer(observer: observer, block: observerBlock))
        observerBlock(self.value)
    }
    
    public func remove(observer: AnyObject) {
        observers = observers.filter { $0.observer !== observer }
    }
    
    private func notifyObservers() {
        for observer in observers {
            DispatchQueue.main.async { observer.block(self.value) }
        }
    }
}
  • Custom Observable
    • value에 값이 emit될 때마다 didSet에서 closure를 통해 subscriber에게 값 방출
    • Presentation Layer에서 사용하기 때문에 main thread에서 observer를 호출
      • observer에게 새로운 값이 방출되면 observer는 ui를 바꾸기 때문에 메인쓰레드에서 value를 전해 줘야한다.

 

final class ExampleViewController: UIViewController {
    
    private var viewModel: MoviesListViewModel!
    
    private func bind(to viewModel: ViewModel) {
        self.viewModel = viewModel
        viewModel.items.observe(on: self) { [weak self] items in
            self?.tableViewController?.items = items
            // Important: You cannot use viewModel inside this closure, it will cause retain cycle memory leak (viewModel.items.value not allowed)
            // self?.tableViewController?.items = viewModel.items.value // This would be retain cycle. You can access viewModel only with self?.viewModel
        }
        // Or in one line
        viewModel.items.observe(on: self) { [weak self] in self?.tableViewController?.items = $0 }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        bind(to: viewModel)
        viewModel.viewDidLoad()
    }
}


protocol ViewModelInput {
    func viewDidLoad()
}

protocol ViewModelOutput {
    var items: Observable<[ItemViewModel]> { get }
}

protocol ViewModel: ViewModelInput, ViewModelOutput {}
  • ViewController에서 Data binding하는 예시
  • 클로저 내에서 viewmodel을 직접 사용하면 순환 참조가 생기는 이유
    • 클로저가 viewmodel을 캡쳐하기 때문이다.
      • 캡쳐
        • 클로저가 실행될 때 해당 변수나 상수의 값을 사용할 수 있도록 참조를 저장하는 것
        • 여러번 클로저가 실행되더라도 캡쳐된 변수의 참조는 첫번째 캡쳐를 사용한다.
    • 클로저는 사용하는 외부변수나 상수에 대한 참조를 캡쳐하여 저장하는데 이 경우 클로저가 viewModel의 참조를 캡처하게 된다.
    • viewModel의 items라는 observable이 observe라는 함수로 클로저를 강하게 참조하고 있기 때문에 서로가 서로를 강하게 참조하게 되어 순환 참조가 발생한다.
    • 클로저 내에서[weak self]를 사용하는 것은 순환참조를 방지하는 방법 중 하나이다.
    • [weak self]를 사용하면 클로저는 self에 대한 약한 참조를 캡쳐하게 된다.
    • 그러나 클로저 내부에서 self?.viewmodel을 사용하면 viewmodel에 접근하는 것이 여전히 안전하다.
    • self가 이미 약한 참조로 캡쳐되었기 때문에 viewModel을 통해 발생할 수 있는 추가적인 순환 참조가 방지되기 때문이다.
    • viewModel를 클로져 내에서 직접 사용하지 말고 [weak self]를 사용하여 self를 약하게 캡쳐하고 self?.viewModel을 통해 viewModel에 접근하는 방식은 순환 참조를 방지하는 안전한 방법이다.

 

TableViewCell에 대한 데이터 바인딩

final class MoviesListItemCell: UITableViewCell {

    private var viewModel: MoviesListItemViewModel! { didSet { unbind(from: oldValue) } }
  
    func fill(with viewModel: MoviesListItemViewModel) { 
        self.viewModel = viewModel
        bind(to: viewModel)
    }
    
    private func bind(to viewModel: MoviesListItemViewModel) {
        viewModel.posterImage.observe(on: self) { [weak self] in self?.imageView.image = $0.flatMap(UIImage.init) }
    }
    
    private func unbind(from item: MoviesListItemViewModel?) {
        item?.posterImage.remove(observer: self)
    }
}

 

통신 방법

  • ViewModel간의 통신 : Delegate pattern
    • A 화면 -> B화면에서 처리 후 다시 A화면으로 넘어오는 경우 -> delegate로 알림
    • B 화면에는 delegate?. 실행
    • A 화면에는 delegate를 구현

 

// Step 1: Define delegate and add it to first ViewModel as weak property
protocol MoviesQueryListViewModelDelegate: class {
    func moviesQueriesListDidSelect(movieQuery: MovieQuery)
}
...
final class DefaultMoviesQueryListViewModel: MoviesListViewModel {
    private weak var delegate: MoviesQueryListViewModelDelegate?
    
    func didSelect(item: MoviesQueryListViewItemModel) { 
        // Note: We have to map here from View Item Model to Domain Enity
        delegate?.moviesQueriesListDidSelect(movieQuery: MovieQuery(query: item.query))
    }
}

// Step 2:  Make second ViewModel to conform to this delegate
extension MoviesListViewModel: MoviesQueryListViewModelDelegate {
    func moviesQueriesListDidSelect(movieQuery: MovieQuery) {
        update(movieQuery: movieQuery)
    }
}
  • delegate pattern
    • 한 객체가 특정 작업을 다른 객체에 위임할 수 있도록 하는 디자인 패턴이다.
    • DefaultMoviesQueryListViewModel의 delegate 변수를 weak로 설정하여  순환 참조를 방지한다.
    • 왜냐하면 DefaultMoviesQueryListViewModel은 delegate로 MoviesListViewModel의 인스턴스를 가진다
    • 그리고 MoviesListViewModel은 DefaultMoviesQueryListViewModel의 인스턴스를 소유할 수 있다.
    • 왜냐하면 MoviesListViewModel이 영화목록화면을 제어하고 그 안에서 검색 쿼리 목록을 관리하는 DefaultMoviesQueryListViewModel인스턴스를 사용할 가능성이 있기 때문이다.

 

 

 

// MoviesQueryList.swift
// 1단계: 다른 ViewModel과 통신할 수 있는 action closure 정의(예: 쿼리가 선택되면 MovieList에 알림)
typealias MoviesQueryListViewModelDidSelectAction = (MovieQuery) -> Void

// 2단계: 필요할 때 액션 클로져를 부른다.
class MoviesQueryListViewModel {
    init(didSelect: MoviesQueryListViewModelDidSelectAction? = nil) {
        self.didSelect = didSelect
    }
    func didSelect(item: MoviesQueryListItemViewModel) {
        didSelect?(MovieQuery(query: item.query))
    }
}

// MoviesQueryList.swift
// 3단계: MoviesQueryListView가 보여질 때 이 액션 클로져를 매개변수로 전달해야합니다.(_ didSelect: MovieQuery) -> Void
struct MoviesListViewModelActions {
    let showMovieQueriesSuggestions: (@escaping (_ didSelect: MovieQuery) -> Void) -> Void
}

class MoviesListViewModel { 
    var actions: MoviesListViewModelActions?

    func showQueriesSuggestions() {
        actions?.showMovieQueriesSuggestions { self.update(movieQuery: $0) } 
        //or simpler actions?.showMovieQueriesSuggestions(update)
    }
}

// FlowCoordinator.swift
// 4단계: FlowCoordinator 내부에서, 우리는 두 개의 ViewModel 간의 통신을 연결하기 위해 액션 클로저를 self 함수로 주입합니다.
class MoviesSearchFlowCoordinator {

	// FlowCoordinator 내부에서 MoviesQueryListViewModel 초기화
	let moviesQueryListViewModel = MoviesQueryListViewModel(didSelect: { [weak self] query in
    	self?.moviesListViewModel.update(movieQuery: query)
	})
    func start() {
        let actions = MoviesListViewModelActions(showMovieQueriesSuggestions: self.showMovieQueriesSuggestions)
        let vc = dependencies.makeMoviesListViewController(actions: actions)  
        present(vc)
    }

    private func showMovieQueriesSuggestions(didSelect: @escaping (MovieQuery) -> Void) {
        let vc = dependencies.makeMoviesQueriesSuggestionsListViewController(didSelect: didSelect)
        present(vc)
    }
}
  • Coordinator를 이용한 통신
    • FlowCoordinator에 의해 할당되거나 주입되는 클로저를 이용
    • MoviesListViewModel이 액션 클로저인 showMovieQueriesSuggestions를 사용하여 MoviesQueriesSuggestions 뷰를 표시하는 방법
    • viewModel들의 통신
      • 사용자의 영화 쿼리 선택
        • 사용자가 MoviesQueryListViewModel이 관리하는 영화 쿼리 목록에서 특정 항목을 선택합니다.
        • 이 선택은 MoviesQueryListViewModel 내의 didSelect(item:) 함수를 통해 처리됩니다.
      • 액션 클로저 호출
        • 사용자의 선택을 처리하는 과정에서 MoviesQueryListViewModel은 미리 정의된 didSelect 액션 클로저를 호출한다.
        • 이 클로저는 MoviesListViewModel에서 정의된 행동을 실행하는데 사용된다.
        • 즉, MoviesQueryListViewModel에서 발생한 사용자의 선택 이벤트가 MoviesListViewModel에 전달되어 처리도니다.
      • MoviesListViewModel에서의 처리
        • MoviesListViewModel에서는 showQueriesSuggestions() 함수를 통해 영화 쿼리 제안 화면을 표시할 준비를 한다. 
        • 이 함수 내에서 action?.showMovieQueriesSuggestions 클로저를 호출하며 이 클로저는 MoviesSearchFlowCoordinator에 의해 MoviesListViewModel 내의 update(movieQuery:)함수를 호출하여 선택된 쿼리를 처리한다.

 

 

 

DI container

  • 개념
    • 한 개체가 다른 개체의 종속성을 제공하는 방법

 

// Define Dependencies protocol for class or struct that needs it
protocol MoviesSearchFlowCoordinatorDependencies  {
    func makeMoviesListViewController() -> MoviesListViewController
}

class MoviesSearchFlowCoordinator {
    
    private let dependencies: MoviesSearchFlowCoordinatorDependencies

    init(dependencies: MoviesSearchFlowCoordinatorDependencies) {
        self.dependencies = dependencies
    }
...
}

// Make the DIContainer to conform to this protocol
extension MoviesSceneDIContainer: MoviesSearchFlowCoordinatorDependencies {}

// And inject MoviesSceneDIContainer `self` into class that needs it
final class MoviesSceneDIContainer {
    ...
    // MARK: - Flow Coordinators
    func makeMoviesSearchFlowCoordinator(navigationController: UINavigationController) -> MoviesSearchFlowCoordinator {
        return MoviesSearchFlowCoordinator(navigationController: navigationController,
                                           dependencies: self)
    }
}
  • 방법 1
    • DI Factory protocol을 이용
      • Dependencies 프로토콜로 정의
      • MoviesSceneDIContainer가 이 프로토콜을 따르도록 적용
      • MoviesSearchFlowCoordinator 주입

 

// Define makeMoviesListViewController closure that returns MoviesListViewController
class MoviesSearchFlowCoordinator {
   
    private var makeMoviesListViewController: () -> MoviesListViewController

    init(navigationController: UINavigationController,
         makeMoviesListViewController: @escaping () -> MoviesListViewController) {
        ...
        self.makeMoviesListViewController = makeMoviesListViewController
    }
    ...
}

// And inject MoviesSceneDIContainer's `self`.makeMoviesListViewController function into class that needs it
final class MoviesSceneDIContainer {
    ...
    // MARK: - Flow Coordinators
    func makeMoviesSearchFlowCoordinator(navigationController: UINavigationController) -> MoviesSearchFlowCoordinator {
        return MoviesSearchFlowCoordinator(navigationController: navigationController,
                                           makeMoviesListViewController: self.makeMoviesListViewController)
    }
    
    // MARK: - Movies List
    func makeMoviesListViewController() -> MoviesListViewController {
        ...
    }
}
  • 방법 2
    • 클로저 이용 : 주입이 필요한 클래스 내부에서 클로저를 참조하고 있다가, 이 클로저를 주입하는 상태

 

Clean Architecture 사용하는 이유

  • 대규모 프로젝트
    • 프로젝트의 규모가 커짐에 따라 코드의 구조화와 명확한 책임 분리가 중요해진다.
    • 대규모 프로젝트에서 클린 아키텍쳐는 유지보수와 확장성에 큰 이점을 제공한다.
  • 팀 작업과 확장성
    • 여러 개발자가 협업하는 경우에 용이
    • 장기적으로 관리 및 확장해야하는 경우
  • 테스트와 재사용성
    • 계층화된 아키텍쳐는 독립적인 테스틍와 코드의 재사용성을 높일 수 있다.

 

MVVM패턴의 사용 이점

  • 결합도 감소
    • ViewModel은 뷰와 모델 사이의 중개자 역할을 하여 뷰가 모델을 직접 참조하지 않도록 합니다.
    • 뷰와 비즈니스 로직이 분리되어 각각 독립적으로 개발 및 수정될 수 있습니다.
  • 테스트 용이성
    • ViewModel은 UI코드와 분리되어 있기 때문에 UI 요소 없이도 쉽게 테스트할 수 있다.
    • 이는 유닛 테스트를 통해 애플리케이션 로직의 정확성을 검증하는데 유리하다.
  • 재사용성 및 유지 보수성 향상
    • ViewModel은 재사용 가능한 방식으로 UI로직을 캡슐화한다.
    • 코드의 중복을 줄이고, 유지보수를 용이하게 만든다.
  • 반응형 프로그래밍 지원
    • MVVM은 데이터 바인딩과 반응형 프로그래밍에 잘 어울린다.
    • 데이터 변경이 자동으로 뷰에 반영될 수 있게하여 뷰와 데이터 사이의 동기화를 쉽게 관리할 수 있다.

 

MVVM

  • 클린 아키텍쳐와 같이 사용하면 Presentation과 UI Layer 사이의 관심사 분리를 돕는다.
  • 하나의 viewModel은 다양한 view에 적용될 수 있다.
  • 또한 한 뷰는 UIKit으로 만들고 다른 뷰는 SwiftUI로 만들 수도 있다.
  • ViewModel에서 UIKit, WatchKit, SwiftUI를 import 하지 않는 것이 중요하다.
  • View와 ViewModel 사이의 데이터 바인딩은 클로저, delegate, observables, Combine, SwiftUI로 할 수 있다.
  • View는 ViewModel에 직접적인 관계를 가지고 있으며 View에서 이벤트가 발생했을 때 ViewModel에 알린다.
  • 하지만 ViewModel에서는 View를 직접 참조하지 않는다. 

 

클로저

  • 클로저와 didSet을 사용해서 third-party 의존성을 없애는 것을 해보자
public final class Observable<Value> {
    
    private var closure: ((Value) -> ())?

    public var value: Value {
        didSet { closure?(value) } // 값이 변경되면 전달받은 클로저를 호출할 겁니다
    }

    public init(_ value: Value) {
        self.value = value
    }

    public func observe(_ closure: @escaping (Value) -> Void) {
        self.closure = closure
        closure(value)
    }
}
  • 위 코드는 Observable을 굉장히 간단화한 버전이다.
  • UI를 포함한 Presentation Layer에 의해 observe가 실행되므로 observer의 block을 메인 쓰레드에서 실행시킨다.
  • 아래는 ViewController에서 데이터 바인딩을 하는 예제이다.
final class ExampleViewController: UIViewController {
    
    private var viewModel: MoviesListViewModel!
    
    private func bind(to viewModel: ViewModel) {
        self.viewModel = viewModel
        viewModel.items.observe(on: self) { [weak self] items in
            self?.tableViewController?.items = items
            // Important: You cannot use viewModel inside this closure, it will cause retain cycle memory leak (viewModel.items.value not allowed)
            // self?.tableViewController?.items = viewModel.items.value // This would be retain cycle. You can access viewModel only with self?.viewModel
        }
        // Or in one line
        viewModel.items.observe(on: self) { [weak self] in self?.tableViewController?.items = $0 } // observable 객체의 값이 변경되면 이 클로저를 실행시킬겁니다
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        bind(to: viewModel) 
        viewModel.viewDidLoad()
    }
}


protocol ViewModelInput {
    func viewDidLoad()
}

protocol ViewModelOutput {
    var items: Observable<[ItemViewModel]> { get }
}

protocol ViewModel: ViewModelInput, ViewModelOutput {}
  • Observing block에서 viewModel에 접근하면 리테인 사이클로 인해 memory leak이 발생하므로, viewModel에 접근할 때는 오로지 self를 이용해서 self?.viewModel로 접근해야한다.
  • 아래는 TableCell에서 데이터를 바인딩 하는 것이다.
  • 만약 view(UITableViewCell)가 재사용이 가능하다면 unbind 해줘야 한다.
final class MoviesListItemCell: UITableViewCell {

    private var viewModel: MoviesListItemViewModel! { didSet { unbind(from: oldValue) } }
  
    func fill(with viewModel: MoviesListItemViewModel) { 
        self.viewModel = viewModel
        bind(to: viewModel)
    }
    
    private func bind(to viewModel: MoviesListItemViewModel) {
        viewModel.posterImage.observe(on: self) { [weak self] in self?.imageView.image = $0.flatMap(UIImage.init) }
    }
    
    private func unbind(from item: MoviesListItemViewModel?) {
        item?.posterImage.remove(observer: self)
    }
}

 

delegate

// ViewModel의 Delegate 프로토콜 정의
protocol ViewModelDelegate: AnyObject {
    func dataDidUpdate(data: String)
}

// ViewModel 구현
class ViewModel {
    weak var delegate: ViewModelDelegate?
    
    func updateData() {
        let data = "Updated Data"
        delegate?.dataDidUpdate(data: data)
    }
}

// View 구현
class View: ViewModelDelegate {
    var viewModel = ViewModel()
    
    init() {
        viewModel.delegate = self
    }
    
    func dataDidUpdate(data: String) {
        print("Data updated: \(data)")
    }
}
  • 델리게이트는 객체 간의 커뮤니케이션을 위한 디자인 패턴입니다. ViewModel이 변경사항을 View에 알리기 위해 델리게이트 프로토콜을 정의하고, View가 이 프로토콜을 준수하도록 할 수 있습니다. ViewModel에서 데이터가 변경되면, 델리게이트 메서드를 호출하여 View에 변경사항을 알립니다.

 

Observable

import RxSwift

// ViewModel 구현
class ViewModel {
    var data: BehaviorSubject<String> = BehaviorSubject(value: "Initial Data")
    
    func updateData() {
        data.onNext("Updated Data")
    }
}

// View 구현
class View {
    var viewModel = ViewModel()
    let disposeBag = DisposeBag()
    
    init() {
        viewModel.data.subscribe(onNext: { data in
            print("Data updated: \(data)")
        }).disposed(by: disposeBag)
    }
}
  • 옵저버블은 객체의 상태 변화를 관찰하는 패턴입니다. ViewModel 내의 데이터를 옵저버블 객체로 만들면, View에서 이를 구독(subscribe)하여 데이터 변화를 실시간으로 감지하고 UI를 업데이트할 수 있습니다. 이 방식은 일반적으로 RxSwift와 같은 리액티브 프로그래밍 라이브러리에서 사용됩니다.

 

combine

import Combine

// ViewModel 구현
class ViewModel: ObservableObject {
    @Published var data = "Initial Data"
    
    func updateData() {
        data = "Updated Data"
    }
}

// SwiftUI View 구현
import SwiftUI

struct ContentView: View {
    @ObservedObject var viewModel = ViewModel()
    
    var body: some View {
        Text(viewModel.data)
            .onAppear {
                viewModel.updateData()
            }
    }
}
  • Combine은 애플이 iOS 13 및 macOS 10.15부터 도입한 리액티브 프로그래밍 프레임워크입니다. Combine을 사용하면, ViewModel의 데이터 변화를 Publisher로 방출하고, View에서 Subscriber를 통해 이를 구독하여 UI를 업데이트할 수 있습니다. Combine은 옵저버블과 유사한 개념을 제공하지만, 애플의 표준 API와 긴밀하게 통합됩니다.

 

swiftUI

import SwiftUI

// ViewModel 구현
class ViewModel: ObservableObject {
    @Published var data = "Initial Data"
}

// SwiftUI View 구현
struct ContentView: View {
    @ObservedObject var viewModel = ViewModel()
    
    var body: some View {
        VStack {
            Text(viewModel.data)
            Button("Update Data") {
                viewModel.data = "Updated Data"
            }
        }
    }
}
  • SwiftUI는 선언적인 방식으로 UI를 구성하는 애플의 최신 프레임워크입니다. SwiftUI에서는 @State, @Binding, @ObservableObject와 같은 프로퍼티 래퍼를 사용하여 데이터 바인딩을 구현합니다. 예를 들어, @ObservableObject를 준수하는 ViewModel을 View에서 참조하고, ViewModel 내의 @Published 프로퍼티가 변경될 때마다 View가 자동으로 업데이트됩니다.