상태 가정
- Next.js(App Router) + Tailwind v4 + shadcn/ui + lucide-react + recharts
 - 탭 제목: naverhow
 - 포트: 7401, Nginx 서브패스: /admin-demo (Nginx가 접두사를 제거해 백엔드로 전달)
 
0) 필수/선택 패키지
# 토스트(shadcn)
npx shadcn@latest add toast
# (선택) 에러 모니터링: Sentry
# pnpm add @sentry/nextjs
# npx sentry-wizard@latest -i nextjs  # 자동 설정 (원치 않으면 아래 "가벼운 로거"만)
1) 전역 로딩 UX — App Router의 loading.tsx + 스켈레톤
파일: src/app/loading.tsx
export default function GlobalLoading() {
  return (
    <div className="min-h-[40vh] grid place-items-center">
      <div className="animate-pulse space-y-3 text-center">
        <div className="mx-auto h-10 w-10 rounded-full bg-gray-200 dark:bg-neutral-800" />
        <p className="text-sm opacity-70">Loading naverhow admin…</p>
      </div>
    </div>
  );
}
이 파일만 있으면, 라우트 전환/서버 컴포넌트 로딩 시 자동 표시됩니다.
2) 전역 토스트 — shadcn Toaster
파일: src/app/layout.tsx (하단에 Toaster 추가)
import { Toaster } from "@/components/ui/toaster";
export const metadata = { title: "naverhow", description: "naverhow admin" };
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <body>
        {children}
        <Toaster /> {/* 전역 토스트 포털 */}
      </body>
    </html>
  );
}
사용 예:
"use client";
import { useToast } from "@/components/ui/use-toast";
export function DemoToast(){
  const { toast } = useToast();
  return (
    <button
      onClick={()=> toast({ title: "Saved", description: "변경 사항이 저장되었습니다." })}
      className="rounded-lg bg-black/80 px-3 py-2 text-white"
    >Save</button>
  );
}
3) 에러 페이지/바운더리 + 서버 로거(API)
3.1 전역 에러 UI
파일: src/app/error.tsx
"use client";
import { useEffect } from "react";
export default function GlobalError({
  error, reset,
}: { error: Error & { digest?: string }; reset: () => void }) {
  useEffect(() => {
    // 서버로 에러 리포팅(간단 로거)
    fetch("/api/logs", {
      method: "POST",
      headers: { "content-type":"application/json" },
      body: JSON.stringify({ type:"client-error", message: error.message, digest: error.digest }),
    }).catch(()=>{});
  }, [error]);
  return (
    <html>
      <body className="min-h-screen grid place-items-center p-6">
        <div className="max-w-md space-y-3 text-center">
          <h1 className="text-2xl font-semibold">문제가 발생했습니다</h1>
          <p className="text-sm opacity-70">{error.message}</p>
          <button
            onClick={() => reset()}
            className="rounded-xl bg-black px-4 py-2 text-white dark:bg-white dark:text-black"
          >
            다시 시도
          </button>
        </div>
      </body>
    </html>
  );
}
특정 섹션만 분리하고 싶으면 해당 경로에
error.tsx를 추가로 두면 됩니다.
3.2 서버 로거(API) — PM2 로그에 기록
파일: src/app/api/logs/route.ts
import { NextResponse } from "next/server";
/** 매우 가벼운 서버 로거.
 *  PM2나 클라우드 로그(CloudWatch, Stackdriver)로 stdout/stderr가 수집됩니다.
 */
export async function POST(req: Request) {
  const body = await req.json().catch(()=> ({}));
  const line = `[LOG:${new Date().toISOString()}] ${JSON.stringify(body)}`;
  if (body?.type === "client-error") {
    console.error(line);
  } else {
    console.log(line);
  }
  return NextResponse.json({ ok: true });
}
(선택) Sentry를 쓰면 더 강력합니다. DSN만
.env에 넣고@sentry/nextjs초기화 코드를 추가하세요.
4) 인증 & 권한(RBAC) — 쿠키 기반 미들웨어
외부 URL은
/admin-demo/*지만, Nginx가 접두사를 제거하므로 Next 입장에선 루트(/) 라우팅입니다.
4.1 로그인 페이지
파일: src/app/login/page.tsx
"use client";
import { useState } from "react";
export default function LoginPage(){
  const [pw, setPw] = useState("");
  const [role, setRole] = useState<"Admin"|"Editor"|"Viewer">("Admin");
  const [err, setErr] = useState("");
  async function submit(e: React.FormEvent){
    e.preventDefault();
    setErr("");
    const r = await fetch("/api/login", {
      method:"POST",
      headers:{ "content-type":"application/json" },
      body: JSON.stringify({ password: pw, role }),
    });
    if (!r.ok) {
      setErr("로그인 실패");
      return;
    }
    // 외부에선 /admin-demo/ 로 노출되지만, 내부 라우팅은 / 입니다.
    location.href = "/";
  }
  return (
    <div className="min-h-screen grid place-items-center p-6">
      <form onSubmit={submit} className="w-full max-w-sm space-y-3 rounded-2xl border p-6">
        <h1 className="text-xl font-semibold">naverhow admin 로그인</h1>
        <input
          type="password" value={pw} onChange={(e)=>setPw(e.target.value)}
          placeholder="비밀번호" className="w-full rounded-xl border px-3 py-2"
        />
        <select value={role} onChange={(e)=>setRole(e.target.value as any)}
          className="w-full rounded-xl border px-3 py-2"
        >
          <option>Admin</option>
          <option>Editor</option>
          <option>Viewer</option>
        </select>
        {err && <p className="text-sm text-rose-600">{err}</p>}
        <button className="w-full rounded-xl bg-black px-3 py-2 text-white">로그인</button>
      </form>
    </div>
  );
}
4.2 로그인/로그아웃 API — 쿠키 설정
파일: src/app/api/login/route.ts
import { NextResponse } from "next/server";
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "naverhow"; // demo
export async function POST(req: Request) {
  const { password, role } = await req.json().catch(()=> ({}));
  if (password !== ADMIN_PASSWORD) {
    return NextResponse.json({ ok:false }, { status: 401 });
  }
  const res = NextResponse.json({ ok:true });
  // 1시간 세션 (HttpOnly)
  res.cookies.set("naverhow_auth", "ok", { httpOnly: true, maxAge: 3600, sameSite: "lax", secure: true });
  res.cookies.set("naverhow_role", role || "Admin", { httpOnly: true, maxAge: 3600, sameSite: "lax", secure: true });
  return res;
}
파일: src/app/api/logout/route.ts
import { NextResponse } from "next/server";
export async function GET() {
  const res = NextResponse.json({ ok:true });
  res.cookies.set("naverhow_auth", "", { httpOnly:true, maxAge: 0 });
  res.cookies.set("naverhow_role", "", { httpOnly:true, maxAge: 0 });
  return res;
}
4.3 보호 미들웨어 — 미인증 시 /login 리다이렉트
파일: middleware.ts
import { NextResponse, type NextRequest } from "next/server";
// 보호 제외 경로
const PUBLIC_PATHS = [
  "/login",
  "/api/login",
  "/api/logs",
  "/_next", // 정적 자산
  "/icon",  // 파비콘
  "/favicon.ico",
  "/robots.txt",
  "/sitemap.xml",
];
export function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;
  const isPublic = PUBLIC_PATHS.some(p => pathname.startsWith(p));
  if (isPublic) return NextResponse.next();
  const auth = req.cookies.get("naverhow_auth")?.value;
  if (!auth) {
    const url = req.nextUrl.clone();
    url.pathname = "/login";    // 내부 라우팅 기준. 외부에선 /admin-demo/login 으로 보임
    url.search = "";
    return NextResponse.redirect(url);
  }
  return NextResponse.next();
}
export const config = {
  matcher: ["/((?!api/health).*)"], // 필요시 조정
};
4.4 페이지 단위 권한(RBAC) — 간단 HOC/가드
파일: src/components/auth/RequireRole.tsx
"use client";
import { useEffect, useState } from "react";
export default function RequireRole({
  role, children
}: { role: "Admin"|"Editor"|"Viewer"; children: React.ReactNode }) {
  const [ok,setOk] = useState<boolean | null>(null);
  useEffect(()=> {
    // 서버 쿠키는 클라이언트에서 직접 못 읽으므로, 헤더/엔드포인트로 제공하거나
    // 간단히 API 한 번 호출해서 권한 체크(여기선 데모로 /api/users 호출 시 200이면 통과라고 가정)
    fetch("/api/users", { method: "GET" }).then(r => setOk(r.ok)).catch(()=> setOk(false));
  }, []);
  if (ok === null) return <div className="p-6 text-sm opacity-70">Checking permission…</div>;
  if (!ok) return <div className="p-6 text-sm text-rose-600">권한이 없습니다</div>;
  return <>{children}</>;
}
실제 RBAC는 서버에서 쿠키의
naverhow_role을 읽어 API 레벨에서 권한을 검사해야 안전합니다(엔드포인트마다 role 체크).
5) 코드 스플리팅 & SSR 제어 — 동적 import로 체감속도 개선
5.1 무거운 차트는 클라이언트 전용 동적 import
파일: src/components/charts/UserGrowth.tsx
"use client";
import { ResponsiveContainer, LineChart, CartesianGrid, XAxis, YAxis, Tooltip, Line } from "recharts";
export default function UserGrowth({ data }: { data: any[] }) {
  return (
    <div 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>
    </div>
  );
}
사용처 예: (대시보드에서)
import dynamic from "next/dynamic";
const UserGrowth = dynamic(()=> import("@/components/charts/UserGrowth"), {
  ssr: false,
  loading: () => <div className="h-64 animate-pulse rounded-2xl bg-gray-100 dark:bg-neutral-900" />,
});
// ...
<UserGrowth data={chartData} />
5.2 페이지 자체를 동적 로드(초기 번들 축소)
import dynamic from "next/dynamic";
const UsersPage = dynamic(()=> import("@/components/pages/users"), {
  loading: () => <div className="p-6 text-sm opacity-70">Loading users…</div>,
});
// renderPage 스위치에서 <UsersPage/> 사용
6) 이미지 최적화 — next/image 교체
import Image from "next/image";
export function BrandMark(){
  return (
    <Image
      src="/naverhow_logo.png"
      alt="naverhow"
      width={120}
      height={32}
      priority
      className="h-8 w-auto"
    />
  );
}
외부 URL 이미지는
next.config.ts의images.remotePatterns허용이 필요합니다.
7) 배포 체크리스트 (요약)
- 환경변수: 
.env(예:ADMIN_PASSWORD,REAL_API_BASE, 선택:SENTRY_DSN) - 빌드 승인: 
pnpm approve-builds(Tailwind v4/oxide 등) - 빌드/실행: 
pnpm build && pnpm start -p 7401 - Nginx:
/admin-demo/→proxy_pass http://127.0.0.1:7401/;(슬래시 있음: 접두사 제거)/_next/→proxy_pass http://127.0.0.1:7401/_next/;(정적 자산)
 - PM2: 
pm2 start "pnpm start -p 7401" --name naverhow-admin && pm2 save && pm2 startup - 보안 헤더: X-Frame-Options, X-Content-Type-Options, Referrer-Policy 등
 - 로그 확인: 
pm2 logs naverhow-admin(API/api/logs레코드 포함) - 무한 리다이렉트: 
/admin-demo/블록의proxy_pass슬래시 유무 확인(있어야 함) - 스타일 미적용: 
/_next/프록시 누락 여부 확인 
8) 선택: Sentry 연동(요약)
pnpm add @sentry/nextjs
# npx sentry-wizard@latest -i nextjs
.env:
SENTRY_DSN=https://<YOUR_DSN>
그 다음 도큐 생성대로 sentry.client.config.ts / sentry.server.config.ts 가 들어가고, 에러는 자동 수집됩니다. (없으면 이 단계 생략)
끝! 🎉
이제 naverhow 관리자 데모는:
- 전역 로딩/토스트로 UX 좋아지고,
 - 에러는 페이지/로그로 가시화,
 - 로그인/권한으로 보호,
 - 동적 import로 초기 속도 개선,
 - 이미지 최적화까지 완료,
 - Nginx/PM2 체크리스트로 운영 안정성 확보.
 
답글 남기기