관리자 UI — ResourceTable SSOT 패턴
관리자 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 을 참고합니다.