The journey to becoming a developer

My future is created by what I do today, not tomorrow.

Projects

(약간은 험난했던) Next.js 13에 MSW 도입기

Millie 2023. 8. 21. 14:08

1. MSW 도입 배경

Next.js 13버전으로 진행 중인 프로젝트에 MSW를 도입하고 싶다는 생각이 들었다.

나는 다른 React 프로젝트에서 MSW를 도입해 유용하게 활용한 적이 있다. 백엔드 팀원들과 함께 API 스펙에 대해서 논의가 끝났는데 아직 구현이 덜 되어 API를 프론트 측에서 사용할 수 없는 상황이라면, 마냥 기다리기엔 시간이 아깝다. 바로 이 때 API를 Mocking한다면, 프론트에서도 기능/UI 구현 작업을 이어나갈 수 있다. 추후 API가 개발된다면, 자연스럽게 실제 API로 대체하면 된다.

또현 현재 나의 프로젝트에는 OAuth 로그인을 도입한 상태다. 로그인 후에는 서버로부터 쿠키를 통해 access token이 발급되는데, 추후 API 요청을 할 때 이 access token을 함께 Request Header에 실어서 보내야 한다.

그런데 개발 환경인 localhost:3000 에서는 실제 도메인과 다르기 때문에, access token을 받을 수가 없다. 그렇기 때문에 추후 API 요청에서도 access token을 실어서 보낼 수가 없다.

그렇다면 이러한 API 요청이 포함된 컴포넌트를 개발할 때마다, Next.js 앱을 실제 도메인에 배포해서 구현이 잘 되었는지 확인을 해야 한다는 것인데, 이는 다음과 같은 문제점이 있다.

  1. 이 방식은 번거롭기도 하고, 결과물을 확인하기까지 시간이 소요된다. 커밋을 하고, 푸시를 하고, 빌드를 기다려야 하는데 최소 2분에서 길면 3~4분까지 소요가 된다.
  2. 막상 결과물을 확인했더니 로컬에서 확인하지 못했던 에러가 발생하는 경우가 있다. 이럴 경우에는 커밋을 reset해서 다시 과거 상태로 돌리고 작업을 하게 되는데 이는 굉장히 비효율적이다.

이러한 불편함과 비효율을 없애 로컬 작업 환경을 쾌적하게 만들기 위해 MSW를 도입하고 싶었다.

그런데 Next.js 13 버전, 그리고 app router를 쓰는 환경에서는 MSW를 도입하는 것이 약간 난관이었는데, 내가 마주한 문제점과 해결책을 정리해 보았다.

이 문제를 해결하는 데 일등 공인은 코드스쿼드 부트캠프의 같은 기수 수료생인 콜라이다. 콜라는 이미 다른 프로젝트에서 이 문제를 해결해 본 경험이 있었고, 나에게 아주 자세하고 친절하게 설명해 주셔서 정말 감사했다.(감사의 의미로 기프티콘을 보내드렸다☕️) 콜라와 이야기를 나누는 과정에서 배운 것이 많아 휘발되지 않도록 그 과정을 정리하고 기록해 보았다.

 

2. MSW의 동작 방식

본격적으로 MSW를 설정하기 전, MSW가 무엇이고 어떤 방식으로 동작하는지 간략하게만 짚고 넘어가보자.

MSW는 무엇이고, 하는 주된 일은 무엇인가?

  • MSW는 Mock Service Worker. Mock은 ‘모의’, ‘모조’, ‘가짜’ 라는 뜻이다.
    • 이름 안에 Service Worker가 있는 것처럼, Web에서 제공하는 Service Worker API를 사용해서 HTTP 요청을 가로챈다.
      • Service Worker: 웹 애플리케이션의 메인 스레드와 분리된 별도의 백그라운드 스레드에서 실행시킬 수 있는 기술. 이를 통해 애플리케이션의 UI Block 없이 연산 처리를 할 수 있다. Service Worker를 통해 네트워크 요청이나 응답을 가로채 조작할 수 있게 된다.
  • MSW는 실제 API 요청이 발생하면, 이 요청을 가로채서 미리 준비해 뒀던 Mock data로 응답해준다.
  • MSW를 사용하면 Mock Server를 따로 구축하지 않아도 API를 Mocking할 수 있어서 효율성이 증대된다.

Mock Service Worker의 request 흐름

  • 아래 도식은 Mock Service Worker의 request가 어떤 식으로 흘러가는지 큰 그림으로 표현한 것이다. 공식문서를 참조했다.

  1. 브라우저에서 요청을 보낸다.
  2. Service Worker는 요청을 받고, 해당 요청을 복제한다. 복제한 요청을 MSW에게 보낸다.
  3. MSW는 요청과 일치하는 목업을 생성한다.
  4. MSW가 모킹한 응답을 Service Worker에게 보낸다.
  5. Service Worker가 모킹된 응답을 브라우저에게 보내고, 브라우저가 최종적으로 이 응답을 받는다.

 

3. MSW 세팅 과정

  • MSW가 어떤 흐름으로 Mocking을 해 주는 것인지 파악했으니, 이제 본격적으로 프로젝트에 세팅을 할 차례다.

참고: https://mswjs.io/docs/getting-started/integrate/browser

  1. MSW를 설치하고, public 폴더에 mockServiceWorker 파일을 생성한다.
// install MSW
npm install msw --save-dev

// create mockServiceWorker file in public folder 
npx msw init public/ --save

2. Handler 함수들을 만들어서 API를 모킹한다.

  • REST API 방식, GraphQL API 방식 중에 선택해서 API를 모킹할 수 있다.
  • 나는 REST API 방식이어서 msw에서 rest를 import한 후 사용했다.
// src/mocks/checklist/handler.ts
import { rest } from 'msw';

import { API_PATH } from '@/constants/paths/apiPath';

import type { RestRequest, ResponseComposition, DefaultBodyType, RestContext } from 'msw';

const getTodayChecklist = (
  req: RestRequest,
  res: ResponseComposition<DefaultBodyType>,
  ctx: RestContext
) => {
  return res(ctx.status(200), ctx.json({ isTodayChecklistCreated: true }));
};

const checklistHandler = [rest.get(API_PATH.todayChecklist, getTodayChecklist)];

export default checklistHandler;
  • /api/checklist/today로 API를 요청하게 되면, { isTodayChecklistCreated: true } 라는 json을 반환하도록 getTodayChecklist 함수를 구현했다.

3. worker를 설정해준다.

// src/mocks/browser.js
import { setupWorker } from 'msw'
import { handlers } from './handlers'

// This configures a Service Worker with the given request handlers.
export const worker = setupWorker(...handlers)

4. worker를 시작한다.

// src/app/layout.tsx
if (process.env.NODE_ENV === 'development') {
  worker.start()
}

공식문서를 따라, 그리고 내가 이전 React 프로젝트에서 했던 것처럼 여기까지 하면 기본적인 세팅은 끝이고, 잘 될 줄 알았으나.. Next.js 13 환경에서는 그렇지 않았다. 에러의 시작이었다.


4. 마주친 문제 1: 서버에서 worker를 시작할 수 없음

  • RootLayout이 프로젝트의 진입점이기 때문에, 여기서 worker를 시작하면 될 거라고 생각했다.
    • NODE_ENV를 활용해 개발 모드일 때에만 worker가 시작될 수 있도록 했다. 하지만 에러가 발생했다.
      • “서버에서는 .start에 접근할 수 없다. 서버 컴포넌트에서는 클라이언트 모듈에 점(.)을 찍을 수 없다. import한 이름만 전달할 수 있다”
      • 즉 worker는 클라이언트 모듈이고 RootLayout은 서버 컴포넌트이기 때문에 위와 같은 에러가 발생하고 있는 것이다.

4-1. 원인: 서버 컴포넌트에서는 클라이언트 모듈을 사용할 수 없다

  • Next.js는 CSR만 하는 게 아니라 SSR도 하기 때문에, 서버에서 실행되는 코드들이 있다.
  • RootLayout은 서버 컴포넌트이기 때문에, 기본적으로 서버에서 렌더링된다.
  • 서버에는 worker가 존재하지 않기 때문에, worker라는 모듈을 찾을 수 없고, 당연히 worker를 시작할 수도 없다.

4-2. 해결 방법: 서버 환경과 브라우저 환경을 분기 처리

  • 서버 환경인지, 브라우저 환경인지에 따라 분기처리를 해서 MSW를 실행해준다.
// mocks/index.ts
export async function initBrowserMocks() {
  if (isBrowser) {
    const { worker } = await import('./browser');
    await worker.start();
  }
}

export async function initServerMocks() {
  if (!isBrowser) {
    const { server } = await import('./server');
    server.listen();
  }
}
  • 여기서 각 모듈을 import 할 때, 일반적으로 파일의 상단에 import를 하는 방식(static import)가 아닌, import() syntax를 활용하여 Dynamic import를 하고 있다.
    • 이 문법을 통해 조건부로 모듈을 로드할 수 있게 된다.
    • 브라우저 환경에서는 브라우저와 관련된 모듈만, 서버 환경에서는 서버에 관련된 모듈만 로드한다.
    • 불필요한 리소스를 로드할 필요가 없게 되어, 초기 로딩 시간을 줄일 수 있다.

 

5. 마주친 문제 2: 컴포넌트가 이미 렌더링 된 후 MSW 초기화

  • 이제 아까와 같은 Server Error가 뜨지 않았다! 모킹도 잘 되는 것 같았다.
  • 그런데 콘솔을 보니 빨갛게 에러가 뜨고 있었다.

  • 보니까 Mocking이 enabled가 되기도 전에 API 요청을 하고 있었다.
  • Mocking enabled 이후에는 정상적으로 응답이 되고 있었다.

5-1. 원인: 컴포넌트가 렌더링 된 후에 MSW가 초기화된다

  • 컴포넌트가 이미 렌더링이 된 후에 MSW가 초기화되기 때문에 위와 같은 상황이 발생한다.
    • 컴포넌트가 렌더링이 되면서 API 요청이 발생하게 되고, 이때는 MSW가 초기화 되어있지 않기 때문에 API Request를 보낸다 하더라도 이것을 가로챌 수가 없다.
    • 따라서 404 에러가 발생한다.

5-2. 해결 방법: 컴포넌트가 렌더링되기 전에 MSW를 초기화

  • 컴포넌트가 렌더링되기 전에 MSW를 초기화시킬 수 있으려면, RootLayout에서 children 컴포넌트들이 렌더링 되기 전에 MSW를 실행할 수 있으면 된다.
    • 그렇다면 MSW를 초기화하는 로직을 담은 컴포넌트를 만들고, 그 컴포넌트로 children을 감싸도록 하면 된다!
// .env.local
NEXT_PUBLIC_API_MOCKING=enabled
  • 환경 변수에 다음과 같이 설정한다. 개발 모드일 때만 enabled로 설정하고, production 모드일 때는 설정해두지 않는다.
// src/mocks/index.ts
export async function initMocks() {
  if (typeof window === 'undefined') {
    const { server } = await import('./server');
    server.listen();
  } else {
    const { worker } = await import('./browser');
    worker.start();
  }
}
// src/mocks/MSWComponent.tsx
'use client';

import { useState, type PropsWithChildren, useEffect } from 'react';

const isMockingMode = process.env.NEXT_PUBLIC_API_MOCKING === 'enabled';

export const MSWComponent = ({ children }: PropsWithChildren) => {
  const [mswReady, setMSWReady] = useState(() => !isMockingMode);

  useEffect(() => {
    const init = async () => {
      if (isMockingMode) {
        const initMocks = await import('./index').then((res) => res.initMocks);
        await initMocks();
        setMSWReady(true);
      }
    };

    if (!mswReady) {
      init();
    }
  }, [mswReady]);

  if (!mswReady) {
    return null;
  }

  return <>{children}</>;
};
  • initMocks 함수를 호출할 때는 await을 붙여서, 이 함수의 실행이 끝날 때까지 기다린 후 다음 코드 라인으로 넘어갈 수 있도록 해야 한다.
    • 이렇게 해야 initMocks 함수 내부의 worker.start()가 호출되고 완료가 될 때까지 기다릴 수 있다.
  • 개발 모드에서 MSWComponent를 실행하면
    1. mswReady 상태 변수의 초기값은 !isMockingMode 값이므로 false가 된다.
    2. if (!mswReady)를 만족하므로, 컴포넌트는 null을 리턴한다.
    3. useEffect의 콜백 함수가 실행된다.
      1. if (!mswReady)를 만족하므로 init 함수를 실행한다.
      2. init 함수가 실행되면 브라우저인지, 서버인지에 따라서 분기처리하여 MSW를 실행한다.
      3. mswReady 상태 변수를 true로 변경해준다.
      4. 상태가 변경되었으니 MSWComponent를 리렌더링한다. children 컴포넌트가 렌더링된다.
  • Production 모드에서 MSWComponent를 실행하면
    1. mswReady 상태 변수의 초기값은 true이다.
    2. <>{children}</> 코드가 실행된다.
    3. useEffect의 콜백 함수가 실행된다.
      1. if (!mswReady)를 만족하지 않으므로 init이 실행되지 않는다.
      2. 결과적으로 아무것도 실행되지 않는다.
  • 이렇게 작성한 MSWComponent를 다음과 같이 RootLayout의 children을 감싸도록 한다.
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <MSWComponent>
          {children}
        </MSWComponent>
      </body>
    </html>
  );
}

이제 정상적으로 children 컴포넌트들이 렌더링되기 전에 MSW를 초기화할 수 있게 되었다!

 

6. 부가적으로 알게 된 것

6-1. Dynamic import

  • 동적으로 import를 한다는 것을 들어본 적은 있지만, 막상 활용을 해 본 적은 없는데 예시를 보니까 딱 감을 잡았다.
  • 서버인지, 클라이언트인지에 따라서 조건부로 로딩을 하고 싶을 때, 혹은 그런 경우가 아니더라도 모듈 크기가 크다면 우선 필요한 모듈만 로드하고 다른 작업을 수행하는 식으로도 활용할 수 있을 것이다.
  • 최적화를 할 때 고려해볼 수 있는 아주 좋은 방안이라는 생각이 들었다.

6-2. PropsWithChildren type

  • 나는 보통 컴포넌트의 Props가 children을 가지고 있어야 할 때, children: ReactNode 이런 식으로 명시적으로 타입을 주곤 했다.
  • 그런데 콜라가 만든 MSWComponent를 참고하니, const MSWComponent = ({ children }: PropsWithChildren) 이런 식으로 활용할 수 있다는 것을 알게 되었다.
    • 이름 자체는 굉장히 명확하다. children을 가지고 있는 props라는 뜻이니 말이다.
  • 각 타입에 대해 궁금해져서 React 팀이 각 타입을 어떻게 정의해 놓았는지를 알아봤다. 실제로 타입들은 다음과 같이 구현되어 있었다.
type ReactNode =
        | ReactElement
        | string
        | number
        | Iterable<ReactNode>
        | ReactPortal
        | boolean
        | null
        | undefined
        | DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_REACT_NODES[keyof DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_REACT_NODES];

type PropsWithChildren<P = unknown> = P & { children?: ReactNode | undefined };
  • PropsWithChildren 타입은 제네릭으로 인자를 받을 수 있다.
    • children 외에 추가적으로 다른 타입이 필요하다면 제네릭으로 전달하면 된다.
  • 다만 PropsWithChildren의 children 타입을 자세히 보면 optional이다.
    • 즉 이 말은, children이 없이 컴포넌트를 사용하더라도 에러가 나지 않는다는 것이다.
    • 만약 children이 반드시 있어야만 하는 컴포넌트라면, PropsWithChildren 타입을 사용하지 않는 편이 나을 수 있겠다.
    • 혹은 다음과 같은 type을 커스텀해서 만들어 사용할 수 있다.
type PropsWithRequiredChildren<P = unknown> = P & { children: ReactNode | undefined }

 

7. MSW를 더 알아보기 위해 참고하면 좋을 자료

현업에서 MSW를 어떻게 사용하는지 알아볼 수 있다.

  • https://tech.kakao.com/2021/09/29/mocking-fe/
    • 카카오에서 MSW를 왜 도입했는지가 자세히 언급되고 있다. MSW를 사용하지 않고 Mocking을 했을 때 어떤 어려움을 겪었는지, MSW는 그 문제를 어떻게 해결해 주었는지가 나와 있다. 특히 MSW를 사용해서 현업에서 개발 과정이 어떻게 달라졌는지 상세히 풀어 쓰여져 있는 부분이 좋았다.
  • https://tech.madup.com/mock-service-worker/
    • API를 호출하는 로직이 포함된 코드를 테스트할 때, Mockup 처리를 하는 것이 매우 번거로워진다는 문제점이 있다. 이러한 문제점을 MSW를 사용하여 어떻게 해결했는지 과정이 담겨 있다.

 

8. 마무리

문제를 해결하기 위해 StackOverFlow에 올라온 글이나 MSW GitHub Repository의 이슈 등도 찾아보면서, 나와 같은 개발 환경에서 MSW를 도입할 때 이러한 이슈를 겪게 된다는 것을 알게 되었다.

콜라 선생님의 도움과 함께 Next.js 13버전, 그리고 app 디렉토리를 사용하는 환경에서도 무사히 MSW를 적용하여 개발을 할 수 있게 되었다. 👏 앞으로 더 효율적으로 개발할 수 있게 되어 기대가 된다.