๐Ÿ”” User Notifications

๋กœ์ปฌ ์•Œ๋ฆผ๊ณผ ์›๊ฒฉ ํ‘ธ์‹œ ์•Œ๋ฆผ ์™„์ „์ •๋ณต

iOS 10+Live Activities ํ†ตํ•ฉ

โœจ User Notifications๋ž€?

User Notifications ํ”„๋ ˆ์ž„์›Œํฌ๋Š” ๋กœ์ปฌ ์•Œ๋ฆผ๊ณผ ์›๊ฒฉ ํ‘ธ์‹œ ์•Œ๋ฆผ์„ ํ†ตํ•ฉ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค. ์‹œ๊ฐ„/์œ„์น˜ ๊ธฐ๋ฐ˜ ํŠธ๋ฆฌ๊ฑฐ, ๋ฏธ๋””์–ด ์ฒจ๋ถ€, ์ปค์Šคํ…€ ์•ก์…˜, Notification Service Extension ๋“ฑ ๊ฐ•๋ ฅํ•œ ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ’ก ํ•ต์‹ฌ ๊ธฐ๋Šฅ: ๋กœ์ปฌ/์›๊ฒฉ ์•Œ๋ฆผ ยท ์‹œ๊ฐ„/์œ„์น˜ ํŠธ๋ฆฌ๊ฑฐ ยท ๋ฏธ๋””์–ด ์ฒจ๋ถ€ ยท ์ปค์Šคํ…€ ์•ก์…˜ ยท ์•Œ๋ฆผ ๊ด€๋ฆฌ ยท Notification Extension ยท ์•Œ๋ฆผ ์„ค์ • ยท ํฌ๊ทธ๋ผ์šด๋“œ ์•Œ๋ฆผ

๐ŸŽฏ 1. ๊ถŒํ•œ ์š”์ฒญ

์•Œ๋ฆผ ์‚ฌ์šฉ ์ „ ๋จผ์ € ์‚ฌ์šฉ์ž ๊ถŒํ•œ์„ ์š”์ฒญํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

NotificationManager.swift โ€” ๊ถŒํ•œ ์š”์ฒญ
import UserNotifications
import SwiftUI

@Observable
class NotificationManager: NSObject {
    var authorizationStatus: UNAuthorizationStatus = .notDetermined
    var isAuthorized = false

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

    // ๊ถŒํ•œ ์ƒํƒœ ํ™•์ธ
    func checkAuthorizationStatus() {
        UNUserNotificationCenter.current().getNotificationSettings { [weak self] settings in
            Task { @MainActor in
                self?.authorizationStatus = settings.authorizationStatus
                self?.isAuthorized = settings.authorizationStatus == .authorized
            }
        }
    }

    // ๊ถŒํ•œ ์š”์ฒญ
    func requestAuthorization() async throws -> Bool {
        let center = UNUserNotificationCenter.current()

        // ์•Œ๋ฆผ, ์‚ฌ์šด๋“œ, ๋ฐฐ์ง€ ๊ถŒํ•œ ์š”์ฒญ
        let granted = try await center.requestAuthorization(
            options: [.alert, .sound, .badge, .criticalAlert]
        )

        await MainActor.run {
            isAuthorized = granted
        }

        // ์›๊ฒฉ ํ‘ธ์‹œ ์•Œ๋ฆผ ๋“ฑ๋ก
        if granted {
            await UIApplication.shared.registerForRemoteNotifications()
        }

        return granted
    }
}

โฐ 2. ๋กœ์ปฌ ์•Œ๋ฆผ (Local Notifications)

์•ฑ์—์„œ ์ง์ ‘ ์Šค์ผ€์ค„๋งํ•˜๋Š” ์•Œ๋ฆผ์ž…๋‹ˆ๋‹ค.

LocalNotifications.swift โ€” ์‹œ๊ฐ„ ๊ธฐ๋ฐ˜ ์•Œ๋ฆผ
import UserNotifications

extension NotificationManager {
    // ์‹œ๊ฐ„ ๊ธฐ๋ฐ˜ ์•Œ๋ฆผ
    func scheduleTimeNotification(
        title: String,
        body: String,
        timeInterval: TimeInterval,
        repeats: Bool = false
    ) async throws {
        let content = UNMutableNotificationContent()
        content.title = title
        content.body = body
        content.sound = .default
        content.badge = 1

        // ํŠธ๋ฆฌ๊ฑฐ ์„ค์ • (5์ดˆ ํ›„)
        let trigger = UNTimeIntervalNotificationTrigger(
            timeInterval: timeInterval,
            repeats: repeats
        )

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

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

    // ์บ˜๋ฆฐ๋” ๊ธฐ๋ฐ˜ ์•Œ๋ฆผ (๋งค์ผ ์˜ค์ „ 9์‹œ)
    func scheduleDailyNotification(
        title: String,
        body: String,
        hour: Int,
        minute: Int
    ) async throws {
        let content = UNMutableNotificationContent()
        content.title = title
        content.body = body
        content.sound = .default

        var dateComponents = DateComponents()
        dateComponents.hour = hour
        dateComponents.minute = minute

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

        let request = UNNotificationRequest(
            identifier: "daily-\(hour)-\(minute)",
            content: content,
            trigger: trigger
        )

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

    // ์œ„์น˜ ๊ธฐ๋ฐ˜ ์•Œ๋ฆผ
    func scheduleLocationNotification(
        title: String,
        body: String,
        latitude: Double,
        longitude: Double,
        radius: Double
    ) async throws {
        let content = UNMutableNotificationContent()
        content.title = title
        content.body = body
        content.sound = .default

        let center = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
        let region = CLCircularRegion(
            center: center,
            radius: radius,
            identifier: UUID().uuidString
        )
        region.notifyOnEntry = true
        region.notifyOnExit = false

        let trigger = UNLocationNotificationTrigger(
            region: region,
            repeats: false
        )

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

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

๐Ÿ“ท 3. ๋ฏธ๋””์–ด ์ฒจ๋ถ€ (Media Attachments)

์•Œ๋ฆผ์— ์ด๋ฏธ์ง€, ๋น„๋””์˜ค, ์˜ค๋””์˜ค๋ฅผ ์ฒจ๋ถ€ํ•ฉ๋‹ˆ๋‹ค.

MediaNotifications.swift โ€” ๋ฏธ๋””์–ด ์ฒจ๋ถ€
import UserNotifications

extension NotificationManager {
    // ์ด๋ฏธ์ง€ ์ฒจ๋ถ€
    func scheduleNotificationWithImage(
        title: String,
        body: String,
        imageURL: URL
    ) async throws {
        let content = UNMutableNotificationContent()
        content.title = title
        content.body = body
        content.sound = .default

        // ์ด๋ฏธ์ง€ ๋‹ค์šด๋กœ๋“œ ๋ฐ ์ฒจ๋ถ€
        let (data, _) = try await URLSession.shared.data(from: imageURL)

        let tempDirectory = FileManager.default.temporaryDirectory
        let fileURL = tempDirectory.appendingPathComponent("notification-image.jpg")

        try data.write(to: fileURL)

        let attachment = try UNNotificationAttachment(
            identifier: "image",
            url: fileURL,
            options: nil
        )

        content.attachments = [attachment]

        let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)
        let request = UNNotificationRequest(
            identifier: UUID().uuidString,
            content: content,
            trigger: trigger
        )

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

    // ๋น„๋””์˜ค ์ฒจ๋ถ€
    func scheduleNotificationWithVideo(videoURL: URL) async throws {
        let content = UNMutableNotificationContent()
        content.title = "์ƒˆ ๋น„๋””์˜ค"
        content.body = "๋น„๋””์˜ค๋ฅผ ํ™•์ธํ•˜์„ธ์š”"

        let attachment = try UNNotificationAttachment(
            identifier: "video",
            url: videoURL,
            options: [UNNotificationAttachmentOptionsTypeHintKey: "public.mpeg-4"]
        )

        content.attachments = [attachment]

        let request = UNNotificationRequest(
            identifier: UUID().uuidString,
            content: content,
            trigger: nil
        )

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

๐ŸŽฌ 4. ์ปค์Šคํ…€ ์•ก์…˜ (Actions)

์•Œ๋ฆผ์—์„œ ์ง์ ‘ ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•  ์ˆ˜ ์žˆ๋Š” ๋ฒ„ํŠผ์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

NotificationActions.swift โ€” ์ปค์Šคํ…€ ์•ก์…˜
import UserNotifications

extension NotificationManager {
    // ์•ก์…˜ ์นดํ…Œ๊ณ ๋ฆฌ ๋“ฑ๋ก
    func registerNotificationCategories() {
        // ๋ฉ”์‹œ์ง€ ์•Œ๋ฆผ ์•ก์…˜
        let replyAction = UNTextInputNotificationAction(
            identifier: "REPLY_ACTION",
            title: "๋‹ต์žฅ",
            options: [],
            textInputButtonTitle: "๋ณด๋‚ด๊ธฐ",
            textInputPlaceholder: "๋ฉ”์‹œ์ง€๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”"
        )

        let likeAction = UNNotificationAction(
            identifier: "LIKE_ACTION",
            title: "๐Ÿ‘",
            options: [.foreground]
        )

        let deleteAction = UNNotificationAction(
            identifier: "DELETE_ACTION",
            title: "์‚ญ์ œ",
            options: [.destructive]
        )

        let messageCategory = UNNotificationCategory(
            identifier: "MESSAGE_CATEGORY",
            actions: [replyAction, likeAction, deleteAction],
            intentIdentifiers: [],
            options: []
        )

        // ๋ฆฌ๋งˆ์ธ๋” ์•Œ๋ฆผ ์•ก์…˜
        let completeAction = UNNotificationAction(
            identifier: "COMPLETE_ACTION",
            title: "์™„๋ฃŒ",
            options: []
        )

        let snoozeAction = UNNotificationAction(
            identifier: "SNOOZE_ACTION",
            title: "10๋ถ„ ๋’ค ์•Œ๋ฆผ",
            options: []
        )

        let reminderCategory = UNNotificationCategory(
            identifier: "REMINDER_CATEGORY",
            actions: [completeAction, snoozeAction],
            intentIdentifiers: [],
            options: []
        )

        UNUserNotificationCenter.current().setNotificationCategories([
            messageCategory,
            reminderCategory
        ])
    }

    // ์•ก์…˜์ด ์žˆ๋Š” ์•Œ๋ฆผ ์ „์†ก
    func scheduleActionableNotification() async throws {
        let content = UNMutableNotificationContent()
        content.title = "์ƒˆ ๋ฉ”์‹œ์ง€"
        content.body = "John์ด ๋ฉ”์‹œ์ง€๋ฅผ ๋ณด๋ƒˆ์Šต๋‹ˆ๋‹ค"
        content.categoryIdentifier = "MESSAGE_CATEGORY"
        content.sound = .default

        let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)
        let request = UNNotificationRequest(
            identifier: UUID().uuidString,
            content: content,
            trigger: trigger
        )

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

๐ŸŽฏ 5. Delegate ์ฒ˜๋ฆฌ

์•Œ๋ฆผ ์‘๋‹ต๊ณผ ํฌ๊ทธ๋ผ์šด๋“œ ์•Œ๋ฆผ์„ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.

NotificationDelegate.swift โ€” Delegate ๊ตฌํ˜„
import UserNotifications

extension NotificationManager: UNUserNotificationCenterDelegate {
    // ํฌ๊ทธ๋ผ์šด๋“œ์—์„œ ์•Œ๋ฆผ ํ‘œ์‹œ
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification
    ) async -> UNNotificationPresentationOptions {
        // ์•ฑ์ด ํ™œ์„ฑํ™” ์ƒํƒœ์ผ ๋•Œ๋„ ์•Œ๋ฆผ ํ‘œ์‹œ
        return [.banner, .sound, .badge]
    }

    // ์•Œ๋ฆผ ์‘๋‹ต ์ฒ˜๋ฆฌ (ํƒญํ•˜๊ฑฐ๋‚˜ ์•ก์…˜ ์„ ํƒ)
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse
    ) async {
        let userInfo = response.notification.request.content.userInfo
        let actionIdentifier = response.actionIdentifier

        switch actionIdentifier {
        case "REPLY_ACTION":
            if let textResponse = response as? UNTextInputNotificationResponse {
                let replyText = textResponse.userText
                // ๋‹ต์žฅ ์ฒ˜๋ฆฌ
                print("๋‹ต์žฅ: \(replyText)")
            }

        case "LIKE_ACTION":
            // ์ข‹์•„์š” ์ฒ˜๋ฆฌ
            print("์ข‹์•„์š” ํด๋ฆญ")

        case "DELETE_ACTION":
            // ์‚ญ์ œ ์ฒ˜๋ฆฌ
            print("์‚ญ์ œ ํด๋ฆญ")

        case "COMPLETE_ACTION":
            // ์™„๋ฃŒ ์ฒ˜๋ฆฌ
            print("์™„๋ฃŒ ํด๋ฆญ")

        case "SNOOZE_ACTION":
            // 10๋ถ„ ๋’ค ๋‹ค์‹œ ์•Œ๋ฆผ
            try? await scheduleTimeNotification(
                title: "๋ฆฌ๋งˆ์ธ๋”",
                body: "๋‹ค์‹œ ์•Œ๋ ค๋“œ๋ฆฝ๋‹ˆ๋‹ค",
                timeInterval: 600
            )

        case UNNotificationDefaultActionIdentifier:
            // ์•Œ๋ฆผ ํƒญ (๊ธฐ๋ณธ ์•ก์…˜)
            print("์•Œ๋ฆผ ํƒญ๋จ")

        case UNNotificationDismissActionIdentifier:
            // ์•Œ๋ฆผ ๋ฌด์‹œ
            print("์•Œ๋ฆผ ๋ฌด์‹œ๋จ")

        default:
            break
        }
    }
}

๐Ÿ“ฑ 6. ์•Œ๋ฆผ ๊ด€๋ฆฌ

์˜ˆ์•ฝ๋œ ์•Œ๋ฆผ์„ ์กฐํšŒ, ์ˆ˜์ •, ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค.

NotificationManagement.swift โ€” ์•Œ๋ฆผ ๊ด€๋ฆฌ
import UserNotifications

extension NotificationManager {
    // ๋Œ€๊ธฐ ์ค‘์ธ ์•Œ๋ฆผ ๋ชฉ๋ก
    func getPendingNotifications() async -> [UNNotificationRequest] {
        await UNUserNotificationCenter.current().pendingNotificationRequests()
    }

    // ์ „๋‹ฌ๋œ ์•Œ๋ฆผ ๋ชฉ๋ก
    func getDeliveredNotifications() async -> [UNNotification] {
        await UNUserNotificationCenter.current().deliveredNotifications()
    }

    // ํŠน์ • ์•Œ๋ฆผ ์ทจ์†Œ
    func cancelNotification(withIdentifier identifier: String) {
        UNUserNotificationCenter.current().removePendingNotificationRequests(
            withIdentifiers: [identifier]
        )
    }

    // ๋ชจ๋“  ์•Œ๋ฆผ ์ทจ์†Œ
    func cancelAllNotifications() {
        UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
    }

    // ์ „๋‹ฌ๋œ ์•Œ๋ฆผ ์ œ๊ฑฐ
    func removeDeliveredNotification(withIdentifier identifier: String) {
        UNUserNotificationCenter.current().removeDeliveredNotifications(
            withIdentifiers: [identifier]
        )
    }

    // ๋ฐฐ์ง€ ์ˆซ์ž ์ดˆ๊ธฐํ™”
    func clearBadge() {
        Task { @MainActor in
            UIApplication.shared.applicationIconBadgeNumber = 0
        }
    }
}

๐Ÿ“ฑ SwiftUI ํ†ตํ•ฉ

NotificationDemoView.swift โ€” ์ข…ํ•ฉ ๋ฐ๋ชจ
import SwiftUI

struct NotificationDemoView: View {
    @State private var manager = NotificationManager()
    @State private var pendingNotifications: [UNNotificationRequest] = []

    var body: some View {
        NavigationStack {
            List {
                Section("๊ถŒํ•œ") {
                    if manager.isAuthorized {
                        Label("์•Œ๋ฆผ ํ—ˆ์šฉ๋จ", systemImage: "checkmark.circle.fill")
                            .foregroundStyle(.green)
                    } else {
                        Button("์•Œ๋ฆผ ๊ถŒํ•œ ์š”์ฒญ") {
                            Task {
                                try? await manager.requestAuthorization()
                            }
                        }
                    }
                }

                Section("๋กœ์ปฌ ์•Œ๋ฆผ") {
                    Button("5์ดˆ ํ›„ ์•Œ๋ฆผ") {
                        Task {
                            try? await manager.scheduleTimeNotification(
                                title: "ํ…Œ์ŠคํŠธ ์•Œ๋ฆผ",
                                body: "5์ดˆ๊ฐ€ ์ง€๋‚ฌ์Šต๋‹ˆ๋‹ค",
                                timeInterval: 5
                            )
                        }
                    }

                    Button("๋งค์ผ ์˜ค์ „ 9์‹œ ์•Œ๋ฆผ") {
                        Task {
                            try? await manager.scheduleDailyNotification(
                                title: "์ข‹์€ ์•„์นจ!",
                                body: "์˜ค๋Š˜๋„ ์ข‹์€ ํ•˜๋ฃจ ๋˜์„ธ์š”",
                                hour: 9,
                                minute: 0
                            )
                        }
                    }

                    Button("์•ก์…˜์ด ์žˆ๋Š” ์•Œ๋ฆผ") {
                        Task {
                            manager.registerNotificationCategories()
                            try? await manager.scheduleActionableNotification()
                        }
                    }
                }

                Section("์˜ˆ์•ฝ๋œ ์•Œ๋ฆผ (\(pendingNotifications.count)๊ฐœ)") {
                    ForEach(pendingNotifications, id: \.identifier) { notification in
                        VStack(alignment: .leading) {
                            Text(notification.content.title)
                                .font(.headline)
                            Text(notification.content.body)
                                .font(.caption)
                                .foregroundStyle(.secondary)
                        }
                        .swipeActions {
                            Button("์‚ญ์ œ", role: .destructive) {
                                manager.cancelNotification(withIdentifier: notification.identifier)
                                loadPendingNotifications()
                            }
                        }
                    }
                }

                Section {
                    Button("๋ชจ๋“  ์•Œ๋ฆผ ์ทจ์†Œ", role: .destructive) {
                        manager.cancelAllNotifications()
                        loadPendingNotifications()
                    }

                    Button("๋ฐฐ์ง€ ์ดˆ๊ธฐํ™”") {
                        manager.clearBadge()
                    }
                }
            }
            .navigationTitle("User Notifications")
            .task {
                loadPendingNotifications()
            }
            .refreshable {
                loadPendingNotifications()
            }
        }
    }

    func loadPendingNotifications() {
        Task {
            pendingNotifications = await manager.getPendingNotifications()
        }
    }
}

๐Ÿ’ก HIG ๊ฐ€์ด๋“œ๋ผ์ธ

๐ŸŽฏ ์‹ค์ „ ํ™œ์šฉ

๐Ÿ“š ๋” ์•Œ์•„๋ณด๊ธฐ

โšก๏ธ ํŒ: Critical Alerts๋Š” ๋ฐฉํ•ด๊ธˆ์ง€ ๋ชจ๋“œ๋ฅผ ๋ฌด์‹œํ•˜๊ณ  ์•Œ๋ฆผ์„ ์ „๋‹ฌํ•ฉ๋‹ˆ๋‹ค. Apple์˜ ํŠน๋ณ„ ๊ถŒํ•œ์ด ํ•„์š”ํ•˜๋ฉฐ, ๊ธด๊ธ‰ ์ƒํ™ฉ(์˜๋ฃŒ, ๋ณด์•ˆ ๋“ฑ)์—๋งŒ ์‚ฌ์šฉํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.