Files
sam-react-prod/claudedocs/architecture/[PLAN-2026-01-22] ui-component-abstraction.md
유병철 f3b07ac875 chore(WEB): claudedocs 디렉토리 도메인별 재구조화
- 루트 문서 30개를 도메인별 하위 폴더로 이동
- accounting/, architecture/, dev/, guides/, security/ 등 카테고리 분류
- archive/ 폴더에 QA 스크린샷 이동
- _index.md 문서 맵 업데이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 09:35:22 +09:00

15 KiB

UI 컴포넌트 공통화/추상화 계획

작성일: 2026-01-22 상태: 🟢 진행 중 범위: 공통 UI 컴포넌트 추상화 및 스켈레톤 시스템 구축


결정 사항 (2026-01-22)

항목 결정
스켈레톤 전환 범위 Option A: 전체 스켈레톤 전환
구현 우선순위 Phase 1 먼저 (ConfirmDialog → StatusBadge → EmptyState)
확장 전략 옵션 기반 확장 - 새 패턴 발견 시 props 옵션으로 추가

1. 현황 분석 요약

반복 패턴 현황

패턴 파일 수 발생 횟수 복잡도 우선순위
확인 다이얼로그 (삭제/저장) 67개 170회 낮음 🔴 높음
상태 스타일 매핑 80개 다수 낮음 🔴 높음
날짜 범위 필터 55개 146회 중간 🟡 중간
빈 상태 UI 70개 86회 낮음 🟡 중간
로딩 스피너/버튼 59개 120회 중간 🟡 중간
스켈레톤 UI 4개 92회 높음 🔴 높음

현재 스켈레톤 현황

기존 구현:

  • src/components/ui/skeleton.tsx - 기본 스켈레톤 (단순 animate-pulse div)
  • IntegratedDetailTemplate/components/skeletons/ - 상세 페이지용 3종
    • DetailFieldSkeleton.tsx
    • DetailSectionSkeleton.tsx
    • DetailGridSkeleton.tsx
  • loading.tsx - 4개 파일만 존재 (대부분 PageLoadingSpinner 사용)

문제점:

  1. 대부분 페이지에서 로딩 스피너 사용 (스켈레톤 미적용)
  2. 리스트 페이지용 스켈레톤 없음
  3. 카드/대시보드용 스켈레톤 없음
  4. 페이지별 loading.tsx 부재 (4개만 존재)

2. 공통화 대상 상세

Phase 1: 핵심 공통 컴포넌트 (1주차)

1-1. ConfirmDialog 컴포넌트

현재 (반복 코드):

// 67개 파일에서 거의 동일하게 반복
const [showDeleteDialog, setShowDeleteDialog] = useState(false);

<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
  <AlertDialogContent>
    <AlertDialogHeader>
      <AlertDialogTitle>삭제 확인</AlertDialogTitle>
      <AlertDialogDescription>
        정말 삭제하시겠습니까? 삭제된 데이터는 복구할  없습니다.
      </AlertDialogDescription>
    </AlertDialogHeader>
    <AlertDialogFooter>
      <AlertDialogCancel disabled={isLoading}>취소</AlertDialogCancel>
      <AlertDialogAction
        onClick={handleDelete}
        className="bg-red-600 hover:bg-red-700"
        disabled={isLoading}
      >
        {isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
        삭제
      </AlertDialogAction>
    </AlertDialogFooter>
  </AlertDialogContent>
</AlertDialog>

개선안:

// src/components/ui/confirm-dialog.tsx
interface ConfirmDialogProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  title: string;
  description: string;
  confirmText?: string;
  cancelText?: string;
  variant?: 'default' | 'destructive' | 'warning';
  loading?: boolean;
  onConfirm: () => void | Promise<void>;
}

// 사용 예시
<ConfirmDialog
  open={showDeleteDialog}
  onOpenChange={setShowDeleteDialog}
  title="삭제 확인"
  description="정말 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다."
  confirmText="삭제"
  variant="destructive"
  loading={isLoading}
  onConfirm={handleDelete}
/>

효과:

  • 코드량: ~30줄 → ~10줄 (70% 감소)
  • 일관된 UX 보장
  • 로딩 상태 자동 처리

1-2. StatusBadge 컴포넌트 + createStatusConfig 유틸

현재 (반복 코드):

// 80개 파일에서 각각 정의
// estimates/types.ts
export const STATUS_STYLES: Record<string, string> = {
  pending: 'bg-yellow-100 text-yellow-800',
  inProgress: 'bg-blue-100 text-blue-800',
  completed: 'bg-green-100 text-green-800',
};
export const STATUS_LABELS: Record<string, string> = {
  pending: '대기',
  inProgress: '진행중',
  completed: '완료',
};

// site-management/types.ts (거의 동일)
export const SITE_STATUS_STYLES: Record<string, string> = { ... };
export const SITE_STATUS_LABELS: Record<string, string> = { ... };

개선안:

// src/lib/utils/status-config.ts
export type StatusVariant = 'default' | 'success' | 'warning' | 'error' | 'info';

export interface StatusConfig<T extends string> {
  value: T;
  label: string;
  variant: StatusVariant;
  description?: string;
}

export function createStatusConfig<T extends string>(
  configs: StatusConfig<T>[]
): {
  options: { value: T; label: string }[];
  getLabel: (status: T) => string;
  getVariant: (status: T) => StatusVariant;
  isValid: (status: string) => status is T;
}

// src/components/ui/status-badge.tsx
interface StatusBadgeProps<T extends string> {
  status: T;
  config: ReturnType<typeof createStatusConfig<T>>;
  size?: 'sm' | 'md' | 'lg';
}

// 사용 예시
// estimates/types.ts
export const estimateStatusConfig = createStatusConfig([
  { value: 'pending', label: '대기', variant: 'warning' },
  { value: 'inProgress', label: '진행중', variant: 'info' },
  { value: 'completed', label: '완료', variant: 'success' },
]);

// 컴포넌트에서
<StatusBadge status={data.status} config={estimateStatusConfig} />

효과:

  • 타입 안전성 강화
  • 일관된 색상 체계
  • options 자동 생성 (Select용)

1-3. EmptyState 컴포넌트

현재 (반복 코드):

// 70개 파일에서 다양한 형태로 반복
{data.length === 0 && (
  <div className="text-center py-10 text-muted-foreground">
    데이터가 없습니다
  </div>
)}

// 또는
<TableRow>
  <TableCell colSpan={columns.length} className="text-center py-8">
    등록된 항목이 없습니다
  </TableCell>
</TableRow>

개선안:

// src/components/ui/empty-state.tsx
interface EmptyStateProps {
  icon?: ReactNode;
  title?: string;
  description?: string;
  action?: ReactNode;
  variant?: 'default' | 'table' | 'card' | 'minimal';
}

// 사용 예시
<EmptyState
  icon={<FileX className="w-12 h-12" />}
  title="데이터가 없습니다"
  description="새로운 항목을 등록하거나 검색 조건을 변경해보세요."
  action={<Button onClick={onCreate}>등록하기</Button>}
/>

// 테이블 내 사용
<EmptyState variant="table" colSpan={10} title="검색 결과가 없습니다" />

Phase 2: 스켈레톤 시스템 구축 (2주차)

2-1. 스켈레톤 컴포넌트 확장

현재 문제:

  • 기본 Skeleton만 존재 (단순 div)
  • 페이지 유형별 스켈레톤 부재
  • 대부분 PageLoadingSpinner 사용 (스켈레톤 미적용)

추가할 스켈레톤:

// src/components/ui/skeletons/
├── index.ts                    // 통합 export
├── ListPageSkeleton.tsx        // 리스트 페이지용
├── DetailPageSkeleton.tsx      // 상세 페이지용 (기존 확장)
├── CardGridSkeleton.tsx        // 카드 그리드용
├── DashboardSkeleton.tsx       // 대시보드용
├── TableSkeleton.tsx           // 테이블용
├── FormSkeleton.tsx            // 폼용
└── ChartSkeleton.tsx           // 차트용

1. ListPageSkeleton (리스트 페이지용)

interface ListPageSkeletonProps {
  hasFilters?: boolean;
  filterCount?: number;
  hasDateRange?: boolean;
  rowCount?: number;
  columnCount?: number;
  hasActions?: boolean;
  hasPagination?: boolean;
}

// 사용 예시
export default function EstimateListLoading() {
  return (
    <ListPageSkeleton
      hasFilters
      filterCount={4}
      hasDateRange
      rowCount={10}
      columnCount={8}
      hasActions
      hasPagination
    />
  );
}

2. CardGridSkeleton (카드 그리드용)

interface CardGridSkeletonProps {
  cardCount?: number;
  cols?: 1 | 2 | 3 | 4;
  cardHeight?: 'sm' | 'md' | 'lg';
  hasImage?: boolean;
  hasFooter?: boolean;
}

// 대시보드 카드, 칸반 보드 등에 사용
<CardGridSkeleton cardCount={6} cols={3} cardHeight="md" />

3. TableSkeleton (테이블용)

interface TableSkeletonProps {
  rowCount?: number;
  columnCount?: number;
  hasCheckbox?: boolean;
  hasActions?: boolean;
  columnWidths?: string[]; // ['w-12', 'w-32', 'flex-1', ...]
}

<TableSkeleton rowCount={10} columnCount={8} hasCheckbox hasActions />

2-2. loading.tsx 파일 생성 전략

현재: 4개 파일만 존재 목표: 주요 페이지 경로에 맞춤형 loading.tsx 생성

생성 대상 (우선순위):

경로 스켈레톤 타입 우선순위
/construction/project/bidding/estimates ListPageSkeleton 🔴
/construction/project/bidding ListPageSkeleton 🔴
/construction/project/contract ListPageSkeleton 🔴
/construction/order/* ListPageSkeleton 🔴
/accounting/* ListPageSkeleton 🟡
/hr/* ListPageSkeleton 🟡
/settings/* ListPageSkeleton 🟢
상세 페이지 DetailPageSkeleton 🟡
대시보드 DashboardSkeleton 🟡

Phase 3: 날짜 범위 필터 + 로딩 버튼 (3주차)

3-1. DateRangeFilter 컴포넌트

현재 (반복 코드):

// 55개 파일에서 반복
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');

<div className="flex gap-2">
  <Input type="date" value={startDate} onChange={...} />
  <span>~</span>
  <Input type="date" value={endDate} onChange={...} />
</div>

개선안:

// src/components/ui/date-range-filter.tsx
interface DateRangeFilterProps {
  value: { start: string; end: string };
  onChange: (range: { start: string; end: string }) => void;
  presets?: ('today' | 'week' | 'month' | 'quarter' | 'year')[];
  disabled?: boolean;
}

// 사용 예시
<DateRangeFilter
  value={{ start: startDate, end: endDate }}
  onChange={({ start, end }) => {
    setStartDate(start);
    setEndDate(end);
  }}
  presets={['today', 'week', 'month']}
/>

3-2. LoadingButton 컴포넌트

현재 (반복 코드):

// 59개 파일에서 반복
<Button disabled={isLoading}>
  {isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
  저장
</Button>

개선안:

// src/components/ui/loading-button.tsx
interface LoadingButtonProps extends ButtonProps {
  loading?: boolean;
  loadingText?: string;
  spinnerPosition?: 'left' | 'right';
}

// 사용 예시
<LoadingButton loading={isLoading} loadingText="저장 중...">
  저장
</LoadingButton>

3. 로딩 스피너 vs 스켈레톤 전략

논의 사항

Option A: 전체 스켈레톤 전환

  • 장점: 더 나은 UX, 레이아웃 시프트 방지
  • 단점: 구현 비용 높음, 페이지별 커스텀 필요

Option B: 하이브리드 (권장)

  • 페이지 로딩: 스켈레톤 (loading.tsx)
  • 버튼/액션 로딩: 스피너 유지 (LoadingButton)
  • 데이터 갱신: 스피너 유지

Option C: 현행 유지

  • 대부분 스피너 유지
  • 특정 페이지만 스켈레톤

권장안: Option B (하이브리드)

상황 로딩 UI 이유
페이지 초기 로딩 스켈레톤 레이아웃 힌트 제공
페이지 전환 스켈레톤 Next.js loading.tsx 활용
버튼 클릭 (저장/삭제) 스피너 짧은 작업, 버튼 내 피드백
데이터 갱신 (필터 변경) 스피너 or 스켈레톤 상황에 따라
무한 스크롤 스켈레톤 추가 컨텐츠 힌트

4. 구현 로드맵

Week 1: 핵심 컴포넌트

  • ConfirmDialog 컴포넌트 생성 (2026-01-22)
    • src/components/ui/confirm-dialog.tsx
    • variants: default, destructive, warning, success
    • presets: DeleteConfirmDialog, SaveConfirmDialog, CancelConfirmDialog
    • 내부/외부 로딩 상태 자동 관리
  • StatusBadge + createStatusConfig 유틸 생성 (2026-01-22)
    • src/lib/utils/status-config.ts
    • src/components/ui/status-badge.tsx
    • 프리셋: default, success, warning, destructive, info, muted, orange, purple
    • 모드: badge (배경+텍스트), text (텍스트만)
    • OPTIONS, LABELS, STYLES 자동 생성
  • EmptyState 컴포넌트 생성 (2026-01-22)
    • src/components/ui/empty-state.tsx
    • variants: default, compact, large
    • presets: noData, noResults, noItems, error
    • TableEmptyState 추가 (테이블용)
  • 기존 코드 마이그레이션 (10개 파일 시범) (2026-01-22)
    • PricingDetailClient.tsx - 삭제 확인
    • ItemManagementClient.tsx - 단일/일괄 삭제
    • LaborDetailClient.tsx - 삭제 확인
    • ConstructionDetailClient.tsx - 완료 확인 (warning)
    • QuoteManagementClient.tsx - 단일/일괄 삭제
    • OrderDialogs.tsx - 저장/삭제/카테고리삭제
    • DepartmentManagement/index.tsx - 삭제 확인
    • VacationManagement/index.tsx - 승인/거절 확인
    • AccountDetail.tsx - 삭제 확인
    • ProcessListClient.tsx - 삭제 확인

Week 2: 스켈레톤 시스템

  • ListPageSkeleton 컴포넌트 생성
  • TableSkeleton 컴포넌트 생성
  • CardGridSkeleton 컴포넌트 생성
  • 주요 경로 loading.tsx 생성 (construction/*)

Week 3: 필터 + 버튼 + 마이그레이션

  • DateRangeFilter 컴포넌트 생성
  • LoadingButton 컴포넌트 생성
  • 전체 코드 마이그레이션

Week 4: 마무리 + QA

  • 남은 마이그레이션
  • 문서화
  • 성능 테스트

5. 예상 효과

코드량 감소

컴포넌트 Before After 감소율
ConfirmDialog ~30줄 ~10줄 67%
StatusBadge ~20줄 ~5줄 75%
EmptyState ~10줄 ~3줄 70%
DateRangeFilter ~15줄 ~5줄 67%

일관성 향상

  • 동일한 UX 패턴 적용
  • 디자인 시스템 강화
  • 유지보수 용이성 증가

성능 개선

  • 스켈레톤으로 인지 성능 향상
  • 레이아웃 시프트 감소
  • 사용자 이탈률 감소

6. 결정 필요 사항

Q1: 스켈레톤 전환 범위

  • Option A: 전체 스켈레톤 전환
  • Option B: 하이브리드 (권장)
  • Option C: 현행 유지

Q2: 구현 우선순위

  • Phase 1 먼저 (ConfirmDialog, StatusBadge, EmptyState)
  • Phase 2 먼저 (스켈레톤 시스템)
  • 동시 진행

Q3: 마이그레이션 범위

  • 전체 파일 한번에
  • 점진적 (신규/수정 파일만)
  • 도메인별 순차 (construction → accounting → hr)

7. 파일 구조 (최종)

src/components/ui/
├── confirm-dialog.tsx          # Phase 1
├── status-badge.tsx            # Phase 1
├── empty-state.tsx             # Phase 1
├── date-range-filter.tsx       # Phase 3
├── loading-button.tsx          # Phase 3
├── skeleton.tsx                # 기존
└── skeletons/                  # Phase 2
    ├── index.ts
    ├── ListPageSkeleton.tsx
    ├── DetailPageSkeleton.tsx
    ├── CardGridSkeleton.tsx
    ├── DashboardSkeleton.tsx
    ├── TableSkeleton.tsx
    ├── FormSkeleton.tsx
    └── ChartSkeleton.tsx

src/lib/utils/
└── status-config.ts            # Phase 1

다음 단계: 위 결정 사항에 대한 의견 확정 후 구현 시작