-
Notifications
You must be signed in to change notification settings - Fork 3
MSW
🥲
프론트가 UI의 대략적인 레이아웃을 개발한 후에 실제 API가 백엔드에서 개발되는 시간이 더 필요해서 기다려야 했습니다
따라서 실제 API가 개발되기 전에 프론트에서 테스트할 가짜 API가 필요하게 되어 찾아보게 되었습니다
1️⃣ Mock 데이터
실제 API 호출 없이 데이터만을 미리 정의하여 사용하는 방식
- 주로 JSON 형태의 정적 데이터 파일이나 하드코딩된 객체 형태로 제공
- 간단하고 설정이 필요 없으며, 데이터만 있으면 빠르게 사용
- 동적 요청이나 다양한 시나리오를 테스트하는 데 한계
2️⃣ Mock API
서버의 API와 유사한 인터페이스를 가진 함수를 만들어, 서버의 응답을 흉내 내는 방식
- 주로 서버 없이 클라이언트 측에서 동작하도록
fetch
나axios
호출을 모킹하여 사용 - 클라이언트 코드에서 서버 인터페이스를 동일하게 유지하며 테스트
- 실제 네트워크의 특성을 완벽하게 흉내 내기 어려움
- 특정 HTTP 메서드에 따라 동작을 세분화하기 위해 추가적인 코드 작성이 필요
3️⃣ MSW(Mock Service Worker) ⭐️
브라우저 또는 Node.js 환경에서 서비스 워커(Service Worker)를 활용하여 요청을 가로채고 이를 가상 서버에서 응답하는 방식
- HTTP 요청을 모킹(mocking)하여 실제 서버와 통신하지 않고, 지정된 핸들러를 통해 응답을 제공
- 실제 네트워크 요청과 동일한 방식으로 동작하기 때문에 API 호출과 관련된 다양한 시나리오를 쉽게 테스트
- 설정이 필요하고 서비스 워커의 동작 원리를 알아야 제대로 활용 가능
클라이언트가 HTTP 요청을 전송하면 Service Worker가 요청을 가로챈(intercept) 후 Mocking된 응답 값을 반환함으로써 서버와의 통신을 모방하는 오픈소스 라이브러리
하나 이상의 페이지를 제어하는 스크립트
- 자신이 제어하는 페이지에서 발생하는 이벤트를 수신 가능
- 서비스 워커는 브라우저와 네트워크 사이의 새로운 계층에 존재 → 네트워크 요청을 가로채서 리소스를 캐싱할 수 있음
공식 문서에 엄청 잘 나와있다!
-
brower.ts로 브라우저 환경에서 Service Worker를 설정
import { setupWorker } from "msw/browser"; import { handlers } from "./handlers"; export const worker = setupWorker(...handlers);
-
main.tsx에 다음처럼 세팅해서
/api
를 포함하는 api를 요청할때만 MSW를 사용하도록 함import { StrictMode } from "react"; import ReactDOM from "react-dom/client"; import { QueryProvider } from "./query"; import { RouteProvider } from "./router"; import "./style/index.css"; async function enableMocking() { if (process.env.NODE_ENV !== "development") { return; } const { worker } = await import("./mock/browser"); return worker.start({ onUnhandledRequest: (request, print) => { if (!request.url.includes("/api/")) return; // /api/가 포함된 요청만 MSW 사용 print.warning(); }, }); } enableMocking().then(() => { const root = ReactDOM.createRoot(document.getElementById("root")!); root.render( <StrictMode> <QueryProvider> <RouteProvider /> </QueryProvider> </StrictMode> ); });
-
handler에서 요청을 핸들링하는 로직을 정의
export const handlers = [http.get(`${import.meta.env.VITE_API_BASE_URL}/user`, mockGetUserInfo)];
-
resolver에 요청을 처리해서 응답을 반환하는 로직을 정의
export const mockGetUserInfo = ({ request }: { request: StrictRequest<DefaultBodyType> }) => { const authorization = request.headers.get("Authorization"); if (!authorization || !authorization.startsWith("Bearer ")) { return new HttpResponse("Unauthorized: Invalid or missing token", { status: 401, headers: { "Content-Type": "text/plain", }, }); } return HttpResponse.json({ id: "1234567890", nickname: "mockUser", profile: "https://github.com/mockUser", }); };
path parameter
import { PathParams } from "msw";
export const deleteLotus = async ({ params }: { params: PathParams }) => {
const { id } = params; // id값을 받아서 사용 가능
};
query parameter
import { DefaultBodyType, StrictRequest } from "msw";
// ?page=1&size=5 일 때 page와 size값 가져오기
export const getPublicLotusList = async ({ request }: { request: StrictRequest<DefaultBodyType> }) => {
const url = new URL(request.url);
const page = Number(url.searchParams.get("page")) || 1;
const size = Number(url.searchParams.get("size")) || 5;
};
header
export const mockGetUserInfo = ({ request }: { request: StrictRequest<DefaultBodyType> }) => {
// Authorization 헤더
const authorization = request.headers.get("Authorization");
};
body
export const patchLotus = async ({ request }: { request: StrictRequest<DefaultBodyType> }) => {
const body = (await request.json()) as Partial<LotusDto>;
};
-
처음에 구현한 public Lotus 목록 조회 api 응답 로직
// public lotus 목록 조회 export const mockGetPublicLotusList = ({ request }: { request: StrictRequest<DefaultBodyType> }) => { const url = new URL(request.url); const page = url.searchParams.get("page"); const size = url.searchParams.get("size"); const search = url.searchParams.get("search"); if (!page || !size || !search) { return new HttpResponse("Bad Request", { status: 400, headers: { "Content-Type": "text/plain", }, }); } return HttpResponse.json({ lotuses: [ { id: "10000000001", title: "Exploring the Depths of TypeScript", tags: ["TypeScript", "Programming", "Web Development"], language: "English", date: "2024-11-16", author: { id: "20000000001", nickname: "dev_master", profile: "https://example.com/profiles/dev_master", }, }, { id: "10000000002", title: "Understanding React Hooks", tags: ["React", "JavaScript", "Frontend"], language: "English", date: "2024-11-15", author: { id: "20000000002", nickname: "react_enthusiast", profile: "https://example.com/profiles/react_enthusiast", }, }, ], page: { current: 1, max: 5, }, }); };
그러나 이 방법은 정적인 데이터를 반환하기 때문에 요청에 따라 상태 변화가 반영되지 않고 항상 고정적인 응답을 받는 문제가 있었습니다.
따라서 MockRepository로 요청 조건에 따라 데이터를 동적으로 반환하도록 구현했습니다.
-
MockRespository 클래스
export type Identifiable<T> = T & { id: string }; export class MockRepository<T> { private _autoId = 0; private memory: Identifiable<T>[] = []; private isMatch(owner: Partial<T>, target: Partial<T>): boolean { for (const key in target) { if (!Object.prototype.hasOwnProperty.call(owner, key) || target[key as keyof typeof target] !== owner[key as keyof typeof owner]) { return false; } } return true; } private generateId() { return String(this._autoId++); } async create(arg: T) { const data = { ...arg, id: this.generateId() }; this.memory.push(data); return data; } async delete(query: Partial<Identifiable<T>>) { const index = this.memory.findIndex((t) => this.isMatch(t, query)); if (index === -1) throw new Error("Not found"); const deletedData = this.memory[index]; this.memory = this.memory.filter((t) => t.id !== deletedData.id); return deletedData; } async update(query: Partial<Identifiable<T>>, data: Partial<Identifiable<T>>) { const index = this.memory.findIndex((item) => this.isMatch(item, query)); if (index === -1) throw new Error("Not found"); this.memory[index] = { ...this.memory[index], ...data }; return this.memory[index]; } async findMany({ query, page = 1, size = 10 }: { query?: Partial<Identifiable<T>>; page?: number; size?: number }) { const filtered = query ? this.memory.filter((item) => this.isMatch(item, query)) : this.memory; const start = (page - 1) * size; const end = start + size; return filtered.slice(start, end); } async findOne(query: Partial<Identifiable<T>>) { const data = this.memory.find((item) => this.isMatch(item, query)); if (!data) throw new Error("Not found"); return data; } }
-
_autoId
: 자동으로 1씩 증가하는 ID값 부여 -
memory
: 메모리 내에서 데이터를 저장 -
create()
,delete()
,update()
: 데이터를 생성, 삭제, 수정하는 메서드 -
findMany()
: 조건에 맞는 데이터를 페이지 단위로 검색하는 메서드 -
findOne()
: 조건에 일치하는 첫번째 데이터를 검색하는 메서드
이를 통해 동적으로 데이터를 관리해서 응답할 수 있게 되었습니다.
다음처럼 MockRepository를 이용해서 데이터 처리 로직을 추상화해서 다양한 handler에서 사용할 수 있었습니다.
-
수정한 public Lotus 목록 조회 api 응답 로직
// Lotus 데이터를 저장할 저장소 생성 const lotusList = new MockRepository<Omit<LotusDto & { author: UserDto }, "id">>(); // lotusList.create()로 더미데이터 추가하기 insertLotus(); // public lotus 목록 조회 export const getPublicLotusList = async ({ request }: { request: StrictRequest<DefaultBodyType> }) => { const url = new URL(request.url); const page = Number(url.searchParams.get("page")) || 1; const size = Number(url.searchParams.get("size")) || 5; const lotuses = await lotusList.findMany({ page, size }); return HttpResponse.json({ lotuses, page: { current: page, max: 5, }, }); };
이를 통해 백엔드의 API 개발이 완료되지 않은 상태에서도 프론트에서 최대한 개발과 테스트를 진행할 수 있었습니다.
모킹을 통해 비즈니스 로직을 미리 작성하고 분리해볼 수 있었다는 점이 가장 좋았습니다.
특히, 인증이 필요한 요청에 대한 로직이나 tanstack query를 미리 적용해보고 테스트해볼 수 있어서 나중에 실제 API를 연결할 때 빠르게 진행할 수 있었습니다!
https://oliveyoung.tech/blog/2024-01-23/msw-frontend/
https://fe-developers.kakaoent.com/2022/221208-service-worker/