fe-api
Original:🇺🇸 English
Translated
React/Next.js 앱의 API 통합 레이어를 설계하고 구현하는 스킬. TanStack Query, fetch 패턴, Server Actions, 타입 안전한 API 클라이언트, 에러 핸들링 등. "API", "데이터 fetching", "TanStack Query", "React Query", "Server Action", "fetch" 등의 요청 시 사용.
15installs
Sourceingpdw/pdw-fe-dev-tool
Added on
NPX Install
npx skill4agent add ingpdw/pdw-fe-dev-tool fe-apiTags
Translated version includes tags in frontmatterSKILL.md Content
View Translation Comparison →FE API Integration
$ARGUMENTS분석 절차
- 요구사항 파악: API 엔드포인트, 데이터 구조, 사용 패턴을 확인한다
- 기존 코드 분석: 프로젝트의 API 레이어 구조를 Glob/Read로 파악한다
- 패턴 제안: 최적의 데이터 페칭 전략을 제시한다
- 구현/개선: 승인 후 코드를 작성하거나 개선한다
API 클라이언트 설계
타입 안전한 Fetch Wrapper
typescript
// 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 };API 엔드포인트 정의
typescript
// 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 };TanStack Query 패턴
Query Key 관리
typescript
// 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 };Query Hook
typescript
// 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 };Optimistic Update
typescript
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),
});
},
});
}Infinite Query (무한 스크롤)
typescript
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;
},
});
}Prefetching
typescript
// 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>
);
}Server Actions 패턴
기본 Server Action
typescript
// 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 };useActionState로 폼 연동
tsx
"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>
);
}API 에러 핸들링
전역 에러 핸들링 (QueryClient)
typescript
// 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 };컴포넌트 레벨 에러 처리
tsx
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>;
}Route Handler (API Route)
CRUD Route Handler
typescript
// 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 });
}동적 Route Handler
typescript
// 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 });
}MSW 개발용 Mock 서버
typescript
// 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 });
}),
];실행 규칙
- 인자가 없으면 사용자에게 API 통합 대상을 질문한다
- 프로젝트의 기존 API 레이어를 먼저 파악한다 (lib/api, hooks, actions 등)
- TanStack Query 사용 여부를 확인하고, 미설치 시 설치를 안내한다
- Zod 스키마로 API 응답 타입을 검증하는 패턴을 기본으로 적용한다
- Server Component에서의 데이터 페칭과 Client Component에서의 TanStack Query를 구분한다
- 에러 핸들링은 반드시 포함한다 (네트워크 에러, 유효성 에러, 서버 에러)