Life Logs && *Timeline

  • 시리즈 4/6 — 데이터 연동 & 폼


    “가짜 API → 진짜 API 스위치”, .env 안전하게, 목록 페이징/검색/정렬, zod + react-hook-form 검증

    대상: 웹 프론트엔드 처음 하는 분
    목표: /api/users 로컬 API를 만들고(가짜 데이터), .envREAL_API_BASE가 있으면 실서버로 자동 프록시하도록 구성.
    프론트에서는 검색/정렬/페이징이 되는 사용자 목록 + **신규 사용자 추가 폼(검증)**까지.


    0) 패키지 설치

    pnpm add zod react-hook-form
    

    1) 환경변수(.env) — 안전한 분리

    파일: .env.local (개발용, Git에 커밋하지 않음)

    # 실 API가 있다면 넣어두세요. 없으면 비워두면 됩니다.
    REAL_API_BASE=https://your-real-api.example.com
    

    보안 팁

    • 클라이언트에서 보일 값은 NEXT_PUBLIC_* 접두사를 씁니다.
    • 비밀키/내부 엔드포인트는 절대 NEXT_PUBLIC_로 노출하지 마세요. 서버 라우트(/api/*)에서 process.env.*로만 사용합니다.

    2) 타입 & 스키마 정의

    파일: src/lib/types.ts

    import { z } from "zod";
    
    export const UserRole = z.enum(["Admin", "Editor", "Viewer"]);
    export type UserRole = z.infer<typeof UserRole>;
    
    export const User = z.object({
      id: z.string(),
      name: z.string().min(2),
      email: z.string().email(),
      role: UserRole,
      status: z.enum(["Active", "Suspended"]).default("Active"),
    });
    export type User = z.infer<typeof User>;
    
    export const CreateUserInput = User.pick({ name: true, email: true, role: true });
    export type CreateUserInput = z.infer<typeof CreateUserInput>;
    
    export const ListQuery = z.object({
      page: z.coerce.number().min(1).default(1),
      pageSize: z.coerce.number().min(1).max(100).default(10),
      q: z.string().optional(),
      sort: z.enum(["name_asc","name_desc","email_asc","email_desc"]).default("name_asc"),
    });
    export type ListQuery = z.infer<typeof ListQuery>;
    
    export type ListResponse<T> = {
      items: T[];
      total: number;
      page: number;
      pageSize: number;
    };
    

    3) API 라우트 — 가짜 데이터 or 실서버 프록시

    파일: src/app/api/users/route.ts

    import { NextResponse } from "next/server";
    import { CreateUserInput, ListQuery, type ListResponse, type User } from "@/lib/types";
    
    /** 선택 1) REAL_API_BASE가 있으면 외부 API로 프록시
     *  선택 2) 없으면 MOCK 데이터로 목록/생성 동작
     */
    
    const REAL_API_BASE = process.env.REAL_API_BASE;
    
    // --- 간단한 인메모리 mock DB (개발용) ---
    let MOCK: User[] = [
      { id:"u-1001", name:"Jin Park",  email:"jin@hajunho.io",  role:"Admin",  status:"Active" },
      { id:"u-1002", name:"Yuna Kim",  email:"yuna@hajunho.io", role:"Editor", status:"Active" },
      { id:"u-1003", name:"Alex Han",  email:"alex@hajunho.io", role:"Viewer", status:"Suspended" },
      { id:"u-1004", name:"Mina Seo",  email:"mina@hajunho.io", role:"Editor", status:"Active" },
      { id:"u-1005", name:"Ryan Lee",  email:"ryan@hajunho.io", role:"Viewer", status:"Active" },
    ];
    
    // 유틸: 정렬
    function sortUsers(arr: User[], sort: string) {
      const [key,dir] = sort.split("_");
      return [...arr].sort((a:any,b:any)=>{
        const ka = a[key as keyof User]?.toString().toLowerCase() ?? "";
        const kb = b[key as keyof User]?.toString().toLowerCase() ?? "";
        if (ka < kb) return dir==="asc" ? -1 : 1;
        if (ka > kb) return dir==="asc" ? 1 : -1;
        return 0;
      });
    }
    
    // GET /api/users?page=1&pageSize=10&q=...&sort=name_asc
    export async function GET(req: Request) {
      const { searchParams } = new URL(req.url);
      const parse = ListQuery.safeParse(Object.fromEntries(searchParams));
      if (!parse.success) {
        return NextResponse.json({ error: parse.error.flatten() }, { status: 400 });
      }
      const { page, pageSize, q, sort } = parse.data;
    
      // 실서버 프록시
      if (REAL_API_BASE) {
        const u = new URL("/users", REAL_API_BASE);
        u.searchParams.set("page", String(page));
        u.searchParams.set("pageSize", String(pageSize));
        if (q) u.searchParams.set("q", q);
        if (sort) u.searchParams.set("sort", sort);
    
        const r = await fetch(u, { headers: { "accept":"application/json" } });
        const data = await r.json();
        return NextResponse.json(data);
      }
    
      // MOCK 동작
      let rows = [...MOCK];
      if (q) {
        const qq = q.toLowerCase();
        rows = rows.filter(u => (u.name+u.email+u.role).toLowerCase().includes(qq));
      }
      rows = sortUsers(rows, sort);
    
      const total = rows.length;
      const start = (page-1)*pageSize;
      const items = rows.slice(start, start+pageSize);
      const resp: ListResponse<User> = { items, total, page, pageSize };
      return NextResponse.json(resp);
    }
    
    // POST /api/users
    export async function POST(req: Request) {
      const body = await req.json();
      const parse = CreateUserInput.safeParse(body);
      if (!parse.success) {
        return NextResponse.json({ error: parse.error.flatten() }, { status: 400 });
      }
    
      // 실서버 프록시
      if (REAL_API_BASE) {
        const r = await fetch(new URL("/users", REAL_API_BASE), {
          method: "POST",
          headers: { "content-type":"application/json" },
          body: JSON.stringify(parse.data),
        });
        const data = await r.json();
        return NextResponse.json(data, { status: r.ok ? 200 : r.status });
      }
    
      // MOCK 생성
      const id = `u-${Date.now()}`;
      const newUser: User = { id, status:"Active", ...parse.data };
      MOCK.unshift(newUser);
      return NextResponse.json(newUser);
    }
    

    4) 사용자 목록 페이지 — 검색/정렬/페이징 + 신규 추가 폼

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

    "use client";
    import { useEffect, useMemo, useState } from "react";
    import { useForm } from "react-hook-form";
    import { z } from "zod";
    import { zodResolver } from "@hookform/resolvers/zod";
    import type { ListResponse, User } from "@/lib/types";
    import { Button } from "@/components/ui/button";
    import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
    import { Input } from "@/components/ui/input";
    import { Label } from "@/components/ui/label";
    import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
    import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
    import { Badge } from "@/components/ui/badge";
    import { CalendarDays, Download, Filter, Search } from "lucide-react";
    
    const createUserSchema = z.object({
      name: z.string().min(2, "이름은 2자 이상"),
      email: z.string().email("이메일 형식이 아닙니다"),
      role: z.enum(["Admin","Editor","Viewer"]),
    });
    type CreateUserForm = z.infer<typeof createUserSchema>;
    
    export default function UsersPage(){
      // filters
      const [q,setQ] = useState("");
      const [sort,setSort] = useState<"name_asc"|"name_desc"|"email_asc"|"email_desc">("name_asc");
      const [page,setPage] = useState(1);
      const [pageSize,setPageSize] = useState(10);
    
      // data
      const [resp,setResp] = useState<ListResponse<User> | null>(null);
      const [loading,setLoading] = useState(false);
    
      // fetch
      useEffect(()=>{
        const ctrl = new AbortController();
        setLoading(true);
        const u = new URL("/api/users", location.origin);
        u.searchParams.set("page", String(page));
        u.searchParams.set("pageSize", String(pageSize));
        if (q) u.searchParams.set("q", q);
        u.searchParams.set("sort", sort);
    
        fetch(u, { signal: ctrl.signal })
          .then(r => r.json())
          .then(d => setResp(d))
          .finally(()=> setLoading(false));
    
        return () => ctrl.abort();
      }, [q, sort, page, pageSize]);
    
      const totalPages = useMemo(()=> resp ? Math.max(1, Math.ceil(resp.total/resp.pageSize)) : 1, [resp]);
    
      // form
      const form = useForm<CreateUserForm>({ resolver: zodResolver(createUserSchema), defaultValues:{ name:"", email:"", role:"Viewer" }});
      const onSubmit = async (values: CreateUserForm) => {
        const r = await fetch("/api/users", { method:"POST", headers:{ "content-type":"application/json" }, body: JSON.stringify(values) });
        if (!r.ok) {
          const e = await r.json().catch(()=>({}));
          alert("생성 실패: " + (e?.error ? JSON.stringify(e.error) : r.status));
          return;
        }
        form.reset({ name:"", email:"", role:"Viewer" });
        // 리로드
        setPage(1);
        setQ("");
        setSort("name_asc");
      };
    
      return (
        <div className="space-y-4">
          {/* 헤더 + 툴바 */}
          <div className="flex flex-wrap items-center justify-between gap-2">
            <div className="flex items-center gap-2">
              <h2 className="text-xl font-semibold">사용자 관리</h2>
              <Badge variant="secondary">{resp?.total ?? 0}</Badge>
            </div>
            <div className="flex flex-wrap 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-52 bg-transparent text-sm outline-none" placeholder="Search name/email/role…" value={q} onChange={(e)=>{ setPage(1); setQ(e.target.value); }} />
              </div>
    
              <Select value={sort} onValueChange={(v:any)=> setSort(v)}>
                <SelectTrigger className="w-[160px]"><SelectValue placeholder="Sort by" /></SelectTrigger>
                <SelectContent>
                  <SelectItem value="name_asc">Name ↑</SelectItem>
                  <SelectItem value="name_desc">Name ↓</SelectItem>
                  <SelectItem value="email_asc">Email ↑</SelectItem>
                  <SelectItem value="email_desc">Email ↓</SelectItem>
                </SelectContent>
              </Select>
    
              <Select value={String(pageSize)} onValueChange={(v)=>{ setPage(1); setPageSize(Number(v)); }}>
                <SelectTrigger className="w-[110px]"><SelectValue placeholder="Page size" /></SelectTrigger>
                <SelectContent>
                  <SelectItem value="5">5 / page</SelectItem>
                  <SelectItem value="10">10 / page</SelectItem>
                  <SelectItem value="20">20 / page</SelectItem>
                </SelectContent>
              </Select>
    
              <Button variant="outline"><Filter className="mr-2 h-4 w-4"/>Filter</Button>
    
              <Sheet>
                <SheetTrigger asChild><Button>+ New user</Button></SheetTrigger>
                <SheetContent className="w-[480px] sm:max-w-none">
                  <SheetHeader>
                    <SheetTitle>새 사용자</SheetTitle>
                    <SheetDescription>필수 정보를 입력하고 생성하세요.</SheetDescription>
                  </SheetHeader>
                  <form className="mt-4 space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
                    <div>
                      <Label>이름</Label>
                      <Input {...form.register("name")} placeholder="홍길동" className="mt-1" />
                      <p className="mt-1 text-xs text-rose-500">{form.formState.errors.name?.message}</p>
                    </div>
                    <div>
                      <Label>이메일</Label>
                      <Input {...form.register("email")} placeholder="user@hajunho.io" className="mt-1" />
                      <p className="mt-1 text-xs text-rose-500">{form.formState.errors.email?.message}</p>
                    </div>
                    <div>
                      <Label>역할(Role)</Label>
                      <select {...form.register("role")} className="mt-1 w-full rounded-md border bg-transparent px-3 py-2 text-sm dark:border-neutral-800">
                        <option value="Admin">Admin</option>
                        <option value="Editor">Editor</option>
                        <option value="Viewer">Viewer</option>
                      </select>
                      <p className="mt-1 text-xs text-rose-500">{form.formState.errors.role?.message}</p>
                    </div>
                    <div className="flex justify-end gap-2">
                      <Button type="button" variant="outline" onClick={()=>form.reset()}>Reset</Button>
                      <Button type="submit">Create</Button>
                    </div>
                  </form>
                </SheetContent>
              </Sheet>
            </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">Email</th>
                  <th className="px-4 py-3 text-left">Role</th>
                  <th className="px-4 py-3 text-left">Status</th>
                  <th className="px-4 py-3 text-right">Actions</th>
                </tr>
              </thead>
              <tbody>
                {(resp?.items ?? []).map((u,i)=> (
                  <tr key={u.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">{u.id}</td>
                    <td className="px-4 py-3">{u.name}</td>
                    <td className="px-4 py-3">{u.email}</td>
                    <td className="px-4 py-3">{u.role}</td>
                    <td className="px-4 py-3">{u.status}</td>
                    <td className="px-4 py-3 text-right">
                      <Button size="sm" variant="secondary">Edit</Button>
                    </td>
                  </tr>
                ))}
                {loading && (
                  <tr><td className="px-4 py-6 text-center text-sm opacity-70" colSpan={6}>Loading…</td></tr>
                )}
              </tbody>
            </table>
          </div>
    
          {/* 페이지네이션 */}
          <div className="flex items-center justify-between">
            <div className="text-sm opacity-70">
              총 {resp?.total ?? 0}건 / {resp?.page ?? page} / {totalPages}페이지
            </div>
            <div className="flex gap-2">
              <Button variant="outline" disabled={page<=1} onClick={()=>setPage(p=>Math.max(1,p-1))}>Prev</Button>
              <Button variant="outline" disabled={page>=totalPages} onClick={()=>setPage(p=>p+1)}>Next</Button>
            </div>
          </div>
        </div>
      );
    }
    

    5) AdminShell에 UsersPage 연결

    파일 수정: src/components/layout/AdminShell.tsx
    맨 위 import에 추가:

    import UsersPage from "@/components/pages/users";
    

    renderPage 스위치에 한 줄 추가:

    case "ops-users": return <UsersPage/>;
    

    6) 실행 (포트 7401)

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

    브라우저 → 사용자 관리: 검색/정렬/페이지 이동/신규 생성(폼 검증) 작동 확인.
    실서버 API가 준비되어 있으면 .env.localREAL_API_BASE만 채워도 자동으로 프록시됩니다.


    7) 운영 팁(간단 정리)

    • 비밀/내부값.env에 두고 서버 라우트에서만 사용하세요.
    • 클라이언트에서 필요한 공개값은 NEXT_PUBLIC_* 접두사로 별도 제공.
    • 입력 검증: 클라이언트(UX) + 서버(zod) 양쪽에서 수행.
    • 목록 성능: 페이지네이션 기본. 5천건 이상이면 서버 정렬/검색이 필수.
    • 오류 처리: try/catch + 사용자 메시지(알림/토스트)로 친절하게.

    여기까지 하면 “데이터 흐름(가짜→진짜)” & “폼/검증/리스트 UX”가 한 번에 정리됩니다.
    다음 편(5/6)에서는 배포 최적화 & Nginx 리버스 프록시(서브패스 /admin-demo), HTTPS, PM2, 포트 7401 운영 체크리스트를 정리합니다.