fe-api
SKILL.md
FE API Integration
$ARGUMENTS를 분석하여 API 통합 레이어를 설계하거나 개선한다.
분석 절차
- 요구사항 파악: API 엔드포인트, 데이터 구조, 사용 패턴을 확인한다
- 기존 코드 분석: 프로젝트의 API 레이어 구조를 Glob/Read로 파악한다
- 패턴 제안: 최적의 데이터 페칭 전략을 제시한다
- 구현/개선: 승인 후 코드를 작성하거나 개선한다
API 클라이언트 설계
타입 안전한 Fetch Wrapper
// 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 엔드포인트 정의
// 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 관리
// 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
// 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
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 (무한 스크롤)
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
// 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
// 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로 폼 연동
"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)
// 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>;
}
Route Handler (API Route)
CRUD Route Handler
// 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
// 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 서버
// 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를 구분한다
- 에러 핸들링은 반드시 포함한다 (네트워크 에러, 유효성 에러, 서버 에러)
Weekly Installs
3
Repository
ingpdw/pdw-fe-dev-toolFirst Seen
Feb 7, 2026
Security Audits
Installed on
cline3
gemini-cli3
github-copilot3
codex3
cursor3
opencode3