[UIKit] Todo 기능 구현 리뷰 (Feat. 나만의 Todo)

2025. 5. 7. 10:00·iOS 개발/UIKit

이번 프로젝트는 ‘나만의 Todo’라는 이름으로 진행해봤어요.

앱의 로고도 직접 디자인해서 적용해보았고, UI 구성부터 기능 구현까지 차근차근 UIKit으로 만들어봤습니다.

 

오늘 블로그에서는 이 앱의 핵심 기능 중 하나인 Todo 기능 구현 내용을 리뷰하려고 해요!

이번에 구현한 Todo 기능은 다음과 같습니다:

  • 📥 Todo 불러오기
  • ✏️ Todo 생성
  • 🗑 Todo 삭제
  • ☑️ Todo 체크(완료 여부 토글)

기능을 단순히 구현하는 데서 그치지 않고, 실제로 사용해보면서

불편한 부분은 계속해서 개선해보는 방식으로 프로젝트를 이어가고 있습니다.

그럼 하나씩 자세히 살펴볼게요!


🔧 프로젝트 설계 및 구현 방식

이번 프로젝트에서는 MVI와 MVVM 아키텍처를 조합하여 적용해보았습니다.

데이터 흐름과 뷰 업데이트의 역할을 명확히 분리함으로써, 유지보수성과 테스트 가능성을 높이는 데 중점을 두었어요.

(MVVM과 MVI의 구조에 대한 설명은 다음 블로그에서 자세히 소개해드릴 예정입니다!)

 

또한 각 Todo 항목은 UICollectionView 기반으로 구현되었고,

UICollectionViewDiffableDataSource와 UICollectionViewCompositionalLayout을 활용해

동적으로 변하는 리스트를 효율적으로 처리하고, 유연한 레이아웃 구성을 실현했습니다.


📥 Todo 불러오기

나만의 Todo 앱에서는 Firebase Firestore를 통해 저장된 데이터를 불러오는 구조로 Todo 불러오기 기능을 구현했습니다. 

1. 화면 진입 시 데이터 요청 시작

TodoViewModel의 process() 메서드를 통해 Firestore에서 데이터를 불러오도록 지시합니다.

viewModel.process(.loadData)

 

 

2. Firebase Firestore로부터 Todo 리스트 요청

Todo라는 컬렉션 아래의 List 문서를 비동기 방식으로 가져옵니다. 이 구조는 Firestore에 저장된 데이터 구조와 동일하게 정의한 TodoResponse 모델을 사용하여 매핑됩니다.

private func loadData() {
        loadDataTask = Task{
            do {
                let db = Firestore.firestore()
                let docRef = db.collection("Todo").document("List")
                
                let snapshot = try await docRef.getDocument()
                guard let response = try? snapshot.data(as: TodoResponse.self)
                else { throw NSError(domain: "DecodingError", code: -1) }
                
                process(.getDataSuccess(response))
                
            } catch{
                process(.getDataFailure(error))
            }
        }
    }

 

 

3. 가져온 데이터를 transformResponse()에서 정제 및 정렬

불러온 Todo 배열을 시간순으로 정렬한 후, 뷰에 바인딩하기 위해 TodoCellViewModel 배열로 변환하여 state.viewModels.todoViewModels에 저장합니다.

private func transformResponse(_ response: TodoResponse){
        Task {
            let formatter = DateFormatter()
            formatter.locale = Locale(identifier: "ko_KR")
            formatter.dateFormat = "a h시 mm분" // 예: 오후 6시 40분
            
            let sortedResponse = response.todo.sorted { todo1, todo2 in
                guard
                    let date1 = formatter.date(from: todo1.time),
                    let date2 = formatter.date(from: todo2.time)
                else {
                    return false
                }
                return date1 < date2
            }
            state.viewModels.todoViewModels = sortedResponse.map{
                TodoCellViewModel(id: $0.id, title: $0.title, date: $0.date, time: $0.time, isCompleted: $0.isCompleted)
            }
        }
    }

 

 

 

4. 뷰 업데이트 

Published 속성으로 선언된 state.viewModels.todoViewModels 값이 갱신되면,

뷰컨트롤러에서는 Combine 구독을 통해 자동으로 Snapshot을 생성하고 UICollectionView를 갱신합니다.

viewModel.state.$viewModels.receive(on: DispatchQueue.main)
            .sink { [weak self] _ in
                self?.applySnapShot()
            }.store(in: &cancellables)

✏️ Todo 생성

Todo 항목을 생성하는 기능은 사용자가 입력한 제목과 시간을 기반으로 새로운 Todo를 Firestore에 저장한 뒤, 메인 화면으로 돌아가 리스트를 새로 불러오는 구조로 설계했습니다.

 

✅ 중요한 포인트!

Todo 탭에서 화면을 navigation 방식으로 이동할 경우,

기존 탭바가 그대로 유지된 채로 화면이 겹쳐지는 현상이 발생합니다.

 

이를 방지하고 탭바를 숨긴 상태에서 자연스럽게 네비게이션 전환이 이루어지도록 하기 위해,

다음과 같은 방법을 적용했습니다:

  • TodoCreateViewController를 UINavigationController로 감싸서 모달로 띄우고
  • modalPresentationStyle = .fullScreen으로 설정해
  • 탭바와 완전히 분리된 새로운 네비게이션 스택을 생성했습니다.

이를 통해 탭바는 자연스럽게 숨겨지고, TodoCreateViewController 내부에서는 < 뒤로가기 버튼을 통해

기존 탭 화면으로 무리 없이 복귀할 수 있게 되었습니다.

let storyboard = UIStoryboard(name: "TodoCreate", bundle: nil)
guard let todoCreateVC = storyboard.instantiateViewController(withIdentifier: "TodoCreateViewController") as? TodoCreateViewController else { return }
        
//TodoCreateViewController를 NavigationController로 감싸서 present 자동으로 < 뒤로가기 버튼이 생김
let nav = UINavigationController(rootViewController: todoCreateVC)
nav.modalPresentationStyle = .fullScreen
        
self.present(nav, animated: true)

 

1. 사용자 입력 받기

  • 사용자는 UITextField에 제목을 입력하고, UIDatePicker를 통해 시간을 선택합니다.
  • 진입 시 현재 날짜는 DateFormatter를 통해 자동 표시됩니다.
  • 입력된 값으로 todoCreateModel 구조체를 생성하고, UUID().uuidString을 사용해 고유한 id도 함께 부여합니다.
let model = todoCreateModel(
    id: UUID().uuidString,
    title: title,
    date: dateLabel.text ?? "오늘",
    time: time,
    isCompleted: false
)

 

2. Firestore에 데이터 저장

ViewModel 내부에서는 다음과 같은 흐름으로 Firestore 저장이 진행됩니다:

  1. Todo 컬렉션의 List 문서 접근
  2. 입력 데이터를 Dictionary 형태로 변환
  3. FieldValue.arrayUnion()을 사용해 기존 todo 배열에 추가
  4. 저장 성공 여부를 completion(true/false)로 전달
func saveTodoToFirestore(_ todo: todoCreateModel, completion: @escaping (Bool) -> Void) {
        let db = Firestore.firestore()
        let todoData: [String: Any] = [
            "id": todo.id,
            "title": todo.title,
            "date": todo.date,
            "time": todo.time,
            "isCompleted": todo.isCompleted
        ]

        let documentRef = db.collection("Todo").document("List")

        documentRef.updateData([
            "todo": FieldValue.arrayUnion([todoData])
        ]) { error in
            if let error = error {
                print("❌ 저장 실패: \(error.localizedDescription)")
                completion(false)
            } else {
                print("✅ Firestore 저장 성공!")
                completion(true)
            }
        }
    }

저장에 성공하면 UIAlertController로 "Todo 생성 완료!" 메세지를 표시합니다. Alert의 확인 버튼을 누르면 현재 화면을 닫고,

onTodoCreated?() 콜백을 실행합니다. 

 

3. 메인 뷰에서 콜백으로 데이터 재요청

TodoCreateViewController가 종료되면서, TodoViewController에서 등록해둔 콜백이 호출됩니다.

// ✅ 콜백으로 데이터 다시 불러오기
todoCreateVC.onTodoCreated = { [weak self] in
	self?.viewModel.process(.loadData)
}

🗑 Todo 삭제

나만의 Todo 앱에서 삭제 기능은 할 일을 길게 누르면 나타나는 Alert를 통해 삭제 여부를 확인받는 방식으로 구현했습니다.

삭제 요청이 확인되면, Firestore에서 해당 Todo 데이터를 삭제하고 다시 데이터를 불러와 리스트를 갱신합니다.

 

1. 셀을 길게 누르면 삭제 Alert 표시

viewDidLoad()에서 UILongPressGestureRecognizer를 컬렉션 뷰에 추가하여 롱프레스 이벤트를 감지하며 사용자가 셀을 꾹 누르면 Alert가 표시되어 삭제 여부를 묻습니다. 

let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
collectionView.addGestureRecognizer(longPressGesture)
@objc func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
        guard gesture.state == .began else { return }

        let location = gesture.location(in: collectionView)

        if let indexPath = collectionView.indexPathForItem(at: location) {
            let cellViewModel = viewModel.state.viewModels.todoViewModels[indexPath.row]

            let alert = UIAlertController(title: "삭제하시겠습니까?",
                                          message: "\"\(cellViewModel.title)\" 할 일을 삭제할까요?",
                                          preferredStyle: .alert)

            alert.addAction(UIAlertAction(title: "삭제", style: .destructive) { _ in
                self.viewModel.process(.deleteTodo(cellViewModel.id))
                self.viewModel.process(.loadData)
            })
            alert.addAction(UIAlertAction(title: "취소", style: .cancel))

            self.present(alert, animated: true)
        }
    }

 

2. 삭제 확정 시 Firestore에서 데이터 삭제

Alert에서 “삭제” 버튼을 누르면, ViewModel의 deleteTodo(id:) 함수가 실행됩니다 기존 배열에서 해당 id를 가진 Todo를 제거하고,

전체 데이터를 덮어쓰기 방식으로 갱신합니다. 

func deleteTodo(id: String) {
        Task {
            let db = Firestore.firestore()
            let docRef = db.collection("Todo").document("List")
            
            do {
                let snapshot = try await docRef.getDocument()
                guard var response = try? snapshot.data(as: TodoResponse.self) else { return }

                response.todo.removeAll { $0.id == id }

                try docRef.setData(from: response)
                process(.loadData) // 다시 불러와서 갱신
            } catch {
                print("삭제 실패: \(error)")
            }
        }
    }

 

☑️ Todo 완료 상태 토글

Todo 셀의 완료 버튼(체크 버튼)을 누르면, 해당 Todo의 isCompleted 상태가 토글되며

UI와 서버(Firebase Firestore) 모두 동기화되도록 구현했습니다. 전체 흐름은 다음과 같습니다.

 

1. 셀에서 버튼 클릭 시 콜백 실행

TodoCell.swift 내부에서 완료 버튼이 눌리면, isCompleteButtonTapped()가 실행되고 연결된 onToggleComplete 콜백이 트리거됩니다.

@IBAction func isCompleteButtonTapped(_ sender: Any) {
    onToggleComplete?()
}

 

2. TodoViewController에서 ViewModel로 요청 전달

TodoViewController에서 셀을 생성할 때, onToggleComplete 콜백을 다음과 같이 지정하며 이로 인해 TodoViewModel.process() 내부에서 해당 액션으로 분기됩니다.

cell.onToggleComplete = { [weak self] in
    guard let self = self else { return }
    let todoID = viewModel.id
    self.viewModel.process(.isCompleteToggle(todoID))
}

 

 

3. ViewModel에서 상태 토글 및 UI 반영 및 Firestore 데이터 동기화

toggleComplete(id:) 함수는 다음과 같은 과정을 거칩니다:

  1. state.viewModels.todoViewModels에서 해당 Todo 항목을 찾습니다
  2. isCompleted 값을 반전시킨 새 ViewModel로 교체
  3. 즉시 UI가 반영되도록 상태를 갱신합니다

상태가 UI에 반영된 이후에는 Firestore의 데이터를 업데이트합니다.

func toggleComplete(id: String) {
        var todos = state.viewModels.todoViewModels
        
        if let index = todos.firstIndex(where: { $0.id == id }) {
            // 1. 현재 Todo 가져오기
            var updatedTodo = todos[index]
            // 2. isCompleted 토글
            updatedTodo = TodoCellViewModel(
                id: updatedTodo.id,
                title: updatedTodo.title,
                date: updatedTodo.date,
                time: updatedTodo.time,
                isCompleted: !updatedTodo.isCompleted
            )
            // 3. 업데이트된 값 적용
            todos[index] = updatedTodo
            state.viewModels.todoViewModels = todos
            
            // 4. Firestore에 업데이트
            let db = Firestore.firestore()
            let listRef = db.collection("Todo").document("List")
            
            // nested array 안의 하나만 수정하기 위해서 전체 todo array를 다시 올리는 구조라면:
            Task {
                do {
                    // 전체 데이터 fetch
                    let snapshot = try await listRef.getDocument()
                    guard var response = try? snapshot.data(as: TodoResponse.self) else { return }
                    
                    if let idx = response.todo.firstIndex(where: { $0.id == id }) {
                        response.todo[idx].isCompleted.toggle()
                        
                        try listRef.setData(from: response) // 전체 덮어쓰기
                    }
                } catch {
                    print("Toggle update error: \(error)")
                }
            }
        }
    }

🎬 Todo 기능 구현 영상


🧩 마무리

이번 포스팅에서는 나만의 Todo 앱의 핵심 기능인 Todo 생성 / 삭제 / 완료 여부 토글 기능을 중심으로

UI 흐름과 ViewModel 처리 구조, 그리고 Firestore 연동 방식까지 상세히 리뷰해보았습니다.

 

기존에는 전체 코드를 블로그에 모두 공유했었지만, 흐름이 눈에 잘 들어오지 않고 가독성이 떨어지는 부분이 있어

앞으로는 코드 전문은 GitHub 저장소에 업로드하고, 블로그에서는 구조와 동작 흐름 중심으로 설명드리도록 방향을 바꾸었습니다.

 

📌 GitHub 코드가 궁금하신 분들은 아래 링크를 참고해주세요:

👉 🔗 GitHub 저장소 바로가기

 

GitHub - woolnd/UIKit_MyTodo

Contribute to woolnd/UIKit_MyTodo development by creating an account on GitHub.

github.com

 

다음 포스팅에서는 앱 내 또 다른 기능인 메모장 기능 구현과 함께 혹시 발견된 오류 수정 내용도 함께 정리해보도록 하겠습니다.

꾸준히 기록하며 성장하는 개발자가 되기 위해 계속 달려보겠습니다! 🚀

 

읽어주셔서 감사합니다 😊

'iOS 개발 > UIKit' 카테고리의 다른 글

[UIKit] 설정 탭 리뷰 (Feat. 나만의 Todo)  (1) 2025.05.20
[UIKit] Timer 기능 리뷰 (Feat. 나만의 Todo)  (0) 2025.05.16
[UIKit] 녹음 기능 리뷰 (Feat. 나만의 Todo)  (0) 2025.05.12
[UIKit] 메모 기능 구현 리뷰 (Feat. 나만의 Todo)  (1) 2025.05.09
[UIKit] Launch & Onboarding 화면 구현 리뷰 (Feat. 음성메모앱)  (2) 2025.05.05
'iOS 개발/UIKit' 카테고리의 다른 글
  • [UIKit] Timer 기능 리뷰 (Feat. 나만의 Todo)
  • [UIKit] 녹음 기능 리뷰 (Feat. 나만의 Todo)
  • [UIKit] 메모 기능 구현 리뷰 (Feat. 나만의 Todo)
  • [UIKit] Launch & Onboarding 화면 구현 리뷰 (Feat. 음성메모앱)
Riu
Riu
안녕하세요 iOS 개발자를 꿈꾸는 Riu입니다. Github: woolnd
  • Riu
    Riu 개발노트
    Riu
  • 전체
    오늘
    어제
    • 분류 전체보기 (27) N
      • 티스토리 (2)
      • iOS 개발 (21) N
        • SwiftUI (9)
        • UIKit (6)
        • Combine (5)
        • Architecture (1) N
      • 알고리즘 (1)
      • 회고록 (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • Github
  • 공지사항

  • 인기 글

  • 태그

    Architecture
    안드로이드
    앗차!
    cleanArchitecture
    SWIF
    티스토리
    시작
    나만의todo
    ios
    SwiftUI
    SWIFT
    ios개발
    막자알림서비스
    UIKit
    알고리즘
    Combine
    회고록
    figma
    구름톤유니브
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.0
Riu
[UIKit] Todo 기능 구현 리뷰 (Feat. 나만의 Todo)
상단으로

티스토리툴바