1120 lines
25 KiB
Markdown
1120 lines
25 KiB
Markdown
|
|
# 📘 SAM MES 솔루션 개발 가이드라인
|
||
|
|
|
||
|
|
## 📋 목차
|
||
|
|
1. [시작하기](#시작하기)
|
||
|
|
2. [프로젝트 구조](#프로젝트-구조)
|
||
|
|
3. [코딩 규칙](#코딩-규칙)
|
||
|
|
4. [컴포넌트 개발](#컴포넌트-개발)
|
||
|
|
5. [스타일링 가이드](#스타일링-가이드)
|
||
|
|
6. [TypeScript 가이드](#typescript-가이드)
|
||
|
|
7. [상태 관리](#상태-관리)
|
||
|
|
8. [파일 명명 규칙](#파일-명명-규칙)
|
||
|
|
9. [Git 워크플로우](#git-워크플로우)
|
||
|
|
10. [테스팅](#테스팅)
|
||
|
|
11. [성능 최적화](#성능-최적화)
|
||
|
|
12. [접근성](#접근성)
|
||
|
|
13. [보안](#보안)
|
||
|
|
14. [문서화](#문서화)
|
||
|
|
15. [베스트 프랙티스](#베스트-프랙티스)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 🚀 시작하기
|
||
|
|
|
||
|
|
### **프로젝트 개요**
|
||
|
|
SAM MES는 중소 및 중견기업을 위한 완전한 제조실행시스템(MES) 솔루션입니다.
|
||
|
|
|
||
|
|
### **핵심 기술**
|
||
|
|
- React 18+ (TypeScript)
|
||
|
|
- Tailwind CSS v4.0
|
||
|
|
- shadcn/ui 컴포넌트
|
||
|
|
- Recharts (데이터 시각화)
|
||
|
|
- Figma Make 플랫폼
|
||
|
|
|
||
|
|
### **개발 환경 설정**
|
||
|
|
1. 프로젝트 파일 구조 파악
|
||
|
|
2. `TECH_STACK.md` 문서 읽기
|
||
|
|
3. `DEVELOPMENT_ENVIRONMENT.md` 문서 읽기
|
||
|
|
4. 기존 컴포넌트 코드 분석
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 📁 프로젝트 구조
|
||
|
|
|
||
|
|
### **디렉토리 구조**
|
||
|
|
```
|
||
|
|
SAM-MES/
|
||
|
|
├── App.tsx # 메인 애플리케이션 엔트리
|
||
|
|
├── *.md # 프로젝트 문서들
|
||
|
|
├── components/ # React 컴포넌트
|
||
|
|
│ ├── [PageComponents].tsx # 페이지 레벨 컴포넌트 (30+)
|
||
|
|
│ ├── ui/ # shadcn/ui 컴포넌트 (53개)
|
||
|
|
│ │ ├── button.tsx
|
||
|
|
│ │ ├── dialog.tsx
|
||
|
|
│ │ ├── table.tsx
|
||
|
|
│ │ └── ...
|
||
|
|
│ └── figma/ # Figma 전용 컴포넌트
|
||
|
|
│ └── ImageWithFallback.tsx # 이미지 폴백 처리
|
||
|
|
├── styles/
|
||
|
|
│ └── globals.css # 글로벌 스타일 & 테마
|
||
|
|
├── guidelines/
|
||
|
|
│ └── Guidelines.md # 추가 가이드라인
|
||
|
|
└── [scripts].js # 유틸리티 스크립트
|
||
|
|
```
|
||
|
|
|
||
|
|
### **컴포넌트 분류**
|
||
|
|
|
||
|
|
#### **1. 페이지 컴포넌트** (`components/`)
|
||
|
|
- `Dashboard.tsx` - CEO 대시보드
|
||
|
|
- `SalesManagement.tsx` - 판매관리
|
||
|
|
- `ProductionManagement.tsx` - 생산관리 (최대 규모)
|
||
|
|
- `QualityManagement.tsx` - 품질관리
|
||
|
|
- `MaterialManagement.tsx` - 자재관리
|
||
|
|
- `ShippingManagement.tsx` - 출고관리
|
||
|
|
- `HRManagement.tsx` - 인사관리
|
||
|
|
- `ApprovalManagement.tsx` - 전자결재
|
||
|
|
- `AccountingManagement.tsx` - 회계관리
|
||
|
|
- `MasterData.tsx` - 기준정보
|
||
|
|
- 등 30+ 컴포넌트
|
||
|
|
|
||
|
|
#### **2. UI 컴포넌트** (`components/ui/`)
|
||
|
|
- 53개의 재사용 가능한 UI 컴포넌트
|
||
|
|
- shadcn/ui 기반
|
||
|
|
- **수정 금지** (새로운 컴포넌트 생성 시에만)
|
||
|
|
|
||
|
|
#### **3. 특수 컴포넌트** (`components/figma/`)
|
||
|
|
- `ImageWithFallback.tsx` - 이미지 로딩 및 폴백
|
||
|
|
- **수정 금지** (시스템 보호 파일)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 📝 코딩 규칙
|
||
|
|
|
||
|
|
### **1. 일반 원칙**
|
||
|
|
```typescript
|
||
|
|
// ✅ DO: 명확하고 읽기 쉬운 코드
|
||
|
|
function calculateTotalPrice(items: Item[]): number {
|
||
|
|
return items.reduce((sum, item) => sum + item.price, 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ❌ DON'T: 불명확한 변수명과 로직
|
||
|
|
function calc(arr: any[]): any {
|
||
|
|
return arr.reduce((a, b) => a + b.p, 0);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### **2. 코드 포맷팅**
|
||
|
|
```typescript
|
||
|
|
// 들여쓰기: 2 스페이스
|
||
|
|
// 세미콜론: 사용
|
||
|
|
// 따옴표: 쌍따옴표 우선
|
||
|
|
// 줄 길이: 최대 100자 권장
|
||
|
|
|
||
|
|
export function MyComponent({ title, description }: Props) {
|
||
|
|
const [isOpen, setIsOpen] = useState(false);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="p-4">
|
||
|
|
<h1>{title}</h1>
|
||
|
|
<p>{description}</p>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### **3. Import 순서**
|
||
|
|
```typescript
|
||
|
|
// 1. React 관련
|
||
|
|
import { useState, useEffect } from "react";
|
||
|
|
|
||
|
|
// 2. UI 컴포넌트 (shadcn/ui)
|
||
|
|
import { Button } from "./ui/button";
|
||
|
|
import { Dialog, DialogContent } from "./ui/dialog";
|
||
|
|
import { Card, CardContent, CardHeader } from "./ui/card";
|
||
|
|
|
||
|
|
// 3. 아이콘
|
||
|
|
import { Plus, Edit, Trash2 } from "lucide-react";
|
||
|
|
|
||
|
|
// 4. 외부 라이브러리
|
||
|
|
import { format } from "date-fns";
|
||
|
|
import { ko } from "date-fns/locale";
|
||
|
|
import { BarChart, Bar } from "recharts";
|
||
|
|
|
||
|
|
// 5. 타입 정의
|
||
|
|
interface Props {
|
||
|
|
title: string;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 🎨 컴포넌트 개발
|
||
|
|
|
||
|
|
### **1. 컴포넌트 구조**
|
||
|
|
```typescript
|
||
|
|
import { useState } from "react";
|
||
|
|
import { Button } from "./ui/button";
|
||
|
|
|
||
|
|
// Props 타입 정의
|
||
|
|
interface MyComponentProps {
|
||
|
|
title: string;
|
||
|
|
onSave?: () => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 컴포넌트 선언
|
||
|
|
export function MyComponent({ title, onSave }: MyComponentProps) {
|
||
|
|
// 1. State 선언
|
||
|
|
const [data, setData] = useState<string[]>([]);
|
||
|
|
const [isLoading, setIsLoading] = useState(false);
|
||
|
|
|
||
|
|
// 2. 핸들러 함수
|
||
|
|
const handleClick = () => {
|
||
|
|
console.log("Clicked");
|
||
|
|
onSave?.();
|
||
|
|
};
|
||
|
|
|
||
|
|
// 3. useEffect (필요시)
|
||
|
|
useEffect(() => {
|
||
|
|
// 초기 데이터 로드
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// 4. 조건부 렌더링
|
||
|
|
if (isLoading) {
|
||
|
|
return <div>Loading...</div>;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 5. JSX 반환
|
||
|
|
return (
|
||
|
|
<div className="p-4">
|
||
|
|
<h1>{title}</h1>
|
||
|
|
<Button onClick={handleClick}>저장</Button>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### **2. 페이지 컴포넌트 템플릿**
|
||
|
|
```typescript
|
||
|
|
import { useState } from "react";
|
||
|
|
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||
|
|
import { Button } from "./ui/button";
|
||
|
|
import { Plus, Download } from "lucide-react";
|
||
|
|
|
||
|
|
export function MyPage() {
|
||
|
|
const [activeTab, setActiveTab] = useState("list");
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="min-h-screen bg-background p-4 md:p-6 lg:p-8">
|
||
|
|
{/* 헤더 */}
|
||
|
|
<div className="bg-card border border-border/20 rounded-xl p-6 mb-8">
|
||
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||
|
|
<div>
|
||
|
|
<h1 className="text-3xl font-bold text-foreground mb-2">페이지 제목</h1>
|
||
|
|
<p className="text-muted-foreground">페이지 설명</p>
|
||
|
|
</div>
|
||
|
|
<div className="flex space-x-3">
|
||
|
|
<Button variant="outline">
|
||
|
|
<Download className="h-4 w-4 mr-2" />
|
||
|
|
내보내기
|
||
|
|
</Button>
|
||
|
|
<Button>
|
||
|
|
<Plus className="h-4 w-4 mr-2" />
|
||
|
|
추가
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 메인 콘텐츠 */}
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle>섹션 제목</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
{/* 내용 */}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### **3. 반응형 컴포넌트 패턴**
|
||
|
|
```typescript
|
||
|
|
// 데스크톱: 테이블 / 모바일: 카드
|
||
|
|
export function ResponsiveList({ items }: Props) {
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
{/* 데스크톱 테이블 (hidden on mobile) */}
|
||
|
|
<div className="hidden md:block">
|
||
|
|
<Table>
|
||
|
|
<TableHeader>
|
||
|
|
<TableRow>
|
||
|
|
<TableHead>이름</TableHead>
|
||
|
|
<TableHead>상태</TableHead>
|
||
|
|
</TableRow>
|
||
|
|
</TableHeader>
|
||
|
|
<TableBody>
|
||
|
|
{items.map((item) => (
|
||
|
|
<TableRow key={item.id}>
|
||
|
|
<TableCell>{item.name}</TableCell>
|
||
|
|
<TableCell>{item.status}</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
))}
|
||
|
|
</TableBody>
|
||
|
|
</Table>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 모바일 카드 (visible on mobile) */}
|
||
|
|
<div className="md:hidden space-y-4">
|
||
|
|
{items.map((item) => (
|
||
|
|
<Card key={item.id}>
|
||
|
|
<CardContent className="p-4">
|
||
|
|
<div className="font-semibold">{item.name}</div>
|
||
|
|
<div className="text-sm text-muted-foreground">{item.status}</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 🎨 스타일링 가이드
|
||
|
|
|
||
|
|
### **1. Tailwind CSS 기본 원칙**
|
||
|
|
|
||
|
|
#### **✅ DO: Tailwind 유틸리티 클래스 사용**
|
||
|
|
```typescript
|
||
|
|
<div className="flex items-center justify-between p-4 bg-card rounded-lg border border-border">
|
||
|
|
<h2 className="text-xl text-foreground">제목</h2>
|
||
|
|
<Button variant="outline" size="sm">버튼</Button>
|
||
|
|
</div>
|
||
|
|
```
|
||
|
|
|
||
|
|
#### **❌ DON'T: 인라인 스타일 사용**
|
||
|
|
```typescript
|
||
|
|
// 피하기
|
||
|
|
<div style={{ display: "flex", padding: "16px" }}>
|
||
|
|
```
|
||
|
|
|
||
|
|
### **2. 색상 사용**
|
||
|
|
```typescript
|
||
|
|
// ✅ CSS 변수 사용 (테마 대응)
|
||
|
|
className="bg-primary text-primary-foreground"
|
||
|
|
className="bg-card text-card-foreground"
|
||
|
|
className="bg-destructive text-destructive-foreground"
|
||
|
|
className="border-border"
|
||
|
|
className="text-muted-foreground"
|
||
|
|
|
||
|
|
// ❌ 하드코딩된 색상 (피하기)
|
||
|
|
className="bg-blue-500 text-white"
|
||
|
|
```
|
||
|
|
|
||
|
|
### **3. 반응형 디자인**
|
||
|
|
```typescript
|
||
|
|
// Mobile First 접근
|
||
|
|
className="
|
||
|
|
p-4 // 모바일: 16px padding
|
||
|
|
md:p-6 // 태블릿: 24px padding
|
||
|
|
lg:p-8 // 데스크톱: 32px padding
|
||
|
|
|
||
|
|
flex-col // 모바일: 세로 방향
|
||
|
|
md:flex-row // 태블릿+: 가로 방향
|
||
|
|
|
||
|
|
text-sm // 모바일: 작은 텍스트
|
||
|
|
md:text-base // 태블릿+: 기본 크기
|
||
|
|
"
|
||
|
|
```
|
||
|
|
|
||
|
|
### **4. 간격 시스템**
|
||
|
|
```typescript
|
||
|
|
// Spacing Scale (4px 단위)
|
||
|
|
className="p-2" // 8px
|
||
|
|
className="p-4" // 16px
|
||
|
|
className="p-6" // 24px
|
||
|
|
className="p-8" // 32px
|
||
|
|
|
||
|
|
className="gap-2" // 8px
|
||
|
|
className="gap-4" // 16px
|
||
|
|
className="gap-6" // 24px
|
||
|
|
```
|
||
|
|
|
||
|
|
### **5. 폰트 크기/굵기 사용 제한**
|
||
|
|
```typescript
|
||
|
|
// ⚠️ 주의: 폰트 크기, 굵기, 라인 높이는 기본값 사용
|
||
|
|
// globals.css에 정의된 기본 스타일 활용
|
||
|
|
|
||
|
|
// ❌ 피하기 (특별한 요구사항 없으면)
|
||
|
|
className="text-2xl font-bold leading-tight"
|
||
|
|
|
||
|
|
// ✅ 권장 (기본값 사용)
|
||
|
|
<h1>제목</h1> // globals.css의 h1 스타일 자동 적용
|
||
|
|
<p>내용</p> // globals.css의 p 스타일 자동 적용
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 📘 TypeScript 가이드
|
||
|
|
|
||
|
|
### **1. 타입 정의**
|
||
|
|
|
||
|
|
#### **Interface vs Type**
|
||
|
|
```typescript
|
||
|
|
// ✅ Props는 interface 사용 (확장 가능)
|
||
|
|
interface ButtonProps {
|
||
|
|
label: string;
|
||
|
|
onClick?: () => void;
|
||
|
|
variant?: "primary" | "secondary";
|
||
|
|
}
|
||
|
|
|
||
|
|
// ✅ 유니온 타입은 type 사용
|
||
|
|
type Status = "active" | "inactive" | "pending";
|
||
|
|
|
||
|
|
// ✅ 복잡한 타입은 type 사용
|
||
|
|
type ComplexType = {
|
||
|
|
id: string;
|
||
|
|
data: Record<string, unknown>;
|
||
|
|
} & BaseType;
|
||
|
|
```
|
||
|
|
|
||
|
|
#### **타입 재사용**
|
||
|
|
```typescript
|
||
|
|
// 공통 타입 정의
|
||
|
|
interface BaseEntity {
|
||
|
|
id: string;
|
||
|
|
createdAt: string;
|
||
|
|
updatedAt: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface Product extends BaseEntity {
|
||
|
|
name: string;
|
||
|
|
price: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface Order extends BaseEntity {
|
||
|
|
productId: string;
|
||
|
|
quantity: number;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### **2. 타입 안정성**
|
||
|
|
|
||
|
|
#### **✅ DO: 명시적 타입 지정**
|
||
|
|
```typescript
|
||
|
|
// State 타입 명시
|
||
|
|
const [items, setItems] = useState<Product[]>([]);
|
||
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||
|
|
|
||
|
|
// 함수 매개변수 타입 명시
|
||
|
|
function calculateTotal(items: Product[]): number {
|
||
|
|
return items.reduce((sum, item) => sum + item.price, 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 이벤트 핸들러 타입
|
||
|
|
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||
|
|
console.log(event.currentTarget);
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
#### **❌ DON'T: any 타입 사용**
|
||
|
|
```typescript
|
||
|
|
// ❌ 피하기
|
||
|
|
const [data, setData] = useState<any>([]);
|
||
|
|
function process(input: any): any { }
|
||
|
|
|
||
|
|
// ✅ 대신 사용
|
||
|
|
const [data, setData] = useState<unknown[]>([]);
|
||
|
|
function process(input: unknown): Result { }
|
||
|
|
```
|
||
|
|
|
||
|
|
### **3. 옵셔널 체이닝 & Nullish Coalescing**
|
||
|
|
```typescript
|
||
|
|
// ✅ 옵셔널 체이닝
|
||
|
|
const userName = user?.profile?.name;
|
||
|
|
|
||
|
|
// ✅ Nullish Coalescing
|
||
|
|
const displayName = user?.name ?? "Guest";
|
||
|
|
|
||
|
|
// ✅ 옵셔널 콜백
|
||
|
|
onSave?.();
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 🔄 상태 관리
|
||
|
|
|
||
|
|
### **1. useState 패턴**
|
||
|
|
```typescript
|
||
|
|
// ✅ 단순 상태
|
||
|
|
const [isOpen, setIsOpen] = useState(false);
|
||
|
|
const [count, setCount] = useState(0);
|
||
|
|
|
||
|
|
// ✅ 객체 상태 (불변성 유지)
|
||
|
|
const [user, setUser] = useState({ name: "", email: "" });
|
||
|
|
|
||
|
|
const updateName = (newName: string) => {
|
||
|
|
setUser(prev => ({ ...prev, name: newName }));
|
||
|
|
};
|
||
|
|
|
||
|
|
// ✅ 배열 상태
|
||
|
|
const [items, setItems] = useState<Item[]>([]);
|
||
|
|
|
||
|
|
const addItem = (item: Item) => {
|
||
|
|
setItems(prev => [...prev, item]);
|
||
|
|
};
|
||
|
|
|
||
|
|
const removeItem = (id: string) => {
|
||
|
|
setItems(prev => prev.filter(item => item.id !== id));
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
### **2. useEffect 패턴**
|
||
|
|
```typescript
|
||
|
|
// ✅ 컴포넌트 마운트 시 1회 실행
|
||
|
|
useEffect(() => {
|
||
|
|
// 초기 데이터 로드
|
||
|
|
fetchData();
|
||
|
|
}, []); // 빈 의존성 배열
|
||
|
|
|
||
|
|
// ✅ 특정 값 변경 시 실행
|
||
|
|
useEffect(() => {
|
||
|
|
if (selectedId) {
|
||
|
|
loadDetails(selectedId);
|
||
|
|
}
|
||
|
|
}, [selectedId]); // selectedId 변경 감지
|
||
|
|
|
||
|
|
// ✅ 클린업 함수
|
||
|
|
useEffect(() => {
|
||
|
|
const timer = setInterval(() => {
|
||
|
|
// 주기적 작업
|
||
|
|
}, 1000);
|
||
|
|
|
||
|
|
return () => {
|
||
|
|
clearInterval(timer); // 컴포넌트 언마운트 시 정리
|
||
|
|
};
|
||
|
|
}, []);
|
||
|
|
```
|
||
|
|
|
||
|
|
### **3. 로컬 상태 vs Props**
|
||
|
|
```typescript
|
||
|
|
// ✅ 로컬에서만 사용되는 UI 상태
|
||
|
|
const [isHovered, setIsHovered] = useState(false);
|
||
|
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||
|
|
|
||
|
|
// ✅ 부모로부터 받는 데이터
|
||
|
|
interface Props {
|
||
|
|
data: Product[]; // 읽기 전용 데이터
|
||
|
|
onUpdate: () => void; // 상태 변경은 부모가 처리
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 📂 파일 명명 규칙
|
||
|
|
|
||
|
|
### **1. 컴포넌트 파일**
|
||
|
|
```
|
||
|
|
PascalCase.tsx
|
||
|
|
├── Dashboard.tsx
|
||
|
|
├── SalesManagement.tsx
|
||
|
|
├── ProductionManagement.tsx
|
||
|
|
└── UserProfile.tsx
|
||
|
|
```
|
||
|
|
|
||
|
|
### **2. UI 컴포넌트 (shadcn/ui)**
|
||
|
|
```
|
||
|
|
kebab-case.tsx
|
||
|
|
├── button.tsx
|
||
|
|
├── dialog.tsx
|
||
|
|
├── table.tsx
|
||
|
|
└── dropdown-menu.tsx
|
||
|
|
```
|
||
|
|
|
||
|
|
### **3. 유틸리티 파일**
|
||
|
|
```
|
||
|
|
camelCase.ts / kebab-case.ts
|
||
|
|
├── utils.ts
|
||
|
|
├── helpers.ts
|
||
|
|
└── use-mobile.ts
|
||
|
|
```
|
||
|
|
|
||
|
|
### **4. 문서 파일**
|
||
|
|
```
|
||
|
|
UPPER_CASE.md / PascalCase.md
|
||
|
|
├── README.md
|
||
|
|
├── TECH_STACK.md
|
||
|
|
├── DEVELOPMENT_ENVIRONMENT.md
|
||
|
|
└── Guidelines.md
|
||
|
|
```
|
||
|
|
|
||
|
|
### **5. 스타일 파일**
|
||
|
|
```
|
||
|
|
kebab-case.css / lowercase.css
|
||
|
|
├── globals.css
|
||
|
|
└── custom-styles.css
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 🔀 Git 워크플로우
|
||
|
|
|
||
|
|
### **1. 브랜치 전략**
|
||
|
|
```bash
|
||
|
|
main # 프로덕션 브랜치
|
||
|
|
├── develop # 개발 브랜치
|
||
|
|
├── feature/* # 기능 개발
|
||
|
|
├── bugfix/* # 버그 수정
|
||
|
|
└── hotfix/* # 긴급 수정
|
||
|
|
```
|
||
|
|
|
||
|
|
### **2. 커밋 메시지 규칙**
|
||
|
|
```bash
|
||
|
|
# 형식: <타입>: <제목>
|
||
|
|
|
||
|
|
feat: 판매관리 견적 산출 기능 추가
|
||
|
|
fix: 생산관리 모달 스크롤 오류 수정
|
||
|
|
style: Dashboard 레이아웃 개선
|
||
|
|
refactor: MaterialManagement 컴포넌트 리팩토링
|
||
|
|
docs: README 업데이트
|
||
|
|
test: QualityManagement 테스트 추가
|
||
|
|
chore: 의존성 업데이트
|
||
|
|
```
|
||
|
|
|
||
|
|
### **3. 커밋 타입**
|
||
|
|
- `feat`: 새로운 기능
|
||
|
|
- `fix`: 버그 수정
|
||
|
|
- `style`: UI/스타일 변경
|
||
|
|
- `refactor`: 코드 리팩토링
|
||
|
|
- `docs`: 문서 수정
|
||
|
|
- `test`: 테스트 추가/수정
|
||
|
|
- `chore`: 빌드, 설정 등
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 🧪 테스팅
|
||
|
|
|
||
|
|
### **1. 컴포넌트 테스트 체크리스트**
|
||
|
|
```typescript
|
||
|
|
// 수동 테스트 체크리스트
|
||
|
|
// [ ] 컴포넌트가 정상적으로 렌더링되는가?
|
||
|
|
// [ ] Props가 올바르게 전달되는가?
|
||
|
|
// [ ] 이벤트 핸들러가 작동하는가?
|
||
|
|
// [ ] 조건부 렌더링이 올바른가?
|
||
|
|
// [ ] 에러 상태가 처리되는가?
|
||
|
|
// [ ] 로딩 상태가 표시되는가?
|
||
|
|
```
|
||
|
|
|
||
|
|
### **2. 반응형 테스트**
|
||
|
|
```
|
||
|
|
브레이크포인트 테스트:
|
||
|
|
[ ] Mobile (< 768px)
|
||
|
|
[ ] Tablet (768px - 1024px)
|
||
|
|
[ ] Desktop (> 1024px)
|
||
|
|
|
||
|
|
기기별 테스트:
|
||
|
|
[ ] iPhone (Safari)
|
||
|
|
[ ] Android (Chrome)
|
||
|
|
[ ] iPad (Safari)
|
||
|
|
[ ] Desktop (Chrome, Firefox, Safari)
|
||
|
|
```
|
||
|
|
|
||
|
|
### **3. 브라우저 호환성**
|
||
|
|
```
|
||
|
|
[ ] Chrome (최신)
|
||
|
|
[ ] Firefox (최신)
|
||
|
|
[ ] Safari (최신)
|
||
|
|
[ ] Edge (최신)
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## ⚡ 성능 최적화
|
||
|
|
|
||
|
|
### **1. 리렌더링 최적화**
|
||
|
|
```typescript
|
||
|
|
// ✅ useMemo - 비용이 큰 계산 메모이제이션
|
||
|
|
const expensiveValue = useMemo(() => {
|
||
|
|
return items.reduce((sum, item) => sum + item.price, 0);
|
||
|
|
}, [items]);
|
||
|
|
|
||
|
|
// ✅ useCallback - 함수 메모이제이션
|
||
|
|
const handleClick = useCallback((id: string) => {
|
||
|
|
console.log("Clicked:", id);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// ✅ React.memo - 컴포넌트 메모이제이션
|
||
|
|
export const MemoizedComponent = memo(function MyComponent({ data }: Props) {
|
||
|
|
return <div>{data}</div>;
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### **2. 조건부 렌더링**
|
||
|
|
```typescript
|
||
|
|
// ✅ 불필요한 컴포넌트 렌더링 방지
|
||
|
|
{isVisible && <ExpensiveComponent />}
|
||
|
|
|
||
|
|
// ✅ 로딩 상태
|
||
|
|
{isLoading ? <Skeleton /> : <Content />}
|
||
|
|
```
|
||
|
|
|
||
|
|
### **3. 이미지 최적화**
|
||
|
|
```typescript
|
||
|
|
// ✅ ImageWithFallback 컴포넌트 사용
|
||
|
|
import { ImageWithFallback } from "./components/figma/ImageWithFallback";
|
||
|
|
|
||
|
|
<ImageWithFallback
|
||
|
|
src={imageUrl}
|
||
|
|
alt="제품 이미지"
|
||
|
|
className="w-full h-auto"
|
||
|
|
/>
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## ♿ 접근성 (Accessibility)
|
||
|
|
|
||
|
|
### **1. 시맨틱 HTML**
|
||
|
|
```typescript
|
||
|
|
// ✅ 의미있는 HTML 태그 사용
|
||
|
|
<header>
|
||
|
|
<nav>
|
||
|
|
<ul>
|
||
|
|
<li><a href="/">홈</a></li>
|
||
|
|
</ul>
|
||
|
|
</nav>
|
||
|
|
</header>
|
||
|
|
|
||
|
|
<main>
|
||
|
|
<article>
|
||
|
|
<h1>제목</h1>
|
||
|
|
<p>내용</p>
|
||
|
|
</article>
|
||
|
|
</main>
|
||
|
|
|
||
|
|
<footer>
|
||
|
|
<p>Copyright 2025</p>
|
||
|
|
</footer>
|
||
|
|
```
|
||
|
|
|
||
|
|
### **2. ARIA 속성**
|
||
|
|
```typescript
|
||
|
|
// ✅ 스크린 리더를 위한 레이블
|
||
|
|
<button aria-label="메뉴 열기">
|
||
|
|
<Menu />
|
||
|
|
</button>
|
||
|
|
|
||
|
|
// ✅ 상태 표시
|
||
|
|
<div role="alert" aria-live="polite">
|
||
|
|
저장되었습니다.
|
||
|
|
</div>
|
||
|
|
|
||
|
|
// ✅ 숨김 콘텐츠
|
||
|
|
<DialogTitle className="sr-only">대화상자 제목</DialogTitle>
|
||
|
|
```
|
||
|
|
|
||
|
|
### **3. 키보드 네비게이션**
|
||
|
|
```typescript
|
||
|
|
// ✅ Tab 키로 이동 가능
|
||
|
|
<button>클릭 가능</button>
|
||
|
|
<a href="/page">링크</a>
|
||
|
|
|
||
|
|
// ✅ Enter/Space로 활성화
|
||
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
|
|
if (e.key === "Enter" || e.key === " ") {
|
||
|
|
handleClick();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 🔒 보안
|
||
|
|
|
||
|
|
### **1. XSS 방지**
|
||
|
|
```typescript
|
||
|
|
// ✅ React는 기본적으로 XSS 방지
|
||
|
|
<div>{userInput}</div> // 자동 이스케이프
|
||
|
|
|
||
|
|
// ❌ dangerouslySetInnerHTML 사용 금지 (특별한 경우 제외)
|
||
|
|
<div dangerouslySetInnerHTML={{ __html: content }} />
|
||
|
|
```
|
||
|
|
|
||
|
|
### **2. 민감 정보 처리**
|
||
|
|
```typescript
|
||
|
|
// ❌ 클라이언트 코드에 민감 정보 하드코딩 금지
|
||
|
|
const API_KEY = "secret-key-123"; // 절대 금지!
|
||
|
|
|
||
|
|
// ✅ 환경 변수 사용 또는 서버사이드 처리
|
||
|
|
const API_URL = process.env.VITE_API_URL;
|
||
|
|
```
|
||
|
|
|
||
|
|
### **3. 입력 검증**
|
||
|
|
```typescript
|
||
|
|
// ✅ 사용자 입력 검증
|
||
|
|
const validateEmail = (email: string): boolean => {
|
||
|
|
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
|
|
return regex.test(email);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleSubmit = (email: string) => {
|
||
|
|
if (!validateEmail(email)) {
|
||
|
|
alert("올바른 이메일을 입력하세요.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
// 처리
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 📚 문서화
|
||
|
|
|
||
|
|
### **1. 컴포넌트 문서화**
|
||
|
|
```typescript
|
||
|
|
/**
|
||
|
|
* 사용자 프로필 카드 컴포넌트
|
||
|
|
*
|
||
|
|
* @param {string} name - 사용자 이름
|
||
|
|
* @param {string} email - 사용자 이메일
|
||
|
|
* @param {() => void} onEdit - 편집 버튼 클릭 핸들러
|
||
|
|
*
|
||
|
|
* @example
|
||
|
|
* <UserProfileCard
|
||
|
|
* name="홍길동"
|
||
|
|
* email="hong@example.com"
|
||
|
|
* onEdit={() => console.log("edit")}
|
||
|
|
* />
|
||
|
|
*/
|
||
|
|
export function UserProfileCard({ name, email, onEdit }: UserProfileCardProps) {
|
||
|
|
// ...
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### **2. 복잡한 로직 주석**
|
||
|
|
```typescript
|
||
|
|
// ✅ 복잡한 비즈니스 로직에 주석 추가
|
||
|
|
function calculateDiscount(price: number, quantity: number): number {
|
||
|
|
// 10개 이상 구매 시 10% 할인
|
||
|
|
// 50개 이상 구매 시 20% 할인
|
||
|
|
// 100개 이상 구매 시 30% 할인
|
||
|
|
if (quantity >= 100) {
|
||
|
|
return price * 0.7;
|
||
|
|
} else if (quantity >= 50) {
|
||
|
|
return price * 0.8;
|
||
|
|
} else if (quantity >= 10) {
|
||
|
|
return price * 0.9;
|
||
|
|
}
|
||
|
|
return price;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### **3. TODO 주석**
|
||
|
|
```typescript
|
||
|
|
// TODO: 성능 최적화 필요
|
||
|
|
// FIXME: 특정 조건에서 버그 발생
|
||
|
|
// HACK: 임시 해결책, 추후 개선 필요
|
||
|
|
// NOTE: 중요한 정보 또는 주의사항
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## ✨ 베스트 프랙티스
|
||
|
|
|
||
|
|
### **1. DRY (Don't Repeat Yourself)**
|
||
|
|
```typescript
|
||
|
|
// ❌ 중복 코드
|
||
|
|
function formatUserName1(name: string) {
|
||
|
|
return name.trim().toUpperCase();
|
||
|
|
}
|
||
|
|
function formatProductName(name: string) {
|
||
|
|
return name.trim().toUpperCase();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ✅ 재사용 가능한 함수
|
||
|
|
function formatName(name: string): string {
|
||
|
|
return name.trim().toUpperCase();
|
||
|
|
}
|
||
|
|
|
||
|
|
const userName = formatName(user.name);
|
||
|
|
const productName = formatName(product.name);
|
||
|
|
```
|
||
|
|
|
||
|
|
### **2. 단일 책임 원칙**
|
||
|
|
```typescript
|
||
|
|
// ❌ 하나의 컴포넌트가 너무 많은 역할
|
||
|
|
function ComplexComponent() {
|
||
|
|
// 데이터 로딩
|
||
|
|
// 폼 처리
|
||
|
|
// 차트 렌더링
|
||
|
|
// 테이블 렌더링
|
||
|
|
// ... 1000+ 줄
|
||
|
|
}
|
||
|
|
|
||
|
|
// ✅ 책임 분리
|
||
|
|
function DataContainer() {
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
<DataForm />
|
||
|
|
<DataChart />
|
||
|
|
<DataTable />
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### **3. 명확한 변수명**
|
||
|
|
```typescript
|
||
|
|
// ❌ 불명확한 이름
|
||
|
|
const d = new Date();
|
||
|
|
const arr = [...items];
|
||
|
|
const temp = x * y;
|
||
|
|
|
||
|
|
// ✅ 명확한 이름
|
||
|
|
const currentDate = new Date();
|
||
|
|
const sortedItems = [...items];
|
||
|
|
const totalPrice = quantity * unitPrice;
|
||
|
|
```
|
||
|
|
|
||
|
|
### **4. Early Return 패턴**
|
||
|
|
```typescript
|
||
|
|
// ❌ 중첩된 조건문
|
||
|
|
function processUser(user: User) {
|
||
|
|
if (user) {
|
||
|
|
if (user.isActive) {
|
||
|
|
if (user.hasPermission) {
|
||
|
|
// 처리
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ✅ Early Return
|
||
|
|
function processUser(user: User) {
|
||
|
|
if (!user) return;
|
||
|
|
if (!user.isActive) return;
|
||
|
|
if (!user.hasPermission) return;
|
||
|
|
|
||
|
|
// 처리
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### **5. 에러 처리**
|
||
|
|
```typescript
|
||
|
|
// ✅ Try-Catch로 에러 처리
|
||
|
|
async function fetchData() {
|
||
|
|
try {
|
||
|
|
const response = await fetch("/api/data");
|
||
|
|
const data = await response.json();
|
||
|
|
return data;
|
||
|
|
} catch (error) {
|
||
|
|
console.error("데이터 로드 실패:", error);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ✅ 에러 상태 표시
|
||
|
|
const [error, setError] = useState<string | null>(null);
|
||
|
|
|
||
|
|
{error && (
|
||
|
|
<Alert variant="destructive">
|
||
|
|
<AlertTitle>오류</AlertTitle>
|
||
|
|
<AlertDescription>{error}</AlertDescription>
|
||
|
|
</Alert>
|
||
|
|
)}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 🚨 일반적인 실수 & 해결책
|
||
|
|
|
||
|
|
### **1. Key Props 누락**
|
||
|
|
```typescript
|
||
|
|
// ❌ key 없이 리스트 렌더링
|
||
|
|
{items.map(item => <div>{item.name}</div>)}
|
||
|
|
|
||
|
|
// ✅ 고유한 key 사용
|
||
|
|
{items.map(item => (
|
||
|
|
<div key={item.id}>{item.name}</div>
|
||
|
|
))}
|
||
|
|
```
|
||
|
|
|
||
|
|
### **2. State 직접 수정**
|
||
|
|
```typescript
|
||
|
|
// ❌ State 직접 수정
|
||
|
|
const [items, setItems] = useState([]);
|
||
|
|
items.push(newItem); // 금지!
|
||
|
|
|
||
|
|
// ✅ 새로운 배열 생성
|
||
|
|
setItems([...items, newItem]);
|
||
|
|
setItems(prev => [...prev, newItem]);
|
||
|
|
```
|
||
|
|
|
||
|
|
### **3. useEffect 의존성 배열 누락**
|
||
|
|
```typescript
|
||
|
|
// ❌ 의존성 배열 누락
|
||
|
|
useEffect(() => {
|
||
|
|
fetchData(userId);
|
||
|
|
}); // 무한 루프!
|
||
|
|
|
||
|
|
// ✅ 의존성 명시
|
||
|
|
useEffect(() => {
|
||
|
|
fetchData(userId);
|
||
|
|
}, [userId]);
|
||
|
|
```
|
||
|
|
|
||
|
|
### **4. 불필요한 리렌더링**
|
||
|
|
```typescript
|
||
|
|
// ❌ 매번 새로운 객체 생성
|
||
|
|
<Component style={{ margin: 10 }} />
|
||
|
|
|
||
|
|
// ✅ 상수로 분리
|
||
|
|
const componentStyle = { margin: 10 };
|
||
|
|
<Component style={componentStyle} />
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 📋 체크리스트
|
||
|
|
|
||
|
|
### **코드 리뷰 전 체크리스트**
|
||
|
|
```
|
||
|
|
코드 품질:
|
||
|
|
[ ] TypeScript 타입 오류 없음
|
||
|
|
[ ] ESLint 경고 없음
|
||
|
|
[ ] 불필요한 console.log 제거
|
||
|
|
[ ] 주석 작성 (복잡한 로직)
|
||
|
|
[ ] 변수명이 명확함
|
||
|
|
|
||
|
|
기능:
|
||
|
|
[ ] 요구사항 모두 구현
|
||
|
|
[ ] 에러 처리 구현
|
||
|
|
[ ] 로딩 상태 구현
|
||
|
|
[ ] 엣지 케이스 처리
|
||
|
|
|
||
|
|
UI/UX:
|
||
|
|
[ ] 반응형 디자인 동작
|
||
|
|
[ ] 모바일/데스크톱 테스트
|
||
|
|
[ ] 접근성 준수
|
||
|
|
[ ] 키보드 네비게이션 가능
|
||
|
|
|
||
|
|
성능:
|
||
|
|
[ ] 불필요한 리렌더링 없음
|
||
|
|
[ ] 큰 리스트 최적화
|
||
|
|
[ ] 이미지 최적화
|
||
|
|
[ ] 번들 크기 확인
|
||
|
|
|
||
|
|
문서화:
|
||
|
|
[ ] README 업데이트 (필요시)
|
||
|
|
[ ] 변경 사항 기록
|
||
|
|
[ ] API 문서 업데이트 (필요시)
|
||
|
|
```
|
||
|
|
|
||
|
|
### **배포 전 체크리스트**
|
||
|
|
```
|
||
|
|
[ ] 모든 테스트 통과
|
||
|
|
[ ] 브라우저 호환성 확인
|
||
|
|
[ ] 성능 측정 완료
|
||
|
|
[ ] 보안 취약점 점검
|
||
|
|
[ ] 백업 완료
|
||
|
|
[ ] 롤백 계획 수립
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 🎓 학습 리소스
|
||
|
|
|
||
|
|
### **필수 문서**
|
||
|
|
1. `TECH_STACK.md` - 기술 스택
|
||
|
|
2. `DEVELOPMENT_ENVIRONMENT.md` - 개발 환경
|
||
|
|
3. `guidelines/Guidelines.md` - 추가 가이드
|
||
|
|
|
||
|
|
### **공식 문서**
|
||
|
|
- [React 공식 문서](https://react.dev/)
|
||
|
|
- [TypeScript 핸드북](https://www.typescriptlang.org/docs/)
|
||
|
|
- [Tailwind CSS 문서](https://tailwindcss.com/docs)
|
||
|
|
- [shadcn/ui 문서](https://ui.shadcn.com/)
|
||
|
|
|
||
|
|
### **추천 학습 경로**
|
||
|
|
1. **1주차**: React & TypeScript 기초
|
||
|
|
2. **2주차**: Tailwind CSS & shadcn/ui
|
||
|
|
3. **3주차**: 프로젝트 구조 파악
|
||
|
|
4. **4주차**: 실제 컴포넌트 개발
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 🎯 핵심 원칙 요약
|
||
|
|
|
||
|
|
### **코드 작성 원칙**
|
||
|
|
1. **타입 안정성**: 모든 코드에 TypeScript 타입 적용
|
||
|
|
2. **재사용성**: DRY 원칙 준수, 공통 컴포넌트 활용
|
||
|
|
3. **가독성**: 명확한 변수명, 적절한 주석
|
||
|
|
4. **성능**: 불필요한 리렌더링 방지
|
||
|
|
5. **접근성**: 모든 사용자가 사용 가능하도록
|
||
|
|
|
||
|
|
### **UI/UX 원칙**
|
||
|
|
1. **반응형**: 모든 화면 크기 대응
|
||
|
|
2. **일관성**: 디자인 시스템 준수
|
||
|
|
3. **피드백**: 로딩, 에러, 성공 상태 표시
|
||
|
|
4. **접근성**: 키보드, 스크린 리더 지원
|
||
|
|
|
||
|
|
### **협업 원칙**
|
||
|
|
1. **문서화**: 코드와 변경사항 문서화
|
||
|
|
2. **커뮤니케이션**: 불확실한 부분 질문
|
||
|
|
3. **코드 리뷰**: 건설적인 피드백
|
||
|
|
4. **지식 공유**: 학습 내용 팀과 공유
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 📞 도움 받기
|
||
|
|
|
||
|
|
### **질문하기 전 체크**
|
||
|
|
1. 공식 문서 확인
|
||
|
|
2. 기존 코드 참고
|
||
|
|
3. 에러 메시지 검색
|
||
|
|
|
||
|
|
### **질문 형식**
|
||
|
|
```
|
||
|
|
제목: [컴포넌트명] 간단한 문제 설명
|
||
|
|
|
||
|
|
환경:
|
||
|
|
- 파일: components/MyComponent.tsx
|
||
|
|
- 브라우저: Chrome 120
|
||
|
|
|
||
|
|
문제:
|
||
|
|
[상세한 문제 설명]
|
||
|
|
|
||
|
|
시도한 해결책:
|
||
|
|
1. ...
|
||
|
|
2. ...
|
||
|
|
|
||
|
|
에러 메시지:
|
||
|
|
[에러 메시지 복사]
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 🎉 결론
|
||
|
|
|
||
|
|
이 가이드라인은 **SAM MES 솔루션** 개발의 기준이 됩니다.
|
||
|
|
|
||
|
|
**핵심 포인트**:
|
||
|
|
✅ TypeScript로 타입 안정성 확보
|
||
|
|
✅ Tailwind CSS로 일관된 스타일링
|
||
|
|
✅ 반응형 디자인으로 모든 기기 지원
|
||
|
|
✅ shadcn/ui로 빠른 개발
|
||
|
|
✅ 베스트 프랙티스 준수
|
||
|
|
|
||
|
|
이 가이드를 따르면 높은 품질의 코드를 작성하고 유지보수가 쉬운 시스템을 구축할 수 있습니다.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
**문서 작성일**: 2025년 10월 17일
|
||
|
|
**버전**: 1.0.0
|
||
|
|
**작성자**: SAM MES 개발팀
|
||
|
|
|
||
|
|
**Happy Coding! 🚀**
|