Next.js 15와 MSW v2로 더 나은 API 모킹 구현하기

2024-12-19조회수 0
NextJs
Post thumbnail

1. 개요

프론트엔드 개발 생태계는 정말 빠르게 변화하고 있습니다. Next.js는 벌써 버전 15까지 나왔습니다. 이런 변화의 속도를 따라가는 것이 때로는 버겁게 느껴질 수 있습니다.(개발자의 숙명인 거 같습니다) 특히 MSW는 프론트엔드 개발에서 필수적인 API 모킹 도구로 자리 잡았는데, v1에서 v2로의 변화가 꽤 큽니다.

기존 코드를 마이그레이션 하시느라 고생하신 분들도 많으실 것 같습니다. 이번 글에서는 Next.js 15 환경에서 MSW v2를 처음부터 차근차근 도입하는 방법을 다뤄보도록 하겠습니다.

특히 MSW는 프론트엔드 개발에서 필수적인 API 모킹 도구로 자리 잡았는데, v1에서 v2로의 변화가 꽤 큽니다. 기존 코드를 마이그레이션 하시느라 고생하신 분들도 많으실 것 같습니다. 이번 글에서는 Next.js 15 환경에서 MSW v2를 처음부터 차근차근 도입하는 방법을 다뤄보도록 하겠습니다.

2. 왜 MSW인가?

diff timeline

일반적인 프로젝트 개발 흐름은 이렇습니다:

  1. 기획자의 기획/요구사항 확정
  2. 백엔드 개발자와 API 스펙 논의
  3. Swagger나 REST Docs 등으로 API 문서화
  4. 프론트엔드 개발 시작

하지만 현실에서는 이런 상황들을 자주 마주치게 됩니다:

  • API 스펙만 나왔는데 실제 API는 아직 준비가 안 된 경우
  • 백엔드 개발이 지연되어 프론트엔드 개발이 블로킹 되는 상황
  • 특정 API 응답에 따른 다양한 UI 상태를 테스트해야 할 때
  • 에러 케이스나 로딩 상태를 안정적으로 테스트하고 싶을 때

"그냥 하드코딩된 더미 데이터를 쓰면 되지 않나요?"라고 생각하실 수 있습니다. 하지만 이런 방식에는 몇 가지 문제가 있습니다:

1.실제 API 호출 흐름과 달라집니다

  • 네트워크 요청이 실제로 발생하지 않아 실제 환경과 차이가 있습니다
  • 나중에 실제 API로 교체할 때 많은 코드 수정이 필요합니다

2.다양한 상황 테스트가 어렵습니다

  • 로딩 상태나 에러 상황을 재현하기 어렵습니다
  • 네트워크 지연을 시뮬레이션할 수 없습니다

3.팀 협업이 어려워집니다

  • 프론트엔드 개발자마다 각자의 더미 데이터를 사용하게 됩니다
  • 백엔드 API 스펙과의 정합성을 보장하기 어렵습니다

이럴 때 MSW는 완벽한 해결책이 됩니다. MSW는 실제 네트워크 수준에서 요청을 가로채서 모의 응답을 만들어주기 때문에:

  1. 실제 API와 동일한 방식으로 개발할 수 있습니다
  2. API 스펙에 맞춘 일관된 모의 응답을 팀원들과 공유할 수 있습니다
  3. 다양한 상황(에러, 로딩, 지연)을 쉽게 시뮬레이션할 수 있습니다
  4. 실제 API로의 전환이 매우 쉽습니다

자, 이제 본격적으로 MSW v2를 Next.js 15 프로젝트에 어떻게 설정하는지 알아보도록 하겠습니다.

3. MSW 설치 및 기본 설정하기

이제 본격적으로 MSW를 Next.js 15 프로젝트에 설정하는 방법을 알아보겠습니다.

이제 본격적으로 MSW를 Next.js 15 프로젝트에 설정하는 방법을 알아보겠습니다. 먼저 프로젝트의 전체적인 구조를 살펴보겠습니다

├── README.md
├── components.json
├── jest.config.ts
├── jest.setup.ts
├── next-env.d.ts
├── next.config.ts
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── public
│   ├── file.svg
│   ├── globe.svg
│   ├── mockServiceWorker.js
│   ├── next.svg
│   ├── vercel.svg
│   └── window.svg
├── src
│   ├── __mocks__
│   │   ├── browser.ts
│   │   ├── handlers
│   │   │   ├── auth.handlers.ts
│   │   │   └── index.ts
│   │   └── server.ts
│   ├── __tests__
│   │   ├── constants
│   │   │   └── authTest.ts
│   │   └── helpers
│   │       ├── test-setup.ts
│   │       └── testUtils.tsx
│   ├── app
│   │   ├── favicon.ico
│   │   ├── globals.css
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   └── signin
│   │       ├── _component
│   │       └── page.tsx
│   ├── contatns
│   │   ├── api.ts
│   │   └── validation.ts
│   └── providers
│       ├── MSWProviders.tsx
│       └── providers.tsx

1. 패키지 설치하기

먼저 MSW를 개발 의존성으로 설치합니다:

npm install msw --save-dev
 또는
yarn add -D msw
 또는
pnpm add -D msw

2. Service Worker 생성하기

MSW는 브라우저에서 Service Worker를 통해 네트워크 요청을 가로챕니다. Next.js의 public 디렉토리에 Service Worker를 생성해야 합니다: Service Workers는 HTTPS를 통해 제공되도록 되어 있지만, 브라우저는 로컬호스트에서 개발하는 동안 HTTP에서 Workers를 등록할 수 있도록 허용합니다.

npx msw init public/ --save

이 명령어를 실행하면 public/mockServiceWorker.js 파일이 생성됩니다. 이 파일의 역할을 이해해보겠습니다:

Service Worker 파일은 왜 필요한가?

1.요청 가로채기

  • 브라우저에서 발생하는 실제 네트워크 요청을 가로채는 역할
  • 개발 서버로 가는 API 요청들을 중간에서 캐치합니다

2.모의 응답 전달

  • 가로챈 요청에 대해 우리가 정의한 모의 응답을 반환합니다

주의: 이 명령어는 Service Worker 파일을 생성만 할 뿐, 실제 Service Worker의 등록과 활성화는 다음 장에서 다룰 MSW Provider 구현을 통해 이루어집니다.

설정이 제대로 되었는지 확인하려면 브라우저에서 Service Worker 파일에 직접 접근해볼 수 있습니다:

  • 개발 서버 실행 중에 http://localhost:3000/mockServiceWorker.js 접속
  • 파일 내용이 보인다면 정상적으로 생성된 것입니다
  • 404나 MIME 타입 에러가 발생한다면 PUBLIC_DIR 경로나 정적 파일 설정을 확인해보세요

3. Mock 핸들러 구현하기

이제 실제로 API 요청을 가로채서 모의 응답을 반환할 핸들러를 구현해보겠습니다. 특히 MSW v2에서는 타입 시스템이 크게 개선되었습니다. 로그인 API를 예시로 살펴보겠습니다:

// src/mocks/handlers.ts
import { HttpResponse, http } from 'msw';

// 요청/응답 타입 정의
type LoginRequest = {
  email: string;
  password: string;
};

type LoginResponse = {
  message: string;
  data: {
    success: boolean;
    token?: string;
  }
};

export const authHandlers = [
  http.post<never, LoginRequest, LoginResponse>(
    '/auth/login', 
    async ({ request }) => {
      const { email, password } = await request.json();

      if (!email || !password) {
        return HttpResponse.json(
          {
            message: 'Email and password are required',
            data: { success: false }
          },
          { status: 400 }
        );
      }

      return HttpResponse.json(
        {
          message: 'Login successful',
          data: { 
            success: true,
            token: 'eyJhbGciOiJ...' 
          }
        },
        { status: 200 }
      );
    }
  ),
];

MSW의 http 메서드는 세 가지 제네릭 타입 파라미터를 받을 수 있습니다:

http.post<PathParams, RequestBody, ResponseBody>

1.PathParams: URL 경로 파라미터의 타입

// 예: /users/:id에서 id의 타입 정의
type UserParams = {
  id: string
}

http.get<UserParams>('/users/:id', ({ params }) => {
  // params.id의 타입이 string으로 추론됨
  const { id } = params;
  return HttpResponse.json({ id, name: '김철수' });
})

2.RequestBody: 요청 본문 타입 명시적으로 타입을 지정하지 않으면 DefaultBodyType을 기본값으로 사용합니다. any와 비슷하게 동작합니다. body값이 있으면 명시적으로 넣어주는게 좋습니다.

type LoginRequest = {
  email: string;
  password: string;
}

http.post<never, LoginRequest>('/auth/login', async ({ request }) => {
  const body = await request.json(); // LoginRequest
  // body의 타입이 LoginRequest로 추론됨
})

3.ResponseBody: 응답 본문의 타입

type LoginResponse = {
  message: string;
  data: {
    success: boolean;
    token?: string;
  }
}

http.post<never, LoginRequest, LoginResponse>('/auth/login', async ({ request }) => {
  return HttpResponse.json({
    message: 'Login successful',
    data: {
      success: true,
      token: 'abc.xyz.123'
    }
  })
})

4. MSW 브라우저 설정하기

이제 핸들러를 등록하고 MSW를 브라우저에서 활성화하는 설정을 해보겠습니다. src/__mocks__/browser.ts 파일을 생성하고 다음과 같이 작성합니다:

// __mocks_/handlers/index.ts
import { authHandlers } from '@/__mocks__/handlers/auth.handlers';

export const handlers = [...authHandlers];
// src/__mocks__/browser.ts
import { setupWorker } from 'msw/browser';
import { handlers } from '@/__mocks__/handlers';

const worker = setupWorker(...handlers);
export default worker;

여기서 setupWorker 함수는 Service Worker를 설정하고 우리가 정의한 핸들러들을 등록하며 MSW를 시작할 수 있는 worker 인스턴스를 생성합니다.

ervice Worker는 웹 애플리케이션의 핵심 JavaScript 코드와 별개로 실행되는 이벤트 기반 워커입니다. 브라우저가 백그라운드에서 실행하는 스크립트로, 웹 페이지와는 별도로 동작합니다.

5. MSW Provider 생성하기

이제 MSW를 실제로 활성화할 Provider를 구현해보겠습니다. 다음은 Next.js 15와 함께 사용할 수 있는 견고한 MSW Provider 구현입니다:

// src/app/providers.tsx
'use client';

import React, { Suspense, use } from 'react';
import { handlers } from '@/__mocks__/handlers';

const mockingEnabledPromise =
 typeof window !== 'undefined'
   ? import('@/__mocks__/browser').then(async ({ default: worker }) => {
       // 프로덕션 또는 MSW 비활성화 설정 시 조기 반환
       if (
         process.env.NODE_ENV === 'production' ||
         process.env.NEXT_PUBLIC_MSW_ENABLED === 'false'
       ) {
         return;
       }

       // MSW 시작 및 설정
       await worker.start({
         onUnhandledRequest(request, print) {
           // Next.js 내부 요청은 무시
           if (request.url.includes('_next')) {
             return;
           }
           // 설정된 API URL에 대한 요청만 처리
           if (!request.url.includes(process.env.NEXT_PUBLIC_API_URL || '')) {
             return;
           }
           print.warning();
         },
       });

       // 핸들러 등록
       worker.use(...handlers);
       
       // HMR 지원
       (module as any).hot?.dispose(() => {
         worker.stop();
       });
       
       // 등록된 핸들러 로깅
       console.log('[MSW] Registered handlers:', worker.listHandlers());
     })
   : Promise.resolve();

export function MSWProvider({
 children,
}: Readonly<{
 children: React.ReactNode;
}>) {
 return (
   <Suspense fallback={null}>
     <MSWProviderWrapper>{children}</MSWProviderWrapper>
   </Suspense>
 );
}

function MSWProviderWrapper({
 children,
}: Readonly<{
 children: React.ReactNode;
}>) {
 use(mockingEnabledPromise);
 return children;
}

RootLayout에 Providers 작성하기

// layout.tsx
export default function RootLayout({
                                     children,
                                   }: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
    <body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
  <Providers>{children}</Providers>
  </body>
  </html>
);
}

.env.development 설정하기

NEXT_PUBLIC_MSW_ENABLED=true

정상적으로 실행중이라면 개발자도구 Console 탭에 [MSW] Mocking enabled.이 정상적으로 나와야 합니다.

4. MSW 실제 사용 예시

1. 로그인 페이지

먼저 Next.js로 구현된 로그인 페이지입니다:

// src/app/signin/page.tsx
import LoginForm from '@/app/signin/_component/LoginForm';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';

export default function Page() {
 return (
   <div className="flex min-h-screen items-center justify-center">
     <Card className="w-[350px]">
       <CardHeader>
         <CardTitle>로그인</CardTitle>
       </CardHeader>
       <CardContent>
         <LoginForm />
       </CardContent>
     </Card>
   </div>
 );
}

2. 로그인 폼

// src/app/signin/_component/LoginForm.tsx
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { authSchema } from '@/schemas/auth.schema';
import type { LoginType } from '@/schemas/auth.schema';

export default function LoginForm() {
  const form = useForm<LoginType>({
    resolver: zodResolver(authSchema),
    defaultValues: {
      email: '',
      password: '',
    },
  });

  const handleLogin = async (values: LoginType) => {
    const response = await fetch(`/auth/login`, {
      method: 'POST',
      body: JSON.stringify(values),
    });
    const result = await response.json();
  };

  const onSubmit = async (values: LoginType) => {
    await handleLogin(values);
  };

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>이메일</FormLabel>
              <FormControl>
                <Input placeholder="email@example.com" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="password"
          render={({ field }) => (
            <FormItem>
              <FormLabel>비밀번호</FormLabel>
              <FormControl>
                <Input type="password" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit" className="w-full">
          로그인
        </Button>
      </form>
    </Form>
  );
}

3. MSW 핸들러 구현

이제 이 로그인 폼의 API 요청을 처리할 MSW 핸들러를 구현해보겠습니다:

// src/__mocks__/handlers.ts
import { http, HttpResponse } from 'msw'

export type LoginRequest = {
  email: string;
  password: string;
};

export const authHandlers = [
  http.post<never, LoginRequest>('/auth/login', async ({ request }) => {
    const { email, password } = await request.json();

    // 유효성 검사
    if (!email || !password) {
      return HttpResponse.json(
        {
          message: '이메일과 비밀번호를 모두 입력해주세요.',
          data: { success: false },
        },
        { status: 400 }
      );
    }

    // 테스트용 계정 체크
    if (email === 'test@example.com' && password === 'password123') {
      return HttpResponse.json(
        {
          message: '로그인 성공',
          data: { 
            success: true,
            token: 'mock_token_123',
            user: {
              id: 1,
              email: 'test@example.com',
              name: '테스트 유저'
            }
          },
        },
        { status: 200 }
      );
    }

    // 로그인 실패
    return HttpResponse.json(
      {
        message: '이메일 또는 비밀번호가 일치하지 않습니다.',
        data: { success: false },
      },
      { status: 401 }
    );
  }),
];

이 MSW 핸들러는 실제 백엔드 API와 동일한 형태의 요청/응답 처리와 다양한 시나리오 테스트 가능합니다.

이렇게 MSW를 사용하면 백엔드 API가 준비되기 전에도 실제와 동일한 방식으로 API 통신 로직을 구현하고 다양한 상황에 대한 UI 처리를 테스트할 수 있으며 나중에 실제 API로의 전환도 매우 쉽습니다.

5. MSW 테스트 작성하기

MSW는 테스트 환경에서도 강력한 기능을 제공합니다. 로그인 기능을 예시로 테스트 작성 방법을 살펴보겠습니다.

1. 테스트 환경 설정

테스트 관련 패키지 설치

npm i -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event @types/jest ts-node

먼저 Jest와 Testing Library 환경에서 MSW를 설정해보겠습니다:

// src/mocks/server.ts
import { setupServer } from 'msw/node'
import { authHandlers } from './handlers'

export const server = setupServer(...authHandlers)

jest.setup.ts 입니다.

// jest.setup.ts
import '@testing-library/jest-dom';
import { server } from '@/__mocks__/server';

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

jest.config.ts 파일입니다.

// jest.config.ts
import nextJest from 'next/jest';
import type { Config } from 'jest';

const createJestConfig = nextJest({
    dir: './',
})

const customJestConfig: Config = {
    setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
    testEnvironment: 'jest-fixed-jsdom',
    testMatch: [
        '**/__tests__/**/*.test.[jt]s?(x)', // .test.tsx 파일만 테스트 파일로 인식
        '**/?(*.)+(spec|test).[jt]s?(x)',
    ],
};

export default createJestConfig(customJestConfig);

tsconfig.ts 파일입니다.

"jest": Jest의 전역 타입을 인식하기 위해 필요

"@testing-library/jest-dom": Jest DOM 확장 매처의 타입을 위해 필요

{
  "compilerOptions": {
    // ... 다른 설정들
    "types": ["jest", "@testing-library/jest-dom"]
  }
}

LoginForm.test.tsx 입니다.

import LoginForm from '@/app/signin/_component/LoginForm';
import { screen } from '@testing-library/dom';
import { AUTH_VALIDATION } from '@/contatns/validation';
import { TEST_AUTH } from '@/__tests__/constants/authTest';
import { render } from '@/__tests__/helpers/testUtils';
import { formHelpers } from '@/__tests__/helpers/test-setup';

describe('LoginForm', () => {
  describe('LoginForm Email Validation', () => {
    const invalidEmailTestCases = [
      {
        scenario: '특수문자가 포함된 경우',
        email: 'test!@#$%@example.com',
        expectedError: AUTH_VALIDATION.INVALID_EMAIL,
      },
      {
        scenario: '@가 없는 경우',
        email: 'testexample.com',
        expectedError: AUTH_VALIDATION.INVALID_EMAIL,
      },
      {
        scenario: '도메인이 없는 경우',
        email: 'test@',
        expectedError: AUTH_VALIDATION.INVALID_EMAIL,
      },
      {
        scenario: '공백이 포함된 경우',
        email: 'test @ example.com',
        expectedError: AUTH_VALIDATION.INVALID_EMAIL,
      },
    ];

    test.each(invalidEmailTestCases)('$scenario - $email', async ({ expectedError, email }) => {
      const { user } = render(<LoginForm />);

      await user.type(screen.getByLabelText('이메일'), email);
      await user.click(screen.getByRole('button', { name: '로그인' }));

      expect(screen.getByText(expectedError)).toBeInTheDocument();
    });

    it('이메일이 올바른 형식이면 에러가 표시가 되지 않아야 함', async () => {
      const { user } = render(<LoginForm />);
      await user.type(screen.getByLabelText('이메일'), TEST_AUTH.VALID.EMAIL);
      await user.click(screen.getByRole('button', { name: '로그인' }));

      expect(screen.queryByText(AUTH_VALIDATION.INVALID_EMAIL)).not.toBeInTheDocument();
    });
  });

  describe('LoginForm Password Validation', () => {
    const { getPasswordInput, getSubmitButton, getErrorMessage } = formHelpers;
    const invalidPasswordTestCases = [
      {
        scenario: '비밀번호가 길이가 6미만 인 경우 ',
        password: 'pass',
        expectedError: AUTH_VALIDATION.PASSWORD_MIN_LENGTH,
      },
      {
        scenario: '숫자가 포함되지 않은경우',
        password: 'password',
        expectedError: AUTH_VALIDATION.PASSWORD_PATTERN,
      },
    ];

    test.each(invalidPasswordTestCases)(
      `$scenario-$password`,
      async ({ password, expectedError }) => {
        const { user } = render(<LoginForm />);
        await user.type(getPasswordInput(), password);
        await user.click(getSubmitButton());
        expect(screen.getByText(expectedError)).toBeInTheDocument();
      }
    );

    it('비밀번호에 아무것도 입력안했을때 에러메세지가 나타나야함', async () => {
      const { user } = render(<LoginForm />);

      await user.click(getSubmitButton());
      expect(getPasswordInput()).toHaveValue('');

      expect(getErrorMessage(AUTH_VALIDATION.PASSWORD_REQUIRED)).toBeInTheDocument();
    });
  });
});

자세한 문법의 내용은 생략하고 다음 블로글에 작성을 하도록 하겠습니다. npm run jest 또는 npm run jest:watch를 입력해서 테스트 통과여부를 확인 하면됩니다.

참고자료

Mocking으로 생산성까지 챙기는 FE 개발

MSW 공식문서