이 글은 누구를 위한 것인가
- 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을 다운로드해야 한다. 스팸 알림은 사용자가 즉시 삭제로 돌아온다.