📅 EventKit 완전정복
캘린더와 리마인더를 앱에 통합! EventKit으로 일정 관리 기능을 구현하세요.
⭐ 난이도: ⭐⭐
⏱️ 예상 시간: 1-2h
📂 App Services
✨ EventKit이란?
EventKit은 iOS의 캘린더 및 리마인더 데이터에 접근하는 프레임워크입니다. 이벤트 생성, 수정, 삭제부터 알람 설정, 반복 일정까지 모든 캘린더 기능을 사용할 수 있습니다. iCloud와 자동 동기화되어 모든 기기에서 일관된 데이터를 제공합니다.
📦 주요 기능
기능 개요
✅ 캘린더 이벤트 생성/수정/삭제
✅ 리마인더 생성/수정/삭제
✅ 알람 설정 (시간 기반, 위치 기반)
✅ 반복 일정 (매일, 매주, 매월...)
✅ 캘린더 검색 및 필터링
✅ 참석자 관리
✅ iCloud 자동 동기화🔐 권한 요청
PermissionManager.swift
import EventKit class EventKitManager { static let shared = EventKitManager() let eventStore = EKEventStore() // 캘린더 권한 요청 func requestCalendarAccess() async -> Bool { do { return try await eventStore.requestFullAccessToEvents() } catch { print("Calendar access error: \(error)") return false } } // 리마인더 권한 요청 func requestReminderAccess() async -> Bool { do { return try await eventStore.requestFullAccessToReminders() } catch { print("Reminder access error: \(error)") return false } } // 권한 상태 확인 func checkAuthorizationStatus() -> EKAuthorizationStatus { return EKEventStore.authorizationStatus(for: .event) } } // 사용 예시 let hasAccess = await EventKitManager.shared.requestCalendarAccess() if hasAccess { print("캘린더 접근 허용됨") }
📆 이벤트 생성하기
CreateEvent.swift
import EventKit extension EventKitManager { // 기본 이벤트 생성 func createEvent( title: String, startDate: Date, endDate: Date, notes: String? = nil, location: String? = nil ) throws -> String { // 새 이벤트 생성 let event = EKEvent(eventStore: eventStore) event.title = title event.startDate = startDate event.endDate = endDate event.notes = notes event.location = location // 기본 캘린더에 저장 event.calendar = eventStore.defaultCalendarForNewEvents // 저장 try eventStore.save(event, span: .thisEvent) return event.eventIdentifier } // 알람 포함 이벤트 func createEventWithAlarm( title: String, startDate: Date, endDate: Date, alarmMinutesBefore: Int ) throws -> String { let event = EKEvent(eventStore: eventStore) event.title = title event.startDate = startDate event.endDate = endDate event.calendar = eventStore.defaultCalendarForNewEvents // 알람 추가 (10분 전) let alarm = EKAlarm(relativeOffset: TimeInterval(-alarmMinutesBefore * 60)) event.addAlarm(alarm) try eventStore.save(event, span: .thisEvent) return event.eventIdentifier } } // 사용 예시 let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: Date())! let oneHourLater = Calendar.current.date(byAdding: .hour, value: 1, to: tomorrow)! let eventID = try EventKitManager.shared.createEvent( title: "팀 미팅", startDate: tomorrow, endDate: oneHourLater, notes: "Q1 결과 리뷰", location: "회의실 A" ) print("이벤트 생성됨: \(eventID)")
🔁 반복 이벤트 생성
RecurringEvent.swift
import EventKit extension EventKitManager { // 매주 반복 이벤트 func createWeeklyRecurringEvent( title: String, startDate: Date, endDate: Date ) throws { let event = EKEvent(eventStore: eventStore) event.title = title event.startDate = startDate event.endDate = endDate event.calendar = eventStore.defaultCalendarForNewEvents // 반복 규칙 설정 (매주 반복) let recurrenceRule = EKRecurrenceRule( recurrenceWith: .weekly, interval: 1, end: nil // 무한 반복 ) event.addRecurrenceRule(recurrenceRule) try eventStore.save(event, span: .thisEvent) } // 매일 반복 (10회) func createDailyRecurringEvent( title: String, startDate: Date, endDate: Date, occurrenceCount: Int ) throws { let event = EKEvent(eventStore: eventStore) event.title = title event.startDate = startDate event.endDate = endDate event.calendar = eventStore.defaultCalendarForNewEvents // 10회 반복 후 종료 let recurrenceEnd = EKRecurrenceEnd(occurrenceCount: occurrenceCount) let recurrenceRule = EKRecurrenceRule( recurrenceWith: .daily, interval: 1, end: recurrenceEnd ) event.addRecurrenceRule(recurrenceRule) try eventStore.save(event, span: .thisEvent) } // 특정 요일만 반복 (월, 수, 금) func createCustomRecurringEvent( title: String, startDate: Date, endDate: Date ) throws { let event = EKEvent(eventStore: eventStore) event.title = title event.startDate = startDate event.endDate = endDate event.calendar = eventStore.defaultCalendarForNewEvents // 월, 수, 금 반복 let daysOfWeek = [ EKRecurrenceDayOfWeek(.monday), EKRecurrenceDayOfWeek(.wednesday), EKRecurrenceDayOfWeek(.friday) ] let recurrenceRule = EKRecurrenceRule( recurrenceWith: .weekly, interval: 1, daysOfTheWeek: daysOfWeek, daysOfTheMonth: nil, monthsOfTheYear: nil, weeksOfTheYear: nil, daysOfTheYear: nil, setPositions: nil, end: nil ) event.addRecurrenceRule(recurrenceRule) try eventStore.save(event, span: .thisEvent) } }
🔍 이벤트 검색하기
SearchEvents.swift
import EventKit extension EventKitManager { // 특정 기간의 이벤트 검색 func fetchEvents(from startDate: Date, to endDate: Date) -> [EKEvent] { let calendars = eventStore.calendars(for: .event) // Predicate 생성 let predicate = eventStore.predicateForEvents( withStart: startDate, end: endDate, calendars: calendars ) // 이벤트 가져오기 let events = eventStore.events(matching: predicate) return events } // 오늘의 이벤트 func fetchTodayEvents() -> [EKEvent] { let calendar = Calendar.current let startOfDay = calendar.startOfDay(for: Date()) let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)! return fetchEvents(from: startOfDay, to: endOfDay) } // 이번 주 이벤트 func fetchThisWeekEvents() -> [EKEvent] { let calendar = Calendar.current let today = Date() guard let weekStart = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: today)), let weekEnd = calendar.date(byAdding: .weekOfYear, value: 1, to: weekStart) else { return [] } return fetchEvents(from: weekStart, to: weekEnd) } // 특정 캘린더의 이벤트만 func fetchEventsFromCalendar(calendarID: String, from: Date, to: Date) -> [EKEvent] { guard let calendar = eventStore.calendar(withIdentifier: calendarID) else { return [] } let predicate = eventStore.predicateForEvents( withStart: from, end: to, calendars: [calendar] ) return eventStore.events(matching: predicate) } } // 사용 예시 let todayEvents = EventKitManager.shared.fetchTodayEvents() for event in todayEvents { print("\(event.title ?? "제목 없음") - \(event.startDate ?? Date())") }
✏️ 이벤트 수정 및 삭제
ModifyEvent.swift
import EventKit extension EventKitManager { // 이벤트 가져오기 func getEvent(identifier: String) -> EKEvent? { return eventStore.event(withIdentifier: identifier) } // 이벤트 수정 func updateEvent(identifier: String, newTitle: String) throws { guard let event = getEvent(identifier: identifier) else { throw NSError(domain: "Event not found", code: 404) } event.title = newTitle try eventStore.save(event, span: .thisEvent) } // 이벤트 삭제 func deleteEvent(identifier: String) throws { guard let event = getEvent(identifier: identifier) else { throw NSError(domain: "Event not found", code: 404) } try eventStore.remove(event, span: .thisEvent) } // 반복 이벤트 삭제 (모든 반복 포함) func deleteRecurringEvent(identifier: String) throws { guard let event = getEvent(identifier: identifier) else { throw NSError(domain: "Event not found", code: 404) } // .futureEvents: 이 이벤트와 미래 반복 모두 삭제 try eventStore.remove(event, span: .futureEvents) } }
📱 SwiftUI 통합
CalendarView.swift
import SwiftUI import EventKit struct CalendarView: View { @State private var events: [EKEvent] = [] @State private var hasAccess = false @State private var showingAddEvent = false let manager = EventKitManager.shared var body: some View { NavigationStack { Group { if hasAccess { List { ForEach(events, id: \.eventIdentifier) { event in VStack(alignment: .leading, spacing: 4) { Text(event.title ?? "제목 없음") .font(.headline) HStack { Image(systemName: "clock") Text(event.startDate ?? Date(), style: .time) } .font(.caption) .foregroundStyle(.secondary) if let location = event.location { HStack { Image(systemName: "location") Text(location) } .font(.caption) .foregroundStyle(.secondary) } } .padding(.vertical, 4) } .onDelete(deleteEvent) } } else { VStack(spacing: 20) { Image(systemName: "calendar.badge.exclamationmark") .font(.system(size: 60)) .foregroundStyle(.gray) Text("캘린더 접근 권한 필요") .font(.title2) .bold() Button("권한 요청") { Task { await requestAccess() } } .buttonStyle(.borderedProminent) } } } .navigationTitle("오늘의 일정") .toolbar { ToolbarItem(placement: .primaryAction) { Button(action: { showingAddEvent = true }) { Image(systemName: "plus") } } } .sheet(isPresented: $showingAddEvent) { AddEventView() } } .task { await requestAccess() } } func requestAccess() async { hasAccess = await manager.requestCalendarAccess() if hasAccess { loadEvents() } } func loadEvents() { events = manager.fetchTodayEvents() } func deleteEvent(at offsets: IndexSet) { for index in offsets { let event = events[index] try? manager.deleteEvent(identifier: event.eventIdentifier) } loadEvents() } } // 이벤트 추가 화면 struct AddEventView: View { @Environment(\.dismiss) var dismiss @State private var title = "" @State private var startDate = Date() @State private var endDate = Date().addingTimeInterval(3600) @State private var location = "" @State private var notes = "" var body: some View { NavigationStack { Form { Section("기본 정보") { TextField("제목", text: $title) TextField("위치", text: $location) } Section("시간") { DatePicker("시작", selection: $startDate) DatePicker("종료", selection: $endDate) } Section("메모") { TextEditor(text: $notes) .frame(height: 100) } } .navigationTitle("새 이벤트") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("취소") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("저장") { saveEvent() } .disabled(title.isEmpty) } } } } func saveEvent() { do { _ = try EventKitManager.shared.createEvent( title: title, startDate: startDate, endDate: endDate, notes: notes.isEmpty ? nil : notes, location: location.isEmpty ? nil : location ) dismiss() } catch { print("이벤트 저장 실패: \(error)") } } }
✅ 리마인더 (Reminders)
Reminders.swift
import EventKit extension EventKitManager { // 리마인더 생성 func createReminder( title: String, dueDate: Date? = nil, priority: Int = 0 ) throws -> String { let reminder = EKReminder(eventStore: eventStore) reminder.title = title reminder.calendar = eventStore.defaultCalendarForNewReminders() // 기한 설정 if let dueDate = dueDate { reminder.dueDateComponents = Calendar.current.dateComponents( [.year, .month, .day, .hour, .minute], from: dueDate ) } // 우선순위 (0: 없음, 1-4: 높음, 5: 보통, 6-9: 낮음) reminder.priority = priority try eventStore.save(reminder, commit: true) return reminder.calendarItemIdentifier } // 완료되지 않은 리마인더 가져오기 func fetchIncompleteReminders() async -> [EKReminder] { let predicate = eventStore.predicateForIncompleteReminders( withDueDateStarting: nil, ending: nil, calendars: nil ) return await withCheckedContinuation { continuation in eventStore.fetchReminders(matching: predicate) { reminders in continuation.resume(returning: reminders ?? []) } } } // 리마인더 완료 표시 func completeReminder(identifier: String) throws { guard let reminder = eventStore.calendarItem(withIdentifier: identifier) as? EKReminder else { throw NSError(domain: "Reminder not found", code: 404) } reminder.isCompleted = true reminder.completionDate = Date() try eventStore.save(reminder, commit: true) } } // 사용 예시 let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: Date())! let reminderID = try EventKitManager.shared.createReminder( title: "우유 사기", dueDate: tomorrow, priority: 1 ) // 미완료 리마인더 가져오기 let reminders = await EventKitManager.shared.fetchIncompleteReminders() print("할 일: \(reminders.count)개")
🔔 위치 기반 알람
LocationAlarm.swift
import EventKit import CoreLocation extension EventKitManager { // 위치 기반 리마인더 func createLocationBasedReminder( title: String, latitude: Double, longitude: Double, radius: Double = 100 // 미터 ) throws { let reminder = EKReminder(eventStore: eventStore) reminder.title = title reminder.calendar = eventStore.defaultCalendarForNewReminders() // 위치 알람 생성 let location = CLLocation(latitude: latitude, longitude: longitude) let structuredLocation = EKStructuredLocation(title: "알림 위치") structuredLocation.geoLocation = location structuredLocation.radius = radius // 도착/출발 알림 let alarm = EKAlarm() alarm.structuredLocation = structuredLocation alarm.proximity = .enter // .enter (도착), .leave (출발) reminder.addAlarm(alarm) try eventStore.save(reminder, commit: true) } } // 사용 예시 (집 근처 도착 시 알림) try EventKitManager.shared.createLocationBasedReminder( title: "현관문 열쇠 확인하기", latitude: 37.5665, longitude: 126.9780, radius: 200 )
💡 HIG 가이드라인
HIG 권장사항
✅ DO
1. 권한 요청 전 이유 설명
- "일정을 캘린더에 저장하려면 접근 권한이 필요합니다"
2. 기본 캘린더 사용
- defaultCalendarForNewEvents 사용
- 사용자가 선택한 기본 캘린더 존중
3. 명확한 이벤트 정보
- 제목, 시간, 위치를 정확하게
- notes에 추가 정보 제공
4. 반복 이벤트 처리 주의
- .thisEvent vs .futureEvents 구분
- 사용자에게 선택 옵션 제공
❌ DON'T
1. 권한 없이 캘린더 접근 시도
2. 사용자 동의 없이 이벤트 생성
3. 기본 캘린더 무시하고 임의 캘린더에 저장
4. 알림 없이 기존 이벤트 삭제🔧 실무 활용 팁
실전 패턴
import EventKit // 1. 캘린더 변경 감지 class CalendarObserver { let eventStore = EKEventStore() func observeChanges() { NotificationCenter.default.addObserver( forName: .EKEventStoreChanged, object: eventStore, queue: .main ) { _ in print("캘린더 데이터 변경됨") // UI 업데이트 } } } // 2. 참석자 추가 func createEventWithAttendees( title: String, startDate: Date, endDate: Date, attendeeEmails: [String] ) throws { let event = EKEvent(eventStore: EventKitManager.shared.eventStore) event.title = title event.startDate = startDate event.endDate = endDate // 참석자는 iOS에서 직접 추가 불가 (읽기만 가능) // Exchange 서버나 iCloud를 통해서만 가능 try EventKitManager.shared.eventStore.save(event, span: .thisEvent) } // 3. 캘린더 색상 가져오기 func getCalendarColor(event: EKEvent) -> UIColor { return UIColor(cgColor: event.calendar.cgColor) }
🔐 Info.plist 설정
Info.plist
<!-- 캘린더 권한 --> <key>NSCalendarsUsageDescription</key> <string>앱에서 일정을 캘린더에 저장하려면 권한이 필요합니다</string> <!-- 리마인더 권한 --> <key>NSRemindersUsageDescription</key> <string>할 일을 리마인더에 추가하려면 권한이 필요합니다</string>
💡 EventKit 핵심
✅ 시스템 캘린더/리마인더 완벽 통합
✅ iCloud 자동 동기화
✅ 반복 일정 지원
✅ 위치 기반 알람
✅ 모든 캘린더 앱과 호환