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를 구분한다
-
에러 핸들링은 반드시 포함한다 (네트워크 에러, 유효성 에러, 서버 에러)