Skip to content

[FEAT] FCM 푸시 알림 구현 (Android / iOS) - #356

Merged
jeonbinggu merged 18 commits into
developfrom
feat/fcm-push-notification(#345)
Aug 11, 2026
Merged

[FEAT] FCM 푸시 알림 구현 (Android / iOS)#356
jeonbinggu merged 18 commits into
developfrom
feat/fcm-push-notification(#345)

Conversation

@jeonbinggu

Copy link
Copy Markdown
Contributor

🔀 Pull Request Title

feat: FCM 푸시 알림 구현 (Android / iOS)


🎞️ 주요 코드 설명

iOS는 FCM 토큰을 별도로 받아와야 한다

@capacitor/push-notificationsregistration 이벤트가 iOS에서는 FCM 등록 토큰이 아니라 APNs 디바이스 토큰을 반환합니다. 서버는 FCM으로 발송하므로 이 값으로는 전송이 불가능해, FCM 토큰을 따로 조회하는 네이티브 브릿지(FindersFcmPlugin)를 iOS에만 추가했습니다.

const fcmToken =
  platform === "IOS" ? (await FindersFcm.getToken()).token : token.value;

Android는 registration 값이 이미 FCM 토큰이라 기존 경로를 그대로 씁니다. 서버 입장에서는 양쪽 다 FCM 토큰이므로 구분해서 처리할 필요가 없습니다.

이 때문에 iOS에도 Firebase SDK와 GoogleService-Info.plist를 연동했습니다.

리스너 등록 레이스 컨디션

requestPermissions()가 비동기라, 응답을 기다리는 사이 effect가 재실행되거나 unmount되면 리스너가 제거된 뒤에 register()가 호출됩니다. Capacitor는 registration 이벤트를 버퍼링하지 않아 토큰이 유실됩니다. 만료된 세션으로 앱을 실행할 때 실기기 로그에서 재현되었습니다.

let cancelled = false;

PushNotifications.requestPermissions().then((result) => {
  if (cancelled) return;
  if (result.receive === "granted") PushNotifications.register();
});

return () => {
  cancelled = true;
  // ...리스너 제거
};

회귀 테스트 2건을 함께 추가했습니다 (usePushNotifications.test.ts).

토큰 해제는 accessToken이 살아있을 때

useLogout 내부 onSuccesstokenStorage.clear()를 먼저 호출하기 때문에, onSettled에서 보낸 해제 요청은 Authorization 헤더 없이 나가 항상 401이었습니다. mutateAsync로 바꿔 onMutate 시점에 해제를 끝냅니다. 해제 실패가 로그아웃·탈퇴 자체를 막지는 않습니다.


📌 PR 설명

이번 PR에서 어떤 작업을 했는지 요약해주세요.

  • 기기 토큰 등록/해제 API 클라이언트 및 타입 추가 (POST/DELETE /notifications/device-tokens)
  • 푸시 권한 요청·리스너 등록·딥링크 이동 훅 추가, RootLayout 연동 (로그인 상태에서만 활성화)
  • Android FCM 네이티브 설정 (google-services.json, POST_NOTIFICATIONS 권한)
  • iOS FCM 네이티브 설정 (Firebase SDK, Push / Background Modes capability, APNs 콜백)
  • iOS FCM 등록 토큰 브릿지 추가 (FindersFcmPlugin)
  • 로그아웃 / 탈퇴 시 기기 토큰 해제 연동
  • iOS 포그라운드 알림이 표시되지 않던 문제 수정 (presentationOptions)
  • 리스너 등록 레이스 컨디션 수정 + 회귀 테스트
  • 로그아웃 · 탈퇴 시 토큰 해제 401 수정
  • Android 상태바 알림 아이콘 지정
  • iOS FCM 토큰 회전 대응 (tokenRefresh)
  • 매칭 실패 라우트 방어 (딥링크가 잘못된 경로를 보낼 때 빈 화면 방지)

📷 스크린샷

UI 변경 없음. 기존 화면의 레이아웃 · 스타일은 그대로이며, 알림 표시는 OS가 그립니다.


✅ 확인 부탁드립니다

리뷰 포인트

  • Router.tsx의 catch-all이 /mainpage로 보내도록 되어 있습니다. 목적지가 적절한지 봐주세요 (404 화면은 새 디자인이 필요해 만들지 않았습니다).
  • Android 알림 아이콘은 @mipmap/ic_launcher_foreground(배경 없는 로고 레이어)를 씁니다. ic_launcher는 배경이 포함돼 실루엣 처리 시 흰 사각형이 됩니다. 실기기에서 상태바 모양 확인이 필요하며, 뭉개지면 단색 실루엣 에셋이 필요합니다.

머지 전 필요

  • 웹 코드 변경이므로 네이티브 반영에 pnpm cap:sync가 필요합니다.
  • GoogleService-Info.plist는 gitignore 처리되어 있습니다. 로컬 iOS 빌드 시 Firebase 콘솔에서 받아 ios/App/App/에 두어야 합니다.

미검증

  • 실기기 배달 · 딥링크 · 콜드 스타트 탭은 아직 확인하지 못했습니다. TestFlight 업로드 후 진행 예정입니다.
  • 백엔드 요청사항(발송 페이로드의 notification 블록 포함 여부, data.route 경로 계약 등)은 별도 정리해 전달했습니다.

jeonbinggu and others added 11 commits July 13, 2026 01:19
@capacitor/push-notifications 설치 및 cap sync 반영, POST_NOTIFICATIONS
런타임 권한 추가. google-services.json은 자격증명이라 gitignore 처리.
FCM 기기 토큰 등록(POST)/해제(DELETE) 엔드포인트 연동을 위한
타입 정의와 axiosInstance 기반 API 클라이언트를 추가.
로그인 상태일 때 FCM 권한 요청, 토큰 등록, 포그라운드 수신,
알림 탭 시 data.route 딥링크 이동까지 처리하는 usePushNotifications
훅을 추가하고 RootLayout에서 로그인 상태 기반으로 활성화.
로그아웃/탈퇴 시 토큰 해제에 쓸 마지막 FCM 토큰은 별도 store에 보관.
로그아웃, 회원 탈퇴 성공 시 마지막으로 등록된 FCM 토큰을 해제 API로
전달. 해제 API가 실제로 성공했을 때만 로컬 토큰 참조를 비워서,
실패 시 재시도할 수 있는 여지를 남김.
CapacitorPushNotifications + FirebaseMessaging pod 연동, Push
Notifications / Background Modes capability, AppDelegate의 APNs 등록
콜백 연결.

- Firebase swizzling은 끈다(FirebaseAppDelegateProxyEnabled=false).
  켜두면 Firebase가 UNUserNotificationCenterDelegate를 가로채
  Capacitor notificationRouter와 경합한다.
- APNs 환경은 자동 판별에 맡기지 않고 빌드 구성으로 고정한다. production
  으로 오판되면 개발 빌드에서 푸시가 에러 없이 유실된다.
- FirebaseMessaging pod은 capacitor_pods 블록 밖에 둔다. 안에 넣으면
  cap sync가 블록을 재생성하며 지운다.
- GoogleService-Info.plist는 레포가 public이라 gitignore한다. Firebase
  콘솔(finders-firebase)에서 받아 Xcode에 추가해야 빌드된다.
iOS의 @capacitor/push-notifications registration 이벤트는 FCM 토큰이
아니라 APNs 디바이스 토큰(대문자 hex 64자)을 준다. 서버는 FCM으로
발송하므로 그대로 올리면 iOS만 전건 실패한다. FCM 등록 토큰을 따로
받아오는 네이티브 브릿지를 추가하고 훅에서 iOS만 분기한다.

Android는 registration 값이 이미 FCM 토큰이라 기존 경로를 유지한다.

앱 로컬 플러그인은 자동 탐지되지 않으므로 MainViewController의
capacitorDidLoad에서 등록해야 한다(FindersBillingPlugin과 동일).
빠뜨리면 호출 시 UNIMPLEMENTED로 실패한다.

실기기 검증: APNs 5502582F... / FCM eN7p0LFk...:APA91b... 로 서로 다른
값이 발급되고 POST /notifications/device-tokens 200 확인.
presentationOptions 미설정 시 @capacitor/push-notifications의 iOS 구현이
빈 배열을 반환해 배너/사운드/배지가 전부 표시되지 않는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
requestPermissions가 비동기라 응답 전에 effect가 재실행/unmount되면
리스너가 제거된 뒤 register()가 호출되어 registration 이벤트가 유실된다
(Capacitor는 이 이벤트를 버퍼링하지 않음).
cancelled 플래그로 차단하고 회귀 테스트를 추가한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
useLogout 내부 onSuccess가 tokenStorage.clear()를 먼저 호출해
onSettled에서 보낸 해제 요청이 Authorization 없이 나갔다.
mutateAsync로 accessToken이 살아있는 시점에 해제를 끝낸 뒤 진행한다.
해제 실패가 로그아웃·탈퇴 자체를 막지는 않는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Android 상태바 알림 아이콘 지정. 미지정 시 배경이 포함된 런처 아이콘이
  실루엣 처리되어 흰 사각형으로 표시된다. 배경 없는 로고 레이어를 쓴다.
- iOS FCM 토큰 회전 대응. Android는 registration 이벤트가 다시 발생해
  자동 갱신되지만 iOS는 그 경로가 없어 서버에 죽은 토큰이 남았다.
  FindersFcmPlugin이 MessagingDelegate를 잡아 tokenRefresh로 넘긴다.
- 매칭 실패 라우트를 메인으로 보낸다. 푸시 딥링크(data.route)가 잘못된
  경로를 보내면 에러 없이 빈 화면이 됐다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
finders Ready Ready Preview Aug 11, 2026 1:27pm

@jeonbinggu jeonbinggu self-assigned this Aug 9, 2026
@jeonbinggu jeonbinggu added chore 기타 작업(패키지 등) feature 새 기능 추가 refactor 내부 구조 개선(가독성,확장성,유지보수성) labels Aug 9, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude 자동 리뷰
⚠️ 이슈 2건 (Critical 0 / Major 2 / Minor 0)
검사 범위: 로직 오류 · 엣지케이스/런타임 · 컨벤션 준수(CLAUDE.md, .claude/skills/tdd-clean-arch/SKILL.mdreferences/{directory-structure,state-management,routing-forms-http,testing-guide}.md) · 정적 검사 tsc -b 통과, pnpm lint 통과, vitest 2/2 통과


Generated by Claude Code

Comment thread android/.gitignore

# Google Services (e.g. APIs or Firebase)
# google-services.json
google-services.json

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] google-services.json을 새로 gitignore 처리했는데 파일이 리포에 커밋되어 있지 않아, 클린 체크아웃/CI에서 Android 푸시가 빌드 에러 없이 전건 실패합니다.

확인한 사실:

  • 이 줄의 변경은 # google-services.jsongoogle-services.json, 즉 무시 시작입니다. git check-ignore -v android/app/google-services.jsonandroid/.gitignore:69 매칭.
  • git ls-files, base 트리(982c471), 워킹트리 어디에도 google-services.json이 없습니다.
  • android/app/build.gradle 말미의 Capacitor 템플릿은 try { if (servicesJSON.text) { apply plugin: 'com.google.gms.google-services' } } catch(Exception e) { logger.info(...) } 이라, 파일이 없으면 플러그인 적용을 조용히 건너뛰고 빌드는 성공합니다.

실패 시나리오: 다른 개발자나 CI가 이 브랜치를 클론 → pnpm cap:sync:androidgradlew assembleRelease 성공 → 그러나 APK에 google_app_id 등 Firebase 리소스가 없어 FirebaseApp 초기화 실패 → 기기 토큰이 서버에 한 번도 등록되지 않음. iOS는 AppDelegate.swiftFirebaseApp.configure()가 요란하게 실패하지만 Android는 무증상이라 원인 추적이 어렵습니다. .github/workflows/main.yml에도 이 파일을 주입하는 단계가 없습니다.

PR 본문의 "머지 전 필요"에는 iOS GoogleService-Info.plist 안내만 있고 Android는 언급이 없으며, 체크리스트의 "Android FCM 네이티브 설정 (google-services.json, POST_NOTIFICATIONS 권한)"은 완료로 표시되어 있습니다.

제안: iOS GoogleService-Info.plist와 동일하게 PR 본문·README에 "Firebase 콘솔에서 받아 android/app/에 두어야 한다"는 안내를 추가하고, CI에서 Android 빌드를 한다면 시크릿으로 주입하는 단계를 넣어주세요. (파일을 gitignore하는 선택 자체는 문제 삼지 않습니다.)


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이미 312브랜치에서 google_services.json 을 주입 하는 플로우를 추가하고 시크릿에도 등록되어 있음 빌드 완료 사실도 확인 된 것임

Comment thread src/hooks/notifications/usePushNotifications.ts Outdated
mutate는 rejection을 삼키고(useMutation 내부 catch(noop)), 위쪽 try/catch는
FindersFcm.getToken()만 커버해 POST 실패가 로그도 재시도도 없이 사라졌다.
effect deps가 모두 안정 참조라 재실행도 없어, 콜드 스타트 시점에 5xx나
네트워크 끊김이 한 번 발생하면 그 세션 내내 푸시를 받지 못했다.

- retry 2회 추가. release 빌드는 JS console이 logcat에 안 찍혀 로깅만으로는
  실사용 환경에서 복구가 안 된다
- onError로 실패를 노출
- setPushToken을 onSuccess로 옮겨, 서버가 성공을 확인했을 때만 로컬 참조를
  채운다. 해제 쪽과 규칙이 어긋나 있었다
- 성공/실패 시 로컬 참조 동작 회귀 테스트 2건 추가

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@MlNTYS MlNTYS left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

리뷰 확인 부탁드립니다! 특히 지금 리프레시 토큰 검증 실패로 로그아웃 하는 경우 unregister가 동작하지 않는 것 같습니다!

// 로그아웃/탈퇴 시 기기 토큰 해제(unregister) API 호출에 사용하기 위해
// 마지막으로 등록된 FCM 토큰 값을 메모리에 보관한다 (영속화 불필요)
export const usePushTokenStore = create<PushTokenState>((set) => ({
token: null,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

혹시라도 store 값이 null로 남는 경우 unregister를 그냥 스킵할거 같은데, 스토어 유무에 의존하지 않고 unregister를 하도록 하면 좋을 것 같습니다

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

리프레스 토큰이 무효나 만료가 되었을 때 서버가 device-token도 같이 정리하게끔 해주는게 좋을 것 같습니다! (a) 권한 거부, (b) 서버 등록 실패, (c) 등록 완료 전 로그아웃 이 경우 중에 누수가 될 수 있는 경우는 (c)뿐이라 안드로이드 경우에는 네이티브 플러그인을 새로짜야하기 때문에 백엔드에서 토큰 정리를 해주는게 나을 것 같습니다!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test 파일은 다 사용 하셨으면 지워도 괜찮을 것 같습니다

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fcm 실기기 테스트 통과 후에 지우도록 하겠습니다

Comment on lines +93 to +98
const route = action.notification.data?.route;
if (typeof route === "string" && route.length > 0) {
navigate(route);
}
},
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이쪽에 정규화 하나 붙혀서 가드 추가해도 좋을거 같네요, 그냥 앞에 '/' 없이 평문으로 오는 경우 Router가 상대 경로로 해석해서 의도치 않은 화면으로 갈 수 있을 것 같습니다

Comment on lines +46 to +78
const registrationHandle = PushNotifications.addListener(
"registration",
async (token) => {
// iOS의 registration 값은 APNs 디바이스 토큰이라 FCM 발송에 쓸 수 없다.
// 네이티브 브릿지로 FCM 등록 토큰을 따로 받아온다 (Android는 이 값이 이미 FCM 토큰)
try {
const fcmToken =
platform === "IOS"
? (await FindersFcm.getToken()).token
: token.value;

registerDeviceToken({ token: fcmToken, platform });
} catch (error) {
console.error("FCM 기기 토큰 조회 실패", error);
}
},
);

const registrationErrorHandle = PushNotifications.addListener(
"registrationError",
(error) => {
console.error("FCM 기기 토큰 등록 실패", error);
},
);

// Android는 토큰이 회전하면 registration 이벤트가 다시 발생하지만 iOS는 그 경로가 없어
// 네이티브 브릿지가 대신 알려준다. 갱신하지 않으면 서버에 죽은 토큰이 남는다
const tokenRefreshHandle =
platform === "IOS"
? FindersFcm.addListener("tokenRefresh", ({ token }) => {
registerDeviceToken({ token, platform });
})
: null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

정확히는 53번 쯤 이랑 73번쯤. 지금 초기 토큰 발급 시 둘 다 동작 하는데, 기존 토큰이랑 비교하는 부분이 없어 보이네요. store값ㄱ과 같으면 스킵하는 방식이나 iOS는 하나의 경로만 사용하게 수정하는게 좋아 보입니다

jeonbinggu and others added 2 commits August 10, 2026 12:01
토큰 재발급 실패 경로가 tokenStorage.clear()만 하고 앱 상태를 그대로 둬서,
토큰은 사라졌는데 로그인된 것처럼 보이는 세션이 남았다.
LoginPage에 도달해야만 clearUser()가 불렸다.

토큰을 버리는 세 경로를 clearSession()으로 모으고,
RootLayout이 등록한 콜백으로 캐시 정리 + 로그인 화면 이동을 처리한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- data.route가 슬래시 없이 오면 Router가 현재 위치 기준 상대 경로로
  해석해 엉뚱한 화면으로 이동했다. 절대 경로만 허용하고 //host도 차단.
- iOS는 초기 토큰 발급 시 registration과 tokenRefresh가 같은 값으로
  둘 다 발화해 등록 요청이 두 번 나갔다. 보낸 시점 기준으로 중복을 막고,
  실패 시·계정 전환 시에는 다시 등록할 수 있게 참조를 비운다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jeonbinggu and others added 3 commits August 10, 2026 16:26
서버가 탈퇴 시점에 accessToken을 즉시 무효화하므로 withdraw 성공 후에
나가는 DELETE /notifications/device-tokens는 도달할 수 없었다.
기기 토큰은 서버가 탈퇴 트랜잭션 안에서 삭제한다(BE #712).

로그아웃 쪽 해제 호출은 onMutate에서 토큰이 살아있을 때 나가므로 유지한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
서버가 경로 문자열 대신 action + resourceId를 보낸다(BE #714). 관리자가
입력한 경로에 오타가 나도 앱의 fallback이 삼켜 실패 신호가 남지 않던 문제를
서버 DTO 검증으로 옮기기 위한 계약 변경.

action이 없는 경우(계약 이전 서버)와 모르는 action(앱보다 나중에 추가된 값)을
구분한다. 전자를 /mainpage로 보내면 배포 시차 구간에 모든 알림 탭이 홈으로
튕기므로 이동하지 않는다.

경로를 서버 문자열이 아니라 매퍼가 만들게 되어 기존 절대경로 검사를 대체한다.
resourceId는 encodeURIComponent로 감싼다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
앱이 알림 채널을 만들지 않아 FCM이 fallback 채널(importance DEFAULT)로
떨어뜨리고 있었다. Android 8+에서는 채널 importance가 메시지 priority를
이기므로, 서버가 high priority를 보내도 헤즈업 배너와 소리가 나오지 않는다.

IMPORTANCE_HIGH 채널을 만들고 manifest의 default_notification_channel_id로
등록한다. 채널명은 시스템 알림 설정에 노출되는 값이라 확정 전까지 임시.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jeonbinggu
jeonbinggu merged commit f428e92 into develop Aug 11, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

chore 기타 작업(패키지 등) feature 새 기능 추가 refactor 내부 구조 개선(가독성,확장성,유지보수성)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEAT] FCM 푸시 알림 프론트엔드 연동 (Capacitor)

3 participants