이 글은 누구를 위한 것인가
- iOS 알림 권한 요청 시점과 방법을 최적화하고 싶은 팀
- 이미지·버튼이 있는 리치 알림을 구현하고 싶은 개발자
- 알림 그룹화와 뱃지 관리를 체계적으로 하고 싶은 엔지니어
들어가며
알림은 사용자를 앱으로 돌아오게 하는 가장 강력한 도구다. 하지만 잘못 구현하면 권한이 거부되거나 스팸으로 인식된다. 리치 알림은 이미지와 액션 버튼으로 인게이지먼트를 높이며, 첨부 파일 처리는 UNNotificationServiceExtension에서 이뤄진다.
이 글은 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을 다운로드해야 한다. 스팸 알림은 사용자가 즉시 삭제로 돌아온다.