diff --git a/src/apis/client.ts b/src/apis/client.ts
index fcb2734..5691be0 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,62 @@ 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 isPlainObjectOrArray =
+ body !== undefined &&
+ body !== null &&
+ typeof body === 'object' &&
+ (Array.isArray(body) || body.constructor === Object);
+
+ const h: Record = {
+ ...(isPlainObjectOrArray ? { '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 = isPlainObjectOrArray ? 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 +190,38 @@ async function sendRequest => {
- const h: Record = {
- ...(isJsonBody ? { 'Content-Type': 'application/json' } : {}),
- ...headers,
- };
+ try {
+ const fetchOpts = buildFetchOptions(options, abortController.signal);
+ const response = await fetch(url, fetchOpts);
+ return { response, timeoutId };
+ } catch (error) {
+ clearTimeout(timeoutId);
+ rethrowFetchError(error, url, didTimeout);
+ }
+}
- if (requiresAuth) {
- const accessToken = useAuthStore.getState().getAccessToken();
- if (accessToken) {
- h['Authorization'] = `Bearer ${accessToken}`;
- }
- }
+async function sendRequest>(
+ endPoint: string,
+ options: FetchOptions = {},
+ timeout: number = 10000,
+ allowRetry: boolean = true
+): Promise {
+ const { method } = options;
- return h;
- };
+ if (!method) {
+ throw new Error('HTTP method가 설정되지 않았습니다.');
+ }
- 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, timeoutId } = await executeFetch(
+ endPoint,
+ options as FetchOptions
& { method: string },
+ timeout
+ );
- const response = await fetch(url, fetchOptions);
+ const url = response.url;
- if (response.status === 401 && requiresAuth) {
- clearTimeout(timeoutId);
+ try {
+ if (response.status === 401 && options.requiresAuth && allowRetry) {
return await handleUnauthorized(endPoint, options, timeout);
}
@@ -197,9 +229,12 @@ async function sendRequest(response);
+ return await parseResponse(response);
} catch (error) {
- rethrowFetchError(error, url, didTimeout);
+ if (error instanceof Error && error.name === 'AbortError') {
+ rethrowFetchError(error, url, true);
+ }
+ throw error;
} finally {
clearTimeout(timeoutId);
}
@@ -213,98 +248,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 +273,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 b1b0939..a50874b 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 e499765..415f8d9 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 b095242..508548b 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 0000000..2108720
--- /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 {
+ // 브릿지 전달 실패가 앱 흐름을 중단시키지 않도록 무시
+ }
+}