[카테고리:] 미분류

  • 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)와 안전한 비밀 관리, 폼/검증, 목록 페이징/검색/정렬을 다룹니다.