푸시 알림 시스템을 서버·클라이언트·시크릿 전부 연결해 완료했다. 4가지 알림 타입(new_post, follow, sale, announcement)이 DB 트리거로 자동 생성되고, send-push Edge Function이 FCM을 통해 Android/iOS로 발송한다. iOS APNs 키만 Apple Developer 가입 병목으로 대기 중이지만, Android는 이론상 작동 준비가 끝났다.
new_post: 구독 작가가 신작 게시 (기존 notify_new_post 트리거)20260817090000_notify_follow_and_sale.sql)follow: 다른 유저가 나를 구독
on_follow_created 트리거 신규 생성notifFollow의 en/ko 등 l10n 문구에 데모 작가명 "하늘바람"이 하드코딩돼 있었다sale: 내 유료 작품이 팔렸을 때 판매자에게 알림
purchased 타입은 구매자에게 "샀어요"sale은 판매자에게 "내 작품이 팔렸어요"notify_purchase 트리거 하나에서 조건부(price_paid > 0, 자기 자신 구매 제외)로 타입 2개를 함께 생성announcement: 전체 공지 (관리자 → 전체 유저)
notifications insert 정책은 "본인 것만"이라 관리자도 남의 user_id로 직접 insert 불가admin_broadcast_announcement(title, body) RPC(security definer, is_admin() 체크)로 우회
20260817094500_push_notifications.sqlon_notification_created: notifications insert 시점에 pg_net.http_post로 send-push Edge Function 비동기 호출send-push가 notification_id로 실재 여부를 확인하므로 위조해도 이미 있는 알림을 다시 보내는 것 이상은 불가 (내용 조작 불가)ponytail: Supabase Vault 기반 웹훉 서명으로 더 막을 수 있지만 지금 위험 수준엔 과함send-push위치: supabase/functions/send-push/index.ts
동작:
1. verify-purchase의 Google 서비스 계정 JWT 서명 패턴 재사용 (scope만 firebase.messaging으로 교체)
2. FCM HTTP v1 API (fcm.googleapis.com/v1/projects/{id}/messages:send)로 발송
3. 타입 4종만 푸시 대상(PUSHABLE): new_post, follow, sale, announcement
purchased, report, product_removed, pay_method)는 인앱 알림함에만 쌓임lib/l10n/app_*.arb의 notifNewPost/notifFollow/notifSold와 같은 문구를 Deno 쪽에 상수로 복제ponytail: 자동 동기화 없음, 문구 고치면 양쪽 다 고칠 것UNREGISTERED(404)를 주면 users.fcm_token을 지움 (죽은 토큰 재사용 방지)배포: supabase functions deploy send-push --use-api 완료
검증 (2026-08-17):
verify_jwt=false 정상 적용 (401로 막히지 않음)
20260817100000_notification_preferences.sqlDB 컬럼 추가:
users.notify_follow (기본값 true)users.notify_sale (기본값 true)users.notify_announcement (기본값 true)users.notify_new_post와 같은 방식동작:
admin_broadcast_announcement도 notify_announcement = true인 사용자만 대상으로 필터링새 화면: NotificationSettingsScreen (/me/notifications-settings)

수정된 코드:
SessionViewModel: 4가지 알림 설정 로드·토글 메서드 추가notificationSettings, notifToggleNewPosts, notifToggleFollows, notifToggleSales, notifToggleAnnouncements 등 15개 언어 × 6개 키 추가프로젝트 ID: atelier316-7a3ce
구성 파일:
google-services.json → android/app/GoogleService-Info.plist → ios/Runner/flutter pub add firebase_core firebase_messaging
버전:
firebase_core ^4.13.0firebase_messaging ^16.5.0google-services.json 추가settings.gradle.kts: com.google.gms.google-services 버전 선언 (apply false)app/build.gradle.kts: 플러그인 적용flutter build apk --debug 성공Xcode 없이 CLI로만 작업 — project.pbxproj 직접 편집
수정 사항:
1. GoogleService-Info.plist 추가 (4곳 편집):
AppFrameworkInfo.plist 패턴을 그대로 복제plutil -lint로 문법 검증iOS 배포 타깃 14.0 → 15.0 상향:
firebase_core가 iOS 15.0 이상 요구Podfile: platform :ios, '15.0'project.pbxproj 3곳: IPHONEOS_DEPLOYMENT_TARGET = 15.0UIBackgroundModes에 remote-notification 추가 (Info.plist)
Push Notifications capability(entitlements)는 아직 안 붙임
빌드 확인: flutter build ios --debug --simulator 성공
main.dart:
Firebase.initializeApp() + 백그라운드 메시지 핸들러 등록onMessageOpenedApp / getInitialMessage → product_id/seller_id 이동 (notifications.dart와 같은 규칙)Firebase.initializeApp() 자체는 config 파일만 있으면 되고 APNs와 무관SessionViewModel._registerPushToken():
getToken() → fcm_token 저장onTokenRefresh 구독부수 수정:
logout()이 fcm_token을 지우도록 추가
FIREBASE_SERVICE_ACCOUNT_JSON
등록 방법:
supabase secrets set --env-file .env.firebase
파일 처리:
.env.firebase 파일은 등록 직후 삭제확인:
supabase secrets list
검증:
send-push → FCM 경로가 이론상 전부 연결됨verify-purchase에서 이미 검증된 것과 같은 JWT 서명 패턴을 재사용 (코드 구조상 동일한 실패 지점 없음)supabase db push
확인:
supabase migration list: local = remoteusers 테이블 스키마 curl로 확인:notify_follow notify_sale notify_announcement fcm_token 채워지는지 확인pg_net 확장이 로컬 CLI 스택에도 있는지 확인 (원격은 이미 create extension 성공)firebase_messaging 패키지 하나로 양쪽 다 됨users.fcm_token 컬럼 하나 (기기 1대 기준)ponytail: 여러 기기 동시 로그인 지원이 필요해지면 device_tokens 테이블로 승격supabase/migrations/20260817090000_notify_follow_and_sale.sql (신규)supabase/migrations/20260817094500_push_notifications.sql (신규)supabase/migrations/20260817100000_notification_preferences.sql (신규)supabase/functions/send-push/index.ts (신규)lib/main.dart (수정: Firebase 초기화, 백그라운드 메시지 핸들러, 탭 시 라우팅)lib/presentation/viewmodels/session_viewmodel.dart (수정: 4가지 알림 설정 로드·토글, FCM 토큰 등록·해제)lib/presentation/screens/me/notification_settings_screen.dart (신규)lib/presentation/screens/me/mypage.dart (수정: 기존 인라인 알림 스위치 제거, 알림 설정 화면 진입점 추가)lib/presentation/router.dart (수정: /me/notifications-settings 라우트 추가)lib/l10n/app_*.arb (15개 언어 × 7개 키 추가/수정)android/app/google-services.json (신규)android/settings.gradle.kts (수정: Google Services 플러그인 버전 선언)android/app/build.gradle.kts (수정: 플러그인 적용)ios/Runner/GoogleService-Info.plist (신규)ios/Runner.xcodeproj/project.pbxproj (수정: plist 파일 참조 4곳 추가, iOS 타깃 15.0)ios/Podfile (수정: platform :ios, '15.0')ios/Runner/Info.plist (수정: UIBackgroundModes 추가)backend-design.md (갱신: §4-5 푸시 알림 항목 전체 재작성)supabase db push 성공migration list: local = remoteusers 테이블 스키마 curl 확인: 200, 새 컬럼 존재send-push 배포 완료verify_jwt=false 정상 적용flutter build apk --debug 성공flutter build ios --debug --simulator 성공dart analyze: 이슈 없음flutter test: 43개 테스트 전부 통과flutter pub add flutter_local_notifications
버전: flutter_local_notifications ^22.3.0
파일: lib/main.dart
final _localNotifications = FlutterLocalNotificationsPlugin();
const _androidChannel = AndroidNotificationChannel(
'default', '일반 알림',
description: '신작 게시·구독·판매·공지 알림',
importance: Importance.high,
);
Future<void> _initLocalNotifications() async {
await _localNotifications.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
iOS: DarwinInitializationSettings(
requestAlertPermission: false, // FCM이 이미 처리
requestBadgePermission: false,
requestSoundPermission: false,
),
),
onDidReceiveNotificationResponse: (response) =>
_handlePushTapPayload(response.payload),
);
await _localNotifications
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(_androidChannel);
}
핵심 포인트:
firebase_messaging이 이미 처리했으므로 중복 방지high로 설정하여 배너 표시 보장onDidReceiveNotificationResponseFirebaseMessaging.onMessage.listen(_showForegroundNotification);
앱 부트스트랩 시 onMessage 스트림을 구독하여 foreground 메시지를 처리.
void _showForegroundNotification(RemoteMessage message) {
final n = message.notification;
if (n == null) return;
final productId = message.data['product_id'];
final sellerId = message.data['seller_id'];
final payload = productId != null
? 'p:$productId'
: (sellerId != null ? 's:$sellerId' : null);
_localNotifications.show(
id: n.hashCode,
title: n.title,
body: n.body,
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
_androidChannel.id, _androidChannel.name,
channelDescription: _androidChannel.description,
importance: Importance.high,
priority: Priority.high,
),
iOS: const DarwinNotificationDetails(),
),
payload: payload,
);
}
핵심 로직:
notification 필드에서 제목/본문 추출data 필드에서 딥링크 정보(product_id, seller_id) 추출p:<product_id> 또는 s:<seller_id>void _handlePushTapPayload(String? payload) {
if (payload == null) {
_cached?.push('/me');
} else if (payload.startsWith('p:')) {
_cached?.push('/detail/${payload.substring(2)}');
} else if (payload.startsWith('s:')) {
_cached?.push('/seller/${payload.substring(2)}');
}
}
라우팅 규칙:
p:<id> → 상품 상세 화면 (/detail/:id)s:<id> → 작가 페이지 (/seller/:id)null → 마이 페이지 (/me)로컬 알림을 선택한 이유:
1. 플랫폼 제약: FCM foreground 메시지는 OS가 자동 배너를 띄워주지 않음
2. 일관된 UX: 백그라운드/foreground 모두 같은 스타일의 알림 제공
3. 딥링크 지원: 알림 탭 시 payload를 통해 정확한 화면으로 라우팅
$ dart analyze
No issues found!
$ flutter test
00:03 +43: All tests passed!

send-push Edge Function 배포 및 에러 처리 검증flutter_local_notifications)