Ver3.0 ๐ฑ iOS ํธ์ ์๋ฆผ ์์ ์ ๋ณต! User Notifications Framework์ APNs ์ฐ๋ ๋ง์คํฐํ๊ธฐ

๐ฑ iOS ํธ์ ์๋ฆผ ์์ ์ ๋ณต! User Notifications Framework์ APNs ์ฐ๋ ๋ง์คํฐํ๊ธฐ
์น๊ตฌ์ฒ๋ผ ์ฝ๊ฒ ๋ฐฐ์ฐ๋ ํธ์ ์๋ฆผ์ ๋ชจ๋ ๊ฒ
์ผ, ๋ ์ฑ ๋ง๋ค๋ค๊ฐ ํธ์ ์๋ฆผ ๋๋ฌธ์ ๋จธ๋ฆฌ ์ํ ๋ ์ ์์ง? ๐ ๋๋ ์ฒ์์ APNs๊ฐ ๋ญ์ง, User Notifications Framework๊ฐ ๋ญ์ง ํ๋๋ ๋ชฐ๋ผ์ ์์ฒญ ํค๋งธ๊ฑฐ๋ . ๊ทผ๋ฐ ์๊ณ ๋ณด๋๊น ์๊ฐ๋ณด๋ค ์ฒด๊ณ์ ์ด๊ณ ๋ ผ๋ฆฌ์ ์ธ ๊ตฌ์กฐ๋๋ผ๊ณ ! ์ค๋์ ๋ด๊ฐ ์ฝ์งํ๋ฉด์ ๋ฐฐ์ด ๊ฒ๋ค์ ์น๊ตฌํํ ์ค๋ช ํ๋ฏ์ด ์ฝ๊ฒ ํ์ด์ ์๋ ค์ค๊ฒ.
์์ฆ ์ฑ ๊ฐ๋ฐ์๋ค ์ฌ์ด์์ ํธ์ ์๋ฆผ์ ์ ํ์ด ์๋๋ผ ํ์์์? ์ฌ์ฉ์ ์ฐธ์ฌ๋๋ฅผ ๋์ด๊ณ , ์ค์ํ ์ ๋ณด๋ฅผ ์ ๋ฌํ๊ณ , ์ฑ์ผ๋ก ๋ค์ ๋ถ๋ฌ์ค๋ ๋ง๋ฒ ๊ฐ์ ๊ธฐ๋ฅ์ด๋๊น. ํนํ ์ฌ๋ฅ๋ท ๊ฐ์ ํ๋ซํผ์์ ์ฌ๋ฅ ๊ฑฐ๋ ์๋ฆผ์ด๋ ๋ฉ์์ง ์๋ฆผ์ ๊ตฌํํ ๋ ํธ์ ์๋ฆผ์ ์ ๋ง ํต์ฌ์ ์ธ ์ญํ ์ ํ์ง.
๐ฏ ํธ์ ์๋ฆผ์ ๊ธฐ๋ณธ ๊ฐ๋ ๋ถํฐ ์ฐจ๊ทผ์ฐจ๊ทผ
์, ์ผ๋จ ํธ์ ์๋ฆผ์ด ๋ญ์ง๋ถํฐ ์ ํํ ์์์ผ๊ฒ ์ง? ํธ์ ์๋ฆผ(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'
};
๐ ๋๋ฒ๊น ๊ณผ ๋ฌธ์ ํด๊ฒฐ
ํธ์ ์๋ฆผ ๊ฐ๋ฐํ๋ค ๋ณด๋ฉด ์ ๋๋ ๊ฒฝ์ฐ๊ฐ ์ ๋ง ๋ง์. ๋ด๊ฐ ๊ฒช์๋ ๋ฌธ์ ๋ค๊ณผ ํด๊ฒฐ ๋ฐฉ๋ฒ์ ๊ณต์ ํ ๊ฒ!
โ ์์ฃผ ๋ฐ์ํ๋ ๋ฌธ์ ๋ค
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}`);
});
๐ก ๋ง๋ฌด๋ฆฌํ๋ฉฐ
์, ์ฌ๊ธฐ๊น์ง ์ฝ์๋ค๋ฉด ์ ๋ง ๋๋จํด! ๐ ํธ์ ์๋ฆผ์ ๋ํด ๊ฑฐ์ ๋ชจ๋ ๊ฑธ ๋ค๋ค์ด. ์ฒ์์ ๋ณต์กํด ๋ณด์ด์ง๋ง, ํ๋์ฉ ์ฐจ๊ทผ์ฐจ๊ทผ ๋ฐ๋ผํ๋ค ๋ณด๋ฉด ์ด๋์ ํธ์ ์๋ฆผ ๋ง์คํฐ๊ฐ ๋์ด ์์ ๊ฑฐ์ผ.
ํต์ฌ ์์ฝ ์ ๋ฆฌ
โ
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
์ง์์ธ์ ์ฒ - ์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
- ์ ์๊ถ ๋ฐ ์์ ๊ถ: ๋ณธ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ๋ ์ AI ๊ธฐ์ ๋ก ์์ฑ๋์์ผ๋ฉฐ, ๋ํ๋ฏผ๊ตญ ์ ์๊ถ๋ฒ ๋ฐ ๊ตญ์ ์ ์๊ถ ํ์ฝ์ ์ํด ๋ณดํธ๋ฉ๋๋ค.
- AI ์์ฑ ์ปจํ ์ธ ์ ๋ฒ์ ์ง์: ๋ณธ AI ์์ฑ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ์ง์ ์ฐฝ์๋ฌผ๋ก ์ธ์ ๋๋ฉฐ, ๊ด๋ จ ๋ฒ๊ท์ ๋ฐ๋ผ ์ ์๊ถ ๋ณดํธ๋ฅผ ๋ฐ์ต๋๋ค.
- ์ฌ์ฉ ์ ํ: ์ฌ๋ฅ๋ท์ ๋ช ์์ ์๋ฉด ๋์ ์์ด ๋ณธ ์ปจํ ์ธ ๋ฅผ ๋ณต์ , ์์ , ๋ฐฐํฌ, ๋๋ ์์ ์ ์ผ๋ก ํ์ฉํ๋ ํ์๋ ์๊ฒฉํ ๊ธ์ง๋ฉ๋๋ค.
- ๋ฐ์ดํฐ ์์ง ๊ธ์ง: ๋ณธ ์ปจํ ์ธ ์ ๋ํ ๋ฌด๋จ ์คํฌ๋ํ, ํฌ๋กค๋ง, ๋ฐ ์๋ํ๋ ๋ฐ์ดํฐ ์์ง์ ๋ฒ์ ์ ์ฌ์ ๋์์ด ๋ฉ๋๋ค.
- AI ํ์ต ์ ํ: ์ฌ๋ฅ๋ท์ AI ์์ฑ ์ปจํ ์ธ ๋ฅผ ํ AI ๋ชจ๋ธ ํ์ต์ ๋ฌด๋จ ์ฌ์ฉํ๋ ํ์๋ ๊ธ์ง๋๋ฉฐ, ์ด๋ ์ง์ ์ฌ์ฐ๊ถ ์นจํด๋ก ๊ฐ์ฃผ๋ฉ๋๋ค.
์์ง ๋๊ธ์ด ์์ต๋๋ค.