ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • iOS 클린 아키텍처 + MVVM 개념과 코드 적용
    iOS/Swift 2025. 10. 24. 18:28

    앱을 개발할 때 구조(아키텍처)를 잘 만들어 놓으면 코드를 추가하거나 변경하는 수정이 쉬워집니다. 반대로 구조가 좋지 않으면 일부분을 수정하기 위해 상관없는 부분까지 만져야 해서 작업이 매우 복잡해질 수 있습니다.

     

    이번 포스트에서는 이 포스트를 기반으로 가장 많이 사용되고 있는 아키텍쳐인 MVVM과 클린 아키텍쳐를 내용을 살펴보고, iOS앱에서 어떻게 적용할 수 있는지 알아보겠습니다.

     

    이 포스트의 예제는 여기에서 다운로드할 수 있습니다.

    1. 클린 아키텍쳐

    엉클밥 아저씨가 만든 아키텍처로 앱을 여러 레이어(층)로 나눴습니다. 핵심은 의존성 규칙입니다. 레이어의 바깥쪽이 안쪽 향해서만 의존해야 한다는 것입니다. 이것을 저수준 층이 고수준 층에 의존해야 한다고 표현하기도 합니다. 수준이 높을수록 잘 변하지 않고, 낮을수록 더 잘 변할 수 있습니다.

     

    모든 레이어를 구분하면 Presentation Layer, Domain Layer, Data Layer로 구분할 수 있습니다.

     

    클린 아키택쳐를 사용하면 레이어를 분리해 관심사를 분리할 수 있고, 종속성 흐름을 한 방향으로 제어할 수 있습니다.

     

    만약 영화를 검색하는 앱을 개발한다고 가정하고, 이 앱의 계층을 클린 아키텍처에서 사용하는 계층을 기준으로 나눠 보겠습니다.

    1.1 도메인 레이어 (비즈니스 로직)

    계층의 가장 안쪽에 위치하는 레이어로 Entitiy, Use Cases, Repository Interface를 포함합니다. 엔티티나, 유스 케이스라는 용어가 생소하실 겁니다. 각각 비즈니스를 수행하는데 필요한 모델과 행위라고 이해하시면 됩니다.

     

    지금 예를 든 앱이 영화를 검색하는 앱인데, 영화 검색 앱은 검색어 입력 시 영화 데이터를 제공해야 합니다. 그래서 여기서 엔티티는 비즈니스에 사용되는 모델인 영화와 영화검색 모델입니다.

     

    (영화 검색 앱 개발 시)

    (1) Entity: 영화 모델, 영화 검색 모델

    유즈케이스는 비즈니스를 수행하는데 필요한 행위라고 말씀드렸습니다. 영화 검색 앱은 영화 리스트를 가져올 수 있어야 하고 영화를 검색할 수 있어야 합니다. 이 것이 유즈케이스가 됩니다.

     

    (2) Use Case: 영화 목록 가져오기, 영화 검색하기

    마지막으로 도메인 레이어에 포함되는 것이 저장소 인터페이스입니다. 유즈케이스로 데이터를 가져올 때 데이터 저장소에 요청을 해야 합니다. 도메인 레이어에서 저장소 구현체를 직접 참조하고 있으면 도메인 계층이 저장소에 의존하게 됩니다. 이것을 방지하고자 구현에 의존하지 않고 인터페이스에 의존하도록 구현합니다.

     

    의존성이란?

    의존성은 데이터 흐름과 다릅니다.
    의존성은 간단히 어떤 계층이 작업을 수행하기 위해 다른 계층을 import(include) 해야 하는지에 대한 부분입니다. 도메인 레이어는 프레젠테이션 레이어에 속한 UIKit이나 SwiftUI 혹은 데이터 레이어에 속한 Codable 매핑 등을 포함하면 안 됩니다.

     

    (3) Repository Interface: 영화 목록 가져오기와 검색하기를 수행하기 위해 어떤 인터페이스에 어떤 값을 파라미터로 넣어 호출해야 하는지를 기술합니다.

    1.2 프레젠테이션 레이어

    프리젠테이션 레이어는 UI (UIViewController 혹은 SwiftUI 뷰)를 포함하고 뷰는 유즈 케이스를 실행하는 뷰모델에 의해 처리됩니다.

    프레젠테이션 레이어는 오직 도메인 레이어에만 의존합니다.

    1.3 데이터 레이어

    데이터 레이어는 저장소 구현과 하나 이상의 데이터 소스를 구현합니다. 저장소는 다른 데이터 소스에서 데이터를 처리하는 책임을 집니다. 데이터 소스는 원격 혹은 로컬이 될 수 있습니다.

     

    데이터 레이어는 오직 도메인 레이어에만 의존합니다.

     

    이 레이어에는 네트워크 JSON 데이터를 모메인 모델로 매핑하는 기능도 포함합니다. 아래 그림은 의존성 방향과 데이터 흐름을 나타냅니다. 저장소 인터페이스(protocol)를 사용해 의존성 역전이 발생한 것을 확인할 수 있습니다.

     

    1.3.1 데이터 흐름

    데이터가 요청되고 수신되는 과정은 아래와 같습니다.

    (1) 뷰가 뷰모델의 특정 액션을 트리거합니다.
    (2) 뷰모델은 유즈 케이스를 실행합니다.
    (3) 유즈 케이스는 저장소에 데이터를 요청합니다.
    (4) 각 저장소는 원격(Network) 혹은 로컬(DB 혹은 메모리)에서 데이터를 조회해 반환합니다.
    (5) 데이터는 아이템 리스트를 표시하기 위해 뷰로 전달 됩니다.

    1.3.2 의존성 방향

    각 레이어는 다음과 같은 의존성을 갖습니다.
    (1) 프레젠테이션 레이어 -> 도메인 레이어 <- 데이터 저장소 레이어
    (2) 프리젠테이션 레이어 = 뷰모델(Presenter) + 뷰(UI)
    (3) 도메인 레이어 = 엔티티 + 유즈케이스 + 저장소 인터페이스
    (4) 데이터 저장소 레이어 = 저장소 구현 + API(Network) + Persistence DB

    2. 샘플 영화 앱의 각 계층 구조

    샘플 앱에서는 다음과 같은 개념이 사용 됐습니다.

    • 클린 아키텍처
    • MVVM
    • Observable
    • 의존성 주입
    • Flow Coordinator
    • Data Transfer Object (DTO)
    • Response Data Caching
    • ViewController LifeCycle Behavior
    • SwiftUI and UIKit Views in code
    • Use of UITableViewDiffableDataSource

    이 포스트에서는 위 개념 중 일부의 핵심 코드만 다루고 있어서 코드를 제대로 분석하고 싶으신 분 이 저장소에서 코드를 다운로드하여서 직접 분석해 보시길 권해드립니다.

     

    코드 전체적으로 프로토콜에 의존하는 DI가 전역적으로 사용돼 있어서 꽤 복잡합니다. 그럼 코드 얘기를 해보도록 하겠습니다.

    영화 앱을 클린아키텍처 + MVVM 구조로 구현했을 때 각 계층을 폴더로 구분하면 다음과 같습니다.

    각 영역의 코드가 어떻게 구성돼 있는지 간단히 살펴보겠습니다.

    2.1 도메인 레이어

    도메인 레이어에는 엔티티와 유즈케이스가 포함됩니다. 유즈케이스에는 영화를 검색하고 검색어를 저장합니다. 또 의존성 역전을 위해 사용되는 데이터 저장소 인터페이스도 포함돼 있습니다.

    2.1.1 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]
    }
    
    struct MovieQuery: Equatable {
        let query: String
    }

    2.1.2 Use Case

    프로토콜로 구성돼 있고, 이 프로토콜을 따르는 기본 구현 클래스를 만들어 사용합니다.

    import Foundation
    
    protocol SearchMoviesUseCase {
        func execute(
            requestValue: SearchMoviesUseCaseRequestValue,
            cached: @escaping (MoviesPage) -> Void,
            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,
            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)
            })
        }
    }
    
    struct SearchMoviesUseCaseRequestValue {
        let query: MovieQuery
        let page: Int
    }

    2.1.3 Repository Interface

    import Foundation
    
    protocol MoviesRepository {
        @discardableResult
        func fetchMoviesList(
            query: MovieQuery,
            page: Int,
            cached: @escaping (MoviesPage) -> Void,
            completion: @escaping (Result<MoviesPage, Error>) -> Void
        ) -> Cancellable?
    }

    2.2 프레젠테이션 레이어

    프레젠테이션 레이어는 뷰모델을 포함하고 있습니다. 뷰모델은 UIKit을 Import하지 않습니다.

    import Foundation
    
    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
    }
    
    enum MoviesListViewModelLoading {
        case fullScreen
        case nextPage
    }
    
    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 }
    }
    
    typealias MoviesListViewModel = MoviesListViewModelInput & MoviesListViewModelOutput
    
    final class DefaultMoviesListViewModel: MoviesListViewModel {
    
        private let searchMoviesUseCase: SearchMoviesUseCase
        private let actions: MoviesListViewModelActions?
    
        var currentPage: Int = 0
        var totalPageCount: Int = 1
        var hasMorePages: Bool { currentPage < totalPageCount }
        var nextPage: Int { hasMorePages ? currentPage + 1 : currentPage }
    
        private var pages: [MoviesPage] = []
        private var moviesLoadTask: Cancellable? { willSet { moviesLoadTask?.cancel() } }
        private let mainQueue: DispatchQueueType
    
        // MARK: - OUTPUT
    
        let items: Observable<[MoviesListItemViewModel]> = Observable([])
        let loading: Observable<MoviesListViewModelLoading?> = Observable(.none)
        let query: Observable<String> = Observable("")
        let error: Observable<String> = Observable("")
        var isEmpty: Bool { return items.value.isEmpty }
        let screenTitle = NSLocalizedString("Movies", comment: "")
        let emptyDataTitle = NSLocalizedString("Search results", comment: "")
        let errorTitle = NSLocalizedString("Error", comment: "")
        let searchBarPlaceholder = NSLocalizedString("Search Movies", comment: "")
    
        // MARK: - Init
    
        init(
            searchMoviesUseCase: SearchMoviesUseCase,
            actions: MoviesListViewModelActions? = nil,
            mainQueue: DispatchQueueType = DispatchQueue.main
        ) {
            self.searchMoviesUseCase = searchMoviesUseCase
            self.actions = actions
            self.mainQueue = mainQueue
        }
    
        // MARK: - Private
    
        private func appendPage(_ moviesPage: MoviesPage) {
            currentPage = moviesPage.page
            totalPageCount = moviesPage.totalPages
    
            pages = pages
                .filter { $0.page != moviesPage.page }
                + [moviesPage]
    
            items.value = pages.movies.map(MoviesListItemViewModel.init)
        }
    
        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
                    }
            })
        }
    ...

    프리젠테이션 레이어에서 뷰와 뷰모델의 바인딩은 ViewController에서 수행됩니다.

    import UIKit
    
    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)
    
        // MARK: - Lifecycle
    
        static func create(
            with viewModel: MoviesListViewModel,
            posterImagesRepository: PosterImagesRepository?
        ) -> MoviesListViewController {
            let view = MoviesListViewController.instantiateViewController()
            view.viewModel = viewModel
            view.posterImagesRepository = posterImagesRepository
            return view
        }
    
        override func viewDidLoad() {
            super.viewDidLoad()
            setupViews()
            setupBehaviours()
            bind(to: viewModel)
            viewModel.viewDidLoad()
        }
    
        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) }
        }
      ...

    뷰 모델과 뷰 바인딩은 옵서버 패턴을 구현한 Observable을 사용해 수행합니다.

    import Foundation
    
    final class Observable<Value> {
    
        struct Observer<Value> {
            weak var observer: AnyObject?
            let block: (Value) -> Void
        }
    
        private var observers = [Observer<Value>]()
    
        var value: Value {
            didSet { notifyObservers() }
        }
    
        init(_ value: Value) {
            self.value = value
        }
    
        func observe(on observer: AnyObject, observerBlock: @escaping (Value) -> Void) {
            observers.append(Observer(observer: observer, block: observerBlock))
            observerBlock(self.value)
        }
    
        func remove(observer: AnyObject) {
            observers = observers.filter { $0.observer !== observer }
        }
    
        private func notifyObservers() {
            for observer in observers {
                observer.block(self.value)
            }
        }
    }
    

    화면 전환에는 FlowCoordinator를 사용합니다.

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

    2.3 데이터 레이어

    데이터 레이어는 기본영화 저장소를 포함하고 있습니다. 이 저장소는 도메인 레이어에 있는 저장소 인터페이스를 준수합니다.

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

    Data Transfer Objects 인 DTO는 JSON 데이터를 도메인에서 사용하는 모델로 매핑하는 데 사용됩니다.

    2.4 인프라 레이어 (네트워크)

    네트워크 래퍼입니다. 필요에 따라 3rd party 라이브러리를 사용할 수 있습니다.

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

    마치며

    원글 저자가 작성한 포스트와 코드는 다방면으로 분석하고 참조할만한 가치가 있는 콘텐츠입니다. 한 번에 모두 정리하기 어려워 대략적으로 정리한 내용만 이렇게 초안처럼 작성해 보았습니다. 앱 아키텍처를 연구하고자 하는 분에게 이 포스트가 좋은 시작점이 되길 바랍니다.

    코드 분석을 위해 정리한 다른 초안 그림도 첨부합니다.

     

    참고

    - https://tech.olx.com/clean-architecture-and-mvvm-on-ios-c9d167d9f5b3

    - https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

    - https://ios-development.tistory.com/665

    - https://ios-development.tistory.com/666
    - https://ios-development.tistory.com/667

    - https://ios-development.tistory.com/668

    - https://ios-development.tistory.com/669

    - https://zeddios.tistory.com/1065

    - https://medium.com/@rudrakshnanavaty/clean-architecture-7c1b3b4cb181

    - https://velog.io/@woohm402/clean-architecture-short-summary

    - https://sunny-maneg.tistory.com/entry/iOS-%EC%84%A4%EA%B3%84%EC%97%90%EC%84%9C%EC%9D%98-Clean-Architecture

    - https://techblog.woowahan.com/2647/

    - https://dogfootsleep.tistory.com/42

    - https://daryeou.tistory.com/280

    - https://medium.com/mj-studio/%ED%81%B4%EB%A6%B0%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EC%8D%BC%EB%8A%94%EB%8D%B0-%EC%99%9C-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EA%B0%80-%EB%8D%94-%EB%8D%94%EB%9F%AC%EC%9B%8C%EC%A7%80%EC%A7%80-3565aaffca8c

    - https://sunny-maneg.tistory.com/entry/%ED%81%B4%EB%A6%B0%EC%95%84%ED%82%A4%ED%85%8D%EC%B3%90Clean-Architecture

Designed by kyejusung.com