fe-api

📁 ingpdw/pdw-fe-dev-tool 📅 Feb 7, 2026
3
总安装量
3
周安装量
#54898
全站排名
安装命令
npx skills add https://github.com/ingpdw/pdw-fe-dev-tool --skill fe-api

Agent 安装分布

cline 3
gemini-cli 3
github-copilot 3
codex 3
cursor 3
opencode 3

Skill 文档

FE API Integration

$ARGUMENTS를 분석하여 API 통합 레이어를 설계하거나 개선한다.

분석 절차

  1. 요구사항 파악: API 엔드포인트, 데이터 구조, 사용 패턴을 확인한다
  2. 기존 코드 분석: 프로젝트의 API 레이어 구조를 Glob/Read로 파악한다
  3. 패턴 제안: 최적의 데이터 페칭 전략을 제시한다
  4. 구현/개선: 승인 후 코드를 작성하거나 개선한다

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

실행 규칙

  1. 인자가 없으면 사용자에게 API 통합 대상을 질문한다
  2. 프로젝트의 기존 API 레이어를 먼저 파악한다 (lib/api, hooks, actions 등)
  3. TanStack Query 사용 여부를 확인하고, 미설치 시 설치를 안내한다
  4. Zod 스키마로 API 응답 타입을 검증하는 패턴을 기본으로 적용한다
  5. Server Component에서의 데이터 페칭과 Client Component에서의 TanStack Query를 구분한다
  6. 에러 핸들링은 반드시 포함한다 (네트워크 에러, 유효성 에러, 서버 에러)