codingstairs
노트에듀라이프연락
⌕검색⌘K
koen

Navigation

  • Intro
  • Blog
  • Life

연락하기

로그인 없이도 보낼 수 있어요. 답변이 필요하면 이메일을 함께 적어 주세요.

  • 익명 폼으로 의견 남기기 →
  • ✉ warragon112@gmail.com
  • 카카오톡 오픈채팅 ↗

© 2026 codingstairs

  • 노트
  • 에듀
  • 검색
  • 라이프
  • 연락
  • 약관
  • RSS
  • GitHub
노트›frontend

관리자 UI — ResourceTable SSOT 패턴

2026-05-06 게시· 2026-05-18 갱신·0회 조회

관리자 UI — ResourceTable SSOT 패턴

사내 관리자 페이지는 숫자로 보면 목록 페이지 수십 개의 합입니다. 유저 목록 · 게시글 목록 · 포인트 거래 목록 · 신고 목록 등. 이들이 각자 다른 모양으로 만들어지면 운영자가 페이지마다 UI 를 다시 배우고, 개발자는 같은 boilerplate 를 매번 복붙합니다. 한 컴포넌트에 모아두면 이런 두 비용이 동시에 줄어듭니다.

1. 공통 목록 페이지의 공통 요소

어떤 관리자 목록이든 대개 같은 요소를 가집니다.

  • 헤더 — 제목 + 아이콘 + 부제 + (추가 등) 액션 버튼
  • 검색 입력 — 이메일 · ID · slug 등으로 필터
  • 필터 — 상태 · 카테고리 · 기간
  • 테이블 — 열 헤더 + 행 + 셀 커스텀 렌더
  • 페이지네이션 — page · pageSize · total
  • 빈 상태 — "데이터가 없습니다" + 버튼
  • 로딩 / 에러

React 로 처음 짜면 페이지마다 30 ~ 100 줄의 보일러플레이트가 생깁니다. 공통 컴포넌트로 추출하면 페이지 본체는 쿼리 + 행 매핑 30 줄로 축소.

2. Props 설계

interface ResourceTableProps<Row> {
  title: string;
  subtitle?: string;
  icon: ReactNode;                       // lucide-react icon
  iconColor: 'blue' | 'green' | 'amber' | 'rose' | 'violet';
  actions?: ReactNode;                   // 헤더 오른쪽 슬롯 ("새로 추가" 등)
  headersOnly?: boolean;                 // true 면 hero 만, 테이블 없음 (hero 단독 모드)
  search?: {
    placeholder: string;
    value: string;
    onChange: (v: string) => void;
  };
  filters?: ReactNode;                   // 상태·카테고리 select 그룹
  columns: Array<{
    header: string;
    key: keyof Row | string;
    render?: (row: Row) => ReactNode;
    className?: string;                  // w-1/3 등 폭 지정
  }>;
  rows: Row[];
  rowKey: (row: Row) => string | number;
  emptyState?: { title: string; description?: string; action?: ReactNode };
  pagination?: {
    page: number;
    pageSize: number;
    total: number;
    onPageChange: (p: number) => void;
  };
}

헤더와 테이블을 한 컴포넌트에 묶으면 페이지 레이아웃이 저절로 일관됩니다. headersOnly 같은 명시적 옵트아웃 플래그는 "등록 양식" 페이지 (테이블이 없는) 도 같은 hero 모양을 유지할 수 있게 합니다.

3. 사용 예

<ResourceTable
  title="감사 로그 — Pryzeet"
  subtitle="도메인별 관리자 행위 추적"
  icon={<ClipboardList />}
  iconColor="violet"
  search={{
    placeholder: '이메일 · 리소스 ID',
    value: q,
    onChange: setQ,
  }}
  filters={
    <Select value={action} onValueChange={setAction}>
      <SelectItem value="">모든 액션</SelectItem>
      <SelectItem value="DELETE">DELETE</SelectItem>
      <SelectItem value="UPDATE">UPDATE</SelectItem>
    </Select>
  }
  columns={[
    { header: '시각', key: 'created_at', render: (r) => formatDate(r.created_at) },
    { header: '사용자', key: 'user_email' },
    { header: '액션', key: 'action' },
    { header: '리소스', key: 'resource' },
    { header: '사유', key: 'details', render: (r) => r.details?.reason ?? '—' },
  ]}
  rows={logs}
  rowKey={(r) => r.id}
  pagination={{ page, pageSize: 20, total, onPageChange: setPage }}
  emptyState={{ title: '감사 로그 없음', description: '필터를 바꿔보세요' }}
/>

페이지 본체는 useState · DB 쿼리 · 이 컴포넌트 반환으로 끝납니다. 페이지당 30 ~ 70 줄.

4. 색상 맵 SSOT

const iconColorMap = {
  blue:   { bg: 'bg-blue-50',   fg: 'text-blue-600'   },
  green:  { bg: 'bg-green-50',  fg: 'text-green-600'  },
  amber:  { bg: 'bg-amber-50',  fg: 'text-amber-600'  },
  rose:   { bg: 'bg-rose-50',   fg: 'text-rose-600'   },
  violet: { bg: 'bg-violet-50', fg: 'text-violet-600' },
} as const;

Tailwind 의 동적 클래스는 완전한 문자열이어야 빌드가 포함시키므로 (text-${color}-600 은 purge 에서 누락), 타입드 객체 키로 매핑합니다. 6 ~ 8 색 정도까지는 이 방식이 실용. 그 이상 늘리면 CSS 변수 (--table-icon-fg 등) 로 분리하는 쪽이 유지보수에 유리.

5. 접근성

  • 테이블은 <table> 시맨틱 태그. role="table" 은 금지
  • 컬럼 헤더는 <th scope="col">, 행 헤더는 <th scope="row">
  • 정렬 가능 헤더는 aria-sort="ascending" | "descending" | "none"
  • 빈 상태 메시지는 <caption> 또는 <tbody> 안의 <tr> 로 테이블 안에 유지 (스크린 리더가 "표 비어 있음" 을 이해)
  • 페이지네이션은 <nav aria-label="페이지"> 안에 버튼 그룹

6. 서버 / 클라이언트 경계

ResourceTable 자체는 'use client' 컴포넌트. 하지만 데이터 패칭은 Server Component 에서 하고 props 로 내려주는 편이 Next.js App Router 에서 자연스럽습니다.

// page.tsx (Server)
export default async function Page({ searchParams }) {
  const sp = await searchParams;
  const { rows, total } = await queryLogs(sp);
  return <LogsView initialRows={rows} initialTotal={total} />;
}

// LogsView.tsx ('use client')
export function LogsView({ initialRows, initialTotal }: Props) {
  const [page, setPage] = useState(1);
  // 페이지 변경은 router.push('?page=2') 로 → Server 에서 다시 받기
  return <ResourceTable rows={rows} ... />;
}

이렇게 두면 검색 · 필터 상태는 URL 에 실리고 (공유 가능 · 새로고침 안전), 테이블 자체는 가벼운 클라이언트 컴포넌트가 됩니다.

7. 자주 걸리는 자리

페이지네이션 상태를 클라이언트만 관리 — URL 쿼리로 올리지 않으면 새로고침 · 공유 시 상태가 사라집니다. 관리자는 "그 페이지 URL 달라고" 요청하는 일이 잦으므로 URL-first 가 유리.

너무 많은 props — title · subtitle · icon · actions · filters · columns ... 로 10 개 이상이 되면 컴포넌트가 비대해집니다. columns 같은 핵심은 유지하고 headerSlot · toolbarSlot 같은 슬롯 props 로 외부가 확장하게 하는 편이 장기적으로 편합니다.

검색 debounce 분산 — 페이지마다 debounce 를 따로 구현하면 동작이 들쭉날쭉. ResourceTable 의 search.onChange 가 이미 debounced 값을 받도록 호출 측이 useDebounced 훅을 통해 정돈.

빈 상태 UX 가 척박 — 데이터 없음 메시지만 있으면 운영자가 "검색이 안 된 건지 진짜 없는 건지" 혼란. "필터 초기화" · "새로 추가" 같은 후속 액션을 빈 상태에 포함.

하고픈 말

공통 테이블 컴포넌트는 첫 3 ~ 5 개 페이지까지는 오히려 오버 엔지니어링처럼 느껴질 수 있습니다. 그러나 10 개를 넘으면 페이지 일관성 · 개발 속도 · 운영자 학습 비용 셋 모두에서 큰 차이가 납니다. 처음에는 간소하게 두고 (title · columns · rows · pagination 만) 필요가 생길 때마다 props 를 하나씩 더 하는 점진 성장이 가장 아프지 않습니다.

Next

  • nextjs-app-router
  • material3-tokens

WAI-ARIA Authoring Practices Guide — Table · shadcn/ui Data Table · TanStack Table 을 참고합니다.

frontend 카테고리의 다른 글

카테고리 전체 보기 →
  • 도메인 위젯의 통일성 — 4개 도메인에 3개 위젯만 두지 마라
  • 페이지 로딩 UX
  • 네이티브 통합 — OS 기능들
  • OCR · STT · TTS
  • SQLite — 로컬 앱의 단일 파일 DB
  • Tauri 모바일과 AdMob