diff --git a/practice/HIGPractice/HIGPractice/Learning/LEARNING_LOG.md b/practice/HIGPractice/HIGPractice/Learning/LEARNING_LOG.md index e16cb9a4..e1b93590 100644 --- a/practice/HIGPractice/HIGPractice/Learning/LEARNING_LOG.md +++ b/practice/HIGPractice/HIGPractice/Learning/LEARNING_LOG.md @@ -24,6 +24,7 @@ For each completed scope, append one row: | 2026-03-04 | Phase 1 | ActivityKit | DeliveryTracker (Local lifecycle + Widget UI + Debug logging) | #11 | - | - | Separated app control plane vs widget rendering plane, unified shared models, and validated local start/update/end debugging flow. | | 2026-03-10 | Phase 1 | App Intents | SiriTodo sample adaptation, intents, shortcuts, concurrency troubleshooting | #16 | #18 | - | Reframed a standalone sample into an in-app learning flow, clarified `nonisolated` vs `MainActor.run`, and reduced App Shortcut registration to a valid minimal set. | | 2026-03-12 | Phase 1 | SwiftUI | TaskMaster scaffold, shared layer, navigation hookup, concept notes | #20 | #22 | - | Reframed a standalone sample app into a navigable in-app demo and clarified `some View`, accessibility, `ContentUnavailableView`, and `static ModelContainer` responsibilities. | +| 2026-03-13 | Phase 1 | SwiftData | TaskMaster data flow review, CRUD path tracing, service/query notes | #23 | - | - | Clarified how `@Model`, `ModelContainer`, `@Query`, `@Bindable`, `FetchDescriptor`, and relationships connect storage changes to SwiftUI updates. | ## Weekly Reflection (Optional) diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Phase-01-AppFrameworks.md b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Phase-01-AppFrameworks.md index 166d0718..6d1d7648 100644 --- a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Phase-01-AppFrameworks.md +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Phase-01-AppFrameworks.md @@ -6,7 +6,7 @@ - [x] ActivityKit - [ ] App Intents - [ ] SwiftUI -- [ ] SwiftData +- [x] SwiftData - [ ] Observation - [ ] Foundation Models diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftData/ISSUE_DRAFT.md b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftData/ISSUE_DRAFT.md new file mode 100644 index 00000000..7ec28e0b --- /dev/null +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftData/ISSUE_DRAFT.md @@ -0,0 +1,38 @@ +# [학습] Phase 1 - SwiftData + +- label: `learning` +- branch: `learning/swiftdata-taskmaster` +- issue: `#23` + +## 학습 Phase +- Phase 1: App Frameworks + +## Framework 이름 +- SwiftData + +## 이번 학습 목표 +- `TaskMaster`를 기준으로 SwiftData의 앱 연결 지점을 설명할 수 있다. +- `@Model`, `ModelContainer`, `ModelContext`, `@Query`, `#Predicate`의 역할을 구분할 수 있다. +- `Create / Read / Update / Delete` 흐름이 `TaskMaster` 안에서 어디서 일어나는지 정리한다. +- SwiftData와 `UserDefaults`, `Keychain`의 저장 목적과 적용 범위를 비교 설명할 수 있다. +- 이후 `Observation` 학습으로 자연스럽게 넘어갈 수 있도록 데이터 흐름 메모를 남긴다. + +## 작업 체크리스트 +- [ ] site 개념 확인 +- [ ] tutorials 실습 +- [ ] samples 구조 비교 +- [ ] `TaskMasterApp.swift` 읽고 container/context 연결 방식 정리 +- [ ] `TaskItem.swift`, `Category.swift` 읽고 모델/관계 정의 정리 +- [ ] `ContentView.swift`에서 `@Query`와 메모리 필터링 경계 정리 +- [ ] `AddTaskView.swift`에서 create 흐름 정리 +- [ ] `TaskDetailView.swift`에서 update/delete 흐름 정리 +- [ ] `DataService.swift`에서 `FetchDescriptor`, `#Predicate`, 정렬 패턴 정리 +- [ ] SwiftData vs UserDefaults vs Keychain 비교 메모 작성 +- [ ] PR 생성 및 CI 확인 +- [ ] 머지 후 `LEARNING_LOG`/Velog 기록 + +## 완료 조건 (Definition of Done) +- `TaskMaster` 기준 SwiftData 핵심 파일 7개의 역할을 스스로 설명할 수 있다. +- `@Query`와 `FetchDescriptor`의 사용 기준을 말할 수 있다. +- 왜 SwiftData가 `UserDefaults`나 `Keychain`의 대체재가 아닌지 설명할 수 있다. +- `SwiftData.md`에 파일별 메모와 비교 정리가 남아 있다. diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftData/SwiftData.md b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftData/SwiftData.md new file mode 100644 index 00000000..c34a5a9c --- /dev/null +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftData/SwiftData.md @@ -0,0 +1,306 @@ +# SwiftData + +## 학습 소스 +- site: `site/swiftdata/01-tutorial.html` +- tutorials: `tutorials/swiftdata` +- sample: `samples/TaskMaster` +- ai-reference: `ai-reference/swiftdata.md` +- issue draft: `practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftData/ISSUE_DRAFT.md` +- issue: `#23` +- branch: `learning/swiftdata-taskmaster` + +## 이번 학습 구조 +- 이번 SwiftData 학습은 새 샘플을 따로 고르지 않고, 이미 SwiftUI에서 봤던 `TaskMaster`를 다시 읽는 방식으로 진행한다. +- 차이는 "UI 관점"이 아니라 "데이터 관점"으로 읽는다는 점이다. +- 즉 같은 프로젝트를 다시 보되, 아래 질문을 중심으로 따라간다. + - 이 데이터는 어디서 정의되는가 + - 저장소는 앱에 어디서 연결되는가 + - 화면은 데이터를 어떻게 읽는가 + - 생성/수정/삭제는 어디서 일어나는가 + +## 이번 학습 방식 +- 읽는 순서는 고정한다. + 1. `TaskMasterApp.swift` + 2. `TaskItem.swift` + 3. `Category.swift` + 4. `ContentView.swift` + 5. `AddTaskView.swift` + 6. `TaskDetailView.swift` + 7. `DataService.swift` +- 각 파일에서 "SwiftData 관점 메모"를 남긴다. +- 문법만 외우지 말고, `TaskMaster` 전체 데이터 흐름을 설명할 수 있는지까지 본다. + +## 읽는 순서와 체크 포인트 + +### 1. `TaskMasterApp.swift` +- 파일: `samples/TaskMaster/TaskMasterApp/TaskMasterApp.swift` +- 여기서는 "SwiftData를 앱에 어떻게 붙이는가"만 본다. +- 완료 여부: [x] +- 체크 포인트 + - `Schema` + - `ModelContainer` + - `.modelContainer(sharedModelContainer)` + - preview용 in-memory container + +### 내가 적을 메모 +- `ModelContainer`는 SwiftData 저장소의 시작점이다. +- 앱 루트에서 `.modelContainer(...)`를 주입해야 하위 View에서 `@Environment(\.modelContext)`와 `@Query`가 동작한다. +- preview에서는 in-memory container를 써서 실제 저장 파일을 건드리지 않는다. +- `Schema([TaskItem.self, Category.self])`는 "이 저장소가 어떤 모델들을 관리하는가"를 선언한다. +- `sharedModelContainer.mainContext`를 통해 기본 데이터 초기화가 일어난다. + +## `TaskMasterApp.swift`를 보고 답할 질문 +- 왜 `.modelContainer(...)`는 앱 루트에 붙이는가 +- `sharedModelContainer`를 따로 만들어 두는 이유는 무엇인가 +- preview container와 실제 container의 차이는 무엇인가 + +### 2. `TaskItem.swift` +- 파일: `samples/TaskMaster/Shared/TaskItem.swift` +- 여기서는 "SwiftData 모델이 어떻게 생겼는가"를 본다. +- 완료 여부: [x] +- 체크 포인트 + - `@Model` + - 저장 프로퍼티 + - 계산 프로퍼티 + - 메서드가 모델 안에 들어가는 방식 + - `@Relationship(inverse:)` + +### 내가 적을 메모 +- `@Model`이 붙은 클래스는 SwiftData가 저장 가능한 모델로 인식한다. +- SwiftData가 모델의 identity와 변경 추적을 관리해야 하므로 `@Model`은 `class`에 붙는다. +- 일반 stored property는 저장 대상이 되고, 계산 프로퍼티는 저장되지 않는다. +- `toggleCompletion()`처럼 도메인 동작을 모델 안에 두면 View가 단순해진다. +- 관계는 `@Relationship`으로 정의하고, 역관계가 있으면 양쪽 연결을 더 명확히 이해할 수 있다. +- 실제 persisted 값은 `priority`, `dueDate`, `createdAt` 같은 stored property다. +- `taskPriority`, `daysUntilDue`, `isDueSoon`, `isOverdue`는 "저장된 값을 바탕으로 계산하는 뷰 친화적 접근자"다. +- `inverse`는 "이 관계의 반대편 프로퍼티가 무엇인지"를 지정한다. +- 양방향 관계는 Task가 Category를 알고, Category도 자신의 Task 목록을 아는 구조다. + +## `TaskItem.swift`를 보고 답할 질문 +- 왜 `@Model`은 `struct`가 아니라 `class`에 붙는가 +- `daysUntilDue`, `isDueSoon`, `isOverdue`는 왜 저장 프로퍼티가 아닌가 +- `taskPriority`는 persisted 값인가, 변환용 접근자인가 + +## 관계 메모 + +### `@Relationship`은 무엇인가 +- SwiftData 모델끼리 연결 관계가 있다는 선언이다. +- 예를 들어 `TaskItem.category`는 "이 할 일이 어느 카테고리에 속하는가"를 나타낸다. + +### 양방향 관계란 무엇인가 +- 한쪽만 상대를 아는 것이 아니라, 양쪽 모델이 서로를 참조할 수 있는 관계다. +- 예: + - `TaskItem.category` = 이 Task가 속한 Category + - `Category.tasks` = 이 Category에 속한 Task 목록 + +### `inverse`는 무엇인가 +- "이 관계의 반대편 프로퍼티가 무엇인지"를 SwiftData에 알려주는 설정이다. +- `@Relationship(inverse: \Category.tasks)`는 + `TaskItem.category`의 반대편이 `Category.tasks`라고 선언하는 의미다. + +### 왜 필요한가 +- SwiftData가 두 모델 사이 연결을 더 명확하게 이해한다. +- 한쪽에서 관계를 바꿨을 때 반대편 관계도 같은 연결로 해석할 수 있다. +- 즉 Task와 Category가 따로 노는 것이 아니라, 하나의 관계 양쪽 면으로 연결된다. + +### 지금 단계에서 기억할 한 줄 +- `@Relationship` = 모델 간 연결 선언 +- `inverse` = 연결의 반대편 지정 +- 양방향 관계 = 양쪽이 서로를 참조하는 구조 + +### 3. `Category.swift` +- 파일: `samples/TaskMaster/Shared/Category.swift` +- 여기서는 관계 모델을 본다. +- 완료 여부: [x] +- 체크 포인트 + - `Category`와 `TaskItem` 연결 + - 역관계가 왜 필요한가 + - 삭제 시 영향 추론 + +### 내가 적을 메모 +- `Category`와 `TaskItem`은 1:N 관계다. +- 한 카테고리에 여러 개의 Task가 연결될 수 있다. +- 관계 정의를 보면 카테고리 삭제 시 Task가 같이 지워지는지, nil 처리되는지 추론할 수 있어야 한다. +- `Category.tasks`는 "카테고리에 속한 Task 목록"이고, `TaskItem.category`는 그 반대편 연결이다. +- 여기서는 `@Relationship(deleteRule: .nullify)`를 쓰므로 카테고리를 삭제해도 Task 자체는 지워지지 않고 `category`만 `nil`이 된다. +- 즉 `name`, `colorHex`, `iconName`은 카테고리 자신의 저장 데이터이고, `tasks`는 다른 모델과의 연결 상태를 나타내는 관계 프로퍼티다. +- 관계를 양쪽 모델에 다 적어 두면 "한쪽에서 본 연결"과 "반대편에서 본 연결"을 같은 관계로 해석할 수 있다. + +## `Category.swift`를 보고 답할 질문 +- 관계를 양쪽 모델에 다 적는 이유는 무엇인가 +- 카테고리를 지우면 기존 Task는 어떻게 될까 +- 이 모델에서 "카테고리 이름"과 "카테고리에 속한 Task 목록"은 성격이 어떻게 다른가 + +### 4. `ContentView.swift` +- 파일: `samples/TaskMaster/TaskMasterApp/ContentView.swift` +- 여기서는 "조회와 화면 연결"만 본다. +- 완료 여부: [x] +- 체크 포인트 + - `@Environment(\.modelContext)` + - `@Query(sort: ...)` + - 가져온 배열이 UI에 바로 반영되는 흐름 + - 검색/필터가 DB 쿼리인지, 메모리 필터인지 구분하기 + +### 내가 적을 메모 +- `@Query`는 SwiftUI 화면 안에서 선언형으로 데이터를 읽는 가장 기본적인 방식이다. +- `allTasks`, `categories`는 저장소와 연결된 상태라 데이터가 바뀌면 화면도 갱신된다. +- 이 파일의 `filteredTasks`는 DB 필터가 아니라 메모리 필터다. +- 즉 현재 구조는 "먼저 `@Query`로 읽고, 그 결과를 다시 화면에서 가공"하는 방식이다. +- `@Environment(\.modelContext)`는 읽기 전용 쿼리용이 아니라, 삭제 같은 변경 작업을 실행할 때 쓰는 작업 창구다. +- `selectedFilter`, `selectedCategory`, `searchText`는 모두 View 상태이므로 SwiftData 저장소에 직접 들어가는 조건이 아니라 화면 레벨에서 후처리된다. +- `modelContext.delete(task)` 후 목록이 바로 갱신되는 이유는 같은 container/context를 기준으로 `@Query` 결과가 다시 반영되기 때문이다. +- 이 구조는 단순하고 읽기 쉽지만, 데이터 양이 커지면 일부 필터를 `@Query`나 `FetchDescriptor`로 내려야 할 수 있다. + +## `ContentView.swift`를 보고 답할 질문 +- `@Query`와 `filteredTasks`는 각각 어느 층의 역할인가 +- 검색과 필터를 모두 `@Query`로 넣지 않고 메모리에서 거르는 이유는 무엇일까 +- `modelContext.delete(task)` 후 왜 목록이 바로 갱신될까 + +### 5. `AddTaskView.swift` +- 파일: `samples/TaskMaster/TaskMasterApp/AddTaskView.swift` +- 여기서는 Create를 본다. +- 완료 여부: [x] +- 체크 포인트 + - 입력값을 모델로 바꾸는 위치 + - `context.insert(...)`가 일어나는 위치 + - 저장 호출이 따로 없는 이유 + +### 내가 적을 메모 +- 사용자 입력은 `@State`로 모으고, 저장 시점에 `TaskItem` 인스턴스를 만든다. +- `modelContext.insert(...)`를 하면 SwiftData가 변경을 추적한다. +- 기본 autosave 흐름 덕분에 간단한 경우 명시적 `save()`가 없어도 된다. +- 입력 단계에서 바로 모델을 만들지 않는 이유는, 아직 취소될 수 있는 임시 입력을 저장소 객체로 올리지 않기 위해서다. +- `addTask()` 안에서 제목 trim, optional dueDate 처리, category 연결을 한 번에 정리한 뒤 insert하는 것이 가장 안전하다. +- create 시 category 관계는 `TaskItem(category: selectedCategory)`를 통해 연결된다. +- 즉 이 화면의 책임은 "폼 상태 수집"이고, 실제 SwiftData 등록은 마지막 확인 액션에서만 일어난다. + +## `AddTaskView.swift`를 보고 답할 질문 +- 입력값을 왜 곧바로 모델에 바인딩하지 않고 `@State`로 먼저 받는가 +- `insert`는 어떤 시점에 호출하는 게 가장 안전한가 +- create 시 category 관계는 어디서 연결되는가 + +### 6. `TaskDetailView.swift` +- 파일: `samples/TaskMaster/TaskMasterApp/TaskDetailView.swift` +- 여기서는 Update/Delete를 본다. +- 완료 여부: [x] +- 체크 포인트 + - 모델 수정이 UI에 반영되는 흐름 + - `@Bindable`의 역할 + - `context.delete(...)` 호출 위치 + +### 내가 적을 메모 +- SwiftData 모델은 변경 추적이 되기 때문에 프로퍼티 수정만으로도 상태 변화가 반영된다. +- `@Bindable`은 모델 프로퍼티를 폼 입력과 직접 연결할 때 중요하다. +- 삭제는 `modelContext.delete(...)`로 처리한다. +- `TextField("제목", text: $task.title)` 같은 바인딩이 가능한 이유는 `@Bindable var task`가 모델의 변경 가능한 binding을 열어 주기 때문이다. +- 제목, 메모, 우선순위, 카테고리처럼 폼 입력과 연결된 수정은 즉시 모델에 반영된다. +- 반면 삭제는 되돌리기 어려운 작업이므로 별도 버튼과 확인 alert를 거쳐 명시적으로 실행한다. +- 이 화면은 상세와 편집이 합쳐진 형태다. 정보를 보여주면서 동시에 모델 값을 직접 수정한다. + +## `TaskDetailView.swift`를 보고 답할 질문 +- `@Bindable var task: TaskItem`이 필요한 이유는 무엇인가 +- 어떤 수정은 즉시 반영되고, 어떤 건 별도 액션 버튼이 필요할까 +- 이 화면은 "편집 화면"인가, "상세 화면"인가, 아니면 둘 다인가 + +### 7. `DataService.swift` +- 파일: `samples/TaskMaster/Shared/DataService.swift` +- 여기서는 SwiftData를 코드에서 직접 다루는 패턴을 본다. +- 완료 여부: [x] +- 체크 포인트 + - `FetchDescriptor` + - `#Predicate` + - 정렬 + - 서비스 레이어로 분리하는 이유 + +### 내가 적을 메모 +- `@Query`는 View 안에서 선언형으로 읽는 방식이고, +- `FetchDescriptor`는 서비스/로직 코드에서 직접 쿼리할 때 쓰는 방식이다. +- `#Predicate`는 문자열 기반이 아니라 타입 안전한 조건식을 제공한다. +- 서비스 레이어를 두면 View에서 쿼리 세부 구현을 덜어낼 수 있다. +- 즉 `DataService`는 "화면이 아닌 코드 레벨에서 SwiftData를 다루는 예시"다. +- `FetchDescriptor`에는 정렬과 predicate를 함께 담을 수 있어서, 재사용 가능한 조회 로직을 함수 단위로 묶기 좋다. +- 이 서비스는 지금 샘플에서는 `shared` 싱글톤으로 쓰지만, SwiftData 자체가 싱글톤을 요구하는 것은 아니다. +- 기준은 단순하다. 화면 하나에서만 쓰는 단순 조회는 `@Query`, 여러 화면/행동에서 재사용할 조회나 배치 작업은 서비스 함수로 빼는 편이 낫다. + +## `DataService.swift`를 보고 답할 질문 +- `@Query` 대신 `FetchDescriptor`를 쓴 이유는 무엇인가 +- 이 서비스는 꼭 싱글톤이어야 하는가 +- "조회 로직을 View에서 할지, 서비스로 뺄지" 기준은 무엇인가 + +## SwiftData 핵심 개념 요약 + +### `@Model` +- SwiftData가 저장 가능한 데이터 모델로 인식하게 만드는 매크로 +- Core Data의 `.xcdatamodeld` 없이 순수 Swift 코드로 모델을 정의할 수 있다 + +### `ModelContainer` +- 저장소 전체를 감싸는 루트 객체 +- 어떤 모델들을 저장할지 schema를 들고 있고, 실제 persistence 설정도 가진다 + +### `ModelContext` +- 생성, 수정, 삭제 같은 실제 작업이 일어나는 문맥 +- `insert`, `delete`, `fetch`가 여기서 일어난다 + +### `@Query` +- SwiftUI View 안에서 선언형으로 데이터를 가져오는 방식 +- 데이터 변경 시 UI 갱신까지 연결된다 + +### `FetchDescriptor` +- 코드에서 직접 fetch 조건을 만들고 싶은 경우 사용하는 쿼리 기술 +- 서비스 레이어나 특정 로직에서 더 유연하다 + +### `#Predicate` +- 타입 안전한 필터 조건 +- 문자열 기반 predicate보다 Swift 문법에 가깝고 안전하다 + +## SwiftData vs UserDefaults vs Keychain + +### SwiftData는 무엇에 적합한가 +- 앱의 구조화된 로컬 데이터 저장 +- 여러 필드를 가진 모델 +- 목록/관계/정렬/필터가 필요한 데이터 +- 예: 할일, 카테고리, 메모, 기록, 로컬 캐시 + +### UserDefaults는 무엇에 적합한가 +- 작은 설정값 저장 +- 앱 상태 플래그 +- 사용자의 간단한 선호값 +- 예: 다크모드 설정, 온보딩 완료 여부, 마지막 선택 탭 + +### Keychain은 무엇에 적합한가 +- 민감 정보 저장 +- 보안이 중요한 인증 데이터 +- 예: access token, refresh token, 비밀번호, 암호 키 + +## 차이를 한 문장씩 정리 +- SwiftData: "구조화된 앱 데이터 저장소" +- UserDefaults: "가벼운 설정 저장소" +- Keychain: "보안 정보 저장소" + +## 왜 서로 대체재가 아닌가 +- SwiftData는 관계형/목록형 데이터에 강하지만, 민감 정보 저장 용도는 아니다. +- UserDefaults는 빠르고 단순하지만, 복잡한 모델/관계/검색에는 맞지 않는다. +- Keychain은 보안은 강하지만, 일반 앱 데이터 저장소처럼 다루기엔 무겁고 목적이 다르다. + +## 예시로 구분해보기 +- "할일 목록 저장" + - SwiftData +- "사용자가 마지막으로 본 필터 상태 저장" + - UserDefaults +- "로그인 토큰 저장" + - Keychain + +## 이번 학습 체크리스트 +- [x] `@Model`은 왜 `class`에 붙는지 설명할 수 있다 +- [x] `.modelContainer(...)`를 앱 루트에 붙이는 이유를 설명할 수 있다 +- [x] `@Query`와 `FetchDescriptor`의 차이를 말할 수 있다 +- [x] `context.insert` 후 왜 별도 `save()`가 없어도 되는지 설명할 수 있다 +- [x] 모델 프로퍼티를 바꾸면 왜 UI가 따라오는지 설명할 수 있다 +- [x] 관계 모델이 어디서 정의되는지 찾을 수 있다 +- [x] SwiftData와 UserDefaults, Keychain의 역할 차이를 말할 수 있다 + +## 이번 학습에서 내가 남길 정리 +- SwiftData는 "저장" 자체보다 "모델과 UI를 어떻게 이어주는가"가 핵심이다. +- `TaskMaster`는 SwiftUI 때 봤던 프로젝트지만, SwiftData 관점으로 다시 읽으면 완전히 다른 프로젝트처럼 보인다. +- 다음 단계인 `Observation` 학습과도 직접 연결된다. + 이유는 "데이터 변경이 어떻게 화면 상태로 이어지는가"를 더 명확히 보기 시작하기 때문이다. diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/App/AddTaskView.swift b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/App/AddTaskView.swift index a8036eea..13a84568 100644 --- a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/App/AddTaskView.swift +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/App/AddTaskView.swift @@ -19,14 +19,18 @@ import SwiftData struct AddTaskView: View { // MARK: - 환경 + // 실제 SwiftData 등록은 modelContext를 통해 수행한다. @Environment(\.modelContext) private var modelContext @Environment(\.dismiss) private var dismiss + // 카테고리 선택 UI를 구성하기 위해 저장소에서 목록을 읽는다. @Query(sort: \Category.order) private var categories: [Category] // MARK: - 상태 + // 사용자의 폼 입력은 우선 @State에 모아 둔다. + // 아직 "저장 확정" 전이므로 처음부터 TaskItem 모델에 직접 쓰지 않는다. @State private var title = "" @State private var hasDueDate = false @State private var dueDate = Date() @@ -174,17 +178,22 @@ struct AddTaskView: View { // MARK: - 액션 private func addTask() { + // 저장 직전에 한 번 더 입력값을 정리한다. let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedTitle.isEmpty else { return } + // 이 시점에만 SwiftData 모델 인스턴스를 만든다. + // 즉 폼 편집 상태와 저장 모델 생성을 분리한 구조다. let task = TaskItem( title: trimmedTitle, dueDate: hasDueDate ? dueDate : nil, priority: priority.rawValue, notes: notes.trimmingCharacters(in: .whitespacesAndNewlines), + // 선택된 Category를 넘기면 관계도 함께 연결된다. category: selectedCategory ) + // insert 이후에는 SwiftData가 변경 추적과 autosave 흐름을 이어받는다. modelContext.insert(task) dismiss() } diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/App/TaskDetailView.swift b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/App/TaskDetailView.swift index 7799823b..01c0ac08 100644 --- a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/App/TaskDetailView.swift +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/App/TaskDetailView.swift @@ -16,14 +16,18 @@ import SwiftData struct TaskDetailView: View { // MARK: - 환경 + // 삭제처럼 명시적인 데이터 작업은 modelContext를 통해 수행한다. @Environment(\.modelContext) private var modelContext @Environment(\.dismiss) private var dismiss + // 카테고리 선택용 목록 조회 @Query(sort: \Category.order) private var categories: [Category] // MARK: - 속성 + // @Bindable 덕분에 TaskItem의 프로퍼티를 + // TextField, Picker 같은 SwiftUI 입력과 직접 바인딩할 수 있다. @Bindable var task: TaskItem // MARK: - 상태 @@ -42,6 +46,7 @@ struct TaskDetailView: View { // 기본 정보 섹션 Section("기본 정보") { // 제목 + // 모델 프로퍼티와 직접 바인딩되므로 입력값이 곧바로 task.title에 반영된다. TextField("제목", text: $task.title) // 우선순위 @@ -147,6 +152,7 @@ struct TaskDetailView: View { "마감일", selection: Binding( get: { dueDate }, + // optional Date를 다루기 위해 직접 Binding을 만들어 연결한다. set: { task.dueDate = $0 } ), displayedComponents: [.date, .hourAndMinute] @@ -208,6 +214,8 @@ struct TaskDetailView: View { // MARK: - 액션 private func deleteTask() { + // 삭제는 즉시 반영되는 편집과 다르게 명시적인 파괴 작업이므로 + // 별도 alert 확인 뒤 modelContext.delete로 수행한다. modelContext.delete(task) dismiss() } diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/App/TaskMasterView.swift b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/App/TaskMasterView.swift index b9ead225..ea4fd4ae 100644 --- a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/App/TaskMasterView.swift +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/App/TaskMasterView.swift @@ -17,11 +17,17 @@ import SwiftData struct TaskMasterView: View { // MARK: - 환경 & 쿼리 + // modelContext는 "데이터 작업 창구"다. + // 읽기는 주로 @Query가 맡고, delete/insert/fetch 같은 변경 작업은 여기서 실행한다. @Environment(\.modelContext) private var modelContext + // 전체 Task를 생성일 역순으로 읽는다. + // 이 시점의 조회는 SwiftData 저장소 기준이고, + // 아래 filteredTasks에서 화면 상태에 따라 한 번 더 메모리 필터링한다. @Query(sort: \TaskItem.createdAt, order: .reverse) private var allTasks: [TaskItem] + // Category도 저장소에서 바로 읽어오며, order 기준으로 정렬한다. @Query(sort: \Category.order) private var categories: [Category] @@ -35,6 +41,8 @@ struct TaskMasterView: View { // MARK: - 필터링된 할일 private var filteredTasks: [TaskItem] { + // 1차 데이터 소스는 @Query 결과다. + // 여기서부터는 DB 쿼리가 아니라 View 안에서 수행하는 메모리 필터링이다. var tasks = allTasks // 완료 상태 필터 @@ -57,6 +65,7 @@ struct TaskMasterView: View { tasks = tasks.filter { $0.title.localizedCaseInsensitiveContains(searchText) } } + // 최종적으로 화면에 그릴 배열을 반환한다. return tasks } @@ -238,12 +247,15 @@ struct TaskMasterView: View { private func deleteTask(_ task: TaskItem) { withAnimation { + // 같은 modelContext에서 삭제하면 @Query가 연결된 목록도 함께 갱신된다. modelContext.delete(task) } } private func deleteCommpltedTasks() { withAnimation { + // 복잡한 삭제 로직은 서비스로 위임하고, + // View는 "언제 실행할지"만 결정한다. DataService.shared.deleteCompletedTasks(from: modelContext) } } diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/Shared/Category.swift b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/Shared/Category.swift index a12e937b..60ae56f7 100644 --- a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/Shared/Category.swift +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/Shared/Category.swift @@ -20,17 +20,28 @@ final class Category { /// 카테고리 이름 var name: String + /// 카테고리 대표 색상 + /// - 실제 저장은 Color 자체가 아니라 hex 문자열로 한다. var colorHex: String + /// 카테고리를 나타내는 SF Symbol 이름 var iconName: String + /// 생성 시각 var createdAt: Date + /// 목록에서의 정렬 순서 var order: Int + /// 이 카테고리에 속한 할일들 + /// - Category -> TaskItem의 1:N 관계 + /// - deleteRule이 .nullify라서 Category를 삭제해도 TaskItem까지 함께 삭제되지는 않는다. + /// - 대신 연결만 끊기므로, 각 TaskItem의 category는 nil이 된다. @Relationship(deleteRule: .nullify) var tasks: [TaskItem] = [] + // Category 자체의 고유 데이터(name, colorHex, iconName)와 + // 다른 모델과의 연결 데이터(tasks)를 나눠서 보면 관계 모델이 더 잘 보인다. init( name: String, colorHex: String = "#007AFF", @@ -49,11 +60,13 @@ final class Category { extension Category { /// SwiftUI Color 접근 + /// - 저장된 hex 문자열을 화면에서 쓰기 쉬운 Color로 변환한다. var color: Color { Color(hex: colorHex) ?? .blue } /// 색상 설정 + /// - View에서 선택한 Color를 다시 저장 가능한 hex 문자열로 바꾼다. func setColor(_ color: Color) { self.colorHex = color.toHex() ?? "#007AFF" } @@ -62,6 +75,7 @@ extension Category { // MARK: - 통계 extension Category { /// 완료되지 않은 할일 수 + /// - 별도 저장값이 아니라 tasks 관계를 기준으로 매번 계산한다. var pendingTaskCount: Int { tasks.filter { !$0.isCompleted }.count } diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/Shared/DataService.swift b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/Shared/DataService.swift index c61a4f90..97cda5af 100644 --- a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/Shared/DataService.swift +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/Shared/DataService.swift @@ -38,12 +38,15 @@ final class DataService { notes: notes, category: category ) + // View 바깥에서도 insert는 결국 ModelContext가 담당한다. context.insert(task) return task } /// 모든 할일 조회 func fetchAllTasks(from context: ModelContext) -> [TaskItem] { + // FetchDescriptor는 "코드에서 직접 만드는 쿼리 객체"다. + // View 안의 @Query와 달리 서비스/로직 계층에서도 사용할 수 있다. let descriptor = FetchDescriptor( sortBy: [SortDescriptor(\.createdAt, order: .reverse)] ) @@ -52,6 +55,8 @@ final class DataService { /// 미완료 할일만 조회 func fetchPendingTasks(from context: ModelContext) -> [TaskItem] { + // #Predicate는 타입 안전한 필터 조건이다. + // 문자열 NSPredicate보다 Swift 코드와 더 자연스럽게 연결된다. let predicate = #Predicate { !$0.isCompleted } let descriptor = FetchDescriptor( predicate: predicate, @@ -76,6 +81,8 @@ final class DataService { let startOfDay = calendar.startOfDay(for: Date()) let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)! + // predicate와 sort를 조합하면 "화면 상태"가 아닌 + // 도메인 규칙에 가까운 조회를 서비스 함수로 캡슐화할 수 있다. let predicate = #Predicate { task in task.dueDate! >= startOfDay && task.dueDate! < endOfDay @@ -152,6 +159,7 @@ final class DataService { /// 전체 통계 조회 func fetchStatistics(from context: ModelContext) -> TaskStatistics { + // 통계처럼 여러 조회/가공이 섞이는 로직은 View보다 서비스에 두는 편이 읽기 쉽다. let all = fetchAllTasks(from: context) let pending = all.filter { !$0.isCompleted } let completed = all.filter { $0.isCompleted } @@ -236,4 +244,3 @@ extension DataService { task.toggleCompletion() } } -