ERP 시스템 프론트엔드. Claude Code / Codex 등 AI 에이전트와 팀원 4명이 함께 따르는 규칙입니다. 코드를 만들기 전에 이 문서를 먼저 확인하세요. 대부분의 코드는 AI로 작성하므로, 이 문서는 "참고 사항"이 아니라 반드시 지켜야 하는 규칙입니다.
AI는 이미 있는 걸 안 찾아보고 새로 만드는 경향이 있습니다. 그래서 항상:
- 공용 컴포넌트가 이미 있는지 먼저 확인.
shared/ui에Fg*컴포넌트(FgButton,FgInput등)가 있으면 무조건 재사용합니다. 비슷한 걸 새로 만들지 않습니다. - 디자인 토큰이 이미 있는지 먼저 확인. 색·폰트는
tailwind.config에 정의된 토큰 클래스(bg-primary,text-body)만 씁니다.#3B82F6,text-[14px]같은 하드코딩 금지. - 데이터와 화면을 한 파일에 섞지 않기. 데이터 가져오기/가공은
api·model에, 화면은ui에. (자세한 건 2번 섹션)
아래 목록에 없는 라이브러리는 함부로 설치하지 마세요. 새 라이브러리가 필요하면 먼저 팀에 제안합니다.
| 용도 | 사용 |
|---|---|
| Framework | React + TypeScript (strict) |
| Build / Dev | Vite |
| 서버 데이터 (API) | TanStack Query |
| 전역 상태 (진짜 전역만) | Zustand |
| 라우팅 | TanStack Router |
| HTTP 클라이언트 | axios (shared/api 단일 인스턴스) |
| 스타일 | Tailwind CSS (handoff 토큰 기반) |
| 공용 컴포넌트 동작/접근성 | Radix UI (스타일 없는 프리미티브) |
| 폼 | react-hook-form + zod + @hookform/resolvers |
| 알림(toast) | sonner |
| 표 | TanStack Table |
| 날짜 처리 | dayjs |
| 날짜 선택 | react-day-picker |
| 차트 | Recharts |
| 아이콘 | lucide-react |
테스트, 다국어(i18n), 테이블 가상화, 엑셀 내보내기(프론트)는 지금 도입하지 않습니다. 엑셀 내보내기는 백엔드에서 파일을 생성해 내려줍니다.
이 프로젝트에서 가장 중요한 규칙입니다. 데이터(가져오기·가공·상태)와 화면(렌더링)을 한 파일에 섞지 않습니다.
| 영역 | 책임 | 위치 |
|---|---|---|
| Data | API 호출, 캐시, 타입, 가공 로직 | api/, model/ |
| Presentation | 화면 그리기, 레이아웃, 스타일 | ui/ |
규칙을 한 문장으로: ui/ 컴포넌트는 "어떻게 보일지"만 알고, 데이터는 model/의 훅을 통해 받기만 한다.
ui/컴포넌트 안에서axios나useQuery를 직접 호출하지 않습니다. 대신api/model의 훅을 호출합니다.- 데이터 계산·가공 로직은 컴포넌트 안에 두지 말고
model/로 뺍니다.
// ❌ 나쁜 예: 화면 안에서 데이터를 직접 가져옴
function OrderList() {
const [orders, setOrders] = useState([]);
useEffect(() => { axios.get('/api/orders').then(r => setOrders(r.data)); }, []);
return <ul>{orders.map(...)}</ul>;
}
// ✅ 좋은 예: 데이터는 훅(model)에서, 화면은 받기만
function OrderList() {
const { data, isLoading } = useOrdersQuery(); // data 영역
if (isLoading) return <FgSpinner />;
return <ul>{data?.map(...)}</ul>; // presentation
}레이어는 4개뿐입니다. 각 기능 폴더 안에서 api / model / ui로 나눕니다.
src/
├── app/ # 앱 시작점: provider, 라우터 설정, 권한 가드, 전역 스타일
│
├── pages/ # 라우트별 화면. 역할군으로 나눔
│ ├── admin/ # ADMIN 전용 화면
│ ├── hq/ # 본사(HQ_MANAGER, HQ_STAFF) 화면
│ └── branch/ # 지점(BRANCH_MANAGER, BRANCH_STAFF) 화면
│
├── features/ # 도메인 기능 단위 (예: order, member, inventory)
│ └── order/
│ ├── api/ # [data] axios 호출 + useQuery / useMutation
│ ├── model/ # [data] 타입, 가공 훅
│ ├── ui/ # [presentation] 컴포넌트 (.tsx)
│ └── index.ts # 외부로 내보낼 것만 모음 (public API)
│
└── shared/ # 어디서나 쓰는 공통
├── ui/ # Fg 공용 컴포넌트 (FgButton, FgInput ...)
├── api/ # axios 인스턴스, 인터셉터, 공통 에러 타입
├── lib/ # 순수 유틸 (날짜 포맷 등), 권한 헬퍼
├── config/ # 환경변수 읽기, 상수
└── types/ # 공용 타입
import 규칙 (간단 버전):
pages→features→shared방향으로만 import 합니다. (위에서 아래로)features끼리 서로 import 하지 않습니다. 공통이 필요하면shared로 뺍니다.- 기능 폴더 바깥에서 가져다 쓸 땐
index.ts를 통합니다. (features/order/ui/Form.tsx처럼 내부 파일 직접 import 금지)
- 서버 데이터는 전부 TanStack Query로 다룹니다.
useState+useEffect수동 패칭 금지. - 조회는
useXxxQuery, 변경은useXxxMutation으로 이름 짓습니다. - 원시 HTTP 호출은
shared/api의 axios 인스턴스만 사용합니다. 컴포넌트에서axios를 직접 부르지 않습니다. - 타입은 사용하는 기능 폴더의
model/에 두고, 여러 곳에서 공용일 때만shared/types로 옮깁니다.
// features/order/api/use-orders-query.ts
import { api } from "@/shared/api";
export function useOrdersQuery() {
return useQuery({
queryKey: ["orders"],
queryFn: async () => {
const res = await api.get<Order[]>("/orders");
return res.data;
},
});
}- 컴포넌트는 props와 model 훅의 반환값만으로 그립니다.
- 데이터 패칭과 화면을 같이 해야 하면, 데이터를 가져오는 부분 + 그리는 순수 컴포넌트로 나눕니다.
- 스타일은 컴포넌트 옆에 둡니다. (Tailwind 클래스로 직접 작성)
- 팀에서 반복해서 쓰는 버튼/입력/모달 등은
shared/ui에Fg접두사로 만듭니다:FgButton,FgInput,FgModal,FgSelect,FgDatePicker. - 동작·접근성은 Radix UI 프리미티브로, 디자인은 handoff 토큰(Tailwind)으로 입힙니다. 모달·드롭다운·셀렉트를 맨손으로 만들지 마세요(키보드·포커스 버그가 납니다).
- shadcn/ui 레시피를 복사해 와서 이름을
Fg*로 바꾸고, 색·폰트만 우리 토큰으로 맞추는 방식이 가장 안전합니다. - MUI·Ant Design 같은 "스타일까지 입힌 통짜 UI 라이브러리는 쓰지 않습니다. handoff 디자인과 충돌합니다.
- 색·간격·폰트는
tailwind.config의 토큰 클래스만 사용:bg-primary,text-danger,text-body,rounded-card등. #3B82F6,text-[13px],mt-[7px]같은 임의 값 하드코딩 금지. 필요한 토큰이 없으면 먼저tailwind.config에 추가하고 씁니다.- handoff 파일에 있는 color/font는
tailwind.config의theme.extend에 등록해 두고 모두 그걸 씁니다.
- 세션은 서버가 HttpOnly 쿠키로 관리합니다. 토큰·인증정보를
localStorage등 클라이언트에 저장하지 않습니다. (XSS 방어) - axios 인스턴스에
withCredentials: true를 설정해 쿠키가 자동 전송되게 합니다. - 로그인 여부의 단일 출처는 서버입니다.
useMeQuery()(/me같은 엔드포인트) 결과로 판단하고, 이 값을 Zustand에 중복 저장하지 않습니다. - 백엔드가 CSRF 토큰을 쓰면 요청 인터셉터에서 자동으로 붙입니다.
// shared/api/index.ts
import axios from "axios";
export const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL, // 예: '/api'
withCredentials: true,
});역할은 5가지: ADMIN, HQ_MANAGER, HQ_STAFF, BRANCH_MANAGER, BRANCH_STAFF.
화면은 역할군별로 통째로 다릅니다. 두 단계로 처리합니다.
- 라우트 가드 (역할군 단위 접근 차단) —
app에서 라우트를 보호합니다. 권한 없는 역할이pages/admin에 들어오면 차단/리다이렉트. - 화면 안의 세부 차이 (예: HQ 화면에서 MANAGER만 보이는 버튼) — 가장 단순하게 역할 비교로 처리합니다.
const { role } = useAuth(); // useMeQuery 기반
{
role === "HQ_MANAGER" && <FgButton>승인</FgButton>;
}처음부터
<Can permission="...">같은 복잡한 추상화를 만들지 않습니다. 위 방식으로 시작하고, 중복이 많이 쌓이면 그때shared/lib에 헬퍼로 묶습니다.중요: 프론트의 권한 체크는 **화면을 깔끔하게 보여주기 위한 것(UX)**일 뿐입니다. 진짜 접근 차단은 백엔드가 합니다. 프론트에서 버튼을 숨겼다고 보안이 되는 게 아닙니다.
백엔드(Spring Boot)는 에러를 다음 형태로 내려줍니다:
// shared/api/error.ts
export interface ApiError {
status: number; // 400, 401, 403 ...
code: string; // 예: "SO-03-01"
message: string; // 사용자에게 보여줄 메시지
}처리는 전역과 지역으로 나눕니다.
- 401 → 쿼리 캐시 비우고 로그인 페이지로 리다이렉트.
- 403 → 권한 없음 안내 (toast 또는 안내 페이지).
- 그 외 처리되지 않은 에러 → sonner toast로
message표시.
api.interceptors.response.use(
(res) => res,
(error) => {
const status = error.response?.status;
if (status === 401) {
/* 캐시 clear 후 로그인 이동 */
}
if (status === 403) {
/* 권한 없음 안내 */
}
// ApiError 형태로 통일해서 다시 던짐 → 화면에서 받을 수 있게
return Promise.reject(error.response?.data as ApiError);
},
);- 400 같은 입력 검증 에러는 인터셉터가 가로채지 않고 그대로 던져집니다. 호출한 화면(폼)에서 받아 처리합니다.
- 백엔드 응답에 "어느 필드에서 난 에러인지"는 없습니다. 그래서 필드별로 붙이지 않고,
message를 폼 상단에 통째로 보여줍니다.
const [formError, setFormError] = useState<string | null>(null);
const onSubmit = handleSubmit(async (values) => {
setFormError(null);
try {
await createOrder(values);
toast.success("저장되었습니다");
} catch (e) {
setFormError((e as ApiError).message); // 폼 상단에 표시
}
});- 입력이 여러 개거나 검증이 필요한 화면은 react-hook-form + zod를 씁니다. (
@hookform/resolvers/zod) - 입력이 1~2개로 아주 단순하면
useState로 처리해도 됩니다. 무조건 폼 라이브러리를 쓸 필요는 없습니다. - 검증 규칙(zod 스키마)은 해당 기능의
model/에 둡니다.
- 사용자 알림은 sonner 하나로 통일합니다. 다른 toast 라이브러리를 추가하지 않습니다.
- 성공:
toast.success('저장되었습니다'), 실패:toast.error(message). - 에러 toast는 8번의 전역 처리와 연결됩니다. 화면마다 따로 에러 toast를 띄우기보다 일관된 방식을 따릅니다.
- 프론트엔드 빌드 결과는 브라우저로 그대로 내려갑니다.
.env는 비밀 보관소가 아닙니다. 여기 넣은 값은 사용자가 다 볼 수 있습니다. - 그래서 API 키 같은 진짜 비밀은 프론트에 두지 않고 백엔드에 둡니다.
- Vite에서는
VITE_접두사가 붙은 변수만 코드에서 읽힙니다:import.meta.env.VITE_API_BASE_URL. .gitignore에.env추가, 대신 키 이름만 적힌.env.example을 커밋해 팀원이 무엇을 채워야 하는지 알게 합니다.
# .env.example
VITE_API_BASE_URL=- 프론트와 API가 같은 도메인(nginx + gateway로
/api호출)이라 세션 쿠키 문제가 없습니다. - Vercel (SPA): 새로고침 시 라우트가 깨지지 않도록
vercel.json에 rewrite를 둡니다. 환경변수는 Vercel 대시보드에 등록합니다.
// vercel.json
{
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}- 로컬 개발: 프론트(
localhost:5173)와 API 도메인이 달라 쿠키·CORS에서 막히기 쉽습니다.vite.config의 dev proxy로/api를 백엔드로 넘겨 "같은 출처"처럼 만듭니다.
// vite.config.ts (server 옵션)
server: {
proxy: {
'/api': { target: 'http://localhost:8080', changeOrigin: true },
},
}- 폴더·일반 파일:
kebab-case(use-orders-query.ts) - 컴포넌트 파일:
PascalCase.tsx(OrderList.tsx), 공용 컴포넌트는Fg접두사 - 훅:
useXxx/ 타입:PascalCase/ 상수:UPPER_SNAKE_CASE - import는 절대경로(
@/features/...)를 씁니다.../../../지양. strict: true,any금지 (모르면unknown후 좁히기). 컴포넌트 props와 API 응답 타입은 명시합니다.
- 작업은 기능(feature) 단위로 나눠 충돌을 줄입니다. 한 기능은 한 사람이 맡는 걸 기본으로 합니다.
shared/(특히shared/ui,shared/api) 변경은 전원에게 영향을 주므로, 수정 시 영향 범위를 공유합니다.- (브랜치·커밋 규칙은 팀 내 별도 규약을 따릅니다.)
일관된 결과를 얻으려면 지시할 때 위치와 재사용을 명시하세요. 예시:
- "주문 목록 화면 만들어줘.
features/order에api/model/ui로 나누고, 표는 TanStack Table, 버튼·입력은shared/ui의Fg컴포넌트 재사용해줘." - "이 입력 폼 react-hook-form + zod로 만들고, 에러는 폼 상단에 message로 보여줘."
- "이 색이랑 폰트,
tailwind.config토큰에 없으면 먼저 추가하고 토큰 클래스로 써줘. 하드코딩하지 마."
AI가 새 컴포넌트나 라이브러리를 만들려 하면, 이미 있는 Fg 컴포넌트나 승인된 라이브러리(1번 표)로 대체 가능한지 먼저 확인하라고 지시하세요.