“가짜 API → 진짜 API 스위치”, .env 안전하게, 목록 페이징/검색/정렬, zod + react-hook-form 검증
대상: 웹 프론트엔드 처음 하는 분
목표: /api/users 로컬 API를 만들고(가짜 데이터),.env에 REAL_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.local의 REAL_API_BASE만 채워도 자동으로 프록시됩니다.
7) 운영 팁(간단 정리)
- 비밀/내부값은
.env에 두고 서버 라우트에서만 사용하세요. - 클라이언트에서 필요한 공개값은
NEXT_PUBLIC_*접두사로 별도 제공. - 입력 검증: 클라이언트(UX) + 서버(zod) 양쪽에서 수행.
- 목록 성능: 페이지네이션 기본. 5천건 이상이면 서버 정렬/검색이 필수.
- 오류 처리:
try/catch+ 사용자 메시지(알림/토스트)로 친절하게.
여기까지 하면 “데이터 흐름(가짜→진짜)” & “폼/검증/리스트 UX”가 한 번에 정리됩니다.
다음 편(5/6)에서는 배포 최적화 & Nginx 리버스 프록시(서브패스 /admin-demo), HTTPS, PM2, 포트 7401 운영 체크리스트를 정리합니다.
답글 남기기