0) 시스템 준비
# 필수 도구
sudo apt update
sudo apt install -y git curl build-essential
# Node 설치 (nvm 권장)
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc
nvm install 22
node -v
# 패키지 매니저
corepack enable   # pnpm / yarn 사용 가능
1) Next.js 앱 만들기
# 프로젝트 생성
pnpm create next-app potatonet-admin \
  --ts --eslint --app --src-dir --tailwind --import-alias "@/*"
cd potatonet-admin
위 옵션으로 App Router, TypeScript, Tailwind 기본 구성까지 잡힙니다.
2) 의존성 설치
# UI/아이콘/차트
pnpm add lucide-react recharts
# shadcn/ui (컴포넌트 생성기)
npx shadcn@latest init -d
npx shadcn@latest add button card input badge label switch sheet dropdown-menu select
# Tailwind v4 플러그인(문서용)
pnpm add -D @tailwindcss/typography
3) Tailwind v4 설정 (PostCSS 방식)
postcss.config.mjs
export default {
  plugins: {
    "@tailwindcss/postcss": {},
    autoprefixer: {},
  },
}
src/app/globals.css
@import "tailwindcss";
@plugin "@tailwindcss/typography";
/* 선택: 라이트/다크 모두 지원 */
:root { color-scheme: light dark; }
src/app/layout.tsx — 글로벌 CSS + 탭 제목(브랜드)
export const metadata = {
  title: "potatonet",           // ← 크롬 탭에 표시될 제목
  description: "potatonet admin",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <body>{children}</body>
    </html>
  );
}
metadata.title만 바꿔도 “Create Next App”가 potatonet으로 바뀝니다.
4) 데모 컴포넌트 연결
컴포넌트 파일 생성
mkdir -p src/components
src/components/AdminThemeDemo.tsx
최소 버전(그룹 메뉴 + 다크토글이 있는 껍데기). 다음 편에서 실제 테이블/차트/시트까지 확장합니다.
"use client";
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { ChevronDown, Menu, Moon, Sun, Users, Activity, Shield, ListChecks, Database, FileText } from "lucide-react";
type RouteKey =
  | "dashboard"
  | "ingest-requests" | "ingest-crawl-logs" | "ingest-deepweb"
  | "assets-screenshots" | "assets-screenshot-hashes" | "assets-files"
  | "analysis-domain" | "analysis-file" | "analysis-ip" | "analysis-js" | "analysis-risk-models"
  | "adj-compare" | "adj-mismatch" | "adj-fp-suspects" | "adj-fp-final-edits" | "adj-fp-reports" | "adj-phishing"
  | "intel-url-db" | "intel-domain-db" | "intel-bkb-db"
  | "ops-users" | "ops-admins" | "ops-initial-passwords" | "ops-billing" | "ops-bookmarks" | "ops-search-history" | "ops-login-stats" | "ops-search-stats"
  | "audit-user-access" | "audit-admin-access" | "audit-user-auth" | "audit-admin-auth" | "audit-system-ops"
  | "settings";
interface MenuItem { key: RouteKey; label: string; icon: React.ComponentType<any> }
interface MenuGroup { label: string; items: MenuItem[] }
const menuGroups: MenuGroup[] = [
  { label: "개요", items: [{ key: "dashboard", label: "대시보드", icon: Activity }] },
  {
    label: "수집·크롤링",
    items: [
      { key: "ingest-requests", label: "웹 요청 관리", icon: Database },
      { key: "ingest-crawl-logs", label: "크롤링 로그 관리", icon: Database },
      { key: "ingest-deepweb", label: "딥웹 관리", icon: Database },
    ],
  },
  {
    label: "스크린샷 · 파일 자산",
    items: [
      { key: "assets-screenshots", label: "웹 스크린샷 관리", icon: FileText },
      { key: "assets-screenshot-hashes", label: "스크린샷 해시 관리", icon: FileText },
      { key: "assets-files", label: "파일 관리", icon: FileText },
    ],
  },
  {
    label: "분석 · 탐지",
    items: [
      { key: "analysis-domain", label: "도메인 분석 관리", icon: ListChecks },
      { key: "analysis-file", label: "파일 분석 관리", icon: ListChecks },
      { key: "analysis-ip", label: "IP 분석 관리", icon: ListChecks },
      { key: "analysis-js", label: "JS 분석 관리", icon: ListChecks },
      { key: "analysis-risk-models", label: "위험도 모델 관리", icon: ListChecks },
    ],
  },
  {
    label: "판정 · 검수",
    items: [
      { key: "adj-compare", label: "판정 결과 비교", icon: Database },
      { key: "adj-mismatch", label: "판정 상이 관리", icon: Database },
      { key: "adj-fp-suspects", label: "오탐 의심 관리", icon: Database },
      { key: "adj-fp-final-edits", label: "오탐(Final) 수정 기록", icon: Database },
      { key: "adj-fp-reports", label: "오탐 신고 관리", icon: Database },
      { key: "adj-phishing", label: "피싱 관리", icon: Database },
    ],
  },
  {
    label: "인텔리전스 DB",
    items: [
      { key: "intel-url-db", label: "URL DB 관리", icon: Database },
      { key: "intel-domain-db", label: "도메인 DB 관리", icon: Database },
      { key: "intel-bkb-db", label: "BKB DB 관리", icon: Database },
    ],
  },
  {
    label: "서비스 운영",
    items: [
      { key: "ops-users", label: "사용자 관리", icon: Users },
      { key: "ops-admins", label: "관리자 관리", icon: Users },
      { key: "ops-initial-passwords", label: "초기 비밀번호 관리", icon: Users },
      { key: "ops-billing", label: "과금 관리", icon: Users },
      { key: "ops-bookmarks", label: "북마크 관리", icon: Users },
      { key: "ops-search-history", label: "검색 기록 관리", icon: Users },
      { key: "ops-login-stats", label: "로그인 통계", icon: Users },
      { key: "ops-search-stats", label: "사용자 검색 기록 통계", icon: Users },
    ],
  },
  {
    label: "접근 기록 · 감사",
    items: [
      { key: "audit-user-access", label: "사용자 접근이력 로그", icon: Shield },
      { key: "audit-admin-access", label: "관리자 접근이력 로그", icon: Shield },
      { key: "audit-user-auth", label: "사용자 로그인/로그아웃 로그", icon: Shield },
      { key: "audit-admin-auth", label: "관리자 로그인/로그아웃 로그", icon: Shield },
      { key: "audit-system-ops", label: "시스템 운영 로그", icon: Shield },
    ],
  },
];
export default function AdminThemeDemo() {
  const [dark, setDark] = useState(true);
  const [collapsed, setCollapsed] = useState(false);
  const [active, setActive] = useState<RouteKey>("dashboard");
  return (
    <div className={dark ? "dark" : ""}>
      <div className="min-h-screen bg-gray-50 text-gray-900 transition-colors dark:bg-neutral-950 dark:text-neutral-100">
        {/* Top bar */}
        <header className="sticky top-0 z-40 border-b bg-white/70 backdrop-blur supports-[backdrop-filter]:bg-white/60 dark:border-neutral-800 dark:bg-neutral-950/60">
          <div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-3">
            <div className="flex items-center gap-2">
              <Button variant="ghost" size="icon" className="md:hidden" onClick={() => setCollapsed((c) => !c)}>
                <Menu className="h-5 w-5" />
              </Button>
              <div className="flex items-center gap-2">
                <div className="h-7 w-7 rounded-xl bg-gradient-to-br from-indigo-500 to-fuchsia-500" />
                <span className="font-semibold tracking-tight">Admin Demo</span>
              </div>
              <Badge variant="secondary" className="ml-2 hidden md:inline-flex">Prototype</Badge>
            </div>
            <div className="flex items-center gap-2">
              <Button variant="ghost" size="icon" onClick={() => setDark((d) => !d)}>
                {dark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
              </Button>
              <DropdownMenu>
                <DropdownMenuTrigger asChild>
                  <Button variant="outline" className="gap-2 dark:border-neutral-800">
                    <div className="h-6 w-6 rounded-full bg-gradient-to-br from-sky-500 to-violet-500" />
                    Admin <ChevronDown className="h-4 w-4 opacity-70" />
                  </Button>
                </DropdownMenuTrigger>
                <DropdownMenuContent align="end" className="w-56">
                  <DropdownMenuLabel>My Account</DropdownMenuLabel>
                  <DropdownMenuSeparator />
                  <DropdownMenuItem>Profile</DropdownMenuItem>
                  <DropdownMenuItem>Billing</DropdownMenuItem>
                  <DropdownMenuSeparator />
                  <DropdownMenuItem className="text-red-600">Sign out</DropdownMenuItem>
                </DropdownMenuContent>
              </DropdownMenu>
            </div>
          </div>
        </header>
        {/* Layout */}
        <div className="mx-auto grid max-w-7xl grid-cols-12 gap-4 px-4 py-6">
          {/* Sidebar */}
          <aside className={`col-span-12 md:col-span-3 lg:col-span-2 ${collapsed ? "hidden md:block" : "block"}`}>
            <nav className="sticky top-[60px] space-y-4 rounded-2xl border bg-white p-3 shadow-sm dark:border-neutral-800 dark:bg-neutral-900">
              {menuGroups.map((g) => (
                <div key={g.label} className="space-y-1">
                  <div className="px-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-neutral-400">{g.label}</div>
                  {g.items.map((m) => (
                    <button
                      key={m.key}
                      onClick={() => setActive(m.key)}
                      className={`flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm transition hover:bg-gray-100 dark:hover:bg-neutral-800 ${active===m.key?"bg-gray-100 font-medium dark:bg-neutral-800":""}`}
                    >
                      <m.icon className="h-4 w-4" />
                      <span>{m.label}</span>
                    </button>
                  ))}
                </div>
              ))}
              <Card className="dark:border-neutral-800 dark:bg-neutral-900">
                <CardHeader>
                  <CardTitle className="text-base">Quick Links</CardTitle>
                  <CardDescription>Common admin actions</CardDescription>
                </CardHeader>
                <CardContent className="flex flex-col gap-2">
                  <Button variant="secondary" className="justify-start">Create user</Button>
                  <Button variant="secondary" className="justify-start">New report</Button>
                  <Button variant="secondary" className="justify-start">Export CSV</Button>
                </CardContent>
              </Card>
            </nav>
          </aside>
          {/* Content */}
          <main className="col-span-12 md:col-span-9 lg:col-span-10">
            <Placeholder />
          </main>
        </div>
      </div>
    </div>
  );
}
function Placeholder(){
  return (
    <div className="space-y-4">
      <Card className="dark:border-neutral-800 dark:bg-neutral-900">
        <CardHeader>
          <CardTitle>시작하기</CardTitle>
          <CardDescription>다음 편에서 테이블/차트/시트까지 붙입니다.</CardDescription>
        </CardHeader>
        <CardContent className="prose dark:prose-invert">
          <p>좌측 그룹형 사이드바는 즉시 동작합니다. 본문은 플레이스홀더입니다.</p>
        </CardContent>
      </Card>
    </div>
  );
}
홈 페이지에 연결
src/app/page.tsx
import AdminThemeDemo from "@/components/AdminThemeDemo";
export default function Page(){ return <AdminThemeDemo />; }
5) 실행 (포트 7401)
pnpm approve-builds          # Tailwind v4(oxide) 빌드 승인 — 처음 한 번
pnpm dev -- -H 0.0.0.0 -p 7401
# 브라우저: http://서버IP:7401  (개발용)
프로덕션은
pnpm build && pnpm start -p 7401(배포/Nginx는 시리즈 5편에서!)
6) 자주 막히는 포인트
- 스타일이 하나도 안 붙음 → 
globals.css임포트 누락, PostCSS 설정 확인,pnpm approve-builds미실행 - Create Next App 제목이 안 바뀜 → 
layout.tsx의metadata.title확인 - 컴포넌트가 안 뜸 → 
page.tsx가 우리 컴포넌트 렌더링 중인지 확인 
여기까지가 “환경 + 기본 골격”입니다.
다음 편(2/6)에서는 테마 교체(다크/브랜드 컬러) + shadcn/ui 구성 + 타이포그래피 + 접근성 팁까지 실전으로 붙입니다.