# Cursor Rules for ERP-node Project ## shadcn/ui 웹 스타일 가이드라인 모든 프론트엔드 개발 시 다음 shadcn/ui 기반 스타일 가이드라인을 준수해야 합니다. ### 1. Color System (색상 시스템) #### CSS Variables 사용 shadcn은 CSS Variables를 사용하여 테마를 관리하며, 모든 색상은 HSL 형식으로 정의됩니다. **기본 색상 토큰 (항상 사용):** - `--background`: 페이지 배경 - `--foreground`: 기본 텍스트 - `--primary`: 메인 액션 (Indigo 계열) - `--primary-foreground`: Primary 위 텍스트 - `--secondary`: 보조 액션 - `--muted`: 약한 배경 - `--muted-foreground`: 보조 텍스트 - `--destructive`: 삭제/에러 (Rose 계열) - `--border`: 테두리 - `--ring`: 포커스 링 **Tailwind 클래스로 사용:** ```tsx bg-primary text-primary-foreground bg-secondary text-secondary-foreground bg-muted text-muted-foreground bg-destructive text-destructive-foreground border-border ``` **추가 시맨틱 컬러:** - Success: `--success` (Emerald-600 계열) - Warning: `--warning` (Amber-500 계열) - Info: `--info` (Cyan-500 계열) ### 2. Spacing System (간격) **Tailwind Scale (4px 기준):** - 0.5 = 2px, 1 = 4px, 2 = 8px, 3 = 12px, 4 = 16px, 6 = 24px, 8 = 32px **컴포넌트별 권장 간격:** - 카드 패딩: `p-6` (24px) - 카드 간 마진: `gap-6` (24px) - 폼 필드 간격: `space-y-4` (16px) - 버튼 내부 패딩: `px-4 py-2` - 섹션 간격: `space-y-8` 또는 `space-y-12` ### 3. Typography (타이포그래피) **용도별 타이포그래피 (필수):** - 페이지 제목: `text-3xl font-bold` - 섹션 제목: `text-2xl font-semibold` - 카드 제목: `text-xl font-semibold` - 서브 제목: `text-lg font-medium` - 본문 텍스트: `text-sm text-muted-foreground` - 작은 텍스트: `text-xs text-muted-foreground` - 버튼 텍스트: `text-sm font-medium` - 폼 라벨: `text-sm font-medium` ### 4. Button Variants (버튼 스타일) **필수 사용 패턴:** ```tsx // Primary (기본) // Secondary // Outline // Ghost // Destructive ``` **버튼 크기:** - `size="sm"`: 작은 버튼 (h-9 px-3) - `size="default"`: 기본 버튼 (h-10 px-4 py-2) - `size="lg"`: 큰 버튼 (h-11 px-8) - `size="icon"`: 아이콘 전용 (h-10 w-10) ### 5. Input States (입력 필드 상태) **필수 적용 상태:** ```tsx // Default className="border-input" // Focus (모든 입력 필드 필수) className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" // Error className="border-destructive focus-visible:ring-destructive" // Disabled className="disabled:opacity-50 disabled:cursor-not-allowed" ``` ### 6. Card Structure (카드 구조) **표준 카드 구조 (필수):** ```tsx 제목 설명 {/* 내용 */} {/* 액션 버튼들 */} ``` ### 7. Border & Radius (테두리 및 둥근 모서리) **컴포넌트별 권장:** - 버튼: `rounded-md` (6px) - 입력 필드: `rounded-md` (6px) - 카드: `rounded-lg` (8px) - 배지: `rounded-full` - 모달/대화상자: `rounded-lg` (8px) - 드롭다운: `rounded-md` (6px) ### 8. Shadow (그림자) **용도별 권장:** - 카드: `shadow-sm` - 드롭다운: `shadow-md` - 모달: `shadow-lg` - 팝오버: `shadow-md` - 버튼 호버: `shadow-sm` ### 9. Interactive States (상호작용 상태) **필수 적용 패턴:** ```tsx // Hover hover:bg-primary/90 // 버튼 hover:bg-accent // Ghost 버튼 hover:underline // 링크 hover:shadow-md transition-shadow // 카드 // Focus (모든 인터랙티브 요소 필수) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 // Active active:scale-95 transition-transform // 버튼 // Disabled disabled:opacity-50 disabled:cursor-not-allowed ``` ### 10. Animation (애니메이션) **권장 Duration:** - 빠른 피드백: `duration-75` - 기본: `duration-150` - 부드러운 전환: `duration-300` **권장 패턴:** - 버튼 클릭: `transition-transform duration-150 active:scale-95` - 색상 전환: `transition-colors duration-150` - 드롭다운 열기: `transition-all duration-200` ### 11. Responsive (반응형) **Breakpoints:** - `sm`: 640px (모바일 가로) - `md`: 768px (태블릿) - `lg`: 1024px (노트북) - `xl`: 1280px (데스크톱) **반응형 패턴:** ```tsx // 모바일 우선 접근 className="flex-col md:flex-row" className="grid-cols-1 md:grid-cols-2 lg:grid-cols-3" className="text-2xl md:text-3xl lg:text-4xl" className="p-4 md:p-6 lg:p-8" ``` ### 12. Accessibility (접근성) **필수 적용 사항:** 1. 포커스 표시: 모든 인터랙티브 요소에 `focus-visible:ring-2` 적용 2. ARIA 레이블: 적절한 `aria-label`, `aria-describedby` 사용 3. 키보드 네비게이션: Tab, Enter, Space, Esc 지원 4. 색상 대비: 최소 4.5:1 (일반 텍스트), 3:1 (큰 텍스트) ### 13. Class 순서 (일관성 유지) **항상 이 순서로 작성:** 1. Layout: `flex`, `grid`, `block` 2. Sizing: `w-full`, `h-10` 3. Spacing: `p-4`, `m-2`, `gap-4` 4. Typography: `text-sm`, `font-medium` 5. Colors: `bg-primary`, `text-white` 6. Border: `border`, `rounded-md` 7. Effects: `shadow-sm`, `opacity-50` 8. States: `hover:`, `focus:`, `disabled:` 9. Responsive: `md:`, `lg:` ### 14. 실무 적용 규칙 1. **shadcn 컴포넌트 우선 사용**: 커스텀 스타일보다 shadcn 기본 컴포넌트 활용 2. **cn 유틸리티 사용**: 조건부 클래스는 `cn()` 함수로 결합 3. **테마 변수 사용**: 하드코딩된 색상 대신 CSS 변수 사용 4. **다크모드 고려**: 모든 컴포넌트는 다크모드 호환 필수 5. **일관성 유지**: 같은 용도의 컴포넌트는 같은 스타일 사용 ### 15. 금지 사항 1. ❌ 하드코딩된 색상 값 사용 (예: `bg-blue-500` 대신 `bg-primary`) 2. ❌ 인라인 스타일로 색상 지정 (예: `style={{ color: '#3b82f6' }}`) 3. ❌ 포커스 스타일 제거 (`outline-none`만 단독 사용) 4. ❌ 접근성 무시 (ARIA 레이블 누락) 5. ❌ 반응형 무시 (데스크톱 전용 스타일) 6. ❌ **중첩 박스 금지**: 사용자가 명시적으로 요청하지 않는 한 Card 안에 Card, Border 안에 Border 같은 중첩된 컨테이너 구조를 만들지 않음 ### 16. 중첩 박스 금지 상세 규칙 **금지되는 패턴 (사용자 요청 없이):** ```tsx // ❌ Card 안에 Card // 중첩 금지! 내용 // ❌ Border 안에 Border
// 중첩 금지! 내용
// ❌ 불필요한 래퍼
// 중첩 금지! 내용
``` **허용되는 패턴:** ```tsx // ✅ 단일 Card 제목 내용 // ✅ 의미적으로 다른 컴포넌트 조합 // Dialog는 별도 UI 레이어 ... // ✅ 그리드/리스트 내부의 Card들
항목 1 항목 2 항목 3
``` **예외 상황 (사용자가 명시적으로 요청한 경우만):** - 대시보드에서 섹션별 그룹핑이 필요한 경우 - 복잡한 데이터 구조를 시각적으로 구분해야 하는 경우 - 드래그앤드롭 등 특수 기능을 위한 경우 **원칙:** - 심플하고 깔끔한 디자인 유지 - 불필요한 시각적 레이어 제거 - 사용자가 명시적으로 "박스 안에 박스", "중첩된 카드" 등을 요청하지 않으면 단일 레벨 유지 ### 17. 표준 모달(Dialog) 디자인 패턴 **프로젝트 표준 모달 구조 (플로우 관리 기준):** ```tsx {/* 헤더: 제목 + 설명 */} 모달 제목 모달에 대한 간단한 설명 {/* 컨텐츠: 폼 필드들 */}
{/* 각 입력 필드 */}

도움말 텍스트 (선택사항)

{/* 푸터: 액션 버튼들 */}
``` **필수 적용 사항:** 1. **반응형 크기** - 모바일: `max-w-[95vw]` (화면 너비의 95%) - 데스크톱: `sm:max-w-[500px]` (고정 500px) 2. **헤더 구조** - DialogTitle: `text-base sm:text-lg` (16px → 18px) - DialogDescription: `text-xs sm:text-sm` (12px → 14px) - 항상 제목과 설명 모두 포함 3. **컨텐츠 간격** - 필드 간 간격: `space-y-3 sm:space-y-4` (12px → 16px) - 각 필드는 `
` 로 감싸기 4. **입력 필드 패턴** - Label: `text-xs sm:text-sm` + 필수 필드는 `*` 표시 - Input/Select: `h-8 text-xs sm:h-10 sm:text-sm` (32px → 40px) - 도움말: `text-muted-foreground mt-1 text-[10px] sm:text-xs` 5. **푸터 버튼** - 컨테이너: `gap-2 sm:gap-0` (모바일에서 간격, 데스크톱에서 자동) - 버튼: `h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm` - 모바일: 같은 크기 (`flex-1`) - 데스크톱: 자동 크기 (`flex-none`) - 순서: 취소(outline) → 확인(default) 6. **접근성** - Label의 `htmlFor`와 Input의 `id` 매칭 - Button에 적절한 `onClick` 핸들러 - Dialog의 `open`과 `onOpenChange` 필수 **확인 모달 (간단한 경고/확인):** ```tsx 작업 확인 정말로 이 작업을 수행하시겠습니까?
이 작업은 되돌릴 수 없습니다.
``` **원칙:** - 모든 모달은 모바일 우선 반응형 디자인 - 일관된 크기, 간격, 폰트 크기 사용 - 사용자가 다른 크기를 명시하지 않으면 `sm:max-w-[500px]` 사용 - 삭제/위험한 작업은 `variant="destructive"` 사용 ### 18. 검색 가능한 Select 박스 (Combobox 패턴) **적용 조건**: 사용자가 "검색 기능이 있는 Select 박스" 또는 "Combobox"를 명시적으로 요청한 경우만 사용 **표준 Combobox 구조 (플로우 관리 기준):** ```tsx import { Check, ChevronsUpDown } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; // 상태 관리 const [open, setOpen] = useState(false); const [value, setValue] = useState(""); // 렌더링 항목을 찾을 수 없습니다. {items.map((item) => ( { setValue(currentValue === value ? "" : currentValue); setOpen(false); }} className="text-xs sm:text-sm" > {item.label} ))} ``` **복잡한 데이터 표시 (라벨 + 설명):** ```tsx { setValue(currentValue); setOpen(false); }} className="text-xs sm:text-sm" >
{item.label} {item.description && ( {item.description} )}
``` **필수 적용 사항:** 1. **반응형 크기** - 버튼 높이: `h-8 sm:h-10` (32px → 40px) - 텍스트 크기: `text-xs sm:text-sm` (12px → 14px) - PopoverContent 너비: `width: "var(--radix-popover-trigger-width)"` (트리거와 동일) 2. **필수 컴포넌트** - Popover: 드롭다운 컨테이너 - Command: 검색 및 필터링 기능 - CommandInput: 검색 입력 필드 - CommandList: 항목 목록 컨테이너 - CommandEmpty: 검색 결과 없음 메시지 - CommandGroup: 항목 그룹 - CommandItem: 개별 항목 3. **아이콘 사용** - ChevronsUpDown: 드롭다운 표시 아이콘 (오른쪽) - Check: 선택된 항목 표시 (왼쪽) 4. **접근성** - `role="combobox"`: ARIA 역할 명시 - `aria-expanded={open}`: 열림/닫힘 상태 - PopoverTrigger에 `asChild` 사용 5. **로딩 상태** ```tsx ``` **일반 Select vs Combobox 선택 기준:** | 상황 | 컴포넌트 | 이유 | |------|----------|------| | 항목 5개 이하 | `` | 빠른 선택 | **원칙:** - 사용자가 명시적으로 요청하지 않으면 일반 Select 사용 - 많은 항목(10개 이상)을 다룰 때는 Combobox 권장 - 일관된 반응형 크기 유지 - 검색 플레이스홀더는 구체적으로 작성 ### 19. Form Validation (폼 검증) **입력 필드 상태별 스타일:** ```tsx // Default (기본) className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" // Error (에러) className="flex h-10 w-full rounded-md border border-destructive bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive" // Success (성공) className="flex h-10 w-full rounded-md border border-success bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-success" // Disabled (비활성) className="flex h-10 w-full rounded-md border border-input bg-muted px-3 py-2 text-sm opacity-50 cursor-not-allowed" ``` **Helper Text (도움말 텍스트):** ```tsx // 기본 Helper Text

8자 이상 입력해주세요

// Error Message

이메일 형식이 올바르지 않습니다

// Success Message

사용 가능한 이메일입니다

``` **Form Label (폼 라벨):** ```tsx // 기본 라벨 // 필수 항목 표시 ``` **전체 폼 필드 구조:** ```tsx
{error && (

{errorMessage}

)} {!error && helperText && (

{helperText}

)}
``` **실시간 검증 피드백:** ```tsx // 로딩 중 (검증 진행)
// 성공
// 실패
``` ### 20. Loading States (로딩 상태) **Spinner (스피너) 크기별:** ```tsx // Small // Default // Large ``` **Spinner 색상별:** ```tsx // Primary // Muted // White (다크 배경용) ``` **Button Loading:** ```tsx ``` **Skeleton UI:** ```tsx // 텍스트 스켈레톤
// 카드 스켈레톤
``` **Progress Bar (진행률):** ```tsx // 기본 Progress Bar
// 라벨 포함
업로드 중... {progress}%
``` **Full Page Loading:** ```tsx

로딩 중...

``` ### 21. Empty States (빈 상태) **기본 Empty State:** ```tsx

데이터가 없습니다

아직 생성된 항목이 없습니다. 새로운 항목을 추가해보세요.

``` **검색 결과 없음:** ```tsx

검색 결과가 없습니다

"{searchQuery}"에 대한 결과를 찾을 수 없습니다. 다른 검색어로 시도해보세요.

``` **에러 상태:** ```tsx

데이터를 불러올 수 없습니다

일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요.

``` **아이콘 가이드:** - 데이터 없음: Inbox, Package, FileText - 검색 결과 없음: Search, SearchX - 필터 결과 없음: Filter, FilterX - 에러: AlertCircle, XCircle - 네트워크 오류: WifiOff, CloudOff - 권한 없음: Lock, ShieldOff --- ## 추가 프로젝트 규칙 - 백엔드 재실행 금지 - 항상 한글로 답변 - 이모지 사용 금지 (명시적 요청 없이) - 심플하고 깔끔한 디자인 유지 --- ## 사용자 관리 필수 규칙 ### 최고 관리자(SUPER_ADMIN) 가시성 제한 **핵심 원칙**: 회사 관리자(COMPANY_ADMIN)와 일반 사용자(USER)는 **절대로** 최고 관리자(company_code = "*")를 볼 수 없어야 합니다. #### 백엔드 구현 필수사항 모든 사용자 관련 API에서 다음 필터링 로직을 **반드시** 적용해야 합니다: ```typescript // 최고 관리자 필터링 (필수) if (req.user && req.user.companyCode !== "*") { // 최고 관리자가 아닌 경우, company_code가 "*"인 사용자는 제외 whereConditions.push(`company_code != '*'`); logger.info("최고 관리자 필터링 적용", { userCompanyCode: req.user.companyCode }); } ``` **SQL 쿼리 예시:** ```sql SELECT * FROM user_info WHERE 1=1 AND company_code != '*' -- 최고 관리자 제외 AND company_code = $1 -- 회사별 필터링 ``` #### 적용 대상 API (필수) 다음 사용자 관련 API에 최고 관리자 필터링을 **반드시** 적용해야 합니다: 1. **사용자 목록 조회** (`GET /api/admin/users`) - 사용자 관리 페이지 - 권한 그룹 멤버 선택 (Dual List Box) - 검색/필터 결과 2. **사용자 검색** (`GET /api/admin/users/search`) - 자동완성/타입어헤드 - 드롭다운 선택 3. **부서별 사용자 조회** (`GET /api/admin/users/by-department`) - 부서 필터링 시 4. **사용자 상세 조회** (`GET /api/admin/users/:userId`) - 최고 관리자의 상세 정보는 최고 관리자만 볼 수 있음 #### 프론트엔드 추가 보호 (권장) 백엔드에서 이미 필터링되지만, 프론트엔드에서도 추가 체크를 권장합니다: ```typescript // 컴포넌트에서 최고 관리자 제외 const visibleUsers = users.filter(user => { // 최고 관리자만 최고 관리자를 볼 수 있음 if (user.companyCode === "*" && !isSuperAdmin) { return false; } return true; }); ``` #### 예외 사항 - **최고 관리자(company_code = "*")** 는 모든 사용자(다른 최고 관리자 포함)를 볼 수 있습니다. - 최고 관리자는 다른 회사의 데이터도 조회할 수 있습니다. #### 체크리스트 새로운 사용자 관련 기능 개발 시 다음을 확인하세요: - [ ] `req.user.companyCode !== "*"` 체크 추가 - [ ] `company_code != '*'` WHERE 조건 추가 - [ ] 로깅으로 필터링 적용 여부 확인 - [ ] 최고 관리자로 로그인하여 정상 작동 확인 - [ ] 회사 관리자로 로그인하여 최고 관리자가 안 보이는지 확인 #### 관련 파일 - `backend-node/src/controllers/adminController.ts` - `getUserList()` 함수 참고 - `backend-node/src/middleware/authMiddleware.ts` - 권한 체크 - `frontend/components/admin/UserManagement.tsx` - 사용자 목록 UI - `frontend/components/admin/RoleDetailManagement.tsx` - 멤버 선택 UI #### 보안 주의사항 - 클라이언트 측 필터링만으로는 부족합니다 (우회 가능). - 반드시 백엔드 SQL 쿼리에서 필터링해야 합니다. - API 응답에 최고 관리자 정보가 절대 포함되어서는 안 됩니다. - 로그에 필터링 여부를 기록하여 감사 추적을 남기세요. --- ## 멀티테넌시(Multi-Tenancy) 필수 규칙 ### 핵심 원칙 **모든 데이터 조회/생성/수정/삭제 로직은 반드시 회사별(company_code)로 격리되어야 합니다.** 이 시스템은 멀티테넌트 아키텍처를 사용하며, 각 회사(tenant)는 자신의 데이터만 접근할 수 있어야 합니다. ### 1. 데이터베이스 스키마 요구사항 #### company_code 컬럼 필수 모든 비즈니스 테이블은 `company_code` 컬럼을 **반드시** 포함해야 합니다: ```sql CREATE TABLE example_table ( id SERIAL PRIMARY KEY, company_code VARCHAR(20) NOT NULL, -- 필수! name VARCHAR(100), created_at TIMESTAMPTZ DEFAULT NOW(), CONSTRAINT fk_company FOREIGN KEY (company_code) REFERENCES company_info(company_code) ); -- 성능을 위한 인덱스 (필수) CREATE INDEX idx_example_company_code ON example_table(company_code); ``` #### 예외 테이블 다음 테이블들만 `company_code` 없이 전역 데이터를 저장할 수 있습니다: - `company_info` (회사 마스터 데이터) - `user_info` (사용자는 company_code 보유) - 시스템 설정 테이블 (`system_config` 등) - 감사 로그 테이블 (`audit_log` 등) ### 2. 백엔드 API 구현 필수 사항 #### 조회(SELECT) 쿼리 **모든 SELECT 쿼리는 company_code 필터링을 반드시 포함해야 합니다:** ```typescript // ✅ 올바른 방법 async function getDataList(req: Request, res: Response) { const companyCode = req.user!.companyCode; // 인증된 사용자의 회사 코드 const query = ` SELECT * FROM example_table WHERE company_code = $1 ORDER BY created_at DESC `; const result = await pool.query(query, [companyCode]); logger.info("데이터 조회", { companyCode, rowCount: result.rowCount }); return res.json({ success: true, data: result.rows }); } // ❌ 잘못된 방법 - company_code 필터링 없음 async function getDataList(req: Request, res: Response) { const query = `SELECT * FROM example_table`; // 모든 회사 데이터 노출! const result = await pool.query(query); return res.json({ success: true, data: result.rows }); } ``` #### 생성(INSERT) 쿼리 **모든 INSERT 쿼리는 company_code를 반드시 포함해야 합니다:** ```typescript // ✅ 올바른 방법 async function createData(req: Request, res: Response) { const companyCode = req.user!.companyCode; const { name, description } = req.body; const query = ` INSERT INTO example_table (company_code, name, description) VALUES ($1, $2, $3) RETURNING * `; const result = await pool.query(query, [companyCode, name, description]); logger.info("데이터 생성", { companyCode, id: result.rows[0].id }); return res.json({ success: true, data: result.rows[0] }); } // ❌ 잘못된 방법 - company_code 누락 async function createData(req: Request, res: Response) { const { name, description } = req.body; const query = ` INSERT INTO example_table (name, description) VALUES ($1, $2) `; // company_code 누락! 다른 회사 데이터와 섞임 const result = await pool.query(query, [name, description]); return res.json({ success: true, data: result.rows[0] }); } ``` #### 수정(UPDATE) 쿼리 **WHERE 절에 company_code를 반드시 포함해야 합니다:** ```typescript // ✅ 올바른 방법 async function updateData(req: Request, res: Response) { const companyCode = req.user!.companyCode; const { id } = req.params; const { name, description } = req.body; const query = ` UPDATE example_table SET name = $1, description = $2, updated_at = NOW() WHERE id = $3 AND company_code = $4 RETURNING * `; const result = await pool.query(query, [name, description, id, companyCode]); if (result.rowCount === 0) { return res.status(404).json({ success: false, message: "데이터를 찾을 수 없거나 권한이 없습니다" }); } logger.info("데이터 수정", { companyCode, id }); return res.json({ success: true, data: result.rows[0] }); } // ❌ 잘못된 방법 - 다른 회사 데이터도 수정 가능 async function updateData(req: Request, res: Response) { const { id } = req.params; const { name, description } = req.body; const query = ` UPDATE example_table SET name = $1, description = $2 WHERE id = $3 `; // 다른 회사의 같은 ID 데이터도 수정됨! const result = await pool.query(query, [name, description, id]); return res.json({ success: true, data: result.rows[0] }); } ``` #### 삭제(DELETE) 쿼리 **WHERE 절에 company_code를 반드시 포함해야 합니다:** ```typescript // ✅ 올바른 방법 async function deleteData(req: Request, res: Response) { const companyCode = req.user!.companyCode; const { id } = req.params; const query = ` DELETE FROM example_table WHERE id = $1 AND company_code = $2 RETURNING id `; const result = await pool.query(query, [id, companyCode]); if (result.rowCount === 0) { return res.status(404).json({ success: false, message: "데이터를 찾을 수 없거나 권한이 없습니다" }); } logger.info("데이터 삭제", { companyCode, id }); return res.json({ success: true }); } // ❌ 잘못된 방법 - 다른 회사 데이터도 삭제 가능 async function deleteData(req: Request, res: Response) { const { id } = req.params; const query = `DELETE FROM example_table WHERE id = $1`; const result = await pool.query(query, [id]); return res.json({ success: true }); } ``` ### 3. company_code = "*" 의미 **중요**: `company_code = "*"`는 **최고 관리자 전용 데이터**를 의미합니다. - ❌ 잘못된 이해: `company_code = "*"` = 모든 회사가 공유하는 공통 데이터 - ✅ 올바른 이해: `company_code = "*"` = 최고 관리자만 관리하는 전용 데이터 **회사별 데이터 격리 원칙**: - 회사 A (`company_code = "COMPANY_A"`): 회사 A 데이터만 조회/수정/삭제 가능 - 회사 B (`company_code = "COMPANY_B"`): 회사 B 데이터만 조회/수정/삭제 가능 - 최고 관리자 (`company_code = "*"`): 모든 회사 데이터 + 최고 관리자 전용 데이터 조회 가능 ### 4. 최고 관리자(SUPER_ADMIN) 예외 처리 **최고 관리자(company_code = "*")는 모든 회사 데이터에 접근할 수 있습니다:** ```typescript async function getDataList(req: Request, res: Response) { const companyCode = req.user!.companyCode; let query: string; let params: any[]; if (companyCode === "*") { // 최고 관리자: 모든 회사 데이터 조회 가능 query = ` SELECT * FROM example_table ORDER BY company_code, created_at DESC `; params = []; logger.info("최고 관리자 전체 데이터 조회"); } else { // 일반 회사: 자신의 회사 데이터만 조회 (company_code = "*" 데이터는 제외) query = ` SELECT * FROM example_table WHERE company_code = $1 ORDER BY created_at DESC `; params = [companyCode]; logger.info("회사별 데이터 조회", { companyCode }); } const result = await pool.query(query, params); return res.json({ success: true, data: result.rows }); } ``` **핵심**: 일반 회사 사용자는 `company_code = "*"` 데이터를 볼 수 없습니다! ### 5. JOIN 쿼리에서의 멀티테넌시 **모든 JOIN된 테이블에도 company_code 필터링을 적용해야 합니다:** ```typescript // ✅ 올바른 방법 const query = ` SELECT a.*, b.name as category_name, c.name as user_name FROM example_table a LEFT JOIN category_table b ON a.category_id = b.id AND a.company_code = b.company_code -- JOIN 조건에도 company_code 필수 LEFT JOIN user_info c ON a.user_id = c.user_id AND a.company_code = c.company_code WHERE a.company_code = $1 `; // ❌ 잘못된 방법 - JOIN에서 다른 회사 데이터와 섞임 const query = ` SELECT a.*, b.name as category_name FROM example_table a LEFT JOIN category_table b ON a.category_id = b.id -- company_code 없음! WHERE a.company_code = $1 `; ``` ### 6. 서비스 계층 패턴 **서비스 함수는 항상 companyCode를 첫 번째 파라미터로 받아야 합니다:** ```typescript // ✅ 올바른 서비스 패턴 class ExampleService { async findAll(companyCode: string, filters?: any) { const query = ` SELECT * FROM example_table WHERE company_code = $1 `; return await pool.query(query, [companyCode]); } async findById(companyCode: string, id: number) { const query = ` SELECT * FROM example_table WHERE id = $1 AND company_code = $2 `; const result = await pool.query(query, [id, companyCode]); return result.rows[0]; } async create(companyCode: string, data: any) { const query = ` INSERT INTO example_table (company_code, name, description) VALUES ($1, $2, $3) RETURNING * `; const result = await pool.query(query, [companyCode, data.name, data.description]); return result.rows[0]; } } // 컨트롤러에서 사용 const exampleService = new ExampleService(); async function getDataList(req: Request, res: Response) { const companyCode = req.user!.companyCode; const data = await exampleService.findAll(companyCode, req.query); return res.json({ success: true, data }); } ``` ### 7. 프론트엔드 고려사항 프론트엔드에서는 직접 company_code를 다루지 않습니다. 백엔드 API가 자동으로 처리합니다. ```typescript // ✅ 프론트엔드 - company_code 불필요 async function fetchData() { const response = await apiClient.get("/api/example/list"); // 백엔드에서 자동으로 현재 사용자의 company_code로 필터링됨 return response.data; } // ❌ 프론트엔드에서 company_code를 수동으로 전달하지 않음 async function fetchData(companyCode: string) { const response = await apiClient.get(`/api/example/list?companyCode=${companyCode}`); return response.data; } ``` ### 8. 마이그레이션 체크리스트 새로운 테이블이나 기능을 추가할 때 반드시 확인하세요: #### 데이터베이스 - [ ] 테이블에 `company_code VARCHAR(20) NOT NULL` 컬럼 추가 - [ ] `company_info` 테이블에 대한 외래키 제약조건 추가 - [ ] `company_code`에 인덱스 생성 - [ ] 샘플 데이터에 올바른 `company_code` 값 포함 #### 백엔드 API - [ ] SELECT 쿼리에 `WHERE company_code = $1` 추가 - [ ] INSERT 쿼리에 `company_code` 컬럼 포함 - [ ] UPDATE/DELETE 쿼리의 WHERE 절에 `company_code` 조건 추가 - [ ] JOIN 쿼리의 ON 절에 `company_code` 매칭 조건 추가 - [ ] 최고 관리자(`company_code = "*"`) 예외 처리 - [ ] 로그에 `companyCode` 정보 포함 #### 테스트 - [ ] 회사 A로 로그인하여 회사 A 데이터만 보이는지 확인 - [ ] 회사 B로 로그인하여 회사 B 데이터만 보이는지 확인 - [ ] 회사 A로 로그인하여 회사 B 데이터에 접근 불가능한지 확인 - [ ] 최고 관리자로 로그인하여 모든 데이터가 보이는지 확인 - [ ] 직접 SQL 인젝션 시도하여 다른 회사 데이터 접근 불가능 확인 ### 9. 보안 주의사항 #### 클라이언트 입력 검증 ```typescript // ❌ 위험 - 클라이언트가 company_code를 지정할 수 있음 async function createData(req: Request, res: Response) { const { companyCode, name } = req.body; // 사용자가 임의의 회사 코드 전달 가능! const query = `INSERT INTO example_table (company_code, name) VALUES ($1, $2)`; await pool.query(query, [companyCode, name]); } // ✅ 안전 - 인증된 사용자의 company_code만 사용 async function createData(req: Request, res: Response) { const companyCode = req.user!.companyCode; // 서버에서 확정 const { name } = req.body; const query = `INSERT INTO example_table (company_code, name) VALUES ($1, $2)`; await pool.query(query, [companyCode, name]); } ``` #### 감사 로그 모든 중요한 작업에 회사 정보를 로깅하세요: ```typescript logger.info("데이터 생성", { companyCode: req.user!.companyCode, userId: req.user!.userId, tableName: "example_table", action: "INSERT", recordId: result.rows[0].id, }); logger.warn("권한 없는 접근 시도", { companyCode: req.user!.companyCode, userId: req.user!.userId, attemptedRecordId: req.params.id, message: "다른 회사의 데이터 접근 시도", }); ``` ### 10. 일반적인 실수와 해결방법 #### 실수 1: 서브쿼리에서 company_code 누락 ```typescript // ❌ 잘못된 방법 const query = ` SELECT * FROM example_table WHERE category_id IN ( SELECT id FROM category_table WHERE active = true ) AND company_code = $1 `; // ✅ 올바른 방법 const query = ` SELECT * FROM example_table WHERE category_id IN ( SELECT id FROM category_table WHERE active = true AND company_code = $1 ) AND company_code = $1 `; ``` #### 실수 2: COUNT/SUM 집계 함수 ```typescript // ❌ 잘못된 방법 - 모든 회사의 총합 const query = `SELECT COUNT(*) as total FROM example_table`; // ✅ 올바른 방법 const query = ` SELECT COUNT(*) as total FROM example_table WHERE company_code = $1 `; ``` #### 실수 3: EXISTS 서브쿼리 ```typescript // ❌ 잘못된 방법 const query = ` SELECT * FROM example_table a WHERE EXISTS ( SELECT 1 FROM related_table b WHERE b.example_id = a.id ) AND a.company_code = $1 `; // ✅ 올바른 방법 const query = ` SELECT * FROM example_table a WHERE EXISTS ( SELECT 1 FROM related_table b WHERE b.example_id = a.id AND b.company_code = a.company_code ) AND a.company_code = $1 `; ``` ### 11. 참고 자료 - 마이그레이션 파일: `db/migrations/033_add_company_code_to_code_tables.sql` - 멀티테넌시 분석 문서: `docs/멀티테넌시_구현_현황_분석_보고서.md` - 사용자 관리 컨트롤러: `backend-node/src/controllers/adminController.ts` - 인증 미들웨어: `backend-node/src/middleware/authMiddleware.ts` ### 12. 요약 **모든 비즈니스 로직에서 회사별 데이터 격리는 필수입니다:** 1. 모든 테이블에 `company_code` 컬럼 추가 2. 모든 쿼리에 `company_code` 필터링 적용 3. 인증된 사용자의 `req.user.companyCode` 사용 4. 클라이언트 입력으로 `company_code`를 받지 않음 5. 최고 관리자(`company_code = "*"`)는 모든 데이터 조회 가능 6. **일반 회사는 `company_code = "*"` 데이터를 볼 수 없음** (최고 관리자 전용) 7. JOIN, 서브쿼리, 집계 함수에도 동일하게 적용 8. 모든 작업을 로깅하여 감사 추적 가능 **절대 잊지 마세요: 멀티테넌시는 보안의 핵심입니다!** **company_code = "*"는 공통 데이터가 아닌 최고 관리자 전용 데이터입니다!**