FCM으로 서버 디스크 상태를 안드로이드 앱에 푸시 알림 보내기

By | 2026년 4월 29일
Table of Contents

FCM으로 서버 디스크 상태를 안드로이드 앱에 푸시 알림 보내기

서버 디스크 사용량이 80%를 초과하면 Firebase Cloud Messaging(FCM)을 통해 안드로이드 앱에 즉시 푸시 알림을 전송하는 방법을 단계별로 설명합니다.

1. 전체 아키텍처 개요

[Linux Server]
  ├── cron + Python 스크립트 (디스크 사용량 감시)
  │       │
  │       │ 80% 초과 시 HTTP POST
  │       ▼
[Firebase Cloud Messaging (FCM)]
  │       │
  │       │ Push Notification (Topic 또는 Token 방식)
  │       ▼
[Android App] × N명 (5명 이상)

핵심 전송 방식: Topic 구독

클라이언트가 5명 이상인 경우, 각 기기의 토큰을 서버에서 일일이 관리하는 대신 FCM Topic(/topics/disk-alert)을 사용합니다. 앱 설치 시 해당 토픽을 구독하면, 서버는 토픽 하나에만 메시지를 보내면 구독한 모든 기기에 자동으로 전달됩니다.


2. Firebase 프로젝트 설정

2-1. Firebase 프로젝트 생성

  1. Firebase Console 접속
  2. 프로젝트 추가 클릭 → 프로젝트 이름 입력 (예: server-disk-monitor)
  3. Google Analytics는 선택 사항 → 프로젝트 만들기

2-2. 안드로이드 앱 등록

  1. 프로젝트 홈 → Android 아이콘 클릭
  2. 패키지 이름 입력 (예: com.example.diskmonitor)
  3. google-services.json 다운로드 → 안드로이드 프로젝트의 app/ 폴더에 복사

2-3. 서비스 계정 키(Service Account Key) 발급

서버에서 FCM API를 호출하려면 서비스 계정 키가 필요합니다.

  1. Firebase Console → 프로젝트 설정서비스 계정
  2. 새 비공개 키 생성 클릭 → JSON 파일 다운로드
  3. 서버의 안전한 경로에 저장 (예: /etc/fcm/service-account.json)
# 파일 권한을 root만 읽을 수 있도록 설정
sudo chmod 600 /etc/fcm/service-account.json
sudo chown root:root /etc/fcm/service-account.json

3. 서버 설정 (Python 모니터링 스크립트)

3-1. 필요 패키지 설치

pip install google-auth requests psutil

3-2. FCM 메시지 전송 스크립트 작성

/usr/local/bin/disk_monitor.py 로 저장합니다.

#!/usr/bin/env python3
"""
서버 디스크 사용량을 감시하고 80% 초과 시 FCM으로 푸시 알림을 전송합니다.
"""

import psutil
import requests
import json
import os
import logging
from google.oauth2 import service_account
from google.auth.transport.requests import Request

# ── 설정 ──────────────────────────────────────────────────────────────
SERVICE_ACCOUNT_FILE = "/etc/fcm/service-account.json"
DISK_THRESHOLD_PERCENT = 80          # 알림 발송 기준 (%)
MONITOR_PATH = "/"                   # 감시할 마운트 포인트
FCM_TOPIC = "disk-alert"             # 구독 토픽 이름
PROJECT_ID = "your-firebase-project-id"  # Firebase 프로젝트 ID로 변경

FCM_URL = f"https://fcm.googleapis.com/v1/projects/{PROJECT_ID}/messages:send"
SCOPES = ["https://www.googleapis.com/auth/firebase.messaging"]

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[
        logging.FileHandler("/var/log/disk_monitor.log"),
        logging.StreamHandler()
    ]
)
# ──────────────────────────────────────────────────────────────────────

def get_access_token() -> str:
    """서비스 계정으로 OAuth2 액세스 토큰 발급"""
    credentials = service_account.Credentials.from_service_account_file(
        SERVICE_ACCOUNT_FILE, scopes=SCOPES
    )
    credentials.refresh(Request())
    return credentials.token

def get_disk_usage(path: str) -> tuple[float, str, str]:
    """디스크 사용률, 사용량, 전체 용량 반환"""
    usage = psutil.disk_usage(path)
    percent = usage.percent
    used_gb = usage.used / (1024 ** 3)
    total_gb = usage.total / (1024 ** 3)
    return percent, f"{used_gb:.1f}GB", f"{total_gb:.1f}GB"

def send_fcm_notification(percent: float, used: str, total: str) -> bool:
    """FCM Topic으로 푸시 알림 전송"""
    token = get_access_token()
    hostname = os.uname().nodename

    payload = {
        "message": {
            "topic": FCM_TOPIC,
            "notification": {
                "title": f"⚠️ 디스크 경고: {hostname}",
                "body": (
                    f"디스크 사용량이 {percent:.1f}%에 도달했습니다.\n"
                    f"사용: {used} / 전체: {total}"
                )
            },
            "data": {
                "type": "DISK_ALERT",
                "hostname": hostname,
                "mount_path": MONITOR_PATH,
                "percent": str(percent),
                "used": used,
                "total": total
            },
            "android": {
                "priority": "high",
                "notification": {
                    "channel_id": "disk_alert_channel",
                    "sound": "default",
                    "click_action": "DISK_ALERT_ACTIVITY"
                }
            }
        }
    }

    headers = {
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json"
    }

    response = requests.post(FCM_URL, headers=headers, json=payload, timeout=10)

    if response.status_code == 200:
        logging.info(f"FCM 전송 성공: {response.json().get('name')}")
        return True
    else:
        logging.error(f"FCM 전송 실패: {response.status_code} - {response.text}")
        return False

def main():
    percent, used, total = get_disk_usage(MONITOR_PATH)
    logging.info(f"디스크 사용량: {percent:.1f}% ({used}/{total})")

    if percent > DISK_THRESHOLD_PERCENT:
        logging.warning(f"임계값 초과 ({DISK_THRESHOLD_PERCENT}%)! FCM 알림 전송 중...")
        send_fcm_notification(percent, used, total)
    else:
        logging.info("정상 범위입니다. 알림 불필요.")

if __name__ == "__main__":
    main()

PROJECT_ID를 Firebase 프로젝트 설정 페이지에서 확인한 실제 프로젝트 ID로 반드시 변경하세요.

3-3. 스크립트 실행 권한 및 cron 설정

# 실행 권한 부여
sudo chmod +x /usr/local/bin/disk_monitor.py

# cron으로 5분마다 실행 (root 권한)
sudo crontab -e

crontab에 아래 내용을 추가합니다:

# 5분마다 디스크 사용량 감시
*/5 * * * * /usr/bin/python3 /usr/local/bin/disk_monitor.py

3-4. 중복 알림 방지 (선택 사항)

80%를 초과하는 상태가 계속되면 5분마다 알림이 발송됩니다. 중복 발송을 막으려면 플래그 파일을 활용하세요.

FLAG_FILE = "/tmp/disk_alert_sent"

def main():
    percent, used, total = get_disk_usage(MONITOR_PATH)

    if percent > DISK_THRESHOLD_PERCENT:
        if not os.path.exists(FLAG_FILE):
            send_fcm_notification(percent, used, total)
            open(FLAG_FILE, "w").close()  # 플래그 생성
            logging.warning("알림 전송 완료. 플래그 파일 생성.")
        else:
            logging.info("이미 알림 발송됨. 중복 전송 건너뜀.")
    else:
        # 정상 복귀 시 플래그 삭제
        if os.path.exists(FLAG_FILE):
            os.remove(FLAG_FILE)
            logging.info("디스크 정상 복귀. 플래그 파일 삭제.")

4. 안드로이드 앱 설정

4-1. build.gradle 의존성 추가

프로젝트 수준 build.gradle:

// build.gradle (Project)
plugins {
    id 'com.google.gms.google-services' version '4.4.1' apply false
}

앱 수준 app/build.gradle:

// build.gradle (App)
plugins {
    id 'com.android.application'
    id 'com.google.gms.google-services'
}

dependencies {
    // Firebase BoM (버전 통합 관리)
    implementation platform('com.google.firebase:firebase-bom:33.1.0')
    implementation 'com.google.firebase:firebase-messaging'
}

4-2. FCM 서비스 클래스 구현

MyFirebaseMessagingService.kt:

package com.example.diskmonitor

import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage

class MyFirebaseMessagingService : FirebaseMessagingService() {

    companion object {
        const val CHANNEL_ID = "disk_alert_channel"
        const val CHANNEL_NAME = "디스크 경고 알림"
        const val CHANNEL_DESC = "서버 디스크 사용량 경고 알림"
    }

    /**
     * FCM 메시지 수신 처리
     */
    override fun onMessageReceived(remoteMessage: RemoteMessage) {
        super.onMessageReceived(remoteMessage)

        val title = remoteMessage.notification?.title ?: "디스크 경고"
        val body = remoteMessage.notification?.body ?: "서버 디스크를 확인하세요."
        val data = remoteMessage.data  // 서버에서 보낸 data 필드

        sendNotification(title, body, data)
    }

    /**
     * 토큰 갱신 시 호출 (서버에 새 토큰 등록 필요 시 여기서 처리)
     */
    override fun onNewToken(token: String) {
        super.onNewToken(token)
        // 필요 시 토큰을 자체 서버에 전송
        // sendTokenToServer(token)
    }

    private fun sendNotification(
        title: String,
        body: String,
        data: Map<String, String>
    ) {
        createNotificationChannel()

        val intent = Intent(this, MainActivity::class.java).apply {
            flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
            // data 필드를 Intent로 전달
            data.forEach { (key, value) -> putExtra(key, value) }
        }

        val pendingIntent = PendingIntent.getActivity(
            this, 0, intent,
            PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
        )

        val hostname = data["hostname"] ?: "서버"
        val percent = data["percent"] ?: "?"

        val notification = NotificationCompat.Builder(this, CHANNEL_ID)
            .setSmallIcon(R.drawable.ic_warning)  // 경고 아이콘 (res에 추가 필요)
            .setContentTitle(title)
            .setContentText(body)
            .setStyle(NotificationCompat.BigTextStyle().bigText(body))
            .setPriority(NotificationCompat.PRIORITY_HIGH)
            .setAutoCancel(true)
            .setContentIntent(pendingIntent)
            .build()

        val notificationManager =
            getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        notificationManager.notify(hostname.hashCode(), notification)
    }

    private fun createNotificationChannel() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel(
                CHANNEL_ID,
                CHANNEL_NAME,
                NotificationManager.IMPORTANCE_HIGH
            ).apply {
                description = CHANNEL_DESC
                enableVibration(true)
            }
            val manager = getSystemService(NotificationManager::class.java)
            manager.createNotificationChannel(channel)
        }
    }
}

4-3. Topic 구독 설정

MainActivity.kt에서 앱 실행 시 자동으로 토픽을 구독합니다:

package com.example.diskmonitor

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.google.firebase.messaging.FirebaseMessaging

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        subscribeToTopic()
    }

    private fun subscribeToTopic() {
        FirebaseMessaging.getInstance().subscribeToTopic("disk-alert")
            .addOnCompleteListener { task ->
                if (task.isSuccessful) {
                    // 구독 성공
                    android.util.Log.d("FCM", "disk-alert 토픽 구독 성공")
                } else {
                    android.util.Log.e("FCM", "토픽 구독 실패", task.exception)
                }
            }
    }
}

4-4. AndroidManifest.xml 설정

<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <!-- 알림 권한 (Android 13 이상 필수) -->
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
    <uses-permission android:name="android.permission.INTERNET"/>

    <application ... >

        <!-- FCM 서비스 등록 -->
        <service
            android:name=".MyFirebaseMessagingService"
            android:exported="false">
            <intent-filter>
                <action android:name="com.google.firebase.MESSAGING_EVENT"/>
            </intent-filter>
        </service>

        <!-- 알림 아이콘 및 색상 기본값 설정 -->
        <meta-data
            android:name="com.google.firebase.messaging.default_notification_icon"
            android:resource="@drawable/ic_warning"/>
        <meta-data
            android:name="com.google.firebase.messaging.default_notification_color"
            android:resource="@color/colorAccent"/>
        <meta-data
            android:name="com.google.firebase.messaging.default_notification_channel_id"
            android:value="disk_alert_channel"/>

    </application>
</manifest>

4-5. 알림 권한 요청 (Android 13 이상)

// MainActivity.kt - onCreate에 추가
private fun requestNotificationPermission() {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS)
            != android.content.pm.PackageManager.PERMISSION_GRANTED
        ) {
            requestPermissions(
                arrayOf(android.Manifest.permission.POST_NOTIFICATIONS),
                1001
            )
        }
    }
}

5. 다중 클라이언트(5명 이상) 전송 전략

Topic 방식 vs Token 방식 비교

항목 Topic 방식 (권장) Token 방식
대상 토픽 구독자 전체 특정 기기 지정
서버 관리 토큰 저장 불필요 DB에 모든 토큰 저장 필요
확장성 무제한 복잡도 증가
개인화 불가 가능
적합한 경우 5명 이상, 공통 알림 개인별 맞춤 알림

이 가이드에서는 Topic 방식을 사용합니다. 사용자 5명이 disk-alert 토픽을 구독하면 서버는 토픽 하나에만 메시지를 보내면 됩니다.

특정 사용자에게만 알림 보내기 (고급)

관리자 그룹과 일반 사용자를 나누어 알림을 제어하려면 여러 토픽을 활용하세요:

# 심각 경고(90% 초과)는 관리자 토픽으로 전송
ADMIN_TOPIC = "disk-alert-admin"    # 관리자 전용
USER_TOPIC  = "disk-alert"          # 전체 구독자

if percent > 90:
    send_fcm_notification(percent, used, total, topic=ADMIN_TOPIC)
elif percent > 80:
    send_fcm_notification(percent, used, total, topic=USER_TOPIC)
// 안드로이드 - 관리자 앱에서 추가 토픽 구독
FirebaseMessaging.getInstance().subscribeToTopic("disk-alert-admin")

6. 테스트 및 검증

6-1. 스크립트 직접 실행 테스트

# 실제 디스크 사용량으로 테스트
python3 /usr/local/bin/disk_monitor.py

# 임계값을 낮춰서 강제 알림 테스트 (스크립트에서 임시로 변경)
DISK_THRESHOLD_PERCENT = 0  # 항상 알림 발송

6-2. Firebase Console에서 테스트 메시지 전송

  1. Firebase Console → Messaging새 캠페인Firebase 알림 메시지
  2. 알림 제목/내용 입력
  3. 타겟: 토픽disk-alert 입력
  4. 지금 보내기 클릭

6-3. cURL로 FCM API 직접 테스트

# 액세스 토큰 발급 (gcloud CLI 필요)
ACCESS_TOKEN=$(gcloud auth print-access-token)

curl -X POST \
  "https://fcm.googleapis.com/v1/projects/YOUR_PROJECT_ID/messages:send" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "message": {
      "topic": "disk-alert",
      "notification": {
        "title": "테스트 알림",
        "body": "FCM 연동 테스트입니다."
      }
    }
  }'

6-4. 로그 확인

# 모니터링 스크립트 로그 확인
tail -f /var/log/disk_monitor.log

# cron 실행 로그 확인
grep CRON /var/log/syslog | tail -20

정리

단계 작업 내용
① Firebase 프로젝트 생성 → 서비스 계정 키 발급
② 서버 Python 스크립트로 디스크 감시 → FCM HTTP v1 API 호출
③ cron 5분마다 스크립트 실행
④ Android FCM SDK 추가 → disk-alert 토픽 구독 → 알림 표시

Topic 방식을 사용하면 클라이언트가 몇 명이 되어도 서버 코드 변경 없이 모든 구독자에게 알림을 전달할 수 있습니다. 디스크 경고 외에도 CPU, 메모리 등의 지표도 동일한 구조로 확장할 수 있습니다.

답글 남기기