์ฝ˜ํ…์ธ  ๋Œ€ํ‘œ ์ด๋ฏธ์ง€ - ๐Ÿ“ฑ iOS ํ‘ธ์‹œ ์•Œ๋ฆผ ์™„์ „์ •๋ณต! User Notifications Framework์™€ APNs ์—ฐ๋™ ๋งˆ์Šคํ„ฐํ•˜๊ธฐ

๐Ÿ“ฑ iOS ํ‘ธ์‹œ ์•Œ๋ฆผ ์™„์ „์ •๋ณต! User Notifications Framework์™€ APNs ์—ฐ๋™ ๋งˆ์Šคํ„ฐํ•˜๊ธฐ

์นœ๊ตฌ์ฒ˜๋Ÿผ ์‰ฝ๊ฒŒ ๋ฐฐ์šฐ๋Š” ํ‘ธ์‹œ ์•Œ๋ฆผ์˜ ๋ชจ๋“  ๊ฒƒ

์•ผ, ๋„ˆ ์•ฑ ๋งŒ๋“ค๋‹ค๊ฐ€ ํ‘ธ์‹œ ์•Œ๋ฆผ ๋•Œ๋ฌธ์— ๋จธ๋ฆฌ ์•„ํŒ ๋˜ ์  ์žˆ์ง€? ๐Ÿ˜… ๋‚˜๋„ ์ฒ˜์Œ์—” APNs๊ฐ€ ๋ญ”์ง€, User Notifications Framework๊ฐ€ ๋ญ”์ง€ ํ•˜๋‚˜๋„ ๋ชฐ๋ผ์„œ ์—„์ฒญ ํ—ค๋งธ๊ฑฐ๋“ . ๊ทผ๋ฐ ์•Œ๊ณ  ๋ณด๋‹ˆ๊นŒ ์ƒ๊ฐ๋ณด๋‹ค ์ฒด๊ณ„์ ์ด๊ณ  ๋…ผ๋ฆฌ์ ์ธ ๊ตฌ์กฐ๋”๋ผ๊ณ ! ์˜ค๋Š˜์€ ๋‚ด๊ฐ€ ์‚ฝ์งˆํ•˜๋ฉด์„œ ๋ฐฐ์šด ๊ฒƒ๋“ค์„ ์นœ๊ตฌํ•œํ…Œ ์„ค๋ช…ํ•˜๋“ฏ์ด ์‰ฝ๊ฒŒ ํ’€์–ด์„œ ์•Œ๋ ค์ค„๊ฒŒ.

์š”์ฆ˜ ์•ฑ ๊ฐœ๋ฐœ์ž๋“ค ์‚ฌ์ด์—์„œ ํ‘ธ์‹œ ์•Œ๋ฆผ์€ ์„ ํƒ์ด ์•„๋‹ˆ๋ผ ํ•„์ˆ˜์ž–์•„? ์‚ฌ์šฉ์ž ์ฐธ์—ฌ๋„๋ฅผ ๋†’์ด๊ณ , ์ค‘์š”ํ•œ ์ •๋ณด๋ฅผ ์ „๋‹ฌํ•˜๊ณ , ์•ฑ์œผ๋กœ ๋‹ค์‹œ ๋ถˆ๋Ÿฌ์˜ค๋Š” ๋งˆ๋ฒ• ๊ฐ™์€ ๊ธฐ๋Šฅ์ด๋‹ˆ๊นŒ. ํŠนํžˆ ์žฌ๋Šฅ๋„ท ๊ฐ™์€ ํ”Œ๋žซํผ์—์„œ ์žฌ๋Šฅ ๊ฑฐ๋ž˜ ์•Œ๋ฆผ์ด๋‚˜ ๋ฉ”์‹œ์ง€ ์•Œ๋ฆผ์„ ๊ตฌํ˜„ํ•  ๋•Œ ํ‘ธ์‹œ ์•Œ๋ฆผ์€ ์ •๋ง ํ•ต์‹ฌ์ ์ธ ์—ญํ• ์„ ํ•˜์ง€.

iOS Device ๐Ÿ“ฑ User Notifications Framework APNs Server โ˜๏ธ Apple Push Notification Service Your Server ๐Ÿ–ฅ๏ธ Backend API Push Provider Request Send Device Token Routing Payload

๐ŸŽฏ ํ‘ธ์‹œ ์•Œ๋ฆผ์˜ ๊ธฐ๋ณธ ๊ฐœ๋…๋ถ€ํ„ฐ ์ฐจ๊ทผ์ฐจ๊ทผ

์ž, ์ผ๋‹จ ํ‘ธ์‹œ ์•Œ๋ฆผ์ด ๋ญ”์ง€๋ถ€ํ„ฐ ์ •ํ™•ํžˆ ์•Œ์•„์•ผ๊ฒ ์ง€? ํ‘ธ์‹œ ์•Œ๋ฆผ(Push Notification)์€ ๋ง ๊ทธ๋Œ€๋กœ ์„œ๋ฒ„์—์„œ ์‚ฌ์šฉ์ž์˜ ๋””๋ฐ”์ด์Šค๋กœ ์ •๋ณด๋ฅผ '๋ฐ€์–ด๋‚ด๋Š”(push)' ๊ฑฐ์•ผ. ์‚ฌ์šฉ์ž๊ฐ€ ์•ฑ์„ ์ผœ์ง€ ์•Š์•„๋„, ์‹ฌ์ง€์–ด ์•ฑ์ด ์™„์ „ํžˆ ์ข…๋ฃŒ๋œ ์ƒํƒœ์—์„œ๋„ ๋ฉ”์‹œ์ง€๋ฅผ ๋ฐ›์„ ์ˆ˜ ์žˆ๋‹ค๋Š” ๊ฒŒ ํ•ต์‹ฌ์ด์ง€.

๐Ÿ’ก ํ‘ธ์‹œ ์•Œ๋ฆผ์˜ 3๊ฐ€์ง€ ํ•ต์‹ฌ ์š”์†Œ

1. Provider (์ œ๊ณต์ž) - ๋„ˆ์˜ ๋ฐฑ์—”๋“œ ์„œ๋ฒ„๋ฅผ ๋งํ•ด. ํ‘ธ์‹œ ์•Œ๋ฆผ์„ ๋ณด๋‚ด๊ณ  ์‹ถ์„ ๋•Œ APNs์— ์š”์ฒญํ•˜๋Š” ์ฃผ์ฒด์•ผ.

2. APNs (Apple Push Notification service) - ์• ํ”Œ์ด ์šด์˜ํ•˜๋Š” ์ค‘์•™ ์„œ๋ฒ„. ๋ชจ๋“  ํ‘ธ์‹œ ์•Œ๋ฆผ์€ ์—ฌ๊ธฐ๋ฅผ ๊ฑฐ์ณ๊ฐ€.

3. Client App (ํด๋ผ์ด์–ธํŠธ ์•ฑ) - ์‚ฌ์šฉ์ž์˜ iOS ๋””๋ฐ”์ด์Šค์— ์„ค์น˜๋œ ๋„ˆ์˜ ์•ฑ์ด์ง€.

์ด ์„ธ ๊ฐ€์ง€๊ฐ€ ์–ด๋–ป๊ฒŒ ํ˜‘๋ ฅํ•˜๋Š”์ง€ ์ดํ•ดํ•˜๋ฉด ํ‘ธ์‹œ ์•Œ๋ฆผ์˜ ์ ˆ๋ฐ˜์€ ์ดํ•ดํ•œ ๊ฑฐ์•ผ! ๐ŸŽ‰

๐Ÿ“ฒ ํ‘ธ์‹œ ์•Œ๋ฆผ์ด ์ „๋‹ฌ๋˜๋Š” ๊ณผ์ •

์ž, ์ด์ œ ์‹ค์ œ๋กœ ํ‘ธ์‹œ ์•Œ๋ฆผ์ด ์–ด๋–ป๊ฒŒ ์ „๋‹ฌ๋˜๋Š”์ง€ ๋‹จ๊ณ„๋ณ„๋กœ ์‚ดํŽด๋ณผ๊ฒŒ. ์ด๊ฑฐ ์ดํ•ดํ•˜๋ฉด ๋‚˜์ค‘์— ๋””๋ฒ„๊น…ํ•  ๋•Œ ์—„์ฒญ ๋„์›€๋ผ.

Step 1: ์•ฑ์ด APNs์— ๋“ฑ๋ก ๐Ÿ“

์•ฑ์ด ์ฒ˜์Œ ์‹คํ–‰๋˜๋ฉด iOS ์‹œ์Šคํ…œ์— "๋‚˜ ํ‘ธ์‹œ ์•Œ๋ฆผ ๋ฐ›๊ณ  ์‹ถ์–ด์š”!"๋ผ๊ณ  ์š”์ฒญํ•ด. ๊ทธ๋Ÿฌ๋ฉด APNs๊ฐ€ ์ด ๋””๋ฐ”์ด์Šค์— ๊ณ ์œ ํ•œ Device Token์„ ๋ฐœ๊ธ‰ํ•ด์ค˜. ์ด๊ฑด ๋งˆ์น˜ ์ง‘ ์ฃผ์†Œ ๊ฐ™์€ ๊ฑฐ์•ผ. ์ด ํ† ํฐ์ด ์žˆ์–ด์•ผ ๋‚˜์ค‘์— ํ‘ธ์‹œ๋ฅผ ๋ณด๋‚ผ ์ˆ˜ ์žˆ๊ฑฐ๋“ .

Step 2: Device Token์„ ์„œ๋ฒ„์— ์ „์†ก ๐Ÿ“ค

์•ฑ์€ ๋ฐ›์€ Device Token์„ ๋„ˆ์˜ ๋ฐฑ์—”๋“œ ์„œ๋ฒ„๋กœ ๋ณด๋‚ด. ์„œ๋ฒ„๋Š” ์ด๊ฑธ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ €์žฅํ•ด๋‘ฌ. "์•„, ์ด ์‚ฌ์šฉ์ž๋Š” ์ด ํ† ํฐ์œผ๋กœ ํ‘ธ์‹œ๋ฅผ ๋ณด๋‚ด๋ฉด ๋˜๋Š”๊ตฌ๋‚˜!" ํ•˜๊ณ  ๊ธฐ์–ตํ•˜๋Š” ๊ฑฐ์ง€.

Step 3: ์„œ๋ฒ„๊ฐ€ APNs์— ํ‘ธ์‹œ ์š”์ฒญ ๐Ÿš€

ํ‘ธ์‹œ๋ฅผ ๋ณด๋‚ด๊ณ  ์‹ถ์„ ๋•Œ, ๋„ˆ์˜ ์„œ๋ฒ„๋Š” APNs์— ์š”์ฒญ์„ ๋ณด๋‚ด. ์ด๋•Œ Device Token๊ณผ ํ•จ๊ป˜ ๋ณด๋‚ผ ๋ฉ”์‹œ์ง€ ๋‚ด์šฉ(payload)์„ ๊ฐ™์ด ๋ณด๋‚ด๋Š” ๊ฑฐ์•ผ. ๋ฌผ๋ก  ์ด๋•Œ ์ธ์ฆ์„œ๋‚˜ ํ† ํฐ์œผ๋กœ "๋‚˜ ์ง„์งœ ์ด ์•ฑ ๊ฐœ๋ฐœ์ž ๋งž์•„์š”!"๋ผ๊ณ  ์ฆ๋ช…ํ•ด์•ผ ํ•ด.

Step 4: APNs๊ฐ€ ๋””๋ฐ”์ด์Šค๋กœ ์ „๋‹ฌ ๐Ÿ“ฌ

APNs๋Š” Device Token์„ ๋ณด๊ณ  ์–ด๋А ๋””๋ฐ”์ด์Šค๋กœ ๋ณด๋‚ผ์ง€ ์ฐพ์•„์„œ ํ‘ธ์‹œ๋ฅผ ์ „๋‹ฌํ•ด. ๋””๋ฐ”์ด์Šค๊ฐ€ ์˜คํ”„๋ผ์ธ์ด๋ฉด ์ผ์ • ์‹œ๊ฐ„ ๋™์•ˆ ๋ณด๊ด€ํ•˜๊ณ  ์žˆ๋‹ค๊ฐ€ ์˜จ๋ผ์ธ ๋˜๋ฉด ์ „๋‹ฌํ•˜์ง€.

Step 5: ์‚ฌ์šฉ์ž๊ฐ€ ์•Œ๋ฆผ ํ™•์ธ ๐Ÿ‘€

๋“œ๋””์–ด ์‚ฌ์šฉ์ž์˜ ํ™”๋ฉด์— ์•Œ๋ฆผ์ด ๋œฌ๋‹ค! ์‚ฌ์šฉ์ž๊ฐ€ ์•Œ๋ฆผ์„ ํƒญํ•˜๋ฉด ์•ฑ์ด ์‹คํ–‰๋˜๊ณ , ์•ฑ์€ ์•Œ๋ฆผ ๋‚ด์šฉ์— ๋”ฐ๋ผ ์ ์ ˆํ•œ ํ™”๋ฉด์„ ๋ณด์—ฌ์ฃผ๋Š” ๊ฑฐ์•ผ.

๐Ÿ› ๏ธ User Notifications Framework ๊นŠ์ด ํŒŒํ—ค์น˜๊ธฐ

iOS 10๋ถ€ํ„ฐ ์• ํ”Œ์ด ๋„์ž…ํ•œ User Notifications Framework๋Š” ์ง„์งœ ๊ฒŒ์ž„ ์ฒด์ธ์ €์•ผ. ์˜ˆ์ „์—๋Š” UILocalNotification์ด๋‹ˆ ๋ญ๋‹ˆ ์—ฌ๋Ÿฌ ๊ฐ€์ง€๊ฐ€ ์„ž์—ฌ ์žˆ์–ด์„œ ๋ณต์žกํ–ˆ๋Š”๋ฐ, ์ด์ œ๋Š” ์ด ํ”„๋ ˆ์ž„์›Œํฌ ํ•˜๋‚˜๋กœ ๋กœ์ปฌ ์•Œ๋ฆผ๊ณผ ์›๊ฒฉ ์•Œ๋ฆผ์„ ๋ชจ๋‘ ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ์–ด.

๐ŸŽจ User Notifications Framework์˜ ์ฃผ์š” ํŠน์ง•

โ€ข ํ†ตํ•ฉ๋œ API: ๋กœ์ปฌ๊ณผ ์›๊ฒฉ ์•Œ๋ฆผ์„ ๋™์ผํ•œ ๋ฐฉ์‹์œผ๋กœ ์ฒ˜๋ฆฌ
โ€ข Rich Notifications: ์ด๋ฏธ์ง€, ๋น„๋””์˜ค, ์˜ค๋””์˜ค ์ฒจ๋ถ€ ๊ฐ€๋Šฅ
โ€ข Notification Actions: ์•Œ๋ฆผ์—์„œ ๋ฐ”๋กœ ์•ก์…˜ ์‹คํ–‰
โ€ข Notification Management: ์ „๋‹ฌ๋œ ์•Œ๋ฆผ ๊ด€๋ฆฌ ๋ฐ ์—…๋ฐ์ดํŠธ
โ€ข Notification Extensions: ์ปค์Šคํ…€ UI์™€ ๋™์ž‘ ๊ตฌํ˜„

๐Ÿ“ฆ ํ•ต์‹ฌ ํด๋ž˜์Šค๋“ค ์•Œ์•„๋ณด๊ธฐ

User Notifications Framework์˜ ํ•ต์‹ฌ ํด๋ž˜์Šค๋“ค์„ ํ•˜๋‚˜์”ฉ ๋œฏ์–ด๋ณด์ž. ์ด๊ฒƒ๋“ค๋งŒ ์ œ๋Œ€๋กœ ์ดํ•ดํ•˜๋ฉด ํ‘ธ์‹œ ์•Œ๋ฆผ ๊ตฌํ˜„์€ ์‹์€ ์ฃฝ ๋จน๊ธฐ์•ผ!

UNUserNotificationCenter ๐ŸŽฏ

์ด๊ฑด ์•Œ๋ฆผ ์‹œ์Šคํ…œ์˜ ์ค‘์•™ ๊ด€์ œํƒ‘์ด๋ผ๊ณ  ๋ณด๋ฉด ๋ผ. ๋ชจ๋“  ์•Œ๋ฆผ ๊ด€๋ จ ์ž‘์—…์€ ์ด ํด๋ž˜์Šค๋ฅผ ํ†ตํ•ด ์ด๋ฃจ์–ด์ ธ.

import UserNotifications

// ์‹ฑ๊ธ€ํ†ค ์ธ์Šคํ„ด์Šค๋กœ ์ ‘๊ทผ
let center = UNUserNotificationCenter.current()

// ์•Œ๋ฆผ ๊ถŒํ•œ ์š”์ฒญ
center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
    if granted {
        print("์•Œ๋ฆผ ๊ถŒํ•œ ํš๋“! ๐ŸŽ‰")
    } else {
        print("์•Œ๋ฆผ ๊ถŒํ•œ ๊ฑฐ๋ถ€๋จ ๐Ÿ˜ข")
    }
}

// ๋ธ๋ฆฌ๊ฒŒ์ดํŠธ ์„ค์ • (์ค‘์š”!)
center.delegate = self

์—ฌ๊ธฐ์„œ options ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ ์ค‘์š”ํ•ด. ์–ด๋–ค ์ข…๋ฅ˜์˜ ์•Œ๋ฆผ์„ ๋ฐ›์„์ง€ ์ง€์ •ํ•˜๋Š” ๊ฑฐ๊ฑฐ๋“ :

โ€ข .alert: ํ™”๋ฉด์— ๋ฐฐ๋„ˆ๋‚˜ ์•Œ๋ฆผ ํ‘œ์‹œ
โ€ข .sound: ์•Œ๋ฆผ์Œ ์žฌ์ƒ
โ€ข .badge: ์•ฑ ์•„์ด์ฝ˜์— ๋ฐฐ์ง€ ํ‘œ์‹œ
โ€ข .carPlay: CarPlay์—์„œ ์•Œ๋ฆผ ํ‘œ์‹œ
โ€ข .criticalAlert: ๋ฐฉํ•ด๊ธˆ์ง€ ๋ชจ๋“œ์—์„œ๋„ ํ‘œ์‹œ (ํŠน๋ณ„ ๊ถŒํ•œ ํ•„์š”)

UNNotificationRequest ๐Ÿ“จ

์•Œ๋ฆผ ์š”์ฒญ์„ ๋‚˜ํƒ€๋‚ด๋Š” ํด๋ž˜์Šค์•ผ. ๋กœ์ปฌ ์•Œ๋ฆผ์„ ์˜ˆ์•ฝํ•  ๋•Œ ์‚ฌ์šฉํ•˜์ง€. ์›๊ฒฉ ์•Œ๋ฆผ์€ ์„œ๋ฒ„์—์„œ ๋ณด๋‚ด๋‹ˆ๊นŒ ์ง์ ‘ ๋งŒ๋“ค ์ผ์€ ์—†์–ด.

// ์•Œ๋ฆผ ์ฝ˜ํ…์ธ  ์ƒ์„ฑ
let content = UNMutableNotificationContent()
content.title = "์žฌ๋Šฅ๋„ท ์•Œ๋ฆผ"
content.body = "์ƒˆ๋กœ์šด ์žฌ๋Šฅ ๊ฑฐ๋ž˜ ์š”์ฒญ์ด ๋„์ฐฉํ–ˆ์Šต๋‹ˆ๋‹ค!"
content.sound = .default
content.badge = 1

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

// ์š”์ฒญ ์ƒ์„ฑ
let request = UNNotificationRequest(
    identifier: "talent-request-001",
    content: content,
    trigger: trigger
)

// ์•Œ๋ฆผ ์˜ˆ์•ฝ
center.add(request) { error in
    if let error = error {
        print("์•Œ๋ฆผ ์˜ˆ์•ฝ ์‹คํŒจ: \(error)")
    }
}

๐Ÿ’ก Identifier์˜ ์ค‘์š”์„ฑ

identifier๋Š” ๊ฐ ์•Œ๋ฆผ์„ ๊ตฌ๋ถ„ํ•˜๋Š” ๊ณ ์œ  ID์•ผ. ๊ฐ™์€ identifier๋กœ ์ƒˆ ์•Œ๋ฆผ์„ ์ถ”๊ฐ€ํ•˜๋ฉด ๊ธฐ์กด ์•Œ๋ฆผ์ด ์—…๋ฐ์ดํŠธ๋ผ. ์ด๊ฑธ ํ™œ์šฉํ•˜๋ฉด ์ค‘๋ณต ์•Œ๋ฆผ์„ ๋ฐฉ์ง€ํ•˜๊ฑฐ๋‚˜ ์•Œ๋ฆผ ๋‚ด์šฉ์„ ์—…๋ฐ์ดํŠธํ•  ์ˆ˜ ์žˆ์–ด!

UNNotificationTrigger โฐ

์•Œ๋ฆผ์ด ์–ธ์ œ ๋ฐœ๋™๋ ์ง€ ๊ฒฐ์ •ํ•˜๋Š” ํŠธ๋ฆฌ๊ฑฐ์•ผ. ์—ฌ๋Ÿฌ ์ข…๋ฅ˜๊ฐ€ ์žˆ๋Š”๋ฐ ์ƒํ™ฉ์— ๋งž๊ฒŒ ๊ณจ๋ผ ์“ฐ๋ฉด ๋ผ:

// 1. ์‹œ๊ฐ„ ๊ฐ„๊ฒฉ ํŠธ๋ฆฌ๊ฑฐ (5๋ถ„ ํ›„)
let timeInterval = UNTimeIntervalNotificationTrigger(
    timeInterval: 300,
    repeats: false
)

// 2. ์บ˜๋ฆฐ๋” ํŠธ๋ฆฌ๊ฑฐ (๋งค์ผ ์˜ค์ „ 9์‹œ)
var dateComponents = DateComponents()
dateComponents.hour = 9
dateComponents.minute = 0
let calendar = UNCalendarNotificationTrigger(
    dateMatching: dateComponents,
    repeats: true
)

// 3. ์œ„์น˜ ํŠธ๋ฆฌ๊ฑฐ (ํŠน์ • ์œ„์น˜ ๋„์ฐฉ ์‹œ)
let center = CLLocationCoordinate2D(latitude: 37.5665, longitude: 126.9780)
let region = CLCircularRegion(
    center: center,
    radius: 100,
    identifier: "Seoul"
)
region.notifyOnEntry = true
let location = UNLocationNotificationTrigger(
    region: region,
    repeats: false
)

UNUserNotificationCenterDelegate ๐ŸŽญ

์ด ๋ธ๋ฆฌ๊ฒŒ์ดํŠธ๋Š” ์ง„์งœ ์ค‘์š”ํ•ด! ์•Œ๋ฆผ์ด ๋„์ฐฉํ–ˆ์„ ๋•Œ๋‚˜ ์‚ฌ์šฉ์ž๊ฐ€ ์•Œ๋ฆผ์„ ํƒญํ–ˆ์„ ๋•Œ์˜ ๋™์ž‘์„ ์ œ์–ดํ•  ์ˆ˜ ์žˆ๊ฑฐ๋“ .

extension AppDelegate: UNUserNotificationCenterDelegate {
    
    // ์•ฑ์ด ํฌ๊ทธ๋ผ์šด๋“œ์— ์žˆ์„ ๋•Œ ์•Œ๋ฆผ์ด ๋„์ฐฉํ•˜๋ฉด
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification,
        withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
    ) {
        // ํฌ๊ทธ๋ผ์šด๋“œ์—์„œ๋„ ์•Œ๋ฆผ ํ‘œ์‹œ
        completionHandler([.banner, .sound, .badge])
    }
    
    // ์‚ฌ์šฉ์ž๊ฐ€ ์•Œ๋ฆผ์„ ํƒญํ–ˆ์„ ๋•Œ
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse,
        withCompletionHandler completionHandler: @escaping () -> Void
    ) {
        let userInfo = response.notification.request.content.userInfo
        
        // ์•Œ๋ฆผ ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ
        if let talentId = userInfo["talentId"] as? String {
            // ํ•ด๋‹น ์žฌ๋Šฅ ์ƒ์„ธ ํ™”๋ฉด์œผ๋กœ ์ด๋™
            navigateToTalentDetail(talentId: talentId)
        }
        
        completionHandler()
    }
}

โš ๏ธ ์ฃผ์˜์‚ฌํ•ญ

iOS 10 ์ด์ „์—๋Š” ์•ฑ์ด ํฌ๊ทธ๋ผ์šด๋“œ์— ์žˆ์œผ๋ฉด ์•Œ๋ฆผ์ด ํ‘œ์‹œ๋˜์ง€ ์•Š์•˜์–ด. ํ•˜์ง€๋งŒ ์ด์ œ๋Š” willPresent ๋ฉ”์„œ๋“œ์—์„œ ์›ํ•˜๋Š” ์˜ต์…˜์„ ๋ฐ˜ํ™˜ํ•˜๋ฉด ํฌ๊ทธ๋ผ์šด๋“œ์—์„œ๋„ ์•Œ๋ฆผ์„ ๋ณผ ์ˆ˜ ์žˆ์–ด!

๐Ÿ” APNs ์ธ์ฆ ์„ค์ •ํ•˜๊ธฐ

์ž, ์ด์ œ ๋ณธ๊ฒฉ์ ์œผ๋กœ APNs์™€ ์—ฐ๋™ํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ์•Œ์•„๋ณผ ์ฐจ๋ก€์•ผ. ์ด ๋ถ€๋ถ„์ด ์ฒ˜์Œ์—” ์ข€ ๋ณต์žกํ•˜๊ฒŒ ๋А๊ปด์งˆ ์ˆ˜ ์žˆ๋Š”๋ฐ, ์ฐจ๊ทผ์ฐจ๊ทผ ๋”ฐ๋ผ์˜ค๋ฉด ๊ดœ์ฐฎ์•„!

๐ŸŽซ ์ธ์ฆ ๋ฐฉ๋ฒ• 2๊ฐ€์ง€

APNs์— ํ‘ธ์‹œ๋ฅผ ๋ณด๋‚ด๋ ค๋ฉด "๋‚˜ ์ง„์งœ ์ด ์•ฑ ๊ฐœ๋ฐœ์ž ๋งž์•„์š”!"๋ผ๊ณ  ์ฆ๋ช…ํ•ด์•ผ ํ•ด. ๋ฐฉ๋ฒ•์€ ๋‘ ๊ฐ€์ง€๊ฐ€ ์žˆ์–ด:

1. Token-based Authentication (ํ† ํฐ ๊ธฐ๋ฐ˜ ์ธ์ฆ) - ์ถ”์ฒœ! โญ

โ€ข .p8 ํ‚ค ํŒŒ์ผ ์‚ฌ์šฉ
โ€ข ๋งŒ๋ฃŒ๋˜์ง€ ์•Š์Œ (ํ•œ ๋ฒˆ ๋งŒ๋“ค๋ฉด ๊ณ„์† ์‚ฌ์šฉ)
โ€ข ์—ฌ๋Ÿฌ ์•ฑ์— ํ•˜๋‚˜์˜ ํ‚ค๋กœ ์‚ฌ์šฉ ๊ฐ€๋Šฅ
โ€ข ์„ค์ •์ด ๊ฐ„๋‹จํ•˜๊ณ  ๊ด€๋ฆฌ๊ฐ€ ํŽธํ•จ
โ€ข ์ตœ์‹  ๋ฐฉ์‹์ด๋ผ ์• ํ”Œ์ด ๊ถŒ์žฅ

2. Certificate-based Authentication (์ธ์ฆ์„œ ๊ธฐ๋ฐ˜ ์ธ์ฆ) - ๋ ˆ๊ฑฐ์‹œ

โ€ข .p12 ์ธ์ฆ์„œ ํŒŒ์ผ ์‚ฌ์šฉ
โ€ข 1๋…„๋งˆ๋‹ค ๊ฐฑ์‹  ํ•„์š”
โ€ข ์•ฑ๋งˆ๋‹ค ๋ณ„๋„ ์ธ์ฆ์„œ ํ•„์š”
โ€ข ์˜ˆ์ „ ๋ฐฉ์‹์ด๋ผ ์ ์  ์‚ฌ์šฉ ์ค„์–ด๋“œ๋Š” ์ถ”์„ธ

๋‚˜๋Š” ํ† ํฐ ๊ธฐ๋ฐ˜ ์ธ์ฆ์„ ๊ฐ•๋ ฅ ์ถ”์ฒœํ•ด! ํ›จ์”ฌ ํŽธํ•˜๊ฑฐ๋“ . ๐Ÿ˜Ž

๐Ÿ”‘ ํ† ํฐ ๊ธฐ๋ฐ˜ ์ธ์ฆ ์„ค์ • ๋‹จ๊ณ„

Step 1: Apple Developer ์‚ฌ์ดํŠธ์—์„œ ํ‚ค ์ƒ์„ฑ

1. Apple Developer ๊ณ„์ •์— ๋กœ๊ทธ์ธ
2. Certificates, Identifiers & Profiles ๋ฉ”๋‰ด๋กœ ์ด๋™
3. Keys ์„น์…˜์—์„œ + ๋ฒ„ํŠผ ํด๋ฆญ
4. ํ‚ค ์ด๋ฆ„ ์ž…๋ ฅํ•˜๊ณ  Apple Push Notifications service (APNs) ์ฒดํฌ
5. Continue โ†’ Register โ†’ Download

๐Ÿ’ก ์ค‘์š”ํ•œ ์ •๋ณด๋“ค ๋ฉ”๋ชจํ•˜๊ธฐ

๋‹ค์šด๋กœ๋“œํ•œ .p8 ํŒŒ์ผ๊ณผ ํ•จ๊ป˜ ๋‹ค์Œ ์ •๋ณด๋“ค์„ ๊ผญ ๊ธฐ๋กํ•ด๋‘ฌ:
โ€ข Key ID: 10์ž๋ฆฌ ๋ฌธ์ž์—ด (์˜ˆ: ABC123DEFG)
โ€ข Team ID: ๊ฐœ๋ฐœ์ž ๊ณ„์ •์˜ ํŒ€ ID (Account ๋ฉ”๋‰ด์—์„œ ํ™•์ธ)
โ€ข .p8 ํŒŒ์ผ: ์ ˆ๋Œ€ ์žฌ๋‹ค์šด๋กœ๋“œ ๋ถˆ๊ฐ€๋Šฅํ•˜๋‹ˆ ์•ˆ์ „ํ•˜๊ฒŒ ๋ณด๊ด€!

Step 2: Xcode ํ”„๋กœ์ ํŠธ ์„ค์ •

1. Xcode์—์„œ ํ”„๋กœ์ ํŠธ ์—ด๊ธฐ
2. Signing & Capabilities ํƒญ ์„ ํƒ
3. + Capability ๋ฒ„ํŠผ ํด๋ฆญ
4. Push Notifications ์ถ”๊ฐ€
5. Background Modes๋„ ์ถ”๊ฐ€ํ•˜๊ณ  Remote notifications ์ฒดํฌ

Step 3: AppDelegate์—์„œ ์›๊ฒฉ ์•Œ๋ฆผ ๋“ฑ๋ก

์ด์ œ ์ฝ”๋“œ๋กœ APNs์— ๋“ฑ๋กํ•˜๋Š” ๊ณผ์ •์„ ๊ตฌํ˜„ํ•ด์•ผ ํ•ด:

import UIKit
import UserNotifications

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        
        // ์•Œ๋ฆผ ์„ผํ„ฐ ๋ธ๋ฆฌ๊ฒŒ์ดํŠธ ์„ค์ •
        UNUserNotificationCenter.current().delegate = self
        
        // ์•Œ๋ฆผ ๊ถŒํ•œ ์š”์ฒญ
        requestNotificationAuthorization()
        
        return true
    }
    
    func requestNotificationAuthorization() {
        let center = UNUserNotificationCenter.current()
        center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
            
            if let error = error {
                print("๊ถŒํ•œ ์š”์ฒญ ์—๋Ÿฌ: \(error.localizedDescription)")
                return
            }
            
            if granted {
                print("์•Œ๋ฆผ ๊ถŒํ•œ ์Šน์ธ๋จ! โœ…")
                
                // ๋ฉ”์ธ ์Šค๋ ˆ๋“œ์—์„œ APNs ๋“ฑ๋ก
                DispatchQueue.main.async {
                    UIApplication.shared.registerForRemoteNotifications()
                }
            } else {
                print("์•Œ๋ฆผ ๊ถŒํ•œ ๊ฑฐ๋ถ€๋จ โŒ")
            }
        }
    }
    
    // Device Token ์ˆ˜์‹  ์„ฑ๊ณต
    func application(
        _ application: UIApplication,
        didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
    ) {
        let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
        print("Device Token: \(token)")
        
        // ์„œ๋ฒ„๋กœ ํ† ํฐ ์ „์†ก
        sendTokenToServer(token: token)
    }
    
    // Device Token ์ˆ˜์‹  ์‹คํŒจ
    func application(
        _ application: UIApplication,
        didFailToRegisterForRemoteNotificationsWithError error: Error
    ) {
        print("APNs ๋“ฑ๋ก ์‹คํŒจ: \(error.localizedDescription)")
    }
    
    func sendTokenToServer(token: String) {
        // ์—ฌ๊ธฐ์„œ ๋„ˆ์˜ ๋ฐฑ์—”๋“œ API๋กœ ํ† ํฐ ์ „์†ก
        let url = URL(string: "https://api.yourserver.com/device-token")!
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        
        let body: [String: Any] = [
            "deviceToken": token,
            "platform": "ios",
            "userId": getCurrentUserId() // ํ˜„์žฌ ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž ID
        ]
        
        request.httpBody = try? JSONSerialization.data(withJSONObject: body)
        
        URLSession.shared.dataTask(with: request) { data, response, error in
            if let error = error {
                print("ํ† ํฐ ์ „์†ก ์‹คํŒจ: \(error)")
                return
            }
            print("ํ† ํฐ ์„œ๋ฒ„ ์ „์†ก ์™„๋ฃŒ! ๐Ÿš€")
        }.resume()
    }
    
    func getCurrentUserId() -> String {
        // ์‹ค์ œ๋กœ๋Š” UserDefaults๋‚˜ Keychain์—์„œ ๊ฐ€์ ธ์™€์•ผ ํ•จ
        return "user123"
    }
}

๐Ÿ” Device Token ๋””๋ฒ„๊น… ํŒ

์‹œ๋ฎฌ๋ ˆ์ดํ„ฐ์—์„œ๋Š” Device Token์„ ๋ฐ›์„ ์ˆ˜ ์—†์–ด! ๋ฐ˜๋“œ์‹œ ์‹ค์ œ ๋””๋ฐ”์ด์Šค์—์„œ ํ…Œ์ŠคํŠธํ•ด์•ผ ํ•ด. ๊ทธ๋ฆฌ๊ณ  ๊ฐœ๋ฐœ ํ™˜๊ฒฝ๊ณผ ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ์˜ ํ† ํฐ์ด ๋‹ค๋ฅด๋‹ˆ๊นŒ ์ฃผ์˜ํ•˜์ž.

๐Ÿ–ฅ๏ธ ์„œ๋ฒ„์—์„œ ํ‘ธ์‹œ ๋ณด๋‚ด๊ธฐ

์ž, ์ด์ œ ์•ฑ ์ชฝ ์„ค์ •์€ ๋๋‚ฌ์–ด. ์ด์ œ ์„œ๋ฒ„์—์„œ ์‹ค์ œ๋กœ ํ‘ธ์‹œ๋ฅผ ๋ณด๋‚ด๋Š” ๋ฐฉ๋ฒ•์„ ์•Œ์•„๋ณด์ž. ์—ฌ๊ธฐ์„œ๋Š” Node.js ์˜ˆ์ œ๋ฅผ ๋ณด์—ฌ์ค„๊ฒŒ. ๋‹ค๋ฅธ ์–ธ์–ด๋„ ๋น„์Šทํ•œ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋“ค์ด ์žˆ์œผ๋‹ˆ๊นŒ ์›๋ฆฌ๋งŒ ์ดํ•ดํ•˜๋ฉด ๋ผ!

๐Ÿ“ฆ Node.js๋กœ ํ‘ธ์‹œ ๋ณด๋‚ด๊ธฐ

Node.js์—์„œ๋Š” apn ๋˜๋Š” node-apn ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ๋งŽ์ด ์จ. ํ† ํฐ ๊ธฐ๋ฐ˜ ์ธ์ฆ์„ ์‚ฌ์šฉํ•˜๋Š” ์˜ˆ์ œ๋ฅผ ๋ณด์ž:

// npm install apn ๋จผ์ € ์„ค์น˜

const apn = require('apn');

// APNs Provider ์„ค์ •
const options = {
    token: {
        key: './AuthKey_ABC123DEFG.p8',  // .p8 ํŒŒ์ผ ๊ฒฝ๋กœ
        keyId: 'ABC123DEFG',              // Key ID
        teamId: 'XYZ987TEAM'              // Team ID
    },
    production: false  // ๊ฐœ๋ฐœ ํ™˜๊ฒฝ์€ false, ํ”„๋กœ๋•์…˜์€ true
};

const apnProvider = new apn.Provider(options);

// ํ‘ธ์‹œ ์•Œ๋ฆผ ๋ณด๋‚ด๊ธฐ ํ•จ์ˆ˜
async function sendPushNotification(deviceToken, title, body, data = {}) {
    
    // ์•Œ๋ฆผ ํŽ˜์ด๋กœ๋“œ ์ƒ์„ฑ
    const notification = new apn.Notification();
    
    // ์•Œ๋ฆผ ๋‚ด์šฉ ์„ค์ •
    notification.alert = {
        title: title,
        body: body
    };
    
    // ๊ธฐํƒ€ ์„ค์ •
    notification.badge = 1;
    notification.sound = 'default';
    notification.topic = 'com.yourcompany.yourapp';  // Bundle ID
    
    // ์ปค์Šคํ…€ ๋ฐ์ดํ„ฐ ์ถ”๊ฐ€
    notification.payload = data;
    
    // ๋งŒ๋ฃŒ ์‹œ๊ฐ„ ์„ค์ • (1์‹œ๊ฐ„)
    notification.expiry = Math.floor(Date.now() / 1000) + 3600;
    
    // ์šฐ์„ ์ˆœ์œ„ ์„ค์ • (10: ์ฆ‰์‹œ, 5: ์ ˆ์ „ ๋ชจ๋“œ ๊ณ ๋ ค)
    notification.priority = 10;
    
    try {
        const result = await apnProvider.send(notification, deviceToken);
        
        if (result.failed.length > 0) {
            console.error('ํ‘ธ์‹œ ์ „์†ก ์‹คํŒจ:', result.failed);
            return { success: false, error: result.failed };
        }
        
        console.log('ํ‘ธ์‹œ ์ „์†ก ์„ฑ๊ณต! ๐ŸŽ‰');
        return { success: true };
        
    } catch (error) {
        console.error('APNs ์—๋Ÿฌ:', error);
        return { success: false, error: error.message };
    }
}

// ์‚ฌ์šฉ ์˜ˆ์‹œ
const deviceToken = 'a1b2c3d4e5f6...';  // ์•ฑ์—์„œ ๋ฐ›์€ Device Token

sendPushNotification(
    deviceToken,
    '์žฌ๋Šฅ๋„ท ์•Œ๋ฆผ',
    '์ƒˆ๋กœ์šด ์žฌ๋Šฅ ๊ฑฐ๋ž˜ ์š”์ฒญ์ด ๋„์ฐฉํ–ˆ์Šต๋‹ˆ๋‹ค!',
    {
        type: 'talent_request',
        talentId: 'talent_12345',
        requesterId: 'user_67890'
    }
);

๐Ÿ“ฑ HTTP/2 ๊ธฐ๋ฐ˜ APNs API ์ง์ ‘ ํ˜ธ์ถœ

๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์—†์ด ์ง์ ‘ APNs API๋ฅผ ํ˜ธ์ถœํ•  ์ˆ˜๋„ ์žˆ์–ด. ์ด ๋ฐฉ๋ฒ•์€ ๋” ์„ธ๋ฐ€ํ•œ ์ œ์–ด๊ฐ€ ๊ฐ€๋Šฅํ•˜์ง€๋งŒ JWT ํ† ํฐ ์ƒ์„ฑ ๋“ฑ ์ถ”๊ฐ€ ์ž‘์—…์ด ํ•„์š”ํ•ด:

const jwt = require('jsonwebtoken');
const http2 = require('http2');
const fs = require('fs');

// JWT ํ† ํฐ ์ƒ์„ฑ
function generateJWT(keyId, teamId, keyPath) {
    const token = jwt.sign(
        {
            iss: teamId,
            iat: Math.floor(Date.now() / 1000)
        },
        fs.readFileSync(keyPath),
        {
            algorithm: 'ES256',
            header: {
                alg: 'ES256',
                kid: keyId
            }
        }
    );
    return token;
}

// APNs์— ํ‘ธ์‹œ ์ „์†ก
function sendPushViaHTTP2(deviceToken, payload, jwtToken, bundleId) {
    
    const host = 'api.sandbox.push.apple.com';  // ๊ฐœ๋ฐœ์šฉ
    // const host = 'api.push.apple.com';  // ํ”„๋กœ๋•์…˜์šฉ
    
    const client = http2.connect(`https://${host}`);
    
    const headers = {
        ':method': 'POST',
        ':scheme': 'https',
        ':path': `/3/device/${deviceToken}`,
        'authorization': `bearer ${jwtToken}`,
        'apns-topic': bundleId,
        'apns-push-type': 'alert',
        'apns-priority': '10',
        'apns-expiration': '0'
    };
    
    const request = client.request(headers);
    
    request.on('response', (headers) => {
        const status = headers[':status'];
        
        if (status === 200) {
            console.log('ํ‘ธ์‹œ ์ „์†ก ์„ฑ๊ณต! โœ…');
        } else {
            console.error(`ํ‘ธ์‹œ ์ „์†ก ์‹คํŒจ: ${status}`);
        }
    });
    
    request.on('data', (chunk) => {
        console.log('์‘๋‹ต:', chunk.toString());
    });
    
    request.on('end', () => {
        client.close();
    });
    
    request.write(JSON.stringify(payload));
    request.end();
}

// ์‚ฌ์šฉ ์˜ˆ์‹œ
const jwtToken = generateJWT(
    'ABC123DEFG',
    'XYZ987TEAM',
    './AuthKey_ABC123DEFG.p8'
);

const payload = {
    aps: {
        alert: {
            title: '์žฌ๋Šฅ๋„ท ์•Œ๋ฆผ',
            body: '์ƒˆ๋กœ์šด ๋ฉ”์‹œ์ง€๊ฐ€ ๋„์ฐฉํ–ˆ์Šต๋‹ˆ๋‹ค!'
        },
        badge: 1,
        sound: 'default'
    },
    customData: {
        messageId: 'msg_12345'
    }
};

sendPushViaHTTP2(
    'a1b2c3d4e5f6...',
    payload,
    jwtToken,
    'com.yourcompany.yourapp'
);

๐Ÿ’ก APNs ์—”๋“œํฌ์ธํŠธ

โ€ข ๊ฐœ๋ฐœ ํ™˜๊ฒฝ: api.sandbox.push.apple.com
โ€ข ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ: api.push.apple.com

๊ฐœ๋ฐœ ๋นŒ๋“œ๋Š” sandbox๋กœ, ์•ฑ์Šคํ† ์–ด ๋ฐฐํฌ ๋ฒ„์ „์€ ํ”„๋กœ๋•์…˜์œผ๋กœ ๋ณด๋‚ด์•ผ ํ•ด!

๐ŸŽจ Rich Notifications ๋งŒ๋“ค๊ธฐ

๊ธฐ๋ณธ ํ…์ŠคํŠธ ์•Œ๋ฆผ๋„ ์ข‹์ง€๋งŒ, ์ด๋ฏธ์ง€๋‚˜ ๋น„๋””์˜ค๊ฐ€ ํฌํ•จ๋œ Rich Notification์„ ๋งŒ๋“ค๋ฉด ํ›จ์”ฌ ๋” ์ž„ํŒฉํŠธ ์žˆ์–ด! ์‚ฌ์šฉ์ž ์ฐธ์—ฌ๋„๋„ ํฌ๊ฒŒ ์˜ฌ๋ผ๊ฐ€๊ณ . ๐Ÿ˜

๐Ÿ“ธ ์ด๋ฏธ์ง€๊ฐ€ ํฌํ•จ๋œ ์•Œ๋ฆผ

์ด๋ฏธ์ง€๋ฅผ ํฌํ•จํ•˜๋ ค๋ฉด Notification Service Extension์„ ์ถ”๊ฐ€ํ•ด์•ผ ํ•ด. ์ด๊ฑด ์•Œ๋ฆผ์ด ๋„์ฐฉํ–ˆ์„ ๋•Œ ๋‚ด์šฉ์„ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ๋Š” ํ™•์žฅ ๊ธฐ๋Šฅ์ด์•ผ.

Step 1: Notification Service Extension ์ถ”๊ฐ€

1. Xcode์—์„œ File โ†’ New โ†’ Target
2. Notification Service Extension ์„ ํƒ
3. ์ด๋ฆ„ ์ž…๋ ฅ (์˜ˆ: NotificationService)
4. Finish ํด๋ฆญ

Step 2: NotificationService.swift ๊ตฌํ˜„

import UserNotifications

class NotificationService: UNNotificationServiceExtension {
    
    var contentHandler: ((UNNotificationContent) -> Void)?
    var bestAttemptContent: UNMutableNotificationContent?
    
    override func didReceive(
        _ request: UNNotificationRequest,
        withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
    ) {
        self.contentHandler = contentHandler
        bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
        
        guard let bestAttemptContent = bestAttemptContent else {
            contentHandler(request.content)
            return
        }
        
        // ํŽ˜์ด๋กœ๋“œ์—์„œ ์ด๋ฏธ์ง€ URL ์ถ”์ถœ
        guard let imageURLString = bestAttemptContent.userInfo["imageUrl"] as? String,
              let imageURL = URL(string: imageURLString) else {
            contentHandler(bestAttemptContent)
            return
        }
        
        // ์ด๋ฏธ์ง€ ๋‹ค์šด๋กœ๋“œ
        downloadImage(from: imageURL) { attachment in
            if let attachment = attachment {
                bestAttemptContent.attachments = [attachment]
            }
            contentHandler(bestAttemptContent)
        }
    }
    
    override func serviceExtensionTimeWillExpire() {
        // ์‹œ๊ฐ„ ์ดˆ๊ณผ ์‹œ (30์ดˆ ์ œํ•œ)
        if let contentHandler = contentHandler,
           let bestAttemptContent = bestAttemptContent {
            contentHandler(bestAttemptContent)
        }
    }
    
    private func downloadImage(
        from url: URL,
        completion: @escaping (UNNotificationAttachment?) -> Void
    ) {
        let task = URLSession.shared.downloadTask(with: url) { localURL, response, error in
            
            guard let localURL = localURL else {
                completion(nil)
                return
            }
            
            // ์ž„์‹œ ๋””๋ ‰ํ† ๋ฆฌ์— ํŒŒ์ผ ๋ณต์‚ฌ
            let tempDirectory = NSTemporaryDirectory()
            let tempFile = "image_\(UUID().uuidString).jpg"
            let tempURL = URL(fileURLWithPath: tempDirectory).appendingPathComponent(tempFile)
            
            do {
                try FileManager.default.moveItem(at: localURL, to: tempURL)
                let attachment = try UNNotificationAttachment(
                    identifier: "image",
                    url: tempURL,
                    options: nil
                )
                completion(attachment)
            } catch {
                print("์ด๋ฏธ์ง€ ์ฒจ๋ถ€ ์‹คํŒจ: \(error)")
                completion(nil)
            }
        }
        task.resume()
    }
}

Step 3: ์„œ๋ฒ„์—์„œ ์ด๋ฏธ์ง€ URL ํฌํ•จํ•ด์„œ ์ „์†ก

const payload = {
    aps: {
        alert: {
            title: '์ƒˆ๋กœ์šด ์žฌ๋Šฅ ๋“ฑ๋ก!',
            body: '๋””์ž์ธ ์žฌ๋Šฅ์ด ๋“ฑ๋ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'
        },
        'mutable-content': 1,  // ์ด๊ฒŒ ์ค‘์š”! Service Extension ํ™œ์„ฑํ™”
        sound: 'default'
    },
    imageUrl: 'https://example.com/talent-image.jpg'
};

โš ๏ธ ์ฃผ์˜์‚ฌํ•ญ

โ€ข Service Extension์€ ์ตœ๋Œ€ 30์ดˆ ๋™์•ˆ๋งŒ ์‹คํ–‰๋ผ
โ€ข ์ด๋ฏธ์ง€ ํฌ๊ธฐ๋Š” 10MB ์ดํ•˜๋กœ ์ œํ•œ
โ€ข ์ง€์› ํฌ๋งท: JPEG, PNG, GIF
โ€ข mutable-content: 1 ํ”Œ๋ž˜๊ทธ๋ฅผ ๋ฐ˜๋“œ์‹œ ํฌํ•จํ•ด์•ผ ํ•ด!

๐ŸŽฌ ๋น„๋””์˜ค๋‚˜ ์˜ค๋””์˜ค ์ฒจ๋ถ€

์ด๋ฏธ์ง€๋ฟ๋งŒ ์•„๋‹ˆ๋ผ ๋น„๋””์˜ค๋‚˜ ์˜ค๋””์˜ค๋„ ์ฒจ๋ถ€ํ•  ์ˆ˜ ์žˆ์–ด. ๋ฐฉ๋ฒ•์€ ๊ฑฐ์˜ ๋™์ผํ•ด:

// ๋น„๋””์˜ค ์ฒจ๋ถ€
let videoAttachment = try UNNotificationAttachment(
    identifier: "video",
    url: videoURL,
    options: [UNNotificationAttachmentOptionsTypeHintKey: kUTTypeMPEG4]
)

// ์˜ค๋””์˜ค ์ฒจ๋ถ€
let audioAttachment = try UNNotificationAttachment(
    identifier: "audio",
    url: audioURL,
    options: [UNNotificationAttachmentOptionsTypeHintKey: kUTTypeMPEG4Audio]
)

๐ŸŽฏ Notification Actions ๊ตฌํ˜„ํ•˜๊ธฐ

์•Œ๋ฆผ์—์„œ ๋ฐ”๋กœ ์•ก์…˜์„ ์‹คํ–‰ํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋งŒ๋“ค๋ฉด ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์ด ์—„์ฒญ ์ข‹์•„์ ธ. ์˜ˆ๋ฅผ ๋“ค์–ด "์ˆ˜๋ฝ", "๊ฑฐ์ ˆ" ๋ฒ„ํŠผ์„ ์•Œ๋ฆผ์— ๋ฐ”๋กœ ๋„ฃ์„ ์ˆ˜ ์žˆ์–ด!

๐Ÿ”˜ ์•ก์…˜ ์นดํ…Œ๊ณ ๋ฆฌ ์ •์˜

func setupNotificationActions() {
    // ์•ก์…˜ ์ •์˜
    let acceptAction = UNNotificationAction(
        identifier: "ACCEPT_ACTION",
        title: "์ˆ˜๋ฝ",
        options: [.foreground]  // ์•ฑ์„ ์—ด๊ณ  ์‹คํ–‰
    )
    
    let declineAction = UNNotificationAction(
        identifier: "DECLINE_ACTION",
        title: "๊ฑฐ์ ˆ",
        options: [.destructive]  // ๋นจ๊ฐ„์ƒ‰์œผ๋กœ ํ‘œ์‹œ
    )
    
    let replyAction = UNTextInputNotificationAction(
        identifier: "REPLY_ACTION",
        title: "๋‹ต์žฅ",
        options: [],
        textInputButtonTitle: "์ „์†ก",
        textInputPlaceholder: "๋ฉ”์‹œ์ง€๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”"
    )
    
    // ์นดํ…Œ๊ณ ๋ฆฌ ์ƒ์„ฑ
    let talentRequestCategory = UNNotificationCategory(
        identifier: "TALENT_REQUEST",
        actions: [acceptAction, declineAction, replyAction],
        intentIdentifiers: [],
        options: []
    )
    
    // ์นดํ…Œ๊ณ ๋ฆฌ ๋“ฑ๋ก
    let center = UNUserNotificationCenter.current()
    center.setNotificationCategories([talentRequestCategory])
}

// AppDelegate์˜ didFinishLaunchingWithOptions์—์„œ ํ˜ธ์ถœ
func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
    setupNotificationActions()
    return true
}

๐ŸŽฌ ์•ก์…˜ ์‘๋‹ต ์ฒ˜๋ฆฌ

extension AppDelegate: UNUserNotificationCenterDelegate {
    
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse,
        withCompletionHandler completionHandler: @escaping () -> Void
    ) {
        let userInfo = response.notification.request.content.userInfo
        
        switch response.actionIdentifier {
            
        case "ACCEPT_ACTION":
            print("์žฌ๋Šฅ ์š”์ฒญ ์ˆ˜๋ฝ!")
            if let requestId = userInfo["requestId"] as? String {
                acceptTalentRequest(requestId: requestId)
            }
            
        case "DECLINE_ACTION":
            print("์žฌ๋Šฅ ์š”์ฒญ ๊ฑฐ์ ˆ")
            if let requestId = userInfo["requestId"] as? String {
                declineTalentRequest(requestId: requestId)
            }
            
        case "REPLY_ACTION":
            if let textResponse = response as? UNTextInputNotificationResponse {
                let replyText = textResponse.userText
                print("๋‹ต์žฅ ๋‚ด์šฉ: \(replyText)")
                sendReply(text: replyText, userInfo: userInfo)
            }
            
        case UNNotificationDefaultActionIdentifier:
            // ์•Œ๋ฆผ ์ž์ฒด๋ฅผ ํƒญํ•œ ๊ฒฝ์šฐ
            print("์•Œ๋ฆผ ํƒญ๋จ")
            navigateToDetail(userInfo: userInfo)
            
        case UNNotificationDismissActionIdentifier:
            // ์•Œ๋ฆผ์„ ์Šค์™€์ดํ”„ํ•ด์„œ ๋‹ซ์€ ๊ฒฝ์šฐ
            print("์•Œ๋ฆผ ๋‹ซํž˜")
            
        default:
            break
        }
        
        completionHandler()
    }
    
    func acceptTalentRequest(requestId: String) {
        // API ํ˜ธ์ถœํ•ด์„œ ์š”์ฒญ ์ˆ˜๋ฝ ์ฒ˜๋ฆฌ
        let url = URL(string: "https://api.yourserver.com/talent-requests/\(requestId)/accept")!
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        
        URLSession.shared.dataTask(with: request) { data, response, error in
            if error == nil {
                print("์š”์ฒญ ์ˆ˜๋ฝ ์™„๋ฃŒ! โœ…")
            }
        }.resume()
    }
}

๐Ÿ“ค ์„œ๋ฒ„์—์„œ ์นดํ…Œ๊ณ ๋ฆฌ ์ง€์ •ํ•ด์„œ ์ „์†ก

const payload = {
    aps: {
        alert: {
            title: '์žฌ๋Šฅ ๊ฑฐ๋ž˜ ์š”์ฒญ',
            body: 'ํ™๊ธธ๋™๋‹˜์ด ๋””์ž์ธ ์žฌ๋Šฅ์„ ์š”์ฒญํ–ˆ์Šต๋‹ˆ๋‹ค.'
        },
        category: 'TALENT_REQUEST',  // ์นดํ…Œ๊ณ ๋ฆฌ ์ง€์ •
        sound: 'default'
    },
    requestId: 'req_12345',
    requesterId: 'user_67890'
};
Notification Actions ์ˆ˜๋ฝ โœ“ ๊ฑฐ์ ˆ โœ— ๋‹ต์žฅ ๐Ÿ’ฌ ์•Œ๋ฆผ์—์„œ ๋ฐ”๋กœ ์•ก์…˜ ์‹คํ–‰! ์•ฑ์„ ์—ด์ง€ ์•Š๊ณ ๋„ ๋น ๋ฅธ ์‘๋‹ต ๊ฐ€๋Šฅ

๐Ÿ› ๋””๋ฒ„๊น…๊ณผ ๋ฌธ์ œ ํ•ด๊ฒฐ

ํ‘ธ์‹œ ์•Œ๋ฆผ ๊ฐœ๋ฐœํ•˜๋‹ค ๋ณด๋ฉด ์•ˆ ๋˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ์ •๋ง ๋งŽ์•„. ๋‚ด๊ฐ€ ๊ฒช์—ˆ๋˜ ๋ฌธ์ œ๋“ค๊ณผ ํ•ด๊ฒฐ ๋ฐฉ๋ฒ•์„ ๊ณต์œ ํ• ๊ฒŒ!

โŒ ์ž์ฃผ ๋ฐœ์ƒํ•˜๋Š” ๋ฌธ์ œ๋“ค

1. Device Token์„ ๋ฐ›์ง€ ๋ชปํ•˜๋Š” ๊ฒฝ์šฐ

์›์ธ:
โ€ข ์‹œ๋ฎฌ๋ ˆ์ดํ„ฐ์—์„œ ํ…Œ์ŠคํŠธ ์ค‘ (์‹œ๋ฎฌ๋ ˆ์ดํ„ฐ๋Š” ํ‘ธ์‹œ ๋ถˆ๊ฐ€)
โ€ข Push Notifications Capability ๋ฏธ์ถ”๊ฐ€
โ€ข ์•Œ๋ฆผ ๊ถŒํ•œ ๊ฑฐ๋ถ€๋จ
โ€ข ๋„คํŠธ์›Œํฌ ๋ฌธ์ œ

ํ•ด๊ฒฐ:
โ€ข ์‹ค์ œ ๋””๋ฐ”์ด์Šค์—์„œ ํ…Œ์ŠคํŠธ
โ€ข Xcode์—์„œ Capability ํ™•์ธ
โ€ข ์„ค์ • ์•ฑ์—์„œ ์•Œ๋ฆผ ๊ถŒํ•œ ํ™•์ธ
โ€ข didFailToRegisterForRemoteNotificationsWithError ๋ฉ”์„œ๋“œ์—์„œ ์—๋Ÿฌ ๋กœ๊ทธ ํ™•์ธ

2. ํ‘ธ์‹œ๊ฐ€ ์ „์†ก๋˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ

์›์ธ:
โ€ข ์ž˜๋ชป๋œ Device Token
โ€ข ๊ฐœ๋ฐœ/ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ ๋ถˆ์ผ์น˜
โ€ข ์ธ์ฆ์„œ๋‚˜ ํ‚ค ๋ฌธ์ œ
โ€ข Bundle ID ๋ถˆ์ผ์น˜
โ€ข APNs ์„œ๋ฒ„ ๋ฌธ์ œ

ํ•ด๊ฒฐ:
โ€ข Device Token ์žฌํ™•์ธ
โ€ข ๋นŒ๋“œ ํ™˜๊ฒฝ๊ณผ APNs ์—”๋“œํฌ์ธํŠธ ์ผ์น˜ ํ™•์ธ
โ€ข .p8 ํ‚ค ํŒŒ์ผ๊ณผ Key ID, Team ID ์žฌํ™•์ธ
โ€ข Bundle ID๊ฐ€ ์ •ํ™•ํ•œ์ง€ ํ™•์ธ
โ€ข APNs ์‘๋‹ต ์ฝ”๋“œ ํ™•์ธ

3. ํฌ๊ทธ๋ผ์šด๋“œ์—์„œ ์•Œ๋ฆผ์ด ํ‘œ์‹œ๋˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ

์›์ธ:
โ€ข willPresent ๋ธ๋ฆฌ๊ฒŒ์ดํŠธ ๋ฉ”์„œ๋“œ ๋ฏธ๊ตฌํ˜„
โ€ข ์ž˜๋ชป๋œ presentation options

ํ•ด๊ฒฐ:
โ€ข UNUserNotificationCenterDelegate ๊ตฌํ˜„
โ€ข willPresent์—์„œ [.banner, .sound, .badge] ๋ฐ˜ํ™˜

๐Ÿ” APNs ์‘๋‹ต ์ฝ”๋“œ ์ดํ•ดํ•˜๊ธฐ

์ฃผ์š” HTTP ์ƒํƒœ ์ฝ”๋“œ

โ€ข 200: ์„ฑ๊ณต! ๐ŸŽ‰
โ€ข 400: Bad request - ํŽ˜์ด๋กœ๋“œ ํ˜•์‹ ์˜ค๋ฅ˜
โ€ข 403: ์ธ์ฆ ์‹คํŒจ - ์ธ์ฆ์„œ/ํ† ํฐ ๋ฌธ์ œ
โ€ข 405: ์ž˜๋ชป๋œ HTTP ๋ฉ”์„œ๋“œ
โ€ข 410: Device Token์ด ๋” ์ด์ƒ ์œ ํšจํ•˜์ง€ ์•Š์Œ (์•ฑ ์‚ญ์ œ๋จ)
โ€ข 413: ํŽ˜์ด๋กœ๋“œ๊ฐ€ ๋„ˆ๋ฌด ํผ (4KB ์ œํ•œ)
โ€ข 429: ๋„ˆ๋ฌด ๋งŽ์€ ์š”์ฒญ (Rate limit)
โ€ข 500: APNs ์„œ๋ฒ„ ๋‚ด๋ถ€ ์˜ค๋ฅ˜
โ€ข 503: APNs ์„œ๋ฒ„ ์ ๊ฒ€ ์ค‘

๐Ÿ› ๏ธ ๋””๋ฒ„๊น… ๋„๊ตฌ๋“ค

ํ‘ธ์‹œ ์•Œ๋ฆผ ๋””๋ฒ„๊น…ํ•  ๋•Œ ์œ ์šฉํ•œ ๋„๊ตฌ๋“ค์„ ์†Œ๊ฐœํ• ๊ฒŒ:

1. Pusher (macOS ์•ฑ)

๋ฌด๋ฃŒ ์•ฑ์œผ๋กœ ๊ฐ„๋‹จํ•˜๊ฒŒ ํ…Œ์ŠคํŠธ ํ‘ธ์‹œ๋ฅผ ๋ณด๋‚ผ ์ˆ˜ ์žˆ์–ด. .p8 ํŒŒ์ผ์ด๋‚˜ .p12 ์ธ์ฆ์„œ๋ฅผ ์—…๋กœ๋“œํ•˜๊ณ  Device Token๋งŒ ์ž…๋ ฅํ•˜๋ฉด ๋ฐ”๋กœ ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅ!

2. curl ๋ช…๋ น์–ด

ํ„ฐ๋ฏธ๋„์—์„œ ์ง์ ‘ APNs API๋ฅผ ํ˜ธ์ถœํ•ด๋ณผ ์ˆ˜ ์žˆ์–ด:

curl -v \
  --header "apns-topic: com.yourcompany.yourapp" \
  --header "apns-push-type: alert" \
  --header "authorization: bearer YOUR_JWT_TOKEN" \
  --data '{"aps":{"alert":"Test Push"}}' \
  --http2 \
  https://api.sandbox.push.apple.com/3/device/YOUR_DEVICE_TOKEN

3. Console.app (macOS)

๋””๋ฐ”์ด์Šค๋ฅผ Mac์— ์—ฐ๊ฒฐํ•˜๊ณ  Console ์•ฑ์„ ์—ด๋ฉด ์‹ค์‹œ๊ฐ„ ๋กœ๊ทธ๋ฅผ ๋ณผ ์ˆ˜ ์žˆ์–ด. "apsd"๋กœ ํ•„ํ„ฐ๋งํ•˜๋ฉด ํ‘ธ์‹œ ๊ด€๋ จ ๋กœ๊ทธ๋งŒ ๋ณผ ์ˆ˜ ์žˆ์ง€.

๐Ÿš€ ๊ณ ๊ธ‰ ๊ธฐ๋Šฅ๋“ค

๊ธฐ๋ณธ์ ์ธ ํ‘ธ์‹œ๋Š” ์ด์ œ ๋‹ค ์•Œ์•˜์ง€? ์ด์ œ ์ข€ ๋” ๊ณ ๊ธ‰ ๊ธฐ๋Šฅ๋“ค์„ ์•Œ์•„๋ณด์ž!

๐Ÿ”• Silent Notifications (์กฐ์šฉํ•œ ์•Œ๋ฆผ)

์‚ฌ์šฉ์ž์—๊ฒŒ ์•Œ๋ฆผ์„ ํ‘œ์‹œํ•˜์ง€ ์•Š๊ณ  ๋ฐฑ๊ทธ๋ผ์šด๋“œ์—์„œ ์•ฑ์„ ๊นจ์›Œ์„œ ๋ฐ์ดํ„ฐ๋ฅผ ์—…๋ฐ์ดํŠธํ•  ์ˆ˜ ์žˆ์–ด. ์ด๊ฑธ Silent Push๋ผ๊ณ  ํ•ด.

// ์„œ๋ฒ„์—์„œ ๋ณด๋‚ด๋Š” ํŽ˜์ด๋กœ๋“œ
const silentPayload = {
    aps: {
        'content-available': 1  // ์ด๊ฒŒ ํ•ต์‹ฌ!
        // alert, sound, badge๋Š” ํฌํ•จํ•˜์ง€ ์•Š์Œ
    },
    updateType: 'newMessages',
    messageCount: 5
};

// AppDelegate์—์„œ ์ฒ˜๋ฆฌ
func application(
    _ application: UIApplication,
    didReceiveRemoteNotification userInfo: [AnyHashable: Any],
    fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
    print("Silent push ๋ฐ›์Œ!")
    
    if let updateType = userInfo["updateType"] as? String {
        switch updateType {
        case "newMessages":
            // ๋ฉ”์‹œ์ง€ ๋ฐ์ดํ„ฐ ๋™๊ธฐํ™”
            syncMessages { success in
                if success {
                    completionHandler(.newData)
                } else {
                    completionHandler(.failed)
                }
            }
        default:
            completionHandler(.noData)
        }
    }
}

โš ๏ธ Silent Push ์ œํ•œ์‚ฌํ•ญ

โ€ข iOS๊ฐ€ ๋ฐฐํ„ฐ๋ฆฌ์™€ ๋„คํŠธ์›Œํฌ ์ƒํƒœ๋ฅผ ๊ณ ๋ คํ•ด์„œ ์ „๋‹ฌ ์‹œ์ ์„ ์กฐ์ ˆํ•ด
โ€ข ๋„ˆ๋ฌด ์ž์ฃผ ๋ณด๋‚ด๋ฉด iOS๊ฐ€ ๋ฌด์‹œํ•  ์ˆ˜ ์žˆ์–ด
โ€ข ์‚ฌ์šฉ์ž๊ฐ€ ์•ฑ์„ ๊ฐ•์ œ ์ข…๋ฃŒํ•˜๋ฉด ์ž‘๋™ํ•˜์ง€ ์•Š์•„
โ€ข Background Modes์—์„œ "Remote notifications" ํ™œ์„ฑํ™” ํ•„์š”

๐Ÿ“Š Notification Grouping (์•Œ๋ฆผ ๊ทธ๋ฃนํ™”)

๊ฐ™์€ ์ข…๋ฅ˜์˜ ์•Œ๋ฆผ๋“ค์„ ๊ทธ๋ฃน์œผ๋กœ ๋ฌถ์–ด์„œ ํ‘œ์‹œํ•  ์ˆ˜ ์žˆ์–ด. ์—ฌ๋Ÿฌ ๊ฐœ์˜ ์•Œ๋ฆผ์ด ์Œ“์—ฌ๋„ ๊น”๋”ํ•˜๊ฒŒ ์ •๋ฆฌ๋ผ!

// ์„œ๋ฒ„์—์„œ thread-id ์ง€์ •
const payload = {
    aps: {
        alert: {
            title: '์ƒˆ ๋ฉ”์‹œ์ง€',
            body: 'ํ™๊ธธ๋™: ์•ˆ๋…•ํ•˜์„ธ์š”!'
        },
        'thread-id': 'chat-user123',  // ๊ทธ๋ฃน ID
        sound: 'default'
    }
};

// ๊ฐ™์€ thread-id๋ฅผ ๊ฐ€์ง„ ์•Œ๋ฆผ๋“ค์ด ์ž๋™์œผ๋กœ ๊ทธ๋ฃนํ™”๋จ

๐ŸŽจ Custom Notification UI

์•Œ๋ฆผ์˜ UI๋ฅผ ์™„์ „ํžˆ ์ปค์Šคํ„ฐ๋งˆ์ด์ง•ํ•  ์ˆ˜ ์žˆ์–ด. Notification Content Extension์„ ์‚ฌ์šฉํ•˜๋ฉด ๋ผ.

Step 1: Notification Content Extension ์ถ”๊ฐ€

1. Xcode์—์„œ File โ†’ New โ†’ Target
2. Notification Content Extension ์„ ํƒ
3. ์ด๋ฆ„ ์ž…๋ ฅ (์˜ˆ: NotificationContent)

Step 2: ์ปค์Šคํ…€ UI ๊ตฌํ˜„

import UIKit
import UserNotifications
import UserNotificationsUI

class NotificationViewController: UIViewController, UNNotificationContentExtension {
    
    @IBOutlet weak var profileImageView: UIImageView!
    @IBOutlet weak var nameLabel: UILabel!
    @IBOutlet weak var messageLabel: UILabel!
    @IBOutlet weak var timeLabel: UILabel!
    
    func didReceive(_ notification: UNNotification) {
        let content = notification.request.content
        
        // ์ปค์Šคํ…€ ๋ฐ์ดํ„ฐ ์ถ”์ถœ
        if let name = content.userInfo["senderName"] as? String {
            nameLabel.text = name
        }
        
        if let message = content.userInfo["message"] as? String {
            messageLabel.text = message
        }
        
        if let imageUrl = content.userInfo["profileImageUrl"] as? String {
            loadImage(from: imageUrl)
        }
        
        timeLabel.text = formatTime(Date())
    }
    
    func loadImage(from urlString: String) {
        guard let url = URL(string: urlString) else { return }
        
        URLSession.shared.dataTask(with: url) { data, response, error in
            if let data = data, let image = UIImage(data: data) {
                DispatchQueue.main.async {
                    self.profileImageView.image = image
                    self.profileImageView.layer.cornerRadius = self.profileImageView.frame.width / 2
                    self.profileImageView.clipsToBounds = true
                }
            }
        }.resume()
    }
    
    func formatTime(_ date: Date) -> String {
        let formatter = DateFormatter()
        formatter.timeStyle = .short
        return formatter.string(from: date)
    }
}

Step 3: Info.plist ์„ค์ •

Content Extension์˜ Info.plist์—์„œ UNNotificationExtensionCategory๋ฅผ ์„ค์ •ํ•ด์ค˜์•ผ ํ•ด:

<key>NSExtension</key>
<dict>
    <key>NSExtensionAttributes</key>
    <dict>
        <key>UNNotificationExtensionCategory</key>
        <string>CUSTOM_MESSAGE</string>
        <key>UNNotificationExtensionInitialContentSizeRatio</key>
        <real>0.5</real>
    </dict>
</dict>

๐Ÿ“ˆ ํ‘ธ์‹œ ์•Œ๋ฆผ ์ตœ์ ํ™” ์ „๋žต

ํ‘ธ์‹œ ์•Œ๋ฆผ์„ ํšจ๊ณผ์ ์œผ๋กœ ์‚ฌ์šฉํ•˜๋ ค๋ฉด ๋‹จ์ˆœํžˆ ๊ธฐ์ˆ ์ ์œผ๋กœ ๊ตฌํ˜„ํ•˜๋Š” ๊ฒƒ๋งŒ์œผ๋กœ๋Š” ๋ถ€์กฑํ•ด. ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ๊ณ ๋ คํ•œ ์ „๋žต์ด ํ•„์š”ํ•˜์ง€!

๐ŸŽฏ ํƒ€์ด๋ฐ์ด ์ค‘์š”ํ•ด

์ตœ์ ์˜ ํ‘ธ์‹œ ๋ฐœ์†ก ์‹œ๊ฐ„

โ€ข ์•„์นจ ์ถœ๊ทผ ์‹œ๊ฐ„ (8-9์‹œ): ํ•˜๋ฃจ ์‹œ์ž‘ํ•  ๋•Œ ํ™•์ธ
โ€ข ์ ์‹ฌ ์‹œ๊ฐ„ (12-1์‹œ): ํœด์‹ ์‹œ๊ฐ„์— ํ™•์ธ
โ€ข ํ‡ด๊ทผ ํ›„ (6-8์‹œ): ์—ฌ์œ  ์‹œ๊ฐ„์— ํ™•์ธ
โ€ข ์‹ฌ์•ผ ์‹œ๊ฐ„ ํ”ผํ•˜๊ธฐ: ๋ฐค 10์‹œ ์ดํ›„๋Š” ์ž์ œ

๐Ÿ“ ๋ฉ”์‹œ์ง€ ์ž‘์„ฑ ํŒ

ํšจ๊ณผ์ ์ธ ํ‘ธ์‹œ ๋ฉ”์‹œ์ง€ ์ž‘์„ฑ๋ฒ•

1. ๋ช…ํ™•ํ•˜๊ณ  ๊ฐ„๊ฒฐํ•˜๊ฒŒ: ํ•œ๋ˆˆ์— ๋‚ด์šฉ ํŒŒ์•… ๊ฐ€๋Šฅํ•˜๊ฒŒ
2. ํ–‰๋™ ์œ ๋„: "์ง€๊ธˆ ํ™•์ธํ•˜์„ธ์š”", "๋‹ต์žฅํ•˜๊ธฐ" ๋“ฑ
3. ๊ฐœ์ธํ™”: ์‚ฌ์šฉ์ž ์ด๋ฆ„์ด๋‚˜ ๊ด€์‹ฌ์‚ฌ ํฌํ•จ
4. ๊ธด๊ธ‰์„ฑ: ์‹œ๊ฐ„ ์ œํ•œ์ด ์žˆ๋‹ค๋ฉด ๋ช…์‹œ
5. ๊ฐ€์น˜ ์ œ๊ณต: ์‚ฌ์šฉ์ž์—๊ฒŒ ๋„์›€์ด ๋˜๋Š” ์ •๋ณด

๐Ÿ”” ์•Œ๋ฆผ ๋นˆ๋„ ๊ด€๋ฆฌ

๋„ˆ๋ฌด ๋งŽ์€ ํ‘ธ์‹œ๋Š” ์˜คํžˆ๋ ค ์—ญํšจ๊ณผ์•ผ. ์‚ฌ์šฉ์ž๊ฐ€ ์•Œ๋ฆผ์„ ๋„๊ฑฐ๋‚˜ ์•ฑ์„ ์‚ญ์ œํ•  ์ˆ˜ ์žˆ์–ด.

// ์„œ๋ฒ„์—์„œ ์•Œ๋ฆผ ๋นˆ๋„ ์ œ์–ด ๋กœ์ง ์˜ˆ์‹œ
class PushNotificationManager {
    
    func shouldSendPush(userId: String, type: String) -> Bool {
        // ์ตœ๊ทผ 24์‹œ๊ฐ„ ๋‚ด ๋ฐœ์†ก ํšŸ์ˆ˜ ํ™•์ธ
        let recentCount = getRecentPushCount(userId: userId, hours: 24)
        
        // ํƒ€์ž…๋ณ„ ์ตœ๋Œ€ ํ—ˆ์šฉ ํšŸ์ˆ˜
        let maxCount: Int
        switch type {
        case "urgent":
            maxCount = 10  // ๊ธด๊ธ‰ ์•Œ๋ฆผ์€ ๋งŽ์ด ํ—ˆ์šฉ
        case "marketing":
            maxCount = 2   // ๋งˆ์ผ€ํŒ… ์•Œ๋ฆผ์€ ์ œํ•œ
        default:
            maxCount = 5
        }
        
        if recentCount >= maxCount {
            print("์•Œ๋ฆผ ๋นˆ๋„ ์ œํ•œ ์ดˆ๊ณผ")
            return false
        }
        
        // ์‚ฌ์šฉ์ž์˜ ๋ฐฉํ•ด๊ธˆ์ง€ ์‹œ๊ฐ„ ํ™•์ธ
        if isUserQuietHours(userId: userId) {
            print("์‚ฌ์šฉ์ž ๋ฐฉํ•ด๊ธˆ์ง€ ์‹œ๊ฐ„")
            return false
        }
        
        return true
    }
    
    func getRecentPushCount(userId: String, hours: Int) -> Int {
        // DB์—์„œ ์ตœ๊ทผ ๋ฐœ์†ก ๊ธฐ๋ก ์กฐํšŒ
        // ์‹ค์ œ ๊ตฌํ˜„ ํ•„์š”
        return 0
    }
    
    func isUserQuietHours(userId: String) -> Bool {
        // ์‚ฌ์šฉ์ž๊ฐ€ ์„ค์ •ํ•œ ๋ฐฉํ•ด๊ธˆ์ง€ ์‹œ๊ฐ„ ํ™•์ธ
        // ์‹ค์ œ ๊ตฌํ˜„ ํ•„์š”
        return false
    }
}

๐Ÿ“Š ํ‘ธ์‹œ ์„ฑ๊ณผ ์ธก์ •

ํ‘ธ์‹œ ์•Œ๋ฆผ์˜ ํšจ๊ณผ๋ฅผ ์ธก์ •ํ•˜๊ณ  ๊ฐœ์„ ํ•˜๋Š” ๊ฒƒ๋„ ์ค‘์š”ํ•ด!

์ธก์ •ํ•ด์•ผ ํ•  ์ฃผ์š” ์ง€ํ‘œ

โ€ข ์ „์†ก๋ฅ  (Delivery Rate): ์‹ค์ œ๋กœ ์ „๋‹ฌ๋œ ๋น„์œจ
โ€ข ์˜คํ”ˆ์œจ (Open Rate): ์•Œ๋ฆผ์„ ํƒญํ•œ ๋น„์œจ
โ€ข ์ „ํ™˜์œจ (Conversion Rate): ์›ํ•˜๋Š” ์•ก์…˜์„ ์ˆ˜ํ–‰ํ•œ ๋น„์œจ
โ€ข ์˜ตํŠธ์•„์›ƒ์œจ: ์•Œ๋ฆผ์„ ๋ˆ ์‚ฌ์šฉ์ž ๋น„์œจ
โ€ข ์•ฑ ์‚ญ์ œ์œจ: ํ‘ธ์‹œ ํ›„ ์•ฑ์„ ์‚ญ์ œํ•œ ๋น„์œจ

// ํ‘ธ์‹œ ์„ฑ๊ณผ ์ถ”์  ์˜ˆ์‹œ
func trackPushNotification(
    notificationId: String,
    userId: String,
    action: String
) {
    let event = [
        "notification_id": notificationId,
        "user_id": userId,
        "action": action,  // "delivered", "opened", "converted"
        "timestamp": Date().timeIntervalSince1970
    ]
    
    // ๋ถ„์„ ์„œ๋ฒ„๋กœ ์ „์†ก
    sendAnalytics(event: event)
}

// AppDelegate์—์„œ ํ˜ธ์ถœ
func userNotificationCenter(
    _ center: UNUserNotificationCenter,
    didReceive response: UNNotificationResponse,
    withCompletionHandler completionHandler: @escaping () -> Void
) {
    let notificationId = response.notification.request.identifier
    let userId = getCurrentUserId()
    
    // ์˜คํ”ˆ ์ด๋ฒคํŠธ ์ถ”์ 
    trackPushNotification(
        notificationId: notificationId,
        userId: userId,
        action: "opened"
    )
    
    completionHandler()
}

๐Ÿ”’ ๋ณด์•ˆ๊ณผ ํ”„๋ผ์ด๋ฒ„์‹œ

ํ‘ธ์‹œ ์•Œ๋ฆผ์„ ๋‹ค๋ฃฐ ๋•Œ๋Š” ๋ณด์•ˆ๊ณผ ํ”„๋ผ์ด๋ฒ„์‹œ๋„ ์‹ ๊ฒฝ ์จ์•ผ ํ•ด. ๋ฏผ๊ฐํ•œ ์ •๋ณด๊ฐ€ ์•Œ๋ฆผ์— ๋…ธ์ถœ๋˜๋ฉด ํฐ์ผ์ด๊ฑฐ๋“ !

๐Ÿ›ก๏ธ ๋ณด์•ˆ ๋ฒ ์ŠคํŠธ ํ”„๋ž™ํ‹ฐ์Šค

1. ๋ฏผ๊ฐํ•œ ์ •๋ณด๋Š” ์•Œ๋ฆผ์— ํฌํ•จํ•˜์ง€ ๋ง๊ธฐ

๋น„๋ฐ€๋ฒˆํ˜ธ, ์นด๋“œ ๋ฒˆํ˜ธ, ์ฃผ๋ฏผ๋“ฑ๋ก๋ฒˆํ˜ธ ๊ฐ™์€ ๊ฑด ์ ˆ๋Œ€ ์•Œ๋ฆผ ํ…์ŠคํŠธ์— ๋„ฃ์œผ๋ฉด ์•ˆ ๋ผ. ๋Œ€์‹  "์ƒˆ๋กœ์šด ๋ณด์•ˆ ์•Œ๋ฆผ์ด ์žˆ์Šต๋‹ˆ๋‹ค" ๊ฐ™์ด ์ผ๋ฐ˜์ ์ธ ๋ฉ”์‹œ์ง€๋งŒ ๋ณด๋‚ด๊ณ , ์•ฑ ์•ˆ์—์„œ ์ž์„ธํ•œ ๋‚ด์šฉ์„ ๋ณด์—ฌ์ค˜.

2. Device Token ์•ˆ์ „ํ•˜๊ฒŒ ๊ด€๋ฆฌ

Device Token์€ ๊ฐœ์ธ์ •๋ณด์•ผ. ์„œ๋ฒ„์—์„œ ์•”ํ˜ธํ™”ํ•ด์„œ ์ €์žฅํ•˜๊ณ , HTTPS๋กœ๋งŒ ์ „์†กํ•ด์•ผ ํ•ด.

// Device Token ์ €์žฅ ์‹œ ์•”ํ˜ธํ™” ์˜ˆ์‹œ (Node.js)
const crypto = require('crypto');

function encryptDeviceToken(token, secretKey) {
    const cipher = crypto.createCipher('aes-256-cbc', secretKey);
    let encrypted = cipher.update(token, 'utf8', 'hex');
    encrypted += cipher.final('hex');
    return encrypted;
}

function decryptDeviceToken(encryptedToken, secretKey) {
    const decipher = crypto.createDecipher('aes-256-cbc', secretKey);
    let decrypted = decipher.update(encryptedToken, 'hex', 'utf8');
    decrypted += decipher.final('utf8');
    return decrypted;
}

// ์‚ฌ์šฉ ์˜ˆ์‹œ
const deviceToken = 'a1b2c3d4e5f6...';
const secretKey = process.env.ENCRYPTION_KEY;
const encryptedToken = encryptDeviceToken(deviceToken, secretKey);

// DB์— ์•”ํ˜ธํ™”๋œ ํ† ํฐ ์ €์žฅ
saveToDatabase(userId, encryptedToken);

3. ์ธ์ฆ์„œ์™€ ํ‚ค ํŒŒ์ผ ๋ณด์•ˆ

.p8 ํ‚ค ํŒŒ์ผ์ด๋‚˜ .p12 ์ธ์ฆ์„œ๋Š” ์ ˆ๋Œ€ Git์— ์ปค๋ฐ‹ํ•˜๋ฉด ์•ˆ ๋ผ! ํ™˜๊ฒฝ ๋ณ€์ˆ˜๋‚˜ ๋ณด์•ˆ ์ €์žฅ์†Œ๋ฅผ ์‚ฌ์šฉํ•ด.

// .gitignore์— ์ถ”๊ฐ€
*.p8
*.p12
*.pem
.env

// ํ™˜๊ฒฝ ๋ณ€์ˆ˜๋กœ ๊ด€๋ฆฌ
const apnOptions = {
    token: {
        key: process.env.APN_KEY_PATH,
        keyId: process.env.APN_KEY_ID,
        teamId: process.env.APN_TEAM_ID
    }
};

๐Ÿ” ์‚ฌ์šฉ์ž ํ”„๋ผ์ด๋ฒ„์‹œ ์กด์ค‘

ํ”„๋ผ์ด๋ฒ„์‹œ ๊ฐ€์ด๋“œ๋ผ์ธ

โ€ข ์•Œ๋ฆผ ๊ถŒํ•œ ์š”์ฒญ ์‹œ ๋ช…ํ™•ํ•œ ์ด์œ  ์„ค๋ช…
โ€ข ์‚ฌ์šฉ์ž๊ฐ€ ์•Œ๋ฆผ ์ข…๋ฅ˜๋ณ„๋กœ ์„ ํƒํ•  ์ˆ˜ ์žˆ๊ฒŒ ์ œ๊ณต
โ€ข ์–ธ์ œ๋“  ์•Œ๋ฆผ์„ ๋Œ ์ˆ˜ ์žˆ๋Š” ์˜ต์…˜ ์ œ๊ณต
โ€ข ๊ฐœ์ธ์ •๋ณด ์ฒ˜๋ฆฌ๋ฐฉ์นจ์— ํ‘ธ์‹œ ์•Œ๋ฆผ ์‚ฌ์šฉ ๋ช…์‹œ
โ€ข ์œ„์น˜ ๊ธฐ๋ฐ˜ ์•Œ๋ฆผ์€ ํŠนํžˆ ์‹ ์ค‘ํ•˜๊ฒŒ

// ์•ฑ ๋‚ด์—์„œ ์•Œ๋ฆผ ์„ค์ • ํ™”๋ฉด ์ œ๊ณต
class NotificationSettingsViewController: UIViewController {
    
    @IBOutlet weak var marketingSwitch: UISwitch!
    @IBOutlet weak var messageSwitch: UISwitch!
    @IBOutlet weak var talentRequestSwitch: UISwitch!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        loadUserPreferences()
    }
    
    @IBAction func settingChanged(_ sender: UISwitch) {
        let preferences = [
            "marketing": marketingSwitch.isOn,
            "message": messageSwitch.isOn,
            "talentRequest": talentRequestSwitch.isOn
        ]
        
        // ์„œ๋ฒ„์— ์‚ฌ์šฉ์ž ์„ค์ • ์ €์žฅ
        savePreferencesToServer(preferences)
    }
    
    func savePreferencesToServer(_ preferences: [String: Bool]) {
        let url = URL(string: "https://api.yourserver.com/notification-preferences")!
        var request = URLRequest(url: url)
        request.httpMethod = "PUT"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        
        let body = try? JSONSerialization.data(withJSONObject: preferences)
        request.httpBody = body
        
        URLSession.shared.dataTask(with: request).resume()
    }
}

๐ŸŒ ๋‹ค๊ตญ์–ด ์ง€์›

๊ธ€๋กœ๋ฒŒ ์•ฑ์„ ๋งŒ๋“ ๋‹ค๋ฉด ํ‘ธ์‹œ ์•Œ๋ฆผ๋„ ๋‹ค๊ตญ์–ด๋กœ ์ง€์›ํ•ด์•ผ๊ฒ ์ง€? ๋‘ ๊ฐ€์ง€ ๋ฐฉ๋ฒ•์ด ์žˆ์–ด.

๐Ÿ“ฑ ๋ฐฉ๋ฒ• 1: ํด๋ผ์ด์–ธํŠธ์—์„œ ๋กœ์ปฌ๋ผ์ด์ง•

์„œ๋ฒ„์—์„œ๋Š” ํ‚ค๋งŒ ๋ณด๋‚ด๊ณ , ์•ฑ์—์„œ ํ˜„์žฌ ์–ธ์–ด์— ๋งž๋Š” ํ…์ŠคํŠธ๋ฅผ ํ‘œ์‹œํ•˜๋Š” ๋ฐฉ๋ฒ•์ด์•ผ.

// ์„œ๋ฒ„์—์„œ ๋ณด๋‚ด๋Š” ํŽ˜์ด๋กœ๋“œ
const payload = {
    aps: {
        alert: {
            'title-loc-key': 'NOTIFICATION_TITLE_NEW_MESSAGE',
            'title-loc-args': ['ํ™๊ธธ๋™'],
            'loc-key': 'NOTIFICATION_BODY_NEW_MESSAGE',
            'loc-args': ['์•ˆ๋…•ํ•˜์„ธ์š”!']
        },
        sound: 'default'
    }
};

// Localizable.strings (ํ•œ๊ตญ์–ด)
"NOTIFICATION_TITLE_NEW_MESSAGE" = "%@๋‹˜์˜ ์ƒˆ ๋ฉ”์‹œ์ง€";
"NOTIFICATION_BODY_NEW_MESSAGE" = "%@";

// Localizable.strings (์˜์–ด)
"NOTIFICATION_TITLE_NEW_MESSAGE" = "New message from %@";
"NOTIFICATION_BODY_NEW_MESSAGE" = "%@";

๐Ÿ–ฅ๏ธ ๋ฐฉ๋ฒ• 2: ์„œ๋ฒ„์—์„œ ์–ธ์–ด๋ณ„ ์ „์†ก

์„œ๋ฒ„์—์„œ ์‚ฌ์šฉ์ž์˜ ์–ธ์–ด ์„ค์ •์„ ํ™•์ธํ•˜๊ณ  ํ•ด๋‹น ์–ธ์–ด๋กœ ๋ฒˆ์—ญ๋œ ํ…์ŠคํŠธ๋ฅผ ๋ณด๋‚ด๋Š” ๋ฐฉ๋ฒ•์ด์•ผ.

// ์„œ๋ฒ„์—์„œ ์‚ฌ์šฉ์ž ์–ธ์–ด์— ๋งž๊ฒŒ ์ „์†ก
function getLocalizedMessage(language, key, args) {
    const messages = {
        'ko': {
            'new_message': `${args[0]}๋‹˜์˜ ์ƒˆ ๋ฉ”์‹œ์ง€: ${args[1]}`
        },
        'en': {
            'new_message': `New message from ${args[0]}: ${args[1]}`
        },
        'ja': {
            'new_message': `${args[0]}ใ•ใ‚“ใ‹ใ‚‰ใฎๆ–ฐใ—ใ„ใƒกใƒƒใ‚ปใƒผใ‚ธ: ${args[1]}`
        }
    };
    
    return messages[language]?.[key] || messages['en'][key];
}

// ์‚ฌ์šฉ ์˜ˆ์‹œ
const userLanguage = getUserLanguage(userId);  // DB์—์„œ ์กฐํšŒ
const message = getLocalizedMessage(
    userLanguage,
    'new_message',
    ['ํ™๊ธธ๋™', '์•ˆ๋…•ํ•˜์„ธ์š”!']
);

const payload = {
    aps: {
        alert: message,
        sound: 'default'
    }
};

๐ŸŽ“ ์‹ค์ „ ์˜ˆ์ œ: ์žฌ๋Šฅ๋„ท ์Šคํƒ€์ผ ํ‘ธ์‹œ ์‹œ์Šคํ…œ

์ž, ์ด์ œ ๋ฐฐ์šด ๊ฑธ ๋‹ค ํ•ฉ์ณ์„œ ์‹ค์ œ๋กœ ๋™์ž‘ํ•˜๋Š” ํ‘ธ์‹œ ์‹œ์Šคํ…œ์„ ๋งŒ๋“ค์–ด๋ณด์ž! ์žฌ๋Šฅ๋„ท ๊ฐ™์€ ํ”Œ๋žซํผ์—์„œ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ์™„์ „ํ•œ ์˜ˆ์ œ์•ผ.

๐Ÿ“ฑ iOS ์•ฑ ์ „์ฒด ๊ตฌ์กฐ

// AppDelegate.swift
import UIKit
import UserNotifications

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        
        // ์•Œ๋ฆผ ์„ผํ„ฐ ์„ค์ •
        setupNotifications()
        
        // ์•ฑ์ด ํ‘ธ์‹œ๋กœ ์‹คํ–‰๋œ ๊ฒฝ์šฐ ์ฒ˜๋ฆฌ
        if let notification = launchOptions?[.remoteNotification] as? [String: AnyObject] {
            handlePushNotification(userInfo: notification)
        }
        
        return true
    }
    
    func setupNotifications() {
        let center = UNUserNotificationCenter.current()
        center.delegate = self
        
        // ์•Œ๋ฆผ ์•ก์…˜ ์„ค์ •
        setupNotificationActions()
        
        // ๊ถŒํ•œ ์š”์ฒญ
        center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
            if granted {
                DispatchQueue.main.async {
                    UIApplication.shared.registerForRemoteNotifications()
                }
            }
        }
    }
    
    func setupNotificationActions() {
        // ์žฌ๋Šฅ ์š”์ฒญ ์•ก์…˜
        let acceptAction = UNNotificationAction(
            identifier: "ACCEPT_TALENT_REQUEST",
            title: "์ˆ˜๋ฝ",
            options: [.foreground]
        )
        
        let declineAction = UNNotificationAction(
            identifier: "DECLINE_TALENT_REQUEST",
            title: "๊ฑฐ์ ˆ",
            options: [.destructive]
        )
        
        let talentRequestCategory = UNNotificationCategory(
            identifier: "TALENT_REQUEST",
            actions: [acceptAction, declineAction],
            intentIdentifiers: [],
            options: []
        )
        
        // ๋ฉ”์‹œ์ง€ ์•ก์…˜
        let replyAction = UNTextInputNotificationAction(
            identifier: "REPLY_MESSAGE",
            title: "๋‹ต์žฅ",
            options: [],
            textInputButtonTitle: "์ „์†ก",
            textInputPlaceholder: "๋ฉ”์‹œ์ง€ ์ž…๋ ฅ"
        )
        
        let messageCategory = UNNotificationCategory(
            identifier: "MESSAGE",
            actions: [replyAction],
            intentIdentifiers: [],
            options: []
        )
        
        UNUserNotificationCenter.current().setNotificationCategories([
            talentRequestCategory,
            messageCategory
        ])
    }
    
    func application(
        _ application: UIApplication,
        didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
    ) {
        let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
        print("๐Ÿ“ฑ Device Token: \(token)")
        
        // ์„œ๋ฒ„๋กœ ์ „์†ก
        PushTokenManager.shared.sendTokenToServer(token: token)
    }
    
    func application(
        _ application: UIApplication,
        didFailToRegisterForRemoteNotificationsWithError error: Error
    ) {
        print("โŒ APNs ๋“ฑ๋ก ์‹คํŒจ: \(error.localizedDescription)")
    }
    
    func handlePushNotification(userInfo: [AnyHashable: Any]) {
        guard let type = userInfo["type"] as? String else { return }
        
        switch type {
        case "talent_request":
            if let talentId = userInfo["talentId"] as? String {
                navigateToTalentDetail(talentId: talentId)
            }
        case "message":
            if let chatRoomId = userInfo["chatRoomId"] as? String {
                navigateToChatRoom(chatRoomId: chatRoomId)
            }
        case "review":
            navigateToReviewList()
        default:
            break
        }
    }
    
    func navigateToTalentDetail(talentId: String) {
        // ์žฌ๋Šฅ ์ƒ์„ธ ํ™”๋ฉด์œผ๋กœ ์ด๋™
        NotificationCenter.default.post(
            name: NSNotification.Name("NavigateToTalentDetail"),
            object: nil,
            userInfo: ["talentId": talentId]
        )
    }
    
    func navigateToChatRoom(chatRoomId: String) {
        // ์ฑ„ํŒ…๋ฐฉ์œผ๋กœ ์ด๋™
        NotificationCenter.default.post(
            name: NSNotification.Name("NavigateToChatRoom"),
            object: nil,
            userInfo: ["chatRoomId": chatRoomId]
        )
    }
    
    func navigateToReviewList() {
        // ๋ฆฌ๋ทฐ ๋ชฉ๋ก์œผ๋กœ ์ด๋™
        NotificationCenter.default.post(
            name: NSNotification.Name("NavigateToReviewList"),
            object: nil
        )
    }
}

extension AppDelegate: UNUserNotificationCenterDelegate {
    
    // ํฌ๊ทธ๋ผ์šด๋“œ์—์„œ ์•Œ๋ฆผ ์ˆ˜์‹ 
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification,
        withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
    ) {
        let userInfo = notification.request.content.userInfo
        
        // ํ˜„์žฌ ํ™”๋ฉด์— ๋”ฐ๋ผ ์•Œ๋ฆผ ํ‘œ์‹œ ์—ฌ๋ถ€ ๊ฒฐ์ •
        if isInChatRoom(userInfo: userInfo) {
            // ํ•ด๋‹น ์ฑ„ํŒ…๋ฐฉ์— ์žˆ์œผ๋ฉด ์•Œ๋ฆผ ํ‘œ์‹œ ์•ˆ ํ•จ
            completionHandler([])
        } else {
            // ์•Œ๋ฆผ ํ‘œ์‹œ
            completionHandler([.banner, .sound, .badge])
        }
    }
    
    // ์•Œ๋ฆผ ํƒญ ๋˜๋Š” ์•ก์…˜ ์‹คํ–‰
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse,
        withCompletionHandler completionHandler: @escaping () -> Void
    ) {
        let userInfo = response.notification.request.content.userInfo
        
        switch response.actionIdentifier {
            
        case "ACCEPT_TALENT_REQUEST":
            if let requestId = userInfo["requestId"] as? String {
                TalentRequestManager.shared.acceptRequest(requestId: requestId)
            }
            
        case "DECLINE_TALENT_REQUEST":
            if let requestId = userInfo["requestId"] as? String {
                TalentRequestManager.shared.declineRequest(requestId: requestId)
            }
            
        case "REPLY_MESSAGE":
            if let textResponse = response as? UNTextInputNotificationResponse,
               let chatRoomId = userInfo["chatRoomId"] as? String {
                MessageManager.shared.sendMessage(
                    chatRoomId: chatRoomId,
                    text: textResponse.userText
                )
            }
            
        case UNNotificationDefaultActionIdentifier:
            // ์•Œ๋ฆผ ์ž์ฒด๋ฅผ ํƒญํ•œ ๊ฒฝ์šฐ
            handlePushNotification(userInfo: userInfo)
            
        default:
            break
        }
        
        completionHandler()
    }
    
    func isInChatRoom(userInfo: [AnyHashable: Any]) -> Bool {
        // ํ˜„์žฌ ์ฑ„ํŒ…๋ฐฉ์— ์žˆ๋Š”์ง€ ํ™•์ธํ•˜๋Š” ๋กœ์ง
        // ์‹ค์ œ ๊ตฌํ˜„ ํ•„์š”
        return false
    }
}

๐Ÿ”ง PushTokenManager (ํ† ํฐ ๊ด€๋ฆฌ)

import Foundation

class PushTokenManager {
    static let shared = PushTokenManager()
    
    private let tokenKey = "deviceToken"
    private let baseURL = "https://api.jaenung.net"
    
    func sendTokenToServer(token: String) {
        // ์ด์ „ ํ† ํฐ๊ณผ ๊ฐ™์œผ๋ฉด ์ „์†กํ•˜์ง€ ์•Š์Œ
        if let savedToken = getSavedToken(), savedToken == token {
            print("ํ† ํฐ์ด ๋ณ€๊ฒฝ๋˜์ง€ ์•Š์Œ")
            return
        }
        
        guard let userId = AuthManager.shared.currentUserId else {
            print("๋กœ๊ทธ์ธ๋˜์ง€ ์•Š์Œ")
            return
        }
        
        let url = URL(string: "\(baseURL)/api/push/register")!
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue(AuthManager.shared.accessToken, forHTTPHeaderField: "Authorization")
        
        let body: [String: Any] = [
            "userId": userId,
            "deviceToken": token,
            "platform": "ios",
            "appVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "",
            "osVersion": UIDevice.current.systemVersion,
            "deviceModel": UIDevice.current.model
        ]
        
        request.httpBody = try? JSONSerialization.data(withJSONObject: body)
        
        URLSession.shared.dataTask(with: request) { data, response, error in
            if let error = error {
                print("โŒ ํ† ํฐ ์ „์†ก ์‹คํŒจ: \(error)")
                return
            }
            
            if let httpResponse = response as? HTTPURLResponse,
               httpResponse.statusCode == 200 {
                print("โœ… ํ† ํฐ ์ „์†ก ์„ฑ๊ณต")
                self.saveToken(token)
            }
        }.resume()
    }
    
    func deleteTokenFromServer() {
        guard let token = getSavedToken(),
              let userId = AuthManager.shared.currentUserId else {
            return
        }
        
        let url = URL(string: "\(baseURL)/api/push/unregister")!
        var request = URLRequest(url: url)
        request.httpMethod = "DELETE"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue(AuthManager.shared.accessToken, forHTTPHeaderField: "Authorization")
        
        let body: [String: Any] = [
            "userId": userId,
            "deviceToken": token
        ]
        
        request.httpBody = try? JSONSerialization.data(withJSONObject: body)
        
        URLSession.shared.dataTask(with: request) { _, response, _ in
            if let httpResponse = response as? HTTPURLResponse,
               httpResponse.statusCode == 200 {
                print("โœ… ํ† ํฐ ์‚ญ์ œ ์„ฑ๊ณต")
                self.clearToken()
            }
        }.resume()
    }
    
    private func saveToken(_ token: String) {
        UserDefaults.standard.set(token, forKey: tokenKey)
    }
    
    private func getSavedToken() -> String? {
        return UserDefaults.standard.string(forKey: tokenKey)
    }
    
    private func clearToken() {
        UserDefaults.standard.removeObject(forKey: tokenKey)
    }
}

๐Ÿ–ฅ๏ธ ์„œ๋ฒ„ ๊ตฌํ˜„ (Node.js + Express)

// server.js
const express = require('express');
const apn = require('apn');
const mongoose = require('mongoose');

const app = express();
app.use(express.json());

// MongoDB ์Šคํ‚ค๋งˆ
const DeviceTokenSchema = new mongoose.Schema({
    userId: { type: String, required: true, index: true },
    deviceToken: { type: String, required: true, unique: true },
    platform: { type: String, required: true },
    appVersion: String,
    osVersion: String,
    deviceModel: String,
    createdAt: { type: Date, default: Date.now },
    updatedAt: { type: Date, default: Date.now }
});

const DeviceToken = mongoose.model('DeviceToken', DeviceTokenSchema);

// APNs Provider ์„ค์ •
const apnProvider = new apn.Provider({
    token: {
        key: process.env.APN_KEY_PATH,
        keyId: process.env.APN_KEY_ID,
        teamId: process.env.APN_TEAM_ID
    },
    production: process.env.NODE_ENV === 'production'
});

// ํ† ํฐ ๋“ฑ๋ก API
app.post('/api/push/register', async (req, res) => {
    try {
        const { userId, deviceToken, platform, appVersion, osVersion, deviceModel } = req.body;
        
        await DeviceToken.findOneAndUpdate(
            { deviceToken },
            {
                userId,
                platform,
                appVersion,
                osVersion,
                deviceModel,
                updatedAt: new Date()
            },
            { upsert: true, new: true }
        );
        
        res.json({ success: true, message: 'ํ† ํฐ ๋“ฑ๋ก ์™„๋ฃŒ' });
    } catch (error) {
        console.error('ํ† ํฐ ๋“ฑ๋ก ์—๋Ÿฌ:', error);
        res.status(500).json({ success: false, error: error.message });
    }
});

// ํ† ํฐ ์‚ญ์ œ API
app.delete('/api/push/unregister', async (req, res) => {
    try {
        const { userId, deviceToken } = req.body;
        
        await DeviceToken.deleteOne({ userId, deviceToken });
        
        res.json({ success: true, message: 'ํ† ํฐ ์‚ญ์ œ ์™„๋ฃŒ' });
    } catch (error) {
        console.error('ํ† ํฐ ์‚ญ์ œ ์—๋Ÿฌ:', error);
        res.status(500).json({ success: false, error: error.message });
    }
});

// ํ‘ธ์‹œ ์ „์†ก ํ•จ์ˆ˜
async function sendPushNotification(userId, notification) {
    try {
        // ์‚ฌ์šฉ์ž์˜ ๋ชจ๋“  ๋””๋ฐ”์ด์Šค ํ† ํฐ ์กฐํšŒ
        const devices = await DeviceToken.find({ userId, platform: 'ios' });
        
        if (devices.length === 0) {
            console.log('๋“ฑ๋ก๋œ ๋””๋ฐ”์ด์Šค ์—†์Œ');
            return { success: false, message: '๋“ฑ๋ก๋œ ๋””๋ฐ”์ด์Šค ์—†์Œ' };
        }
        
        const apnNotification = new apn.Notification();
        apnNotification.alert = {
            title: notification.title,
            body: notification.body
        };
        apnNotification.badge = notification.badge || 1;
        apnNotification.sound = notification.sound || 'default';
        apnNotification.topic = process.env.BUNDLE_ID;
        apnNotification.payload = notification.data || {};
        
        if (notification.category) {
            apnNotification.category = notification.category;
        }
        
        if (notification.mutableContent) {
            apnNotification.mutableContent = 1;
        }
        
        const deviceTokens = devices.map(d => d.deviceToken);
        const result = await apnProvider.send(apnNotification, deviceTokens);
        
        // ์‹คํŒจํ•œ ํ† ํฐ ์ฒ˜๋ฆฌ
        if (result.failed.length > 0) {
            for (const failure of result.failed) {
                if (failure.status === '410') {
                    // ํ† ํฐ์ด ๋” ์ด์ƒ ์œ ํšจํ•˜์ง€ ์•Š์Œ - DB์—์„œ ์‚ญ์ œ
                    await DeviceToken.deleteOne({ deviceToken: failure.device });
                    console.log(`ํ† ํฐ ์‚ญ์ œ: ${failure.device}`);
                }
            }
        }
        
        return {
            success: true,
            sent: result.sent.length,
            failed: result.failed.length
        };
        
    } catch (error) {
        console.error('ํ‘ธ์‹œ ์ „์†ก ์—๋Ÿฌ:', error);
        return { success: false, error: error.message };
    }
}

// ์žฌ๋Šฅ ์š”์ฒญ ์•Œ๋ฆผ API
app.post('/api/push/talent-request', async (req, res) => {
    try {
        const { receiverId, requesterName, talentTitle, requestId } = req.body;
        
        const notification = {
            title: '์ƒˆ๋กœ์šด ์žฌ๋Šฅ ์š”์ฒญ',
            body: `${requesterName}๋‹˜์ด "${talentTitle}" ์žฌ๋Šฅ์„ ์š”์ฒญํ–ˆ์Šต๋‹ˆ๋‹ค.`,
            category: 'TALENT_REQUEST',
            data: {
                type: 'talent_request',
                requestId: requestId,
                talentTitle: talentTitle
            }
        };
        
        const result = await sendPushNotification(receiverId, notification);
        res.json(result);
        
    } catch (error) {
        res.status(500).json({ success: false, error: error.message });
    }
});

// ๋ฉ”์‹œ์ง€ ์•Œ๋ฆผ API
app.post('/api/push/message', async (req, res) => {
    try {
        const { receiverId, senderName, message, chatRoomId } = req.body;
        
        const notification = {
            title: senderName,
            body: message,
            category: 'MESSAGE',
            data: {
                type: 'message',
                chatRoomId: chatRoomId,
                senderId: req.body.senderId
            }
        };
        
        const result = await sendPushNotification(receiverId, notification);
        res.json(result);
        
    } catch (error) {
        res.status(500).json({ success: false, error: error.message });
    }
});

// ๋ฆฌ๋ทฐ ์•Œ๋ฆผ API
app.post('/api/push/review', async (req, res) => {
    try {
        const { receiverId, reviewerName, rating } = req.body;
        
        const stars = 'โญ'.repeat(rating);
        
        const notification = {
            title: '์ƒˆ๋กœ์šด ๋ฆฌ๋ทฐ',
            body: `${reviewerName}๋‹˜์ด ${stars} ๋ฆฌ๋ทฐ๋ฅผ ๋‚จ๊ฒผ์Šต๋‹ˆ๋‹ค.`,
            data: {
                type: 'review'
            }
        };
        
        const result = await sendPushNotification(receiverId, notification);
        res.json(result);
        
    } catch (error) {
        res.status(500).json({ success: false, error: error.message });
    }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
    console.log(`์„œ๋ฒ„ ์‹คํ–‰ ์ค‘: ${PORT}`);
});
์™„์ „ํ•œ ํ‘ธ์‹œ ์•Œ๋ฆผ ์‹œ์Šคํ…œ ์•„ํ‚คํ…์ฒ˜ iOS App ๐Ÿ“ฑ User Notifications Framework Device Token Delegate Actions Backend Server ๐Ÿ–ฅ๏ธ Node.js + Express Token Manager Push Service Database Analytics APNs โ˜๏ธ Apple Push Notification Service Authentication Routing Delivery Register Send Deliver

๐Ÿ’ก ๋งˆ๋ฌด๋ฆฌํ•˜๋ฉฐ

์™€, ์—ฌ๊ธฐ๊นŒ์ง€ ์ฝ์—ˆ๋‹ค๋ฉด ์ •๋ง ๋Œ€๋‹จํ•ด! ๐Ÿ‘ ํ‘ธ์‹œ ์•Œ๋ฆผ์— ๋Œ€ํ•ด ๊ฑฐ์˜ ๋ชจ๋“  ๊ฑธ ๋‹ค๋ค˜์–ด. ์ฒ˜์Œ์—” ๋ณต์žกํ•ด ๋ณด์ด์ง€๋งŒ, ํ•˜๋‚˜์”ฉ ์ฐจ๊ทผ์ฐจ๊ทผ ๋”ฐ๋ผํ•˜๋‹ค ๋ณด๋ฉด ์–ด๋А์ƒˆ ํ‘ธ์‹œ ์•Œ๋ฆผ ๋งˆ์Šคํ„ฐ๊ฐ€ ๋˜์–ด ์žˆ์„ ๊ฑฐ์•ผ.

ํ•ต์‹ฌ ์š”์•ฝ ์ •๋ฆฌ

โœ… User Notifications Framework๋กœ ๋กœ์ปฌ/์›๊ฒฉ ์•Œ๋ฆผ ํ†ตํ•ฉ ๊ด€๋ฆฌ
โœ… APNs๋Š” ๋ชจ๋“  ํ‘ธ์‹œ์˜ ์ค‘์•™ ํ—ˆ๋ธŒ
โœ… ํ† ํฐ ๊ธฐ๋ฐ˜ ์ธ์ฆ์ด ์ตœ์‹  ๋ฐฉ์‹์ด๊ณ  ํŽธํ•จ
โœ… Device Token์€ ๊ฐ ๋””๋ฐ”์ด์Šค์˜ ๊ณ ์œ  ์ฃผ์†Œ
โœ… Rich Notifications๋กœ ์ด๋ฏธ์ง€/๋น„๋””์˜ค ์ฒจ๋ถ€ ๊ฐ€๋Šฅ
โœ… Notification Actions๋กœ ๋น ๋ฅธ ์‘๋‹ต ๊ตฌํ˜„
โœ… ๋ณด์•ˆ๊ณผ ํ”„๋ผ์ด๋ฒ„์‹œ๋Š” ํ•ญ์ƒ ์ตœ์šฐ์„ 
โœ… ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ๊ณ ๋ คํ•œ ํƒ€์ด๋ฐ๊ณผ ๋นˆ๋„ ์กฐ์ ˆ

ํ‘ธ์‹œ ์•Œ๋ฆผ์€ ๋‹จ์ˆœํžˆ ๋ฉ”์‹œ์ง€๋ฅผ ๋ณด๋‚ด๋Š” ๊ธฐ๋Šฅ์ด ์•„๋‹ˆ์•ผ. ์‚ฌ์šฉ์ž์™€ ์†Œํ†ตํ•˜๊ณ , ์ฐธ์—ฌ๋ฅผ ์œ ๋„ํ•˜๊ณ , ๊ฐ€์น˜๋ฅผ ์ „๋‹ฌํ•˜๋Š” ๊ฐ•๋ ฅํ•œ ๋„๊ตฌ์ง€. ํ•˜์ง€๋งŒ ์ž˜๋ชป ์‚ฌ์šฉํ•˜๋ฉด ์˜คํžˆ๋ ค ์‚ฌ์šฉ์ž๋ฅผ ๋– ๋‚˜๊ฒŒ ๋งŒ๋“ค ์ˆ˜ ์žˆ์–ด. ํ•ญ์ƒ ์‚ฌ์šฉ์ž ์ž…์žฅ์—์„œ ์ƒ๊ฐํ•˜๊ณ , ์ •๋ง ํ•„์š”ํ•œ ์•Œ๋ฆผ๋งŒ ๋ณด๋‚ด์ž!

์ด์ œ ๋„ˆ๋„ ๋ฉ‹์ง„ ํ‘ธ์‹œ ์•Œ๋ฆผ ์‹œ์Šคํ…œ์„ ๋งŒ๋“ค ์ˆ˜ ์žˆ์„ ๊ฑฐ์•ผ. ํ˜น์‹œ ๋ง‰ํžˆ๋Š” ๋ถ€๋ถ„์ด ์žˆ์œผ๋ฉด ์• ํ”Œ ๊ณต์‹ ๋ฌธ์„œ๋‚˜ ์ปค๋ฎค๋‹ˆํ‹ฐ๋ฅผ ์ฐธ๊ณ ํ•˜๊ณ , ๊ณ„์† ์‹คํ—˜ํ•˜๋ฉด์„œ ๋ฐฐ์›Œ๋‚˜๊ฐ€๋ฉด ๋ผ. ํ™”์ดํŒ…! ๐Ÿš€

๐Ÿ“š ๋” ๊ณต๋ถ€ํ•˜๊ณ  ์‹ถ๋‹ค๋ฉด

โ€ข Apple Developer - User Notifications
โ€ข Setting Up a Remote Notification Server
โ€ข Local and Remote Notification Programming Guide
โ€ข WWDC ์„ธ์…˜ ์˜์ƒ๋“ค (ํŠนํžˆ User Notifications ๊ด€๋ จ)

๊ทธ๋ฆฌ๊ณ  ํ‘ธ์‹œ ์•Œ๋ฆผ ๊ตฌํ˜„ํ•˜๋ฉด์„œ ์–ด๋ ค์šด ์ ์ด ์žˆ๊ฑฐ๋‚˜ ๋„์›€์ด ํ•„์š”ํ•˜๋ฉด, ์žฌ๋Šฅ๋„ท์—์„œ iOS ๊ฐœ๋ฐœ ์ „๋ฌธ๊ฐ€๋“ค์˜ ๋„์›€์„ ๋ฐ›์„ ์ˆ˜๋„ ์žˆ์–ด. ๋‹ค์–‘ํ•œ ๊ฐœ๋ฐœ์ž๋“ค์ด ์ž์‹ ์˜ ๊ฒฝํ—˜๊ณผ ๋…ธํ•˜์šฐ๋ฅผ ๊ณต์œ ํ•˜๊ณ  ์žˆ์œผ๋‹ˆ๊นŒ ํ™œ์šฉํ•ด๋ณด๋Š” ๊ฒƒ๋„ ์ข‹์„ ๊ฑฐ์•ผ!

์ž, ์ด์ œ ์ง์ ‘ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•˜๊ณ  ํ…Œ์ŠคํŠธํ•ด๋ณด์ž. ์‹ค์ œ๋กœ ํ‘ธ์‹œ๊ฐ€ ๋‚ ์•„์˜ค๋Š” ๊ฑธ ๋ณด๋ฉด ์ •๋ง ๋ฟŒ๋“ฏํ•  ๊ฑฐ์•ผ! ํ–‰์šด์„ ๋นŒ์–ด! ๐Ÿ€

๋Œ“๊ธ€ ์ž‘์„ฑ

์ด ๊ธ€์— ๋Œ€ํ•œ ์—ฌ๋Ÿฌ๋ถ„์˜ ์ƒ๊ฐ์„ ๋“ค๋ ค์ฃผ์„ธ์š”

๋Œ“๊ธ€ 0
๋Œ“๊ธ€์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.

์•„์ง ๋Œ“๊ธ€์ด ์—†์Šต๋‹ˆ๋‹ค.