SwiftUI์์๋ @ObservableObject ๋๋ถ์ ViewModel(์ญํ ์ ํ๋?)์ ์ฝ๊ฒ ๊ตฌํํ ์ ์๋ค. ๋๋ถ์ ์ค์ต์ ์งํํ๋ฉด์ ๋๋ถ๋ถ์ ํ๋ก์ ํธ์์ ViewModel๋ก ๋ถ๋ฆฌ๋ ํ์ง๋ง ๋ฌธ์ ๊ฐ ๋ง์๋ค. ์ผ๋จ SwiftUI๊ฐ View ์์ฒด์ ์ผ๋ก Data Binding์ด ๊ฐ๋ฅํ๊ธฐ ๋๋ฌธ์ ์ด๋ฏธ ViewModel์ด ๋
น์๋ค์ด๊ฐ ๋๋์ด๋ค. ํ์ง๋ง ์ด๊ฒ๋ณด๋ค๋ ํ๋์ ViewModel์์ ์ฌ๋ฌ ์์
์ ์งํํ๋ค๋ณด๋ Massive ViewModel์ด๋๊น? ViewModel์ด ๋น๋ํด์ง ๋๋์ด ๋ค์๊ณ , ๊ทธ๋์ ํด์ปคํค ๋๋ ์ด๋ฐ ๊ฒ๋ค์ ๊ฑท์ด๋ด๊ณ ์ํคํ
์ณ์ ์ผ๋ก ์กฐ๊ธ ๋ ๊ด์ฐฎ์ ์ฑ์ ๊ตฌํํ๊ณ ์ถ์๋ค. ๋คํํ๋ ํ์ ์ค ํ ๋ถ์ด ํด๋ฆฐ ์ํคํ
์ณ์ ๋ํด ์ ์๊ณ ๊ณ์
์ ๋์์ ๋ง์ด ๋ฐ์๊ณ , ์ ๋ณด๋ค๋ ์ด๋์ ๋ ์์ฑ๋ ์ฑ์ ๋ง๋ค ์ ์์๋ค.
์๋ก ์ด ๊ธธ์์ง๋ง ์๋ฌดํผ ํด์ปคํค์ ๊ณ๊ธฐ๋ก Clean Architecure์ ๋ํด ๋ค์ ๊ณต๋ถํ๊ณ ์ถ์ด์ ์ ์ ๋ถ๋งํฌํด๋ ๊ธ์ ๋ณด๋ฉด์ ๊ฐ๋ ์ ์ ๋ฆฌํด๋ณด๋ ์๊ฐ์ ๊ฐ์ก๋ค.
Clean Architecture
์ฐ์ ์ด๋ฐ ํํ๋ฅผ ๊ฐ์ง๋ค.
์์กด ๊ด๊ณ๋ ์์ชฝ์ผ๋ก ํฅํ๊ฒ๋ ์ค๊ณ๋ฅผ ํ๊ณ , ์์ชฝ์ ์๋ ์์๋ค์ ๋ฐ๊นฅ์ชฝ์ ์๋ ์์์ ์กด์ฌ๋ฅผ ๋ชจ๋ฅธ๋ค.
์๋ฅผ ๋ค์ด, Presenters์ ํด๋นํ๋ ViewModel์ Domain์ ํด๋นํ๋ Usecase์ ์์กด์ ์ด์ง๋ง, (์์ฑ์๋ฅผ ํตํด ์ ๋ฌ)
Domain์ ViewModel์ ๋ํด ์ ํ์๊ฐ ์๋ค.
์ข ์์ฑ ๋ฐฉํฅ
- Presentation Layer → Domain Layer ← Data Layer
- Presentation Layer๋ Domain Layer์ ์์กด
- Domain Layer๊ฐ Data Layer์ ์์กด → ์ถ์ํ๋ Repository์ ์์กดํ๋ฏ๋ก ์์กด์ฑ ์ญ์
๋ฐ์ดํฐ ํ๋ฆ
- View๋ ViewModel์์ ๋ฉ์๋๋ฅผ ํธ์ถํ๋ค.
- ViewModel์ UseCase๋ฅผ ์คํํ๋ค.
- UseCase๋ User์ Repository์ ๋ฐ์ดํฐ๋ฅผ ๊ฒฐํฉํ๋ค.
- Repository๋ DB, Network์ ์ฐ๊ฒฐ๋์ด ๋ฐ์ดํฐ๋ฅผ ๋ฐํํ๋ค.
- ์ด๋ ๊ฒ ํ ์ชฝ ๋ฐฉํฅ์ผ๋ก ํ๋ฅธ ํ๋ก์ฐ๋ ๋ค์ ๋ฐ๋๋ก ํ๋ฌ๊ฐ๋ค. (๋จ๋ฐฉํฅ, ์ฝ๋ฐฑ)
Domain Layer
- Entity, Usecase
- Entity : ์ฐ๋ฆฌ๊ฐ ์ง๊ธ๊น์ง Model๋ก ์์ฑํ ๊ฒ๋ค
- Usecase: ์ํํธ์จ์ด ๊ณตํ์์ ๊ธฐ๋ฅ ๋จ์๋ก ์ค๋ช ํ๋๋ฐ ๊ฑฐ์ ๋น์ทํ๋ค๊ณ ๋ณผ ์ ์์. ์ ์ฌ์ง์์ ๋ณผ ์ ์๋ฏ, ์ํ ๊ฒ์(Movie Search) ์ ์ค์ผ์ด์ค, ์ต์ ์ํ ๋ชฉ๋ก?(Fetch Recent Movie Queries) ์ ์ค์ผ์ด์ค์ฒ๋ผ ๊ธฐ๋ฅ์ ๋ํ ๋จ์
์๋๋ก์ด๋ ๊ณต์ ๋ฌธ์์ด์ง๋ง, ์ํคํ ์ณ์ ์ธ ๊ด์ ์์๋ iOS์ ๋์ผํ๋ฏ๋ก ์ฒจ๋ถํ๋ค.
Domain Layer์ ๋ํ ํน์ง์ด ๋์์๋ค.
๐ ๊ทผ๋ฐ ์ ์ค์ผ์ด์ค๋ฅผ ๊ผญ ์จ์ผํ๋์?
→ Clean Architecture๋ฅผ ์ ํ๊ธฐ ์ ๊น์ง๋ง ํ๋๋ผ๋ ๋๋ ๊ฐ์ ์๊ฐ์ด์๋ค. ์ฌ๋ฌ ๊ธ์ ์ฐธ๊ณ ํ๋ฉด์ ๋ด๋ฆฐ ๊ฒฐ๋ก ์ ์ธํฐํ์ด์ค์ ์ธ ๊ฐ๋ ์ด ์ ์ฉ๋๋ค๋ ๊ฒ์ด๋ค. ๋ฌผ๋ก ์ด๋ ๊ฒ ๋๋์ง ์๊ณ , Presentation๊ฐ Repository๋ฅผ ์ง์ ์ฐธ์กฐํด๋ ์๊ด์ ์๋ค๊ณ ์๊ฐํ๋ค.
๊ทธ๋ ๋ค๋ฉด ์ด๋ค ์ฅ์ ์ด ์๊ธฐ์, ์ด๋ ๊ฒ ๋ถ๋ฆฌ๋ฅผ ํ ๊ฒ์ผ๊น?
์ผ๋จ, ์ง๊ด์ ์ด๋ค. ViewModel๋ง ๋ฑ ๋ดค์ ๋, ๊ธฐ๋ฅ ๋จ์๋ก ๋ถ๋ฅํด๋์ Usecase๋ฅผ ๋ณด๊ณ ๋ฐ๋ก ์ดํดํ ์ ์๊ฒ ๋๋ค.
๋ค์ํ Repository Layer๋ฅผ ์ฐธ์กฐํ์ง ์๊ณ , ๊ธฐ๋ฅ์ ๋ํ ์ต์ ๋จ์์ธ Usecase ์ฐธ์กฐํ๊ณ ์์ผ๋ฏ๋ก ViewModel์์ ์ด๋ค ๊ธฐ๋ฅ(์๋น์ค)๋ฅผ ์ ๊ณตํ๊ณ ์๋์ง ๋ช ํํ๊ฒ ์ ์ ์๋ค.
๋ํ ๊ธฐ๋ฅ์ด ํ์ฅ๋จ์ ๋ฐ๋ผ ํ๋์ Usecase์์ ๋ค์ํ Repository๋ฅผ ์ฐธ์กฐํ๋ ๊ฒฝ์ฐ๊ฐ ๋ฐ์ํ ๊ฒ์ด๋ค. ํ์ง๋ง ViewModel์? Usecase๊ฐ ์ด๋ค Repository์ ์ฐ๊ฒฐ๋์ด ์๋์ง ์ ํ์๊ฐ ์๋ค. ๊ทธ๋ฅ ๋ด๊ฐ ๊ตฌํํ๋ ค๋ ๊ธฐ๋ฅ์ ํด๋นํ๋ Usecase๋ฅผ ๋ฐ๋ผ๋ณด๋ฉด ๋๋ค.
๊ฐ๋ น, ํน์ ๊ธฐ๋ฅ์ ๋ํ ๋ด๋ถ์ ์ธ ๋ก์ง์ด ์กฐ๊ธ ๋ณ๊ฒฝ๋ ํ์๊ฐ ์๋ค๊ณ ๊ฐ์ ํด๋ณด์. Usecase๊ฐ ์๋, Repository ๋ฅผ ์ง์ ์ฐธ์กฐํ๊ฒ ๋๋ค๋ฉด Repository ์์ ์ ๋ฐ๋ฅธ ViewModel์ ์์ ์ด ๋ถ๊ฐํผํ ๊ฒ์ด๋ค. ๊ทธ๋ฐ๋ฐ Usecase๋ฅผ ์ฐธ์กฐํ๋ค๋ฉด? VIewModel์ ๊ทธ๋ฅ ๊ฐ๋งํ ์์ด๋ ๋๋ค. ๋น์ฆ๋์ค ๋ก์ง์ ๋ด๊ณ ์๋ Usecase ๋ด์์ ์์ ์ด ์ผ์ด๋๊ธฐ ๋๋ฌธ์ด๋ค.
์ค๋ช ์ด ๊ธธ์์ง๋ง ๊ฒฐ๋ก ์ ๋ค๋ฅธ Layer๋ค์ด ์ด๋ค ์ผ์ ํ๊ณ ์๋์ง ์ ํ์๊ฐ ์ ํ ์๋ค๋ ๊ฒ์ด๋ค.
์ง์ ์ ์ผ๋ก ๋ง๋ฟ์์๋ ์์ชฝ Layer๋ง ์๊ณ ์์ผ๋ฉด ๋๋ค.
๋ด ์์ง, ์์ง์ ๋๊ฐ ์ด๊ณ ์๋์ง๋ง ์๋ฉด ๋๋ค๋ ์
๊ฒฐ๋ก ,
- ์ ์ง๋ณด์ ์ฉ์ด
- ์ง๊ด์ ์ธ ์๊ตฌ์ฌํญ
- ์์กด์ฑ ์ต์ํ
Presentation Layer
- ViewModel์๋ import UIkit, SwiftUI๊ฐ ์์ด์ผ ํ๋ค. (๋ชจ๋ UI ํ๋ ์์ํฌ์์ ๋จ์ )
- ์ด๋ ๊ฒ ๊ตฌํํ๋ฉด UIKit → SwiftUI ์ ํ์ด ๋งค์ฐ ์ฉ์ดํด์ง๋ค. ๋ค๋ฅธ ๋ถ๋ถ์ ์ ๊ฒฝ์ฐ์ง ์๊ณ UI๋ง ๋ณ๊ฒฝํด์ฃผ๋ฉด ๋๊ธฐ ๋๋ฌธ์ด๋ค.
1. ViewModel
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 { }
- ํ๋กํ ์ฝ ๋จผ์ ์ ์ธํด์ฃผ๊ณ , ํ๋กํ ์ฝ์ ์ฑํํ์ฌ ์ ์ํ๋ค.
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: "")
}
}
}
}
- ์์ฑ์๋ก Domain Layer(Usecase)์ ์ธ์คํด์ค๋ฅผ ์ ๋ฌ๋ฐ๋๋ค.
// 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 ?? ""
}
}
- ๋ํ Model์ ์ง์ ์ ์ผ๋ก ์ฐธ์กฐํ๋ฉด ์๋๋ค. View๊ฐ ์ง์ ์ ์ผ๋ก ์ ๊ทผํ๋ฉด ์๋๊ธฐ ๋๋ฌธ์ด๋ค. (์ฒ์์ ๊ทธ๋ฆผ์ ์๊ฐํ ๊ฒ)
2. ViewController
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)
}
}
- UI๋ ๋น์ฆ๋์ค ๋ก์ง์ด๋ ์ดํ๋ฆฌ์ผ์ด์ ๋ก์ง์ ์ง์ ์ ์ผ๋ก ์ ๊ทผํ ์ ์๊ณ , ํญ์ ViewModel์๋ง ์ ๊ทผํ ์ ์๋ค.
- ์๊น ๋งํ๋ ๊ฒ์ฒ๋ผ ViewModel์ Entity์ ํด๋นํ๋ ํ๋กํผํฐ(items)๋ฅผ ๋ณ๋๋ก ๋ฌ์ View์์ Entity์ ๋ํด ์ ํ์๊ฐ ์๊ฒ ๋ง๋ค์๋ค.
- ๋ํ View์์ ViewModel์ Observingํ๋ ํํ๋ก ๊ตฌํํ๋ค.
- weak self๋ฅผ ํตํด ์ํ ์ฐธ์กฐ๋ฅผ ๋ฐฉ์งํ๋ค.
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)
}
}
- View์ ViewModel ์ฌ์ด์ Coordinator๋ฅผ ์ถ๊ฐํด์ ViewModel์ ์์ ํ์ง ์๊ณ ๋ค๋ฅธ ๋ทฐ๋ฅผ ์ฝ๊ฒ ์ฌ์ฉํ ์ ์๊ฒ ๋ง๋ค์ด ์ค๋ค.
- ๋ํ ViewController์ ๋ํ ์ฑ ์์ ์ค์ฌ์ฃผ๋ ์ญํ ์ ํ๋ค.
- Delegate๋ ๋น์ทํ๋ฏ? Coordinator๋ ์ข ๋ ์์๋ด์ผ๊ฒ ๋ค.
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() })
}
}
...
- Usecase๊ฐ ์์กดํ๊ณ ์๋ Repository๊ฐ ์๋ค.
- DTO(Data Transfer Object)๊ฐ ์ ์๋์ด ์๋ค.
- DB, Network๋ก ๋ฐ์ดํฐ๋ฅผ ๊ฐ์ ธ์ค๊ณ , Domain์ ๋งคํ๋๋ค. (completion์ผ๋ก ์ ๋ฌ, Usecase๋ฅผ ๊ฑฐ์ณ ์ต์ข ์ ์ผ๋ก ViewModel๊น์ง)
๊ฐ๋ ์ ์ผ๋ก๋ ์ดํด๋ฅผ ํ์ง๋ง ์ค์ ๋ก ์จ๋ณด๋ฉด์ ์๋๋ค์ด๋ ๋ ์นํด์ ธ์ผ๊ฒ ๋ค.