iOS 로컬 알림과 리치 푸시: UserNotifications 완전 가이드

모바일 개발

iOS로컬 알림푸시 알림UserNotifications리치 알림

이 글은 누구를 위한 것인가

  • iOS 알림 권한 요청 시점과 방법을 최적화하고 싶은 팀
  • 이미지·버튼이 있는 리치 알림을 구현하고 싶은 개발자
  • 알림 그룹화와 뱃지 관리를 체계적으로 하고 싶은 엔지니어

들어가며

알림은 사용자를 앱으로 돌아오게 하는 가장 강력한 도구다. 하지만 잘못 구현하면 권한이 거부되거나 스팸으로 인식된다. 리치 알림은 이미지와 액션 버튼으로 인게이지먼트를 높인다.

이 글은 bluebutton.kr의 iOS 알림 최적화 가이드 를 참고하여 작성했습니다.


1. 알림 시스템 설계

[알림 권한 요청 전략]

나쁜 방법:
  앱 첫 실행 즉시 요청
  → 거부율 70%+ (사용자가 앱을 모름)

좋은 방법:
  가치를 먼저 보여준 후 요청
  "알림을 켜면 주문 상태를 실시간으로 받을 수 있습니다"
  맥락이 있는 시점에 요청 (첫 주문 완료 직후)
  → 허용율 60%+

[알림 카테고리와 액션]
  카테고리: 알림 유형 정의
  액션: 알림에서 바로 수행 가능한 버튼
  
  예시 - 주문 알림:
    카테고리: "ORDER_NOTIFICATION"
    액션: "배달 추적", "취소하기"

[알림 그룹화]
  threadIdentifier: 같은 스레드로 묶기
  summaryArgument: 그룹 요약 텍스트
  예: "새 메시지 5개" (대화 단위 그룹)

[리치 알림 구성요소]
  이미지/GIF/비디오 첨부
  커스텀 UI (Notification Content Extension)
  인터랙티브 위젯
  → 이미지: Notification Service Extension 필요

2. 알림 구현

import UserNotifications
import UIKit

class NotificationManager: NSObject {
    static let shared = NotificationManager()
    private let center = UNUserNotificationCenter.current()
    
    override init() {
        super.init()
        center.delegate = self
        setupCategories()
    }
    
    // 권한 요청 (맥락과 함께)
    func requestPermission(
        from viewController: UIViewController,
        completion: @escaping (Bool) -> Void,
    ) {
        center.getNotificationSettings { [weak self] settings in
            DispatchQueue.main.async {
                switch settings.authorizationStatus {
                case .notDetermined:
                    self?.showPermissionRationale(from: viewController, completion: completion)
                case .authorized, .provisional:
                    completion(true)
                case .denied:
                    self?.showSettingsAlert(from: viewController)
                    completion(false)
                default:
                    completion(false)
                }
            }
        }
    }
    
    private func showPermissionRationale(
        from vc: UIViewController,
        completion: @escaping (Bool) -> Void,
    ) {
        let alert = UIAlertController(
            title: "알림 허용",
            message: "주문 상태, 배달 도착 알림을 받을 수 있어요.",
            preferredStyle: .alert,
        )
        alert.addAction(UIAlertAction(title: "허용하기", style: .default) { _ in
            self.center.requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in
                DispatchQueue.main.async { completion(granted) }
            }
        })
        alert.addAction(UIAlertAction(title: "나중에", style: .cancel) { _ in
            completion(false)
        })
        vc.present(alert, animated: true)
    }
    
    // 카테고리와 액션 설정
    private func setupCategories() {
        let trackAction = UNNotificationAction(
            identifier: "TRACK_ORDER",
            title: "배달 추적",
            options: [.foreground],
        )
        let cancelAction = UNNotificationAction(
            identifier: "CANCEL_ORDER",
            title: "취소하기",
            options: [.destructive],
        )
        
        let orderCategory = UNNotificationCategory(
            identifier: "ORDER_NOTIFICATION",
            actions: [trackAction, cancelAction],
            intentIdentifiers: [],
            options: [.customDismissAction],
        )
        
        center.setNotificationCategories([orderCategory])
    }
    
    // 로컬 알림 스케줄링
    func scheduleOrderNotification(
        orderId: String,
        message: String,
        deliveryTime: Date,
        imageURL: URL? = nil,
    ) async throws {
        let content = UNMutableNotificationContent()
        content.title = "주문 업데이트"
        content.body = message
        content.sound = .default
        content.badge = 1
        content.categoryIdentifier = "ORDER_NOTIFICATION"
        content.threadIdentifier = "orders"  // 주문 그룹
        content.userInfo = ["orderId": orderId]
        
        // 리치 알림: 이미지 첨부
        if let imageURL = imageURL,
           let attachment = try? await downloadAndAttach(from: imageURL) {
            content.attachments = [attachment]
        }
        
        // 트리거: 특정 시간
        let trigger = UNCalendarNotificationTrigger(
            dateMatching: Calendar.current.dateComponents([.hour, .minute], from: deliveryTime),
            repeats: false,
        )
        
        let request = UNNotificationRequest(
            identifier: "order_\(orderId)",
            content: content,
            trigger: trigger,
        )
        
        try await center.add(request)
    }
    
    // 반복 알림 (매일 아침 운동 알림)
    func scheduleDailyReminder(hour: Int, minute: Int) async throws {
        let content = UNMutableNotificationContent()
        content.title = "오늘의 운동"
        content.body = "30분 운동으로 하루를 시작하세요 💪"
        content.sound = .default
        
        var dateComponents = DateComponents()
        dateComponents.hour = hour
        dateComponents.minute = minute
        
        let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true)
        let request = UNNotificationRequest(
            identifier: "daily_workout",
            content: content,
            trigger: trigger,
        )
        
        try await center.add(request)
    }
    
    // 알림 취소
    func cancelNotification(identifier: String) {
        center.removePendingNotificationRequests(withIdentifiers: [identifier])
        center.removeDeliveredNotifications(withIdentifiers: [identifier])
    }
    
    func cancelAllNotifications() {
        center.removeAllPendingNotificationRequests()
    }
    
    private func downloadAndAttach(from url: URL) async throws -> UNNotificationAttachment {
        let (localURL, _) = try await URLSession.shared.download(from: url)
        let ext = url.pathExtension
        let destURL = localURL.deletingLastPathComponent()
            .appendingPathComponent(localURL.lastPathComponent + "." + ext)
        try FileManager.default.moveItem(at: localURL, to: destURL)
        return try UNNotificationAttachment(identifier: "image", url: destURL)
    }
    
    private func showSettingsAlert(from vc: UIViewController) {
        let alert = UIAlertController(
            title: "알림이 꺼져 있어요",
            message: "설정에서 알림을 허용하면 중요한 업데이트를 받을 수 있습니다.",
            preferredStyle: .alert,
        )
        alert.addAction(UIAlertAction(title: "설정으로 이동", style: .default) { _ in
            UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!)
        })
        alert.addAction(UIAlertAction(title: "취소", style: .cancel))
        vc.present(alert, animated: true)
    }
}

extension NotificationManager: UNUserNotificationCenterDelegate {
    // 앱 포그라운드에서도 알림 표시
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification,
        withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void,
    ) {
        completionHandler([.banner, .badge, .sound])
    }
    
    // 알림 액션 처리
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse,
        withCompletionHandler completionHandler: @escaping () -> Void,
    ) {
        let orderId = response.notification.request.content.userInfo["orderId"] as? String
        
        switch response.actionIdentifier {
        case "TRACK_ORDER":
            // 배달 추적 화면으로 이동
            NotificationCenter.default.post(name: .trackOrder, object: orderId)
        case "CANCEL_ORDER":
            // 주문 취소 처리
            if let id = orderId { Task { await cancelOrder(id: id) } }
        default:
            // 알림 탭으로 앱 열기
            break
        }
        
        completionHandler()
    }
}

extension Notification.Name {
    static let trackOrder = Notification.Name("trackOrder")
}

func cancelOrder(id: String) async { /* 취소 API 호출 */ }

마무리

알림 허용율을 높이려면 요청 전에 가치를 먼저 보여줘라. 카테고리와 액션으로 알림에서 바로 행동하게 하면 인게이지먼트가 30% 이상 높아진다. 리치 알림의 이미지는 Notification Service Extension에서 원격 URL을 다운로드해야 한다. 스팸 알림은 사용자가 즉시 삭제로 돌아온다.