구조 다이어그램
View <—@Published—> ViewModel | Model ViewModel: - @Published로 상태 발행 - View가 자동으로 반응 (선언형) - UIKit 의존 없음 Combine Pipeline: $tasks .combineLatest($searchText, $filter) .map { ... filter ... } .assign(to: &$filteredTasks)
MVP와의 차이
  • MVP: Presenter가 View를 직접 호출 (명령형)
  • MVVM: @Published 상태를 View가 자동 구독
  • Combine 파이프라인으로 검색+필터 처리
  • Command 패턴으로 Undo/Redo 지원
  • SwiftUI와 자연스럽게 통합
내장 디자인 패턴
Factory Method Command

Factory Method — TaskItem 생성 로직을 캡슐화.

Command — ToggleCommand, DeleteCommand로 Undo 스택 구현.

 03-MVVM/Finish/TaskListViewModel.swift
import Foundation
import Combine

// MARK: - MVVM ViewModel (완성)

protocol TaskCommand {
    func execute()
    func undo()
}

@MainActor
final class TaskListViewModel: ObservableObject {
    @Published var tasks: [TaskItem] = []
    @Published var searchText = ""
    @Published var filterPriority: TaskItem.Priority? = nil
    @Published var filteredTasks: [TaskItem] = []
    @Published var showAddSheet = false

    private var commandHistory: [any TaskCommand] = []
    private var cancellables = Set<AnyCancellable>()
    private let storage = TaskStorage.shared

    init() {
        tasks = storage.loadAll()
        setupFilterPipeline()
    }

    // Combine 파이프라인: 검색 + 필터를 자동으로 처리
    private func setupFilterPipeline() {
        $tasks.combineLatest($searchText, $filterPriority)
            .map { tasks, search, priority in
                tasks.filter { task in
                    let matchSearch = search.isEmpty ||
                        task.title.localizedCaseInsensitiveContains(search)
                    let matchPriority = priority == nil || task.priority == priority
                    return matchSearch && matchPriority
                }
            }
            .assign(to: &$filteredTasks)
    }

    func addTask(title: String, priority: TaskItem.Priority = .medium) {
        let task = TaskItem(title: title, priority: priority)
        tasks.insert(task, at: 0)
        storage.saveAll(tasks)
    }

    // Command 패턴: 액션을 객체로 캡슐화 → Undo 가능
    func toggleTask(_ task: TaskItem) {
        guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
        let cmd = ToggleCommand(viewModel: self, index: idx)
        cmd.execute()
        commandHistory.append(cmd)
    }

    func deleteTask(_ task: TaskItem) {
        guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
        let cmd = DeleteCommand(viewModel: self, index: idx, task: task)
        cmd.execute()
        commandHistory.append(cmd)
    }

    func undo() {
        guard let last = commandHistory.popLast() else { return }
        last.undo()
    }

    var canUndo: Bool { !commandHistory.isEmpty }

    // ---- Command 구현체 ----

    final class ToggleCommand: TaskCommand {
        let vm: TaskListViewModel; let index: Int
        init(viewModel: TaskListViewModel, index: Int) { self.vm = viewModel; self.index = index }
        func execute() { vm.tasks[index].isCompleted.toggle(); vm.storage.saveAll(vm.tasks) }
        func undo() { execute() }  // 토글은 역연산이 동일
    }

    final class DeleteCommand: TaskCommand {
        let vm: TaskListViewModel; let index: Int; let task: TaskItem
        init(viewModel: TaskListViewModel, index: Int, task: TaskItem) {
            self.vm = viewModel; self.index = index; self.task = task
        }
        func execute() { vm.tasks.remove(at: index); vm.storage.saveAll(vm.tasks) }
        func undo() { vm.tasks.insert(task, at: min(index, vm.tasks.count)); vm.storage.saveAll(vm.tasks) }
    }
}
핵심 개념
@Published 데이터 바인딩
프로퍼티에 @Published를 붙이면 Combine Publisher가 자동 생성됩니다. SwiftUI View는 @ObservedObject로 구독하여 값 변경 시 자동 리렌더링합니다.
Combine 파이프라인
combineLatest로 여러 Publisher를 합치고 map으로 변환합니다. 검색 텍스트나 필터가 바뀔 때마다 자동으로 filteredTasks가 업데이트됩니다.
Command 패턴 + Undo
사용자의 액션을 execute() / undo()를 가진 객체로 캡슐화합니다. 히스토리 스택에 쌓아 두었다가 역순으로 실행하면 Undo가 됩니다.
@MainActor
UI 업데이트는 반드시 메인 스레드에서 실행되어야 합니다. @MainActor를 클래스에 붙이면 모든 메서드가 자동으로 메인 스레드에서 실행됩니다.