Clean Architecture + MVVM 완벽 가이드
kudoleh/iOS-Clean-Architecture-MVVM 프로젝트 기반 완전 분석
🔄 데이터 흐름 시퀀스 다이어그램
영화 검색 요청 → API 호출 → 화면 업데이트 전체 흐름
(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
💾 Data Layer
🖥️ Presentation Layer
🔗 전체 의존성 흐름
1. 왜 Clean Architecture인가?
🤔 해결하려는 문제
- ViewController에 모든 로직 집중
- 네트워크 + UI + 비즈니스 로직 혼재
- 테스트 불가능 (UIKit 의존)
- 재사용 불가능
- 관심사 분리 (레이어별 역할)
- 비즈니스 로직 독립 (Domain Layer)
- 각 레이어 독립 테스트 가능
- UI 프레임워크 교체 용이
📐 의존성 역전 원칙 (DIP)
// ❌ 잘못된 방식: 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. 프로젝트 폴더 구조
4. Domain Layer (비즈니스 핵심)
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?
}
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 ?? "")
)
}
}
- 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
)
}
}
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)
}
}
- 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)
}
}
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 | 화면 전환 로직 |
- 테스트 용이: 각 레이어 독립 테스트 가능 (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 차이?
| VIPER | Clean Architecture + MVVM | |
|---|---|---|
| 구성 | View, Interactor, Presenter, Entity, Router | Domain, Data, Presentation (MVVM) |
| 화면 전환 | Router | Coordinator |
| 특징 | 화면 단위로 모듈화 | 레이어 단위로 분리 |
| 복잡도 | 더 많은 파일, 더 세분화 | 상대적으로 적은 파일 |
Q. RxSwift/Combine 없이 Observable 쓰는 이유?
외부 의존성 최소화 (Clean Architecture 원칙)
간단한 바인딩만 필요하면 직접 구현으로 충분
복잡한 스트림 연산이 필요하면 RxSwift/Combine 도입 고려
Q. Repository Impl과 DTO 변환은 어느 레이어에? (2026-07-02)
흔한 오해: UseCase 층에 Repository Impl과 DTO 변환 로직이 있다
정답:
| 항목 | 올바른 위치 | 이유 |
|---|---|---|
| Repository Interface (Protocol) | Domain | UseCase가 의존할 추상화 |
| Repository Impl | Data | 실제 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
↑
(중심, 아무것도 의존 안 함)
| 레이어 | 의존 대상 |
|---|---|
| UI | Domain |
| Data | Domain |
| 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대 원칙
- 의존성 주입(DI) — 의존을 내부에서 new 말고 밖에서 주입(URLSession.shared 직접 쓰면 못 바꿈).
- 프로토콜로 추상화 — 구체 타입 아니라 프로토콜에 의존. 테스트 땐 Mock/Stub.
- 명확한 입출력 — 로직을 "입력→상태변화"로. 사이드이펙트(네트워크·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.