[iOS] iOS-Clean-Architecture and MVVM 학습 - 3

Hyunndy·2023년 2월 6일
0

🐸

오늘은 드디어 Clean Architecture 학습에 맞는 주제로
Presenter > MovieList 폴더를 까보겠습니다!

이 글 부터는 정보전달의 목적이 아닌 제가 코드를 보면서 CleanArchitecture구조에 대해 느낀 것을 정리하는 느낌으로 쓰겠습니다!
( ㅠㅠ 정보전달목적으로 쓰면 너무 오래걸리네요)


Clean Architecture


클린 아키텍처의 기본 컨셉은 이 그림과 같습니다.

  • Entity
  • Use Case
  • Presenters
  • UI, API, DB, InfraStructure, Framework

계층으로 나뉘어져 있으며,
inner Circle은 outer Circle에 Dependency를 가질 수 없습니다.

MoviesListViewController

이 프로젝트에서 ViewController는 Clean Architecture에서
가장 밖의 영역 UI입니다.

이 예제에서는 정말 View를 구성하는 코드만 VC에 있고 아주 사소한 데이터
(ex. VC의 ScreenTitle, emptyDataTitle 등..) 까지 모두 ViewModel(Presenter)에서 관리합니다.

한 가지 의문인것은 영화의 썸네일 Image를 가져오는 posterImagesRepository 객체는 ViewController이 갖고있다.
TableViewCell에 그대로 Image를 불러와서 세팅해주고있다.
데이터 가공이 일어나지 않아서 ViewModel이 아닌 VC에서 전달해준건지..? 의문. 이건 그냥 스타일인듯하다.

그럼 ViewModel의 코드는 어떻게 되어있나

MoviesListViewModel

ViewModel은 CleanArchitecture의 Presenter 영역입니다.
ViewModel은 UIKit을 포함하지 않습니다!!

  • VC로부터 오는 Input Protocol
  • ViewModel -> VC의 데이터 Output Protocol
    프로토콜을 채택하여 UIKit과의 분리를 돕고, test가 용이하게 했습니다.
protocol MoviesListViewModelInput {
    func viewDidLoad()
    func didLoadNextPage()
    func didSearch(query: String)
    func didCancelSearch()
    func showQueriesSuggestions()
    func closeQueriesSuggestions()
    func didSelectItem(at index: Int)
}

protocol MoviesListViewModelOutput {
    var items: Observable<[MoviesListItemViewModel]> { get } /// Also we can calculate view model items on demand:  https://github.com/kudoleh/iOS-Clean-Architecture-MVVM/pull/10/files
    var loading: Observable<MoviesListViewModelLoading?> { get }
    var query: Observable<String> { get }
    var error: Observable<String> { get }
    var isEmpty: Bool { get }
    var screenTitle: String { get }
    var emptyDataTitle: String { get }
    var errorTitle: String { get }
    var searchBarPlaceholder: String { get }
}

또 전 시간 학습했던
VC -> ViewModel -> FlowCoordinator로 이어지는 Action에 대한 Struct도 포함한다.

struct MoviesListViewModelActions {
    /// Note: if you would need to edit movie inside Details screen and update this Movies List screen with updated movie then you would need this closure:
    /// showMovieDetails: (Movie, @escaping (_ updated: Movie) -> Void) -> Void
    let showMovieDetails: (Movie) -> Void
    let showMovieQueriesSuggestions: (@escaping (_ didSelect: MovieQuery) -> Void) -> Void
    let closeMovieQueriesSuggestions: () -> Void
}


CleanArchitecture 그래프를 보았을 때 DataRepository 영역과 Presentation MVVM까지 보았는데, 다음 inner Circle인 Use Case 영역을 살펴보겠습니다.


Use Case

UseCase는 DIContainer에서 ViewModel 생성 시 주입됩니다.

    func makeSearchMoviesUseCase() -> SearchMoviesUseCase {
        return DefaultSearchMoviesUseCase(moviesRepository: makeMoviesRepository(),
                                          moviesQueriesRepository: makeMoviesQueriesRepository())
    }
    
    func makeFetchRecentMovieQueriesUseCase(requestValue: FetchRecentMovieQueriesUseCase.RequestValue,
                                            completion: @escaping (FetchRecentMovieQueriesUseCase.ResultValue) -> Void) -> UseCase {
        return FetchRecentMovieQueriesUseCase(requestValue: requestValue,
                                              completion: completion,
                                              moviesQueriesRepository: makeMoviesQueriesRepository()
        )
    }

또한 UseCase는 Repository Interface를 갖습니다.

protocol MoviesRepository {
    @discardableResult
    func fetchMoviesList(query: MovieQuery, page: Int,
                         cached: @escaping (MoviesPage) -> Void,
                         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)
}

DefaultSearchMoviesUseCase는 SearchBar에 text 입력 후 Search 버튼을 누를 때 실행됩니다.

UseCase는 Repository(데이터 통신)을 갖고있으며,
excute() 함수 내부에서 Repository Interface를 통해 데이터를 받아옵니다.

    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. VC(UI)에서 Search 버튼을 눌러 ViewModel(Presenter)의 함수를 실행시킨다.
2. ViewModel(Peresenter)에서 UseCase의 excute()를 호출한다.
3. UseCase는 excute()에서 Repository의 fetchData()를 호출해 데이터를 요청한다.
4. Repository는 Network, Persistent DB(ex. CoreData) 등에서 데이터를 받고, Entity의 형태로 반환한다.
5. 반환된 데이터는 VC(UI)에 전달된다.

⁉️

앗 그런데...
ViewController(UI) -> ViewModel(Presenter) -> UseCase
로 Dependency Direction이 이어지는건 맞는데...
Repository는 가장 바깥의 영역이라 inner Circle인 UseCase에서 갖고있으면 안되는거 아닌가욥 ㅠ

맞습니다. 원래 갖고 있으면 안되는데..!
우리는 DIContainer를 통해 UseCase에 Repository Interface를 주입해주었기 때문에
의존성 역전을 통해 의존성이 없어져서 사용할 수 있다고 합니다.

    // MARK: - Use Cases
    func makeSearchMoviesUseCase() -> SearchMoviesUseCase {
        return DefaultSearchMoviesUseCase(moviesRepository: makeMoviesRepository(),
                                          moviesQueriesRepository: makeMoviesQueriesRepository())
    }

이 부분에서 창시자의 블로그를 보면..
업로드중..

Dependency Direction

Presentation Layer -> Domain Layer <- Data Repositories Layer
의존성의 방향이 이렇게 되어있습니다.

각 레이어는..

Presentation Layer (MVVM)

  • ViewModel(Presenters) + View(UI)

Domain Layer

  • Entity + Use Case + Repositories Interface

Data Repositories Layer

  • Repositories Implementation + API(Network) + Persistence DB

이렇게 구성되어 있습니다.

그럼 Use Case까지 봤고, 그 다음 Entity를 볼 차례 입니다.


Entity

이 프로젝트에서는 Repository 객체를 이용해서 NetworkService를 합니다.
MoveList를 받아오는 부분을 보면...

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

APIEndPoint를 통해 Response를 받는데, ResponseDTO라는 객체가 보입니다.

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

Decodable을 채택하고 있는것 보니 API에서 받은 데이터를 디코딩하는 객체입니다.
toDomain() 이라는 함수는 이름을 보아도 알 수 있듯,
ResponseDTO를 Entity 객체로 바꿔주는 함수입니다.

extension MoviesResponseDTO {
    func toDomain() -> MoviesPage {
        return .init(page: page,
                     totalPages: totalPages,
                     movies: movies.map { $0.toDomain() })
    }
}

왜이러는걸까요? 그냥 Entity를 Codable을 채택한 객체로 쓰면 되는 것 아닐까요?
여기서 잠깐 DTO의 개념에 대해 알아봅시다.

DTO

DTO는 Data Transfer Object의 약자입니다.
Network나 DB에서 꺼낸 데이터를 실제 View에서 쓰는 Entity로 만드는 일종의 wrapper입니다.
실제 Data <-> DTO <-> Entity <-> UI

이렇게 불리하는 이유는
1. Data가 변경되면 DTO만 변경하고 실제 여러 계층에서 Dependency를 갖는 Entity 객체는 바꾸지 않아도된다. -> Entity가 바뀌게 되면 특히 Presentation 계층도 다 수정해야한다..!!
2. Entity를 어떤 Layer에도 영향 받지 않는 독립적인 객체로 유지할 수 있다.

이러한 이유로 DTO를 Entity로 변경해서 쓰고 있습니다!

struct MoviesPage: Equatable {
    let page: Int
    let totalPages: Int
    let movies: [Movie]
}

이렇게 변환된 Entity는 각 Layer에 전달되어 용도에 맞게 쓰이게 됩니다.


느낀점

여기까지 iOS-Clean-Architecture-MVVM 프로젝트를 학습하며

  • Clean Architecture
  • Dependency Injection
  • MVVM
  • Coordinator

을 학습했습니다.

Clean Architecture를 공부하려고 시작했는데 여러 개념을 알게되어 좋았습니다.
반대로 현타도 왔습니다. 내가 MVVM을 너무 얼레벌레 쓰고있던 것 같구나...
이제부터 약간 강박적으로 써보려고요.

최근에 회사에서 A,B,C API에서 받은 데이터를 거의 유사한 UI에 뿌리는 작업을 맡았는데 이 프로젝트를 하면서 학습한 DIContainer 코드가 많이 도움 되었습니다.

역시 예제를 보면서 학습하는것은 중요합니다.

profile
https://hyunndyblog.tistory.com/163 티스토리에서 이사 중

0개의 댓글