Loading...
Loading...
React/Next.js 앱의 API 통합 레이어를 설계하고 구현하는 스킬. TanStack Query, fetch 패턴, Server Actions, 타입 안전한 API 클라이언트, 에러 핸들링 등. "API", "데이터 fetching", "TanStack Query", "React Query", "Server Action", "fetch" 등의 요청 시 사용.
npx skill4agent add ingpdw/pdw-fe-dev-tool fe-api$ARGUMENTS// src/lib/api.ts
import { z } from "zod";
class ApiError extends Error {
constructor(
public status: number,
public statusText: string,
public data?: unknown
) {
super(`API Error: ${status} ${statusText}`);
this.name = "ApiError";
}
}
async function fetchApi<T>(
url: string,
schema: z.ZodType<T>,
options?: RequestInit
): Promise<T> {
const response = await fetch(url, {
headers: { "Content-Type": "application/json", ...options?.headers },
...options,
});
if (!response.ok) {
throw new ApiError(response.status, response.statusText);
}
const data = await response.json();
return schema.parse(data);
}
export { fetchApi, ApiError };// src/lib/api/users.ts
import { z } from "zod";
import { fetchApi } from "@/lib/api";
const userSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
role: z.enum(["admin", "user"]),
});
const usersResponseSchema = z.object({
data: z.array(userSchema),
total: z.number(),
});
type User = z.infer<typeof userSchema>;
async function getUsers(params?: { page?: number; limit?: number }) {
const searchParams = new URLSearchParams();
if (params?.page) searchParams.set("page", String(params.page));
if (params?.limit) searchParams.set("limit", String(params.limit));
return fetchApi(`/api/users?${searchParams}`, usersResponseSchema);
}
async function getUser(id: string) {
return fetchApi(`/api/users/${id}`, userSchema);
}
async function createUser(data: Omit<User, "id">) {
return fetchApi(`/api/users`, userSchema, {
method: "POST",
body: JSON.stringify(data),
});
}
export { getUsers, getUser, createUser };
export type { User };// src/lib/queryKeys.ts
const queryKeys = {
users: {
all: ["users"] as const,
lists: () => [...queryKeys.users.all, "list"] as const,
list: (filters: Record<string, unknown>) =>
[...queryKeys.users.lists(), filters] as const,
details: () => [...queryKeys.users.all, "detail"] as const,
detail: (id: string) => [...queryKeys.users.details(), id] as const,
},
products: {
all: ["products"] as const,
lists: () => [...queryKeys.products.all, "list"] as const,
list: (filters: Record<string, unknown>) =>
[...queryKeys.products.lists(), filters] as const,
details: () => [...queryKeys.products.all, "detail"] as const,
detail: (id: string) => [...queryKeys.products.details(), id] as const,
},
} as const;
export { queryKeys };// src/hooks/useUsers.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getUsers, getUser, createUser } from "@/lib/api/users";
import { queryKeys } from "@/lib/queryKeys";
function useUsers(filters?: { page?: number; limit?: number }) {
return useQuery({
queryKey: queryKeys.users.list(filters ?? {}),
queryFn: () => getUsers(filters),
});
}
function useUser(id: string) {
return useQuery({
queryKey: queryKeys.users.detail(id),
queryFn: () => getUser(id),
enabled: !!id,
});
}
function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.users.lists() });
},
});
}
export { useUsers, useUser, useCreateUser };function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateUser,
onMutate: async (newUser) => {
await queryClient.cancelQueries({
queryKey: queryKeys.users.detail(newUser.id),
});
const previousUser = queryClient.getQueryData(
queryKeys.users.detail(newUser.id)
);
queryClient.setQueryData(
queryKeys.users.detail(newUser.id),
newUser
);
return { previousUser };
},
onError: (_err, newUser, context) => {
queryClient.setQueryData(
queryKeys.users.detail(newUser.id),
context?.previousUser
);
},
onSettled: (_data, _err, newUser) => {
queryClient.invalidateQueries({
queryKey: queryKeys.users.detail(newUser.id),
});
},
});
}function useInfiniteUsers() {
return useInfiniteQuery({
queryKey: queryKeys.users.lists(),
queryFn: ({ pageParam }) => getUsers({ page: pageParam, limit: 20 }),
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) => {
const totalFetched = allPages.reduce((sum, p) => sum + p.data.length, 0);
return totalFetched < lastPage.total ? allPages.length + 1 : undefined;
},
});
}// Server Component에서 prefetch
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";
export default async function UsersPage() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: queryKeys.users.list({}),
queryFn: () => getUsers(),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<UserList />
</HydrationBoundary>
);
}// src/app/actions/users.ts
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
const createUserSchema = z.object({
name: z.string().min(1, "이름을 입력하세요"),
email: z.string().email("유효한 이메일을 입력하세요"),
});
interface ActionState {
success: boolean;
message: string;
errors?: Record<string, string[]>;
}
async function createUserAction(
_prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const raw = {
name: formData.get("name"),
email: formData.get("email"),
};
const result = createUserSchema.safeParse(raw);
if (!result.success) {
return {
success: false,
message: "유효성 검사 실패",
errors: result.error.flatten().fieldErrors,
};
}
try {
await db.user.create({ data: result.data });
revalidatePath("/users");
return { success: true, message: "사용자가 생성되었습니다" };
} catch (error) {
return { success: false, message: "서버 오류가 발생했습니다" };
}
}
export { createUserAction };
export type { ActionState };"use client";
import { useActionState } from "react";
import { createUserAction } from "@/app/actions/users";
import type { ActionState } from "@/app/actions/users";
const initialState: ActionState = { success: false, message: "" };
function CreateUserForm() {
const [state, formAction, isPending] = useActionState(
createUserAction,
initialState
);
return (
<form action={formAction}>
<Input name="name" placeholder="이름" />
{state.errors?.name && (
<p className="text-sm text-destructive">{state.errors.name[0]}</p>
)}
<Input name="email" placeholder="이메일" type="email" />
{state.errors?.email && (
<p className="text-sm text-destructive">{state.errors.email[0]}</p>
)}
<Button type="submit" disabled={isPending}>
{isPending ? "생성 중..." : "생성"}
</Button>
{state.message && (
<p className={state.success ? "text-green-600" : "text-destructive"}>
{state.message}
</p>
)}
</form>
);
}// src/lib/queryClient.ts
import { QueryClient } from "@tanstack/react-query";
import { ApiError } from "@/lib/api";
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
retry: (failureCount, error) => {
if (error instanceof ApiError && error.status === 401) return false;
if (error instanceof ApiError && error.status === 404) return false;
return failureCount < 3;
},
},
mutations: {
onError: (error) => {
if (error instanceof ApiError && error.status === 401) {
window.location.href = "/login";
}
},
},
},
});
}
export { makeQueryClient };function UserProfile({ id }: { id: string }) {
const { data, error, isLoading } = useUser(id);
if (isLoading) return <Skeleton className="h-40 w-full" />;
if (error) {
if (error instanceof ApiError && error.status === 404) {
return <p>사용자를 찾을 수 없습니다.</p>;
}
return <p>데이터를 불러오는 중 오류가 발생했습니다.</p>;
}
return <div>{data.name}</div>;
}// src/app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const page = Number(searchParams.get("page") ?? "1");
const limit = Number(searchParams.get("limit") ?? "20");
const [users, total] = await Promise.all([
db.user.findMany({ skip: (page - 1) * limit, take: limit }),
db.user.count(),
]);
return NextResponse.json({ data: users, total });
}
export async function POST(request: NextRequest) {
const body = await request.json();
const result = createUserSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: "Validation failed", details: result.error.flatten() },
{ status: 400 }
);
}
const user = await db.user.create({ data: result.data });
return NextResponse.json(user, { status: 201 });
}// src/app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const user = await db.user.findUnique({ where: { id } });
if (!user) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(user);
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
const user = await db.user.update({ where: { id }, data: body });
return NextResponse.json(user);
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
await db.user.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}// src/mocks/handlers.ts
import { http, HttpResponse } from "msw";
const users = [
{ id: "1", name: "Alice", email: "alice@example.com", role: "admin" },
{ id: "2", name: "Bob", email: "bob@example.com", role: "user" },
];
export const handlers = [
http.get("/api/users", ({ request }) => {
const url = new URL(request.url);
const page = Number(url.searchParams.get("page") ?? "1");
const limit = Number(url.searchParams.get("limit") ?? "20");
const start = (page - 1) * limit;
return HttpResponse.json({
data: users.slice(start, start + limit),
total: users.length,
});
}),
http.post("/api/users", async ({ request }) => {
const body = await request.json();
const newUser = { id: String(users.length + 1), ...body };
users.push(newUser);
return HttpResponse.json(newUser, { status: 201 });
}),
];