iOS

푸시 알람을 어떻게 설계할까?

2료일 2025. 12. 21. 23:36
 

토스 같은 금융 앱은 하루에 수백만 건의 알림을 보냅니다.

 송금 완료, 카드 결제, 이벤트 참여, 마케팅까지.....

만약 이걸 그냥 "생각날 때마다" 보낸다면? 사용자는 알림 폭격에 스트레스 받아서 앱 삭제할 겁니다. 

저도 쓰잘데기 없는 알람이 많은 앱들은 성가셔서 대부분 삭제했어요... (죄송합니다 개발자분들)

실제로 Localytics 연구에 따르면 사용자의 절반 이상이 성가신 알림 때문에 알림 권한 자체를 거부한다고 하죠.


그럼 대기업들은 어떻게 할까요?

그저 "알림 보내기"가 아니라 "언제, 어떻게, 얼마나 자주 보낼 것인가"를 시스템적으로 설계합니다. 

 

이를 위해 알람에 대해 먼저 공부하고 -> 어떻게 설계하는지 -> 로 이어지면서 오늘의 글을 시작하겠습니다:)

 


발송 주체 Local VS Remote

먼저 알아야 할 게 있습니다. iOS 알림은 크게 두 가지입니다:

Remote Push Notification (원격 알림)

  • 경로: 서버 → APNs → 디바이스
  • 용도: 실시간 이벤트 (채팅, 송금 완료, 좋아요)
  • 특징: 앱이 꺼져 있어도 도착

Local Notification (로컬 알림)

  • 경로: 앱 자체 → iOS 시스템 → 사용자
  • 용도: 리마인더, 타이머, 주기적 작업
  • 특징: 네트워크 불필요, 완전 오프라인

그런데 여기서 중요한 포인트: 두 알림은 발송 주체만 다를 뿐, 도착 이후의 처리는 완전히 동일합니다.

똑같이 UNUserNotificationCenterDelegate를 거치고, 똑같이 그룹핑되고, 똑같이 딥링크를 처리하죠.

그렇다면 왜 원격 알림은 APNs라는 복잡한 시스템을 거쳐야 할까요?

 

APNs의 동작 원리 - 왜 복잡할까? 

1단계: Device Token - 디바이스를 찾는 방법

// 앱 시작 시 알림 권한 요청
let center = UNUserNotificationCenter.current()

let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])

if granted {
    await UIApplication.shared.registerForRemoteNotifications()
}

// AppDelegate에서 Token 수신
func application(
    _ application: UIApplication,
    didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
    let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
    
    // 이 Token을 자사 서버에 전송
    await sendTokenToServer(token)
}
[앱 실행]
    ↓
[알림 권한 요청] - UNUserNotificationCenter.requestAuthorization()
    ↓
[iOS 시스템이 APNs에 연결]
    ↓
[APNs가 고유한 Device Token 발급] (가변 길이의 고유 식별자)
    ↓
[AppDelegate의 didRegisterForRemoteNotificationsWithDeviceToken 호출]
    ↓
[앱이 이 Token을 자사 서버에 전송]
 

왜 매번 Token을 받을까요?

Device Token은 앱 재설치, iOS 업데이트, 복원 등의 상황에서 변경될 수 있습니다.
그래서 앱이 실행될 때마다 최신 Token을 받아서 서버에 업데이트해야 합니다.
이게 빠지면 알림이 도착하지 않는 버그가 발생하죠.
 

 

2단계: 알림 전송 - 디바이스가 꺼져있다면?

[서버가 알림 발송 결정]
    ↓
[서버 → APNs로 HTTP/2 요청] (Token + Payload)
    ↓
[APNs가 해당 디바이스 찾기]
    ↓
[디바이스가 꺼져있으면? APNs가 대기 (서버가 지정한 헤더만큼 )]
    ↓
[디바이스 켜지면 즉시 전달]
    ↓
[iOS가 앱의 Notification Service Extension 호출] (있다면)
    ↓
[알림 표시 or Background 작업]

APNs는 얼마나 기다려줄까요?

기본적으로 APNs는 서버가 apns-expiration 헤더로 지정한 시간만큼 대기합니다. 이 값을 0으로 설정하면 즉시 전달 실패하고, 미래의 Unix timestamp를 설정하면 그 시간까지 재시도합니다.

3단계: 앱 내부 처리 - 상태에 따라 다른 동작

알림이 도착하면 앱은 현재 상태에 따라 다르게 반응합니다:

 

앱 상태 호출되는 Delegate 메서드 일반적인 동작
Not Running didFinishLaunchingWithOptions launchOptions에서 알림 데이터 파싱
Background
(silent Push)
didReceiveRemoteNotification:fetchCompletionHandler 데이터 동기화 (content-available: 1 필요)
Background X 시스템이 알림만 표시
Foreground willPresentNotification 앱 내부 UI로 표시 (토스트 등)
사용자가 알림 탭 didReceiveNotificationResponse 딥링크 처리, 화면 이동

여기서 핵심은 Foreground 상태입니다. 

앱을 쓰고 있는데 시스템 알림이 뜨면 UX가 끊기잖아요?

그래서 카카오톡 같은 앱들은 willPresentNotification에서 .banner 옵션을 끄고, 대신 자체 디자인된 인앱 토스트를 띄웁니다.

func userNotificationCenter(
    _ center: UNUserNotificationCenter,
    willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
    
    // 앱이 foreground일 때
    // 시스템 배너 대신 커스텀 UI 표시
    await showInAppToast(notification.request.content)
    
    return [] // 시스템 알림 표시하지 않음
}

UNNotificationContent의 핵심 프로퍼티들

알림을 제대로 이해하려면 UNNotificationContent의 각 프로퍼티가 무엇을 의미하는지 알아야 합니다.

기본 표시 프로퍼티

content.title = "새 메시지"              
content.subtitle = "채팅방: iOS 개발자"  
content.body = "안녕하세요!"             

content.badge = 5                        
content.sound = .default

그룹핑 관련 프로퍼티

이게 핵심입니다. Instagram에서 좋아요 10개를 10개 알림으로 받고 싶지 않잖아요? 같은 유형은 묶어서 "선호님 외 9명이 좋아합니다"로 보여줘야 합니다.

// MARK: - Thread Identifier (그룹 ID)
content.threadIdentifier = "chat_room_123"

// 같은 threadIdentifier를 가진 알림들은 자동으로 그룹핑됩니다.
// 예: "채팅방: iOS 개발자" 그룹 안에 메시지 3개

원격 알림의 경우 aps Dictionary의 thread-id 키로 이 값을 설정합니다.

 
// MARK: - Summary Argument (요약 텍스트)
content.summaryArgument = "홍길동"
content.summaryArgumentCount = 1

// iOS가 자동으로 "3 more notifications from 홍길동" 같은 요약 생성

iOS 18에서는 AI가 알림을 정리해주면서 이 프로퍼티의 역할이 더욱 중요해졌다고 합니다.

중단 수준 (Interruption Level): 집중 모드

content.interruptionLevel = .passive        // 조용히 알림 센터에만
content.interruptionLevel = .active         // 일반 알림 (기본값)
content.interruptionLevel = .timeSensitive  // 집중 모드 뚫고 표시
content.interruptionLevel = .critical       // 무음 모드에서도 소리

왜 이게 중요할까요?

사용자가 "업무 집중" 모드를 켰는데 마케팅 알림이 뜨면 짜증나잖아요. .passive로 설정하면 조용히 알림 센터에만 쌓입니다.

반면 택시 도착 알림은 .timeSensitive로 설정해서 집중 모드를 뚫고 표시해야 하죠.

카테고리와 액션: 알림에서 바로 답장할때

카테고리 등록 예시:

let replyAction = UNTextInputNotificationAction(
    identifier: "REPLY_ACTION",
    title: "답장",
    options: [],
    textInputButtonTitle: "전송",
    textInputPlaceholder: "메시지 입력..."
)

let category = UNNotificationCategory(
    identifier: "MESSAGE_CATEGORY",
    actions: [replyAction],
    intentIdentifiers: []
)

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

카카오톡 알림에서 바로 답장 보낼 수 있는 기능, 바로 이겁니다.

 

시스템 설계: 대기업은 어떻게 알림을 관리할까?

1단계: 도메인별 알림 타입 정의

Facebook, Instagram 같은 앱은 수십 가지 알림 유형이 있습니다.

좋아요, 댓글, 친구 요청, 라이브 방송 시작, 광고... 이걸 if-else로 처리하면? 지옥이죠.

먼저 알림을 도메인별로 분류합니다:

enum NotificationDomain: String {
    case social       // 좋아요, 댓글
    case commerce     // 주문, 결제
    case marketing    // 이벤트, 쿠폰
    
    var dailyQuota: Int {
        switch self {
        case .social: return 50
        case .commerce: return 10
        case .marketing: return 1    // 마케팅은 하루 1개!
        }
    }
    
    var interruptionLevel: UNNotificationInterruptionLevel {
        switch self {
        case .commerce: return .timeSensitive  // 집중 모드 뚫기
        case .social: return .active
        case .marketing: return .passive       // 조용히
        }
    }
}

enum NotificationType: String {
    case like = "social.like"
    case orderConfirmed = "commerce.order_confirmed"
    case coupon = "marketing.coupon"
    
    var domain: NotificationDomain {
        let prefix = rawValue.split(separator: ".").first!
        return NotificationDomain(rawValue: String(prefix))!
    }
}

중요한 건, 도메인마다 다른 정책을 적용한다는 겁니다:

  • 마케팅 알림: 하루 1개, 조용히 (passive)
  • 결제 알림: 하루 10개, 집중 모드 뚫기 (timeSensitive)
  • 소셜 알림: 하루 50개, 일반 표시 (active)

2단계: Frequency Capping (빈도 제한)

사용자가 알림 폭격을 받지 않도록 하루에 보낼 수 있는 알림 개수를 제한합니다.

final class NotificationQuotaManager {
    static let shared = NotificationQuotaManager()
    
    private struct DailyQuota: Codable {
        var date: String
        var counts: [String: Int]
        
        var isToday: Bool {
            let today = DateFormatter().string(from: Date())
            return date == today
        }
    }
    
    func canSendNotification(type: NotificationType) -> Bool {
        let quota = loadQuota()
        let domain = type.domain
        let currentCount = quota.counts[domain.rawValue, default: 0]
        
        return currentCount < domain.dailyQuota
    }
    
    func recordNotificationSent(type: NotificationType) {
        var quota = loadQuota()
        quota.counts[type.domain.rawValue, default: 0] += 1
        saveQuota(quota)
    }
}

핵심은 "자정이 지나면 리셋"입니다.

날짜가 바뀌었으면 새로운 DailyQuota를 만들어서 카운트를 0으로 리셋하죠.

3단계: Intelligent Scheduler (지능형 스케줄러)

"언제 보낼 것인가"가 중요합니다. 새벽 3-4시에 뭔 광고 알람으로 유저를 깨우고 싶으시지 않다면..

final class NotificationScheduler {
    
    func scheduleNotification(
        type: NotificationType,
        content: UNMutableNotificationContent
    ) async throws {
        
        // 1. 빈도 제한 체크
        guard NotificationQuotaManager.shared.canSendNotification(type: type) else {
            throw NotificationError.quotaExceeded
        }
        
        // 2. 적절한 시간 계산
        let scheduledTime = calculateOptimalTime(type: type)
        
        // 3. 알림 설정
        content.interruptionLevel = type.domain.interruptionLevel
        
        // 4. 스케줄링
        let trigger = UNCalendarNotificationTrigger(
            dateMatching: Calendar.current.dateComponents(
                [.year, .month, .day, .hour, .minute],
                from: scheduledTime
            ),
            repeats: false
        )
        
        let request = UNNotificationRequest(
            identifier: UUID().uuidString,
            content: content,
            trigger: trigger
        )
        
        try await UNUserNotificationCenter.current().add(request)
        NotificationQuotaManager.shared.recordNotificationSent(type: type)
    }
    
    private func calculateOptimalTime(type: NotificationType) -> Date {
        let now = Date()
        let hour = Calendar.current.component(.hour, from: now)
        
        // 야간(22시~8시)이면서 마케팅 알림이면 → 다음날 오전 10시
        if (hour >= 22 || hour < 8) && type.domain == .marketing {
            var components = Calendar.current.dateComponents([.year, .month, .day], from: now)
            components.hour = 10
            components.minute = 0
            if hour >= 22 { components.day! += 1 }
            
            return Calendar.current.date(from: components) ?? now
        }
        
        return now  // 중요한 알림은 즉시
    }
}

핵심 로직:

  • 마케팅 알림은 야간이면 다음날 오전 10시로 연기
  • 거래 알림은 긴급하므로 즉시 발송

왜 이렇게 할까요? 새벽 3시에 쿠폰 알림이 오면 사용자는 짜증나서 알림을 꺼버릴 겁니다.

 

4단계: 딥링크 라우터 + Back Stack 복원

알림을 눌렀을 때 단순히 화면 하나만 띄우면 안 됩니다. 사용자가 "뒤로가기"를 눌렀을 때 자연스러운 흐름이 나와야 하죠.

final class NotificationRouter {
    
    func handleNotificationTap(_ response: UNNotificationResponse) async {
        let userInfo = response.notification.request.content.userInfo
        
        guard let deepLink = userInfo["deepLink"] as? String,
              let url = URL(string: deepLink) else {
            return
        }
        
        // 예: myapp://chat/room123
        if url.host == "chat" {
            let roomId = url.pathComponents[1]
            
            // 채팅 목록 → 채팅방 순서로 스택 생성
            navigationController.setViewControllers([
                ChatListViewController(),
                ChatRoomViewController(roomId: roomId)
            ], animated: false)
        }
    }
}

이렇게 하는 이유:

채팅방 알림을 타고 들어갔다가 "뒤로가기"를 눌렀을 때, 앱이 종료되면 이상하잖아요? 

채팅 목록으로 가야 자연스럽습니다. 그래서 알림 딥링크 처리 시 전체 네비게이션 스택을 미리 만들어두는 겁니다.

그래서 setViewControllers로 [채팅 목록, 채팅방]을 한 번에 넣어서 전체 네비게이션 스택을 복원하는 겁니다.

 

Notification Service Extension: 보안과 커스터마이징(별도 프로세스임)

알림이 사용자에게 표시되기 직전에 앱이 깨어나서 추가 작업을 할 수 있습니다.

대기업 앱들이 단순히 텍스트만 틱 보내는 게 아니라, 고화질 이미지를 붙이거나("오늘의 특가"),

보안 메시지를 복호화("OOO님이 메시지를 보냈습니다") 할 수 있는 핵심 기술이 바로 여기에 있습니다.

 

왜 Extension에서 처리할까요?

APNs가 보내는 페이로드(Payload)는 용량 제한(4KB)이 있습니다.

그래서 고화질 이미지를 직접 실어 보낼 수 없습니다. 또한, 보안상 민감한 내용을 텍스트 그대로 실어 보내는 것은 위험합니다.

  1. Rich Media: 이미지 URL만 받아서, Extension이 다운로드 후 알림에 사진을 붙여줄 수 있습니다
  2. End-to-End Encryption (보안): 서버는 암호화된 난수만 보냄. Extension이 앱 내부 키로 복호화해서 실제 텍스트로 변환.
  3. 데이터 보정: 사용자 이름이 변경되었거나 최신 정보가 필요할 때 데이터를 업데이트.

메인 앱은 꺼져 있을 수 있지만, Extension은 알림이 오는 순간 30초 동안 실행됩니다. 이 짧은 시간에 이미지 다운로드, 암호 해제, API 호출까지 할 수 있죠. 

동작 메커니즘 

  1. 서버 발송: 페이로드에 반드시 “mutable-content”: 1을 포함해서 발송.
  2. iOS 수신: 기기가 알림을 받음. 바로 띄우지 않고 멈춤.
  3. Extension 실행: 별도의 프로세스로 Notification Service Extension을 깨움 (약 30초의 시간 주어짐).
  4. 로직 수행: 이미지 다운로드, 복호화 등 수행.
  5. 알림 게시: 수정된 콘텐츠(bestAttemptContent)를 시스템에 전달하여 사용자에게 표시.

이 부분에 대해서는 이후에 다룰 일이 있을 때 더 자세히 적고 따로 페이지를 정리해볼게요