Fix(client): FCM알람 커스텀 및 api instance 수정#162
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughFCM service worker now uses Firebase Messaging’s onBackgroundMessage for notifications. Axios request interceptors in client and extension were reworked to conditionally fetch/refresh tokens using stored email, handle failures by clearing storage and redirecting to onboarding, and set Authorization headers accordingly. Vercel rewrite was added for the service worker path. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor U as User Device
participant SW as Service Worker (client)
participant FM as Firebase Messaging
participant NB as Notification UI
U->>FM: Receives push via FCM
FM-->>SW: Background message payload
Note over SW: onBackgroundMessage handler (new)
SW->>NB: showNotification(title, body)
NB-->>U: Displays notification
sequenceDiagram
autonumber
participant App as Client/Extension App
participant AX as Axios Request Interceptor
participant LS as localStorage
participant AUTH as Auth API (refreshToken)
participant NAV as Router/Location
App->>AX: Outgoing request
AX->>AX: Check isNoAuth
alt Auth required
AX->>LS: Get email (+possibly token)
alt Email present
AX->>AUTH: refreshToken(email)
alt Success
AUTH-->>AX: New token
AX->>LS: Store token
AX->>AX: Set Authorization: Bearer <token>
else Failure
AX->>LS: Clear token/email
AX->>NAV: Redirect to /onboarding
AX-->>App: Throw error
end
else No email
AX-->>App: Throw "토큰이 없습니다..." and/or redirect
end
else No-auth path
AX->>AX: Bypass token logic
end
AX-->>App: Proceed with request
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
Pre-merge checks and finishing touches❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing touches
🧪 Generate unit tests
Tip 👮 Agentic pre-merge checks are now available in preview!Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.
Please see the documentation for more information. Example: reviews:
pre_merge_checks:
custom_checks:
- name: "Undocumented Breaking Changes"
mode: "warning"
instructions: |
Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal).Please share your feedback with us on this Discord post. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
✅ Storybook chromatic 배포 확인: |
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/extension/src/apis/axiosInstance.ts (1)
76-79: 하드코드된 이메일로 토큰 재발급 시도 — 사용자 계정 오염/오동작 위험.응답 인터셉터에서
fetchToken('test@gmail.com')은 치명적입니다. 저장된 이메일로 재발급하도록 수정하세요. 이메일이 없으면 온보딩 유도 로직으로 위임하세요.- const newToken = await fetchToken('test@gmail.com'); + // chrome.storage에서 이메일 조회 + const email = await new Promise<string | undefined>((resolve) => { + chrome.storage.local.get('email', (result) => resolve(result.email)); + }); + if (!email) { + return Promise.reject(new Error('이메일 정보가 없어 토큰 재발급에 실패했습니다. 온보딩을 먼저 완료해주세요.')); + } + const newToken = await fetchToken(email); originalRequest.headers.Authorization = `Bearer ${newToken}`; return apiRequest(originalRequest);형 안전성을 위해
_retry속성 타입 확장도 권장합니다(별도 d.ts):// types/axios.d.ts import 'axios'; declare module 'axios' { interface InternalAxiosRequestConfig { _retry?: boolean; } }
🧹 Nitpick comments (10)
apps/client/public/firebase-messaging-sw.js (1)
14-16: 활성화 단계에서 클라이언트 즉시 장악 권장.새 SW가 설치되면 바로 페이지를 제어하도록
clients.claim()을 호출해 주세요. 업데이트 전파가 더 빨라집니다.self.addEventListener('activate', function () { - console.log('실행중..'); + console.log('실행중..'); + self.clients && self.clients.claim && self.clients.claim(); });apps/extension/src/apis/axiosInstance.ts (2)
34-38: 매 요청 전 저장된 토큰 로드가 불필요합니다. 즉시 재발급으로 덮어씌워집니다.이 구간에서 읽은
token은 곧바로fetchToken(email)결과로 대체되어 의미가 없습니다. 제거해도 동작 동일하며 비용을 줄일 수 있습니다.- let token = await new Promise<string | undefined>((resolve) => { - chrome.storage.local.get('token', (result) => { - resolve(result.token); - }); - }); + let token: string | undefined;
25-29: isNoAuth 이중 체크 정리로 가독성 개선.이미 Line 28에서 early return 하므로 Line 40의
if (!isNoAuth)는 항상 참 전제입니다. 조건 중복을 제거하면 흐름이 명확해집니다.- if (isNoAuth) return config; + if (isNoAuth) return config; // ... - if (!isNoAuth) { + { // 본문 동일 }Also applies to: 40-41
apps/client/vercel.json (1)
3-6: firebase-config.js·firebase_sdk 리라이트 제거 및 SW no-cache 헤더 추가
앱 디렉토리(apps/client/public)에서 해당 파일/폴더가 발견되지 않아 해당 리라이트는 불필요합니다. 대신firebase-messaging-sw.js에Cache-Control: no-cache헤더를 설정해 SW 업데이트를 보장하세요.apps/client/src/shared/apis/setting/axiosInstance.ts (6)
57-58: 헤더 대입 시 타입/정규화 보장 필요Axios는 헤더 키를 정규화합니다. 안전하게 동작하도록 headers 초기화 + 대괄호 표기 사용을 권장합니다.
- config.headers.Authorization = `Bearer ${token}`; + config.headers = config.headers ?? {}; + (config.headers as any)['Authorization'] = `Bearer ${token}`;
11-32:refreshToken은 전용 axios 인스턴스를 사용하여 인터셉터 재귀를 원천 차단하세요.기본
axios에 다른 곳에서 인터셉터가 붙으면 재귀/루프가 생길 수 있습니다. 타임아웃도 설정 권장.예시:
const authClient = axios.create({ baseURL: import.meta.env.VITE_BASE_URL, headers: { 'Content-Type': 'application/json' }, timeout: 5000, }); // 내부에서 axios 대신 authClient 사용 const response = await authClient.get('/api/v1/auth/token', { params: { email } });
53-55: 이메일 없음 분기에서도 일관되게 정리/리다이렉트 권장요청 인터셉터의 실패 분기(라인 46-51)는 정리 후 리다이렉트하지만, 이 분기는 단순 throw만 합니다. UX 일관성을 위해 동일 처리 권장.
- console.error('토큰이 없습니다. 온보딩을 먼저 완료해주세요.'); - throw new Error('토큰이 없습니다. 온보딩을 먼저 완료해주세요.'); + console.error('토큰/이메일이 없습니다. 온보딩을 먼저 완료해주세요.'); + localStorage.removeItem('token'); + localStorage.removeItem('email'); + window.location.href = '/onboarding'; + throw new Error('인증 정보 없음');
22-24: 보안 관점: 액세스 토큰의 localStorage 보관은 XSS 노출면이 큽니다.장기적으로 httpOnly/secure 쿠키(또는 WebAuthn 등)로 전환을 검토하세요. 당장은 CSP 강화, DOM XSS 가드(라이브러리/린팅) 적용을 권장합니다.
36-37:noAuthNeeded중복 선언 제거 제안두 인터셉터에서 동일 상수를 중복 정의하고 있습니다. 모듈 상단으로 끌어올려 단일 소스로 관리하면 누락/드리프트를 방지할 수 있습니다.
const NO_AUTH_PATHS = ['/api/v1/auth/token', '/api/v1/auth/signup']; // ...요청/응답 인터셉터에서 NO_AUTH_PATHS 사용Also applies to: 68-71
63-105: 응답 인터셉터:_retry타입 보강·헤더 초기화 권장
src/types/axios.d.ts에 아래 선언 추가해AxiosRequestConfig를 확장하세요.declare module 'axios' { export interface AxiosRequestConfig { _retry?: boolean; } }- 오류 발생 시
originalRequest.headers가undefined일 수 있어, 다음처럼 초기화 후 대괄호 표기법으로 설정하세요.- originalRequest.headers.Authorization = `Bearer ${newToken}`; + originalRequest.headers = originalRequest.headers ?? {}; + (originalRequest.headers as any)['Authorization'] = `Bearer ${newToken}`;- 코드베이스 전반을 확인한 결과 이 파일 외에 전역
request/response인터셉터가 없어 재귀 호출 위험은 없습니다.
📜 Review details
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (4)
apps/client/public/firebase-messaging-sw.js(1 hunks)apps/client/src/shared/apis/setting/axiosInstance.ts(1 hunks)apps/client/vercel.json(1 hunks)apps/extension/src/apis/axiosInstance.ts(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/client/public/firebase-messaging-sw.js (1)
apps/client/src/firebase-config.ts (1)
firebaseConfig(1-9)
🪛 GitHub Check: lint
apps/extension/src/apis/axiosInstance.ts
[warning] 45-45:
Unexpected console statement
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: storybook
🔇 Additional comments (2)
apps/extension/src/apis/axiosInstance.ts (2)
45-50: 스토리지 정합성 및 확장 환경용 리디렉션 방식 적용 필요
- 토큰·이메일 삭제는
localStorage가 아니라chrome.storage.local.remove(['token','email'])로 통일하고, 로깅은import.meta.env.DEV에서만 남기세요.window.location.href대신 확장 API(chrome.runtime.openOptionsPage()또는chrome.tabs.create({ url: chrome.runtime.getURL('index.html#/onboarding') }))를 사용해 리디렉션하고, 실제 라우팅 방식(내장 해시 라우터 vs 외부 온보딩 페이지 https://www.pinback.today/onboarding) 및 배포 구조에 맞춰 경로를 확정해야 합니다.
45-45: ESLint 설정에서no-console규칙 확인 필요
CI 환경에서console.error가 lint 에러로 처리되는지, 사용 중인packages/eslint-config/base.js의rules.no-console설정을 검토하세요. 적용된 규칙에 따라 개발 모드 조건부 래핑(import.meta.env.DEV)을 도입하거나 로거 유틸로 대체해야 합니다.
| importScripts( | ||
| './firebase_sdk/firebase-app-compat.js', | ||
| './firebase_sdk/firebase-messaging-compat.js', | ||
| './firebase-config.js' | ||
| ); |
There was a problem hiding this comment.
🧩 Analysis chain
firebase-config 로딩 방식 점검 필요 (런타임 ReferenceError 위험).
importScripts('./firebase-config.js')는 글로벌 스코프에 firebaseConfig 변수가 정의되어 있어야 합니다. 현재 레포에는 src/firebase-config.ts만 보이며, 번들 산출물이 public/firebase-config.js로 생성되지 않으면 SW에서 firebaseConfig 참조 시 즉시 실패합니다. 또한 이 파일과 firebase_sdk/*가 SPA 캐치올 리라이트의 영향을 받지 않는지 확인이 필요합니다.
다음으로 존재/형식을 확인해 주세요(전역 변수 할당 여부까지 점검):
가능하면 public/firebase-config.js는 다음과 같이 전역에 주입되도록 유지하세요.
// apps/client/public/firebase-config.js
self.firebaseConfig = { /* ...env에서 주입된 값... */ };🏁 Script executed:
#!/bin/bash
# public/firebase-config.js 유무 및 전역 변수 정의 확인
if fd -a '^firebase-config\.js$' apps/client/public >/dev/null; then
echo "[OK] apps/client/public/firebase-config.js 존재"
echo "---- 파일 앞부분 ----"
head -n 20 apps/client/public/firebase-config.js || true
echo "--------------------"
else
echo "[ERROR] apps/client/public/firebase-config.js 없음"
fi
# SDK 파일 존재 확인
fd -a '^firebase-app-compat\.js$' apps/client/public/firebase_sdk
fd -a '^firebase-messaging-compat\.js$' apps/client/public/firebase_sdkLength of output: 418
firebase-config.js 및 SDK 파일 경로와 전역 변수 정의를 확인하려면 아래 스크립트를 실행해 주세요.
🏁 Script executed:
#!/bin/bash
echo "=== firebase-config.js 위치 및 내용 확인 ==="
config_path=$(fd -t f 'firebase-config.js' | head -n1)
if [ -n "$config_path" ]; then
echo "[FOUND] $config_path"
echo "---- 상위 20줄 ----"
head -n 20 "$config_path"
echo "---- 'firebaseConfig' 검색 ----"
grep -R "firebaseConfig" "$config_path" || echo "[WARN] 'firebaseConfig' 할당 미발견"
else
echo "[ERROR] firebase-config.js 파일을 찾을 수 없음"
fi
echo -e "\n=== firebase-app-compat.js 위치 ==="
fd -t f 'firebase-app-compat.js' || echo "[ERROR] 해당 파일 없음"
echo -e "\n=== firebase-messaging-compat.js 위치 ==="
fd -t f 'firebase-messaging-compat.js' || echo "[ERROR] 해당 파일 없음"
echo -e "\n=== firebase_sdk 디렉터리 및 내부 파일 목록 확인 ==="
sdk_dir=$(fd -t d 'firebase_sdk' | head -n1)
if [ -n "$sdk_dir" ]; then
echo "[FOUND DIR] $sdk_dir"
echo "---- 내부 파일 ----"
ls "$sdk_dir" || true
else
echo "[ERROR] firebase_sdk 디렉터리를 찾을 수 없음"
fiLength of output: 228
apps/client/public에 firebase-config.js 및 firebase-sdk 파일이 없음
importScripts에서 참조하는 firebase-config.js, firebase-app-compat.js, firebase-messaging-compat.js 파일이 존재하지 않아 서비스 워커 로딩 시 ReferenceError가 발생합니다. 빌드 프로세스가 해당 파일들을 apps/client/public에 산출하도록 설정하거나, importScripts 경로를 올바르게 조정하세요.
🤖 Prompt for AI Agents
In apps/client/public/firebase-messaging-sw.js around lines 4 to 8, the
importScripts references firebase-config.js, firebase-app-compat.js, and
firebase-messaging-compat.js which are missing and cause a ReferenceError when
the service worker loads; fix by ensuring the build step outputs those files
into apps/client/public (or copy them into that directory), or update the
importScripts paths to point to the correct existing locations (e.g., adjust to
/static/, /assets/, or correct relative path) and verify the files are served by
the dev/prod server so the service worker can successfully import them.
| messaging.onBackgroundMessage((payload) => { | ||
| console.log('Received background message ', payload); | ||
|
|
||
| const notificationTitle = payload.notification?.title ?? '알림이 도착했어요!'; | ||
| const notificationOptions = { | ||
| body: resultData.body, | ||
| body: payload.notification?.body, | ||
| }; | ||
| console.log(resultData.title, { | ||
| body: resultData.body, | ||
| }); | ||
| e.waitUntil( | ||
| self.registration.showNotification(notificationTitle, notificationOptions) | ||
| ); | ||
| self.registration.showNotification(notificationTitle, notificationOptions); | ||
| }); |
There was a problem hiding this comment.
onBackgroundMessage는 “data‑only” 메시지에만 호출됩니다. click 핸들러와 data fallback을 추가하세요.
payload.notification 필드가 포함된 푸시는 브라우저가 자체 표시하고 SW 콜백이 호출되지 않는 경우가 많습니다. 커스텀 UI가 필요하면 서버에서 data‑only 형태로 전송하고, SW에서는 payload.data도 처리하세요. 또한 알림 클릭 시 목적지로 이동하도록 notificationclick 핸들러를 추가하세요.
messaging.onBackgroundMessage((payload) => {
console.log('Received background message ', payload);
- const notificationTitle = payload.notification?.title ?? '알림이 도착했어요!';
- const notificationOptions = {
- body: payload.notification?.body,
- };
- self.registration.showNotification(notificationTitle, notificationOptions);
+ const titleFromData = payload.data && (payload.data.title || payload.data.notificationTitle);
+ const bodyFromData = payload.data && (payload.data.body || payload.data.notificationBody);
+ const notificationTitle =
+ payload.notification?.title ?? titleFromData ?? '알림이 도착했어요!';
+ const notificationOptions = {
+ body: payload.notification?.body ?? bodyFromData ?? '',
+ data: payload.data || {},
+ // 아이콘/배지 등 필요 시 추가
+ // icon: '/icons/icon-192.png',
+ // badge: '/icons/badge-72.png',
+ };
+ self.registration.showNotification(notificationTitle, notificationOptions);
});
+
+// 알림 클릭 시 포커스/열기
+self.addEventListener('notificationclick', (event) => {
+ event.notification.close();
+ const url = event.notification?.data?.url || event.notification?.data?.click_action || '/';
+ event.waitUntil(
+ (async () => {
+ const allClients = await clients.matchAll({ type: 'window', includeUncontrolled: true });
+ const existing = allClients.find((c) => c.url.includes(url));
+ if (existing) return existing.focus();
+ return clients.openWindow(url);
+ })()
+ );
+});| let token = localStorage.getItem('token'); | ||
| const email = localStorage.getItem('email'); | ||
| if (email) { | ||
| try { | ||
| token = await refreshToken(email); | ||
| } catch (err) { | ||
| console.error('요청 인터셉터에서 토큰 재발급 실패:', err); | ||
| localStorage.removeItem('token'); | ||
| localStorage.removeItem('email'); | ||
| window.location.href = '/onboarding'; | ||
| throw err; | ||
| } |
There was a problem hiding this comment.
매 요청마다 토큰을 재발급하고 있어 서버 부하 및 중복 호출 위험이 큽니다.
email 존재 시 무조건 refreshToken을 호출해 모든 요청이 재발급을 트리거합니다. 동시 다발 요청 시 토큰 재발급이 중복으로 발생하고, 응답 인터셉터의 재시도와 겹쳐 지연 및 불필요 트래픽이 생깁니다. 토큰이 없거나 만료(또는 만료 임박)한 경우에만 재발급하고, 단일 진행(single-flight)으로 동시 요청을 합치는 게 안전합니다.
아래처럼 만료 검사 + 단일 진행으로 수정 제안합니다(요청 인터셉터 부분만 diff). 헬퍼는 별도 코드 블록 참고.
if (!isNoAuth) {
- let token = localStorage.getItem('token');
- const email = localStorage.getItem('email');
- if (email) {
- try {
- token = await refreshToken(email);
- } catch (err) {
- console.error('요청 인터셉터에서 토큰 재발급 실패:', err);
- localStorage.removeItem('token');
- localStorage.removeItem('email');
- window.location.href = '/onboarding';
- throw err;
- }
- } else {
- console.error('토큰이 없습니다. 온보딩을 먼저 완료해주세요.');
- throw new Error('토큰이 없습니다. 온보딩을 먼저 완료해주세요.');
- }
-
- config.headers.Authorization = `Bearer ${token}`;
+ let token = localStorage.getItem('token');
+ if (!token || isTokenExpired(token)) {
+ const email = localStorage.getItem('email');
+ if (!email) {
+ console.error('토큰/이메일이 없습니다. 온보딩을 먼저 완료해주세요.');
+ localStorage.removeItem('token');
+ localStorage.removeItem('email');
+ window.location.href = '/onboarding';
+ throw new Error('인증 정보 없음');
+ }
+ try {
+ if (!refreshPromise) {
+ refreshPromise = refreshToken(email).finally(() => {
+ refreshPromise = null;
+ });
+ }
+ token = await refreshPromise;
+ } catch (err) {
+ console.error('요청 인터셉터에서 토큰 재발급 실패:', err);
+ localStorage.removeItem('token');
+ localStorage.removeItem('email');
+ window.location.href = '/onboarding';
+ throw err;
+ }
+ }
+ config.headers = config.headers ?? {};
+ (config.headers as any)['Authorization'] = `Bearer ${token}`;
}헬퍼/전역 보조 코드(파일 상단 아무 곳에 추가):
let refreshPromise: Promise<string> | null = null;
function isTokenExpired(token: string, skewSec = 60): boolean {
try {
const [, payload] = token.split('.');
const data = JSON.parse(atob(payload));
if (!data?.exp) return false;
const now = Math.floor(Date.now() / 1000);
return data.exp - now <= skewSec;
} catch {
// 파싱 실패 시 보수적으로 만료로 간주하여 재발급 유도
return true;
}
}🤖 Prompt for AI Agents
In apps/client/src/shared/apis/setting/axiosInstance.ts around lines 40-51, the
interceptor unconditionally calls refreshToken for every request which causes
redundant refreshes and server load; change it to only call refreshToken when
there is no token or the token is expired/about-to-expire using an
isTokenExpired(token, skewSec) check, and implement a single-flight refresh
using a module-level refreshPromise: if a refresh is already in progress await
that promise instead of starting a new one, on success store the new token in
localStorage and use it for the request, and on failure clear token/email, reset
refreshPromise to null, redirect to onboarding and rethrow the error; ensure
refreshPromise is cleared on both success and failure so subsequent requests can
retry.
📌 Related Issues
📄 Tasks
⭐ PR Point (To Reviewer)
📷 Screenshot
Summary by CodeRabbit