From 06cc804446df76b102d48d8fb55508648944c64e Mon Sep 17 00:00:00 2001 From: KIM JEONGMIN <163228804+jjangminii@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:03:30 +0900 Subject: [PATCH 1/2] =?UTF-8?q?Refactor:=20=20isRefreshing=20+=20Queue=20?= =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=A0=81=EC=9A=A9=20(#318)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: isRefreshing + Queue 패턴 적용 * chore: 주삭 정리 * fix: 대기열 패턴에서 토큰 재발급 실패 시 요청 취소 처리 및 타입 수정 * fix: 응답 인터셉터에서 토큰 갱신 후 요청 재시도 시 헤더 설정 추가 --- .../src/shared/apis/setting/axiosInstance.ts | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/apps/client/src/shared/apis/setting/axiosInstance.ts b/apps/client/src/shared/apis/setting/axiosInstance.ts index 1998afce..8cd853a0 100644 --- a/apps/client/src/shared/apis/setting/axiosInstance.ts +++ b/apps/client/src/shared/apis/setting/axiosInstance.ts @@ -1,6 +1,6 @@ -import axios from 'axios'; import { authStorage } from '@shared/utils/authStorage'; import { extensionBridge } from '@shared/utils/extensionBridge'; +import axios from 'axios'; const noAuthNeeded = [ '/api/v1/auth/token', @@ -30,7 +30,20 @@ const clearAuthSessionAndRedirect = () => { window.location.href = '/onboarding?step=SOCIAL_LOGIN'; }; -// Axios 인스턴스 +// 대기열 패턴을 위한 상태 변수 선언 +let isRefreshing = false; +let refreshSubscribers: ((token: string | null) => void)[] = []; + +// 대기 중인 요청들을 일괄 실행하고 큐를 비우는 함수 +const onRefreshed = (token: string | null) => { + refreshSubscribers.forEach((callback) => callback(token)); + refreshSubscribers = []; +}; +// 대기열에 요청을 추가 +const addRefreshSubscriber = (callback: (token: string | null) => void) => { + refreshSubscribers.push(callback); +}; + const apiRequest = axios.create({ baseURL: import.meta.env.VITE_BASE_URL, headers: { @@ -68,7 +81,26 @@ apiRequest.interceptors.response.use( !isNoAuth && !isLoginPage ) { + if (isRefreshing) { + return new Promise((resolve, reject) => { + addRefreshSubscriber((token) => { + if (token) { + originalRequest._retry = true; + originalRequest.headers = originalRequest.headers ?? {}; + + originalRequest.headers.Authorization = `Bearer ${token}`; + resolve(apiRequest(originalRequest)); + } else { + reject( + new Error('토큰 재발급에 실패하여 대기 중인 요청을 취소합니다.') + ); + } + }); + }); + } + originalRequest._retry = true; + isRefreshing = true; try { const res = await reissueToken(); @@ -79,12 +111,20 @@ apiRequest.interceptors.response.use( } syncAccessToken(newAccessToken); + + // 토큰 갱신 성공 처리 + isRefreshing = false; + onRefreshed(newAccessToken); + originalRequest.headers = originalRequest.headers ?? {}; originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; return apiRequest(originalRequest); } catch (reissueError) { console.error('토큰 재발급 실패. 다시 로그인해주세요.', reissueError); + // 토큰 갱신 실패 처리 + isRefreshing = false; + onRefreshed(null); clearAuthSessionAndRedirect(); return Promise.reject(reissueError); From a122099482dbe5584d756bb22de5cdf2c12a6087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A7=84=ED=98=81?= Date: Thu, 19 Mar 2026 17:55:00 +0900 Subject: [PATCH 2/2] Feat(extension, client): separate extension send token environment (#319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: VITE_GOOGLE_REDIRECT_URI 하나로 통합 * feat: extension build dev와 prod 환경 분리하도록 script, turbo.json 설정 * feat: extension build 시점에 dev, prod가 name env 기준으로 나뉘도록 앱 분리 설정 * feat: extension login popup 창 열릴 때 dev, prod 환경 분리되도록 동적 url 변수 설정 * feat: extension background webUrl 설정 시 동적으로 분기로직 추가 * setting: update extension version to 1.0.3 * fix: build script 패키지 매니저 yarn에서 pnpm으로 수정 * refactor: ALLOWED_ORIGINS 없는 경우 예외처리 추가 * refactor: split(,)에 따라 [] 배열 생성으로 인한 엣지 케이스 에러 방지 로직 추가 --- .../src/pages/onBoarding/GoogleCallback.tsx | 4 +- .../src/shared/utils/handleGoogleLogin.ts | 5 +- apps/extension/.gitignore | 2 + apps/extension/manifest.json | 2 +- apps/extension/package.json | 8 ++- apps/extension/src/background.ts | 6 +- apps/extension/src/content.ts | 30 +++++--- apps/extension/src/pages/LogOutPop.tsx | 4 +- apps/extension/src/vite-env.d.ts | 3 + apps/extension/vite.config.ts | 70 +++++++++++-------- package.json | 8 ++- turbo.json | 11 +++ 12 files changed, 99 insertions(+), 54 deletions(-) create mode 100644 apps/extension/src/vite-env.d.ts diff --git a/apps/client/src/pages/onBoarding/GoogleCallback.tsx b/apps/client/src/pages/onBoarding/GoogleCallback.tsx index a742e7fe..b4d03e64 100644 --- a/apps/client/src/pages/onBoarding/GoogleCallback.tsx +++ b/apps/client/src/pages/onBoarding/GoogleCallback.tsx @@ -46,9 +46,7 @@ const GoogleCallback = () => { } }; - const redirectUri = import.meta.env.PROD - ? import.meta.env.VITE_GOOGLE_REDIRECT_URI_PROD - : import.meta.env.VITE_GOOGLE_REDIRECT_URI_DEV; + const redirectUri = import.meta.env.VITE_GOOGLE_REDIRECT_URI; const loginWithCode = async (code: string) => { try { diff --git a/apps/client/src/shared/utils/handleGoogleLogin.ts b/apps/client/src/shared/utils/handleGoogleLogin.ts index 8b0ff14b..f70ac974 100644 --- a/apps/client/src/shared/utils/handleGoogleLogin.ts +++ b/apps/client/src/shared/utils/handleGoogleLogin.ts @@ -1,14 +1,13 @@ export const handleGoogleLogin = () => { const clientId = import.meta.env.VITE_GOOGLE_CLIENT_ID; - const redirectUri = import.meta.env.PROD - ? import.meta.env.VITE_GOOGLE_REDIRECT_URI_PROD - : import.meta.env.VITE_GOOGLE_REDIRECT_URI_DEV; + const redirectUri = import.meta.env.VITE_GOOGLE_REDIRECT_URI; if (!clientId || !redirectUri) { alert('Google OAuth 설정이 누락되었습니다.'); return; } + const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?` + `client_id=${clientId}` + diff --git a/apps/extension/.gitignore b/apps/extension/.gitignore index 800f3a80..7a22cca6 100644 --- a/apps/extension/.gitignore +++ b/apps/extension/.gitignore @@ -15,8 +15,10 @@ # misc .DS_Store .env.local +.env.development .env.development.local .env.test.local +.env.production .env.production.local npm-debug.log* diff --git a/apps/extension/manifest.json b/apps/extension/manifest.json index f0b3aa5a..9ae1f400 100644 --- a/apps/extension/manifest.json +++ b/apps/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Pinback 핀백 - 북마크 관리와 리마인드 도구", - "version": "1.0.2", + "version": "1.0.3", "action": { "default_popup": "popup.html" }, "icons": { "128": "icon.png", diff --git a/apps/extension/package.json b/apps/extension/package.json index f1f7715f..84779808 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -4,11 +4,13 @@ "private": true, "type": "module", "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", + "dev": "vite --mode development", + "build": "pnpm build:prod", + "build:dev": "tsc -b && vite build --mode development", + "build:prod": "tsc -b && vite build --mode production", "lint": "eslint .", "format": "prettier -w .", - "zip": "vite build && node scripts/zip.mjs" + "zip": "pnpm build:prod && node scripts/zip.mjs" }, "dependencies": { "@pinback/contracts": "workspace:*", diff --git a/apps/extension/src/background.ts b/apps/extension/src/background.ts index 42b17fcd..a089b710 100644 --- a/apps/extension/src/background.ts +++ b/apps/extension/src/background.ts @@ -1,5 +1,7 @@ import { EXTENSION_MESSAGE_TYPE } from '@pinback/contracts/extension-messages'; +const webUrl = import.meta.env.VITE_WEB_URL || 'https://pinback.today'; + chrome.runtime.onInstalled.addListener((details) => { if (details.reason === 'install') { chrome.identity.getProfileUserInfo(function (info) { @@ -8,7 +10,7 @@ chrome.runtime.onInstalled.addListener((details) => { }); setTimeout(() => { chrome.tabs.create({ - url: `https://pinback.today/onboarding?email=${info.email}`, + url: `${webUrl}/onboarding?email=${info.email}`, }); }, 1000); }); @@ -21,9 +23,7 @@ chrome.runtime.onMessage.addListener((message) => { console.log('Token saved!'); }); } -}); -chrome.runtime.onMessage.addListener((message) => { if (message.type === EXTENSION_MESSAGE_TYPE.logout) { chrome.storage.local.remove('token', () => { console.log('Token removed!'); diff --git a/apps/extension/src/content.ts b/apps/extension/src/content.ts index 92002d60..30dd9146 100644 --- a/apps/extension/src/content.ts +++ b/apps/extension/src/content.ts @@ -1,23 +1,35 @@ import { EXTENSION_MESSAGE_TYPE } from '@pinback/contracts/extension-messages'; +const ALLOWED_ORIGINS = __ALLOWED_ORIGINS__; + +if (ALLOWED_ORIGINS.length === 0) { + console.warn( + '[Pinback] 허용된 도메인(ALLOWED_ORIGINS) 설정이 비어 있습니다. 토큰 동기화가 작동하지 않으니 .env 파일을 확인해주세요.' + ); +} + window.addEventListener('message', (event) => { if (event.source !== window) return; - if (event.data.type === EXTENSION_MESSAGE_TYPE.setToken) { + + if (!ALLOWED_ORIGINS.includes(event.origin)) return; + + const { type, token } = event.data; + + if (type === EXTENSION_MESSAGE_TYPE.setToken) { chrome.runtime.sendMessage({ type: EXTENSION_MESSAGE_TYPE.setToken, - token: event.data.token, + token, }); - chrome.storage.local.set({ token: event.data.token }, () => { - console.log('Token saved!'); + + chrome.storage.local.set({ token }, () => { + console.log(`[${event.origin}] Token saved!`); }); } -}); -window.addEventListener('message', (event) => { - if (event.source !== window) return; - if (event.data.type === EXTENSION_MESSAGE_TYPE.logout) { + // 토큰 삭제 (로그아웃) + if (type === EXTENSION_MESSAGE_TYPE.logout) { chrome.storage.local.remove('token', () => { - console.log('Token removed!'); + console.log(`[${event.origin}] Token removed!`); }); } }); diff --git a/apps/extension/src/pages/LogOutPop.tsx b/apps/extension/src/pages/LogOutPop.tsx index c515a224..4f8f2e97 100644 --- a/apps/extension/src/pages/LogOutPop.tsx +++ b/apps/extension/src/pages/LogOutPop.tsx @@ -1,6 +1,8 @@ import { Icon } from '@pinback/design-system/icons'; import LogOutImg from '/logout_chippi.svg'; const LogOutPop = () => { + const webUrl = import.meta.env.VITE_WEB_URL || 'https://pinback.today'; + return (
@@ -25,7 +27,7 @@ const LogOutPop = () => { type="button" onClick={() => { chrome.tabs.create({ - url: 'https://pinback.today/onboarding?step=SOCIAL_LOGIN', + url: `${webUrl}/onboarding?step=SOCIAL_LOGIN`, }); }} > diff --git a/apps/extension/src/vite-env.d.ts b/apps/extension/src/vite-env.d.ts new file mode 100644 index 00000000..ffefc447 --- /dev/null +++ b/apps/extension/src/vite-env.d.ts @@ -0,0 +1,3 @@ +/// + +declare const __ALLOWED_ORIGINS__: string[]; diff --git a/apps/extension/vite.config.ts b/apps/extension/vite.config.ts index 84ab1ff4..c1c3d914 100644 --- a/apps/extension/vite.config.ts +++ b/apps/extension/vite.config.ts @@ -1,36 +1,50 @@ -// vite.config.ts -import { defineConfig } from 'vite'; +import { crx } from '@crxjs/vite-plugin'; +import svgSpritePlugin from '@pivanov/vite-plugin-svg-sprite'; import tailwindcss from '@tailwindcss/vite'; import react from '@vitejs/plugin-react-swc'; -import tsconfigPaths from 'vite-tsconfig-paths'; import { resolve } from 'path'; -import { crx } from '@crxjs/vite-plugin'; +import { defineConfig, loadEnv } from 'vite'; +import tsconfigPaths from 'vite-tsconfig-paths'; import manifest from './manifest.json'; -import svgSpritePlugin from '@pivanov/vite-plugin-svg-sprite'; -export default defineConfig({ - plugins: [ - react(), - tsconfigPaths({ - projects: [resolve(__dirname, './tsconfig.json')], - }), - tailwindcss(), - crx({ manifest }), - svgSpritePlugin({ - iconDirs: ['../../packages/design-system/src/icons/source'], - symbolId: 'icon-[name]', - inject: 'body-last', - }), - ], +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd()); + + const dynamicManifest = { + ...manifest, + name: env.VITE_EXTENSION_NAME || manifest.name, + }; + + return { + define: { + __ALLOWED_ORIGINS__: JSON.stringify( + env.VITE_ALLOWED_ORIGINS?.split(',').filter(Boolean) || [] + ), + }, + + plugins: [ + react(), + tsconfigPaths({ + projects: [resolve(__dirname, './tsconfig.json')], + }), + tailwindcss(), + crx({ manifest: dynamicManifest }), + svgSpritePlugin({ + iconDirs: ['../../packages/design-system/src/icons/source'], + symbolId: 'icon-[name]', + inject: 'body-last', + }), + ], - build: { - outDir: 'dist', - copyPublicDir: true, - }, - publicDir: 'public', + build: { + outDir: 'dist', + copyPublicDir: true, + }, + publicDir: 'public', - server: { - port: 5175, - strictPort: true, - }, + server: { + port: 5175, + strictPort: true, + }, + }; }); diff --git a/package.json b/package.json index b4d1c485..73cec766 100644 --- a/package.json +++ b/package.json @@ -3,14 +3,16 @@ "private": true, "scripts": { "build": "turbo run build", + "build:dev": "turbo run build:dev --filter=@pinback/extension", + "build:prod": "turbo run build:prod --filter=@pinback/extension", + "build-storybook": "storybook build", "dev": "turbo run dev", "dev:client": "turbo run dev --filter=@pinback/client", + "storybook": "storybook dev -p 6006", "lint": "turbo run lint", "format": "prettier --write \"**/*.{ts,tsx,md}\"", "check-types": "turbo run check-types", - "test": "turbo run test", - "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "test": "turbo run test" }, "devDependencies": { "@chromatic-com/storybook": "^4.1.1", diff --git a/turbo.json b/turbo.json index e8616fb8..f040fdaa 100644 --- a/turbo.json +++ b/turbo.json @@ -7,6 +7,17 @@ "inputs": ["$TURBO_DEFAULT$", ".env*", "!.env.sample"], "outputs": [".next/**", "!.next/cache/**", "dist/**"] }, + + "build:dev": { + "dependsOn": ["^build"], + "inputs": ["$TURBO_DEFAULT$", ".env*", "!.env.sample"], + "outputs": ["dist/**"] + }, + "build:prod": { + "dependsOn": ["^build"], + "inputs": ["$TURBO_DEFAULT$", ".env*", "!.env.sample"], + "outputs": ["dist/**"] + }, "dev": { "cache": false, "persistent": true