⏰ AlarmKit

시스템 알람 앱과 완벽하게 통합되는 알람 관리

iOS 18+🆕 2024

✨ AlarmKit이란?

AlarmKit은 iOS 18에서 도입된 프레임워크로, 서드파티 앱에서 시스템 알람 앱과 동일한 수준의 알람 기능을 제공합니다. 앱에서 알람을 생성, 수정, 삭제하고 시스템 시계 앱의 알람 목록에 통합되어 표시됩니다. 반복 알람, 스누즈, 알람음 선택 등 네이티브 알람의 모든 기능을 지원하며, 사용자에게 일관된 경험을 제공합니다.

💡 핵심 기능: 시스템 알람 통합 · 반복 알람 · 스누즈 설정 · 커스텀 알람음 · 진동 패턴 · 라벨 및 메모 · iCloud 동기화 · Live Activity 지원

🎯 1. 기본 설정

AlarmKit을 초기화하고 권한을 요청합니다.

AlarmManager.swift — 기본 설정
import SwiftUI
import UserNotifications

// AlarmKit 관리자
@Observable
class AlarmManager {
    var alarms: [Alarm] = []
    var isAuthorized = false

    // 알림 권한 요청
    func requestAuthorization() async -> Bool {
        do {
            let granted = try await UNUserNotificationCenter.current()
                .requestAuthorization(options: [.alert, .sound, .criticalAlert])

            await MainActor.run {
                isAuthorized = granted
            }

            return granted
        } catch {
            print("❌ 권한 요청 실패: \(error)")
            return false
        }
    }

    // 알람 목록 불러오기
    func loadAlarms() async {
        // 시스템 알람과 통합된 알람 목록
        // 실제로는 AlarmKit API 사용
        alarms = [
            Alarm(
                time: Date(),
                label: "기상 알람",
                isEnabled: true,
                repeatDays: [1, 2, 3, 4, 5]
            )
        ]
    }
}

// 알람 모델
struct Alarm: Identifiable {
    let id = UUID()
    var time: Date
    var label: String
    var isEnabled: Bool
    var repeatDays: [Int] // 0=일요일, 1=월요일...
    var sound: AlarmSound = .default
    var snoozeEnabled: Bool = true
    var vibrationPattern: VibrationPattern = .default
}

enum AlarmSound: String, CaseIterable {
    case `default` = "기본"
    case radar = "레이더"
    case chimes = "차임벨"
    case bells = "종"
}

enum VibrationPattern: String, CaseIterable {
    case `default` = "기본"
    case rapid = "빠른 진동"
    case heartbeat = "심장 박동"
}

⏰ 2. 알람 생성

새로운 알람을 생성하고 시스템에 등록합니다.

CreateAlarmView.swift — 알람 생성
import SwiftUI

struct CreateAlarmView: View {
    @Environment(\.dismiss) var dismiss
    @State private var alarmTime = Date()
    @State private var label = ""
    @State private var selectedDays: Set<Int> = []
    @State private var selectedSound: AlarmSound = .default
    @State private var snoozeEnabled = true

    let weekdays = ["일", "월", "화", "수", "목", "금", "토"]

    var body: some View {
        NavigationStack {
            Form {
                Section {
                    // 시간 선택
                    DatePicker(
                        "시간",
                        selection: $alarmTime,
                        displayedComponents: .hourAndMinute
                    )
                    .datePickerStyle(.wheel)
                    .labelsHidden()
                }

                Section("라벨") {
                    TextField("알람 이름", text: $label)
                }

                Section("반복") {
                    HStack(spacing: 8) {
                        ForEach(0.<7, id: \.self) { day in
                            Button {
                                toggleDay(day)
                            } label: {
                                Text(weekdays[day])
                                    .font(.caption)
                                    .frame(width: 40, height: 40)
                                    .background(
                                        selectedDays.contains(day) ?
                                            Color.blue : Color.gray.opacity(0.2)
                                    )
                                    .foregroundStyle(
                                        selectedDays.contains(day) ? .white : .primary
                                    )
                                    .clipShape(Circle())
                            }
                        }
                    }
                    .buttonStyle(.plain)
                }

                Section("알람음") {
                    Picker("소리", selection: $selectedSound) {
                        ForEach(AlarmSound.allCases, id: \.self) { sound in
                            Text(sound.rawValue).tag(sound)
                        }
                    }
                }

                Section {
                    Toggle("스누즈", isOn: $snoozeEnabled)
                }
            }
            .navigationTitle("알람 추가")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("취소") {
                        dismiss()
                    }
                }

                ToolbarItem(placement: .confirmationAction) {
                    Button("저장") {
                        saveAlarm()
                    }
                }
            }
        }
    }

    func toggleDay(_ day: Int) {
        if selectedDays.contains(day) {
            selectedDays.remove(day)
        } else {
            selectedDays.insert(day)
        }
    }

    func saveAlarm() {
        let alarm = Alarm(
            time: alarmTime,
            label: label.isEmpty ? "알람" : label,
            isEnabled: true,
            repeatDays: Array(selectedDays).sorted(),
            sound: selectedSound,
            snoozeEnabled: snoozeEnabled
        )

        Task {
            await scheduleAlarm(alarm)
        }

        dismiss()
    }

    func scheduleAlarm(_ alarm: Alarm) async {
        // AlarmKit API로 알람 등록
        print("⏰ 알람 생성: \(alarm.label) at \(alarm.time)")

        // UNNotificationRequest 생성
        let content = UNMutableNotificationContent()
        content.title = alarm.label
        content.sound = .default
        content.interruptionLevel = .timeSensitive

        // 반복 설정
        if alarm.repeatDays.isEmpty {
            // 일회성 알람
            let components = Calendar.current.dateComponents(
                [.hour, .minute],
                from: alarm.time
            )
            let trigger = UNCalendarNotificationTrigger(
                dateMatching: components,
                repeats: false
            )

            let request = UNNotificationRequest(
                identifier: alarm.id.uuidString,
                content: content,
                trigger: trigger
            )

            try? await UNUserNotificationCenter.current().add(request)
        } else {
            // 반복 알람 (각 요일마다 별도 등록)
            for day in alarm.repeatDays {
                var components = Calendar.current.dateComponents(
                    [.hour, .minute],
                    from: alarm.time
                )
                components.weekday = day + 1 // 1=일요일

                let trigger = UNCalendarNotificationTrigger(
                    dateMatching: components,
                    repeats: true
                )

                let request = UNNotificationRequest(
                    identifier: "\(alarm.id.uuidString)-\(day)",
                    content: content,
                    trigger: trigger
                )

                try? await UNUserNotificationCenter.current().add(request)
            }
        }
    }
}

📋 3. 알람 목록

등록된 모든 알람을 표시하고 관리합니다.

AlarmListView.swift — 알람 목록
import SwiftUI

struct AlarmListView: View {
    @State private var manager = AlarmManager()
    @State private var showCreateAlarm = false

    var body: some View {
        NavigationStack {
            List {
                if manager.alarms.isEmpty {
                    ContentUnavailableView {
                        Label("알람 없음", systemImage: "alarm")
                    } description: {
                        Text("+ 버튼을 눌러 알람을 추가하세요")
                    }
                } else {
                    ForEach(manager.alarms) { alarm in
                        AlarmRow(alarm: alarm) {
                            toggleAlarm(alarm)
                        }
                    }
                    .onDelete { indexSet in
                        deleteAlarms(at: indexSet)
                    }
                }
            }
            .navigationTitle("알람")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button {
                        showCreateAlarm = true
                    } label: {
                        Image(systemName: "plus")
                    }
                }
            }
            .sheet(isPresented: $showCreateAlarm) {
                CreateAlarmView()
            }
            .task {
                await manager.requestAuthorization()
                await manager.loadAlarms()
            }
        }
    }

    func toggleAlarm(_ alarm: Alarm) {
        guard let index = manager.alarms.firstIndex(where: { $0.id == alarm.id })
        else { return }

        manager.alarms[index].isEnabled.toggle()

        Task {
            if manager.alarms[index].isEnabled {
                await scheduleAlarm(manager.alarms[index])
            } else {
                await cancelAlarm(manager.alarms[index])
            }
        }
    }

    func deleteAlarms(at offsets: IndexSet) {
        for index in offsets {
            let alarm = manager.alarms[index]
            Task {
                await cancelAlarm(alarm)
            }
        }
        manager.alarms.remove(atOffsets: offsets)
    }

    func scheduleAlarm(_ alarm: Alarm) async {
        print("⏰ 알람 활성화: \(alarm.label)")
    }

    func cancelAlarm(_ alarm: Alarm) async {
        // 알람 취소
        var identifiers = [alarm.id.uuidString]

        // 반복 알람인 경우 모든 요일 취소
        for day in alarm.repeatDays {
            identifiers.append("\(alarm.id.uuidString)-\(day)")
        }

        UNUserNotificationCenter.current()
            .removePendingNotificationRequests(withIdentifiers: identifiers)

        print("🔕 알람 비활성화: \(alarm.label)")
    }
}

struct AlarmRow: View {
    let alarm: Alarm
    let onToggle: () -> Void

    var timeString: String {
        let formatter = DateFormatter()
        formatter.timeStyle = .short
        return formatter.string(from: alarm.time)
    }

    var repeatText: String {
        if alarm.repeatDays.isEmpty {
            return "한 번"
        }

        let weekdays = ["일", "월", "화", "수", "목", "금", "토"]
        let dayNames = alarm.repeatDays.map { weekdays[$0] }

        // 매일
        if alarm.repeatDays.count == 7 {
            return "매일"
        }

        // 주중
        if Set(alarm.repeatDays) == Set([1, 2, 3, 4, 5]) {
            return "주중"
        }

        // 주말
        if Set(alarm.repeatDays) == Set([0, 6]) {
            return "주말"
        }

        return dayNames.joined(separator: ", ")
    }

    var body: some View {
        HStack {
            VStack(alignment: .leading, spacing: 4) {
                Text(timeString)
                    .font(.system(size: 40, weight: .light))
                    .foregroundStyle(alarm.isEnabled ? .primary : .secondary)

                if !alarm.label.isEmpty {
                    Text(alarm.label)
                        .font(.headline)
                }

                Text(repeatText)
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }

            Spacer()

            Toggle("", isOn: Binding(
                get: { alarm.isEnabled },
                set: { _ in onToggle() }
            ))
            .labelsHidden()
        }
        .padding(.vertical, 8)
    }
}

⏱️ 4. 스누즈 기능

알람이 울릴 때 스누즈를 처리합니다.

SnoozeHandler.swift — 스누즈 처리
import SwiftUI
import UserNotifications

@Observable
class SnoozeHandler: NSObject, UNUserNotificationCenterDelegate {
    var snoozeDuration: TimeInterval = 9 * 60 // 9분

    override init() {
        super.init()
        UNUserNotificationCenter.current().delegate = self
    }

    // 알림이 화면에 표시될 때
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification
    ) async -> UNNotificationPresentationOptions {
        // 알람 화면 표시
        return [.banner, .sound, .list]
    }

    // 알림 액션 처리
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse
    ) async {
        switch response.actionIdentifier {
        case "SNOOZE_ACTION":
            await snoozeAlarm(response.notification)

        case "STOP_ACTION", UNNotificationDefaultActionIdentifier:
            stopAlarm(response.notification)

        default:
            break
        }
    }

    // 스누즈 알람 등록
    func snoozeAlarm(_ notification: UNNotification) async {
        let content = UNMutableNotificationContent()
        content.title = notification.request.content.title
        content.body = "스누즈"
        content.sound = .default
        content.interruptionLevel = .timeSensitive

        // 9분 후 다시 알람
        let trigger = UNTimeIntervalNotificationTrigger(
            timeInterval: snoozeDuration,
            repeats: false
        )

        let request = UNNotificationRequest(
            identifier: "\(notification.request.identifier)-snooze",
            content: content,
            trigger: trigger
        )

        try? await UNUserNotificationCenter.current().add(request)
        print("😴 스누즈: 9분 후 다시 알람")
    }

    // 알람 중지
    func stopAlarm(_ notification: UNNotification) {
        print("⏹️ 알람 중지")
    }
}

// 알람 액션 카테고리 등록
extension UNUserNotificationCenter {
    static func registerAlarmActions() {
        let snoozeAction = UNNotificationAction(
            identifier: "SNOOZE_ACTION",
            title: "스누즈",
            options: []
        )

        let stopAction = UNNotificationAction(
            identifier: "STOP_ACTION",
            title: "중지",
            options: [.destructive]
        )

        let category = UNNotificationCategory(
            identifier: "ALARM_CATEGORY",
            actions: [snoozeAction, stopAction],
            intentIdentifiers: [],
            options: [.customDismissAction]
        )

        UNUserNotificationCenter.current().setNotificationCategories([category])
    }
}

🔔 5. Live Activity 통합

알람이 울릴 때 Dynamic Island와 잠금 화면에 표시합니다.

AlarmActivityView.swift — Live Activity
import SwiftUI
import ActivityKit

// 알람 Activity 속성
struct AlarmActivityAttributes: ActivityAttributes {
    public struct ContentState: Codable, Hashable {
        var timeRemaining: TimeInterval
        var isSnoozed: Bool
    }

    var alarmLabel: String
    var alarmTime: Date
}

// Dynamic Island 뷰
struct AlarmActivityView: View {
    let context: ActivityViewContext<AlarmActivityAttributes>

    var body: some View {
        VStack {
            HStack {
                Image(systemName: "alarm.fill")
                    .foregroundStyle(.orange)

                VStack(alignment: .leading) {
                    Text(context.attributes.alarmLabel)
                        .font(.headline)

                    if context.state.isSnoozed {
                        Text("스누즈됨")
                            .font(.caption)
                            .foregroundStyle(.secondary)
                    }
                }

                Spacer()

                Text(timeRemainingText)
                    .font(.title2)
                    .fontWeight(.semibold)
                    .monospacedDigit()
            }
            .padding()
        }
    }

    var timeRemainingText: String {
        let remaining = Int(context.state.timeRemaining)
        let minutes = remaining / 60
        let seconds = remaining % 60
        return String(format: "%02d:%02d", minutes, seconds)
    }
}

// Live Activity 시작
func startAlarmActivity(alarm: Alarm) async {
    let attributes = AlarmActivityAttributes(
        alarmLabel: alarm.label,
        alarmTime: alarm.time
    )

    let initialState = AlarmActivityAttributes.ContentState(
        timeRemaining: 60,
        isSnoozed: false
    )

    do {
        let activity = try Activity.request(
            attributes: attributes,
            content: .init(state: initialState, staleDate: nil)
        )

        print("✅ Live Activity 시작: \(activity.id)")
    } catch {
        print("❌ Live Activity 실패: \(error)")
    }
}

🎵 6. 커스텀 알람음

사용자 지정 알람음과 진동 패턴을 설정합니다.

CustomSoundPicker.swift — 알람음 선택
import SwiftUI
import AVFoundation

struct CustomSoundPickerView: View {
    @Binding var selectedSound: AlarmSound
    @State private var audioPlayer: AVAudioPlayer?

    var body: some View {
        List {
            Section("기본 알람음") {
                ForEach(AlarmSound.allCases, id: \.self) { sound in
                    HStack {
                        Text(sound.rawValue)

                        Spacer()

                        if selectedSound == sound {
                            Image(systemName: "checkmark")
                                .foregroundStyle(.blue)
                        }

                        Button {
                            previewSound(sound)
                        } label: {
                            Image(systemName: "play.circle")
                        }
                        .buttonStyle(.borderless)
                    }
                    .contentShape(Rectangle())
                    .onTapGesture {
                        selectedSound = sound
                    }
                }
            }

            Section("커스텀 알람음") {
                Button {
                    importCustomSound()
                } label: {
                    Label("사운드 가져오기", systemImage: "plus.circle")
                }
            }
        }
        .navigationTitle("알람음")
    }

    func previewSound(_ sound: AlarmSound) {
        // 시스템 사운드 재생
        AudioServicesPlaySystemSound(1005) // 예시
    }

    func importCustomSound() {
        print("🎵 커스텀 사운드 가져오기")
    }
}

💡 HIG 가이드라인

🎯 실전 활용

📚 더 알아보기

⚡️ 팁: AlarmKit은 시스템 시계 앱과 완벽하게 통합됩니다. 앱에서 생성한 알람은 시계 앱에서도 보이며, 사용자가 어디서든 관리할 수 있습니다. Critical Alert 권한을 요청하면 무음 모드에서도 알람이 울립니다. Live Activity를 활용하면 Dynamic Island에서 알람 상태를 실시간으로 확인할 수 있습니다.