← 홈으로

MSW로 mock api 구현하기

회사에서 개발 중인 새 서비스의 데모 시연을 위해 현재 개발중인 백엔드 API 없이도 동작하도록 mock API를 구현했다.

mock API를 구현한 서비스의 프론트엔드는 다음과 같은 기술 스택으로 구성되어 있었다:

  • Next.js v15.0.2
  • TypeScript v5

mock API를 위해 꼭 별도의 라이브러리를 설치해야 할까?

처음에는 JSON 파일 기반의 간단한 DB + Next.jsroute handlermock API를 구현했다. 이런 방식으로 하니 초기 설정은 간편했는데, 개발하는 과정에서 몇 가지 불편한 점이 있었다.

  1. 매 요청마다 파일 읽기/쓰기가 발생한다. 예를 들어 POST 요청의 경우, 기존 데이터를 읽고, 수정한 다음, 다시 파일에 저장하는 과정이 필요하다. 데이터가 커질수록 이러한 I/O 작업으로 인한 메모리 사용량이 증가하여 성능 저하가 우려된다.

  2. API 응답으로 사용되는 JSON 파일을 수정할 때마다 Next.js 서버가 재시작되어 개발 흐름이 끊긴다.

  3. 각 엔드포인트마다 api 디렉토리에 별도의 파일을 만들어야 하는 보일러플레이트가 발생한다. 결국 실제 API로 전환할 텐데, 이를 위한 임시 환경을 구축하는 데 너무 많은 수고가 든다.

MSW 알아보기

프론트엔드에서 mock API를 구현하는 법을 구글링하면 MSW에 대한 글이 대부분이다. 그래서 나도 MSW를 사용해보기로 했다.

MSWMock Service Worker의 줄임말로, 실제 네트워크 요청을 가로채서 모의 응답을 보낼 수 있게끔 해주는 라이브러리다. 때문에 백엔드 API가 준비되지 않았을 때, 개발환경에서 API 응답을 실제처럼 시뮬레이션할 수 있어 유용하다.

Service Worker는 웹 브라우저에서 제공하는 Web API다. MSW는 브라우저 환경에서 발생하는 요청에 한해 Service Worker API를 활용하며, Node.js 환경에서 발생하는 요청에 대해서는 또 다른 방식인 node-request-interceptor를 활용하여 그 요청을 가로채게 된다.

MSW로 회원가입 및 로그인 mock API 만들기

  1. msw를 설치한다.
npm install msw --save-dev
  1. msw를 초기화하고 Service Worker를 생성한다.
npx msw init public/ --save

위 명령어를 실행하고 나면 public 디렉토리에 mockServiceWorker.js 라는 파일이 생성된다.

  1. mock API handler와 설정파일을 생성한다.

mock 관련 파일들은 root 디렉토리에 mocks 폴더를 만들어서 관리했다. API 요청에 대한 모의 응답을 작성한 handler 파일들은 mocks/handlers에 작성했다.

다음은 회원가입과 로그인을 위한 handler들이다.

// mocks/handlers/auth.handlers.ts
import { HttpResponse, http } from "msw";
const users = [];
export const authHandlers = [
http.post("/api/sign-up", async ({ request }) => {
const newUser = await request.json();
if (users.find((user) => user.username === newUser.email)) {
return HttpResponse.json(
{
message_code: 400,
message: "이미 가입한 이메일 주소입니다.",
data: null,
},
{ status: 400 }
);
}
const user = {
id: crypto.randomUUID(),
username: newUser.email,
fullname: newUser.fullname,
password: newUser.password,
image: {
id: Date.now(),
image: newUser.image || "default-profile.jpg",
created_at: new Date().toISOString(),
},
};
users.push(user);
return HttpResponse.json(
{
message_code: 200,
message: "회원가입이 성공적으로 완료되었습니다.",
data: user,
},
{ status: 200 }
);
}),
http.post("/api/sign-in", async ({ request }) => {
const { email, password } = await request.json();
const user = users.find(
(u) => u.username === email && u.password === password
);
if (!user) {
return HttpResponse.json(
{
message_code: 401,
message: "이메일 또는 비밀번호가 일치하지 않습니다.",
data: null,
},
{ status: 401 }
);
}
return HttpResponse.json(
{
message_code: 200,
message: "성공적으로 로그인되었습니다.",
data: user,
},
{ status: 200 }
);
}),
];
// mocks/handlers/index.ts
import { authHandlers } from "./auth.handlers";
const handlers = [...authHandlers];
export default handlers;
// mocks/browser.ts
import { setupWorker } from "msw/browser";
import handlers from "./handlers";
export const worker = setupWorker(...handlers);
// mocks/init.ts
export async function initMsw() {
if (typeof window !== "undefined") {
const { worker } = await import("./browser");
await worker.start();
}
}
  1. msw Provider를 만들어준다.
// mocks/MSWComponent.tsx
"use client";
import { useEffect } from "react";
export const MSWComponent = ({ children }: { children: React.ReactNode }) => {
useEffect(() => {
import("./index").then((res) => res.initMsw());
}, []);
return <>{children}</>;
};
// utils/Providers
import { MSWComponent } from "@/mocks/MSWProvider";
export function Providers({ children }: { children: React.ReactNode }) {
return <MSWComponent>{children}</MSWComponent>;
}

이렇게 하고 Next.js 서버를 실행하면 브라우저 개발도구의 콘솔창에 MSW가 실행되었다는 메시지가 표시된다.

MSW로 작성한 mock API를 호출하는 방식은 실제 API를 호출하는 방식과 동일하다. 예를 들어 회원가입 요청은 다음과 같이 하면 된다.

// components/SignUpForm.tsx
...
const handleSubmit: React.FormEventHandler = async (e) => {
e.preventDefault()
try {
const response = await fetch('/api/sign-up', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: formData.email,
password: formData.password
})
})
const result = await response.json()
if (!response.ok) {
// TODO: 에러 처리
}
router.push('/sign-in')
} catch (error) {
// TODO: 에러 처리
}
}

mock API가 실행되면 마찬가지로 브라우저 콘솔창에서 다음과 같이 메시지를 확인할 수 있다.

또한 네트워크창에서도 확인할 수 있다.

MSWTypeScript 함께 사용하기

msw 공식문서에서 Using with TypeScript를 보면 REST apiTypeScript와 함께 쓰는 방법이 잘 설명되어 있다. 문서를 보면, http 네임스페이스의 모든 요청 핸들러는 세 가지 제네릭 인자를 지원한다.

/**
* Params: 요청 경로 매개변수
* RequestBodyType: 요청 본문 타입
* ResponseBodyType: 응답 본문 타입
* Path: 경로
*/
http.get<Params, RequestBodyType, ResponseBodyType, Path>(path, resolver);

API로부터 받게 되는 응답 데이터는 message_code, message, data로 구성되고, 엔드포인트에 따라 data의 타입만 달라지기 때문에, 다음과 같이 ApiResponse<T> 인터페이스를 작성해서 모든 엔드포인트의 응답 데이터 타입 선언에 활용했다.

// types/mock/auth.ts
export interface ApiResponse<T> {
message_code: number;
message: string;
data: T | null;
}

위에서 작성한 회원가입 및 로그인을 위한 핸들러에 TypeScript를 적용한 결과는 다음과 같다.

// mocks/handlers/auth.handlers.ts
import { HttpResponse, http } from "msw";
import {
ApiResponse,
SignInRequestBody,
SignUpRequestBody,
UserDetail,
UserRegister,
} from "@/types/mock/auth";
const users: UserRegister[] = [];
export const authHandlers = [
http.post<
never,
SignUpRequestBody,
ApiResponse<UserRegister>,
"/api/sign-up"
>("/api/sign-up", async ({ request }) => {
const newUser = await request.json();
if (users.find((user) => user.username === newUser.email)) {
return HttpResponse.json<ApiResponse<null>>(
{
message_code: 400,
message: "이미 가입한 이메일 주소입니다.",
data: null,
},
{ status: 400 }
);
}
const user: UserRegister = {
id: crypto.randomUUID(),
username: newUser.email,
fullname: newUser.fullname,
password: newUser.password,
image: {
id: Date.now(),
image: newUser.image || "default-profile.jpg",
created_at: new Date().toISOString(),
},
};
users.push(user);
return HttpResponse.json<ApiResponse<UserRegister>>(
{
message_code: 200,
message: "회원가입이 성공적으로 완료되었습니다.",
data: user,
},
{ status: 200 }
);
}),
http.post<
never,
SignInRequestBody,
ApiResponse<UserRegister>,
"/api/sign-in"
>("/api/sign-in", async ({ request }) => {
const { email, password } = await request.json();
const user = users.find(
(u) => u.username === email && u.password === password
);
if (!user) {
return HttpResponse.json<ApiResponse<null>>(
{
message_code: 401,
message: "이메일 또는 비밀번호가 일치하지 않습니다.",
data: null,
},
{ status: 401 }
);
}
return HttpResponse.json<ApiResponse<UserRegister>>(
{
message_code: 200,
message: "성공적으로 로그인되었습니다.",
data: user,
},
{ status: 200 }
);
}),
];

mock API에서 실제 API로 갈아끼우기

msw를 사용하면 실제 API로의 전환이 매우 수월하다. msw는 실제 API와 동일한 엔드포인트를 사용하므로, API 호출을 위해 작성한 프론트엔드 코드를 전혀 수정할 필요가 없기 때문이다.

예를 들어, 회원가입을 위한 실제 API를 구현할 때는 Next.js App Router에서 아래와 같이 app/api/sign-up/route.ts 파일을 추가하기만 하면 된다:

// app/api/sign-up/route.ts
export async function POST(request: Request) {
// 실제 API 로직 구현
const data = await request.json();
// 백엔드 서버로 요청 전달
const response = await fetch("https://api.example.com/sign-up", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
return response;
}

개발 과정에서 실제 API로 테스트해야 할 때는 msw를 비활성화해야 한다. msw의 활성화 여부를 쉽게 제어하기 위해 NEXT_PUBLIC_API_MOCKING이라는 환경변수를 만들고, 이 값이 enabled일 때만 msw가 동작하도록 설정할 수 있다:

// mocks/init.ts
export async function initMsw() {
if (process.env.NEXT_PUBLIC_API_MOCKING === "enabled") {
if (typeof window !== "undefined") {
const { worker } = await import("./browser");
await worker.start();
}
}
}