Skip to content

ToDoList 앱을 MVP 패턴과 CoreData, Swift Concurrency로 구현해보기

Notifications You must be signed in to change notification settings

emilyj4482/ToDoList_MVP_CoreData

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

97 Commits
 
 
 
 
 
 
 
 

Repository files navigation

ToDoList

storyboard 기반으로 처음 구현한 할 일 관리 앱을 codebase로 리팩토링 한 프로젝트입니다.

기술 스택

  • 코드베이스 UIKit
  • MVP Architecture
  • Coordinator 패턴
  • CoreData CRUD
  • Swift Concurrency

프로젝트 구조

📦 ToDoList
├── 📂 Delegate
│   ├── AppDelegate.swift
│   └── SceneDelegate.swift
├── 📂 Helper
│   ├── AppCoordinator.swift
│   ├── CoreDataError.swift
│   └── EditTaskMode.swift
├── 📂 Model
│   ├── CoreDataManager.swift
│   ├── TodoRepository.swift
│   └── Todo.xcdatamodeld
├── 📂 Scene
│   ├── 📂 Main
│   │   ├─ MainListCoordinator.swift
│   │   ├─ MainListViewController.swift
│   │   ├─ MainListView.swift
│   │   ├─ ListCell.swift
│   │   └─ MainListPresenter.swift
│   ├── 📂 AddList
│   │   ├─ AddListCoordinator.swift
│   │   ├─ AddListViewController.swift
│   │   └─ AddListPresenter.swift
│   ├── 📂 Todo
│   │   ├─ TodoListCoordinator.swift
│   │   ├─ TodoListViewController.swift
│   │   ├─ TodoListView.swift
│   │   ├─ TaskCell.swift
│   │   ├─ TaskDoneHeader.swift
│   │   └─ TodoListPresenter.swift
│   ├── 📂 EditTask
│   │   ├─ EditTaskCoordinator.swift
│   │   ├─ EditTaskViewController.swift
│   │   ├─ EditTaskView.swift
│   │   └─ EditTaskPresenter.swift
├── Assets.xcassets
└── Info.plist

주요 구현사항

📌 ContainerView 운영

UI 컴포넌트들을 컨테이너 뷰에서 그리고, Controller에는 컨테이너 뷰만 subview로 추가하여 Controller의 책임을 덜고 코드를 줄였습니다.

class MainListViewController: UIViewController {
    private let containerView = MainListView()

    // ... //

    private func setupUI() {
        view.addSubView(containerView)

        // ... //
    }
}
class MainListView: UIView {
    private let tableView: UITableView = { // ... // }()

    private let countLabel: UILabel = { // ... // }()

    private lazy var addListButton: UIButton = { // ... // }()
}

📌 Coordinator 패턴 적용

네비게이션(화면 전환) 책임을 담당하는 Coordinator를 추가하여 Controller의 책임을 덜었습니다.

구현 과정을 정리한 포스트

final class TodoListCoordinator: Coordinator {
    weak var parentCoordinator: MainListCoordinator?
    var childCoordinators = [Coordinator]()
    var navigationController: UINavigationController
    
    private let repository: TodoRepository
    
    init(navigationController: UINavigationController, repository: TodoRepository) {
        self.navigationController = navigationController
        self.repository = repository
    }
    
    func start(with list: ListEntity) {
        let viewController = TodoListViewController(repository: repository, list: list)
        viewController.coordinator = self
        navigationController.pushViewController(viewController, animated: true)
    }
    
    func finish(shouldPop: Bool) {
        if shouldPop {
            navigationController.popViewController(animated: true)
        }
        parentCoordinator?.childDidFinish(self)
    }

    // ... //
}
  • Controller는 메모리 누수 방지를 위해 Coorinator를 약한 참조
class TodoListViewController: UIViewController {
    weak var coordinator: TodoListCoordinator?
}

📌 MVP 아키텍처 적용

PresenterView의 이벤트를 받고 → Model에서 데이터를 받아 가공 후 → View에 전달하는 역할을 담당하여 명령형 업데이트 패턴의 중심 객체입니다. 명령형 UI 프레임워크인 UIKit에 적합한지, MVCMVVM 아키텍처와 어떤 차이가 있는지에 대한 학습 목적으로 MVP 아키텍처를 채택하였습니다.

MVP 아키텍처에 대해 정리한 포스트

protocol AddListProtocol: AnyObject {
    func setupUI()
    func dismiss()
    func showAlert()
}

final class AddListPresenter: NSObject {
    private weak var viewController: AddListProtocol?
    private let repository: TodoRepository
    
    init(viewController: AddListProtocol, repository: TodoRepository) {
        self.viewController = viewController
        self.repository = repository
    }
    
    func viewDidLoad() {
        viewController?.setupUI()
    }
    
    func leftBarButtonTapped() {
        viewController?.dismiss()
    }
    
    func rightBarButtonTapped(_ input: String) {
        // ... //
    }
}
class AddListViewController: UIViewController, AddListProtocol {
    private lazy var presenter = AddListPresenter(viewController: self, repository: repository)
    private var repository: TodoRepository

    // ... //

    override func viewDidLoad() {
        super.viewDidLoad()
        presenter.viewDidLoad()
    }

    // ... //

    @objc private func leftBarButtonTapped() {
        presenter.leftBarButtonTapped()
    }
    
    @objc private func rightBarButtonTapped() {
        if let input = textField.text {
            presenter.rightBarButtonTapped(input)
        }
    }
}

📌 CoreData CRUD 비동기로 구현 (+ Swift Concurrency)

ReadviewContext(메인 스레드), Create, Update, DeletebackgroundContext(백그라운드 스레드)에서 작동하도록 데이터 핸들링을 비동기 처리 하였습니다. 이 과정은 context 병합 정책(MergePolicy)에 대해 제대로 이해하는 계기가 되었습니다.

CoreData 비동기 처리 과정을 정리한 포스트

  • CoreDataManager : 영구 저장소(Container)를 하나만 운영하기 위해 싱글톤으로 관리. 컨테이너와 context를 관리하는 객체
final class CoreDataManaer {
    static let shared = CoreDataManager()
    private init() {}

    private var persistentContainer: NSPersistentContainer = { // ... // }()

    var viewContext: NSManagedObjectContext {
        return persistentContainer.viewContext
    }
    
    func newBackgroundContext() -> NSManagedObjectContext {
        let context = persistentContainer.newBackgroundContext()
        context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
        return context
    }
}
  • TodoRepository : CoreDataManager를 참조하여 FetchRequest와 Todo 데이터의 CRUD를 담당하는 객체. Presenter들이 레포지토리를 참조하여 데이터를 View에 갱신
final class TodoRepository {
    private let coreDataManager = CoreDataManager.shared
    
    var viewContext: NSManagedObjectContext {
        return coreDataManager.viewContext
    }

    var listsFetchRequest: NSFetchRequest<ListEntity> { // ... // }

    func tasksFetchRequest(for list: ListEntity) -> NSFetchRequest<TaskEntity> { // ... // }

    // ... //
}

📌 NSFetchedResultsController + UITableView로 데이터 리로드 구현

NSFetchedResultsController는 CoreData 적용 프로젝트에서 FetchRequest를 통해 편리하게 UITableView 또는 UICollectionView의 데이터를 갱신하도록 해주는 클래스입니다. 데이터 변경 시 tableView.reloadData()를 수동 호출할 필요 없이 변경사항이 감지되면 UI가 자동으로 업데이트 됩니다.

NsFetchedResultsController 적용 과정을 정리한 포스트

final class MainListPresenter: NSObject {
    // ... //

    private lazy var fetchedResultsController: NSFetchedResultsController<ListEntity> = {
        let controller = NSFetchedResultsController(
            fetchRequest: repository.listsFetchRequest,
            managedObjectContext: repository.viewContext,
            sectionNameKeyPath: nil,
            cacheName: Keys.fetchedResultsControllerListCacheName
        )
        
        controller.delegate = self
        return controller
    }()
}

extension MainListPresenter: NSFetchedResultsControllerDelegate {
    func controllerWillChangeContent(_ controller: NSFetchedResultsController<any NSFetchRequestResult>) {
        viewController?.tableViewBeginUpdates()
    }
    
    func controller(_ controller: NSFetchedResultsController<any NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
        switch type {
        case .insert:
            if let newIndexPath = newIndexPath {
                viewController?.tableViewInsertRows(at: [newIndexPath])
            }
        case .update:
            if let indexPath = indexPath {
                viewController?.tableViewReloadRows(at: [indexPath])
            }
        case .delete:
            if let indexPath = indexPath {
                viewController?.tableViewDeleteRows(at: [indexPath])
            }
        default:
            break
        }
    }

    func controllerDidChangeContent(_ controller: NSFetchedResultsController<any NSFetchRequestResult>) {
        viewController?.tableViewEndUpdates()
        viewController?.configure(with: numberOfRows())
    }
}

📌 Error handling

데이터 CRUD 메소드들을 throws로 구현하여 에러 발생 시 alert이 호출되도록 처리하였습니다. (사용자 경험 개선)

final class TodoRepository {
    // ... //

    func renameList(objectID: NSManagedObjectID, newName: String) async throws {
        let backgroundContext = coreDataManager.newBackgroundContext()
        
        // 중복 검사
        let processedName = try await processListName(newName)
        
        try await backgroundContext.perform {
            let managedObject = try backgroundContext.existingObject(with: objectID)
            
            guard let list = managedObject as? ListEntity else {
                throw CoreDataError.castingObjectFailed
            }
            
            list.name = processedName
            try backgroundContext.save()
        }
    }
}
final class TodoListPresenter: NSObject {
    // ... //

    func renameList(with name: String) async {
        do {
            try await repository.renameList(objectID: list.objectID, newName: name)
        } catch {
            viewController?.showError(error)
        }
    }
}
class TodoListViewController: TodoListProtocol {
    // ... //

    func showError(_ error: Error) {
        print("[Error] \(error.localizedDescription)")
        
        let alert = UIAlertController(
            title: "Error",
            message: "Data could not be loaded. Please try again later.",
            preferredStyle: .alert
        )
        
        let okayButton = UIAlertAction(title: "OK", style: .default)
        
        alert.addAction(okayButton)
        present(alert, animated: true)
    }
}

About

ToDoList 앱을 MVP 패턴과 CoreData, Swift Concurrency로 구현해보기

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages