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

42
src/app/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

35
src/app/App.tsx Normal file
View File

@@ -0,0 +1,35 @@
import { useState } from 'react'
import reactLogo from '../assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
function App() {
const [count, setCount] = useState(0)
return (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/app/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
}
export default App

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

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,
};
}

68
src/index.css Normal file
View File

@@ -0,0 +1,68 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

51
src/main.tsx Normal file
View File

@@ -0,0 +1,51 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import "./index.css";
import App from "./app/App.tsx";
import { LoadingProvider, useLoading, LoadingOverlay, ToastProvider } from "./common/components";
import { AuthProvider } from "./common/auth/AuthProvider";
import { QueryProvider } from "./common/query/QueryProvider";
/**
* Root 컴포넌트
* - LoadingOverlay 표시
*/
export function Root() {
const { visible } = useLoading();
return (
<>
<LoadingOverlay visible={visible} />
<App />
</>
);
}
/**
* Provider 조합 순서 (구조적으로 고정):
* 1. BrowserRouter (라우팅)
* 2. LoadingProvider (최상단에서 모든 요청 감시)
* 3. QueryProvider (TanStack Query - 옵션, 환경 변수로 제어)
* 4. AuthProvider (서버 상태 기반)
* 5. ToastProvider (토스트 메시지)
* 6. Root (App + LoadingOverlay)
*
* TanStack Query 활성화:
* - .env.development 또는 .env.production에 VITE_USE_REACT_QUERY=true 추가
*/
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<LoadingProvider>
<QueryProvider>
<AuthProvider>
<ToastProvider>
<Root />
</ToastProvider>
</AuthProvider>
</QueryProvider>
</LoadingProvider>
</BrowserRouter>
</StrictMode>
);