콘텐츠 대표 이미지 - iOS 디자인 패턴과MVC · MVVM · Coordinator 아키텍처 완전 정복 🏗️
📱 iOS Architecture Deep Dive

iOS 디자인 패턴과
MVC · MVVM · Coordinator 아키텍처 완전 정복 🏗️

앱이 커질수록 중요해지는 구조 설계, 지금 제대로 파헤쳐보자!

야, 솔직히 말해볼게. 🤔
처음 iOS 개발 시작했을 때 ViewController 하나에 모든 코드 다 때려박은 경험 있지 않아?
네트워크 호출도 거기, UI 업데이트도 거기, 데이터 파싱도 거기... 그러다 나중에 그 파일 열면 스크롤이 끝도 없이 내려가는 그 공포 😱

이걸 개발자들 사이에서 Massive View Controller 라고 부르는데, iOS 개발자라면 한 번쯤은 겪어봤을 거야.
그래서 오늘은 이 문제를 해결하기 위한 iOS 디자인 패턴과 아키텍처에 대해 친구처럼 편하게 얘기해볼게.

MVC, MVVM, Coordinator — 이 세 가지를 제대로 이해하면 코드가 얼마나 깔끔해지는지, 그리고 팀 프로젝트에서 얼마나 협업이 쉬워지는지 직접 느낄 수 있을 거야. 🚀

iOS 아키텍처 패턴 한눈에 보기 MVC Model-View-Controller 📦 Model 🖼️ View 🎮 Controller Apple 기본 권장 간단하고 직관적 ⚠️ Massive VC 위험 MVVM Model-View-ViewModel 📦 Model 🖼️ View ⚙️ ViewModel 테스트 용이 데이터 바인딩 활용 ✨ 현업 인기 패턴 Coordinator 화면 흐름 전담 관리 🗺️ AppCoordinator 🔀 화면 전환 로직 🧩 의존성 주입 VC 독립성 확보 재사용성 극대화 🚀 MVVM과 찰떡궁합

🤔 1. 왜 아키텍처가 필요한가? — 진짜 현실적인 이야기

아키텍처 얘기를 시작하기 전에, 먼저 왜 이게 필요한지부터 공감해보자.
처음 앱 만들 때는 솔직히 아키텍처 같은 거 신경 안 써도 돼. 화면 하나, 기능 두세 개면 그냥 ViewController에 다 넣어도 잘 돌아가거든.

근데 앱이 커지면? 상황이 달라져. 😅

📌 아키텍처 없이 개발하면 생기는 일들

1. 코드 파악이 불가능해진다
3개월 전에 내가 짠 코드인데 뭐가 뭔지 모르겠는 상황. 팀원이 짠 코드는 더 심각.

2. 버그 수정이 두렵다
A 기능 고쳤더니 B 기능이 망가지는 연쇄 반응. 이른바 "두더지 잡기 게임" 🐹

3. 테스트가 불가능하다
UI 로직이랑 비즈니스 로직이 뒤섞여 있으면 유닛 테스트 작성 자체가 불가능.

4. 협업이 지옥이 된다
같은 파일을 두 명이 동시에 수정하다 merge conflict 지옥 경험해봤지? 😭

이런 문제들을 해결하기 위해 개발자들이 수십 년간 고민하고 만들어낸 게 바로 디자인 패턴이야.
디자인 패턴은 "이런 상황에서는 이렇게 코드를 구조화하면 좋더라"는 검증된 해결책이라고 보면 돼.

iOS에서 가장 많이 쓰이는 패턴이 바로 MVC, MVVM, 그리고 Coordinator야.
각각의 특징과 장단점을 하나씩 파헤쳐보자! 💪

💡 Tip: 아키텍처 패턴은 "정답"이 없어. 프로젝트 규모, 팀 구성, 개발 기간에 따라 최적의 선택이 달라져. 중요한 건 각 패턴의 특징을 제대로 이해하는 것!

🏛️ 2. MVC — Apple이 사랑하는 기본 패턴

MVC는 Model-View-Controller의 약자야.
Apple이 UIKit을 설계할 때 기본으로 채택한 패턴이고, iOS 개발을 시작하면 자연스럽게 MVC를 쓰게 돼.
Xcode 프로젝트 템플릿 자체가 MVC 구조로 되어 있거든! 📱

📦 MVC의 세 가지 구성 요소
Model (모델) 📦
앱의 데이터와 비즈니스 로직을 담당해.
사용자 정보, 상품 목록, API 응답 데이터 같은 것들이 여기 들어가.
UI에 대해서는 전혀 모르고, 순수하게 데이터만 다뤄.

예시: User, Product, NetworkManager 같은 클래스들
View (뷰) 🖼️
사용자에게 보여지는 UI 요소들이야.
버튼, 레이블, 테이블뷰, 컬렉션뷰 같은 것들.
iOS에서는 UIView와 그 서브클래스들이 여기에 해당해.
데이터를 직접 가지지 않고, Controller가 전달해주는 데이터를 표시하는 역할만 해.
Controller (컨트롤러) 🎮
Model과 View 사이의 중간 다리 역할을 해.
iOS에서는 UIViewController가 이 역할을 담당해.
사용자 입력을 받아서 Model에 전달하고, Model의 변화를 View에 반영하는 조율자야.
🔄 MVC의 데이터 흐름
MVC 데이터 흐름 📦 Model 데이터 · 비즈니스 로직 🎮 Controller UIViewController 🖼️ View UIView 계층 Notification/ KVO/Delegate 데이터 요청 UI 업데이트 사용자 액션 ⚠️ iOS MVC의 현실적인 문제 View와 Controller가 UIViewController 안에서 강하게 결합 → Massive View Controller 발생!
💻 MVC 코드 예시

간단한 사용자 목록을 보여주는 앱을 MVC로 구현해보자:

// MARK: - Model
struct User {
    let id: Int
    let name: String
    let email: String
}

// 비즈니스 로직 담당
class UserService {
    func fetchUsers(completion: @escaping ([User]) -> Void) {
        // API 호출 시뮬레이션
        let users = [
            User(id: 1, name: "김철수", email: "kim@example.com"),
            User(id: 2, name: "이영희", email: "lee@example.com")
        ]
        completion(users)
    }
}
// MARK: - View (UITableViewCell)
class UserCell: UITableViewCell {
    @IBOutlet weak var nameLabel: UILabel!
    @IBOutlet weak var emailLabel: UILabel!
    
    func configure(with user: User) {
        nameLabel.text = user.name
        emailLabel.text = user.email
    }
}
// MARK: - Controller (UIViewController)
class UserListViewController: UIViewController {
    
    @IBOutlet weak var tableView: UITableView!
    
    // 문제: Controller가 Model도 알고, View도 알고, 
    // 심지어 네트워크 로직까지 다 알아야 함
    private let userService = UserService()
    private var users: [User] = []
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupTableView()
        loadUsers()
    }
    
    private func setupTableView() {
        tableView.delegate = self
        tableView.dataSource = self
        tableView.register(UserCell.self, 
                          forCellReuseIdentifier: "UserCell")
    }
    
    private func loadUsers() {
        userService.fetchUsers { [weak self] users in
            DispatchQueue.main.async {
                self?.users = users
                self?.tableView.reloadData()
            }
        }
    }
}

// MARK: - UITableViewDataSource
extension UserListViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, 
                   numberOfRowsInSection section: Int) -> Int {
        return users.count
    }
    
    func tableView(_ tableView: UITableView, 
                   cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(
            withIdentifier: "UserCell", 
            for: indexPath
        ) as! UserCell
        cell.configure(with: users[indexPath.row])
        return cell
    }
}

// MARK: - UITableViewDelegate
extension UserListViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, 
                   didSelectRowAt indexPath: IndexPath) {
        let user = users[indexPath.row]
        // 화면 전환 로직도 여기에... 점점 커지는 VC 😅
        let detailVC = UserDetailViewController()
        detailVC.user = user
        navigationController?.pushViewController(detailVC, animated: true)
    }
}
✅ MVC의 장단점 정리
구분 내용
✅ 장점 • Apple 공식 지원, UIKit과 자연스럽게 통합
• 학습 곡선이 낮아 입문자에게 적합
• 소규모 프로젝트에서 빠른 개발 가능
• 코드 구조가 직관적
❌ 단점 • View와 Controller의 강한 결합 (UIViewController)
• 앱 규모가 커질수록 ViewController 비대화
• 비즈니스 로직 테스트가 어려움
• 화면 전환 로직이 ViewController에 집중
🎯 적합한 상황 • 소규모 프로토타입 앱
• 빠른 MVP 개발
• 1인 개발 프로젝트
⚠️ 주의: iOS의 MVC는 원래 MVC 패턴과 다르게 동작해. UIViewController가 View의 생명주기를 직접 관리하기 때문에 View와 Controller가 사실상 하나로 묶여버려. 그래서 일부 개발자들은 iOS MVC를 "MassiveViewController"라고 비꼬기도 해 😂

⚙️ 3. MVVM — 현업에서 가장 사랑받는 패턴

MVVM은 Model-View-ViewModel의 약자야.
Microsoft의 Ken Cooper와 Ted Peters가 2005년에 처음 제안한 패턴인데,
iOS 개발에서는 특히 RxSwift, Combine 같은 반응형 프레임워크와 함께 폭발적으로 인기를 얻었어.

MVC의 가장 큰 문제인 Massive View Controller를 해결하기 위해 등장한 패턴이라고 보면 돼! 🎯

🔑 MVVM의 핵심 아이디어

MVVM의 핵심은 ViewModel이라는 새로운 레이어를 추가하는 거야.
ViewController(View)에서 비즈니스 로직을 분리해서 ViewModel로 옮기는 거지.
그러면 ViewController는 정말 UI 관련 코드만 담당하게 돼! 😎

Model (모델) 📦
MVC와 동일하게 데이터와 비즈니스 로직을 담당해.
네트워크 요청, 데이터베이스 접근, 데이터 파싱 등이 여기에 포함돼.
View (뷰) 🖼️
MVVM에서 View는 UIViewController + UIView를 모두 포함해.
ViewController가 View의 일부로 취급되는 거야.
ViewModel을 관찰(observe)하고, 변화가 생기면 UI를 업데이트하는 역할만 해.
비즈니스 로직은 전혀 없어야 해!
ViewModel (뷰모델) ⚙️
MVVM의 핵심! View와 Model 사이의 중간 레이어야.
View에서 필요한 데이터를 가공하고, 사용자 액션을 처리하는 비즈니스 로직이 여기에 들어가.
중요한 점: ViewModel은 View(UIKit)에 대해 전혀 몰라야 해!
그래서 UIKit import 없이도 독립적으로 테스트할 수 있어. 🧪
MVVM 데이터 바인딩 흐름 🖼️ View UIViewController + UIView ⚙️ ViewModel 비즈니스 로직 데이터 가공 📦 Model 데이터 구조 네트워크/DB 사용자 액션 데이터 바인딩 데이터 요청 데이터 응답 📡 데이터 바인딩 방법들 Combine Apple 공식 반응형 iOS 13+ 지원 RxSwift 서드파티 반응형 풍부한 생태계 클로저/Delegate 전통적인 방식 의존성 없음
💻 MVVM 코드 예시 (Combine 활용)

같은 사용자 목록 앱을 MVVM으로 구현해보자. 코드가 얼마나 깔끔해지는지 비교해봐!

// MARK: - Model (MVC와 동일)
struct User {
    let id: Int
    let name: String
    let email: String
}

class UserService {
    func fetchUsers() -> AnyPublisher<[User], Error> {
        // Combine을 활용한 네트워크 요청
        return Future { promise in
            let users = [
                User(id: 1, name: "김철수", email: "kim@example.com"),
                User(id: 2, name: "이영희", email: "lee@example.com")
            ]
            promise(.success(users))
        }
        .eraseToAnyPublisher()
    }
}
// MARK: - ViewModel
// 핵심: UIKit을 import하지 않아도 됨!
import Combine
import Foundation

class UserListViewModel {
    
    // Output: View가 관찰할 Published 프로퍼티들
    @Published var users: [User] = []
    @Published var isLoading: Bool = false
    @Published var errorMessage: String? = nil
    
    // 가공된 데이터 (View에서 바로 사용 가능한 형태)
    var userCount: String {
        return "총 \(users.count)명의 사용자"
    }
    
    private let userService: UserService
    private var cancellables = Set<AnyCancellable>()
    
    init(userService: UserService = UserService()) {
        self.userService = userService
    }
    
    // Input: View에서 호출하는 메서드
    func loadUsers() {
        isLoading = true
        
        userService.fetchUsers()
            .receive(on: DispatchQueue.main)
            .sink(
                receiveCompletion: { [weak self] completion in
                    self?.isLoading = false
                    if case .failure(let error) = completion {
                        self?.errorMessage = error.localizedDescription
                    }
                },
                receiveValue: { [weak self] users in
                    self?.users = users
                }
            )
            .store(in: &cancellables)
    }
    
    func selectUser(at index: Int) -> User {
        return users[index]
    }
}
// MARK: - View (UIViewController)
// 이제 ViewController는 UI 관련 코드만!
import UIKit
import Combine

class UserListViewController: UIViewController {
    
    @IBOutlet weak var tableView: UITableView!
    @IBOutlet weak var loadingIndicator: UIActivityIndicatorView!
    @IBOutlet weak var countLabel: UILabel!
    
    // ViewModel 주입 (의존성 주입)
    var viewModel: UserListViewModel!
    private var cancellables = Set<AnyCancellable>()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupTableView()
        bindViewModel()
        viewModel.loadUsers()
    }
    
    private func setupTableView() {
        tableView.delegate = self
        tableView.dataSource = self
    }
    
    // 핵심: ViewModel 바인딩
    private func bindViewModel() {
        // users 변화 감지 → 테이블뷰 리로드
        viewModel.$users
            .receive(on: DispatchQueue.main)
            .sink { [weak self] _ in
                self?.tableView.reloadData()
                self?.countLabel.text = self?.viewModel.userCount
            }
            .store(in: &cancellables)
        
        // 로딩 상태 바인딩
        viewModel.$isLoading
            .receive(on: DispatchQueue.main)
            .sink { [weak self] isLoading in
                isLoading 
                    ? self?.loadingIndicator.startAnimating()
                    : self?.loadingIndicator.stopAnimating()
            }
            .store(in: &cancellables)
        
        // 에러 처리
        viewModel.$errorMessage
            .compactMap { $0 }
            .receive(on: DispatchQueue.main)
            .sink { [weak self] message in
                self?.showAlert(message: message)
            }
            .store(in: &cancellables)
    }
    
    private func showAlert(message: String) {
        let alert = UIAlertController(
            title: "오류", 
            message: message, 
            preferredStyle: .alert
        )
        alert.addAction(UIAlertAction(title: "확인", style: .default))
        present(alert, animated: true)
    }
}

// MARK: - UITableViewDataSource
extension UserListViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, 
                   numberOfRowsInSection section: Int) -> Int {
        return viewModel.users.count
    }
    
    func tableView(_ tableView: UITableView, 
                   cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(
            withIdentifier: "UserCell", 
            for: indexPath
        ) as! UserCell
        cell.configure(with: viewModel.users[indexPath.row])
        return cell
    }
}

// MARK: - UITableViewDelegate
extension UserListViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, 
                   didSelectRowAt indexPath: IndexPath) {
        let user = viewModel.selectUser(at: indexPath.row)
        // 화면 전환은 Coordinator에게 위임 (다음 섹션에서 설명!)
        // coordinator?.showUserDetail(user: user)
    }
}
💡 MVVM의 핵심 포인트: ViewModel은 UIKit을 전혀 몰라야 해! import UIKit이 없어야 진정한 MVVM이야. 그래야 ViewModel만 독립적으로 유닛 테스트할 수 있거든. 테스트 코드 작성이 엄청 쉬워져! 🧪
🧪 MVVM의 테스트 코드 예시
// MARK: - ViewModel 유닛 테스트
import XCTest
import Combine
@testable import MyApp

class UserListViewModelTests: XCTestCase {
    
    var viewModel: UserListViewModel!
    var cancellables = Set<AnyCancellable>()
    
    override func setUp() {
        super.setUp()
        // Mock 서비스 주입 (실제 네트워크 없이 테스트!)
        viewModel = UserListViewModel(userService: MockUserService())
    }
    
    func testLoadUsers_Success() {
        // Given
        let expectation = XCTestExpectation(description: "사용자 로드 완료")
        
        // When
        viewModel.$users
            .dropFirst()
            .sink { users in
                // Then
                XCTAssertEqual(users.count, 2)
                XCTAssertEqual(users[0].name, "김철수")
                expectation.fulfill()
            }
            .store(in: &cancellables)
        
        viewModel.loadUsers()
        
        wait(for: [expectation], timeout: 2.0)
    }
    
    func testUserCount_DisplayString() {
        // Given
        viewModel.users = [
            User(id: 1, name: "테스트", email: "test@test.com")
        ]
        
        // Then
        XCTAssertEqual(viewModel.userCount, "총 1명의 사용자")
    }
}

봐봐! ViewController 없이도 ViewModel만 독립적으로 테스트할 수 있어.
이게 MVVM의 가장 큰 장점 중 하나야. 🎉

✅ MVVM의 장단점 정리
구분 내용
✅ 장점 • ViewController 코드 대폭 감소
• ViewModel 독립 테스트 가능
• 데이터 바인딩으로 UI 동기화 자동화
• 관심사 분리(SoC) 원칙 준수
• 팀 협업 시 역할 분담 명확
❌ 단점 • MVC보다 학습 곡선 높음
• 간단한 화면에도 ViewModel 파일 생성 필요
• 데이터 바인딩 프레임워크 의존성
• 화면 전환 로직은 여전히 해결 안 됨
• 초기 보일러플레이트 코드 많음
🎯 적합한 상황 • 중대형 프로젝트
• 테스트 코드 작성이 중요한 프로젝트
• 팀 협업 프로젝트
• 복잡한 비즈니스 로직이 있는 앱

🗺️ 4. Coordinator 패턴 — 화면 전환의 구원자

자, 이제 MVVM을 쓰면 ViewController가 많이 가벼워졌어.
근데 아직 해결 안 된 문제가 하나 있어. 바로 화면 전환 로직이야! 😤

MVVM을 써도 navigationController?.pushViewController(...) 같은 코드는 여전히 ViewController 안에 있잖아.
이게 왜 문제냐면:

🚨 화면 전환 로직이 ViewController에 있을 때의 문제

1. ViewController 간 강한 결합
A화면이 B화면을 직접 생성하고 알아야 함. A를 재사용하려면 B도 같이 따라와야 해.

2. 딥링크 처리가 복잡
앱 외부에서 특정 화면으로 바로 이동해야 할 때 로직이 여기저기 흩어져 있어.

3. 의존성 주입이 어려움
A → B → C로 이동할 때 A에서 C까지 데이터를 전달하려면 B를 거쳐야 해서 코드가 지저분해져.

4. 테스트 불가
화면 전환 로직을 테스트하려면 실제 UINavigationController가 필요해.

이 문제를 해결하기 위해 Soroush Khanlou가 2015년에 iOS 개발에 Coordinator 패턴을 소개했어.
(원래는 Martin Fowler의 "Application Controller" 패턴에서 영감을 받은 거야)

핵심 아이디어는 간단해: 화면 전환 로직을 ViewController에서 분리해서 Coordinator라는 별도 객체에게 맡기자!

Coordinator 패턴 구조 🗺️ AppCoordinator 앱 전체 흐름 관리 · 루트 코디네이터 🔐 AuthCoordinator 로그인 · 회원가입 흐름 🏠 MainCoordinator 메인 탭 · 홈 흐름 ⚙️ SettingsCoordinator 설정 화면 흐름 LoginVC 화면만 담당 SignUpVC 화면만 담당 HomeVC 화면만 담당 DetailVC 화면만 담당 ProfileVC 화면만 담당 NotificationVC 화면만 담당 🎯 핵심: ViewController는 다음 화면을 전혀 몰라도 됨! 화면 전환이 필요하면 Coordinator에게 "이벤트"만 전달 → Coordinator가 알아서 처리
💻 Coordinator 패턴 코드 구현

먼저 Coordinator 프로토콜을 정의하자:

// MARK: - Coordinator 프로토콜
protocol Coordinator: AnyObject {
    var childCoordinators: [Coordinator] { get set }
    var navigationController: UINavigationController { get set }
    
    func start()
}

// 편의 메서드 추가
extension Coordinator {
    func addChildCoordinator(_ coordinator: Coordinator) {
        childCoordinators.append(coordinator)
    }
    
    func removeChildCoordinator(_ coordinator: Coordinator) {
        childCoordinators = childCoordinators.filter { $0 !== coordinator }
    }
}
// MARK: - AppCoordinator
class AppCoordinator: Coordinator {
    var childCoordinators: [Coordinator] = []
    var navigationController: UINavigationController
    
    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }
    
    func start() {
        // 앱 시작 시 로그인 여부에 따라 분기
        if UserDefaults.standard.bool(forKey: "isLoggedIn") {
            showMainFlow()
        } else {
            showAuthFlow()
        }
    }
    
    private func showAuthFlow() {
        let authCoordinator = AuthCoordinator(
            navigationController: navigationController
        )
        authCoordinator.delegate = self
        addChildCoordinator(authCoordinator)
        authCoordinator.start()
    }
    
    private func showMainFlow() {
        let mainCoordinator = MainCoordinator(
            navigationController: navigationController
        )
        addChildCoordinator(mainCoordinator)
        mainCoordinator.start()
    }
}

// MARK: - AuthCoordinatorDelegate
extension AppCoordinator: AuthCoordinatorDelegate {
    func authCoordinatorDidFinish(_ coordinator: AuthCoordinator) {
        removeChildCoordinator(coordinator)
        showMainFlow()
    }
}
// MARK: - AuthCoordinator
protocol AuthCoordinatorDelegate: AnyObject {
    func authCoordinatorDidFinish(_ coordinator: AuthCoordinator)
}

class AuthCoordinator: Coordinator {
    var childCoordinators: [Coordinator] = []
    var navigationController: UINavigationController
    weak var delegate: AuthCoordinatorDelegate?
    
    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }
    
    func start() {
        showLogin()
    }
    
    private func showLogin() {
        // ViewModel 생성 및 의존성 주입
        let viewModel = LoginViewModel()
        viewModel.coordinator = self
        
        let loginVC = LoginViewController()
        loginVC.viewModel = viewModel
        
        navigationController.setViewControllers([loginVC], animated: false)
    }
    
    func showSignUp() {
        let viewModel = SignUpViewModel()
        viewModel.coordinator = self
        
        let signUpVC = SignUpViewController()
        signUpVC.viewModel = viewModel
        
        navigationController.pushViewController(signUpVC, animated: true)
    }
    
    func loginDidSucceed() {
        delegate?.authCoordinatorDidFinish(self)
    }
}
// MARK: - LoginViewModel (Coordinator 참조)
class LoginViewModel {
    weak var coordinator: AuthCoordinator?
    
    @Published var email: String = ""
    @Published var password: String = ""
    @Published var isLoading: Bool = false
    
    func login() {
        isLoading = true
        
        // 로그인 로직...
        AuthService.shared.login(email: email, password: password) { [weak self] result in
            DispatchQueue.main.async {
                self?.isLoading = false
                switch result {
                case .success:
                    // 화면 전환은 Coordinator에게 위임!
                    self?.coordinator?.loginDidSucceed()
                case .failure(let error):
                    print("로그인 실패: \(error)")
                }
            }
        }
    }
    
    func navigateToSignUp() {
        coordinator?.showSignUp()
    }
}

// MARK: - LoginViewController
class LoginViewController: UIViewController {
    var viewModel: LoginViewModel!
    private var cancellables = Set<AnyCancellable>()
    
    @IBOutlet weak var emailTextField: UITextField!
    @IBOutlet weak var passwordTextField: UITextField!
    @IBOutlet weak var loginButton: UIButton!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        bindViewModel()
    }
    
    private func bindViewModel() {
        // 텍스트 필드 → ViewModel 바인딩
        emailTextField.textPublisher
            .assign(to: &viewModel.$email)
        
        passwordTextField.textPublisher
            .assign(to: &viewModel.$password)
        
        // 로딩 상태 바인딩
        viewModel.$isLoading
            .receive(on: DispatchQueue.main)
            .sink { [weak self] isLoading in
                self?.loginButton.isEnabled = !isLoading
            }
            .store(in: &cancellables)
    }
    
    @IBAction func loginButtonTapped(_ sender: UIButton) {
        viewModel.login()
        // 화면 전환 코드가 없어! ViewModel이 Coordinator에게 위임
    }
    
    @IBAction func signUpButtonTapped(_ sender: UIButton) {
        viewModel.navigateToSignUp()
        // 이것도 마찬가지!
    }
}
💡 Coordinator의 핵심 포인트:
LoginViewController는 SignUpViewController의 존재를 전혀 몰라! 그냥 ViewModel에게 "회원가입 화면으로 가고 싶어"라고 이벤트만 전달하면, ViewModel이 Coordinator에게 위임하고, Coordinator가 실제 화면 전환을 처리해. 완벽한 역할 분리! 🎯
✅ Coordinator의 장단점 정리
구분 내용
✅ 장점 • ViewController 간 결합도 제거
• 화면 전환 로직 한 곳에서 관리
• 딥링크 처리 용이
• ViewController 재사용성 극대화
• 의존성 주입 자연스럽게 처리
• 화면 흐름 테스트 가능
❌ 단점 • 초기 설정 코드가 많음
• 메모리 관리 주의 필요 (순환 참조)
• 작은 앱에는 오버엔지니어링
• 팀원 모두가 패턴을 이해해야 함
• Back 버튼 처리 등 엣지 케이스 고려 필요
🎯 적합한 상황 • 화면 수가 많은 대형 앱
• 딥링크가 많은 앱
• 여러 팀이 협업하는 프로젝트
• MVVM과 함께 사용 시 시너지 극대화

🚀 5. MVVM + Coordinator — 현업 최강 조합

자, 이제 진짜 핵심이야! 🔥
현업에서 가장 많이 쓰이는 조합이 바로 MVVM + Coordinator야.
각각의 단점을 서로가 보완해주는 완벽한 파트너 관계거든!

MVVM이 비즈니스 로직을 ViewController에서 분리하고,
Coordinator가 화면 전환 로직을 ViewController에서 분리하면?
ViewController는 정말 UI 렌더링만 담당하는 깔끔한 코드가 돼! 😍

MVVM + Coordinator 전체 아키텍처 🗺️ Coordinator 레이어 (화면 전환 담당) AppCoordinator AuthCoordinator MainCoordinator DetailCoordinator 🖼️ View 레이어 (UI 렌더링 담당) LoginVC HomeVC DetailVC ProfileVC ⚙️ ViewModel 레이어 (비즈니스 로직 담당) LoginViewModel HomeViewModel DetailViewModel ProfileViewModel 📦 Model 레이어 (데이터 담당) NetworkService Repository DataModel LocalStorage
📁 프로젝트 폴더 구조
MyApp/
├── Application/
│   ├── AppDelegate.swift
│   ├── SceneDelegate.swift
│   └── AppCoordinator.swift
│
├── Coordinators/
│   ├── Coordinator.swift          // 프로토콜
│   ├── AuthCoordinator.swift
│   ├── MainCoordinator.swift
│   └── SettingsCoordinator.swift
│
├── Scenes/
│   ├── Auth/
│   │   ├── Login/
│   │   │   ├── LoginViewController.swift
│   │   │   └── LoginViewModel.swift
│   │   └── SignUp/
│   │       ├── SignUpViewController.swift
│   │       └── SignUpViewModel.swift
│   ├── Home/
│   │   ├── HomeViewController.swift
│   │   └── HomeViewModel.swift
│   └── Detail/
│       ├── DetailViewController.swift
│       └── DetailViewModel.swift
│
├── Models/
│   ├── User.swift
│   ├── Product.swift
│   └── APIResponse.swift
│
├── Services/
│   ├── NetworkService.swift
│   ├── AuthService.swift
│   └── UserRepository.swift
│
├── Common/
│   ├── Extensions/
│   ├── Utils/
│   └── Constants.swift
│
└── Resources/
    ├── Assets.xcassets
    └── Info.plist
🔗 SceneDelegate에서 Coordinator 시작하기
// MARK: - SceneDelegate
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    
    var window: UIWindow?
    var appCoordinator: AppCoordinator?
    
    func scene(_ scene: UIScene, 
               willConnectTo session: UISceneSession, 
               options connectionOptions: UIScene.ConnectionOptions) {
        
        guard let windowScene = (scene as? UIWindowScene) else { return }
        
        // 1. 윈도우 생성
        let window = UIWindow(windowScene: windowScene)
        self.window = window
        
        // 2. NavigationController 생성
        let navigationController = UINavigationController()
        
        // 3. AppCoordinator 생성 및 시작
        let appCoordinator = AppCoordinator(
            navigationController: navigationController
        )
        self.appCoordinator = appCoordinator
        appCoordinator.start()
        
        // 4. 루트 뷰 설정
        window.rootViewController = navigationController
        window.makeKeyAndVisible()
    }
}
💡 재능넷에서 iOS 개발 관련 재능을 찾아보면, 실제 현업에서 MVVM + Coordinator 패턴을 사용하는 개발자들이 많아. 이 패턴을 제대로 이해하면 취업이나 프리랜서 활동에서도 큰 강점이 될 거야! 💪

⚖️ 6. 패턴 비교 및 선택 가이드

자, 이제 세 가지 패턴을 다 알았으니 어떤 상황에서 뭘 써야 하는지 정리해보자!
이게 사실 제일 중요한 부분이야. 패턴은 도구일 뿐이고, 상황에 맞는 도구를 선택하는 게 진짜 실력이거든. 🔧

아키텍처 패턴 선택 가이드 프로젝트 시작! 소규모 프로토타입 또는 빠른 MVP? YES MVC 선택 빠르고 간단! NO 테스트 코드가 중요한가? YES MVVM 선택 테스트 용이! NO 화면 수가 많고 딥링크가 있나? YES MVVM + Coordinator NO MVVM으로 충분!
📊 세 가지 패턴 종합 비교
항목 MVC MVVM MVVM+Coordinator
학습 난이도 ⭐ 쉬움 ⭐⭐⭐ 중간 ⭐⭐⭐⭐ 어려움
테스트 용이성 ❌ 어려움 ✅ 쉬움 ✅✅ 매우 쉬움
코드 재사용성 낮음 중간 높음
화면 전환 관리 VC에 분산 VC에 분산 Coordinator 집중
보일러플레이트 적음 중간 많음
팀 협업 보통 좋음 매우 좋음
적합한 규모 소규모 중규모 대규모

💡 7. 실전에서 바로 쓸 수 있는 팁들

이론은 충분히 배웠으니, 이제 실제로 코드 짤 때 도움이 되는 팁들을 공유할게! 🛠️

🔑 팁 1: Input/Output 패턴으로 ViewModel 설계하기

ViewModel을 더 명확하게 설계하는 방법으로 Input/Output 패턴이 있어.
View에서 ViewModel로 들어오는 것(Input)과 ViewModel에서 View로 나가는 것(Output)을 명확히 구분하는 거야.

// MARK: - Input/Output 패턴 ViewModel
class UserListViewModel {
    
    // MARK: - Input (View → ViewModel)
    struct Input {
        let viewDidLoad: AnyPublisher<Void, Never>
        let refreshTrigger: AnyPublisher<Void, Never>
        let userSelected: AnyPublisher<IndexPath, Never>
    }
    
    // MARK: - Output (ViewModel → View)
    struct Output {
        let users: AnyPublisher<[User], Never>
        let isLoading: AnyPublisher<Bool, Never>
        let errorMessage: AnyPublisher<String, Never>
    }
    
    private let userService: UserService
    weak var coordinator: MainCoordinator?
    
    init(userService: UserService) {
        self.userService = userService
    }
    
    // Input을 받아서 Output을 반환하는 transform 메서드
    func transform(input: Input) -> Output {
        let loadTrigger = Publishers.Merge(
            input.viewDidLoad, 
            input.refreshTrigger
        )
        
        let usersResult = loadTrigger
            .flatMap { [weak self] _ -> AnyPublisher<[User], Never> in
                guard let self = self else { 
                    return Just([]).eraseToAnyPublisher() 
                }
                return self.userService.fetchUsers()
                    .catch { _ in Just([]) }
                    .eraseToAnyPublisher()
            }
            .share()
        
        let users = usersResult.eraseToAnyPublisher()
        
        let isLoading = loadTrigger
            .map { true }
            .merge(with: usersResult.map { _ in false })
            .eraseToAnyPublisher()
        
        // 사용자 선택 시 Coordinator에게 위임
        input.userSelected
            .sink { [weak self] indexPath in
                // coordinator?.showDetail(...)
            }
            .cancel()
        
        return Output(
            users: users,
            isLoading: isLoading,
            errorMessage: Just("").eraseToAnyPublisher()
        )
    }
}
🔑 팁 2: Coordinator에서 메모리 누수 방지하기
// MARK: - 메모리 누수 방지를 위한 Coordinator 패턴
protocol CoordinatorDelegate: AnyObject {
    func coordinatorDidFinish(_ coordinator: Coordinator)
}

class ChildCoordinator: Coordinator {
    var childCoordinators: [Coordinator] = []
    var navigationController: UINavigationController
    weak var parentDelegate: CoordinatorDelegate?
    
    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }
    
    func start() {
        // 화면 설정...
    }
    
    // 작업 완료 시 부모에게 알림 → 부모가 자식을 메모리에서 해제
    func finish() {
        parentDelegate?.coordinatorDidFinish(self)
    }
}

// 부모 Coordinator에서 자식 해제 처리
extension ParentCoordinator: CoordinatorDelegate {
    func coordinatorDidFinish(_ coordinator: Coordinator) {
        removeChildCoordinator(coordinator)
        // 필요한 경우 다음 화면으로 이동
    }
}
🔑 팁 3: 딥링크 처리하기
// MARK: - 딥링크 처리
// 예: myapp://user/123 → 특정 유저 상세 화면으로 이동

enum DeepLink {
    case userDetail(userId: Int)
    case settings
    case notification(notificationId: String)
    
    init?(url: URL) {
        guard let components = URLComponents(url: url, 
                                            resolvingAgainstBaseURL: false),
              let host = components.host else { return nil }
        
        switch host {
        case "user":
            if let idString = components.queryItems?
                .first(where: { $0.name == "id" })?.value,
               let id = Int(idString) {
                self = .userDetail(userId: id)
            } else { return nil }
        case "settings":
            self = .settings
        default:
            return nil
        }
    }
}

// AppCoordinator에서 딥링크 처리
extension AppCoordinator {
    func handle(deepLink: DeepLink) {
        switch deepLink {
        case .userDetail(let userId):
            // 현재 어떤 화면에 있든 상관없이 유저 상세로 이동
            mainCoordinator?.showUserDetail(userId: userId)
        case .settings:
            mainCoordinator?.showSettings()
        case .notification(let id):
            mainCoordinator?.showNotification(id: id)
        }
    }
}
🔑 팁 4: 의존성 주입(DI) 제대로 활용하기
// MARK: - 의존성 주입 컨테이너 (간단한 버전)
class DIContainer {
    static let shared = DIContainer()
    
    // 서비스 등록
    private var services: [String: Any] = [:]
    
    func register<T>(_ service: T, for type: T.Type) {
        let key = String(describing: type)
        services[key] = service
    }
    
    func resolve<T>(_ type: T.Type) -> T? {
        let key = String(describing: type)
        return services[key] as? T
    }
}

// AppDelegate 또는 SceneDelegate에서 등록
func setupDependencies() {
    let container = DIContainer.shared
    
    // 실제 서비스 등록
    container.register(NetworkService(), for: NetworkServiceProtocol.self)
    container.register(UserRepository(), for: UserRepositoryProtocol.self)
    container.register(AuthService(), for: AuthServiceProtocol.self)
}

// Coordinator에서 ViewModel 생성 시 DI 활용
class MainCoordinator: Coordinator {
    func showHome() {
        let userRepo = DIContainer.shared.resolve(UserRepositoryProtocol.self)!
        let viewModel = HomeViewModel(userRepository: userRepo)
        viewModel.coordinator = self
        
        let homeVC = HomeViewController()
        homeVC.viewModel = viewModel
        
        navigationController.setViewControllers([homeVC], animated: false)
    }
}
💡 실무 팁: 프로토콜을 활용한 의존성 주입은 테스트 시 Mock 객체로 쉽게 교체할 수 있어서 테스트 코드 작성이 훨씬 편해져. NetworkServiceProtocol을 구현한 MockNetworkService를 만들어서 테스트에 주입하면 실제 네트워크 없이도 테스트 가능! 🧪

🍎 8. SwiftUI 시대의 아키텍처 — 잠깐 살펴보기

SwiftUI가 나오면서 아키텍처 패턴도 조금 변화가 생겼어.
SwiftUI는 선언형 UI 프레임워크라서 UIKit 기반의 MVC/MVVM과는 다른 접근이 필요해.

SwiftUI에서는 @State, @ObservedObject, @StateObject, @EnvironmentObject 같은
프로퍼티 래퍼들이 데이터 바인딩을 자동으로 처리해줘서 MVVM의 바인딩 코드가 훨씬 간결해져.

// MARK: - SwiftUI + MVVM
import SwiftUI
import Combine

// ViewModel (ObservableObject 채택)
class UserListViewModel: ObservableObject {
    @Published var users: [User] = []
    @Published var isLoading: Bool = false
    
    private let userService: UserService
    private var cancellables = Set<AnyCancellable>()
    
    init(userService: UserService = UserService()) {
        self.userService = userService
    }
    
    func loadUsers() {
        isLoading = true
        userService.fetchUsers()
            .receive(on: DispatchQueue.main)
            .sink(
                receiveCompletion: { [weak self] _ in
                    self?.isLoading = false
                },
                receiveValue: { [weak self] users in
                    self?.users = users
                }
            )
            .store(in: &cancellables)
    }
}

// View (SwiftUI)
struct UserListView: View {
    @StateObject private var viewModel = UserListViewModel()
    
    var body: some View {
        NavigationView {
            Group {
                if viewModel.isLoading {
                    ProgressView("로딩 중...")
                } else {
                    List(viewModel.users, id: \.id) { user in
                        NavigationLink(destination: UserDetailView(user: user)) {
                            VStack(alignment: .leading) {
                                Text(user.name)
                                    .font(.headline)
                                Text(user.email)
                                    .font(.subheadline)
                                    .foregroundColor(.secondary)
                            }
                        }
                    }
                }
            }
            .navigationTitle("사용자 목록")
        }
        .onAppear {
            viewModel.loadUsers()
        }
    }
}
🍎 SwiftUI에서의 Coordinator 대안

SwiftUI에서는 전통적인 Coordinator 패턴 대신 다음과 같은 방법들이 사용돼:

1. NavigationStack + NavigationPath (iOS 16+)
프로그래매틱 네비게이션을 지원하는 SwiftUI 네이티브 방식

2. Router 패턴
Coordinator와 유사하지만 SwiftUI에 맞게 변형된 패턴

3. TCA (The Composable Architecture)
Point-Free에서 만든 SwiftUI 전용 아키텍처 프레임워크
⚠️ 주의: UIKit과 SwiftUI를 혼용하는 프로젝트에서는 아키텍처 선택이 더 복잡해져. 이 경우 UIKit 부분은 MVVM+Coordinator, SwiftUI 부분은 ObservableObject 기반 MVVM을 사용하는 하이브리드 접근이 일반적이야.

🚨 9. 아키텍처 적용 시 자주 하는 실수들

이론을 알아도 실제로 적용하다 보면 실수를 하게 돼.
내가 (그리고 많은 개발자들이) 겪었던 실수들을 미리 알려줄게! 😅

1
ViewModel에서 UIKit import하기
import UIKit이 ViewModel에 있으면 MVVM의 의미가 없어.
ViewModel은 UIKit을 전혀 몰라야 독립적으로 테스트할 수 있어.
UIColor 대신 String이나 커스텀 타입을 사용하고,
UIImage 대신 이미지 URL이나 이름을 전달하는 방식으로 해결해.
2
Coordinator에서 순환 참조 만들기
Coordinator가 ViewController를 strong으로 참조하고,
ViewController가 Coordinator를 strong으로 참조하면 메모리 누수 발생!
ViewController에서 Coordinator 참조는 반드시 weak로 선언해야 해.
3
모든 프로젝트에 같은 패턴 적용하기
간단한 설정 화면에도 MVVM+Coordinator를 적용하면 오버엔지니어링이야.
화면의 복잡도와 프로젝트 규모에 맞는 패턴을 선택하는 게 중요해.
4
ViewModel에 너무 많은 책임 주기
MVC의 Massive ViewController를 피하려다 Massive ViewModel을 만드는 경우가 있어.
ViewModel이 너무 커지면 UseCase나 Interactor로 비즈니스 로직을 더 분리해야 해.
5
바인딩 해제(cancellables) 관리 소홀히 하기
Combine의 AnyCancellable을 Set에 저장하지 않으면
구독이 즉시 해제되어 데이터를 받지 못하는 버그가 생겨.
반드시 .store(in: &cancellables)로 관리해야 해!
6
팀원과 패턴 합의 없이 혼용하기
같은 프로젝트에서 어떤 화면은 MVC, 어떤 화면은 MVVM으로 만들면
코드베이스가 일관성을 잃고 유지보수가 더 어려워져.
팀 내에서 패턴을 통일하고 문서화하는 게 중요해.

🎯 10. 마무리 — 어떻게 시작할까?

자, 오늘 정말 많은 내용을 다뤘어! 🎉
MVC부터 MVVM, Coordinator까지 iOS 아키텍처의 핵심을 다 살펴봤는데,
처음 보면 좀 복잡하게 느껴질 수 있어. 근데 걱정 마!

아키텍처는 한 번에 다 이해하려고 하면 안 돼.
직접 코드를 짜면서 "아, 이래서 이 패턴이 필요하구나"를 느끼는 게 진짜 학습이거든. 💪

🚀 단계별 학습 로드맵

Step 1: MVC로 간단한 앱 만들어보기
→ ViewController가 얼마나 커지는지 직접 경험해봐

Step 2: 같은 앱을 MVVM으로 리팩토링해보기
→ ViewController가 얼마나 가벼워지는지 비교해봐

Step 3: Combine 또는 RxSwift 기초 학습
→ 데이터 바인딩의 편리함을 느껴봐

Step 4: Coordinator 패턴 추가하기
→ 화면 전환 로직이 얼마나 깔끔해지는지 확인해봐

Step 5: 유닛 테스트 작성해보기
→ MVVM의 진짜 가치를 느낄 수 있어!

그리고 혼자 공부하다 막히면 재능넷에서 iOS 개발 멘토링을 찾아보는 것도 좋은 방법이야.
실제 현업 개발자에게 직접 물어보면 책이나 문서에서는 얻기 힘든 실전 노하우를 배울 수 있거든! 🙌

아키텍처 패턴은 결국 코드를 더 잘 관리하기 위한 도구야.
도구에 집착하기보다는 "왜 이 패턴이 필요한가"를 이해하는 게 더 중요해.
그 이해가 생기면 어떤 패턴을 써도, 심지어 새로운 패턴이 나와도 빠르게 적응할 수 있어! 🌟

💻 코드 작성 직접 만들어봐! → 📈 실력 향상 패턴이 눈에 보여! → 🏆 아키텍트 레벨 팀을 이끌어! 꾸준히 하다 보면 반드시 성장해! 화이팅! 🚀 MVC → MVVM → Coordinator → 그 다음은 네가 만들어가는 거야

📋 핵심 요약 한눈에 보기
🏛️ MVC (Model-View-Controller)
• Apple 기본 패턴, UIKit과 자연스럽게 통합
• View와 Controller가 UIViewController에서 강하게 결합
• 소규모 프로젝트, 빠른 프로토타입에 적합
• 주의: Massive View Controller 문제 발생 가능
⚙️ MVVM (Model-View-ViewModel)
• ViewController에서 비즈니스 로직을 ViewModel로 분리
• ViewModel은 UIKit을 모름 → 독립적 유닛 테스트 가능
• Combine/RxSwift로 데이터 바인딩 구현
• 중대형 프로젝트, 테스트가 중요한 프로젝트에 적합
🗺️ Coordinator
• 화면 전환 로직을 ViewController에서 분리
• ViewController 간 결합도 제거, 재사용성 극대화
• 딥링크 처리, 의존성 주입에 유리
• MVVM과 함께 사용 시 시너지 극대화
🚀 MVVM + Coordinator (현업 최강 조합)
• ViewController = UI 렌더링만 담당
• ViewModel = 비즈니스 로직 담당
• Coordinator = 화면 전환 담당
• Model = 데이터 및 네트워크 담당
• 대형 프로젝트, 팀 협업에 최적화
댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0
댓글을 불러오는 중입니다.

아직 댓글이 없습니다.