← Home
iOS Architecture · Clean Architecture · MVVM

Clean Architecture + MVVM 완벽 가이드

kudoleh/iOS-Clean-Architecture-MVVM 프로젝트 기반 완전 분석

🔄 데이터 흐름 시퀀스 다이어그램

영화 검색 요청 → API 호출 → 화면 업데이트 전체 흐름

sequenceDiagram autonumber participant User participant View as View
(ViewController) participant VM as ViewModel participant UC as UseCase
(Domain) participant Repo as Repository
(Data) participant API as Network
(Infrastructure) User->>View: 검색 버튼 클릭 View->>VM: didSearch(query) VM->>UC: execute(query, page) UC->>Repo: fetchMoviesList() Note over Repo: Protocol 호출
(Domain은 구현체 모름) Repo->>API: request(endpoint) API-->>Repo: MoviesResponseDTO Note over Repo: DTO → Entity 변환
toDomain() Repo-->>UC: Result<MoviesPage, Error> UC-->>VM: completion(result) Note over VM: Entity → ItemViewModel 변환
items.value = [...] VM-->>View: Observable 트리거 View-->>User: UI 업데이트 (TableView reload)

🏗️ 클래스 다이어그램

레이어별 클래스 관계와 의존성 방향 (클릭하면 확대)

📦 Domain Layer

classDiagram class Movie { +id: String +title: String +posterPath: String } class MoviesPage { +page: Int +totalPages: Int +movies: Movie[] } class MoviesRepository { <<protocol>> +fetchMoviesList() } class SearchMoviesUseCase { <<protocol>> +execute() } class DefaultSearchMoviesUseCase { -moviesRepository +execute() } MoviesPage o-- Movie SearchMoviesUseCase <|.. DefaultSearchMoviesUseCase DefaultSearchMoviesUseCase --> MoviesRepository

💾 Data Layer

classDiagram class MoviesRepository { <<protocol>> } class DefaultMoviesRepository { -dataTransferService -cache +fetchMoviesList() } class MoviesResponseDTO { <<Codable>> +page: Int +movies: MovieDTO[] +toDomain() MoviesPage } class MovieDTO { <<Codable>> +id: Int +title: String +poster_path: String } MoviesRepository <|.. DefaultMoviesRepository DefaultMoviesRepository ..> MoviesResponseDTO : creates MoviesResponseDTO o-- MovieDTO

🖥️ Presentation Layer

classDiagram direction LR class MoviesListViewModelInput { <<protocol>> +viewDidLoad() +didSearch(query) +didSelectItem(index) } class MoviesListViewModelOutput { <<protocol>> +items: Observable +loading: Observable +error: Observable } class DefaultMoviesListViewModel { -searchMoviesUseCase -pages: MoviesPage[] +items: Observable +didSearch(query) } class MoviesListViewController { -viewModel +viewDidLoad() -bind(to:) } MoviesListViewModelInput <|.. DefaultMoviesListViewModel MoviesListViewModelOutput <|.. DefaultMoviesListViewModel MoviesListViewController --> MoviesListViewModelInput : calls MoviesListViewController --> MoviesListViewModelOutput : observes

🔗 전체 의존성 흐름

flowchart LR subgraph Presentation["🖥️ Presentation"] VC[ViewController] VM[ViewModel] end subgraph Domain["📦 Domain"] UC[UseCase] RP[Repository\nProtocol] E[Entity] end subgraph Data["💾 Data"] RI[Repository\nImpl] DTO[DTO] end subgraph Infra["⚙️ Infrastructure"] NET[Network] DB[Storage] end VC --> VM VM --> UC UC --> RP RI -.->|implements| RP RI --> DTO DTO -->|toDomain| E RI --> NET RI --> DB style Presentation fill:#fef3c7,stroke:#f59e0b style Domain fill:#dbeafe,stroke:#3b82f6 style Data fill:#dcfce7,stroke:#10b981 style Infra fill:#fce7f3,stroke:#ec4899
의존성 규칙: 실선 = 직접 의존, 점선 = implements. Domain은 아무것도 의존하지 않음 (가장 안쪽)

1. 왜 Clean Architecture인가?

🤔 해결하려는 문제

❌ MVC의 한계 (Massive View Controller)
  • ViewController에 모든 로직 집중
  • 네트워크 + UI + 비즈니스 로직 혼재
  • 테스트 불가능 (UIKit 의존)
  • 재사용 불가능
✅ Clean Architecture의 해결책
  • 관심사 분리 (레이어별 역할)
  • 비즈니스 로직 독립 (Domain Layer)
  • 각 레이어 독립 테스트 가능
  • UI 프레임워크 교체 용이

📐 의존성 역전 원칙 (DIP)

핵심: 고수준 모듈(Domain)이 저수준 모듈(Data)에 의존하면 안 된다. 둘 다 추상화(Protocol)에 의존해야 한다.
// ❌ 잘못된 방식: UseCase가 구체 클래스에 의존
class SearchMoviesUseCase {
    let repository = DefaultMoviesRepository()  // 구체 클래스 직접 생성
}

// ✅ 올바른 방식: Protocol에 의존 + 외부에서 주입
class SearchMoviesUseCase {
    let repository: MoviesRepository  // Protocol 타입
    init(repository: MoviesRepository) { self.repository = repository }
}

🎯 Uncle Bob의 Clean Architecture 원칙

원칙설명이 프로젝트에서
Independent of Frameworks 프레임워크는 도구일 뿐 Domain Layer에 UIKit 없음
Testable UI, DB 없이 테스트 가능 UseCase 단위 테스트
Independent of UI UI 변경이 비즈니스 로직에 영향 X SwiftUI↔UIKit 교체 가능
Independent of Database DB 교체 용이 CoreData → Realm 교체 가능

2. 레이어 구조 개요

핵심 원칙: 의존성은 항상 바깥 → 안쪽으로만 향한다

Domain Layer

  • Entity (비즈니스 모델)
  • UseCase (비즈니스 로직)
  • Repository Protocol

Data Layer

  • Repository 구현체
  • DTO (Data Transfer Object)
  • Network / DB 접근

Presentation Layer

  • ViewModel
  • View (UIKit/SwiftUI)
  • Coordinator
Layer 역할 의존 방향
Domain 비즈니스 규칙 (앱의 핵심) ❌ 아무것도 의존 안 함
Data 데이터 접근 (API, DB) → Domain
Presentation UI 로직 → Domain

3. 프로젝트 폴더 구조

ExampleMVVM/ ├── Application/ # 앱 시작점 + DI Container │ ├── AppDelegate.swift │ ├── AppFlowCoordinator.swift │ └── DIContainer/ │ ├── AppDIContainer.swift # 앱 전체 DI │ └── MoviesSceneDIContainer.swift # Scene별 DI │ ├── Domain/ # ⭐ 핵심 비즈니스 (순수 Swift) │ ├── Entities/ │ │ ├── Movie.swift # 비즈니스 모델 │ │ └── MovieQuery.swift │ ├── UseCases/ │ │ ├── SearchMoviesUseCase.swift # 비즈니스 로직 │ │ └── FetchRecentMovieQueriesUseCase.swift │ └── Interfaces/Repositories/ │ ├── MoviesRepository.swift # Protocol만 (구현 X) │ └── MoviesQueriesRepository.swift │ ├── Data/ # 데이터 접근 구현 │ ├── Repositories/ │ │ ├── DefaultMoviesRepository.swift # Protocol 구현체 │ │ └── DefaultMoviesQueriesRepository.swift │ ├── Network/ │ │ ├── APIEndpoints.swift │ │ └── DataMapping/ │ │ └── MoviesResponseDTO+Mapping.swift # DTO → Entity 변환 │ └── PersistentStorages/ │ └── CoreDataMoviesResponseStorage.swift │ ├── Presentation/ # UI Layer (MVVM) │ └── MoviesScene/ │ ├── Flows/ │ │ └── MoviesSearchFlowCoordinator.swift │ ├── MoviesList/ │ │ ├── ViewModel/ │ │ │ └── MoviesListViewModel.swift │ │ └── View/ │ │ └── MoviesListViewController.swift │ └── MovieDetails/ │ ├── ViewModel/ │ └── View/ │ └── Infrastructure/ # 프레임워크/라이브러리 래핑 └── Network/ ├── NetworkService.swift └── DataTransferService.swift

4. Domain Layer (비즈니스 핵심)

절대 규칙: Domain Layer는 UIKit, Codable, 네트워크 등 외부 의존성이 없어야 함 (순수 Swift만)

4.1 Entity (비즈니스 모델)

앱의 핵심 데이터 구조. API 응답 형식과 무관하게 앱에서 사용할 형태로 정의

// Domain/Entities/Movie.swift

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

4.2 UseCase (비즈니스 로직)

하나의 기능 = 하나의 UseCase. Repository를 통해 데이터 접근

// Domain/UseCases/SearchMoviesUseCase.swift

import Foundation

// UseCase Protocol
protocol SearchMoviesUseCase {
    func execute(
        requestValue: SearchMoviesUseCaseRequestValue,
        cached: @escaping (MoviesPage) -> Void,
        completion: @escaping (Result<MoviesPage, Error>) -> Void
    ) -> Cancellable?
}

// UseCase 구현체
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)
            }
        )
    }
}

// Request Value (입력)
struct SearchMoviesUseCaseRequestValue {
    let query: MovieQuery
    let page: Int
}

4.3 Repository Protocol (인터페이스만)

구현은 Data Layer에서. Domain은 Protocol만 정의

// Domain/Interfaces/Repositories/MoviesRepository.swift

import Foundation

protocol MoviesRepository {
    @discardableResult
    func fetchMoviesList(
        query: MovieQuery,
        page: Int,
        cached: @escaping (MoviesPage) -> Void,
        completion: @escaping (Result<MoviesPage, Error>) -> Void
    ) -> Cancellable?
}
핵심: Domain Layer는 "무엇을 할 것인가"만 정의. "어떻게 할 것인가"는 Data Layer가 결정

5. Data Layer (데이터 접근)

5.1 Repository 구현체

Domain의 Protocol을 구현. 네트워크/DB 접근 담당

// Data/Repositories/DefaultMoviesRepository.swift

import Foundation

final class DefaultMoviesRepository {

    private let dataTransferService: DataTransferService
    private let cache: MoviesResponseStorage

    init(
        dataTransferService: DataTransferService,
        cache: MoviesResponseStorage
    ) {
        self.dataTransferService = dataTransferService
        self.cache = cache
    }
}

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

        // 1. 캐시에서 먼저 조회
        cache.getResponse(for: requestDTO) { result in
            if case let .success(responseDTO?) = result {
                cached(responseDTO.toDomain())  // DTO → Entity 변환
            }

            guard !task.isCancelled else { return }

            // 2. 네트워크 요청
            let endpoint = APIEndpoints.getMovies(with: requestDTO)
            task.networkTask = self.dataTransferService.request(
                with: endpoint
            ) { result in
                switch result {
                case .success(let responseDTO):
                    // 캐시 저장 + Entity 변환
                    self.cache.save(response: responseDTO, for: requestDTO)
                    completion(.success(responseDTO.toDomain()))
                case .failure(let error):
                    completion(.failure(error))
                }
            }
        }
        return task
    }
}

5.2 DTO (Data Transfer Object)

API 응답 형식과 1:1 매핑. Entity로 변환하는 메서드 포함

// Data/Network/DataMapping/MoviesResponseDTO+Mapping.swift

import Foundation

// DTO: API 응답 형식 그대로
struct MoviesResponseDTO: Decodable {
    private enum CodingKeys: String, CodingKey {
        case page
        case totalPages = "total_pages"  // snake_case → camelCase
        case movies = "results"
    }

    let page: Int
    let totalPages: Int
    let movies: [MovieDTO]
}

extension MoviesResponseDTO {
    struct MovieDTO: Decodable {
        private enum CodingKeys: String, CodingKey {
            case id
            case title
            case posterPath = "poster_path"
            case overview
            case releaseDate = "release_date"
        }

        let id: Int
        let title: String?
        let posterPath: String?
        let overview: String?
        let releaseDate: String?
    }
}

// MARK: - DTO → Domain Entity 변환

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

extension MoviesResponseDTO.MovieDTO {
    func toDomain() -> Movie {
        return .init(
            id: Movie.Identifier(id),
            title: title,
            genre: nil,
            posterPath: posterPath,
            overview: overview,
            releaseDate: dateFormatter.date(from: releaseDate ?? "")
        )
    }
}
DTO vs Entity 분리 이유:
  • API 응답 형식 변경 → DTO만 수정 (Domain 영향 X)
  • Entity는 앱 로직에 최적화된 형태 유지
  • Codable은 Data Layer에만 존재

6. Presentation Layer (MVVM)

6.1 ViewModel

UseCase를 호출하고 View에 표시할 데이터를 관리

// Presentation/MoviesScene/MoviesList/ViewModel/MoviesListViewModel.swift

import Foundation

// Actions (화면 전환)
struct MoviesListViewModelActions {
    let showMovieDetails: (Movie) -> Void
    let showMovieQueriesSuggestions: (@escaping (_ didSelect: MovieQuery) -> Void) -> Void
    let closeMovieQueriesSuggestions: () -> Void
}

// Input Protocol (View → ViewModel)
protocol MoviesListViewModelInput {
    func viewDidLoad()
    func didLoadNextPage()
    func didSearch(query: String)
    func didCancelSearch()
    func didSelectItem(at index: Int)
}

// Output Protocol (ViewModel → View)
protocol MoviesListViewModelOutput {
    var items: Observable<[MoviesListItemViewModel]> { get }
    var loading: Observable<MoviesListViewModelLoading?> { get }
    var query: Observable<String> { get }
    var error: Observable<String> { get }
    var isEmpty: Bool { get }
    var screenTitle: String { get }
}

// Input + Output 합침
typealias MoviesListViewModel = MoviesListViewModelInput & MoviesListViewModelOutput

// 구현체
final class DefaultMoviesListViewModel: MoviesListViewModel {

    private let searchMoviesUseCase: SearchMoviesUseCase  // Domain UseCase
    private let actions: MoviesListViewModelActions?

    // Pagination
    var currentPage: Int = 0
    var totalPageCount: Int = 1
    var hasMorePages: Bool { currentPage < totalPageCount }

    private var pages: [MoviesPage] = []
    private var moviesLoadTask: Cancellable? {
        willSet { moviesLoadTask?.cancel() }  // 새 검색 시 이전 요청 취소
    }

    // 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: "")

    // MARK: - Init

    init(
        searchMoviesUseCase: SearchMoviesUseCase,
        actions: MoviesListViewModelActions? = nil
    ) {
        self.searchMoviesUseCase = searchMoviesUseCase
        self.actions = actions
    }

    // MARK: - Private

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

        moviesLoadTask = searchMoviesUseCase.execute(
            requestValue: .init(query: movieQuery, page: currentPage + 1),
            cached: { [weak self] page in
                // 캐시 데이터 즉시 표시
                self?.appendPage(page)
            },
            completion: { [weak self] result in
                switch result {
                case .success(let page):
                    self?.appendPage(page)
                case .failure(let error):
                    self?.handle(error: error)
                }
                self?.loading.value = .none
            }
        )
    }
}

// MARK: - INPUT (View 이벤트 처리)

extension DefaultMoviesListViewModel {

    func viewDidLoad() { }

    func didSearch(query: String) {
        guard !query.isEmpty else { return }
        resetPages()
        load(movieQuery: MovieQuery(query: query), loading: .fullScreen)
    }

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

    func didSelectItem(at index: Int) {
        actions?.showMovieDetails(pages.movies[index])
    }
}

6.2 View (ViewController)

ViewModel을 바인딩하고 UI 업데이트

// Presentation/MoviesScene/MoviesList/View/MoviesListViewController.swift

import UIKit

final class MoviesListViewController: UIViewController {

    @IBOutlet private var contentView: UIView!
    @IBOutlet private var moviesListContainer: UIView!
    @IBOutlet private var emptyDataLabel: UILabel!

    private var viewModel: MoviesListViewModel!  // 주입받음
    private var searchController = UISearchController(searchResultsController: nil)

    // MARK: - Factory Method (DI Container에서 호출)

    static func create(
        with viewModel: MoviesListViewModel,
        posterImagesRepository: PosterImagesRepository?
    ) -> MoviesListViewController {
        let view = MoviesListViewController.instantiateViewController()
        view.viewModel = viewModel
        return view
    }

    // MARK: - Lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()
        setupViews()
        bind(to: viewModel)  // ViewModel 바인딩
        viewModel.viewDidLoad()
    }

    // MARK: - Binding

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

    private func updateItems() {
        // TableView reload
    }

    private func updateLoading(_ loading: MoviesListViewModelLoading?) {
        switch loading {
        case .fullScreen:
            LoadingView.show()
        case .nextPage:
            moviesListContainer.isHidden = false
        case .none:
            LoadingView.hide()
            moviesListContainer.isHidden = viewModel.isEmpty
            emptyDataLabel.isHidden = !viewModel.isEmpty
        }
    }
}

// MARK: - UISearchBarDelegate

extension MoviesListViewController: UISearchBarDelegate {
    func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
        guard let searchText = searchBar.text else { return }
        viewModel.didSearch(query: searchText)  // ViewModel에 이벤트 전달
    }
}

7. Dependency Injection (DI Container)

모든 의존성을 한 곳에서 생성하고 주입

// Application/DIContainer/MoviesSceneDIContainer.swift

import UIKit

final class MoviesSceneDIContainer {

    struct Dependencies {
        let apiDataTransferService: DataTransferService
        let imageDataTransferService: DataTransferService
    }

    private let dependencies: Dependencies

    // Storages
    lazy var moviesQueriesStorage: MoviesQueriesStorage =
        CoreDataMoviesQueriesStorage(maxStorageLimit: 10)
    lazy var moviesResponseCache: MoviesResponseStorage =
        CoreDataMoviesResponseStorage()

    init(dependencies: Dependencies) {
        self.dependencies = dependencies
    }

    // MARK: - Use Cases

    func makeSearchMoviesUseCase() -> SearchMoviesUseCase {
        DefaultSearchMoviesUseCase(
            moviesRepository: makeMoviesRepository(),
            moviesQueriesRepository: makeMoviesQueriesRepository()
        )
    }

    // MARK: - Repositories

    func makeMoviesRepository() -> MoviesRepository {
        DefaultMoviesRepository(
            dataTransferService: dependencies.apiDataTransferService,
            cache: moviesResponseCache
        )
    }

    func makeMoviesQueriesRepository() -> MoviesQueriesRepository {
        DefaultMoviesQueriesRepository(
            moviesQueriesPersistentStorage: moviesQueriesStorage
        )
    }

    // MARK: - ViewController (진입점)

    func makeMoviesListViewController(
        actions: MoviesListViewModelActions
    ) -> MoviesListViewController {
        MoviesListViewController.create(
            with: makeMoviesListViewModel(actions: actions),
            posterImagesRepository: makePosterImagesRepository()
        )
    }

    // MARK: - ViewModel

    func makeMoviesListViewModel(
        actions: MoviesListViewModelActions
    ) -> MoviesListViewModel {
        DefaultMoviesListViewModel(
            searchMoviesUseCase: makeSearchMoviesUseCase(),
            actions: actions
        )
    }
}
DI 흐름:
AppDIContainer
    └── MoviesSceneDIContainer
            ├── makeSearchMoviesUseCase()
            │       ├── makeMoviesRepository()
            │       └── makeMoviesQueriesRepository()
            │
            └── makeMoviesListViewController()
                    └── makeMoviesListViewModel()
                            └── searchMoviesUseCase

8. 핵심 패턴 상세

8.1 Observable 패턴 (데이터 바인딩)

ViewModel의 상태 변경을 View에 알리는 간단한 구현 (RxSwift/Combine 없이)

// Presentation/Utils/Observable.swift

final class Observable<Value> {

    // 구독자 관리 (여러 View가 같은 값을 관찰할 수 있음)
    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
    }

    // View가 구독할 때 호출
    func observe(on observer: AnyObject, block: @escaping (Value) -> Void) {
        observers.append(Observer(observer: observer, block: block))
        block(value)  // 구독 즉시 현재 값 전달
    }

    // View 해제 시 구독 제거
    func remove(observer: AnyObject) {
        observers = observers.filter { $0.observer !== observer }
    }

    private func notifyObservers() {
        for observer in observers {
            DispatchQueue.main.async { // UI 업데이트는 메인 스레드
                observer.block(self.value)
            }
        }
    }
}
사용 예시:
// ViewModel
let items: Observable<[Item]> = Observable([])

// View (ViewController)
viewModel.items.observe(on: self) { [weak self] items in
    self?.tableView.reloadData()
}

8.2 Coordinator 패턴 (화면 전환)

ViewController에서 화면 전환 로직 분리. ViewModel은 화면 전환을 직접 하지 않고 Action 클로저 호출

// Presentation/MoviesScene/Flows/MoviesSearchFlowCoordinator.swift

protocol MoviesSearchFlowCoordinatorDependencies {
    func makeMoviesListViewController(
        actions: MoviesListViewModelActions
    ) -> MoviesListViewController
    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() {
        // Actions: ViewModel이 화면 전환 요청 시 호출될 클로저
        let actions = MoviesListViewModelActions(
            showMovieDetails: showMovieDetails,      // 상세 화면으로
            showMovieQueriesSuggestions: showMovieQueriesSuggestions,
            closeMovieQueriesSuggestions: closeMovieQueriesSuggestions
        )

        let vc = dependencies.makeMoviesListViewController(actions: actions)
        navigationController?.pushViewController(vc, animated: false)
    }

    // ViewModel이 actions.showMovieDetails(movie) 호출 → 이 메서드 실행
    private func showMovieDetails(movie: Movie) {
        let vc = dependencies.makeMoviesDetailsViewController(movie: movie)
        navigationController?.pushViewController(vc, animated: true)
    }
}
Coordinator 장점:
  • ViewController가 다른 ViewController를 몰라도 됨
  • 화면 흐름 변경 시 Coordinator만 수정
  • ViewModel 테스트 시 화면 전환 Mock 가능

8.3 Cancellable 패턴 (요청 취소)

네트워크 요청을 취소할 수 있게 하는 패턴. 새 검색 시 이전 요청 취소

// Domain/Interfaces/Cancellable.swift

protocol Cancellable {
    func cancel()
}

// Data/Repositories/RepositoryTask.swift

final class RepositoryTask: Cancellable {
    var networkTask: NetworkCancellable?
    var isCancelled: Bool = false

    func cancel() {
        networkTask?.cancel()
        isCancelled = true
    }
}

// ViewModel에서 사용
private var moviesLoadTask: Cancellable? {
    willSet {
        moviesLoadTask?.cancel()  // ⭐ 새 검색 시 이전 요청 자동 취소
    }
}

func didSearch(query: String) {
    moviesLoadTask = searchMoviesUseCase.execute(...)
}

9. Unit Testing 예시

9.1 UseCase 테스트 (Domain Layer)

// Domain Layer 테스트: Repository를 Mock으로 교체

final class SearchMoviesUseCaseTests: XCTestCase {

    // Mock Repository
    class MoviesRepositoryMock: MoviesRepository {
        var result: Result<MoviesPage, Error> = .success(MoviesPage.stub)

        func fetchMoviesList(
            query: MovieQuery, page: Int,
            cached: @escaping (MoviesPage) -> Void,
            completion: @escaping (Result<MoviesPage, Error>) -> Void
        ) -> Cancellable? {
            completion(result)
            return nil
        }
    }

    func test_execute_성공시_영화목록_반환() {
        // Given
        let mockRepo = MoviesRepositoryMock()
        mockRepo.result = .success(MoviesPage(page: 1, totalPages: 1, movies: [Movie.stub]))

        let useCase = DefaultSearchMoviesUseCase(
            moviesRepository: mockRepo,
            moviesQueriesRepository: MoviesQueriesRepositoryMock()
        )

        // When
        var resultMovies: [Movie] = []
        _ = useCase.execute(
            requestValue: .init(query: MovieQuery(query: "test"), page: 1),
            cached: { _ in },
            completion: { result in
                if case .success(let page) = result {
                    resultMovies = page.movies
                }
            }
        )

        // Then
        XCTAssertEqual(resultMovies.count, 1)
    }
}

9.2 ViewModel 테스트 (Presentation Layer)

// Presentation Layer 테스트: UseCase를 Mock으로 교체

final class MoviesListViewModelTests: XCTestCase {

    class SearchMoviesUseCaseMock: SearchMoviesUseCase {
        var result: Result<MoviesPage, Error> = .success(.stub)

        func execute(...) -> Cancellable? {
            completion(result)
            return nil
        }
    }

    func test_didSearch_성공시_items_업데이트() {
        // Given
        let mockUseCase = SearchMoviesUseCaseMock()
        mockUseCase.result = .success(MoviesPage(page: 1, totalPages: 1, movies: [.stub]))

        let viewModel = DefaultMoviesListViewModel(searchMoviesUseCase: mockUseCase)

        // When
        viewModel.didSearch(query: "Batman")

        // Then
        XCTAssertFalse(viewModel.items.value.isEmpty)
        XCTAssertEqual(viewModel.items.value.first?.title, Movie.stub.title)
    }
}
테스트 가능한 이유: Protocol 기반 의존성 주입 → Mock 객체로 교체 가능 → UI/네트워크 없이 로직만 테스트

10. 흔한 실수 & 주의사항

❌ Domain에 UIKit import

// Domain/Entities/Movie.swift
import UIKit  // ❌ 절대 금지!

struct Movie {
    let posterImage: UIImage?  // ❌ UIKit 타입
}

Domain은 순수 Swift만. 이미지는 URL String으로

❌ ViewModel에서 직접 화면 전환

// ViewModel
func didSelectItem(at index: Int) {
    let vc = MovieDetailViewController()
    navigationController?.push(vc)  // ❌
}

ViewModel은 UIKit 몰라야 함. Coordinator Action 사용

❌ DTO를 View까지 전달

// ViewModel
let items: Observable<[MovieDTO]>  // ❌ DTO 노출

DTO는 Data Layer 내부에서만. Entity로 변환 후 전달

❌ UseCase에서 직접 네트워크 호출

// UseCase
func execute() {
    URLSession.shared.dataTask(...)  // ❌
}

UseCase는 Repository Protocol만 사용

⚠️ 언제 Clean Architecture가 오버엔지니어링인가?

상황권장
간단한 1~2화면 앱 MVC로 충분. Clean Architecture는 과함
프로토타입 / MVP 빠른 개발 우선. 나중에 리팩터링
팀원이 1명 + 유지보수 계획 없음 단순한 구조가 나음
복잡한 비즈니스 로직 + 장기 유지보수 ✅ Clean Architecture 적합
여러 플랫폼 (iOS, macOS, watchOS) ✅ Domain Layer 재사용 가능

11. 핵심 정리

개념 위치 역할
Entity Domain 비즈니스 모델 (Movie, MoviesPage)
UseCase Domain 비즈니스 로직 (SearchMoviesUseCase)
Repository Protocol Domain 데이터 접근 인터페이스
Repository 구현 Data 실제 API/DB 호출
DTO Data API 응답 매핑 + Entity 변환
ViewModel Presentation UI 로직 + UseCase 호출
View Presentation UI 표시 + 사용자 이벤트
DI Container Application 의존성 생성/주입
Coordinator Presentation 화면 전환 로직
Clean Architecture 장점:
  • 테스트 용이: 각 레이어 독립 테스트 가능 (Mock 주입)
  • 유지보수: API 변경 → Data Layer만 수정
  • 확장성: 새 기능 = 새 UseCase 추가
  • 재사용: Domain Layer는 플랫폼 무관

12. Q&A

Q. Clean Architecture와 MVVM의 차이는?

MVVM은 Presentation Layer의 패턴 (View-ViewModel 관계)

Clean Architecture는 전체 앱 구조 (Domain/Data/Presentation 레이어 분리)

→ Clean Architecture 안에서 Presentation Layer에 MVVM을 적용한 것. 서로 다른 레벨의 개념

Q. Repository Protocol을 Domain에 두는 이유?

의존성 역전 (DIP)을 위해서

Domain이 Data에 의존하면 → Data 변경 시 Domain도 변경해야 함 (의존 방향 역전)

Protocol을 Domain에 두면 → Data가 Domain을 구현하는 형태 → Domain은 Data 몰라도 됨

Q. DTO와 Entity를 왜 분리하나?

DTO: API 응답 형식 그대로 (snake_case, Codable)

Entity: 앱 비즈니스에 최적화된 형태 (camelCase, 순수 Swift)

분리 이유: API 응답 형식 변경 → DTO만 수정, Entity는 그대로 → Domain Layer 영향 없음

Q. UseCase 하나에 여러 Repository 사용해도 되나?

가능. 하나의 비즈니스 로직이 여러 데이터 소스를 필요로 할 수 있음

예: SearchMoviesUseCase → MoviesRepository + MoviesQueriesRepository

단, UseCase가 너무 커지면 분리 고려 (단일 책임 원칙)

Q. ViewModel에서 UseCase 없이 Repository 직접 호출하면 안 되나?

기술적으로 가능하지만 권장하지 않음

UseCase 없이 → 비즈니스 로직이 ViewModel에 섞임 → 재사용 어려움, 테스트 어려움

단순 CRUD면 UseCase 생략할 수 있지만, 로직이 조금이라도 있으면 UseCase 분리 권장

Q. Coordinator 없이 ViewModel에서 화면 전환하면?

ViewModel이 UIKit에 의존하게 됨 → 테스트 어려움

ViewController가 다른 ViewController를 알아야 함 → 결합도 증가

Coordinator로 분리하면 → 화면 흐름 변경 시 Coordinator만 수정, ViewModel 테스트 시 Action Mock

Q. VIPER와 Clean Architecture 차이?
VIPERClean Architecture + MVVM
구성View, Interactor, Presenter, Entity, RouterDomain, Data, Presentation (MVVM)
화면 전환RouterCoordinator
특징화면 단위로 모듈화레이어 단위로 분리
복잡도더 많은 파일, 더 세분화상대적으로 적은 파일
Q. RxSwift/Combine 없이 Observable 쓰는 이유?

외부 의존성 최소화 (Clean Architecture 원칙)

간단한 바인딩만 필요하면 직접 구현으로 충분

복잡한 스트림 연산이 필요하면 RxSwift/Combine 도입 고려

Q. Repository Impl과 DTO 변환은 어느 레이어에? (2026-07-02)

흔한 오해: UseCase 층에 Repository Impl과 DTO 변환 로직이 있다

정답:

항목올바른 위치이유
Repository Interface (Protocol)DomainUseCase가 의존할 추상화
Repository ImplData실제 API/DB 호출은 바깥 레이어
DTO ↔ Entity 변환Data (Repository Impl 내부)외부 형식은 Data만 앎

UseCase는 DTO 존재 자체를 모른다 — Entity만 다룸

// ✅ UseCase - DTO 모름!
class GetUserUseCase {
    private let repository: UserRepository  // Protocol
    func execute(id: String) -> User {      // Entity만
        return repository.fetchUser(id: id)
    }
}

// ✅ Data Layer - DTO 변환 여기서!
class UserRepositoryImpl: UserRepository {
    func fetchUser(id: String) -> User {
        let dto: UserDTO = api.request(...)  // DTO로 받음
        return dto.toEntity()                 // Entity로 변환
    }
}

왜 이렇게? API 응답 형식이 바뀌어도 UseCase 코드는 안 바뀜 → 비즈니스 로직이 인프라에 의존하지 않음

Q. Domain = Entity + UseCase 둘 다 포함? (2026-07-02)

네, iOS 실무에서는 맞습니다.

구분Uncle Bob 원본iOS 실무
Entity별도 계층 (Enterprise Rules)Domain Layer에 함께 포함
UseCase별도 계층 (Application Rules)
이유대규모 엔터프라이즈모바일 앱은 규모가 작아서 합침
Domain Layer
├── Entity              ← 비즈니스 모델 (User, Movie 등)
├── UseCase             ← 비즈니스 로직
└── Repository Protocol ← 데이터 접근 인터페이스

그래서 "도메인 = Entity + UseCase"라고 말해도 맞습니다.

Q. Clean Architecture 보충 — UseCase Interface, DI Container, Coordinator (2026-07-03)

1. UseCase도 Interface(Protocol) 가질 수 있음

// UseCase Interface (테스트용)
protocol SearchMoviesUseCase {
    func execute(query: String) -> [Movie]
}

// UseCase Impl
class DefaultSearchMoviesUseCase: SearchMoviesUseCase {
    private let repository: MoviesRepository  // Repository Interface

    func execute(query: String) -> [Movie] {
        return repository.fetch(query: query)
    }
}

→ ViewModel이 UseCase Interface에 의존하면 UseCase도 Mock 가능 (테스트 용이)

2. 의존성 주입은 누가? → DI Container

// Application Layer
class DIContainer {
    func makeUseCase() -> SearchMoviesUseCase {
        DefaultSearchMoviesUseCase(
            repository: makeRepository()
        )
    }

    func makeRepository() -> MoviesRepository {
        DefaultMoviesRepository(api: makeAPI())
    }
}

앱 시작 시점에 DI Container가 모든 의존성을 조립해서 주입

3. 의존성 방향 = 항상 안쪽으로

UI → Domain ← Data
        ↑
    (중심, 아무것도 의존 안 함)
레이어의존 대상
UIDomain
DataDomain
Domain없음 (순수 Swift)

4. 화면 전환은 ViewModel이 아님 → Coordinator

// ViewModel은 Action 클로저만 호출
struct Actions {
    let showDetail: (Movie) -> Void
}

// Coordinator가 실제 화면 전환
class Coordinator {
    func showDetail(movie: Movie) {
        let vc = DetailViewController(movie: movie)
        navigationController.push(vc)
    }
}

→ ViewModel은 UIKit을 모름 (테스트 가능)

5. 전체 그림 (보완)

┌─────────────────────────────────────────────────────────────┐
│  Application (DI Container)                                 │
│    └── 모든 의존성 조립 & 주입                                │
└─────────────────────────────────────────────────────────────┘
                              ↓ 주입
┌─────────────────────────────────────────────────────────────┐
│  Presentation                                               │
│  ├── View (ViewController)                                  │
│  ├── ViewModel → UseCase Interface 의존                     │
│  │     └── Entity ↔ UIModel 변환                            │
│  └── Coordinator → 화면 전환                                 │
└────────────────────────┬────────────────────────────────────┘
                         │ Entity
┌────────────────────────▼────────────────────────────────────┐
│  Domain (아무것도 import 안 함)                              │
│  ├── Entity                                                 │
│  ├── UseCase Interface + Impl                               │
│  └── Repository Interface                                   │
└────────────────────────┬────────────────────────────────────┘
                         │ Entity
┌────────────────────────▼────────────────────────────────────┐
│  Data                                                       │
│  ├── Repository Impl                                        │
│  │     └── Entity ↔ DTO 변환                                │
│  └── DTO                                                    │
└────────────────────────┬────────────────────────────────────┘
                         │ DTO
┌────────────────────────▼────────────────────────────────────┐
│  Infrastructure                                             │
│  ├── Network (API Client)                                   │
│  └── Storage (CoreData, UserDefaults)                       │
└─────────────────────────────────────────────────────────────┘

13. iOS 아키텍처 비교

패턴 구조 장점 단점 적합한 경우
MVC Model-View-Controller 단순, 빠른 개발 Massive VC, 테스트 어려움 소규모 앱, 프로토타입
MVP Model-View-Presenter 테스트 용이 Presenter 비대화 중간 규모
MVVM Model-View-ViewModel 바인딩, 테스트 용이 과도한 바인딩 복잡성 데이터 바인딩 필요 시
VIPER View-Interactor-Presenter-Entity-Router 완전한 분리, 테스트 파일 많음, 러닝커브 대규모, 팀 프로젝트
Clean + MVVM Domain-Data-Presentation 레이어 분리, 재사용 초기 설정 복잡 장기 유지보수, 멀티플랫폼
TCA State-Action-Reducer-Environment 단방향, 예측 가능 러닝커브, 보일러플레이트 SwiftUI, 상태 관리 중심

2026-07-20 통합노트 보강 — 테스트 3원칙 · DIP 화살표 · 레이어별 Mock

아키텍처 패턴들의 공통 목적

MVVM/TCA/Clean 이름은 달라도 목표는 하나: UI / 로직(상태·비즈니스) / 외부의존(네트워크·DB)을 분리 → 로직을 UI·네트워크 없이 독립 테스트. 뷰컨에 다 때려박으면 테스트하려면 실제 서버+화면이 필요 → 사실상 불가능.

MVVM + DI로 테스트 가능하게

protocol FeedRepository { func fetchPosts() async throws -> [Post] }   // 추상화(테스트 핵심)

struct RealFeedRepository: FeedRepository {
    let client: NetworkClient
    func fetchPosts() async throws -> [Post] { try await client.request(GetFeedEndpoint()) }
}

@MainActor
final class FeedViewModel {
    enum State { case idle, loading, loaded([Post]), failed(String) }
    private(set) var state: State = .idle { didSet { onStateChange?(state) } }
    var onStateChange: ((State) -> Void)?
    private let repository: FeedRepository        // 프로토콜로 주입(구체 타입 아님)
    init(repository: FeedRepository) { self.repository = repository }
    func loadFeed() async {
        state = .loading
        do { state = .loaded(try await repository.fetchPosts()) }
        catch { state = .failed("불러오기 실패") }
    }
}

// 테스트: 가짜 Repository 주입 → 네트워크 없이
struct StubFeedRepository: FeedRepository {
    var result: Result<[Post], Error>
    func fetchPosts() async throws -> [Post] { try result.get() }
}

테스트 가능한 코드의 3대 원칙

  1. 의존성 주입(DI) — 의존을 내부에서 new 말고 밖에서 주입(URLSession.shared 직접 쓰면 못 바꿈).
  2. 프로토콜로 추상화 — 구체 타입 아니라 프로토콜에 의존. 테스트 땐 Mock/Stub.
  3. 명확한 입출력 — 로직을 "입력→상태변화"로. 사이드이펙트(네트워크·UI)를 경계 밖으로.

의존성 역전 원칙(DIP)의 핵심

핵심 = 프로토콜(추상)을 상위 레이어(도메인)가 소유·정의, 하위 레이어(데이터)가 구현 → 의존 화살표가 데이터→도메인(안쪽)으로 역전.

Presentation(ViewModel) --depends--> Domain(UseCase, Repository protocol)
                                          ^
                                          | implements (의존이 안쪽으로!)
                                      Data(RealRepository: 네트워크/DB)

결과: 도메인은 데이터를 전혀 모름. REST→GraphQL, CoreData→SQLite 바꿔도 도메인·ViewModel 안 건드림.

Clean 레이어별로 무엇을 Mock하나

테스트 대상Mock/주입
ViewModel가짜 UseCase(또는 Repository)
UseCase가짜 Repository
Repository impl가짜 NetworkClient

각 레이어가 아래 레이어를 프로토콜로 주입받으므로 한 레이어씩 격리 테스트. "주입 = 테스트 이음새". UseCase 두는 이유: 비즈니스 규칙(필터·정렬) 모아 재사용 + ViewModel 슬림 + 도메인 로직 격리 테스트(규칙 단순하면 생략하고 ViewModel→Repository 직결도 흔함 — 과설계 주의).

MVVM Input/Output 프로토콜 패턴 (Rx/Combine)

protocol FeedViewModelInput { func viewDidAppear(); func didTapRefresh() }   // VC가 호출
protocol FeedViewModelOutput { var items: Observable<[String]> { get }; var isLoading: Observable { get } }  // VC가 구독

Input = VC 생명주기/액션을 ViewModel에 알림(함수 호출). Output = ViewModel의 관찰 가능 상태(VC가 observe/bind). 경계가 프로토콜로 명확 + Output만 Mock하면 테스트 쉬움. Combine이면 @Published/AnyPublisher.

연결: 네트워크 레이어 계층·DTO↔도메인·토큰 리프레시 동시성·Codable 견고 파싱은 → 네트워크 레이어·견고한 파싱·토큰 리프레시. TCA 상세는 → TCA 문서, 모듈화 빌드타임은 → 모듈화·빌드·링킹 Q&A.