조민수 조민수 05-06
api client 추가 + use menu List 변경
@d93bb19a65dc92721cddf6a1f0e9ea70db99bf1d
src/admin/hook/useMenuList.ts
--- src/admin/hook/useMenuList.ts
+++ src/admin/hook/useMenuList.ts
@@ -1,84 +1,78 @@
-import { useEffect, useState } from 'react';
-import { adminAuthJson } from '../../api/adminAuth';
-import type { MenuItem } from '../component/menu/MenuList';
+import {useEffect, useState} from 'react';
+import {apiClient} from '../../api/apiClient';
+import type {MenuItem} from '../component/menu/MenuList';
 
 type BackendMenuItem = {
-  menuNo?: number | string;
-  menuNm?: string;
-  url?: string;
-  upperMenuId?: number | string;
+    menuNo?: number | string;
+    menuNm?: string;
+    url?: string;
+    upperMenuId?: number | string;
 };
 
 type MenuLeftResponse = {
-  head?: BackendMenuItem[];
-  menu?: BackendMenuItem[];
+    head?: BackendMenuItem[];
+    menu?: BackendMenuItem[];
 };
 
-const fallbackHeadMenuList: MenuItem[] = [
-  { name: '테스트 메뉴1', no: '1', url: '#', upperNo: '0' },
-  { name: '테스트 메뉴2', no: '2', url: '#', upperNo: '0' },
-  { name: '테스트 메뉴3', no: '3', url: '#', upperNo: '0' },
-];
-
-const fallbackMenuList: MenuItem[] = [
-  { name: '테스트 1', no: '6', url: '#', upperNo: '1' },
-  { name: '테스트 2', no: '7', url: '#', upperNo: '1' },
-  { name: '테스트 3', no: '8', url: '#', upperNo: '2' },
-];
-
 function toMenuItem(item: BackendMenuItem): MenuItem {
-  return {
-    no: String(item.menuNo ?? ''),
-    name: item.menuNm ?? '',
-    url: item.url ?? '#',
-    upperNo: String(item.upperMenuId ?? '0'),
-  };
+    return {
+        no: String(item.menuNo ?? ''),
+        name: item.menuNm ?? '',
+        url: item.url ?? '#',
+        upperNo: String(item.upperMenuId ?? '0'),
+    };
 }
 
 export const useMenuList = () => {
-  const [headMenuList, setHeadMenuList] = useState<MenuItem[]>(fallbackHeadMenuList);
-  const [menuList, setMenuList] = useState<MenuItem[]>(fallbackMenuList);
-  const [isLoading, setIsLoading] = useState(true);
-  const [errorMessage, setErrorMessage] = useState<string | null>(null);
+    const [headMenuList, setHeadMenuList] = useState<MenuItem[]>([]);
+    const [menuList, setMenuList] = useState<MenuItem[]>([]);
+    const [isLoading, setIsLoading] = useState(true);
+    const [errorMessage, setErrorMessage] = useState<string | null>(null);
 
-  useEffect(() => {
-    let mounted = true;
+    useEffect(() => {
 
-    adminAuthJson<MenuLeftResponse>('/sym/mms/menuLeft.do')
-      .then((data) => {
-        if (!mounted) {
-          return;
-        }
+        let mounted = true;
 
-        const nextHeadMenuList = (data.head ?? []).map(toMenuItem).filter((item) => item.no && item.name);
-        const nextMenuList = (data.menu ?? []).map(toMenuItem).filter((item) => item.no && item.name);
+        apiClient.get<MenuLeftResponse>('/sym/mms/menuLeft.do')
+            .then((data) => {
+                if (!mounted) {
+                    return;
+                }
 
-        setHeadMenuList(nextHeadMenuList.length > 0 ? nextHeadMenuList : fallbackHeadMenuList);
-        setMenuList(nextMenuList.length > 0 ? nextMenuList : fallbackMenuList);
-        setErrorMessage(null);
-      })
-      .catch((error: unknown) => {
-        if (!mounted) {
-          return;
-        }
+                const nextHeadMenuList = (data.head ?? []).map(toMenuItem).filter((item) => item.no && item.name);
+                const nextMenuList = (data.menu ?? []).map(toMenuItem).filter((item) => item.no && item.name);
 
-        setErrorMessage(error instanceof Error ? error.message : '메뉴 조회에 실패했습니다.');
-      })
-      .finally(() => {
-        if (mounted) {
-          setIsLoading(false);
-        }
-      });
+                setHeadMenuList(nextHeadMenuList.length > 0 ? nextHeadMenuList : []);
+                setMenuList(nextMenuList.length > 0 ? nextMenuList : []);
 
-    return () => {
-      mounted = false;
+                setErrorMessage(null);
+            })
+
+            .catch((error: Error) => {
+
+                if (!mounted) {
+                    return;
+                }
+
+                setErrorMessage(error ? error.message : '메뉴 조회에 실패했습니다.');
+            })
+
+            .finally(() => {
+                if (mounted) {
+                    setIsLoading(false);
+                }
+            });
+
+        return () => {
+            mounted = false;
+        };
+
+    }, []);
+
+    return {
+        headMenuList,
+        menuList,
+        isLoading,
+        errorMessage,
     };
-  }, []);
-
-  return {
-    headMenuList,
-    menuList,
-    isLoading,
-    errorMessage,
-  };
-};
+};
(No newline at end of file)
 
src/api/adminAuth.ts (deleted)
--- src/api/adminAuth.ts
@@ -1,123 +0,0 @@
-const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '';
-const ADMIN_LOGIN_URL = '/uat/uia/actionSecurityLogin.do';
-const ADMIN_ID = 'admin';
-const ADMIN_PASSWORD = '1';
-
-type ApiEnvelope<T> = {
-  success?: boolean;
-  message?: string;
-  data?: T;
-};
-
-let loginPromise: Promise<void> | null = null;
-
-function toApiUrl(path: string) {
-  if (/^https?:\/\//i.test(path)) {
-    return path;
-  }
-
-  return `${API_BASE_URL}${path}`;
-}
-
-function createHeaders(headers?: HeadersInit) {
-  return new Headers(headers);
-}
-
-async function adminLogin() {
-  if (!loginPromise) {
-    loginPromise = fetch(toApiUrl(ADMIN_LOGIN_URL), {
-      method: 'POST',
-      credentials: 'include',
-      headers: {
-        'Content-Type': 'application/x-www-form-urlencoded',
-        'X-Requested-With': 'XMLHttpRequest',
-      },
-      body: new URLSearchParams({
-        id: ADMIN_ID,
-        password: ADMIN_PASSWORD,
-      }),
-    })
-      .then(async (response) => {
-        if (!response.ok) {
-          throw new Error(`Admin login failed: ${response.status}`);
-        }
-
-        const contentType = response.headers.get('content-type') ?? '';
-        if (contentType.includes('application/json')) {
-          const result = (await response.json()) as ApiEnvelope<unknown>;
-          if (result.success === false) {
-            throw new Error(result.message ?? 'Admin login failed');
-          }
-        }
-      })
-      .finally(() => {
-        loginPromise = null;
-      });
-  }
-
-  return loginPromise;
-}
-
-async function needsAdminLogin(response: Response) {
-  if (response.type === 'opaqueredirect' || response.status === 0) {
-    return true;
-  }
-
-  if (response.status >= 300 && response.status < 400) {
-    return true;
-  }
-
-  if (response.status === 401 || response.status === 403) {
-    return true;
-  }
-
-  const contentType = response.headers.get('content-type') ?? '';
-
-  if (contentType.includes('text/html')) {
-    const html = await response.clone().text();
-    return html.includes('/uat/uia/actionMain.do') || html.includes('actionSecurityLogin.do');
-  }
-
-  if (contentType.includes('application/json')) {
-    try {
-      const result = (await response.clone().json()) as ApiEnvelope<unknown>;
-      const message = result.message ?? '';
-      return result.success === false && /login|로그인|/.test(message);
-    } catch {
-      return false;
-    }
-  }
-
-  return false;
-}
-
-export async function adminAuthFetch(path: string, init: RequestInit = {}, retry = true): Promise<Response> {
-  const response = await fetch(toApiUrl(path), {
-    ...init,
-    credentials: 'include',
-    headers: createHeaders(init.headers),
-    redirect: 'manual',
-  });
-
-  if (retry && (await needsAdminLogin(response))) {
-    await adminLogin();
-    return adminAuthFetch(path, init, false);
-  }
-
-  return response;
-}
-
-export async function adminAuthJson<T>(path: string, init?: RequestInit): Promise<T> {
-  const response = await adminAuthFetch(path, init);
-
-  if (!response.ok) {
-    throw new Error(`API request failed: ${response.status}`);
-  }
-
-  const result = (await response.json()) as ApiEnvelope<T>;
-  if (result.success === false) {
-    throw new Error(result.message ?? 'API request failed');
-  }
-
-  return result.data as T;
-}
 
src/api/apiClient.ts (added)
+++ src/api/apiClient.ts
@@ -0,0 +1,168 @@
+const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '';
+const ADMIN_LOGIN_URL = '/uat/uia/actionSecurityLogin.do';
+const ADMIN_ID = 'admin';
+const ADMIN_PASSWORD = '1';
+const MAX_RETRY_COUNT = 3;
+
+type ApiResponse<T> = {
+    success: boolean;
+    message: string;
+    data: T;
+}
+
+
+class ApiClient {
+    private loginPromise: Promise<void> | null = null;
+
+    private toApiUrl(path: string) {
+        if (/^https?:\/\//i.test(path)) {
+            return path;
+        }
+
+        return `${API_BASE_URL}${path}`;
+    }
+
+    private createHeaders(headers?: HeadersInit) {
+        const result = new Headers(headers);
+
+        result.set('X-Requested-With', 'XMLHttpRequest');
+
+        return result;
+    }
+
+    private async login() {
+        if (!this.loginPromise) {
+            this.loginPromise = fetch(this.toApiUrl(ADMIN_LOGIN_URL), {
+                method: 'POST',
+                credentials: 'include',
+                headers: {
+                    'Content-Type': 'application/x-www-form-urlencoded',
+                    'X-Requested-With': 'XMLHttpRequest',
+                },
+                body: new URLSearchParams({
+                    id: ADMIN_ID,
+                    password: ADMIN_PASSWORD,
+                }),
+            }).then(async (response) => {
+                if (!response.ok) {
+                    throw new Error(`로그인 실패 : ${response.status}`);
+                }
+
+                const contentType = response.headers.get('content-type') ?? '';
+
+                if (contentType === 'application/json') {
+                    const result = (await response.json()) as ApiResponse<unknown>;
+
+                    if (!result.success) {
+                        throw new Error(result.message);
+                    }
+                }
+            }).finally(() => {
+                this.loginPromise = null;
+            });
+        }
+        return this.loginPromise;
+    }
+
+    private async needsLogin(response: Response) {
+        if(response.status === 401 || response.status === 403) {
+            return true;
+        }
+
+        const contentType=  response.headers.get("content-type") ?? '';
+
+        if (contentType.includes('text/html')) {
+
+            const html = await response.clone().text();
+
+            return (
+                html.includes('/uat/uia/actionMain.do') ||
+                html.includes('actionSecurityLogin.do')
+            );
+        }
+
+        if (contentType.includes('application/json')) {
+
+            try {
+
+                const result = (await response.clone().json()) as ApiResponse<unknown>;
+
+                return (!result.success && /login|로그인/i.test(result.message ?? ''));
+
+            } catch {
+                return false;
+            }
+        }
+
+        return false;
+    }
+    private async request(
+        path: string,
+        init: RequestInit = {},
+        retryCount = 0
+    ): Promise<Response> {
+
+        const response = await fetch(this.toApiUrl(path), {
+            ...init,
+            credentials: 'include',
+            headers: this.createHeaders(init.headers),
+            redirect: 'manual',
+        });
+
+        if (await this.needsLogin(response)) {
+
+            if (retryCount >= MAX_RETRY_COUNT) {
+                throw new Error('인증 재시도 횟수 초과');
+            }
+
+            await this.login();
+
+            return this.request(path, init, retryCount + 1);
+        }
+
+        return response;
+    }
+
+    async get<T>(path: string): Promise<T> {
+
+        const response = await this.request(path, {
+            method: 'GET',
+        });
+
+        return this.parseJson<T>(response);
+    }
+
+    async post<T>(
+        path: string,
+        body?: unknown
+    ): Promise<T> {
+
+        const response = await this.request(path, {
+            method: 'POST',
+            body: body ? JSON.stringify(body) : undefined,
+            headers: {
+                'Content-Type': 'application/json',
+            },
+        });
+
+        return this.parseJson<T>(response);
+    }
+
+
+    private async parseJson<T>(response: Response): Promise<T> {
+
+        if (!response.ok) {
+            throw new Error(`API request failed: ${response.status}`);
+        }
+
+        const result = (await response.json()) as ApiResponse<T>;
+
+        if (!result.success) {
+            throw new Error(result.message ?? 'API request failed');
+        }
+
+        return result.data as T;
+    }
+}
+
+export const apiClient = new ApiClient();(No newline at end of file)
Add a comment
List