[iOS] Clean Architecture

강치우·2024년 5월 12일
1

수입푸드

목록 보기
12/13

Clean Architecture


SW의 구조를 계층화하여 나눔으로서 관심사를 분리한 구조.
이 아키텍처가 가능하게 하는 중요한 요소는 의존규칙.

이 규칙에 의해서 아키텍처의 계층화가 가능해지며 각 계층별 책임이 분리될 수 있다.

또 클린아키텍처에서 알아야할 것은 뭘까?

바깥으로 갈수록 저수준의 컴포넌트, 가운데로 갈수록 고수준의 컴포넌트가 있다.

안 쪽 Circle들은 바깥 쪽 Circle에 대해서 몰라야한다.
즉, 안쪽에서 바깥쪽으로 의존하면 안됩니다.내부일수록 중요하고, 변화가 없어야한다는 의미다.

일단, Domain은 회사 정책이 바뀌지 않는 이상 그대로 유지해야 되는 것 들이고, Network나 DB는 언제든 바뀔 수 있으니깐 Repository를 구현하고 Domain 계층에 대한 변경도를 낮추는 방식이다.


만약에 Repository가 없다면?

Repository가 없고, 바로 UseCase에서 Network, DB에 접근한다면?

Network, DB Code가 변경되면 UseCase에 포함된 비즈니스 로직에 영향을 많이 끼치게 된다.



Clean Architecture의 구조


클린아키텍처의 구조는 크게 3 영역으로 나뉘게 된다.

위와 같이, Present / Domain / Data Layer로 나뉘게 된다.

Presentation Layer (Domain Layer을 의존)

화면에 보이는 영역을 담당하는 레이어
MVVM에서는 ViewView Model이 여기에 해당함.
또한 사용자의 이벤트에 대한 처리를 담당한다.


그러면 왜 ViewModel이 Present Layer에 속하게 될까?

이제 ViewModel은 비즈니스 로직을 제외하고, 화면에 필요한 데이터에만 집중하게 된다.
이제 View, ViewController는 화면을 그리는 역할, ViewModel은 View에 그려질 데이터를 만드는 역할만 수행하게 된다.


Domain Layer

Business Logic이 담겨있는 레이어. 최대한 변경을 지양해야하는 레이어다.
Entities, UseCases, Repository Interface들이 여기에 해당하게 된다.

위 그래프의 가장 안쪽에 위치한 부분이다.
Entities는 가장 안쪽에 있으므로 바깥쪽을 전혀 모르고, 다른 객체를 의존하지 않는 계층이다.
도메인 레이어는 다른 레이어(Presentation의 SwiftUI/UIKit)등을 포함하면 안된다.


Data Layer (Domain Layer을 의존)

Repository Implementation 와 하나 이상의 Data Source를 포함한다.
Repositories Implementation(구체타입), API(Network), Persistence DB들이 여기에 속하게 된다.

(위에서도 기재되어있지만 Repository Interface는 Data Layer가 아닌 Domain Layer에 속한다.)

Data Source는 Network이나 Local이다. (Server, CoreData, Realm)

위의 구조를 간단하게 화살표로 표시하면 아래처럼 된다.

서버, DB ← Entity → Repo(접근 역할) ← Entity → UseCase(BL) ← Model → Present


이 그림에서 Dependency Direction과 Data flow - Request/Response로 각 레이어를 표시하고 있다.

Repository Interface(프로토콜)을 사용하는 지점에서 DI/의존성 역전이 일어나는 것을 볼 수 있다.

그에 대한 데이터 흐름 예제는 아래와 같다.

DataFlow(데이터 흐름)

  1. View(UI)가 ViewModel(Presenter)의 메서드를 호출한다.

  2. ViewModel이 UseCase를 실행시킨다.

  3. Use Case가 UserRepositories에서 데이터를 가져온다.

  4. 각 Repository는 Remote Data(Network), Persitent DB 저장소, In-memory Data(원격 또는 Cached)에서 데이터를 반환한다.

  5. 반환된 데이터가 우리가 아이템들을 화면에 출력할 View에 전달한다.


Dependency Direction(종속성 방향)

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

Presentation 레이어(MVVM) = ViewModel(Presenters) + Views(UI)

Domain 레이어 = Entities + Use Cases + Repositories Interface(프로토콜)

Data Repositories 레이어 = Repositories Implementations(구현) + API(Network) + Persitence DB



예시


GitHub를 참고하면서 한번 코드를 보자.


Domain Layer

최상단 링크로 설정해둔 GitHub프로젝트를 보면 도메인 레이어를 찾을 수 있다.

이 레이어에는 Entity와 영화 검색 및 최근 검색 쿼리를 저장하는 UseCase를 가지고 있다. 또한 DI(Dependency Inversion, 의존성 역전)에 필요한 Data Repository Interface를 가지고 있다.

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)
}

Use Case들을 생성하는 또다른 방법은 UseCase프로토콜의 start()함수를 이용하고 모든 Use Case들이 이 프로토콜을 구현하도록 하면 된다.

예제 프로젝트에서 이 방법을 사용하는 Use Case중 하나는 FetchRecentMovieQueriesUseCase이다. Use Case는 Interactor라고도 불린다.


Presentation Layer

MovieListViewModel이 있고, 이 안의 아이템들은 MoviewListView에서 출력할 것이다.

MoviewListViewModel은 UIKit을 포함하지 않는다.

ViewModel을 UIKit, SwiftUI, WatchKit과 같은 UI프레임워크와 분리시켜 놓으면 재사용하고 리팩토링 하기 쉽다.

ViewModel은 UIKit이나 SwiftUI와 별개이기 때문에 리팩토링이 더 쉬워질 것이다.

// 주의: UIKit이나 SwiftUI와 같은 UI 프레임워크를 import할 수 없습니다.
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: 만약 Details 화면에서 영화를 수정하고 이를 업데이트한 후
    // MoviesList 화면에서 업데이트 된 영화를 보려면 이 클로저가 필요합니다:
    // 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):
                // 주의: 여기서는 Domain Entities를 Item View Models로 매핑해야 합니다. Domain과 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])
    }
}

// 주의: 이 아이템 뷰 모델은 데이터를 표시하기 위한 것이며, 뷰가 이를 액세스하지 않도록 도메인 모델을 포함하지 않습니다.
struct MoviesListItemViewModel: Equatable {
    let title: String
}

extension MoviesListItemViewModel {
    init(movie: Movie) {
        self.title = movie.title ?? ""
    }
}

MoviesListViewModelInputMoviesListViewModelOutput을 만들어서 MoviewsListViewModel를 mocking을 통해 테스트 하기 쉽게 만들었다.

또한, MoviesListViewModelActions클로저를 갖고있고, 이 클로저는 MoviesSearchFlowCoordinator에게 다른 뷰들을 언제 출력할지 알려준다.

action클로저는 Coordinator가 영화 디테일 화면을 출력할 때 호출된다.
여기서 action들의 그룹을 구조체로 묶어서 주는데 이는 이후에 더 많은 action들이 필요하게 될 경우 쉽게 추가하기 위해서이다.

UI는 비즈니스 로직이나 앱 로직에 접근할 수 없고 ViewModel만이 여기에 접근할 수 있다.

관심사의 분리를 적용한 것이다.

비즈니스 모델을 바로 View에 전달할 수 없다.
이게 ViewModel에서 비즈니스 모델을 ViewModel로 맵핑해서 View에 전달하는 이유이다.


그리고 나서 View에서 ViewModel에게 영화 찾는것을 시작하라는 호출을 추가했다.

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)
    }
}


여기서 부터는 또 다른 내 궁금증이다.

Repository가 왜 필요한가?

UseCase랑 DB, Network 코드랑 붙어 있으면 Network, DB 바뀔때마다 기존의 Domain 로직에 계속 영향이 가고 변경점이 너무 많아져서 코드가 산으로 가게 됨.

그래서 Repository 층을 놔서 Network, DB 바뀌면 딱 Repository 구체타입까지만 변경이 미치도록 한 것 같다는 생각이 듬.

즉, Repository가 없다면 DB, Network가 바뀌면 UseCase가 바뀌어야함.

그리고 ViewModel도 바뀌게됨. → 너어무 많이 바뀌게 됨.

하지만 중간에 Repository 구체타입이 변경된 DB, Network 코드에 대응 한다면 큰 변경을 막을 수 있다.
요렇게 생각을 하고 있음.


왜 Repository Interface는 Domain Layer에 속해있나?

UseCase는 Repository 구체 타입이 아닌 Interface를 바라보고 있음.

만약 UseCase가 바뀌지 않는 이상 Repository Interface들도 바뀔 필요가 없기 때문에 Domain Layer에 속하는 것이라고 생각함.


왜 Data가 Domain을 의존하나? 반대 아닌가?

Domain Layer는 Repository Interface를 바라보게 됨. → DIP 적용됨.

그리고 Data Layer의 Repository 구체 타입들은 Repository Interface들을 채택하게 됨.

그러므로 Data Layer는 Domain Layer를 바라보게 됨.

아래 화살표 처럼 의존관계가 형성 될 것 같음.
DomainLayer ➡️ Repository Interface ⬅️ DataLayer

profile
자허블을 좀 더 좋아하긴 합니다.

0개의 댓글