first commit

This commit is contained in:
NYD
2026-01-30 09:01:27 +09:00
commit d96fc95782
56 changed files with 7424 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
/**
* API 경로 상수 정의
*
* 목표:
* - 문자열 API 경로를 코드 전역에 흩뿌리지 않음
* - route 변경/버전 변경 시 영향 범위 최소화
*
* 규칙:
* - API 경로는 endpoints.ts로만 관리
* - feature 코드에서 문자열 URL 직접 사용 금지
*/
export const API = {
AUTH: {
CSRF: "/auth/csrf",
LOGIN: "/auth/login",
LOGOUT: "/auth/logout",
ME: "/auth/me",
},
USER: {
LIST: "/users",
DETAIL: (id: string | number) => `/users/${encodeURIComponent(id)}`,
ME: "/users/me",
},
DOCUMENT: {
LIST: "/documents",
DETAIL: (id: string | number) => `/documents/${encodeURIComponent(id)}`,
},
} as const;

22
src/common/api/http.ts Normal file
View File

@@ -0,0 +1,22 @@
import axios from "axios";
/**
* Axios 인스턴스 설정
*
* - 세션 기반 인증 (withCredentials: true)
* - 환경 변수 기반 baseURL 설정
* - 타임아웃 설정
*/
const baseURL = `${import.meta.env.VITE_API_BASE_URL ?? ""}${import.meta.env.VITE_API_PREFIX ?? "/api"}`
.replace(/\/+$/g, "");
const timeout = Number(import.meta.env.VITE_HTTP_TIMEOUT_MS ?? 20000);
export const http = axios.create({
baseURL,
timeout,
withCredentials: true,
headers: {
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest",
},
});

View File

@@ -0,0 +1,107 @@
import { http } from "./http";
import type { AxiosError, InternalAxiosRequestConfig } from "axios";
/**
* Axios Interceptor 설정
*
* 역할:
* - 모든 요청은 전역 로딩과 연결
* - 모든 응답은 로딩 종료 보장
* - 401 응답은 전역 인증 만료로 처리
* - 401 재시도 루프 방지
* - 민감정보는 절대 로그에 남기지 않음
*
* 보안 규칙:
* - localStorage/sessionStorage에 인증정보 저장 금지
* - 401인데 아무 처리 없이 콘솔만 출력 금지
*
* 주의:
* - loading.start/end는 동적으로 주입받아야 함 (순환 참조 방지)
* - window 객체를 통해 접근
*/
// 401 재시도 루프 방지 플래그
let isHandling401 = false;
// 로딩 제어 함수 (동적 주입)
let loadingStart: (() => void) | null = null;
let loadingEnd: (() => void) | null = null;
/**
* 로딩 제어 함수 등록 (LoadingProvider에서 호출)
*/
export function setLoadingControls(start: () => void, end: () => void) {
loadingStart = start;
loadingEnd = end;
}
/**
* 요청 인터셉터
*/
http.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 로딩 시작 (X-Skip-Loading 헤더가 없을 때만)
if (!config.headers?.["X-Skip-Loading"] && loadingStart) {
loadingStart();
}
return config;
},
(error) => {
// 요청 에러 시에도 로딩 종료
if (loadingEnd) {
loadingEnd();
}
return Promise.reject(error);
}
);
/**
* 응답 인터셉터
*/
http.interceptors.response.use(
(response) => {
// 로딩 종료 (X-Skip-Loading 헤더가 없을 때만)
if (!response.config.headers?.["X-Skip-Loading"] && loadingEnd) {
loadingEnd();
}
return response;
},
(error: AxiosError) => {
// 로딩 종료 (에러 시에도)
if (!error.config?.headers?.["X-Skip-Loading"] && loadingEnd) {
loadingEnd();
}
const status = error.response?.status;
// 401 처리 (인증 만료)
if (status === 401 && !isHandling401) {
isHandling401 = true;
// 전역 인증 만료 이벤트 발생
window.dispatchEvent(new CustomEvent("app:unauthorized"));
// 플래그 리셋 (다음 요청을 위해)
setTimeout(() => {
isHandling401 = false;
}, 1000);
}
// 403 처리 (권한 없음)
if (status === 403) {
// 필요 시 권한 없음 이벤트 발생
// window.dispatchEvent(new CustomEvent("app:forbidden"));
}
// 로깅 (민감정보 제외)
// 운영 환경에서는 console.log 금지
if (import.meta.env.DEV && import.meta.env.VITE_HTTP_LOGGING === "true") {
const url = error.config?.url;
const method = error.config?.method?.toUpperCase();
console.warn(`[API Error] ${method} ${url} - ${status}`);
}
return Promise.reject(error);
}
);

View File

@@ -0,0 +1,24 @@
import { createContext } from "react";
import type { AuthContextValue } from "./types";
/**
* 인증 Context 정의
*
* 규칙:
* - 인증 상태 및 사용자 정보는 AuthContext에서만 관리
* - 페이지별 /me 호출로 중복 상태 생성 금지
* - 앱 초기 로딩에서 1회 /me 호출 + refresh 로직만 허용
*/
export const AuthContext = createContext<AuthContextValue>({
loading: true,
user: null,
login: async () => {
throw new Error("AuthProvider not initialized");
},
logout: async () => {
throw new Error("AuthProvider not initialized");
},
refreshMe: async () => {
throw new Error("AuthProvider not initialized");
},
});

View File

@@ -0,0 +1,97 @@
import { useEffect, useState, useCallback } from "react";
import { AuthContext } from "./AuthContext";
import { authService } from "./authService";
import type { User, AuthContextValue } from "./types";
/**
* 인증 Provider
*
* 책임:
* - 앱 최초 로딩 시 사용자 상태 확정
* - /api/auth/me 호출
* - 인증 성공 시 user 상태 설정
* - 인증 실패(401) 시 user = null 처리
* - 전역 unauthorized 이벤트 대응
*
* 규칙:
* - AuthProvider가 초기 /me 호출 → user set
* - 페이지는 useAuth로 읽기만
*/
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [loading, setLoading] = useState(true);
const [user, setUser] = useState<User | null>(null);
/**
* 사용자 정보 갱신
*/
const refreshMe = useCallback(async () => {
try {
const me = await authService.me();
setUser(me);
} catch {
setUser(null);
} finally {
setLoading(false);
}
}, []);
/**
* 로그인
*/
const login = useCallback(async (username: string, password: string) => {
await authService.login(username, password);
// 로그인 성공 후 사용자 정보 갱신
await refreshMe();
}, [refreshMe]);
/**
* 로그아웃
*/
const logout = useCallback(async () => {
try {
await authService.logout();
} catch {
// 로그아웃 실패해도 상태는 초기화
} finally {
setUser(null);
setLoading(false);
}
}, []);
/**
* 초기 사용자 정보 조회
*/
useEffect(() => {
refreshMe();
}, [refreshMe]);
/**
* 전역 401 이벤트 리스너
* interceptor에서 발생시킨 이벤트 처리
*/
useEffect(() => {
const handleUnauthorized = () => {
setUser(null);
setLoading(false);
};
window.addEventListener("app:unauthorized", handleUnauthorized);
return () => {
window.removeEventListener("app:unauthorized", handleUnauthorized);
};
}, []);
const value: AuthContextValue = {
loading,
user,
login,
logout,
refreshMe,
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}

View File

@@ -0,0 +1,29 @@
import { Navigate } from "react-router-dom";
import { useAuth } from "./useAuth";
/**
* 보호된 라우트
*
* 역할:
* - 인증이 필요한 페이지 접근 제어
* - 인증되지 않은 사용자는 로그인 페이지로 리다이렉트
*
* 규칙:
* - ProtectedRoute는 라우팅 레벨에서 적용
* - 버튼 숨김/메뉴 숨김은 보안이 아님 (라우트 차단 필수)
*/
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { loading, user } = useAuth();
// 로딩 중에는 아무것도 렌더링하지 않음
if (loading) {
return null;
}
// 인증되지 않은 경우 로그인 페이지로 리다이렉트
if (!user) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,51 @@
import { http } from "../api/http";
import { API } from "../api/endpoints";
import type { User } from "./types";
/**
* 인증 서비스
*
* 역할:
* - login / logout / me API 호출
* - 세션 기반 인증 (withCredentials 자동 처리)
*
* 보안 규칙:
* - localStorage/sessionStorage에 인증정보 저장 금지
* - 토큰을 프론트엔드에서 직접 관리하지 않음
*/
export const authService = {
/**
* 로그인
*/
async login(username: string, password: string): Promise<void> {
await http.post(API.AUTH.LOGIN, { username, password });
},
/**
* 로그아웃
*/
async logout(): Promise<void> {
await http.post(API.AUTH.LOGOUT);
},
/**
* 현재 사용자 정보 조회
*/
async me(): Promise<User | null> {
try {
const response = await http.get(API.AUTH.ME);
// ApiResponse 구조에 맞게 data.data 접근
return response.data?.data ?? null;
} catch (error) {
// 401 등 에러 시 null 반환
return null;
}
},
/**
* CSRF 토큰 초기화
*/
async csrf(): Promise<void> {
await http.get(API.AUTH.CSRF);
},
};

32
src/common/auth/types.ts Normal file
View File

@@ -0,0 +1,32 @@
/**
* 인증 관련 타입 정의
*
* 백엔드 MeResponse와 일치하도록 정의
* - userId: 사용자 ID (number)
* - email: 이메일 (string, 필수)
* - displayName: 표시 이름 (string, 필수)
* - role: 역할 (string, 필수)
*
* 보안 규칙:
* - password, token 등 민감정보 포함 금지
* - 최소 정보만 포함
*/
export interface User {
userId: number;
email: string;
displayName: string;
role: string;
}
export interface AuthState {
loading: boolean;
user: User | null;
}
export interface AuthActions {
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refreshMe: () => Promise<void>;
}
export interface AuthContextValue extends AuthState, AuthActions {}

View File

@@ -0,0 +1,14 @@
import { useContext } from "react";
import { AuthContext } from "./AuthContext";
/**
* AuthContext 접근 훅
*
* 사용법:
* ```tsx
* const { loading, user, login, logout } = useAuth();
* ```
*/
export function useAuth() {
return useContext(AuthContext);
}

View File

@@ -0,0 +1,68 @@
import React from "react";
/**
* Button 공통 컴포넌트
*
* 공통 원칙:
* - id, name, className 전달 옵션 지원
* - 스타일은 CSS로 처리
* - 범용성 위주, 과도한 추상화 지양
* - 동작은 props 옵션으로만 제어
*
* 지원 옵션:
* - disabled: 비활성화
* - loading: 로딩 상태 (중복 클릭 방지)
* - size: 버튼 크기
* - variant: 버튼 스타일 변형
*/
export type ButtonSize = "sm" | "md" | "lg";
export type ButtonVariant = "primary" | "secondary" | "danger" | "outline" | "ghost";
export interface ButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onClick"> {
disabled?: boolean;
loading?: boolean;
size?: ButtonSize;
variant?: ButtonVariant;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
children: React.ReactNode;
}
export function Button({
disabled = false,
loading = false,
size = "md",
variant = "primary",
onClick,
className = "",
id,
name,
children,
...props
}: ButtonProps) {
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (disabled || loading) {
e.preventDefault();
return;
}
onClick?.(e);
};
const sizeClass = `btn-${size}`;
const variantClass = `btn-${variant}`;
const loadingClass = loading ? "btn-loading" : "";
const disabledClass = disabled || loading ? "btn-disabled" : "";
return (
<button
id={id}
name={name}
className={`btn ${sizeClass} ${variantClass} ${loadingClass} ${disabledClass} ${className}`.trim()}
disabled={disabled || loading}
onClick={handleClick}
{...props}
>
{loading && <span className="btn-spinner" aria-hidden="true" />}
<span className={loading ? "btn-content-loading" : ""}>{children}</span>
</button>
);
}

View File

@@ -0,0 +1,4 @@
/**
* Action 컴포넌트 통합 export
*/
export * from "./Button";

View File

@@ -0,0 +1,189 @@
import React from "react";
import type { SortValue, TableColumn, RowSelectionState } from "./types";
import { TableEmpty } from "./TableEmpty";
import { TableSkeleton } from "./TableSkeleton";
/**
* 공통 Table 컴포넌트
*
* 목표:
* - 페이지마다 테이블이 달라도 "동작"은 동일하게 유지
* - 로딩, Empty, RowClick, Selection, Server Sort UI를 표준화
* - 데이터 fetch는 Table이 하지 않음
* - 페이지 상태(page/size/sort/filter)는 URL state가 담당
*
* Table이 책임지는 것:
* - 로딩 표시(영역 Skeleton)
* - Empty 표시
* - Row 클릭 핸들링(옵션)
* - Selection(checkbox) 핸들링(옵션)
* - 서버 정렬 UI 표시 및 정렬 변경 이벤트 발생
*
* Table이 책임지지 않는 것:
* - 데이터 fetch
* - page/size/filter 상태 관리
* - 비즈니스 로직 처리(권한에 따른 컬럼 숨김, API 조건 조합 등)
*/
export interface TableProps<T> {
columns: Array<TableColumn<T>>;
rows: T[];
getRowId: (row: T) => string;
loading?: boolean;
emptyText?: string;
onRowClick?: (row: T) => void;
selection?: RowSelectionState;
sort?: SortValue;
onSortChange?: (next: SortValue) => void;
className?: string;
}
export function Table<T>({
columns,
rows,
getRowId,
loading,
emptyText,
onRowClick,
selection,
sort,
onSortChange,
className,
}: TableProps<T>) {
const allIds = rows.map(getRowId);
const allSelected =
selection
? allIds.length > 0 && allIds.every((id) => selection.selectedIds.has(id))
: false;
function toggleSort(col: TableColumn<T>) {
if (!col.sortable || !onSortChange) return;
const key = col.sortKey ?? col.key;
if (!sort || sort.key !== key) {
return onSortChange({ key, dir: "asc" });
}
if (sort.dir === "asc") {
return onSortChange({ key, dir: "desc" });
}
return onSortChange(null);
}
return (
<div
className={className}
style={{
border: "1px solid rgba(255,255,255,0.12)",
borderRadius: 10,
overflow: "hidden",
}}
>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead style={{ background: "rgba(255,255,255,0.03)" }}>
<tr>
{selection ? (
<th style={{ padding: 10, width: 44 }}>
<input
type="checkbox"
checked={allSelected}
onChange={() => selection.toggleAll(allIds)}
aria-label="select all"
/>
</th>
) : null}
{columns.map((c) => {
const isSorted = sort && (c.sortKey ?? c.key) === sort.key;
const sortMark = isSorted
? sort!.dir === "asc"
? " ▲"
: " ▼"
: "";
return (
<th
key={c.key}
onClick={() => toggleSort(c)}
style={{
padding: 10,
textAlign: c.align ?? "left",
width: c.width,
cursor: c.sortable ? "pointer" : "default",
userSelect: "none",
borderBottom: "1px solid rgba(255,255,255,0.08)",
}}
>
{c.header}
{c.sortable ? sortMark : null}
</th>
);
})}
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td
colSpan={(selection ? 1 : 0) + columns.length}
style={{ padding: 12 }}
>
<TableSkeleton rows={8} />
</td>
</tr>
) : rows.length === 0 ? (
<tr>
<td
colSpan={(selection ? 1 : 0) + columns.length}
style={{ padding: 12 }}
>
<TableEmpty text={emptyText ?? "데이터가 없습니다."} />
</td>
</tr>
) : (
rows.map((row) => {
const id = getRowId(row);
const clickable = Boolean(onRowClick);
return (
<tr
key={id}
onClick={() => onRowClick?.(row)}
style={{
cursor: clickable ? "pointer" : "default",
borderBottom: "1px solid rgba(255,255,255,0.06)",
}}
>
{selection ? (
<td
style={{ padding: 10, width: 44 }}
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={selection.selectedIds.has(id)}
onChange={() => selection.toggle(id)}
aria-label={`select ${id}`}
/>
</td>
) : null}
{columns.map((c) => (
<td
key={c.key}
style={{ padding: 10, textAlign: c.align ?? "left" }}
>
{c.cell(row)}
</td>
))}
</tr>
);
})
)}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,16 @@
/**
* Table Empty 컴포넌트
*
* 데이터가 없을 때 표시되는 컴포넌트
*/
export interface TableEmptyProps {
text?: string;
}
export function TableEmpty({ text }: TableEmptyProps) {
return (
<div style={{ padding: 12, textAlign: "center", color: "#888" }}>
{text ?? "데이터가 없습니다."}
</div>
);
}

View File

@@ -0,0 +1,60 @@
/**
* Table Pagination 컴포넌트
*
* 주의:
* - Pagination은 Table 내부 상태로 두지 않음
* - Pagination은 URL state 업데이트만 호출
*/
export interface TablePaginationProps {
page: number;
totalPages: number;
onChangePage: (page: number) => void;
}
export function TablePagination({ page, totalPages, onChangePage }: TablePaginationProps) {
const tp = Math.max(1, totalPages ?? 1);
return (
<div
style={{
display: "flex",
gap: 8,
justifyContent: "flex-end",
paddingTop: 10,
alignItems: "center",
}}
>
<button
disabled={page <= 1}
onClick={() => onChangePage(1)}
style={{ padding: "4px 8px", cursor: page <= 1 ? "not-allowed" : "pointer" }}
>
{"<<"}
</button>
<button
disabled={page <= 1}
onClick={() => onChangePage(page - 1)}
style={{ padding: "4px 8px", cursor: page <= 1 ? "not-allowed" : "pointer" }}
>
Prev
</button>
<span style={{ padding: "0 8px" }}>
Page {page} / {tp}
</span>
<button
disabled={page >= tp}
onClick={() => onChangePage(page + 1)}
style={{ padding: "4px 8px", cursor: page >= tp ? "not-allowed" : "pointer" }}
>
Next
</button>
<button
disabled={page >= tp}
onClick={() => onChangePage(tp)}
style={{ padding: "4px 8px", cursor: page >= tp ? "not-allowed" : "pointer" }}
>
{">>"}
</button>
</div>
);
}

View File

@@ -0,0 +1,26 @@
/**
* Table Skeleton 컴포넌트
*
* 로딩 중 표시되는 스켈레톤 UI
*/
export interface TableSkeletonProps {
rows?: number;
}
export function TableSkeleton({ rows = 8 }: TableSkeletonProps) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{Array.from({ length: rows }).map((_, i) => (
<div
key={i}
style={{
height: 14,
background: "rgba(255,255,255,0.06)",
borderRadius: 6,
animation: "pulse 1.5s ease-in-out infinite",
}}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,6 @@
export { Table } from "./Table";
export type { TableProps } from "./Table";
export { TableEmpty } from "./TableEmpty";
export { TableSkeleton } from "./TableSkeleton";
export { TablePagination } from "./TablePagination";
export type { TableColumn, SortValue, RowSelectionState, ColumnAlign } from "./types";

View File

@@ -0,0 +1,33 @@
import React from "react";
/**
* Table 컴포넌트 타입 정의
*
* 설계 원칙:
* - Column 정의는 "표준 타입"으로만 작성
* - Column 정의는 페이지마다 임의로 만들지 않음
*/
export type ColumnAlign = "left" | "center" | "right";
export type TableColumn<T> = {
key: string;
header: React.ReactNode;
width?: number | string;
align?: ColumnAlign;
cell: (row: T) => React.ReactNode;
sortable?: boolean;
sortKey?: string;
};
export type SortValue = {
key: string;
dir: "asc" | "desc";
} | null;
export type RowSelectionState = {
selectedIds: Set<string>;
toggle: (id: string) => void;
toggleAll: (ids: string[]) => void;
clear: () => void;
};

View File

@@ -0,0 +1,45 @@
import React from "react";
/**
* Checkbox 공통 컴포넌트
*
* 공통 원칙:
* - id, name, className 전달 옵션 지원
* - 스타일은 CSS로 처리
* - 기본 UI 통일 목적
* - 추가 로직은 옵션 기반으로 확장 가능
*/
export interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "checked" | "onChange"> {
checked?: boolean;
onChange?: (checked: boolean) => void;
label?: React.ReactNode;
}
export function Checkbox({
checked = false,
onChange,
label,
id,
name,
className = "",
...props
}: CheckboxProps) {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange?.(e.target.checked);
};
return (
<label className={`checkbox-wrapper ${className}`.trim()}>
<input
id={id}
name={name}
type="checkbox"
className="checkbox"
checked={checked}
onChange={handleChange}
{...props}
/>
{label && <span className="checkbox-label">{label}</span>}
</label>
);
}

View File

@@ -0,0 +1,183 @@
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
import type { InputHTMLAttributes } from "react";
/**
* Input 공통 컴포넌트
*
* 공통 원칙:
* - id, name, className 전달 옵션 지원
* - 스타일은 CSS로 처리
* - 범용성 위주, 과도한 추상화 지양
* - 동작은 props 옵션으로만 제어
*
* 지원 타입:
* - text: 기본 텍스트 입력
* - password: 비밀번호 입력
* - number: 숫자 입력 (숫자 이외 문자 자동 제거)
*
* text 타입 옵션:
* - formatEmail: 이메일 형식 자동 포맷
* - formatPhone: 전화번호 형식 자동 포맷
* - validationRegex: 정규식 검증 (옵션)
*
* 검색 옵션:
* - debounce, commitOnEnter, clearOnEscape 등
*/
export interface InputSearchOptions {
enabled?: boolean;
debounceMs?: number;
autoCommit?: boolean;
commitOnEnter?: boolean;
clearOnEscape?: boolean;
onCommit?: (value: string) => void;
}
export interface InputFormatOptions {
formatEmail?: boolean;
formatPhone?: boolean;
validationRegex?: RegExp;
}
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> {
type?: "text" | "password" | "number";
value: string;
onChange?: (value: string) => void;
search?: InputSearchOptions;
format?: InputFormatOptions;
}
export function Input({
type = "text",
value,
onChange,
search,
format,
id,
name,
className = "",
...props
}: InputProps) {
const [localValue, setLocalValue] = useState(value);
const timer = useRef<number | null>(null);
// value prop 변경 시 localValue 동기화
useEffect(() => {
setLocalValue(value);
}, [value]);
// 포맷 적용 함수
const applyFormat = (v: string): string => {
if (type !== "text" || !format) return v;
// 이메일 포맷 (자동 포맷은 적용하지 않음, 검증만)
if (format.formatEmail) {
// 이메일 형식 검증은 onChange에서 처리하지 않고, 외부에서 처리
return v;
}
// 전화번호 포맷 (010-1234-5678)
if (format.formatPhone) {
const digits = v.replace(/\D/g, "");
if (digits.length <= 3) return digits;
if (digits.length <= 7) return `${digits.slice(0, 3)}-${digits.slice(3)}`;
if (digits.length <= 11) return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`;
return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7, 11)}`;
}
return v;
};
// 정규식 검증
const validateRegex = (v: string): boolean => {
if (!format?.validationRegex) return true;
return format.validationRegex.test(v);
};
// 검색 커밋
const commit = (v: string) => {
search?.onCommit?.(v);
};
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
let v = e.target.value;
// number 타입: 숫자 이외 문자 제거
if (type === "number") {
v = v.replace(/\D/g, "");
}
// 포맷 적용 (text 타입만)
if (type === "text" && format) {
v = applyFormat(v);
}
// 정규식 검증 (실패 시 이전 값 유지)
if (format?.validationRegex && !validateRegex(v)) {
return;
}
setLocalValue(v);
onChange?.(v);
if (!search?.enabled) {
return;
}
// autoCommit이면 즉시 커밋
if (search.autoCommit) {
commit(v);
return;
}
// debounce 처리
if (search.debounceMs) {
if (timer.current !== null) {
window.clearTimeout(timer.current);
}
timer.current = window.setTimeout(() => {
commit(v);
}, search.debounceMs);
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (!search?.enabled) {
return;
}
// Enter 키 커밋
if (e.key === "Enter" && search.commitOnEnter) {
e.preventDefault();
commit(localValue);
}
// Escape 키 초기화
if (e.key === "Escape" && search.clearOnEscape) {
setLocalValue("");
onChange?.("");
commit("");
}
};
// cleanup
useEffect(() => {
return () => {
if (timer.current !== null) {
window.clearTimeout(timer.current);
}
};
}, []);
return (
<input
id={id}
name={name}
type={type}
className={`input ${className}`.trim()}
value={localValue}
onChange={handleChange}
onKeyDown={handleKeyDown}
{...props}
/>
);
}

View File

@@ -0,0 +1,45 @@
import React from "react";
/**
* Radio 공통 컴포넌트
*
* 공통 원칙:
* - id, name, className 전달 옵션 지원
* - 스타일은 CSS로 처리
* - 기본 UI 통일 목적
* - 추가 로직은 옵션 기반으로 확장 가능
*/
export interface RadioProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "checked" | "onChange"> {
checked?: boolean;
onChange?: (checked: boolean) => void;
label?: React.ReactNode;
}
export function Radio({
checked = false,
onChange,
label,
id,
name,
className = "",
...props
}: RadioProps) {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange?.(e.target.checked);
};
return (
<label className={`radio-wrapper ${className}`.trim()}>
<input
id={id}
name={name}
type="radio"
className="radio"
checked={checked}
onChange={handleChange}
{...props}
/>
{label && <span className="radio-label">{label}</span>}
</label>
);
}

View File

@@ -0,0 +1,64 @@
import React from "react";
/**
* Select 공통 컴포넌트
*
* 공통 원칙:
* - id, name, className 전달 옵션 지원
* - 스타일은 CSS로 처리
* - 기본 select 기능을 공통 컴포넌트화
* - 스타일 통일 목적 위주
*/
export interface SelectOption {
value: string | number;
label: string;
disabled?: boolean;
}
export interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, "onChange"> {
options: SelectOption[];
value?: string | number;
onChange?: (value: string | number) => void;
placeholder?: string;
}
export function Select({
options,
value,
onChange,
placeholder,
id,
name,
className = "",
...props
}: SelectProps) {
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const selectedValue = e.target.value;
// 숫자 옵션이면 숫자로 변환 시도
const numValue = Number(selectedValue);
const finalValue = isNaN(numValue) ? selectedValue : numValue;
onChange?.(finalValue);
};
return (
<select
id={id}
name={name}
className={`select ${className}`.trim()}
value={value ?? ""}
onChange={handleChange}
{...props}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((option) => (
<option key={option.value} value={option.value} disabled={option.disabled}>
{option.label}
</option>
))}
</select>
);
}

View File

@@ -0,0 +1,46 @@
import React from "react";
/**
* Textarea 공통 컴포넌트
*
* 공통 원칙:
* - id, name, className 전달 옵션 지원
* - 스타일은 CSS로 처리
*
* 기본 속성:
* - resize: none
* - width: 100%
*/
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
value: string;
onChange?: (value: string) => void;
}
export function Textarea({
value,
onChange,
id,
name,
className = "",
...props
}: TextareaProps) {
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange?.(e.target.value);
};
return (
<textarea
id={id}
name={name}
className={`textarea ${className}`.trim()}
value={value}
onChange={handleChange}
style={{
resize: "none",
width: "100%",
...props.style,
}}
{...props}
/>
);
}

View File

@@ -0,0 +1,8 @@
/**
* Form 컴포넌트 통합 export
*/
export * from "./Input";
export * from "./Select";
export * from "./Textarea";
export * from "./Checkbox";
export * from "./Radio";

View File

@@ -0,0 +1,23 @@
/**
* 공통 컴포넌트 통합 export
*
* 모든 공통 컴포넌트는 여기서 export하여 사용 편의성 제공
*
* 디렉터리 구조:
* - form/: 폼 입력 컴포넌트 (Input, Select, Textarea, Checkbox, Radio)
* - action/: 액션 컴포넌트 (Button)
* - display/: 표시 컴포넌트 (Table)
* - system/: 시스템 컴포넌트 (LoadingOverlay, LoadingProvider, Toast, CommonModal)
*/
// Form 컴포넌트
export * from "./form";
// Action 컴포넌트
export * from "./action";
// Display 컴포넌트
export * from "./display/table";
// System 컴포넌트
export * from "./system";

View File

@@ -0,0 +1,237 @@
import React, { useEffect } from "react";
/**
* CommonModal 공통 컴포넌트
*
* 공통 원칙:
* - id, name, className 전달 옵션 지원
* - 스타일은 CSS로 처리
*
* 구조:
* - 헤더: 타이틀, X 닫기 버튼
* - 컨텐츠: children
* - 푸터: 확인/닫기 버튼 (옵션)
*
* 사이즈:
* - 기본: width 50vw, height 50vh
* - 커스텀 사이즈 옵션 제공
*/
export interface CommonModalProps {
open: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
// 푸터 옵션
showFooter?: boolean;
onConfirm?: () => void;
confirmText?: string;
cancelText?: string;
// 사이즈 옵션
width?: string | number;
height?: string | number;
// 공통 속성
id?: string;
className?: string;
}
export function CommonModal({
open,
onClose,
title,
children,
showFooter = true,
onConfirm,
confirmText = "확인",
cancelText = "닫기",
width = "50vw",
height = "50vh",
id,
className = "",
}: CommonModalProps) {
// ESC 키로 닫기
useEffect(() => {
if (!open) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleEscape);
return () => {
window.removeEventListener("keydown", handleEscape);
};
}, [open, onClose]);
// 모달 열릴 때 body 스크롤 방지
useEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [open]);
if (!open) return null;
const handleConfirm = () => {
if (onConfirm) {
onConfirm();
} else {
onClose();
}
};
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
onClose();
}
};
const modalStyle: React.CSSProperties = {
width: typeof width === "number" ? `${width}px` : width,
height: typeof height === "number" ? `${height}px` : height,
};
return (
<div
className={`modal-backdrop ${className}`.trim()}
onClick={handleBackdropClick}
style={{
position: "fixed",
inset: 0,
background: "rgba(0, 0, 0, 0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 10000,
}}
>
<div
id={id}
className={`modal ${className}`.trim()}
style={{
background: "white",
borderRadius: 8,
boxShadow: "0 4px 20px rgba(0, 0, 0, 0.15)",
display: "flex",
flexDirection: "column",
maxWidth: "90vw",
maxHeight: "90vh",
...modalStyle,
}}
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 */}
<div
className="modal-header"
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "16px 20px",
borderBottom: "1px solid #e0e0e0",
}}
>
{title && (
<h3
className="modal-title"
style={{
margin: 0,
fontSize: "18px",
fontWeight: 600,
}}
>
{title}
</h3>
)}
<button
type="button"
className="modal-close"
onClick={onClose}
aria-label="닫기"
style={{
background: "none",
border: "none",
fontSize: "24px",
cursor: "pointer",
padding: 0,
width: "24px",
height: "24px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
×
</button>
</div>
{/* 컨텐츠 */}
<div
className="modal-content"
style={{
flex: 1,
padding: "20px",
overflow: "auto",
}}
>
{children}
</div>
{/* 푸터 */}
{showFooter && (
<div
className="modal-footer"
style={{
display: "flex",
justifyContent: onConfirm ? "flex-end" : "center",
gap: "8px",
padding: "16px 20px",
borderTop: "1px solid #e0e0e0",
}}
>
{onConfirm && (
<button
type="button"
className="modal-btn modal-btn-cancel"
onClick={onClose}
style={{
padding: "8px 16px",
border: "1px solid #ccc",
background: "white",
borderRadius: 4,
cursor: "pointer",
}}
>
{cancelText}
</button>
)}
<button
type="button"
className="modal-btn modal-btn-confirm"
onClick={handleConfirm}
style={{
padding: "8px 16px",
border: "none",
background: "#007bff",
color: "white",
borderRadius: 4,
cursor: "pointer",
}}
>
{confirmText}
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
/**
* 전역 로딩 Overlay 컴포넌트
*
* 설계 목적:
* - "표현 전용 컴포넌트"
* - 로딩 상태를 내부에서 판단하지 않음
* - visible prop만 보고 렌더링 여부 결정
* - 디자인 시스템에 맞게 교체 가능
*
* 규칙:
* - 로딩 상태를 내부에서 판단하지 않음
* - visible prop만 보고 렌더링 여부 결정
*/
export interface LoadingOverlayProps {
visible: boolean;
}
export function LoadingOverlay({ visible }: LoadingOverlayProps) {
if (!visible) return null;
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.35)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 9999,
pointerEvents: "auto",
}}
>
<div
style={{
background: "white",
padding: "20px 40px",
borderRadius: 8,
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
}}
>
Loading...
</div>
</div>
);
}

View File

@@ -0,0 +1,72 @@
import { createContext, useContext, useState, useCallback, useMemo, useEffect } from "react";
import { setLoadingControls } from "../api/interceptors";
/**
* 전역 로딩 Provider
*
* 설계 목적:
* - 네트워크 통신이 발생하면 사용자는 항상 "지금 처리 중"임을 인지
* - 페이지별 로딩 처리로 인한 UX 불일치와 코드 중복 제거
* - Vibe Coding / AI 코드 생성 시 "각 페이지에서 로딩 처리" 패턴 원천 차단
*
* 동작 원칙:
* - 요청 시작 시 count +1
* - 요청 종료 시 count -1
* - count > 0 이면 로딩 표시
* - 중첩 요청에서도 안정적으로 동작
*
* 책임:
* - 로딩 상태 단일 관리
* - Axios Interceptor와 직접 연결
* - UI 표현은 담당하지 않음
*/
interface LoadingContextValue {
start: () => void;
end: () => void;
visible: boolean;
}
const LoadingContext = createContext<LoadingContextValue>({
start: () => {},
end: () => {},
visible: false,
});
export function LoadingProvider({ children }: { children: React.ReactNode }) {
const [count, setCount] = useState(0);
const start = useCallback(() => {
setCount((c) => c + 1);
}, []);
const end = useCallback(() => {
setCount((c) => Math.max(0, c - 1));
}, []);
// interceptor에 로딩 제어 함수 등록
useEffect(() => {
setLoadingControls(start, end);
return () => {
setLoadingControls(() => {}, () => {});
};
}, [start, end]);
const value = useMemo(
() => ({
start,
end,
visible: count > 0,
}),
[start, end, count]
);
return (
<LoadingContext.Provider value={value}>
{children}
</LoadingContext.Provider>
);
}
export function useLoading() {
return useContext(LoadingContext);
}

View File

@@ -0,0 +1,103 @@
import {
createContext,
useCallback,
useMemo,
useState,
type ReactNode,
} from "react";
/**
* Toast 메시지 시스템
*
* 목적:
* - 사용자에게 "즉시 피드백" 제공
* - 성공/실패/경고 메시지를 화면 어디서든 통일되게 출력
* - 페이지마다 alert/console.log로 처리하는 것을 금지
*
* 규칙:
* - 성공: 저장/삭제/등록 완료 정도만 짧게 표시
* - 실패: 네트워크/권한/유효성 등 최소 정보로 표시
* - 경고: 사용자 선택이 필요할 때만
*/
export interface ToastItem {
id: string;
message: string;
type?: "success" | "error" | "warning" | "info";
}
interface ToastContextValue {
push: (message: string, type?: ToastItem["type"]) => void;
remove: (id: string) => void;
}
export const ToastContext = createContext<ToastContextValue>({
push: () => {},
remove: () => {},
});
export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<ToastItem[]>([]);
const remove = useCallback((id: string) => {
setItems((prev) => prev.filter((x) => x.id !== id));
}, []);
const push = useCallback(
(message: string, type: ToastItem["type"] = "info") => {
const id = `${Date.now()}_${Math.random().toString(16).slice(2)}`;
setItems((prev) => [...prev, { id, message, type }]);
// 3.5초 후 자동 제거
window.setTimeout(() => remove(id), 3500);
},
[remove]
);
const value = useMemo(() => ({ push, remove }), [push, remove]);
return (
<ToastContext.Provider value={value}>
{children}
<div
style={{
position: "fixed",
right: 14,
bottom: 14,
display: "flex",
flexDirection: "column",
gap: 10,
zIndex: 9998,
pointerEvents: "none",
}}
>
{items.map((t) => (
<div
key={t.id}
style={{
border: "1px solid rgba(255,255,255,0.16)",
background:
t.type === "error"
? "rgba(220, 38, 38, 0.9)"
: t.type === "success"
? "rgba(34, 197, 94, 0.9)"
: t.type === "warning"
? "rgba(234, 179, 8, 0.9)"
: "rgba(0,0,0,0.65)",
color: "white",
padding: "10px 12px",
borderRadius: 10,
maxWidth: 360,
cursor: "pointer",
pointerEvents: "auto",
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
}}
onClick={() => remove(t.id)}
role="alert"
>
{t.message}
</div>
))}
</div>
</ToastContext.Provider>
);
}

View File

@@ -0,0 +1,8 @@
/**
* System 컴포넌트 통합 export
*/
export * from "./CommonModal";
export * from "./LoadingOverlay";
export * from "./LoadingProvider";
export * from "./Toast";
export * from "./useToast";

View File

@@ -0,0 +1,17 @@
import { useContext } from "react";
import { ToastContext } from "./Toast";
/**
* Toast 훅
*
* ToastProvider의 Context를 사용하여 Toast 기능을 제공합니다.
*
* 사용법:
* ```tsx
* const toast = useToast();
* toast.push("메시지", "success");
* ```
*/
export function useToast() {
return useContext(ToastContext);
}

View File

@@ -0,0 +1,60 @@
import { useCallback } from "react";
import { mapAxiosError } from "../utils/errorMapper";
import { useToast } from "../components";
import type { AppError } from "../types/error";
/**
* 공통 에러 처리 훅
*
* 역할:
* - AxiosError를 AppError로 변환
* - Toast 메시지 자동 표시 (옵션)
* - 에러 정보 반환
*
* 사용법:
* ```tsx
* const { handle } = useAppError();
*
* try {
* await apiCall();
* } catch (error) {
* const appError = handle(error);
* // 추가 처리
* }
* ```
*/
export interface UseAppErrorOptions {
toast?: boolean;
fallbackMessage?: string;
}
export function useAppError() {
const toast = useToast();
const handle = useCallback(
(error: unknown, opts?: UseAppErrorOptions): AppError => {
const appErr = mapAxiosError(error);
// Toast 표시 (기본값: true)
if (opts?.toast !== false) {
const message = opts?.fallbackMessage ?? appErr.message;
const type =
appErr.kind === "unauthorized" ||
appErr.kind === "forbidden" ||
appErr.kind === "server" ||
appErr.kind === "network" ||
appErr.kind === "timeout"
? "error"
: appErr.kind === "validation"
? "warning"
: "error";
toast.push(message, type);
}
return appErr;
},
[toast]
);
return { handle };
}

View File

@@ -0,0 +1,120 @@
import { useSearchParams } from "react-router-dom";
/**
* URL 기반 필터 쿼리 훅
*
* 역할:
* - URL → 상태 파싱
* - 상태 → URL 반영
* - 타입 정규화
* - page 리셋 규칙 강제 (q, f_*, size 변경 시 page=1)
* - 조합형 검색 통합
*
* 핵심 원칙:
* - 리스트 상태의 단일 진실은 URL SearchParams
* - 컴포넌트 내부 state는 보조 표현일 뿐
* - 뒤로가기/앞으로가기/새로고침 시 100% 동일 상태 복원
*
* page 리셋 규칙 (중요):
* - q 변경 시 page=1
* - f_* 변경 시 page=1
* - size 변경 시 page=1
*/
export interface FilterQueryState {
q: string;
page: number;
size: number;
sort?: string;
filters: Record<string, string>;
}
export interface FilterQueryUpdate {
q?: string;
page?: number;
size?: number;
sort?: string;
filters?: Record<string, string | null>;
}
export interface UseFilterQueryOptions {
defaults?: {
size?: number;
sort?: string;
};
}
export function useFilterQuery(options: UseFilterQueryOptions = {}) {
const [params, setParams] = useSearchParams();
const defaults = options.defaults ?? {};
// URL → 상태 파싱
const state: FilterQueryState = {
q: params.get("q") ?? "",
page: Number(params.get("page") ?? 1) || 1,
size: Number(params.get("size") ?? defaults.size ?? 20) || 20,
sort: params.get("sort") ?? defaults.sort,
filters: Object.fromEntries(
[...params.entries()].filter(([k]) => k.startsWith("f_"))
),
};
// 상태 → URL 반영
const update = (next: FilterQueryUpdate) => {
const p = new URLSearchParams(params);
// q 변경 시 page=1 리셋
if (next.q !== undefined) {
if (next.q === "") {
p.delete("q");
} else {
p.set("q", next.q);
}
p.set("page", "1");
}
// page 변경
if (next.page !== undefined) {
p.set("page", String(next.page));
}
// size 변경 시 page=1 리셋
if (next.size !== undefined) {
p.set("size", String(next.size));
p.set("page", "1");
}
// sort 변경
if (next.sort !== undefined) {
if (next.sort === "" || next.sort == null) {
p.delete("sort");
} else {
p.set("sort", next.sort);
}
}
// filters 변경 시 page=1 리셋
if (next.filters !== undefined) {
// 기존 f_* 파라미터 제거
const keysToDelete = [...p.keys()].filter((k) => k.startsWith("f_"));
keysToDelete.forEach((k) => p.delete(k));
// 새로운 filters 설정
Object.entries(next.filters).forEach(([k, v]) => {
if (v == null || v === "") {
p.delete(k);
} else {
p.set(k, String(v));
}
});
// filters 변경 시 page=1 리셋
if (Object.keys(next.filters).length > 0) {
p.set("page", "1");
}
}
setParams(p);
};
return { state, update };
}

View File

@@ -0,0 +1,23 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "./queryClient";
/**
* TanStack Query Provider 래퍼
*
* 옵션으로 사용 가능하도록 래핑
* 환경 변수 VITE_USE_REACT_QUERY로 제어
*/
export function QueryProvider({ children }: { children: React.ReactNode }) {
const useReactQuery = import.meta.env.VITE_USE_REACT_QUERY === "true";
if (!useReactQuery) {
// TanStack Query 비활성화 시 그대로 children 반환
return <>{children}</>;
}
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,48 @@
import { useQuery } from "@tanstack/react-query";
import { qk } from "../keys";
import { http } from "../../api/http";
import { API } from "../../api/endpoints";
/**
* 문서 목록 Query Hook
*
* 주의:
* - TanStack Query가 비활성화된 경우 사용 불가
* - 환경 변수 VITE_USE_REACT_QUERY=true 필요
*/
export interface DocumentRow {
id: string | number;
title: string;
[key: string]: unknown;
}
export interface DocumentListResponse {
items: DocumentRow[];
total: number;
}
export function useDocuments(params: {
q: string;
page: number;
size: number;
sort?: string;
filters: Record<string, any>;
}) {
const useReactQuery = import.meta.env.VITE_USE_REACT_QUERY === "true";
if (!useReactQuery) {
throw new Error(
"TanStack Query is not enabled. Set VITE_USE_REACT_QUERY=true in .env"
);
}
return useQuery({
queryKey: qk.documents(params),
queryFn: async () => {
const res = await http.get(API.DOCUMENT.LIST, { params });
// ApiResponse 구조에 맞게 data.data 접근
return (res.data?.data ?? { items: [], total: 0 }) as DocumentListResponse;
},
keepPreviousData: true,
});
}

33
src/common/query/keys.ts Normal file
View File

@@ -0,0 +1,33 @@
/**
* QueryKey 팩토리
*
* 원칙:
* - queryKey의 shape는 항상 동일해야 함
* - params는 URL state를 그대로 사용하되 "정규화된 값"만 포함
* - filters는 useFilterQuery에서 만든 "빈값 제거/타입 통일"된 값을 사용
*
* 정리:
* - 리스트: ["documents", {q,page,size,sort,filters}]
* - 상세: ["document", id]
*/
export const qk = {
documents: (params: {
q: string;
page: number;
size: number;
sort?: string;
filters: Record<string, any>;
}) => ["documents", params] as const,
document: (id: string | number) => ["document", id] as const,
users: (params: {
q: string;
page: number;
size: number;
sort?: string;
filters: Record<string, any>;
}) => ["users", params] as const,
user: (id: string | number) => ["user", id] as const,
};

View File

@@ -0,0 +1,28 @@
import { QueryClient } from "@tanstack/react-query";
/**
* TanStack Query Client 설정
*
* 정책:
* - staleTime 30초: 뒤로가기 UX에 유리 (짧은 시간은 캐시를 신선하게 취급)
* - retry 1: 네트워크 튐 보완
* - refetchOnWindowFocus false: 업무 시스템에서 포커스만으로 리패치 반복 방지
*
* 전제:
* - 검색/필터/페이지/정렬 상태는 URL이 단일 진실
* - TanStack Query는 URL state를 입력으로 받아 fetch
* - 전역 로딩(Overlay)은 axios interceptor가 처리
* - Query의 loading은 TableSkeleton 같은 "영역 UX"에만 사용
*/
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
refetchOnWindowFocus: false,
},
mutations: {
retry: 0,
},
},
});

13
src/common/types/api.ts Normal file
View File

@@ -0,0 +1,13 @@
/**
* API 응답 타입 정의
*
* 백엔드 ApiResponse<T> 구조와 일치
*/
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
};
}

29
src/common/types/error.ts Normal file
View File

@@ -0,0 +1,29 @@
/**
* 공통 에러 타입
*
* 설계 목적:
* - AxiosError를 공통 구조(AppError)로 매핑
* - 사용자에게는 안전한 메시지만 표시
* - 필요 시 토스트로 즉시 피드백
* - 로깅은 민감정보를 완전히 제거한 형태로만
*
* 규정:
* - 서버 메시지를 그대로 사용자에게 노출하지 않음
* - 서버 message는 내부 정보(SQL, stack trace, 경로 등)를 포함할 수 있음
* - 프론트는 상태코드/코드 기반으로 사용자 메시지를 통제
*/
export type AppError = {
kind:
| "network"
| "timeout"
| "unauthorized"
| "forbidden"
| "notfound"
| "validation"
| "server"
| "unknown";
status?: number;
code?: string;
message: string;
details?: unknown;
};

View File

@@ -0,0 +1,115 @@
import type { AxiosError } from "axios";
import type { AppError } from "../types/error";
/**
* AxiosError → AppError 매핑
*
* 규정:
* - 서버 메시지를 그대로 사용자에게 노출하지 않음
* - 상태코드/코드 기반으로 사용자 메시지 통제
* - 에러 종류를 최소 공통 분류로 통일
*/
function isTimeout(err: AxiosError): boolean {
return (
err.code === "ECONNABORTED" ||
(typeof err.message === "string" &&
err.message.toLowerCase().includes("timeout"))
);
}
export function mapAxiosError(error: unknown): AppError {
const ax = error as AxiosError<any>;
// 응답이 없는 경우 (네트워크 오류, 타임아웃)
if (!ax.response) {
if (isTimeout(ax)) {
return {
kind: "timeout",
message: "요청 시간이 초과되었습니다. 잠시 후 다시 시도해주세요.",
};
}
return {
kind: "network",
message: "네트워크 오류가 발생했습니다. 연결 상태를 확인해주세요.",
};
}
const status = ax.response.status;
const data = ax.response.data;
// 서버 메시지 추출 (안전하게)
const serverMessage: string | undefined =
(typeof data?.error?.message === "string" && data.error.message) ||
(typeof data?.message === "string" && data.message) ||
(typeof data?.error === "string" && data.error) ||
undefined;
// 상태코드별 에러 분류
if (status === 401) {
return {
kind: "unauthorized",
status,
message: serverMessage ?? "로그인이 필요합니다.",
};
}
if (status === 403) {
return {
kind: "forbidden",
status,
message: serverMessage ?? "권한이 없습니다.",
};
}
if (status === 404) {
return {
kind: "notfound",
status,
message: serverMessage ?? "요청한 리소스를 찾을 수 없습니다.",
};
}
if (status === 409) {
return {
kind: "validation",
status,
message: serverMessage ?? "충돌이 발생했습니다. 다시 시도해주세요.",
details: data,
};
}
if (status === 429) {
return {
kind: "server",
status,
message: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요.",
};
}
// 4xx 클라이언트 오류
if (status >= 400 && status < 500) {
return {
kind: "validation",
status,
message: serverMessage ?? "요청 값이 올바르지 않습니다.",
details: data,
};
}
// 5xx 서버 오류
if (status >= 500) {
return {
kind: "server",
status,
message: serverMessage ?? "서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.",
};
}
// 알 수 없는 오류
return {
kind: "unknown",
status,
message: serverMessage ?? "알 수 없는 오류가 발생했습니다.",
details: data,
};
}