# 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.invyone.com` | `https://api.invyone.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. **항상 한글로 답변**
