🇺🇸 EN

📅 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 자동 동기화
✅ 반복 일정 지원
✅ 위치 기반 알람
✅ 모든 캘린더 앱과 호환

📦 학습 자료

💻
GitHub 프로젝트
📖
Apple 공식 문서
🎨
HIG 개인정보 가이드

📎 Apple 공식 자료

📘 공식 문서 🎬 WWDC 세션