본문으로 이동

플러터:푸시 알람(FCM)

학교의 모든 지식. SMwiki

틀:플러터 Dart:개요 플러터에 대한 지식 분류

  1. 플러터:개요
    1. 플러터:VSCode
    2. 플러터:안드로이드 스튜디오
  2. 플러터:실행
  3. 플러터:개념 잡기
    1. 플러터:화면 하나 만들기
    2. 플러터:변하는 화면(StatefulWidget)
    3. 플러터:화면 전환(화면 쌓기, 하단 네비게이션 바)
    4. 플러터:화면 전환(Drawer)
    5. 플러터:입력 관련
      1. 플러터:버튼
      2. 플러터:키보드 입력
      3. 플러터:슬라이더
    6. 플러터:그래프 그리기(fl chart)
    7. 플러터:데이터 저장(간단한 데이터)
    8. 플러터:인증(Firebase 인증)(미완)
    9. 플러터:인증(OAuth2)(미완)
    10. 플러터:푸시 알람(FCM)(미완)
  4. 권한 사용
    1. 플러터:마이크 입력
  5. 위젯
    1. 플러터:아이콘
    2. 플러터:레이아웃 계열 위젯
    3. 플러터:네비게이션 계열 위젯
    4. 플러터:버튼
    5. 플러터:상태관리(미완)
  6. 플러터:DB연결
    1. 플러터:Firebase(미완)
    2. 플러터:MySQL(미완)
  7. 디자인
    1. 플러터:테마
    2. 플러터:앱바
  8. 플러터:배포
    1. 플러터:배포(안드로이드)(미완)
    2. 플러터:배포(iOS)(미완)
  9. 플러터:참고자료
  10. 플러터:위젯
    1. 플러터:공간배치용 위젯
  11. 플러터:구글 AdMob(미완)
  12. 플러터:라이브러리
    1. 플러터:logger

FCM(Firebase Cloud Messaging)은 Firebase에서 제공하는 푸시 알림 전송 서비스로, 안드로이드와 iOS를 동시에 지원.

플러터(Flutter) 환경에서는 사실상 표준적인 푸시 알림 수단.

iOS와 안드로이드에서만 사용 가능하다. 브라우저와 이외 OS에선 웹소캣 등 따로 구성해주어야 함.

기본 개념

[편집 | 원본 편집]
  • 서버 키를 통해 알림을 보내는 주체가 관리자임을 인증하고,
  • 기기마다 주어지는 토큰을 통해 어떤 기기로 알림을 보낼지 정한다.
구분 설명 비고
FCM 서버 키(Server Key)
  • Firebase 프로젝트 단위로 발급되는 비밀 키
  • 서버에서 FCM으로 푸시 요청을 보낼 때 사용
  • 절대 클라이언트(앱)에 포함하면 안 됨
FCM 토큰(Registration Token)
  • 앱이 설치된 기기 + 앱 단위로 발급되는 식별자
  • 푸시 알림의 실제 수신 대상 사용자(기기)
서버에서 계정과 매핑

전체 구조

[편집 | 원본 편집]
[사용자 로그인]
      ↓
[FCM 토큰 발급 (앱)]
      ↓
[서버로 토큰 전송]
      ↓
[서버 DB: 계정 ↔ FCM 토큰 저장]
      ↓
[알림 발생]
      ↓
[FCM 서버 → 기기]

FCM은 알림을 전달할 뿐, 어떤 사용자에게 보낼지는 서버가 결정한다.

예시 프로젝트

[편집 | 원본 편집]

사전 준비

[편집 | 원본 편집]
과정 설명 비고
Firebase 프로젝트 생성
Android / iOS 앱 등록
google-services.json 또는 GoogleService-Info.plist 설정 완료
Flutter 설정

pubspec.yaml

[편집 | 원본 편집]
dependencies:
  flutter:
    sdk: flutter
  firebase_core: ^2.24.2
  firebase_messaging: ^14.7.10
flutter pub get

예시 코드

[편집 | 원본 편집]
  • onMessage, onMessageOpenedApp, getInitialMessage 이들을 이해하는 게 핵심.
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';

/// 🔹 백그라운드 메시지 핸들러 (top-level 필수)
Future<void> firebaseMessagingBackgroundHandler(
    RemoteMessage message) async {
  await Firebase.initializeApp();
  // UI 접근 불가, 필요하면 로그/저장만
  debugPrint('백그라운드 메시지 수신: ${message.messageId}');
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();

  // 백그라운드 핸들러 등록
  FirebaseMessaging.onBackgroundMessage(
    firebaseMessagingBackgroundHandler,
  );

  runApp(const MyApp());
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  String? fcmToken;
  final List<String> messages = [];

  @override
  void initState() {
    super.initState();
    _initFCM();
    _setupMessageHandlers();
  }

  /// FCM 초기 설정 + 토큰 발급
  Future<void> _initFCM() async {
    // iOS 권한 요청 (Android에서는 무시됨)
    await FirebaseMessaging.instance.requestPermission();

    final token = await FirebaseMessaging.instance.getToken();
    setState(() {
      fcmToken = token;
    });
  }

  /// 메시지 수신 / 클릭 처리
  void _setupMessageHandlers() {
    // 1️⃣ 포그라운드 수신
    FirebaseMessaging.onMessage.listen((RemoteMessage message) {
      final title = message.notification?.title ?? '(no title)';
      final body = message.notification?.body ?? '(no body)';
      setState(() {
        messages.add('[FOREGROUND] $title - $body');
      });
    });

    // 2️⃣ 백그라운드 상태에서 알림 클릭
    FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
      final title = message.notification?.title ?? '(no title)';
      setState(() {
        messages.add('[CLICK - BG] $title');
      });
    });

    // 3️⃣ 종료 상태에서 알림 클릭
    FirebaseMessaging.instance.getInitialMessage().then((message) {
      if (message != null) {
        final title = message.notification?.title ?? '(no title)';
        setState(() {
          messages.add('[CLICK - TERMINATED] $title');
        });
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('FCM 단일 파일 예제')),
        body: Padding(
          padding: const EdgeInsets.all(16),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              const Text(
                'FCM Token',
                style: TextStyle(fontWeight: FontWeight.bold),
              ),
              SelectableText(
                fcmToken ?? '토큰 불러오는 중...',
                style: const TextStyle(fontSize: 12),
              ),
              const SizedBox(height: 16),
              const Text(
                '수신 / 클릭 메시지',
                style: TextStyle(fontWeight: FontWeight.bold),
              ),
              const SizedBox(height: 8),
              Expanded(
                child: ListView.builder(
                  itemCount: messages.length,
                  itemBuilder: (context, index) {
                    return ListTile(
                      title: Text(messages[index]),
                    );
                  },
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

푸시 알림 수신

[편집 | 원본 편집]

권한 요청 (iOS 필수)

[편집 | 원본 편집]
await FirebaseMessaging.instance.requestPermission(
  alert: true,
  badge: true,
  sound: true,
);

포그라운드 수신

[편집 | 원본 편집]
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
  print('푸시 수신: ${message.notification?.title}');
});

백그라운드 수신

[편집 | 원본 편집]
Future<void> firebaseMessagingBackgroundHandler(
    RemoteMessage message) async {
  await Firebase.initializeApp();
}
FirebaseMessaging.onBackgroundMessage(
  firebaseMessagingBackgroundHandler,
);

서버 측 (예시)

[편집 | 원본 편집]

Device Token 테이블 구조

[편집 | 원본 편집]
필드명 설명
user_id 사용자 ID
fcm_token 기기 토큰
platform android / ios

푸시 전송 (JSON 예시)

[편집 | 원본 편집]
{
  "to": "FCM_TOKEN",
  "notification": {
    "title": "테스트 알림",
    "body": "푸시 알림이 도착했습니다"
  }
}

로그인 시 처리

[편집 | 원본 편집]

클라이언트(Flutter)

[편집 | 원본 편집]

앱 실행 후 FCM 토큰을 발급받는다.

FirebaseMessaging messaging = FirebaseMessaging.instance;
String? token = await messaging.getToken();

로그인 성공 시, 해당 토큰을 서버로 전송한다.

서버는 사용자 계정과 FCM 토큰을 매핑하여 저장한다.

user_id fcm_token platform
123 token_A android
123 token_B ios
  • 하나의 계정은 여러 기기를 가질 수 있다.
  • FCM 토큰은 기기 단위로 관리한다.

로그아웃 / 계정 삭제 처리

[편집 | 원본 편집]

로그아웃

[편집 | 원본 편집]
  • 현재 기기의 FCM 토큰만 삭제
  • 다른 기기 로그인은 유지
DELETE FROM device_token
WHERE user_id = 123
  AND fcm_token = 'token_A';

계정 삭제(탈퇴)

[편집 | 원본 편집]
  • 해당 계정에 연결된 모든 FCM 토큰 삭제
DELETE FROM device_token
WHERE user_id = 123;

토큰 변경 및 예외 처리

[편집 | 원본 편집]

토큰 갱신

[편집 | 원본 편집]

다음과 같은 경우 FCM 토큰이 변경될 수 있다.

  • 앱 재설치
  • 기기 변경
  • OS 업데이트
  • Firebase 정책 변경

Flutter에서는 토큰 변경 이벤트를 수신하여 서버에 반영해야 한다.

FirebaseMessaging.instance.onTokenRefresh.listen((newToken) {
  // 서버로 새 토큰 전송
});

앱 삭제

[편집 | 원본 편집]
  • 서버는 앱 삭제 여부를 직접 알 수 없음
  • 푸시 전송 시 FCM에서 에러 반환

대표적인 에러:

  • NotRegistered
  • InvalidRegistration

이 경우 서버에서 해당 토큰을 삭제해야 한다.

푸시 전송

[편집 | 원본 편집]

서버 → FCM

[편집 | 원본 편집]

서버는 FCM 서버 키를 사용하여 푸시 요청을 전송한다.

{
  "to": "fcm_token",
  "notification": {
    "title": "새 메시지",
    "body": "홍길동: 안녕하세요"
  }
}

중요 사항

[편집 | 원본 편집]
  • FCM 서버 키는 서버에서만 사용
  • 클라이언트에 노출 금지

핵심 정리

[편집 | 원본 편집]
  • FCM은 전달자 역할만 수행한다.
  • 사용자와 기기의 연결은 서버에서 직접 관리해야 한다.
  • 로그인 = 기기 등록
  • 로그아웃 = 기기 해제
  • 탈퇴 = 모든 기기 해제

체크리스트

[편집 | 원본 편집]
  • 로그인 시 FCM 토큰 등록
  • 로그아웃 시 해당 토큰 삭제
  • 탈퇴 시 전체 토큰 삭제
  • 토큰 갱신 처리
  • FCM 에러 발생 시 토큰 정리