12. Clean Architecture (iOS)
이글은 챗지피티를 사용하였고 아래의 블로그를 참고 했습니다.
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
- 클린 아키텍쳐를 도입한 앱 예제
- 영화를 검색하는 앱
- 영화를 검색하면 해당 단어가 들어가는 영화를 보여주고, 스크롤하면 더 불러오기까지 된다.
- 특정 영화를 누르면 해당 영화의 사진, 제목, 설명을 보여주는 화면으로 이동하게 된다.
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
- Repository Interface는 도메인 레이어에 속했다.
- Data Source는 원격이나 로컬일 수 있다.
- Data Source
- 데이터 검색 및 저장을 직접 관리하는 코드를 나타낸다.
- Data Source
- 원격일 경우는 API 호출을 통해 Json 데이터를 내려 받는 경우가 되고, 로컬 일때는 데이터 베이스 일 것이다.
- Data Layer는 오로지 도메인 레이어에만 의존하고 있다.
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
- View(UI)가 ViewModel(Presenter)의 메서드를 호출한다.
- ViewModel이 UseCase를 실행시킨다.
- Use Case가 User와 Repositories에서 데이터를 취합한다.
- 각 Repository는 Remote Data(NW), Persistent DB저장소나 메모리 데이터를 반환한다.
- 반환된 데이터가 우리가 아이템들을 화면에 출력할 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)를 외부에서 주입하는 기법이다.
- 의존성 역전 원칙(DIP)
- 이 두 원칙을 적용함으로써, 높은 모듈성, 낮을 결합도, 더 나은 테스트 가능성을 달성할 수 있다.
- 모듈성과 재사용성
- 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하기 전 마지막 설정
- endPoint
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에 접근하는 방식은 순환 참조를 방지하는 안전한 방법이다.
- 클로저가 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 주입
- DI Factory protocol을 이용
// 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가 자동으로 업데이트됩니다.