[Architecture] CleanArchitecture

조성민·2023년 5월 24일
2

CleanArchitecture 개요

Architecture란 소프트웨어에서 확장, 수정이 유연하도록 계층을 나눠 설계하는 것이다. 지금까지 수많은 아키텍쳐가 나왔으며 그 공통점을 살리고 보완하여 나온 것이 Clean Architecture이다.

구성도

Clean Architecture의 핵심은 바깥쪽 레이어가 안쪽 레이어에 대한 정보는 알아도 되지만, 안쪽 레이어는 바깥쪽 레이어에 대해 최대한 몰라야 한다는 것이다.

외부 사항은 서비스가 완성이 되어 배포한 후에도 교체 및 수정의 가능성이 있지만, 비즈니스 룰은 변화의 가능성이 매우 적다. 그러므로 외부 사항이 변할 때마다 내부 사항을 변화해야 한다면 능률이 저하될 것이다.

의존 관계

  • Domain Layer = Entities + Use Cases + Repositories Interfaces
  • Data Repositories Layer = Repositories Implementations + API (Network) + Persistence DB
  • Presentation Layer (MVVM) = ViewModels + Views

Note : Domain Layer는 다른 layer의 어떤 것도 포함해서는 안 된다.

기본 개념

이렇게 구조를 구성하면 프로젝트의 핵심 비즈니스와 관련 없는 외부 사항에 대한 결정을 최대한 미룰 수 있는 장점이 있다. 외부 사항에 대한 결정을 확실히 하지 않으면 비즈니스 룰도 구현할 수 없는 상황이 발생하기도 한다. Clean Architecture를 사용하면 의존관계라는 장점으로 인해 외부 사항에 대한 결정이 이루어지지 않은 상태에서도 비즈니스 룰을 구현할 수 있다. 내부의 관점에서 외부는 완전히 독립적이기 때문에 외부에 의존하지 않고도 테스트 할 수 있다는 장점이 있다.

Clean Architecture에서 다른 구조보다 MVVM이 많이 사용된다. 그 이유는 ViewModel이 독립적으로 테스트 가능하기도 하며, ViewModel이 ‘어떻게 그릴지’에 대한 고려를 하지 않아도 되게 만들어서 한 층 위인 View에 대한 고려가 완전히 필요 없어졌기 때문이다.

예시

[클린 아키텍쳐 + MVVM] 구조를 사용한 예시로 간단한 영화 관련 앱이 있다.

이 앱에서 클린 아키텍쳐의 요소를 살펴보자.

Entity

외부 변화가 있을 때 변경될 가능성이 가장 작은 요소들이다. 데이터 구조 및 함수 집합, 변수를 담는 공간이라고 생각하면 쉽다.

영화 앱에서 어떻게 영화를 찾아주고 영화의 내용을 띄워주고 어떻게 구현을 하던 영화 실제 정보는 변하지 않는다. 그런 영화의 정보를 Entity라고 보면 된다. 영화 앱에서 Entity를 보면 아래의 코드와 같다.

import Foundation

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는 Clean Architecture의 핵심이다. 모든 것은 Use Cases를 통해서 이루어진다.

Use Cases는 Entity와의 데이터 흐름을 조정하고, 해당 Entity가 Use Cases의 목표를 달성하도록 지시하는 역할을 한다. 사용자가 시스템(서비스)를 통해서 하고자 하는 것을 의미한다.

예를 들어, '영화관' 이라는 서비스가 있다고 가정해보자. 영화관에서 손님(클라이언트)는 '영화 예매'를 할 수도 있고, '예매 취소'를 할 수도 있고, '환불', 심지어 '팝콘 사기'를 할 수도 있을 것이다. 이 때, 이런 '영화 예매', '예매 취소', '환불', '팝콘 사기' 등등이, '영화관'이라는 시스템에 사용자가 요청할 수 있는, '영화관'의 Use case이다. 위의 영화 앱에서 영화를 검색하는 것이 Use Case 라고 할 수 있다.

요약하면 Entity를 활용하여 비지니스 룰을 실행하는 단계이다.

Use Cases - Crossing Boundaries

구성도의 우측 하단에 이러한 그래프가 있다. 분홍색 선을 보면 Controller에서 시작하여 Use Case를 거쳐 Presenter를 호출한다. Use Case에서 Presenter를 호출한다는 점에서 의존 관계에 대한 모순이 생긴다. 이러한 의존성 모순을 해결하는 것이 Dependency Inversion Principle(의존성 역전 법칙)이다.

의존성 역전 법칙이란 Use Case에 Output Port라는 Interface를 둬서 Presenter처럼 바깥 원의 내용을 직접 참조하는 것이 아닌 Interface를 참조하면 된다.

Interface Adapters (Controllers, Gateway, Presenters)

Interface Adapters는 Presentation Layer라고도 불린다. 이 Layer에서는 데이터가 들어오면 Entity와 Use Cases에 가장 편리한 Format에서 외부 프레임에 가장 편리한 Format으로 변환된다.

예재 앱에서는 Presentation 레이어에 Coordinator, ViewModel, ViewController 등이 들어있다.

Frameworks & Drives

Devices, Web, UI, DB, External Intefaces 등이 있다. 프레임워크나 도구로 구성되어 있으며 안쪽의 레이어와 통신할 연결 코드를 작성한다.

Data Flow

사실 위의 이론만 봐서는 이해가 어렵다. 그래서 Clean Architecture와 MVVM을 합친 예시 앱의 코드를 보면서 흐름을 파악하면 좋다.

<개요>

  1. View(UI)는 ViewModel(Presenter) 의 메소드를 호출.
  2. ViewModel은 UseCase를 실행.
  3. Use Case는 User와 Repository로부터 데이터를 조합.
  4. 각각의 Repository는 Remote Data (Network) 또는 Persistent DB Storage Source 또는 In-memory Data (Remote or Cached) 로부터 데이터를 가져온다.
  5. 정보는 다시 View(UI)로 흘러서 (Information flows back to the View(UI)) 우리는 새로운 화면을 보게 된다.
  1. View(UI)는 ViewModel(Presenter) 의 메소드를 호출.

제일 바깥 원에 속하는 UI에서 안쪽 원인 Presenter를 호출할 수 있다.

(MoviesListViewController)

영화 앱에서 검색어를 입력하고 검색하면 아래 viewModel의 메소드를 호출한다.

final class MoviesListViewController: UIViewController, StoryboardInstantiable, Alertable {
    
    @IBOutlet private var contentView: UIView!
    @IBOutlet private var moviesListContainer: UIView!
    @IBOutlet private(set) var suggestionsListContainer: UIView!
    @IBOutlet private var searchBarContainer: UIView!
    @IBOutlet private var emptyDataLabel: UILabel!
    
    private var viewModel: MoviesListViewModel!
    private var posterImagesRepository: PosterImagesRepository?

    private var moviesTableViewController: MoviesListTableViewController?
    private var searchController = UISearchController(searchResultsController: nil)

(MoviesListViewController)

didSearch 메소드가 호출

extension MoviesListViewController: UISearchBarDelegate {
    func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
        guard let searchText = searchBar.text, !searchText.isEmpty else { return }
        searchController.isActive = false
        viewModel.didSearch(query: searchText)
    }

    func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
        viewModel.didCancelSearch()
    }
}

(MoviesListViewModel)

didSearch 메소드가 호출되면 update 메소드가 호출되고

extension DefaultMoviesListViewModel {

    func viewDidLoad() { }

    func didLoadNextPage() {
        guard hasMorePages, loading.value == .none else { return }
        load(movieQuery: .init(query: query.value),
             loading: .nextPage)
    }

    func didSearch(query: String) {
        guard !query.isEmpty else { return }
        update(movieQuery: MovieQuery(query: query))
    }

(MoviesListViewModel)

resetPages와 load 메소드를 호출한다.

private func update(movieQuery: MovieQuery) {
        resetPages()
        load(movieQuery: movieQuery, loading: .fullScreen)
    }
  1. ViewModel은 UseCase를 실행.

(MoviesListViewModel)

ViewModel은 Use Case를 가지고 있고, 위에서 실행한 load 메소드에서는 Use Case를 실행한다.

final class DefaultMoviesListViewModel: MoviesListViewModel {

    private let searchMoviesUseCase: SearchMoviesUseCase
    private let actions: MoviesListViewModelActions?
private func resetPages() {
        currentPage = 0
        totalPageCount = 1
        pages.removeAll()
        items.value.removeAll()
    }

    private func load(movieQuery: MovieQuery, loading: MoviesListViewModelLoading) {
        self.loading.value = loading
        query.value = movieQuery.query

        moviesLoadTask = searchMoviesUseCase.execute(
            requestValue: .init(query: movieQuery, page: nextPage),
            cached: { [weak self] page in
                self?.mainQueue.async {
                    self?.appendPage(page)
                }
            },
            completion: { [weak self] result in
                self?.mainQueue.async {
                    switch result {
                    case .success(let page):
                        self?.appendPage(page)
                    case .failure(let error):
                        self?.handle(error: error)
                    }
                    self?.loading.value = .none
                }
        })
    }
  1. Use Case는 User와 Repository로부터 데이터를 조합.

(SearchMoviesUseCase)

Use Case는 ececute 함수 안에서 Repository에게 movieList를 가져오라고 요청.

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,
        cached: @escaping (MoviesPage) -> Void,
        completion: @escaping (Result<MoviesPage, Error>) -> Void
    ) -> Cancellable? {

        return moviesRepository.fetchMoviesList(
            query: requestValue.query,
            page: requestValue.page,
            cached: cached,
            completion: { result in

            if case .success = result {
                self.moviesQueriesRepository.saveRecentQuery(query: requestValue.query) { _ in }
            }

            completion(result)
        })
    }
}
  1. 각각의 Repository는 Remote Data (Network) 또는 Persistent DB Storage Source 또는 In-memory Data (Remote or Cached) 로부터 데이터를 가져온다.

(DefaultMoviesRepository)

Repository는 서버로부터 데이터를 가져와서 넘겨준다.

extension DefaultMoviesRepository: MoviesRepository {
    
    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) { [weak self, backgroundQueue] 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,
                on: backgroundQueue
            ) { 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
    }
}
  1. 정보는 다시 View(UI)로 흘러서 (Information flows back to the View(UI)) 우리는 새로운 화면을 보게 된다.

(MoviesListViewController)

View Controller의 bind 메소드에서 ViewModel의 items를 옵져빙하여 화면을 갱신한다.

private func bind(to viewModel: MoviesListViewModel) {
        viewModel.items.observe(on: self) { [weak self] _ in self?.updateItems() }
        viewModel.loading.observe(on: self) { [weak self] in self?.updateLoading($0) }
        viewModel.query.observe(on: self) { [weak self] in self?.updateSearchQuery($0) }
        viewModel.error.observe(on: self) { [weak self] in self?.showError($0) }
    }

참고 사이트

The Clean Architecture

GitHub - kudoleh/iOS-Clean-Architecture-MVVM: Template iOS app using Clean Architecture and MVVM. Includes DIContainer, FlowCoordinator, DTO, Response Caching and one of the views in SwiftUI

Clean Architecture

[Clean Architecture] iOS Clean Architecture + MVVM 개념과 예제

profile
성장하는 iOS 개발자

0개의 댓글