fe-api

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

FE API Integration

前端API集成

$ARGUMENTS
를 분석하여 API 통합 레이어를 설계하거나 개선한다.
分析
$ARGUMENTS
以设计或优化API集成层。

분석 절차

分析流程

  1. 요구사항 파악: API 엔드포인트, 데이터 구조, 사용 패턴을 확인한다
  2. 기존 코드 분석: 프로젝트의 API 레이어 구조를 Glob/Read로 파악한다
  3. 패턴 제안: 최적의 데이터 페칭 전략을 제시한다
  4. 구현/개선: 승인 후 코드를 작성하거나 개선한다
  1. 需求确认:确认API端点、数据结构、使用模式
  2. 现有代码分析:通过Glob/Read了解项目的API层结构
  3. 模式建议:提出最优的数据获取策略
  4. 实现/优化:获得批准后编写或改进代码

API 클라이언트 설계

API客户端设计

타입 안전한 Fetch Wrapper

类型安全的Fetch封装

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 };
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错误: ${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 엔드포인트 정의

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 };
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 패턴

TanStack Query模式

Query Key 관리

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 };
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

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 };
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),
      });
    },
  });
}
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;
    },
  });
}
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>
  );
}
typescript
// Server Component中预取
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 Actions模式

기본 Server Action

基础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 };
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로 폼 연동

用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>
  );
}
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 에러 핸들링

API错误处理

전역 에러 핸들링 (QueryClient)

全局错误处理(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 };
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>;
}
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)

Route Handler(API路由)

CRUD Route Handler

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 });
}
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: "验证失败", details: result.error.flatten() },
      { status: 400 }
    );
  }

  const user = await db.user.create({ data: result.data });
  return NextResponse.json(user, { status: 201 });
}

동적 Route Handler

动态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 });
}
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: "未找到" }, { 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 서버

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 });
  }),
];
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 });
  }),
];

실행 규칙

执行规则

  1. 인자가 없으면 사용자에게 API 통합 대상을 질문한다
  2. 프로젝트의 기존 API 레이어를 먼저 파악한다 (lib/api, hooks, actions 등)
  3. TanStack Query 사용 여부를 확인하고, 미설치 시 설치를 안내한다
  4. Zod 스키마로 API 응답 타입을 검증하는 패턴을 기본으로 적용한다
  5. Server Component에서의 데이터 페칭과 Client Component에서의 TanStack Query를 구분한다
  6. 에러 핸들링은 반드시 포함한다 (네트워크 에러, 유효성 에러, 서버 에러)
  1. 若无参数,则询问用户API集成的目标对象
  2. 先了解项目的现有API层(如lib/api、hooks、actions等)
  3. 确认是否使用TanStack Query,若未安装则引导安装
  4. 默认采用Zod Schema验证API响应类型的模式
  5. 区分Server Component中的数据获取与Client Component中的TanStack Query使用
  6. 必须包含错误处理(网络错误、验证错误、服务器错误)