# 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 { 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(sql, params)` — 다건 조회 (배열 반환) - `queryOne(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` — 표준 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 (
{/* 페이지 헤더 */}

페이지 제목

페이지 설명

{/* 툴바 + 테이블 + 모달 등 */}
); } ``` **핵심 규칙:** - 모든 페이지: `"use client"` + `export default function` - 비즈니스 로직은 커스텀 훅으로 분리 - 페이지는 훅 + UI 컴포넌트 조합에 집중 ### 3.3 컴포넌트 패턴 ```tsx // frontend/components/xxx/XxxToolbar.tsx interface XxxToolbarProps { searchFilter: SearchFilter; totalCount: number; onSearchChange: (filter: Partial) => void; onCreateClick: () => void; } export function XxxToolbar({ searchFilter, totalCount, onSearchChange, onCreateClick, }: XxxToolbarProps) { return (
{/* ... */}
); } ``` **핵심 규칙:** - `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([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(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 { success: boolean; data?: T; message?: string; } export async function getXxxList(params?: Record) { 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` 라이브러리 직접 사용 - 루트 레이아웃에 `` 설정됨 ### 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 ( 모달 제목 설명 {/* 컨텐츠 */} ); } ``` ### 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. **항상 한글로 답변**