3/6편 사이드바 “그룹 IA” 적용


페이지 템플릿 4종(테이블/로그/차트/시트)


0) 준비 상태 점검

  • Next.js(App Router) + Tailwind v4 + shadcn/ui + lucide-react + recharts 설치 완료
  • src/app/globals.css 에 Tailwind v4 스타일(@import "tailwindcss";) 들어있음
  • 탭 제목은 naver.how으로 설정되어 있음(시리즈 2/6)

1) 라우트 키 & 메뉴 그룹 정의 (한 곳에서 관리)

파일: src/config/ia.ts

// src/config/ia.ts
import type { LucideIcon } from "lucide-react";
import {
  Activity, Database, ListChecks, Shield, Users, FileText,
} from "lucide-react";

// 라우트 키(화면 식별자)
export 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"
  // 인텔리전스 DB
  | "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";

export interface MenuItem { key: RouteKey; label: string; icon: LucideIcon }
export interface MenuGroup { label: string; items: MenuItem[] }

// 사이드바 그룹(IA)
export 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 function findLabelByKey(key: RouteKey){
  for (const g of menuGroups) {
    const f = g.items.find(i => i.key === key);
    if (f) return f.label;
  }
  return undefined;
}

2) 그룹형 사이드바 컴포넌트

파일: src/components/layout/Sidebar.tsx

"use client";
import { menuGroups, type RouteKey } from "@/config/ia";

export default function Sidebar({
  active,
  onChange,
}: { active: RouteKey; onChange: (k: RouteKey)=>void }) {
  return (
    <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={() => onChange(m.key)}
              aria-current={active === m.key ? "page" : undefined}
              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>
      ))}
    </nav>
  );
}

접근성 팁: 현재 항목에 aria-current="page"를 달아 스크린리더가 인식할 수 있게 했습니다.


3) 페이지 템플릿 4종

파일: src/components/pages/templates.tsx

"use client";
import React, { useMemo, useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { CalendarDays, Download, Filter, Search } from "lucide-react";
import { ResponsiveContainer, LineChart, CartesianGrid, XAxis, YAxis, Tooltip, Line, BarChart, Bar } from "recharts";

// 1) 대시보드(차트 + KPI)
export function DashboardPage(){
  const kpis = [
    { label:"Active Users", value:"12,391", diff:+6.2 },
    { label:"New Signups", value:"1,204", diff:+3.1 },
    { label:"Error Rate", value:"0.18%", diff:-0.05 },
    { label:"Revenue", value:"₩48.2M", diff:+12.4 },
  ];
  const data = Array.from({length:12}, (_,i)=>({ m:`M${i+1}`, users: Math.round(800+Math.random()*600) }));

  return (
    <div className="space-y-6">
      <div className="grid grid-cols-2 gap-4 md:grid-cols-4">
        {kpis.map(k => (
          <Card key={k.label} className="dark:border-neutral-800 dark:bg-neutral-900">
            <CardHeader className="pb-2">
              <CardDescription>{k.label}</CardDescription>
              <CardTitle className="text-2xl">{k.value}</CardTitle>
            </CardHeader>
            <CardContent>
              <Badge variant={k.diff>=0?"default":"destructive"}>{k.diff>=0?"▲":"▼"}{Math.abs(k.diff)}%</Badge>
            </CardContent>
          </Card>
        ))}
      </div>

      <Card className="dark:border-neutral-800 dark:bg-neutral-900">
        <CardHeader>
          <CardTitle>User Growth</CardTitle>
          <CardDescription>Last 12 months</CardDescription>
        </CardHeader>
        <CardContent className="h-64">
          <ResponsiveContainer width="100%" height="100%">
            <LineChart data={data}>
              <CartesianGrid strokeDasharray="3 3" />
              <XAxis dataKey="m" /><YAxis /><Tooltip />
              <Line type="monotone" dataKey="users" strokeWidth={2} />
            </LineChart>
          </ResponsiveContainer>
        </CardContent>
      </Card>
    </div>
  );
}

// 2) 제너릭 테이블
export function GenericTablePage({ title }: { title: string }){
  const rows = Array.from({length:8}, (_,i)=>({ id: i+1, name:`Item ${i+1}`, status: i%3?"Active":"Pending" }));
  return (
    <div className="space-y-3">
      <div className="flex items-center justify-between">
        <h2 className="text-xl font-semibold">{title}</h2>
        <div className="flex items-center gap-2">
          <Button variant="outline"><Filter className="mr-2 h-4 w-4"/>Filter</Button>
          <Button><Download className="mr-2 h-4 w-4"/>Export</Button>
        </div>
      </div>

      <div className="overflow-hidden rounded-2xl border dark:border-neutral-800">
        <table className="w-full text-sm">
          <thead className="bg-gray-50 text-gray-600 dark:bg-neutral-900 dark:text-neutral-400">
            <tr>
              <th className="px-4 py-3 text-left">ID</th>
              <th className="px-4 py-3 text-left">Name</th>
              <th className="px-4 py-3 text-left">Status</th>
            </tr>
          </thead>
          <tbody>
            {rows.map((r,i)=> (
              <tr key={r.id} className={i%2?"bg-white/60 dark:bg-neutral-950/50":"bg-white dark:bg-neutral-950"}>
                <td className="px-4 py-3 font-mono">{r.id}</td>
                <td className="px-4 py-3">{r.name}</td>
                <td className="px-4 py-3">{r.status}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

// 3) 로그 테이블
export function LogsPage({ title }: { title: string }){
  const logs = Array.from({length:24}, (_,i)=>({
    id: i+1,
    at: new Date(Date.now()-i*3600_000).toISOString().replace('T',' ').slice(0,16),
    who: i%3?"system":"admin@naver.how",
    what: i%4?"Updated policy mapping":"Resolved mismatch",
  }));
  return (
    <div className="space-y-3">
      <h2 className="text-xl font-semibold">{title}</h2>
      <div className="overflow-hidden rounded-2xl border dark:border-neutral-800">
        <table className="w-full text-sm">
          <thead className="bg-gray-50 text-gray-600 dark:bg-neutral-900 dark:text-neutral-400">
            <tr>
              <th className="px-4 py-3 text-left">Time</th>
              <th className="px-4 py-3 text-left">Actor</th>
              <th className="px-4 py-3 text-left">Action</th>
            </tr>
          </thead>
          <tbody>
            {logs.map((l,i)=> (
              <tr key={l.id} className={i%2?"bg-white/60 dark:bg-neutral-950/50":"bg-white dark:bg-neutral-950"}>
                <td className="px-4 py-3 font-mono">{l.at}</td>
                <td className="px-4 py-3">{l.who}</td>
                <td className="px-4 py-3">{l.what}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

// 4) 시트(Sheet) 포함된 “판정 상이 관리” 예시
export function MismatchPage(){
  const [query, setQuery] = useState("");
  const rows = useMemo(()=>[
    { id:"mm-001", platform:"Olpemi", type:"SKU Mismatch", src:"ExtVendor A", detected:"2025-10-02", severity:"High", status:"Open" },
    { id:"mm-002", platform:"Olpemi", type:"Policy Drift", src:"Service B", detected:"2025-10-06", severity:"Medium", status:"Investigating" },
    { id:"mm-003", platform:"Olpemi", type:"Attribute Gap", src:"ExtVendor C", detected:"2025-10-08", severity:"Low", status:"Resolved" },
  ].filter(r => (r.id+r.type+r.platform+r.src).toLowerCase().includes(query.toLowerCase())), [query]);

  return (
    <div className="space-y-4">
      <div className="flex flex-wrap items-center justify-between gap-2">
        <h2 className="text-xl font-semibold">판정 상이 관리</h2>
        <div className="flex items-center gap-2">
          <div className="flex items-center gap-2 rounded-xl border px-3 py-1.5 dark:border-neutral-800">
            <Search className="h-4 w-4 opacity-60" />
            <input className="w-48 bg-transparent text-sm outline-none" placeholder="Search…" value={query} onChange={(e)=>setQuery(e.target.value)} />
          </div>
          <Select>
            <SelectTrigger className="w-[160px]"><SelectValue placeholder="Severity" /></SelectTrigger>
            <SelectContent>
              <SelectItem value="all">All</SelectItem>
              <SelectItem value="high">High</SelectItem>
              <SelectItem value="medium">Medium</SelectItem>
              <SelectItem value="low">Low</SelectItem>
            </SelectContent>
          </Select>
          <Button variant="outline"><CalendarDays className="mr-2 h-4 w-4"/>Range</Button>
        </div>
      </div>

      <div className="overflow-hidden rounded-2xl border dark:border-neutral-800">
        <table className="w-full text-sm">
          <thead className="bg-gray-50 text-gray-600 dark:bg-neutral-900 dark:text-neutral-400">
            <tr>
              <th className="px-4 py-3 text-left">ID</th>
              <th className="px-4 py-3 text-left">Platform</th>
              <th className="px-4 py-3 text-left">Type</th>
              <th className="px-4 py-3 text-left">Source</th>
              <th className="px-4 py-3 text-left">Detected</th>
              <th className="px-4 py-3 text-left">Severity</th>
              <th className="px-4 py-3 text-left">Status</th>
              <th className="px-4 py-3 text-right">Actions</th>
            </tr>
          </thead>
          <tbody>
            {rows.map((r,i)=> (
              <tr key={r.id} className={i%2?"bg-white/60 dark:bg-neutral-950/50":"bg-white dark:bg-neutral-950"}>
                <td className="px-4 py-3 font-mono">{r.id}</td>
                <td className="px-4 py-3">{r.platform}</td>
                <td className="px-4 py-3">{r.type}</td>
                <td className="px-4 py-3">{r.src}</td>
                <td className="px-4 py-3">{r.detected}</td>
                <td className="px-4 py-3">{pill(r.severity)}</td>
                <td className="px-4 py-3">{pill(r.status)}</td>
                <td className="px-4 py-3 text-right">
                  <Sheet>
                    <SheetTrigger asChild><Button size="sm">Resolve</Button></SheetTrigger>
                    <SheetContent className="w-[520px] sm:max-w-none">
                      <SheetHeader>
                        <SheetTitle>Resolve {r.id}</SheetTitle>
                        <SheetDescription>상태 업데이트 & 감사 메모 기록</SheetDescription>
                      </SheetHeader>
                      <div className="mt-4 space-y-4">
                        <div className="grid grid-cols-3 gap-2 text-sm">
                          <Label>Type</Label><div className="col-span-2">{r.type}</div>
                          <Label>Source</Label><div className="col-span-2">{r.src}</div>
                          <Label>Severity</Label><div className="col-span-2">{pill(r.severity)}</div>
                        </div>
                        <div>
                          <Label>Resolution</Label>
                          <Input placeholder="e.g. Mapped vendor SKU to master catalog" className="mt-1"/>
                        </div>
                        <div>
                          <Label>Assignee</Label>
                          <Input placeholder="Type an email or team" className="mt-1"/>
                        </div>
                        <div className="flex justify-end gap-2">
                          <Button variant="outline">Cancel</Button>
                          <Button>Save</Button>
                        </div>
                      </div>
                    </SheetContent>
                  </Sheet>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

function pill(s: string){
  const map: Record<string, string> = {
    High: "bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300",
    Medium: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300",
    Low: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
    Open: "bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300",
    Investigating: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300",
    Resolved: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300",
    Pending: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300",
    Active: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
  };
  return <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs ${map[s]||"bg-gray-100 text-gray-700 dark:bg-neutral-800 dark:text-neutral-300"}`}>{s}</span>;
}

4) 상단바 + 레이아웃(쉘) 구성

파일: src/components/layout/AdminShell.tsx

"use client";
import { useState } from "react";
import Sidebar from "./Sidebar";
import { type RouteKey, findLabelByKey } from "@/config/ia";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ChevronDown, Menu, Moon, Sun } from "lucide-react";
import { DashboardPage, GenericTablePage, LogsPage, MismatchPage } from "@/components/pages/templates";

export default function AdminShell(){
  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-[var(--brand)] [background:linear-gradient(135deg,var(--brand),var(--brand-2))]" />
                <span className="font-semibold tracking-tight">naver.how admin</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-[var(--brand-2)]" />
                    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"}`}>
            <Sidebar active={active} onChange={setActive} />
            <Card className="mt-4 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>
          </aside>

          {/* Content */}
          <main className="col-span-12 md:col-span-9 lg:col-span-10">
            {renderPage(active)}
          </main>
        </div>
      </div>
    </div>
  );
}

function renderPage(k: RouteKey){
  const label = findLabelByKey(k) || k;
  switch (k) {
    case "dashboard": return <DashboardPage/>;
    case "adj-mismatch": return <MismatchPage/>;
    case "ops-login-stats":
    case "ops-search-stats":
    case "analysis-risk-models":
    case "ingest-crawl-logs":
    case "audit-user-access":
    case "audit-admin-access":
    case "audit-user-auth":
    case "audit-admin-auth":
    case "audit-system-ops":
      return <LogsPage title={label} />;
    default:
      return <GenericTablePage title={label} />;
  }
}

5) 홈에서 레이아웃 렌더링

파일: src/app/page.tsx

import AdminShell from "@/components/layout/AdminShell";
export default function Page(){ return <AdminShell />; }

클라이언트 컴포넌트 파일들(Sidebar, AdminShell, templates)은 각각 맨 위에 "use client" 선언이 들어 있어야 합니다(위 예시 포함).


6) 실행 (포트 7401)

pnpm approve-builds           # 처음 한 번만
pnpm dev -- -H 0.0.0.0 -p 7401
# 또는
pnpm build && pnpm start -p 7401

브라우저에서 좌측 그룹형 사이드바와 본문 템플릿(대시보드/테이블/로그/시트)이 정상 동작하면 성공!


7) 문제 해결 체크리스트

  • 레이아웃이 깨짐(글자만 보임)src/app/globals.css 임포트, PostCSS 설정, pnpm approve-builds 확인
  • 아이콘 타입 에러any 대신 LucideIcon 타입 사용(위 ia.ts 참고)
  • recharts 렌더링 경고 → 해당 파일 맨 위에 "use client" 필수
  • 서브패스 프록시(nginx)와 정적파일 → 시리즈 5/6에서 Nginx 설정을 정리합니다

마무리

이제 그룹형 IA로 메뉴가 정리됐고, 클릭 시 바로 쓸 수 있는 4가지 페이지 템플릿이 붙었습니다. 다음 편에서는 데이터 연동(가짜 API → 실제 API 스위칭), 환경변수(.env)와 안전한 비밀 관리, 폼/검증, 목록 페이징/검색/정렬을 다룹니다.

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다