From c8a5cfcec1cb47b69cb92e82f5b37245ca9256e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Mon, 30 Mar 2026 17:39:28 +0900 Subject: [PATCH 1/3] =?UTF-8?q?refactor:=20apiClient=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=ED=8B=B0=EB=B8=8C=20=EB=B8=8C=EB=A6=BF?= =?UTF-8?q?=EC=A7=80=20=EC=9D=B8=EC=A6=9D=20=EB=8F=99=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EC=A4=91=EC=95=99=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/client.ts | 231 +++++++----------- .../hooks/useInboxNotificationStream.ts | 15 +- src/pages/User/MyPage/hooks/useLogout.ts | 7 - src/stores/authStore.ts | 14 +- src/utils/ts/nativeBridge.ts | 12 + 5 files changed, 113 insertions(+), 166 deletions(-) create mode 100644 src/utils/ts/nativeBridge.ts diff --git a/src/apis/client.ts b/src/apis/client.ts index fcb27348..500ea46f 100644 --- a/src/apis/client.ts +++ b/src/apis/client.ts @@ -2,6 +2,7 @@ import { refreshAccessToken } from '@/apis/auth'; import type { ApiError, ApiErrorResponse } from '@/interface/error'; import { useAuthStore } from '@/stores/authStore'; import { isServerErrorStatus, redirectToServerErrorPage } from '@/utils/ts/errorRedirect'; +import { postNativeMessage } from '@/utils/ts/nativeBridge'; const BASE_URL = import.meta.env.VITE_API_PATH; @@ -19,8 +20,6 @@ interface FetchOptions

> exten requiresAuth?: boolean; } -let refreshPromise: Promise | null = null; - export const apiClient = { get: >( endPoint: string, @@ -128,23 +127,61 @@ function buildQuery(params: Record) { return usp.toString(); } -async function sendRequest>( - endPoint: string, - options: FetchOptions

= {}, - timeout: number = 10000 -): Promise { +function buildUrl(endPoint: string, params?: Record): string { + let url = joinUrl(BASE_URL, endPoint); + if (params && Object.keys(params).length > 0) { + const query = buildQuery(params); + if (query) url += `?${query}`; + } + return url; +} + +function buildFetchOptions

( + options: FetchOptions

& { method: string }, + abortSignal: AbortSignal +): RequestInit { + // eslint-disable-next-line @typescript-eslint/no-unused-vars const { headers, body, method, params, requiresAuth, ...restOptions } = options; - if (!method) { - throw new Error('HTTP method가 설정되지 않았습니다.'); + const isJsonBody = body !== undefined && body !== null && !(body instanceof FormData); + + const h: Record = { + ...(isJsonBody ? { 'Content-Type': 'application/json' } : {}), + ...headers, + }; + + if (requiresAuth) { + const accessToken = useAuthStore.getState().getAccessToken(); + if (accessToken) { + h['Authorization'] = `Bearer ${accessToken}`; + } } - let url = joinUrl(BASE_URL, endPoint); - if (params && Object.keys(params).length > 0) { - const query = buildQuery(params as Record); - if (query) url += `?${query}`; + const fetchOpts: RequestInit = { + headers: h, + method, + signal: abortSignal, + credentials: 'include', + ...restOptions, + }; + + if (body !== undefined && body !== null && !['GET', 'HEAD'].includes(method)) { + fetchOpts.body = + typeof body === 'object' && !(body instanceof Blob) && !(body instanceof FormData) + ? JSON.stringify(body) + : (body as BodyInit); } + return fetchOpts; +} + +async function executeFetch

( + endPoint: string, + options: FetchOptions

& { method: string }, + timeout: number +): Promise<{ response: Response; timeoutId: ReturnType }> { + const url = buildUrl(endPoint, options.params as Record | undefined); + const abortController = new AbortController(); let didTimeout = false; const timeoutId = setTimeout(() => { @@ -152,44 +189,36 @@ async function sendRequest => { - const h: Record = { - ...(isJsonBody ? { 'Content-Type': 'application/json' } : {}), - ...headers, - }; +async function sendRequest>( + endPoint: string, + options: FetchOptions

= {}, + timeout: number = 10000, + allowRetry: boolean = true +): Promise { + const { method } = options; - if (requiresAuth) { - const accessToken = useAuthStore.getState().getAccessToken(); - if (accessToken) { - h['Authorization'] = `Bearer ${accessToken}`; - } - } + if (!method) { + throw new Error('HTTP method가 설정되지 않았습니다.'); + } - return h; - }; + const { response, timeoutId } = await executeFetch

( + endPoint, + options as FetchOptions

& { method: string }, + timeout + ); try { - const fetchOptions: RequestInit = { - headers: buildHeaders(), - method, - signal: abortController.signal, - credentials: 'include', - ...restOptions, - }; - - if (body !== undefined && body !== null && !['GET', 'HEAD'].includes(method)) { - fetchOptions.body = - typeof body === 'object' && !(body instanceof Blob) && !(body instanceof FormData) - ? JSON.stringify(body) - : (body as BodyInit); - } - - const response = await fetch(url, fetchOptions); - - if (response.status === 401 && requiresAuth) { - clearTimeout(timeoutId); + if (response.status === 401 && options.requiresAuth && allowRetry) { return await handleUnauthorized(endPoint, options, timeout); } @@ -198,8 +227,6 @@ async function sendRequest(response); - } catch (error) { - rethrowFetchError(error, url, didTimeout); } finally { clearTimeout(timeoutId); } @@ -213,98 +240,16 @@ async function handleUnauthorized(endPoint, options, timeout); -} - -async function sendRequestWithoutRetry>( - endPoint: string, - options: FetchOptions

= {}, - timeout: number = 10000 -): Promise { - const { headers, body, method, params, requiresAuth, ...restOptions } = options; - - if (!method) { - throw new Error('HTTP method가 설정되지 않았습니다.'); - } - - let url = joinUrl(BASE_URL, endPoint); - if (params && Object.keys(params).length > 0) { - const query = buildQuery(params as Record); - if (query) url += `?${query}`; - } - - const abortController = new AbortController(); - let didTimeout = false; - const timeoutId = setTimeout(() => { - didTimeout = true; - abortController.abort(); - }, timeout); - - const isJsonBody = body !== undefined && body !== null && !(body instanceof FormData); - - try { - const h: Record = { - ...(isJsonBody ? { 'Content-Type': 'application/json' } : {}), - ...headers, - }; - - if (requiresAuth) { - const accessToken = useAuthStore.getState().getAccessToken(); - if (accessToken) { - h['Authorization'] = `Bearer ${accessToken}`; - } - } - - const fetchOptions: RequestInit = { - headers: h, - method, - signal: abortController.signal, - credentials: 'include', - ...restOptions, - }; - - if (body !== undefined && body !== null && !['GET', 'HEAD'].includes(method)) { - fetchOptions.body = - typeof body === 'object' && !(body instanceof Blob) && !(body instanceof FormData) - ? JSON.stringify(body) - : (body as BodyInit); - } - - const response = await fetch(url, fetchOptions); - - if (!response.ok) { - return await throwApiError(response); - } - - return parseResponse(response); - } catch (error) { - rethrowFetchError(error, url, didTimeout); - } finally { - clearTimeout(timeoutId); - } + return await sendRequest(endPoint, options, timeout, false); } async function parseErrorResponse(response: Response): Promise { @@ -320,16 +265,28 @@ async function parseErrorResponse(response: Response): Promise(response: Response): Promise { + if (response.status === 204 || response.headers.get('Content-Length') === '0') { + return null as unknown as T; + } + const contentType = response.headers.get('Content-Type') || ''; + if (contentType.includes('application/json')) { try { return await response.json(); } catch { - return {} as T; + const error = new Error('응답 JSON 파싱에 실패했습니다.') as ApiError; + error.name = 'ParseError'; + error.status = response.status; + error.statusText = response.statusText; + error.url = response.url; + throw error; } - } else if (contentType.includes('text')) { + } + + if (contentType.includes('text')) { return (await response.text()) as unknown as T; - } else { - return null as unknown as T; } + + return null as unknown as T; } diff --git a/src/components/notification/hooks/useInboxNotificationStream.ts b/src/components/notification/hooks/useInboxNotificationStream.ts index b1b0939b..a50874b8 100644 --- a/src/components/notification/hooks/useInboxNotificationStream.ts +++ b/src/components/notification/hooks/useInboxNotificationStream.ts @@ -4,6 +4,7 @@ import type { InboxNotification } from '@/apis/notification/entity'; import { useAuthStore } from '@/stores/authStore'; import { getAccessTokenExpirationTime } from '@/utils/ts/accessToken'; import { isServerErrorStatus, redirectToServerErrorPage } from '@/utils/ts/errorRedirect'; +import { postNativeMessage } from '@/utils/ts/nativeBridge'; import { NORMALIZED_API_BASE_URL } from '@/utils/ts/oauth'; const ACCESS_TOKEN_REFRESH_BUFFER_MS = 60_000; @@ -60,16 +61,6 @@ function parseSseEvent(chunk: string): { event: string | null; data: string | nu }; } -function syncAccessTokenToNative(accessToken: string) { - try { - if (window.ReactNativeWebView) { - window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'TOKEN_REFRESH', accessToken })); - } - } catch { - // 브릿지 전달 실패가 인증 흐름을 중단시키지 않도록 무시 - } -} - async function openInboxNotificationStream( signal: AbortSignal, onConnected: () => void, @@ -106,7 +97,7 @@ async function openInboxNotificationStream( try { const nextAccessToken = await refreshAccessToken(); useAuthStore.getState().setAccessToken(nextAccessToken); - syncAccessTokenToNative(nextAccessToken); + postNativeMessage({ type: 'TOKEN_REFRESH', accessToken: nextAccessToken }); } catch { useAuthStore.getState().clearAuth(); throw new Error('인증이 만료되었습니다.'); @@ -224,7 +215,7 @@ export function useInboxNotificationStream( } useAuthStore.getState().setAccessToken(nextAccessToken); - syncAccessTokenToNative(nextAccessToken); + postNativeMessage({ type: 'TOKEN_REFRESH', accessToken: nextAccessToken }); } catch { if (isCancelled || useAuthStore.getState().getAccessToken() !== accessToken) { return; diff --git a/src/pages/User/MyPage/hooks/useLogout.ts b/src/pages/User/MyPage/hooks/useLogout.ts index e499765e..415f8d9e 100644 --- a/src/pages/User/MyPage/hooks/useLogout.ts +++ b/src/pages/User/MyPage/hooks/useLogout.ts @@ -10,13 +10,6 @@ export const useLogoutMutation = () => { return useMutation({ ...authMutations.logout(), onSuccess: () => { - try { - if (window.ReactNativeWebView) { - window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'LOGOUT' })); - } - } catch { - // 브릿지 전달 실패가 로그아웃 흐름을 중단시키지 않도록 무시 - } clearAuth(); navigate('/'); }, diff --git a/src/stores/authStore.ts b/src/stores/authStore.ts index b0952423..508548b1 100644 --- a/src/stores/authStore.ts +++ b/src/stores/authStore.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import { getMyInfo, refreshAccessToken } from '@/apis/auth'; import type { MyInfoResponse } from '@/apis/auth/entity'; import { isAccessTokenExpired } from '@/utils/ts/accessToken'; +import { postNativeMessage } from '@/utils/ts/nativeBridge'; let initializePromise: Promise | null = null; let hydrateUserPromise: Promise | null = null; @@ -19,15 +20,7 @@ const hydrateUser = async (nextAccessToken: string) => { useAuthStore.setState({ user: nextUser }); - try { - if (window.ReactNativeWebView) { - window.ReactNativeWebView.postMessage( - JSON.stringify({ type: 'LOGIN_COMPLETE', accessToken: nextAccessToken }) - ); - } - } catch { - // 브릿지 전달 실패가 인증 성공 상태를 롤백시키지 않도록 무시 - } + postNativeMessage({ type: 'LOGIN_COMPLETE', accessToken: nextAccessToken }); } catch { if (useAuthStore.getState().accessToken !== nextAccessToken) return; @@ -95,7 +88,7 @@ export const useAuthStore = create((set, get) => ({ set({ accessToken: nextAccessToken, authStatus: 'authenticated' }); void hydrateUser(nextAccessToken); } catch { - set({ user: null, accessToken: null, authStatus: 'anonymous' }); + get().clearAuth(); } finally { initializePromise = null; } @@ -122,5 +115,6 @@ export const useAuthStore = create((set, get) => ({ initializePromise = null; hydrateUserPromise = null; set({ user: null, accessToken: null, authStatus: 'anonymous' }); + postNativeMessage({ type: 'LOGOUT' }); }, })); diff --git a/src/utils/ts/nativeBridge.ts b/src/utils/ts/nativeBridge.ts new file mode 100644 index 00000000..21087208 --- /dev/null +++ b/src/utils/ts/nativeBridge.ts @@ -0,0 +1,12 @@ +type BridgeMessage = + | { type: 'TOKEN_REFRESH'; accessToken: string } + | { type: 'LOGIN_COMPLETE'; accessToken: string } + | { type: 'LOGOUT' }; + +export function postNativeMessage(message: BridgeMessage): void { + try { + window.ReactNativeWebView?.postMessage(JSON.stringify(message)); + } catch { + // 브릿지 전달 실패가 앱 흐름을 중단시키지 않도록 무시 + } +} From 4cf31e5e79f213be6a4b3d86a1ea5728d9dd4f60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Mon, 30 Mar 2026 17:55:44 +0900 Subject: [PATCH 2/3] =?UTF-8?q?refactor:=20body=20=EC=A7=81=EB=A0=AC?= =?UTF-8?q?=ED=99=94=20=EA=B0=80=EB=93=9C=EB=A5=BC=20plain=20object/array?= =?UTF-8?q?=EB=A1=9C=20=ED=95=9C=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/client.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/apis/client.ts b/src/apis/client.ts index 500ea46f..be725833 100644 --- a/src/apis/client.ts +++ b/src/apis/client.ts @@ -143,10 +143,14 @@ function buildFetchOptions

( // eslint-disable-next-line @typescript-eslint/no-unused-vars const { headers, body, method, params, requiresAuth, ...restOptions } = options; - const isJsonBody = body !== undefined && body !== null && !(body instanceof FormData); + const isPlainObjectOrArray = + body !== undefined && + body !== null && + typeof body === 'object' && + (Array.isArray(body) || body.constructor === Object); const h: Record = { - ...(isJsonBody ? { 'Content-Type': 'application/json' } : {}), + ...(isPlainObjectOrArray ? { 'Content-Type': 'application/json' } : {}), ...headers, }; @@ -166,10 +170,7 @@ function buildFetchOptions

( }; if (body !== undefined && body !== null && !['GET', 'HEAD'].includes(method)) { - fetchOpts.body = - typeof body === 'object' && !(body instanceof Blob) && !(body instanceof FormData) - ? JSON.stringify(body) - : (body as BodyInit); + fetchOpts.body = isPlainObjectOrArray ? JSON.stringify(body) : (body as BodyInit); } return fetchOpts; From 7ab1637f4cc24dd298c798b03bbf22ff96f20ccb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Mon, 30 Mar 2026 18:01:12 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20body=20=EC=9D=BD=EA=B8=B0=20?= =?UTF-8?q?=EC=A4=91=20AbortError=EA=B0=80=20ParseError=EB=A1=9C=20?= =?UTF-8?q?=EC=98=A4=EB=B6=84=EB=A5=98=EB=90=98=EB=8A=94=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/client.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/apis/client.ts b/src/apis/client.ts index be725833..5691be03 100644 --- a/src/apis/client.ts +++ b/src/apis/client.ts @@ -218,6 +218,8 @@ async function sendRequest(endPoint, options, timeout); @@ -227,7 +229,12 @@ async function sendRequest(response); + return await parseResponse(response); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + rethrowFetchError(error, url, true); + } + throw error; } finally { clearTimeout(timeoutId); }