← 홈으로 돌아가기

📱 Rankly 알림 시스템 가이드

개요

Rankly는 모든 활성 기기에 알림 발송 전략을 사용합니다.

지원 플랫폼: - 📱 iOS (APNs) - 💻 PC/웹 (세션 추적만, 푸시 알림 미지원)

핵심 기능: - ✅ 여러 기기 동시 지원 - ✅ 디바이스 메타데이터 추적 - ✅ 중복 알림 자동 그룹핑 - ✅ 발송 이력 기록 - ✅ PC/웹 접속 정보 기록


📊 데이터 모델

1. DeviceToken (모바일 기기)

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     # 마지막 사용 시간 (자동 갱신)

2. SessionLog (PC/웹 접속)

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       # 접속 시간

3. NotificationLog (발송 이력)

user                # 사용자
device_token        # 발송한 기기
title               # 제목
body                # 내용
priority            # CRITICAL/HIGH/NORMAL/LOW
notification_type   # subscription_expiry, rank_update 등
thread_id           # 그룹핑 ID
success             # 발송 성공 여부
error_message       # 에러 메시지
sent_at             # 발송 시간

🔌 API 엔드포인트

모바일 기기 관리

1. 디바이스 토큰 등록

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 자동 갱신 - 메타데이터는 선택사항 (없어도 등록 가능)

2. 등록된 기기 목록

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"
  }
]

3. 디바이스 토큰 삭제

DELETE /api/v1/notifications/devices/{device_id}
Authorization: Bearer {access_token}

응답:

{
  "detail": "iPhone 14 Pro 토큰이 삭제되었습니다."
}

PC/웹 세션 관리

4. 세션 등록

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 자동 파싱 (선택) - 푸시 알림은 발송 안 됨 (기록만)

5. 세션 이력 조회

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"
  }
]

알림 이력

6. 알림 발송 이력

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: 최신 알림만 표시 - 우선순위: 중요도에 따라 차등 발송


🔔 알림 우선순위

CRITICAL (긴급)

대상: 모든 활성 기기
우선순위: 10 (최고)
예시: 결제 완료, 보안 경고, 구독 만료 당일

HIGH (높음)

대상: 모든 활성 기기
우선순위: 10
예시: 구독 만료 3일 전, 중요한 순위 변동

NORMAL (보통)

대상: 모든 활성 기기
우선순위: 5
예시: 순위 변동, 슬롯 상태 변경

LOW (낮음)

대상: 최근 사용 기기 1개
우선순위: 5
예시: 마케팅 알림, 공지사항

💻 iOS 앱 개발자 가이드

1. 앱 시작 시 - 디바이스 토큰 등록

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()
}

2. 앱 업데이트 시 - 토큰 재등록

// AppDelegate.swift
func applicationDidBecomeActive(_ application: UIApplication) {
    // 앱이 활성화될 때마다 토큰 재등록 (last_used_at 갱신)
    if let token = savedDeviceToken {
        registerDeviceToken(...)
    }
}

3. 알림 수신 처리

// 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)
}

🖥️ PC/웹 개발자 가이드

1. 로그인 성공 시 - 세션 등록

// 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();
});

2. 주기적 세션 갱신 (선택사항)

// 30분마다 세션 갱신
setInterval(() => {
    registerSession();
}, 30 * 60 * 1000);

📡 알림 발송 예시

서버 측 (Python)

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()

🔧 관리 명령어

1. 디바이스 및 로그 정리

# 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

2. Cron Job 설정 (권장)

# 매일 새벽 4시에 자동 정리
0 4 * * * cd /path/to/mysite && python manage.py cleanup_devices

📊 알림 타입별 처리

1. 구독 만료 알림

# 우선순위: CRITICAL
# 발송 대상: 모든 기기
# Thread ID: rankly-subscription-expiry-{user_id}

send_renewal_reminder(user_id=5, days_until_expiry=1)

2. 순위 변동 알림

# 우선순위: NORMAL
# 발송 대상: 모든 기기
# Thread ID: rankly-rank-{slot_id}

send_rank_update_notification(
    user_id=5,
    slot_id=101,
    old_rank=15,
    new_rank=8
)

3. 결제 완료 알림

# 우선순위: 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 인증 정보 (.env)

# 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

APNs p8 키 발급 방법

  1. Apple Developer Console 접속
  2. Certificates, Identifiers & Profiles
  3. Keys → Create a key
  4. Apple Push Notifications service (APNs) 체크
  5. AuthKey_XXX.p8 다운로드
  6. Key ID, Team ID 메모

📈 모니터링

1. 활성 기기 통계

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;

2. 알림 발송 성공률

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;

3. PC 접속 통계

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 토큰 등록하지 않았는지

📋 체크리스트

모바일 앱 개발

PC/웹 개발

서버 설정


🎓 참고 자료


작성: 2025-12-17
프로젝트: Rankly
버전: 2.0