Tips & Tricks (업데이트: 2026. 6. 3.)

Claude Code로 MSW API Mock 구축하기

Claude Code와 MSW로 브라우저, Node 테스트, CI에서 쓸 현실적인 API Mock을 만드는 방법.

Claude Code로 MSW API Mock 구축하기

MSW는 Mock Service Worker의 약자입니다. 브라우저에서는 Service Worker로 HTTP 요청을 가로채고, Node.js 테스트에서는 현재 프로세스의 요청 모듈을 가로챕니다. 그래서 로컬 화면 개발, Vitest, CI가 같은 API 처리기를 공유할 수 있습니다.

Claude Code와 함께 쓸 때 핵심은 단순한 고정 JSON 생성이 아닙니다. 인증, 페이지네이션, 입력 검증, 오류 상태, 네트워크 실패, 응답 계약의 변화를 함께 설계해야 실제 프로젝트에 도움이 됩니다. 성공 응답만 있는 Mock은 빠르게 보이지만, 실제 장애 상태에서 깨지는 UI를 숨깁니다.

이 글은 MSW 2 공식 문서의 http, HttpResponse, setupWorker, setupServer를 기준으로 작성했습니다. 먼저 MSW Quick start를 보고, 브라우저는 Browser integration, 테스트는 Node.js integration을 확인하세요. 실패 응답은 error responsesnetwork errors를 기준으로 삼으면 됩니다.

관련 글로는 Vitest 고급 기법, Playwright E2E 테스트, API 테스트 자동화, CI/CD 설정을 함께 보면 좋습니다.

적용하기 좋은 상황

MSW는 백엔드가 아직 없을 때만 쓰는 도구가 아닙니다. 실제 API가 있어도, 매번 만들기 어려운 오류 상태를 안정적으로 재현할 수 있다는 점이 큽니다.

상황Mock 대상빠뜨렸을 때 위험
백엔드 전에 화면 개발목록, 상세, 생성, 빈 상태실제 API 연결 시 필드가 맞지 않음
인증과 권한 확인401, 403, 역할별 응답일반 사용자에게 관리자 버튼이 보임
장애 UX 검증500, 422, 네트워크 실패, 지연로딩이 끝나지 않거나 재시도 버튼이 없음
CI 계약 검증JSON 구조, 필수 필드, 상태 코드API 변경이 조용히 배포됨

Claude Code에는 이렇게 요청하세요.

MSW 2로 사용자 API Mock을 만들어 주세요.
브라우저 개발과 Vitest Node 환경에서 같은 handlers.ts를 공유합니다.
필수 인증, 페이지네이션, 역할 필터, 422, 404, 500, 네트워크 오류 테스트를 포함하세요.
TypeScript로 작성하고, 정의되지 않은 프로젝트 타입을 남기지 마세요.

구조

처리기는 한 파일에 두고, 브라우저는 setupWorker, 테스트는 setupServer로 연결합니다.

flowchart LR
  UI["브라우저 UI"] --> Worker["setupWorker"]
  Test["Vitest / CI"] --> Server["setupServer"]
  Worker --> Handlers["MSW handlers.ts"]
  Server --> Handlers
  Handlers --> Contract["API 계약: 상태 / JSON / 인증 / 지연"]

설치

npm i -D msw vitest typescript
npx msw init public/ --save

개발 서버 실행 중 http://localhost:5173/mockServiceWorker.js가 404라면 브라우저에서 요청을 가로챌 수 없습니다. 이 파일이 공개되는지 먼저 확인하세요.

바로 사용할 수 있는 처리기

아래 예시는 사용자 목록, 상세, 생성, 수정, 삭제를 제공합니다. 인증, 페이지네이션, 입력 검증, 404, 지연을 포함하며, Node의 기본 fetch에서도 쓰기 쉽도록 절대 URL을 사용합니다.

import { delay, http, HttpResponse } from "msw";

export const API_ORIGIN = "https://api.example.test";

type Role = "admin" | "editor" | "viewer";

export type User = {
  id: string;
  name: string;
  email: string;
  role: Role;
};

type CreateUserInput = {
  name: string;
  email: string;
  role?: Role;
};

type ErrorBody = {
  error: {
    code: string;
    message: string;
    requestId: string;
  };
};

type PageMeta = {
  total: number;
  page: number;
  perPage: number;
};

type UserListResponse = {
  data: User[];
  meta: PageMeta;
};

const seedUsers: User[] = [
  { id: "u_1", name: "Aki Tanaka", email: "aki@example.com", role: "admin" },
  { id: "u_2", name: "Bea Sato", email: "bea@example.com", role: "editor" },
  { id: "u_3", name: "Cal Mori", email: "cal@example.com", role: "viewer" },
];

let users: User[] = [...seedUsers];

const jsonError = (status: number, code: string, message: string) =>
  HttpResponse.json(
    { error: { code, message, requestId: "req_mock_001" } },
    { status }
  );

const requireAuth = (request: Request) => {
  const token = request.headers.get("authorization");
  return token === "Bearer demo-token"
    ? null
    : jsonError(401, "UNAUTHORIZED", "Missing or invalid bearer token");
};

const isRole = (value: string | null): value is Role =>
  value === "admin" || value === "editor" || value === "viewer";

export function resetMockData() {
  users = [...seedUsers];
}

export const handlers = [
  http.get(`${API_ORIGIN}/users`, async ({ request }) => {
    const authError = requireAuth(request);
    if (authError) return authError;

    await delay(120);
    const url = new URL(request.url);
    const page = Number(url.searchParams.get("page") ?? "1");
    const perPage = Number(url.searchParams.get("perPage") ?? "20");
    const role = url.searchParams.get("role");

    if (!Number.isInteger(page) || page < 1) {
      return jsonError(422, "INVALID_PAGE", "page must be a positive integer");
    }

    if (!Number.isInteger(perPage) || perPage < 1 || perPage > 50) {
      return jsonError(422, "INVALID_PER_PAGE", "perPage must be between 1 and 50");
    }

    if (role && !isRole(role)) {
      return jsonError(422, "INVALID_ROLE", "role must be admin, editor, or viewer");
    }

    const filtered = role ? users.filter((user) => user.role === role) : users;
    const start = (page - 1) * perPage;

    return HttpResponse.json({
      data: filtered.slice(start, start + perPage),
      meta: { total: filtered.length, page, perPage },
    });
  }),

  http.get(`${API_ORIGIN}/users/:id`, async ({ params, request }) => {
    const authError = requireAuth(request);
    if (authError) return authError;

    await delay(80);
    const user = users.find((item) => item.id === String(params.id));

    return user
      ? HttpResponse.json({ data: user })
      : jsonError(404, "USER_NOT_FOUND", "User was not found");
  }),

  http.post(`${API_ORIGIN}/users`, async ({ request }) => {
    const authError = requireAuth(request);
    if (authError) return authError;

    const body = (await request.json()) as Partial<CreateUserInput>;

    if (!body.name?.trim() || !body.email?.includes("@")) {
      return jsonError(422, "INVALID_INPUT", "name and a valid email are required");
    }

    if (body.role && !isRole(body.role)) {
      return jsonError(422, "INVALID_ROLE", "role must be admin, editor, or viewer");
    }

    const user: User = {
      id: `u_${Date.now()}`,
      name: body.name.trim(),
      email: body.email,
      role: body.role ?? "viewer",
    };

    users = [user, ...users];

    return HttpResponse.json({ data: user }, { status: 201 });
  }),

  http.patch(`${API_ORIGIN}/users/:id`, async ({ params, request }) => {
    const authError = requireAuth(request);
    if (authError) return authError;

    const index = users.findIndex((item) => item.id === String(params.id));
    if (index === -1) return jsonError(404, "USER_NOT_FOUND", "User was not found");

    const body = (await request.json()) as Partial<CreateUserInput>;

    if (body.email && !body.email.includes("@")) {
      return jsonError(422, "INVALID_EMAIL", "email must include @");
    }

    if (body.role && !isRole(body.role)) {
      return jsonError(422, "INVALID_ROLE", "role must be admin, editor, or viewer");
    }

    users[index] = { ...users[index], ...body };

    return HttpResponse.json({ data: users[index] });
  }),

  http.delete(`${API_ORIGIN}/users/:id`, async ({ params, request }) => {
    const authError = requireAuth(request);
    if (authError) return authError;

    users = users.filter((item) => item.id !== String(params.id));

    return new HttpResponse(null, { status: 204 });
  }),
];

브라우저 설정

브라우저에서는 setupWorker를 사용합니다. 앱을 렌더링하기 전에 worker.start()를 기다려야 첫 요청이 실제 API로 새지 않습니다.

import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";

export const worker = setupWorker(...handlers);
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";

async function enableMocking() {
  if (!import.meta.env.DEV || import.meta.env.VITE_API_MOCKING !== "enabled") {
    return;
  }

  const { worker } = await import("./mocks/browser");

  await worker.start({
    onUnhandledRequest: "bypass",
  });
}

enableMocking().then(() => {
  ReactDOM.createRoot(document.getElementById("root")!).render(
    <React.StrictMode>
      <App />
    </React.StrictMode>
  );
});

로컬에서는 VITE_API_MOCKING=enabled npm run dev처럼 명시적으로 켭니다. 운영 환경에서 Mock이 조용히 켜지면 로그인, 결제, 문의 폼, 수익 CTA가 가짜 응답으로 정상처럼 보일 수 있습니다.

Vitest와 CI

Node 테스트에서는 setupServer를 사용합니다. CI에서는 onUnhandledRequest: "error"가 기본입니다.

import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { API_ORIGIN, handlers, resetMockData } from "../src/mocks/handlers";

const server = setupServer(...handlers);

function authed(input: string, init: RequestInit = {}) {
  const headers = new Headers(init.headers);
  headers.set("authorization", "Bearer demo-token");
  return fetch(input, { ...init, headers });
}

beforeAll(() => server.listen({ onUnhandledRequest: "error" }));

afterEach(() => {
  server.resetHandlers();
  resetMockData();
});

afterAll(() => server.close());

describe("users API mock", () => {
  it("returns a paginated user list", async () => {
    const response = await authed(`${API_ORIGIN}/users?page=1&perPage=2`);
    const body = (await response.json()) as {
      data: Array<Record<string, unknown>>;
      meta: Record<string, unknown>;
    };

    expect(response.status).toBe(200);
    expect(body.data).toHaveLength(2);
    expect(body.meta).toMatchObject({ total: 3, page: 1, perPage: 2 });
  });

  it("rejects missing auth", async () => {
    const response = await fetch(`${API_ORIGIN}/users`);
    const body = (await response.json()) as { error: { code: string } };

    expect(response.status).toBe(401);
    expect(body.error.code).toBe("UNAUTHORIZED");
  });

  it("simulates a network failure for retry UI", async () => {
    server.use(
      http.get(`${API_ORIGIN}/users`, () => {
        return HttpResponse.error();
      })
    );

    await expect(authed(`${API_ORIGIN}/users`)).rejects.toThrow();
  });

  it("guards against response contract drift", async () => {
    const response = await authed(`${API_ORIGIN}/users`);
    const body = (await response.json()) as {
      data: Array<Record<string, unknown>>;
      meta: Record<string, unknown>;
    };

    expect(Object.keys(body.data[0]).sort()).toEqual(["email", "id", "name", "role"]);
    expect(body.data[0]).toEqual(
      expect.objectContaining({
        id: expect.any(String),
        email: expect.stringContaining("@"),
      })
    );
    expect(body.meta).toEqual(expect.objectContaining({ page: 1, perPage: 20 }));
  });
});
name: msw-contract

on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run test -- --run

자주 나는 실수

첫째, mockServiceWorker.js가 공개되지 않는 문제입니다. 이 파일이 404면 브라우저 Mock은 동작하지 않습니다.

둘째, 테스트 사이에 상태를 초기화하지 않는 문제입니다. server.resetHandlers()와 데이터 초기화를 함께 실행하세요.

셋째, CI에서도 onUnhandledRequest: "bypass"를 쓰는 문제입니다. 테스트에서는 누락된 요청을 실패로 처리해야 합니다.

넷째, 인증을 Mock하지 않는 문제입니다. 로그인됨, 만료됨, 권한 없음, 역할 불일치 상태를 따로 확인하세요.

다섯째, 응답 계약을 보지 않는 문제입니다. UI가 의존하는 data, meta.total, error.code는 반드시 단언하세요.

수익화 CTA

MSW는 기사 CTA, 상품 구매, 상담 폼, 무료 가입, 결제 전 확인처럼 수익과 가까운 흐름에서 특히 효과가 큽니다. 500, 느린 응답, 인증 만료, 입력 오류를 먼저 재현해 보세요. Claude Code 프롬프트와 검토 체크리스트를 팀용으로 정리하고 싶다면 제품 목록 또는 Claude Code 교육을 활용할 수 있습니다.

검증 결과

Masa가 이 구조로 기사 CTA와 상품 흐름을 테스트했을 때 가장 도움이 된 것은 HttpResponse.error()onUnhandledRequest: "error"였습니다. 성공 응답만 있는 Mock에서는 재시도 버튼 누락, 인증 헤더 누락, meta.total 삭제를 놓쳤습니다. 같은 처리기를 로컬 개발과 CI에서 공유하니 실패가 안정적으로 재현되어 Claude Code로 수정하기 쉬웠습니다.

#Claude Code #MSW #API Mock #테스트 #프런트엔드
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.