| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 | 31 |
- 백준
- 독서일기
- Flutter Toy Project
- 프로그래머스
- 영화기록
- 키노
- sopt ios
- 새벽녘 소소한 기록
- SWIFT
- 영화
- swift concurrency
- 플러터
- 자바공부
- java
- 영화일기
- MVVM-C
- 코딩공부
- 자바 스터디
- 영화후기
- 일기
- sopt 35기
- 토이프로젝트
- 티스토리챌린지
- IOS
- Flutter
- 자바
- 리뷰
- 오블완
- SOPT
- 영화리뷰
- Today
- Total
새벽의 기록
[iOS] SwiftUI에서 MVVM-C 패턴 톺아보기 본문
SwiftUI로 프로젝트를 진행하면서 MVVM-C (Model-View-ViewModel-Coordinator) 아키텍처를 도입하고, 특히 화면 전환 로직을 어떻게 효과적으로 분리하고 관리할 수 있을지에 대해 깊이 고민했던 경험을 기록하려고 한다.
UIKit 환경에서는 비교적 Coordinator 패턴에 대한 자료가 많지만, SwiftUI에서는 참고할 만한 예시가 부족해 직접 구조를 설계하며 고민하고 연구했던 내용을 적어보고자 한다.
https://dawning-record.tistory.com/m/105
[iOS] SwiftUI 프로젝트에서 MVVM-C를 선택한 이유와 고민들
프로젝트를 새롭게 시작하면서 디자인 패턴에 대한 고민이 많았다.특히 이번 프로젝트는 내가 처음으로 리드 역할을 맡은 프로젝트였고, SwiftUI에 익숙하지 않은 팀원도 있는 상황이었다.따라서
dawning-record.tistory.com
https://github.com/cerdeuk/CERTI-iOS
GitHub - cerdeuk/CERTI-iOS: At SOPT 36기 최우수상 Certi-iOS
At SOPT 36기 최우수상 Certi-iOS. Contribute to cerdeuk/CERTI-iOS development by creating an account on GitHub.
github.com
MVVM-C 도입, 그리고 새로운 고민의 시작
MVVM 아키텍처는 SwiftUI에서 가장 보편적이며 기초적인 디자인 패턴으로, 데이터 바인딩을 통해 View와 ViewModel을 분리하여 코드의 테스트 용이성과 유지보수성을 높여주는 훌륭한 패턴이다.
하지만 프로젝트를 진행하며 규모가 커질수록 화면 전환(Navigation)의 책임에 대한 불편성과 모호성에 대한 문제를 느낄 수 있었다.
초기에는 View에서 직접 화면 전환을 처리했지만, 이는 View가 너무 많은 책임을 갖게 만들어 재사용성을 떨어뜨렸다.
그래서 화면 흐름을 제어하는 'Coordinator'를 도입하여 이 문제를 해결하고자 했다.
인터넷에서 찾은 대부분의 MVVM-C 예제들은 ViewModel이 Coordinator를 직접 소유하고 호출하는 방식을 사용했다.
이는 View의 액션에 따라 ViewModel이 로직을 처리한 후, 다음 화면으로 전환하라는 명령을 Coordinator에게 내리는 흐름이니 언뜻 합리적으로 보일 수 있다.
하지만 나는 이 방식이 마음에 들지 않았다.
최근 클린 아키텍처를 도입하며 가장 경계했던 'Massive ViewModel(거대 뷰모델)' 문제를 다시 야기할 수 있다고 생각했기 때문이다. 비즈니스 로직과 상태 관리만으로도 ViewModel의 역할은 충분히 무거운데, 여기에 화면 전환 로직까지 더해진다면 ViewModel은 또다시 비대해질 것이라고 생각했다.
역할을 줄여놓은 ViewModel에게 다시 과도한 책임을 부여하는 것 같았다.
화면 전환만을 책임지는 CoordinatorView의 탄생
고민 끝에, 화면 전환의 책임을 온전히 위임받는 CoordinatorView라는 개념을 도입했다.
이 접근 방식의 핵심은 Coordinator를 View와 ViewModel에게서 완전히 분리하는 것이다.
1. ViewModel은 Coordinator를 모른다: ViewModel은 화면 전환이 필요하다는 상태만 관리할 뿐, Coordinator의 존재를 전혀 알지 못한다.
2. View는 ViewModel에게 상태 변경만 요청한다: View는 화면 전환이 필요할 때, ViewModel에 정의된 ViewRoute 열거형(enum)의 상태 변경을 요청한다.
3. ViewModel은 `ViewRoute` 상태만 변경한다: ViewModel은 View의 요청을 받아 ViewRoute의 값만 업데이트할 뿐, 실제 화면 전환 로직에는 관여하지 않는다.
4. `CoordinatorView`는 `ViewRoute`를 관찰하고 화면을 전환한다: CoordinatorView는 Factory로부터 ViewModel을 주입받아 ViewRoute의 변경을 감지한다. (onChange 활용)
그리고 변경된 ViewRoute 값에 따라 Coordinator의 메서드를 호출하여 실제 화면 전환을 실행한다.
이 구조를 통해 View, ViewModel, Coordinator 각자의 책임이 명확해지고, 화면 전환 로직이 한 곳(CoordinatorView)에서 중앙 집중적으로 관리되어 코드의 응집도와 재사용성이 높아졌다.

전체적인 아키텍처 흐름
이 구조를 도입한 프로젝트는 다음과 같은 계층적인 Coordinator 구조를 가진다.
1. AppCoordinator & AppCoordinatorView (앱의 최상위 흐름 제어)
- AppCoordinator는 앱의 가장 큰 상태(스플래시, 온보딩, 인증, 메인)를 관리한다.
- AppCoordinatorView는 AppCoordinator의 상태(appState)에 따라 적절한 View를 렌더링한다.
// AppCoordinator.swift
final class AppCoordinator: ObservableObject {
@Published var appState: AppRoute = .splash
let tabCoordinator = CertiTabCoordinator()
let onboardingCoordinator = OnboardingCoordinator()
// ...
}// AppCoordinatorView.swift
struct AppCoordinatorView: View {
@StateObject private var appCoordinator = AppCoordinator()
private let appDIContainer = AppDIContainer.shared
var body: some View {
switch appCoordinator.appState {
case .splash:
SplashView()
case .main:
CertiTabBarCoordinatorView(tabCoordinator: appCoordinator.tabCoordinator, /*...*/)
// ...
}
}
}`
2. CertiTabCoordinator & CertiTabBarCoordinatorView (메인 탭 화면 제어)
- CertiTabCoordinator는 탭바의 각 탭(홈, 카테고리, 추천, 이력서)에 해당하는 하위 Coordinator들을 소유하고, 현재 선택된 탭 상태(selectedTab)를 관리한다.
- CertiTabBarCoordinatorView는 CertiTabCoordinator의 selectedTab 상태에 따라 해당 탭의 CoordinatorView를 보여준다. 예를 들어, .home 탭이 선택되면 HomeCoordinatorView를 렌더링한다.
// CertiTabCoordinator.swift
class CertiTabCoordinator: ObservableObject {
@Published var selectedTab: CertiTabRoute = .home
let homeCoordinator = HomeCoordinator()
let categoryCoordinator = CategoryCoordinator()
// ...
}// CertiTabBarCoordinatorView.swift
struct CertiTabBarCoordinatorView: View {
@ObservedObject var tabCoordinator: CertiTabCoordinator
private let appDIContainer: AppDIContainer
init(tabCoordinator: CertiTabCoordinator, appDIContainer: AppDIContainer) {
self.tabCoordinator = tabCoordinator
self.appDIContainer = appDIContainer
}
var body: some View {
// ...
switch tabCoordinator.selectedTab {
case .home:
HomeCoordinatorView(homeCoordinator: tabCoordinator.homeCoordinator, /*...*/)
// ...
}
// ...
}
}
3. HomeCoordinator & HomeCoordinatorView (개별 탭 내의 네비게이션)
- HomeCoordinator는 NavigationPath를 사용하여 홈 탭 내의 네비게이션 스택을 관리한다. push, pop, reset과 같은 메서드를 통해 화면 이동을 처리한다.
- HomeCoordinatorView는 NavigationStack을 설정하고, HomeViewModel의 homeViewRoute 상태 변경을 감지하여 HomeCoordinator의 메서드를 호출한다.
// HomeCoordinator.swift
final class HomeCoordinator: ObservableObject {
//MARK: - Property Wrappers
@Published var path = NavigationPath()
//MARK: - Method
//다음에 보여질 view를 navigationStack에 push
func push(next route: HomeRoute) {
path.append(route)
}
//현재 view를 navigationStack에서 pop
func pop() {
path.removeLast()
}
//맨 처음으로 돌아감(navigationStack 초기화)
func reset() {
path = NavigationPath()
}
// ...
}// HomeCoordinatorView.swift
struct HomeCoordinatorView: View {
@ObservedObject var homeCoordinator: HomeCoordinator
@StateObject private var homeViewModel: HomeViewModel
private let homeFactory: HomeFactory
init(homeCoordinator: HomeCoordinator, homeFactory: HomeFactory) {
self.homeCoordinator = homeCoordinator
self.homeFactory = homeFactory
_homeViewModel = StateObject(wrappedValue: homeFactory.makeHomeViewModel())
}
var body: some View {
NavigationStack(path: $homeCoordinator.path) {
HomeView(viewModel: homeViewModel)
.onChange(of: homeViewModel.homeViewRoute) { route in
guard let route = route else { return }
switch route {
case .navigateToPreLicenseEdit:
homeCoordinator.push(next: .preLicenseEdit)
// ...
}
}
.navigationDestination(for: HomeRoute.self) { route in
switch route {
case .preLicenseEdit:
PreLicenseEditView(viewModel: homeViewModel)
// ...
}
}
// ...
}
}
}
4. HomeViewModel & HomeView (화면 전환 요청)
- HomeViewModel은 화면 전환을 위한 HomeViewRoute 열거형을 가지고 있다.
- HomeView에서는 특정 액션(버튼 클릭 등)이 발생했을 때 homeViewModel의 navigateToPreLicenseEdit() 같은 메서드를 호출하여 homeViewRoute의 상태를 변경하도록 요청한다.
// HomeViewModel.swift
enum HomeViewRoute {
case navigateToPreLicenseEdit
// ...
}
@MainActor
final class HomeViewModel: ObservableObject {
@Published var homeViewRoute: HomeViewRoute?
// ...
// MARK: - Navigation Func
func navigateToCertificateDetail() {
homeViewRoute = .navigateToCertificateDetail
}
// ...
}
이 아키텍처의 장점
- 관심사 분리 (soc): View는 UI 렌더링, ViewModel은 상태 관리 및 비즈니스 로직, Coordinator는 화면 전환 로직에만 집중할 수 있다.
- Massive ViewModel 방지: ViewModel이 화면 전환 로직을 전혀 알지 못하므로, 비대해지는 것을 근본적으로 방지한다
- 느슨한 결합 (Loose Coupling): ViewModel과 Coordinator가 서로를 몰라도 되므로 의존성이 크게 낮아지고, 각 컴포넌트의 독립성과 재사용성이 향상된다.
- 중앙집중식 내비게이션 관리: CoordinatorView에서 내비게이션 로직을 모두 처리하므로 앱의 전체적인 흐름을 파악하고 수정하기 용이하다.
- 테스트 용이성: 각 컴포넌트가 독립적으로 작동하므로 단위 테스트 작성이 수월해진다.
마무리하며
SwiftUI에서 Coordinator 패턴을 적용하는 것은 참고 자료가 부족하여, 한정된 시간안에 구현해야 했던 내게는 꽤나 큰 도전적인 과제였다. 하지만 일반적인 방식에 의문을 품고, ViewModel의 책임을 최소화하려는 노력 끝에 CoordinatorView라는 나름의 해결책을 찾을 수 있었다.
이 구조는 화면 전환 로직을 효과적으로 분리하고, 더 깔끔하고 유지보수하기 좋은 코드를 작성하는 데 큰 도움이 되었다.
혹시 나처럼 SwiftUI와 MVVM-C 아키텍처, 그리고 Massive ViewModel에 대해 고민하는 개발자가 있다면 이 글이 작은 도움이 되기를 바란다.
또한 여전히 구조에 대한 고민을 하고 있기에, 어떠한 지적이나 피드백도 환영한다.
'[iOS]' 카테고리의 다른 글
| [iOS] Swift Concurrency 박살내기 #1 - iOS에서의 동시성과 비동기 프로그래밍 방법 (0) | 2025.10.18 |
|---|---|
| [iOS] Swift Concurrency 박살내기 #0 - 동시성과 비동기 (0) | 2025.10.15 |
| [iOS] 클린아키텍처 공부할 때 주저리주저리 썼던 거 (0) | 2025.10.01 |
| [iOS] SwiftUI+MVVM-C 프로젝트 클린 아키텍처 도입 (0) | 2025.10.01 |
| [iOS] CoreData vs SwiftData vs Realm? 로컬 데이터베이스 비교 (0) | 2025.09.12 |
