[카테고리:] 미분류

  • 6/6 로딩/토스트 UX, 에러 핸들링·로깅, 인증/권한, 코드 스플리팅, 이미지 최적화, 배포 체크리스트

    상태 가정

    • 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.tsimages.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 체크리스트로 운영 안정성 확보.