ERP-node/.cursor/rules/project-conventions.mdc

732 lines
21 KiB
Plaintext

# WACE ERP/PLM 프로젝트 관행 (Project Conventions)
이 문서는 AI 에이전트가 새 기능을 구현할 때 기존 코드베이스의 관행을 따르기 위한 참조 문서입니다.
코드를 작성하기 전에 반드시 이 문서를 읽고 동일한 패턴을 사용하세요.
---
## 1. 프로젝트 구조
```
ERP-node/
├── backend-node/src/ # Express + TypeScript 백엔드
│ ├── app.ts # 엔트리포인트 (미들웨어, 라우트 등록)
│ ├── controllers/ # API 컨트롤러 (요청 처리, 응답 반환)
│ ├── services/ # 비즈니스 로직 (DB 접근, 트랜잭션)
│ ├── routes/ # Express 라우터 (URL 매핑)
│ ├── middleware/ # 인증, 에러처리, 권한 미들웨어
│ ├── database/db.ts # PostgreSQL 연결 풀, query/queryOne/transaction
│ ├── config/environment.ts # 환경 변수 설정
│ ├── types/ # TypeScript 타입 정의
│ └── utils/logger.ts # winston 로거
├── frontend/ # Next.js 15 (App Router) 프론트엔드
│ ├── app/ # 페이지 (Route Groups: (main), (auth), (admin))
│ ├── components/ # React 컴포넌트
│ │ ├── ui/ # shadcn/ui 기본 컴포넌트 (33개)
│ │ ├── admin/ # 관리자 화면 컴포넌트
│ │ └── screen/ # 화면 디자이너/렌더러 컴포넌트
│ ├── hooks/ # 커스텀 React 훅
│ ├── lib/api/ # API 클라이언트 모듈 (63개 파일)
│ ├── lib/utils.ts # cn() 등 유틸리티
│ ├── types/ # 프론트엔드 타입 정의
│ └── contexts/ # React Context (Auth, Menu 등)
├── db/migrations/ # SQL 마이그레이션 파일
└── docs/ # 프로젝트 문서
```
---
## 2. 백엔드 관행
### 2.1 새 기능 추가 시 파일 생성 순서
1. `backend-node/src/types/` — 타입 정의 (필요 시)
2. `backend-node/src/services/xxxService.ts` — 비즈니스 로직
3. `backend-node/src/controllers/xxxController.ts` — 컨트롤러
4. `backend-node/src/routes/xxxRoutes.ts` — 라우터
5. `backend-node/src/app.ts` — 라우트 등록 (`app.use("/api/xxx", xxxRoutes)`)
### 2.2 컨트롤러 패턴
```typescript
// backend-node/src/controllers/xxxController.ts
import { Request, Response } from "express";
import { logger } from "../utils/logger";
import { AuthenticatedRequest } from "../types/auth";
// 패턴 A: named async function (가장 많이 사용)
export async function getXxxList(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId;
logger.info("XXX 목록 조회 요청", { companyCode, userId });
// ... 비즈니스 로직 ...
res.status(200).json({
success: true,
message: "XXX 목록 조회 성공",
data: result,
});
} catch (error) {
logger.error("XXX 목록 조회 중 오류:", error);
res.status(500).json({
success: false,
message: "XXX 목록 조회 중 오류가 발생했습니다.",
error: {
code: "XXX_LIST_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
}
```
**핵심 규칙:**
- `AuthenticatedRequest`로 인증된 사용자 정보 접근
- `req.user?.companyCode`로 회사 코드 추출
- `try-catch` + `logger.error` + `res.status().json()` 패턴
- 응답 형식: `{ success, data?, message?, error?: { code, details } }`
### 2.3 서비스 패턴
```typescript
// backend-node/src/services/xxxService.ts
import { logger } from "../utils/logger";
import { query, queryOne, transaction } from "../database/db";
export class XxxService {
// static 메서드 또는 인스턴스 메서드 (둘 다 사용됨)
static async getList(companyCode: string, filters?: any) {
const conditions: string[] = ["company_code = $1"];
const params: any[] = [companyCode];
let paramIndex = 2;
if (filters?.search) {
conditions.push(`name ILIKE $${paramIndex}`);
params.push(`%${filters.search}%`);
paramIndex++;
}
const whereClause = conditions.length > 0
? `WHERE ${conditions.join(" AND ")}`
: "";
const rows = await query(
`SELECT * FROM xxx_table ${whereClause} ORDER BY created_date DESC`,
params
);
return rows;
}
}
```
**핵심 규칙:**
- `query<T>(sql, params)` — 다건 조회 (배열 반환)
- `queryOne<T>(sql, params)` — 단건 조회 (객체 | null 반환)
- `transaction(async (client) => { ... })` — 트랜잭션
- 동적 WHERE: `conditions[]` + `params[]` + `paramIndex` 패턴
- 파라미터 바인딩: `$1`, `$2`, ... (절대 문자열 삽입 금지)
### 2.4 DB 쿼리 함수 (database/db.ts)
```typescript
import { query, queryOne, transaction } from "../database/db";
// 다건 조회
const rows = await query<{ id: string; name: string }>(
"SELECT * FROM xxx WHERE company_code = $1",
[companyCode]
);
// 단건 조회
const row = await queryOne<{ id: string }>(
"SELECT * FROM xxx WHERE id = $1 AND company_code = $2",
[id, companyCode]
);
// 트랜잭션
const result = await transaction(async (client) => {
await client.query("INSERT INTO xxx (...) VALUES (...)", [params]);
await client.query("UPDATE yyy SET ... WHERE ...", [params]);
return { success: true };
});
```
### 2.5 라우터 패턴
```typescript
// backend-node/src/routes/xxxRoutes.ts
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import { getXxxList, createXxx, updateXxx, deleteXxx } from "../controllers/xxxController";
const router = Router();
// 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
// CRUD 라우트
router.get("/", getXxxList); // GET /api/xxx
router.get("/:id", getXxxDetail); // GET /api/xxx/:id
router.post("/", createXxx); // POST /api/xxx
router.put("/:id", updateXxx); // PUT /api/xxx/:id
router.delete("/:id", deleteXxx); // DELETE /api/xxx/:id
export default router;
```
**URL 네이밍:**
- 리소스명: 복수형, kebab-case (`/api/flow-definitions`, `/api/admin/users`)
- 하위 리소스: `/api/xxx/:id/yyy`
- 액션: `/api/xxx/:id/toggle`, `/api/xxx/check-duplicate`
### 2.6 app.ts 라우트 등록
```typescript
// backend-node/src/app.ts 에 추가
import xxxRoutes from "./routes/xxxRoutes";
// ...
app.use("/api/xxx", xxxRoutes);
```
라우트 등록 위치: 기존 라우트들 사이에 알파벳 순서 또는 관련 기능 근처에 배치.
### 2.7 타입 정의
```typescript
// backend-node/src/types/xxx.ts
export interface XxxItem {
id: string;
company_code: string;
name: string;
created_date?: string;
updated_date?: string;
writer?: string;
}
```
**공통 타입 (types/common.ts):**
- `ApiResponse<T>` — 표준 API 응답
- `AuthenticatedRequest` — 인증된 요청 (req.user 포함)
- `PaginationParams` — 페이지네이션 파라미터
**인증 타입 (types/auth.ts):**
- `PersonBean` — 세션 사용자 정보 (userId, companyCode, userType 등)
- `AuthenticatedRequest` — Request + PersonBean
### 2.8 로깅
```typescript
import { logger } from "../utils/logger";
logger.info("작업 시작", { companyCode, userId });
logger.error("작업 실패:", error);
logger.warn("경고 상황", { details });
logger.debug("디버그 정보", { query, params });
```
---
## 3. 프론트엔드 관행
### 3.1 새 기능 추가 시 파일 생성 순서
1. `frontend/lib/api/xxx.ts` — API 클라이언트 함수
2. `frontend/hooks/useXxx.ts` — 커스텀 훅 (선택)
3. `frontend/components/xxx/XxxComponent.tsx` — 비즈니스 컴포넌트
4. `frontend/app/(main)/xxx/page.tsx` — 페이지
### 3.2 페이지 패턴
```tsx
// frontend/app/(main)/xxx/page.tsx
"use client";
import { useState } from "react";
import { useXxx } from "@/hooks/useXxx";
import { XxxToolbar } from "@/components/xxx/XxxToolbar";
import { XxxTable } from "@/components/xxx/XxxTable";
export default function XxxPage() {
const { data, isLoading, ... } = useXxx();
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight">페이지 제목</h1>
<p className="text-sm text-muted-foreground">페이지 설명</p>
</div>
{/* 툴바 + 테이블 + 모달 등 */}
<XxxToolbar ... />
<XxxTable ... />
</div>
</div>
);
}
```
**핵심 규칙:**
- 모든 페이지: `"use client"` + `export default function`
- 비즈니스 로직은 커스텀 훅으로 분리
- 페이지는 훅 + UI 컴포넌트 조합에 집중
### 3.3 컴포넌트 패턴
```tsx
// frontend/components/xxx/XxxToolbar.tsx
interface XxxToolbarProps {
searchFilter: SearchFilter;
totalCount: number;
onSearchChange: (filter: Partial<SearchFilter>) => void;
onCreateClick: () => void;
}
export function XxxToolbar({
searchFilter,
totalCount,
onSearchChange,
onCreateClick,
}: XxxToolbarProps) {
return (
<div className="flex items-center justify-between">
{/* ... */}
</div>
);
}
```
**핵심 규칙:**
- `export function ComponentName()` (arrow function 아님)
- `interface XxxProps` 정의 후 props 구조 분해
- 이벤트 핸들러: 내부 `handle` 접두사, props 콜백 `on` 접두사
- shadcn/ui 컴포넌트 우선 사용
### 3.4 커스텀 훅 패턴
```typescript
// frontend/hooks/useXxx.ts
import { useState, useCallback, useEffect, useMemo } from "react";
import { xxxApi } from "@/lib/api/xxx";
import { toast } from "sonner";
export const useXxx = () => {
const [data, setData] = useState<XxxItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const response = await xxxApi.getList();
if (response.success) {
setData(response.data);
}
} catch (err) {
setError("데이터 로딩 실패");
toast.error("데이터를 불러올 수 없습니다.");
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
return {
data,
isLoading,
error,
refreshData: loadData,
};
};
```
### 3.5 API 클라이언트 패턴
```typescript
// frontend/lib/api/xxx.ts
import { apiClient } from "./client";
interface ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
}
export async function getXxxList(params?: Record<string, any>) {
try {
const response = await apiClient.get("/xxx", { params });
return response.data;
} catch (error) {
console.error("XXX 목록 API 오류:", error);
throw error;
}
}
export async function createXxx(data: any) {
try {
const response = await apiClient.post("/xxx", data);
return response.data;
} catch (error) {
console.error("XXX 생성 API 오류:", error);
throw error;
}
}
export async function updateXxx(id: string, data: any) {
const response = await apiClient.put(`/xxx/${id}`, data);
return response.data;
}
export async function deleteXxx(id: string) {
const response = await apiClient.delete(`/xxx/${id}`);
return response.data;
}
// 객체로도 export (선택)
export const xxxApi = {
getList: getXxxList,
create: createXxx,
update: updateXxx,
delete: deleteXxx,
};
```
**핵심 규칙:**
- `apiClient` (Axios) 사용 — 절대 `fetch` 직접 사용 금지
- `apiClient`는 자동으로 Authorization 헤더, 환경별 URL, 토큰 갱신 처리
- URL에 `/api` 접두사 불필요 (client.ts에서 baseURL에 포함됨)
- 개별 함수 export + 객체 export 둘 다 가능
### 3.6 토스트/알림
```typescript
import { toast } from "sonner";
toast.success("저장되었습니다.");
toast.error("저장에 실패했습니다.");
toast.info("처리 중입니다.");
```
- `sonner` 라이브러리 직접 사용
- 루트 레이아웃에 `<Toaster position="top-right" />` 설정됨
### 3.7 모달/다이얼로그
```tsx
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
DialogDescription, DialogFooter
} from "@/components/ui/dialog";
interface XxxModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
editingItem?: XxxItem | null;
}
export function XxxModal({ isOpen, onClose, onSuccess, editingItem }: XxxModalProps) {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">모달 제목</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">설명</DialogDescription>
</DialogHeader>
{/* 컨텐츠 */}
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={onClose}>취소</Button>
<Button onClick={handleSubmit}>확인</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
```
### 3.8 레이아웃 계층
```
app/layout.tsx → QueryProvider, RegistryProvider, Toaster
app/(main)/layout.tsx → AuthProvider, MenuProvider, AppLayout
app/(main)/admin/xxx/page.tsx → 실제 페이지
app/(auth)/layout.tsx → 로그인 등 인증 페이지
```
---
## 4. 데이터베이스 관행
### 4.1 테이블 생성 패턴
```sql
CREATE TABLE xxx_table (
id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
company_code VARCHAR(500) NOT NULL,
name VARCHAR(500),
description VARCHAR(500),
status VARCHAR(500) DEFAULT 'active',
created_date TIMESTAMP DEFAULT NOW(),
updated_date TIMESTAMP DEFAULT NOW(),
writer VARCHAR(500) DEFAULT NULL
);
CREATE INDEX idx_xxx_table_company_code ON xxx_table(company_code);
```
**기본 컬럼 (모든 테이블 필수):**
- `id` — VARCHAR(500), PK, `gen_random_uuid()::text`
- `company_code` — VARCHAR(500), NOT NULL
- `created_date` — TIMESTAMP, DEFAULT NOW()
- `updated_date` — TIMESTAMP, DEFAULT NOW()
- `writer` — VARCHAR(500)
**컬럼 타입 관행:**
- 문자열: `VARCHAR(500)` (거의 모든 컬럼에 통일)
- 날짜: `TIMESTAMP`
- ID: `VARCHAR(500)` + `gen_random_uuid()::text`
### 4.2 마이그레이션 파일명
```
db/migrations/NNN_description.sql
예: 034_create_numbering_rules.sql
078_create_production_plan_tables.sql
1003_add_source_menu_objid_to_menu_info.sql
```
---
## 5. 멀티테넌시 (가장 중요)
### 5.1 모든 쿼리에 company_code 필수
```typescript
// SELECT
WHERE company_code = $1
// INSERT
INSERT INTO xxx (company_code, ...) VALUES ($1, ...)
// UPDATE
UPDATE xxx SET ... WHERE id = $1 AND company_code = $2
// DELETE
DELETE FROM xxx WHERE id = $1 AND company_code = $2
// JOIN
LEFT JOIN yyy ON xxx.yyy_id = yyy.id AND xxx.company_code = yyy.company_code
WHERE xxx.company_code = $1
```
### 5.2 최고 관리자(SUPER_ADMIN) 예외
```typescript
const companyCode = req.user?.companyCode;
if (companyCode === "*") {
// 최고 관리자: 전체 데이터 조회
query = "SELECT * FROM xxx ORDER BY company_code, created_date DESC";
params = [];
} else {
// 일반 사용자: 자기 회사만
query = "SELECT * FROM xxx WHERE company_code = $1 ORDER BY created_date DESC";
params = [companyCode];
}
```
### 5.3 최고 관리자 가시성 제한
사용자 관련 API에서 일반 회사 사용자는 `company_code = "*"` 데이터를 볼 수 없음:
```typescript
if (req.user && req.user.companyCode !== "*") {
whereConditions.push(`company_code != '*'`);
}
```
---
## 6. 인증 체계
### 6.1 JWT 토큰 기반
- 로그인 → JWT 발급 → `localStorage`에 저장
- 모든 API 요청: `Authorization: Bearer {token}` 헤더
- 프론트엔드 `apiClient`가 자동으로 토큰 관리
### 6.2 사용자 권한 3단계
| 역할 | company_code | userType |
|------|-------------|----------|
| 최고 관리자 | `"*"` | `SUPER_ADMIN` |
| 회사 관리자 | `"COMPANY_A"` | `COMPANY_ADMIN` |
| 일반 사용자 | `"COMPANY_A"` | `USER` |
### 6.3 미들웨어
- `authenticateToken` — JWT 검증 (대부분의 라우트에 적용)
- `requireSuperAdmin` — 최고 관리자 전용
- `requireAdmin` — 관리자(슈퍼+회사) 전용
---
## 7. 코드 스타일 관행
### 7.1 백엔드
- TypeScript strict: `false` (느슨한 타입 체크)
- 로거: `winston` (`logger` import)
- 컬럼명: `snake_case` (DB), `camelCase` (TypeScript 변수)
- 에러 코드: `UPPER_SNAKE_CASE` (예: `XXX_LIST_ERROR`)
### 7.2 프론트엔드
- TypeScript strict: `true`
- 스타일: Tailwind CSS v4 + shadcn/ui
- 클래스 병합: `cn()` (clsx + tailwind-merge)
- 색상: CSS 변수 기반 (`bg-primary`, `text-muted-foreground`)
- 아이콘: `lucide-react`
- 상태 관리: `zustand` (전역), `useState`/`useReducer` (로컬)
- 데이터 패칭: `@tanstack/react-query` 또는 직접 `useEffect` + API 호출
- 폼: `react-hook-form` + `zod` 또는 직접 `useState`
- 테이블: `@tanstack/react-table` 또는 shadcn `Table`
- 차트: `recharts`
- 날짜: `date-fns`
### 7.3 네이밍 컨벤션
| 대상 | 컨벤션 | 예시 |
|------|--------|------|
| 파일명 (백엔드) | camelCase | `xxxController.ts`, `xxxService.ts`, `xxxRoutes.ts` |
| 파일명 (프론트엔드 컴포넌트) | PascalCase | `XxxToolbar.tsx`, `XxxModal.tsx` |
| 파일명 (프론트엔드 훅) | camelCase | `useXxx.ts` |
| 파일명 (프론트엔드 API) | camelCase | `xxx.ts` |
| 파일명 (프론트엔드 페이지) | camelCase 폴더 | `app/(main)/xxxMng/page.tsx` |
| DB 테이블명 | snake_case | `xxx_table`, `user_info` |
| DB 컬럼명 | snake_case | `company_code`, `created_date` |
| 컴포넌트명 | PascalCase | `XxxToolbar`, `XxxModal` |
| 함수명 | camelCase | `getXxxList`, `handleSubmit` |
| 이벤트 핸들러 (내부) | handle 접두사 | `handleCreateUser` |
| 이벤트 콜백 (props) | on 접두사 | `onSearchChange`, `onClose` |
| 상수 | UPPER_SNAKE_CASE | `MAX_PAGE_SIZE`, `DEFAULT_LIMIT` |
---
## 8. 응답 형식 표준
### 8.1 성공 응답
```json
{
"success": true,
"message": "조회 성공",
"data": [ ... ],
"pagination": {
"page": 1,
"limit": 20,
"total": 100,
"totalPages": 5
}
}
```
### 8.2 에러 응답
```json
{
"success": false,
"message": "조회 중 오류가 발생했습니다.",
"error": {
"code": "XXX_LIST_ERROR",
"details": "에러 상세 메시지"
}
}
```
---
## 9. 환경별 URL 매핑
| 환경 | 프론트엔드 | 백엔드 API |
|------|-----------|-----------|
| 프로덕션 | `v1.vexplor.com` | `https://api.vexplor.com/api` |
| 개발 (로컬) | `localhost:9771` 또는 `localhost:3000` | `http://localhost:8080/api` |
- 프론트엔드 `apiClient`가 `window.location.hostname` 기반으로 자동 판별
- 프론트엔드에서 API URL 하드코딩 금지
---
## 10. 자주 사용하는 import 경로
### 백엔드
```typescript
import { Request, Response } from "express";
import { logger } from "../utils/logger";
import { AuthenticatedRequest, PersonBean } from "../types/auth";
import { ApiResponse } from "../types/common";
import { query, queryOne, transaction } from "../database/db";
import { authenticateToken } from "../middleware/authMiddleware";
```
### 프론트엔드
```typescript
import { apiClient } from "@/lib/api/client";
import { cn } from "@/lib/utils";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, ... } from "@/components/ui/dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
```
---
## 11. 체크리스트: 새 기능 구현 시
### 백엔드
- [ ] `company_code` 필터링이 모든 SELECT/INSERT/UPDATE/DELETE에 포함되어 있는가?
- [ ] `req.user?.companyCode`를 사용하는가? (클라이언트 입력 아님)
- [ ] SUPER_ADMIN (`company_code === "*"`) 예외 처리가 되어 있는가?
- [ ] JOIN 쿼리에도 `company_code` 매칭이 있는가?
- [ ] 파라미터 바인딩 (`$1`, `$2`) 사용하는가? (SQL 인젝션 방지)
- [ ] `try-catch` + `logger` + 적절한 HTTP 상태 코드를 반환하는가?
- [ ] `app.ts`에 라우트가 등록되어 있는가?
### 프론트엔드
- [ ] `apiClient`를 통해 API를 호출하는가? (fetch 직접 사용 금지)
- [ ] `"use client"` 지시어가 있는가?
- [ ] 비즈니스 로직이 커스텀 훅으로 분리되어 있는가?
- [ ] shadcn/ui 컴포넌트를 사용하는가?
- [ ] 에러 시 `toast.error()`로 사용자에게 피드백하는가?
- [ ] 로딩 상태를 표시하는가?
- [ ] 반응형 디자인 (모바일 우선)을 적용했는가?
---
## 12. 주의사항
1. **백엔드 재시작 금지** — nodemon이 파일 변경 감지 시 자동 재시작
2. **fetch 직접 사용 금지** — 반드시 `apiClient` 사용
3. **하드코딩 색상 금지** — `bg-blue-500` 대신 `bg-primary` 등 CSS 변수 사용
4. **company_code 누락 금지** — 모든 비즈니스 테이블/쿼리에 필수
5. **중첩 박스 금지** — Card 안에 Card, Border 안에 Border 금지
6. **항상 한글로 답변**