Skip to content
nakyong82 edited this page Dec 1, 2024 · 1 revision

📌 사용 배경

🥲

프론트가 UI의 대략적인 레이아웃을 개발한 후에 실제 API가 백엔드에서 개발되는 시간이 더 필요해서 기다려야 했습니다

따라서 실제 API가 개발되기 전에 프론트에서 테스트할 가짜 API가 필요하게 되어 찾아보게 되었습니다

Mock 데이터 vs Mock API vs MSW(Mock Service Worker)


1️⃣ Mock 데이터

실제 API 호출 없이 데이터만을 미리 정의하여 사용하는 방식

  • 주로 JSON 형태의 정적 데이터 파일이나 하드코딩된 객체 형태로 제공
  • 간단하고 설정이 필요 없으며, 데이터만 있으면 빠르게 사용
  • 동적 요청이나 다양한 시나리오를 테스트하는 데 한계

2️⃣ Mock API

서버의 API와 유사한 인터페이스를 가진 함수를 만들어, 서버의 응답을 흉내 내는 방식

  • 주로 서버 없이 클라이언트 측에서 동작하도록 fetchaxios 호출을 모킹하여 사용
  • 클라이언트 코드에서 서버 인터페이스를 동일하게 유지하며 테스트
  • 실제 네트워크의 특성을 완벽하게 흉내 내기 어려움
  • 특정 HTTP 메서드에 따라 동작을 세분화하기 위해 추가적인 코드 작성이 필요

3️⃣ MSW(Mock Service Worker) ⭐️

브라우저 또는 Node.js 환경에서 서비스 워커(Service Worker)를 활용하여 요청을 가로채고 이를 가상 서버에서 응답하는 방식

  • HTTP 요청을 모킹(mocking)하여 실제 서버와 통신하지 않고, 지정된 핸들러를 통해 응답을 제공
  • 실제 네트워크 요청과 동일한 방식으로 동작하기 때문에 API 호출과 관련된 다양한 시나리오를 쉽게 테스트
  • 설정이 필요하고 서비스 워커의 동작 원리를 알아야 제대로 활용 가능

📌 MSW란?

클라이언트가 HTTP 요청을 전송하면 Service Worker가 요청을 가로챈(intercept) 후 Mocking된 응답 값을 반환함으로써 서버와의 통신을 모방하는 오픈소스 라이브러리

Service Worker란?


하나 이상의 페이지를 제어하는 스크립트

  • 자신이 제어하는 페이지에서 발생하는 이벤트를 수신 가능

image

  • 서비스 워커는 브라우저와 네트워크 사이의 새로운 계층에 존재 → 네트워크 요청을 가로채서 리소스를 캐싱할 수 있음

MSW 세팅하기


공식 문서에 엄청 잘 나와있다!

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

📌 MSW 사용하기

HTTP Request 처리하기


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를 연결할 때 빠르게 진행할 수 있었습니다!

📌 Reference

https://oliveyoung.tech/blog/2024-01-23/msw-frontend/

https://fe-developers.kakaoent.com/2022/221208-service-worker/

https://white-blank.tistory.com/185

https://kyechan99.github.io/post/lib/msw

Clone this wiki locally