# 프로젝트 기술 결정 사항 > `_index.md`에서 분리됨 (2026-02-23). 프로젝트 전반의 기술 선택 배경과 근거를 기록. --- ### `` 태그 사용 — `next/image` 미사용 이유 (2026-02-10) **현황**: 프로젝트 전체 `` 태그 10건, `next/image` 0건 **결정**: `` 유지, `next/image` 전환 불필요 **근거**: 1. **폐쇄형 ERP 시스템** — SEO 불필요, LCP 점수 무의미 2. **전량 외부 동적 이미지** — 백엔드 API에서 받아오는 URL (정적 내부 이미지 0건) 3. **프린트/문서 레이아웃** — 10건 중 8건이 검사 기준서·도해 등 인쇄용. `next/image`의 `width`/`height` 강제 지정이 프린트 레이아웃을 깰 위험 4. **blob URL 비호환** — 업로드 미리보기(blob:)는 `next/image`가 지원 안 함 5. **설정 부담 > 이점** — `remotePatterns` 설정 + 백엔드 도메인 관리 비용이 실질 이점보다 큼 ### 모바일 헤더 `backdrop-filter` 깜빡임 수정 (2026-02-11) **현상**: 모바일(Safari/Chrome)에서 sticky 헤더가 스크롤 시 투명↔불투명 깜빡임 발생. PC 브라우저 축소로는 재현 불가, 실제 모바일 기기에서만 발생. **원인 2가지**: 1. `globals.css`에 `* { transition: all 0.2s }` — 전체 요소의 모든 CSS 속성에 전역 transition. 모바일 스크롤 리페인트 시 background/opacity가 매번 애니메이션 2. 모바일 헤더의 `clean-glass` 클래스: `backdrop-filter: blur(8px)` + `background: rgba(255,255,255, 0.95)` 조합이 모바일 sticky 요소에서 GPU 컴포지팅 충돌 **수정**: - `globals.css`: `*` 전역 transition → `button, a, input, select, textarea, [role]` 인터랙티브 요소만, `transition: all` → `color, background-color, border-color, box-shadow` 속성만 - 모바일 헤더: `clean-glass` (반투명+blur) → `bg-background border border-border` (불투명 배경) **교훈**: - `transition: all`은 절대 `*`에 걸지 않기. 모바일 성능 저하 + 의도치 않은 애니메이션 발생 - `backdrop-filter: blur()` + `sticky` 조합은 모바일 브라우저 고질적 리페인트 버그. 모바일 헤더는 불투명 배경 사용 - 0.95 투명도는 육안 구분 불가 → 불투명 처리해도 시각적 차이 없음 **사용처 (9개 파일)**: | 파일 | 용도 | 이미지 소스 | |------|------|-------------| | `DocumentHeader.tsx` (2건) | 문서 헤더 로고 | `logo.imageUrl` (API) | | `ProductInspectionInputModal.tsx` | 제품검사 사진 미리보기 | blob URL | | `ProductInspectionDocument.tsx` | 제품검사 문서 | `data.productImage` (API) | | `inspection-shared.tsx` | 검사 기준서 이미지 | `standardImage` (API) | | `SlatInspectionContent.tsx` | 도해 이미지 | `schematicImage` (API) | | `ScreenInspectionContent.tsx` | 도해 이미지 | `schematicImage` (API) | | `BendingInspectionContent.tsx` | 도해 이미지 | `schematicImage` (API) | | `SlatJointBarInspectionContent.tsx` | 도해 이미지 | `schematicImage` (API) | | `BendingWipInspectionContent.tsx` | 도해 이미지 | `schematicImage` (API) | **참고**: `next/image`가 유효한 케이스는 공개 사이트 + 정적/내부 이미지 + SEO 중요한 상황 ### `next/dynamic` 코드 스플리팅 적용 (2026-02-10) **결정**: 대형 컴포넌트 + 무거운 라이브러리에 `next/dynamic` / 동적 `import()` 적용 **핵심 개념 — Suspense vs dynamic()**: - **`Suspense` + 정적 import** → 코드가 부모와 같은 번들 청크에 포함. 유저가 안 봐도 이미 다운로드됨. UI fallback만 제공하고 **코드 분할은 안 일어남** - **`dynamic()`** → webpack이 별도 `.js` 청크로 분리. 컴포넌트가 실제 렌더될 때만 네트워크 요청으로 해당 청크 다운로드. **진짜 코드 분할** **적용 내역**: | 파일 | 대상 | 절감 | |------|------|------| | `reports/comprehensive-analysis/page.tsx` | MainDashboard (2,651줄 + recharts) | ~350KB | | `components/business/Dashboard.tsx` | CEODashboard | ~200KB | | `construction/ConstructionDashboard.tsx` | ConstructionMainDashboard | ~100KB | | `production/dashboard/page.tsx` | ProductionDashboard | ~100KB | | `lib/utils/excel-download.ts` | xlsx 라이브러리 (~400KB) | ~400KB | | `quotes/LocationListPanel.tsx` | xlsx 직접 import 제거 | (위와 중복) | **xlsx 동적 로드 패턴**: ```typescript // Before: 모든 페이지에 xlsx ~400KB 포함 import * as XLSX from 'xlsx'; // After: 엑셀 버튼 클릭 시에만 로드 async function loadXLSX() { return await import('xlsx'); } export async function downloadExcel(...) { const XLSX = await loadXLSX(); // ... } ``` **총 절감**: 초기 번들에서 ~850KB 제외 (대시보드 미방문 + 엑셀 미사용 시) ### 테이블 가상화 (react-window) — 보류 (2026-02-10) **결정**: 현시점 도입 불필요, 성능 이슈 발생 시 검토 **근거**: 1. **페이지네이션 사용 중** — 리스트 페이지 대부분 서버 사이드 페이지네이션 (20~50건/페이지). 50개 ``은 브라우저가 문제없이 처리 2. **적용 복잡도 높음** — 테이블 헤더 고정, 체크박스 선택, `rowSpan/colSpan` 병합 등 기존 기능과 충돌 가능. DataTable + IntegratedListTemplateV2 + UniversalListPage 전부 수정 필요 3. **YAGNI** — 500건 이상 한 번에 렌더링하는 페이지가 현재 없음 **도입 시점**: 한 페이지에 200건+ 데이터를 페이지네이션 없이 표시해야 하는 요구가 생길 때 ### SWR / React Query — 보류 (2026-02-10) **결정**: 현시점 도입 불필요, 성능 이슈 발생 시 검토 **근거**: 1. **기존 패턴 안정화 완료** — `useEffect` + Server Action 호출 패턴이 전 페이지에 일관 적용됨 2. **캐싱 니즈 낮음** — 폐쇄형 ERP 특성상 항상 최신 데이터 필요. stale 데이터 표시는 오히려 위험 3. **마스터데이터 캐싱 구현됨** — Zustand (`stores/masterDataStore`)로 변경 빈도 낮은 데이터는 이미 캐싱 중 4. **도입 비용 과다** — 수십 개 페이지 `useState`+`useEffect` 패턴 전면 리팩토링 + 팀 학습 비용 **도입 시점**: 동일 데이터를 여러 컴포넌트에서 동시 요구하거나, 목록 ↔ 상세 이동 시 재로딩이 체감될 때 ### 컴포넌트 레지스트리 관계도 (2026-02-12) **구현**: `/dev/component-registry` 페이지에 관계도(카드형 플로우) 뷰 추가 **구성**: - `actions.ts` — `extractComponentImports()` + `buildRelationships()`로 import 관계 양방향 파싱 (imports/usedBy) - `ComponentRelationshipView.tsx` — 3칼럼 카드형 플로우 (사용처 → 선택 컴포넌트 → 구성요소) - `ComponentRegistryClient.tsx` — 목록/관계도 뷰 토글 **활용 규칙** (CLAUDE.md에 추가됨): - 새 컴포넌트 생성 전 → 목록에서 중복 검색 + 관계도에서 조합 패턴 확인 - 기존 컴포넌트 수정 시 → usedBy로 영향 범위 파악 ### Action 팩토리 패턴 — 신규 CRUD 적용 규칙 (2026-02-10) **결정**: 기존 84개 actions.ts 전면 전환은 하지 않음. **신규 CRUD 도메인에만 팩토리 사용** **현황**: - `src/lib/api/create-crud-service.ts` (177줄) — CRUD 보일러플레이트 자동 생성 팩토리 - 현재 사용 중: TitleManagement, RankManagement (2개) - 전환 가능: 15~20개 / 전환 불가 (커스텀 로직): 50+개 **규칙**: - 신규 도메인 추가 시 단순 CRUD → `createCrudService` 사용 필수 - 기존 actions.ts는 잘 동작하므로 무리하게 전환하지 않음 - 커스텀 비즈니스 로직이 있는 도메인(견적, 수주, 생산 등)은 팩토리 비적합 **사용 예시**: ```typescript import { createCrudService } from '@/lib/api/create-crud-service'; const service = createCrudService({ basePath: '/api/v1/resources', transform: (api) => ({ id: api.id, name: api.name }), entityName: '리소스', }); export const getList = service.getList; export const getById = service.getById; export const create = service.create; export const update = service.update; export const remove = service.remove; ``` **미전환 사유**: 84개 중 전환 가능 15~20개, 작업 2~4시간 대비 기능 변화 없음. 시간 대비 효율 낮음 ### Server Action 공통 유틸리티 — 전체 마이그레이션 완료 (2026-02-12) **결정**: `buildApiUrl()` 전체 43개 actions.ts에 적용 완료 **배경**: - 89개 actions.ts 중 43개에서 동일한 URLSearchParams 조건부 `.set()` 패턴 반복 (326+ 건) - 50+ 파일에서 `current_page → currentPage` 수동 변환 반복 - `toPaginationMeta`가 `src/lib/api/types.ts`에 존재하나 import 0건 **생성된 유틸리티**: 1. `src/lib/api/query-params.ts` — `buildQueryParams()`, `buildApiUrl()`: URLSearchParams 보일러플레이트 제거 2. `src/lib/api/execute-paginated-action.ts` — `executePaginatedAction()`: 페이지네이션 조회 패턴 통합 (내부에서 `toPaginationMeta` 사용) **마이그레이션 결과** (2026-02-12): - `new URLSearchParams` 사용: 326건 → **0건** (actions.ts 기준) - `const API_URL = process.env.NEXT_PUBLIC_API_URL` 선언: 43개 → **0개** (마이그레이션 대상 파일) - `buildApiUrl()` import: 43개 actions.ts 전체 적용 - 3가지 API_URL 패턴 통합: 표준(`process.env`), `/api` 접미사(HR), `API_BASE` 전체경로(품질) → 모두 `buildApiUrl('/api/v1/...')` 통일 **`executePaginatedAction` 마이그레이션** (2026-02-12): - 14개 actions.ts에서 페이지네이션 목록 조회 함수를 `executePaginatedAction`으로 전환 - Wave A (accounting 9개): BillManagement, DepositManagement, SalesManagement, PurchaseManagement, WithdrawalManagement, VendorLedger, CardTransactionInquiry, BankTransactionInquiry, ExpectedExpenseManagement - Wave B (5개): PaymentHistoryManagement, StockStatus, ReceivingManagement, ShipmentManagement, quotes - 제외 5개: AccountManagement(`meta` 필드명), orders(`data.items` 중첩), VacationManagement, EmployeeManagement, construction/order-management (별도 구조) - 순 감소: ~220줄 (14파일 × ~20줄 제거, ~28줄 추가) - 제거된 보일러플레이트: `DEFAULT_PAGINATION`, `FrontendPagination`/`PaginationMeta` 로컬 인터페이스, `PaginatedApiResponse` import, 수동 transform+pagination 조립 - **화면 검수 완료** (4개 페이지): Bills, StockStatus, Quotes, Shipments — 전체 PASS - **버그 발견/수정**: `quotes/actions.ts`에서 `export type { PaginationMeta }` re-export가 Turbopack 런타임 에러 유발 (`tsc`로 미감지) → re-export 제거, 컴포넌트에서 `@/lib/api/types` 직접 import로 변경 ### `'use server'` 파일 타입 export 제한 (2026-02-12) **발견 배경**: `executePaginatedAction` 마이그레이션 화면 검수 중 견적관리 페이지 빌드 에러 **제한 사항**: - `'use server'` 파일에서는 **async 함수만 export 가능** (Next.js Turbopack 제한) - `export type { X } from '...'` (re-export) → **런타임 에러 발생** - `export interface X { ... }` / `export type X = ...` (인라인 정의) → **문제 없음** (컴파일 시 제거) - `tsc --noEmit`으로는 감지 불가 — Next.js 전용 규칙이므로 실제 페이지 접속(Turbopack)에서만 발생 **현재 상태**: 전체 81개 `'use server'` 파일 점검 완료, re-export 패턴 0건 (수정된 1건 포함) **buildApiUrl 마이그레이션 전략**: - Wave A: 1건짜리 단순 파일 20개 - Wave B: 2건짜리 파일 12개 (quotes, WorkOrders, orders 등 대형 파일 포함) - Wave C: 3건 이상 파일 12개 (VendorLedger 5건, ReceivingManagement 5건, ProcessManagement 19건 URL 등) **효과**: - 페이지네이션 조회 코드: ~20줄 → ~5줄 - `DEFAULT_PAGINATION` 중앙화 (`execute-paginated-action.ts` 내부) - `toPaginationMeta` 자동 활용 (직접 import 불필요) - URL 빌딩 패턴 완전 일관화 (undefined/null/'' 자동 필터링, boolean/number 자동 변환) ### KST 안전 날짜 유틸리티 — `toISOString` 사용 금지 (2026-02-19) **현황**: `new Date().toISOString().split('T')[0]` — 15개 파일 26곳에서 사용 중이었음 **문제**: `toISOString()`은 **UTC 기준**으로 변환. 한국(KST, UTC+9)에서 오전 9시 이전에 실행하면 **전날 날짜** 반환 ``` // 2026-02-19 08:30 KST → UTC는 2026-02-18 23:30 new Date().toISOString().split('T')[0] // "2026-02-18" ← 잘못됨 ``` **결정**: KST 안전 유틸리티 함수로 전량 교체, 직접 `toISOString` 사용 금지 **유틸리티** (`src/lib/utils/date.ts`): | 함수 | 용도 | 대체 대상 | |------|------|-----------| | `getTodayString()` | 오늘 날짜 문자열 | `new Date().toISOString().split('T')[0]` | | `getLocalDateString(date)` | 임의 Date 객체 문자열 | `someDate.toISOString().split('T')[0]` | **사용 규칙**: ```typescript // 올바른 패턴 import { getTodayString, getLocalDateString } from '@/lib/utils/date'; const today = getTodayString(); // "2026-02-19" const thirtyDaysAgo = getLocalDateString(pastDate); // "2026-01-20" // 금지 패턴 const today = new Date().toISOString().split('T')[0]; ``` **현재 상태**: `src/` 내 `toISOString().split` 사용 0건 (date.ts 내 구현부 제외) ### 달력/스케줄 공통 리소스 — 작업 전 필수 확인 (2026-02-23) 달력·일정·날짜 관련 작업 시 아래 공통 리소스를 **반드시 확인**하고 사용할 것. **날짜 유틸리티** (`src/lib/utils/date.ts`): | 함수 | 용도 | |------|------| | `getLocalDateString(date)` | Date → `'YYYY-MM-DD'` (KST 안전) | | `getTodayString()` | 오늘 날짜 문자열 | | `formatDate(dateStr)` | 표시용 날짜 포맷 (null → `'-'`) | | `formatDateForInput(dateStr)` | input용 `YYYY-MM-DD` 변환 | | `formatDateRange(start, end)` | `'시작 ~ 종료'` 포맷 | | `getDateAfterDays(n)` | N일 후 날짜 | **달력 일정 스토어** (`src/stores/useCalendarScheduleStore.ts`): - 달력관리(CalendarManagement)에서 등록한 공휴일/세무일정/회사일정을 프로젝트 전체에 공유 - `fetchSchedules(year)` — 연도별 캐시 조회 (API 호출) - `setSchedulesForYear(year, data)` — 이미 가져온 데이터 직접 설정 - `invalidateYear(year)` — 캐시 무효화 (등록/수정/삭제 후) - **현재 상태**: 백엔드 API 미구현 → 호출부 주석 처리 (TODO 검색) **달력 이벤트 유틸** (`src/constants/calendarEvents.ts`): - `isHoliday(date)`, `isTaxDeadline(date)`, `getHolidayName(date)` 등 - 스토어 우선 → 하드코딩 폴백(2026년) 패턴 - 새 연도 폴백 데이터 필요 시 이 파일에 `HOLIDAYS_YYYY`, `TAX_DEADLINES_YYYY` 추가 **ScheduleCalendar 공통 컴포넌트** (`src/components/common/ScheduleCalendar/`): - `hideNavigation` prop으로 헤더 숨김 가능 (연간 달력 등 상위 네비게이션 사용 시) - `availableViews={[]}` 으로 뷰 전환 버튼 숨김 **규칙**: - `Date → string` 변환 시 `getLocalDateString()` 필수 (`toISOString().split('T')[0]` 금지) - 공휴일/세무일 판별 시 `calendarEvents.ts` 유틸 함수 사용 - 달력 데이터 공유 시 zustand 스토어 경유 (컴포넌트 간 직접 전달 금지) ### `useDateRange` 훅 — 날짜 필터 보일러플레이트 제거 (2026-02-19) **현황**: 20+ 리스트 페이지에서 `useState('2025-01-01')` / `useState('2025-12-31')` 하드코딩 **문제**: 연도가 바뀌면 수동으로 모든 파일 수정 필요 (2025→2026 전환 시 데이터 미표시 버그 발생) **결정**: `useDateRange` 훅으로 동적 날짜 범위 자동 계산 **훅** (`src/hooks/useDateRange.ts`): ```typescript import { useDateRange } from '@/hooks'; // 프리셋 const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear'); // 2026-01-01 ~ 2026-12-31 const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentMonth'); // 2026-02-01 ~ 2026-02-28 const { startDate, endDate, setStartDate, setEndDate } = useDateRange('today'); // 2026-02-19 ~ 2026-02-19 ``` **적용 규칙**: - 리스트 페이지 날짜 필터 → `useDateRange` 필수 사용 - 연간 조회 → `'currentYear'`, 월간 조회 → `'currentMonth'` - `useState('YYYY-MM-DD')` 하드코딩 금지 **현재 상태**: `useState('2025` 패턴 0건 (전량 `useDateRange`로 전환 완료) ### Zod 스키마 검증 — 신규 폼 적용 규칙 (2026-02-11) **결정**: 기존 폼은 건드리지 않음. **신규 폼에만 Zod + zodResolver 적용** **설치 상태**: `zod@^4.1.12`, `@hookform/resolvers@^5.2.2` — 이미 설치됨 **효과**: 1. 스키마 하나로 **타입 추론 + 런타임 검증** 동시 해결 (`z.infer`) 2. 별도 `interface` 중복 정의 불필요 3. 신규 코드에서 `as` 캐스트 자연 감소 (D-2 개선 효과) **규칙**: - 신규 폼 → `zodResolver(schema)` 사용 필수 (CLAUDE.md에 패턴 명시) - 기존 `rules={{ required: true }}` 패턴 폼 → 마이그레이션 불필요 - 단순 1~2 필드 인라인 폼 → Zod 불필요 (오버엔지니어링) **미적용 사유**: 기존 폼 수십 개를 전면 전환하는 비용 >> 이득. 신규 코드에서 점진적 확산