icon

메티의 블로그

MSW Next.js 에 적용해보기(클라이언트 사이드)
MSW Next.js 에 적용해보기(클라이언트 사이드)

MSW Next.js 에 적용해보기(클라이언트 사이드)

Tags
Frontend
Next.js
MSW
날짜
May 3, 2024
상태
공개
  • Mock Service Worker is an API mocking library that allows you to write client-agnostic mocks and reuse them across any frameworks, tools, and environments.
 
Mock Service Worker (MSW)라이브러리는 백엔드 서버를 Mocking 할 때 사용하기 좋은 JS 라이브러리 입니다. MSW 의 소개글은 다음과 같습니다. 클라이언트에 구애받지 않게 mock 을 만들수 있도록 해주고, 어떤 툴과 환경, 프레임워크에서도 재사용 가능하게 해준다고 합니다. 독립적으로 사용할 수 있는 JS 백엔드 Mock 서버라고 생각하면 될 것 같습니다. MSW 같은 백엔드 mock 서버가 있다면, 프론트엔드 개발이 가진 백엔드 개발의 의존성(package.json 의 그 의존성이 아닌 개발 프로세스 상의 의존성)을 줄일 수 있습니다. 백엔드 개발이 아직 완료되지 않았더라도 프론트엔드 개발은 마치 서버가 완성이 된 것처럼 간단한 통신을 가능하게 해준다는 것입니다.
 

1. 어떻게 해주는가?

클라이언트 사이드에서는 브라우저 제공하는 Service Worker 라는 기능을 사용해 합니다. Service Worker 는 Web API 로 웹 페이지를 보여주는 부분과 별개로 백그라운드에서 실행되어 브라우저 웹 페이지 성능에 도움을 줄 수 있게 도와주는 기능입니다. 해당 기능은 스레드를 따로 할당받기 때문에, 웹 브라우저의 네트워크 요청을 가로채서 웹 애플리케이션이 백엔드에 api 를 호출하는 것을 모의로 할 수 있습니다.
→ 실제로 api 이름도 서버가 아닌 worker 가 됩니다.
서버 사이드에서는 node 의 http 모듈을 통해 들어오는 요청을 가로채고 처리합니다. 마치 미들웨어 처럼 요청을 가로채고 조작하여 모의 응답을 합니다.
→ 실제로 api 이름이 서버입니다.

2. 적용

Next.js 에 적용하기 때문에 클라이언트 사이드와 서버 사이드 양측에 모의 서버를 적용할 수 있어야합니다. 하지만, 현재는 서버 사이드에서의 MSW 적용에 문제가 약간 있어서 일단은 클라이언트 쪽만 먼저 해보려고 합니다. (
Support Next.js 13 (App Router)
Updated May 13, 2024
) Next.js 는 14.2.2 버전을 사용하였습니다. MSW Docs 는 정말 잘 정리되어 있어서, Docs 만 읽고 따라해도 금방 적용할 수 있습니다. 클라이언트 사이드 즉, browser 환경의 경우에는 문제 없이 잘 됩니다.
 
  • 동작방식 이해하기
      1. 설치
        1. npm install msw@latest --save-dev pnpm add -D msw@latest
          msw 는 개발환경에서만 사용할 것이니 devDependency 로 추가합니다.
           
      1. API 작성
        1. // src/mocks/handlers.js import { http, HttpResponse } from 'msw' export const handlers = [ // Intercept "GET https://example.com/user" requests... http.get('https://example.com/user', () => { // ...and respond to them using this JSON response. return HttpResponse.json({ id: 'c7b3d8e0-5e0b-4b0f-8b3a-3b9f4b3d3b3d', firstName: 'John', lastName: 'Maverick', }) }), ]
          예시와 같이 handler 를 작성합니다. 보시면 아시겠지만, msw 에서 제공하는 http api 등의 배열로 제공되므로, 나중에 한번에 통합해서 넣을 수도 있습니다. 이 handlers 변수를 mock 서버 실행하는 api 에 인자로 넣어 시작하면 끝입니다. GraphQL 도 지원합니다.
           
      1. Mock 서버 실행
        1. // src/mocks/node.js import { setupServer } from 'msw/node' import { handlers } from './handlers' export const server = setupServer(...handlers)
          예시와 같이 핸들러 객체를 넣고, 세팅 후 실행하면 됩니다.
       
  • 실제 프로젝트에 적용
    • 위 큰 그림 예시는 동작의 큰 그림을 이해하기 위한 예시일 뿐 Next.js 프로젝트에 그대로 사용할 수 없습니다. 일단 클라이언트 사이드에선 어떻게 적용해야할지, 확인해보겠습니다.
      // src/mocks/api/test/index.ts import { HttpResponse, http } from "msw"; const employeeEntity = new Map<string, TestEmployee>(); export type TestEmployeeParams = { empNumber: string; }; export interface TestEmployee { empNumber: string; name: string; age: number; } export interface TestEmployeeRequest { name: string; age: number; } interface TestEmployeeResponse extends TestEmployee {} export const testController = [ http.get< TestEmployeeParams, TestEmployeeRequest, TestEmployeeResponse[], "/emplyee" >("/emplyee", () => { return HttpResponse.json(Array.from(employeeEntity.values())); }), http.post<never, TestEmployeeRequest, TestEmployeeResponse>( "/employee", async ({ request }) => { const newEmployee = await request.json(); const newEmpNumber = employeeEntity.size + 1 + ""; employeeEntity.set(newEmpNumber, { empNumber: newEmpNumber, ...newEmployee, }); return HttpResponse.json(employeeEntity.get(newEmpNumber), { status: 201, }); } ), http.delete<TestEmployeeParams, never, never, "/employee/:empNumber">( "/employee/:empNumber", ({ params }) => { const employee = employeeEntity.get(params.empNumber); if (employee) { employeeEntity.delete(employee.empNumber); return HttpResponse.json(employee); } else { return HttpResponse.json(null, { status: 404 }); } } ), ];
      GET, POST, DELETE 함수를 같이 테스트 해보기 위해 만든 예시 코드 입니다. 관련 컨트롤러 별로 mock 을 만들 예정이기 때문에, 도메인 별 컨트롤러를 만들고, 추가적으로 나중에 통합하여 서버를 기동합니다. 중간 부분을 삭제하면 동작하지 않는 버그가 있지만, 말그대로 예시 코드라서 더 개선을 하지는 않았습니다.
      // src/mocks/handlers.ts import { testController } from "./api/test"; export const handlers = [...testController]; // export const handlers = [...testController, ...otherController];
      mock 컨트롤러를 한번에 받을 핸들러 파일입니다. 나중에 다른 컨트롤러를 통합할 때 사용합니다. 각기 다른 환경에 같은 핸들러를 만들기 위해 다음과 같은 레이어를 두었습니다.
      // src/mocks/brower.ts import { setupWorker } from "msw/browser"; import { handlers } from "./handlers"; export const worker = setupWorker(...handlers);
      브라우저에서 mock 서버 역할을 해줄 worker 입니다. 이게 이전에 동작방식 이해하기의 마지막 서버 실행하는 부분과 같다고 보면 됩니다.
      // src/mocks/server.ts import { setupServer } from "msw/node"; import { handlers } from "./handlers"; export const server = setupServer(...handlers);
      서버에서 mock 서버 역할을 해줄 서버입니다. 동작방식 이해하기 에서 나온 서버 실행하는 부분과 완전히 같습니다.
      export async function enableMocking() { if (typeof window === "undefined") { const { server } = await import("./server"); server.listen(); } else { const { worker } = await import("./browser"); return worker.start(); } }
      서버와 클라이언트에서 한 api 로 사용하기 위해 통합합니다.
       
      클라이언트 컴포넌트에서 백엔드 api 를 실행 할 때, 다음과 같이 worker 를 실행하는 훅을 만들어, 사용하는 컴포넌트에서 사용합니다.
      "use client"; import { useEffect, useState } from "react"; import { enableMocking } from "."; export function MSWStarter({ children }: { children: React.ReactNode }) { const [enableMSW, setEnableMSW] = useState(false); useEffect(() => { const init = async () => { await enableMocking(); setEnableMSW(true); }; if (!enableMSW) { init(); } }, [enableMSW]); return enableMSW && <>{children}</>; }
      다음과 같이 컴포넌트를 만들어
      export function TestInputForm() const [employees, setEmployees] = useState(); useEffect(() => { const fetch = async () => { const data = await getTestEmployee(); setEmployees(data); }; try { fetch(); } catch (e) {} }, [enabled]); return ( <MSWStarter> <ListWrapper> {employees && employees.map(({ name, age, empNumber }) => ( <TestEmployeeItem key={empNumber} name={name} age={age} empNumber={empNumber} onClickDelete={() => handleDelete(empNumber)} /> ))} </ListWrapper> </MSWStarter> ); }
      다음과 같이 사용할 컴포넌트에서 감싸주면 됩니다.
      하지만 위와 같이 클라이언트 컴포넌트를 상위에 사용해버리면, 하위 노드들 또한 클라이언트 컴포넌트로 변경되게 됩니다. 그러므로, 훅을 만들어 놓고, 필요한 클라이언트 컴포넌트에만 훅을 사용하는 것이 좋을 수도 있겠습니다. (worker.start() 를 매번 호출해서 경고가 뜨는 문제가 있습니다. 이는 추후에 고칠 수 있는 방법을 찾아보겠습니다.)

3. 클라이언트 사이드 적용 테스트

테스트 환경은 실제 서버 사이드 환경, 클라이언트 사이드 환경과 다릅니다. 테스트 환경은 클라이언트 컴포넌트를 테스트 하더라도 node 에서 동작합니다. 저는 vitest 를 통해 테스트를 진행하려고 하였으며, 제가 미리 만들어 두었던 클라이언트 컴포넌트를 테스트 하고자 했습니다.
// vitest.config.ts import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; import path from "path"; export default defineConfig({ plugins: [react()], test: { globals: true, environment: "jsdom", setupFiles: "./vitest.setup.ts", }, resolve: { alias: { "@/*": path.resolve(__dirname, "src/*"), }, }, });
setupFiles 에 만들어둔 msw 적용 로직을 추가할 것입니다.
// vitest.setup.ts import "@testing-library/jest-dom"; import { afterAll, afterEach, beforeAll, expect } from "vitest"; import { server } from "./src/mocks/server"; (global as any).expect = expect; // Mock server setup beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); afterAll(() => server.close());
다음과 같이 vitest 설정에 사용해두면 됩니다.
이후 제가 적용해 두었던 테스트를 실행합니다.
import { describe, expect, it } from "vitest"; import { render, screen } from "@testing-library/react"; import { TestInputForm } from "../../mocks/components/TestInputForm"; import userEvent from "@testing-library/user-event"; describe("MockingTest in client", () => { it("should render correctly", () => { const testInputForm = render(<TestInputForm />); expect(testInputForm).toMatchSnapshot(); }); it("should input correctly", async () => { render(<TestInputForm />); const nameInput = screen.getByPlaceholderText("name"); const ageInput = screen.getByPlaceholderText("age"); const postButton = screen.getByText("POST"); screen.debug(); await userEvent.type(nameInput, "김영수"); await userEvent.type(ageInput, "2"); await userEvent.click(postButton); const item = screen.getByText("사번: 1"); expect(item).toBeInTheDocument(); const deleteButton = screen.getByText("delete"); await userEvent.click(deleteButton); expect(item).not.toBeInTheDocument(); }); });
notion image
TestInputForm 컴포넌트 안에서 직접 api 호출을 하며, GET, POST, DELETE api 를 각각 호출하는 것을 테스트 하였습니다.

4. 정리

mock 서버는 클라이언트 사이드에서 보다는 서버 사이드에서가 더 필요할 것 같기는 하지만, 일단 환경 설정은 해두고, 테스트를 해본 것에 의의를 두었습니다.