Rankly는 모든 활성 기기에 알림 발송 전략을 사용합니다.
지원 플랫폼: - 📱 iOS (APNs) - 💻 PC/웹 (세션 추적만, 푸시 알림 미지원)
핵심 기능: - ✅ 여러 기기 동시 지원 - ✅ 디바이스 메타데이터 추적 - ✅ 중복 알림 자동 그룹핑 - ✅ 발송 이력 기록 - ✅ PC/웹 접속 정보 기록
user # 사용자
device_token # APNs 토큰
env # sandbox / prod
device_name # iPhone 14 Pro
device_model # iPhone15,2
os_version # iOS 17.2
app_version # 1.0.0
is_active # 활성화 여부
last_used_at # 마지막 사용 시간 (자동 갱신)
user # 사용자
ip_address # IP 주소
user_agent # User Agent 문자열
browser # Chrome, Safari 등
os # Windows 10, macOS 등
device_type # desktop, mobile, tablet
country # KR, US 등
city # Seoul 등
created_at # 접속 시간
user # 사용자
device_token # 발송한 기기
title # 제목
body # 내용
priority # CRITICAL/HIGH/NORMAL/LOW
notification_type # subscription_expiry, rank_update 등
thread_id # 그룹핑 ID
success # 발송 성공 여부
error_message # 에러 메시지
sent_at # 발송 시간
POST /api/v1/notifications/register-device
Authorization: Bearer {access_token}
Content-Type: application/json
{
"device_token": "abc123def456...",
"env": "prod",
"device_name": "iPhone 14 Pro",
"device_model": "iPhone15,2",
"os_version": "iOS 17.2",
"app_version": "1.0.0"
}
응답:
{
"detail": "iPhone 14 Pro 토큰이 등록되었습니다."
}
특징:
- 기존 토큰 있으면 업데이트
- last_used_at 자동 갱신
- 메타데이터는 선택사항 (없어도 등록 가능)
GET /api/v1/notifications/devices
Authorization: Bearer {access_token}
응답:
[
{
"id": 1,
"device_token": "abc123...",
"env": "prod",
"device_name": "iPhone 14 Pro",
"device_model": "iPhone15,2",
"os_version": "iOS 17.2",
"app_version": "1.0.0",
"is_active": true,
"last_used_at": "2025-12-17T10:00:00Z",
"created_at": "2025-12-01T09:00:00Z"
},
{
"id": 2,
"device_token": "def456...",
"env": "prod",
"device_name": "iPad Air",
"is_active": false,
"last_used_at": "2025-11-15T08:00:00Z",
"created_at": "2025-11-01T09:00:00Z"
}
]
DELETE /api/v1/notifications/devices/{device_id}
Authorization: Bearer {access_token}
응답:
{
"detail": "iPhone 14 Pro 토큰이 삭제되었습니다."
}
POST /api/v1/notifications/register-session
Authorization: Bearer {access_token}
Content-Type: application/json
{
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)...",
"browser": "Chrome",
"os": "Windows 10",
"device_type": "desktop"
}
응답:
{
"detail": "PC/웹 접속 정보가 기록되었습니다."
}
특징: - IP 주소 자동 추출 - User Agent 자동 파싱 (선택) - 푸시 알림은 발송 안 됨 (기록만)
GET /api/v1/notifications/sessions?limit=20
Authorization: Bearer {access_token}
응답:
[
{
"id": 1,
"ip_address": "123.45.67.89",
"user_agent": "Mozilla/5.0...",
"browser": "Chrome",
"os": "Windows 10",
"device_type": "desktop",
"country": "KR",
"city": "Seoul",
"created_at": "2025-12-17T11:00:00Z"
}
]
GET /api/v1/notifications/history?notification_type=rank_update&success=true&limit=20
Authorization: Bearer {access_token}
필터:
- notification_type: subscription_expiry, rank_update, payment 등
- success: true/false
- limit: 조회 개수 (최대 100)
응답:
[
{
"id": 1,
"title": "🎉 순위 상승!",
"body": "슬롯 #101의 순위가 15위 → 8위로 상승했습니다.",
"priority": "normal",
"notification_type": "rank_update",
"success": true,
"error_message": null,
"sent_at": "2025-12-17T10:30:00Z"
}
]
이유: 1. APNs 무료: 비용 부담 없음 2. 중요한 알림: 순위 변동, 구독 만료 등 3. 사용자 경험: 어느 기기에서든 즉시 확인 4. 기기 전환: iPhone ↔ iPad 자유롭게
중복 방지: - Thread ID: 같은 이벤트는 하나로 그룹핑 - Collapse ID: 최신 알림만 표시 - 우선순위: 중요도에 따라 차등 발송
대상: 모든 활성 기기
우선순위: 10 (최고)
예시: 결제 완료, 보안 경고, 구독 만료 당일
대상: 모든 활성 기기
우선순위: 10
예시: 구독 만료 3일 전, 중요한 순위 변동
대상: 모든 활성 기기
우선순위: 5
예시: 순위 변동, 슬롯 상태 변경
대상: 최근 사용 기기 1개
우선순위: 5
예시: 마케팅 알림, 공지사항
import UIKit
import UserNotifications
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// 알림 권한 요청
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in
if granted {
DispatchQueue.main.async {
application.registerForRemoteNotifications()
}
}
}
return true
}
// 디바이스 토큰 수신
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
// 기기 정보 수집
let deviceName = UIDevice.current.name // "홍길동's iPhone"
let deviceModel = UIDevice.current.model // "iPhone"
let osVersion = UIDevice.current.systemVersion // "17.2"
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
// Rankly API로 토큰 등록
registerDeviceToken(
token: token,
deviceName: deviceName,
deviceModel: deviceModel,
osVersion: "iOS \(osVersion)",
appVersion: appVersion
)
}
// 토큰 등록 실패
func application(_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error) {
print("Failed to register for remote notifications: \(error)")
}
}
// API 호출 함수
func registerDeviceToken(token: String, deviceName: String, deviceModel: String,
osVersion: String, appVersion: String) {
let url = URL(string: "https://rankly.kr/api/v1/notifications/register-device")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body: [String: Any] = [
"device_token": token,
"env": "prod", // or "sandbox" for TestFlight
"device_name": deviceName,
"device_model": deviceModel,
"os_version": osVersion,
"app_version": appVersion
]
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
print("Token registration failed: \(error)")
return
}
print("Device token registered successfully")
}.resume()
}
// AppDelegate.swift
func applicationDidBecomeActive(_ application: UIApplication) {
// 앱이 활성화될 때마다 토큰 재등록 (last_used_at 갱신)
if let token = savedDeviceToken {
registerDeviceToken(...)
}
}
// AppDelegate.swift
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable : Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
// 알림 데이터 파싱
if let aps = userInfo["aps"] as? [String: Any] {
if let alert = aps["alert"] as? [String: Any] {
let title = alert["title"] as? String ?? ""
let body = alert["body"] as? String ?? ""
print("Notification: \(title) - \(body)")
}
}
// 커스텀 데이터
if let slotId = userInfo["slot_id"] as? Int {
// 슬롯 상세 화면으로 이동
navigateToSlotDetail(slotId)
}
completionHandler(.newData)
}
// JavaScript (React/Vue 등)
async function registerSession() {
const userAgent = navigator.userAgent;
const browser = getBrowserName(); // Chrome, Safari 등
const os = getOSName(); // Windows 10, macOS 등
const deviceType = getDeviceType(); // desktop, mobile, tablet
try {
const response = await fetch('https://rankly.kr/api/v1/notifications/register-session', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
user_agent: userAgent,
browser: browser,
os: os,
device_type: deviceType
})
});
const data = await response.json();
console.log(data.detail);
} catch (error) {
console.error('Session registration failed:', error);
}
}
// 브라우저 감지
function getBrowserName() {
const ua = navigator.userAgent;
if (ua.includes('Chrome')) return 'Chrome';
if (ua.includes('Safari')) return 'Safari';
if (ua.includes('Firefox')) return 'Firefox';
if (ua.includes('Edge')) return 'Edge';
return 'Unknown';
}
// OS 감지
function getOSName() {
const ua = navigator.userAgent;
if (ua.includes('Win')) return 'Windows';
if (ua.includes('Mac')) return 'macOS';
if (ua.includes('Linux')) return 'Linux';
if (ua.includes('Android')) return 'Android';
if (ua.includes('iOS')) return 'iOS';
return 'Unknown';
}
// 기기 유형 감지
function getDeviceType() {
const ua = navigator.userAgent;
if (/(tablet|ipad|playbook|silk)|(android(?!.*mobi))/i.test(ua)) {
return 'tablet';
}
if (/Mobile|Android|iP(hone|od)|IEMobile|BlackBerry|Kindle|Silk-Accelerated|(hpw|web)OS|Opera M(obi|ini)/.test(ua)) {
return 'mobile';
}
return 'desktop';
}
// 로그인 성공 후 호출
loginSuccess().then(() => {
registerSession();
});
// 30분마다 세션 갱신
setInterval(() => {
registerSession();
}, 30 * 60 * 1000);
from notifications.utils.apns import send_apns_notification
from notifications.models import DeviceToken, NotificationLog, NotificationPriorityEnum
def send_rank_update(user, slot_id, old_rank, new_rank):
"""순위 변동 알림"""
# 모든 활성 기기 조회
devices = DeviceToken.objects.filter(user=user, is_active=True)
if not devices.exists():
return
# 알림 내용
if new_rank < old_rank:
title = "🎉 순위 상승!"
body = f"슬롯 #{slot_id}이 {old_rank}위 → {new_rank}위로 상승했습니다."
else:
title = "순위 변동"
body = f"슬롯 #{slot_id}이 {old_rank}위 → {new_rank}위로 변경되었습니다."
thread_id = f"rankly-rank-{slot_id}" # 같은 슬롯 알림은 그룹핑
# 모든 기기에 발송
for device in devices:
success, error = send_apns_notification(
device_token=device.device_token,
title=title,
body=body,
env=device.env,
thread_id=thread_id,
priority="normal",
data={
'slot_id': slot_id,
'old_rank': old_rank,
'new_rank': new_rank
}
)
# 로그 기록
NotificationLog.objects.create(
user=user,
device_token=device,
title=title,
body=body,
priority=NotificationPriorityEnum.NORMAL,
notification_type="rank_update",
thread_id=thread_id,
data={'slot_id': slot_id, 'old_rank': old_rank, 'new_rank': new_rank},
success=success,
error_message=error
)
# 만료된 토큰 자동 비활성화
if not success and error and '410' in error:
device.is_active = False
device.save()
# 30일 이상 비활성 토큰 및 90일 이상 오래된 로그 삭제
python manage.py cleanup_devices --inactive-days 30 --log-days 90
# 테스트 (실제 삭제 안 함)
python manage.py cleanup_devices --dry-run
# 커스텀 설정
python manage.py cleanup_devices --inactive-days 60 --log-days 180
# 매일 새벽 4시에 자동 정리
0 4 * * * cd /path/to/mysite && python manage.py cleanup_devices
# 우선순위: CRITICAL
# 발송 대상: 모든 기기
# Thread ID: rankly-subscription-expiry-{user_id}
send_renewal_reminder(user_id=5, days_until_expiry=1)
# 우선순위: NORMAL
# 발송 대상: 모든 기기
# Thread ID: rankly-rank-{slot_id}
send_rank_update_notification(
user_id=5,
slot_id=101,
old_rank=15,
new_rank=8
)
# 우선순위: CRITICAL
# 발송 대상: 모든 기기
# Thread ID: rankly-payment-{transaction_id}
devices = DeviceToken.objects.filter(user=user, is_active=True)
for device in devices:
send_apns_notification(
device_token=device.device_token,
title="결제 완료",
body=f"{plan_name} 구독이 완료되었습니다.",
env=device.env,
thread_id=f"rankly-payment-{transaction_id}",
priority="critical"
)
# APNs JWT 인증 (p8 파일)
APNS_KEY_PATH=/path/to/AuthKey_ABC123.p8
APNS_KEY_ID=ABC123
APNS_TEAM_ID=DEF456
APNS_BUNDLE_ID=kr.rankly.app
APNS_TOPIC=kr.rankly.app
APNS_USE_SANDBOX=false
SELECT
COUNT(*) as total_devices,
COUNT(CASE WHEN is_active = true THEN 1 END) as active_devices,
COUNT(CASE WHEN env = 'prod' THEN 1 END) as prod_devices,
COUNT(CASE WHEN env = 'sandbox' THEN 1 END) as sandbox_devices
FROM device_token;
SELECT
notification_type,
COUNT(*) as total,
COUNT(CASE WHEN success = true THEN 1 END) as success,
ROUND(COUNT(CASE WHEN success = true THEN 1 END) * 100.0 / COUNT(*), 2) as success_rate
FROM notification_log
WHERE sent_at >= NOW() - INTERVAL '30 days'
GROUP BY notification_type;
SELECT
device_type,
browser,
COUNT(*) as access_count
FROM session_log
WHERE created_at >= NOW() - INTERVAL '7 days'
GROUP BY device_type, browser
ORDER BY access_count DESC;
1. 디바이스 토큰 확인
GET /api/v1/notifications/devices
→ is_active가 true인지 확인
2. APNs 설정 확인
- APNS_KEY_PATH 파일 존재 확인
- APNS_KEY_ID, TEAM_ID 정확한지 확인
3. 알림 이력 확인
GET /api/v1/notifications/history?success=false
→ error_message 확인
4. iOS 설정 확인
- 앱 알림 권한 허용 확인
- 방해 금지 모드 확인
1. Thread ID 확인
- 같은 이벤트는 같은 thread_id 사용
2. Collapse ID 설정 확인
- APNs 헤더에 apns-collapse-id 포함 확인
3. iOS 설정 확인
- 알림 그룹핑 설정 확인
1. 앱 재설치 확인
- 앱 재설치 시 토큰 변경됨
- 새 토큰 재등록 필요
2. 인증서 만료 확인
- APNs p8 키 유효 기간 확인
3. 환경 일치 확인
- 프로덕션 앱에 sandbox 토큰 등록하지 않았는지
작성: 2025-12-17
프로젝트: Rankly
버전: 2.0