트위터 클론 코딩 - MSW

김재한·2024년 2월 1일
0

트위터 클론코딩

목록 보기
3/6
post-custom-banner

Mock Service Worker

프론트 개발자가 임시로 API를 만들 수 있도록 도와주는 라이브러리이다. 현재 Next14 버전을 완벽하게 지원하는 것은 아니지만 사용할만한 수준이다.

백앤드 API가 완성되지 않은 상태에서 화면 개발을 해야하거나, API 에러가 발생한 경우를 만들어 테스트 할 때 주로 사용한다.

설치

npx msw init public/ --save
npm install msw --save-dev

public 폴더 안에 mockServiceWorker.js 파일이 생성된다.
이 파일은 요청하는 request 를 가로채서 내부 로직에 따라 response 해주게 된다.

/src/mocks/https.ts

Next 는 서버단에서도 돌아가기 때문에 MSW 가 서버에서도 실행되어야 하지만 현재는 지원하고 있지않아 노드 서버를 이용한다.

npm i -D @mswjs/http-middleware express cors
npm i --save-dev @types/express @types/cors

// /src/mocks/https.ts
import { createMiddleware } from '@mswjs/http-middleware';
import express from 'express';
import cors from 'cors';
import { handlers } from './handlers';

const app = express();
const port = 9090;

app.use(cors({ origin: 'http://localhost:3000', optionsSuccessStatus: 200, credentials: true }));
app.use(express.json());
app.use(createMiddleware(...handlers));
app.listen(port, () => console.log(`Mock server is running on port: ${port}`));

/src/mocks/browser.ts

Client 에서 실행되는 환경이다.

// /src/mocks/brower.ts
import {setupWorker} from "msw/browser";
import {handlers} from "@/mocks/handlers";

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

export default worker

/src/mocks/handlers.ts

실제 response 내용을 작성하는 파일이다. http.tsbrowser.ts 가 이 핸들러를 사용한다.

// src/mocks/handlers.ts

import {http, HttpResponse, StrictResponse} from 'msw'
import {faker} from "@faker-js/faker";

function generateDate() {
  const lastWeek = new Date(Date.now());
  lastWeek.setDate(lastWeek.getDate() - 7);
  return faker.date.between({
    from: lastWeek,
    to: Date.now(),
  });
}
const User = [
  {id: 'elonmusk', nickname: 'Elon Musk', image: '/yRsRRjGO.jpg'},
  {id: 'mju6013', nickname: 'jae_han_e', image: '/5Udwvqim.jpg'},
  {id: 'zerohch0', nickname: '제로초', image: '/5Udwvqim.jpg'},
]
const Posts = [];
const delay = (ms: number) => new Promise((res) => {
  setTimeout(res, ms);
})

export const handlers = [
  //   로그인
  http.post('/api/login', () => {
    console.log('로그인');
    return HttpResponse.json(User[1], {
      headers: {
        'Set-Cookie': 'connect.sid=msw-cookie;HttpOnly;Path=/'
      }
    })
  }),

  //   로그아웃
  http.post('/api/logout', () => {
    console.log('로그아웃');
    return new HttpResponse(null, {
      headers: {
        'Set-Cookie': 'connect.sid=;HttpOnly;Path=/;Max-Age=0'
      }
    })
  }),

  //   회원가입
  http.post('/api/users', async ({ request }) => {
    console.log('회원가입');
    // return HttpResponse.text(JSON.stringify('user_exists'), {
    //   status: 403,
    // })
    return HttpResponse.text(JSON.stringify('ok'), {
      headers: {
        'Set-Cookie': 'connect.sid=msw-cookie;HttpOnly;Path=/;Max-Age=0'
      }
    })
  }),

  //   추천 게시물 조회
  http.get('/api/postRecommends', async ({ request }) => {
    await delay(3000);
    const url = new URL(request.url)
    const cursor = parseInt(url.searchParams.get('cursor') as string) || 0
    return HttpResponse.json(
      [
        {
          postId: cursor + 1,
          User: User[0],
          content: `${cursor + 1} Z.com is so marvelous. I'm gonna buy that.`,
          Images: [{imageId: 1, link: faker.image.urlLoremFlickr()}],
          createdAt: generateDate(),
        },
        {
          postId: cursor + 2,
          User: User[0],
          content: `${cursor + 2} Z.com is so marvelous. I'm gonna buy that.`,
          Images: [
            {imageId: 1, link: faker.image.urlLoremFlickr()},
            {imageId: 2, link: faker.image.urlLoremFlickr()},
          ],
          createdAt: generateDate(),
        },
        {
          postId: cursor + 3,
          User: User[0],
          content: `${cursor + 3} Z.com is so marvelous. I'm gonna buy that.`,
          Images: [],
          createdAt: generateDate(),
        },
        {
          postId: cursor + 4,
          User: User[0],
          content: `${cursor + 4} Z.com is so marvelous. I'm gonna buy that.`,
          Images: [
            {imageId: 1, link: faker.image.urlLoremFlickr()},
            {imageId: 2, link: faker.image.urlLoremFlickr()},
            {imageId: 3, link: faker.image.urlLoremFlickr()},
            {imageId: 4, link: faker.image.urlLoremFlickr()},
          ],
          createdAt: generateDate(),
        },
        {
          postId: cursor + 5,
          User: User[0],
          content: `${cursor + 5} Z.com is so marvelous. I'm gonna buy that.`,
          Images: [
            {imageId: 1, link: faker.image.urlLoremFlickr()},
            {imageId: 2, link: faker.image.urlLoremFlickr()},
            {imageId: 3, link: faker.image.urlLoremFlickr()},
          ],
          createdAt: generateDate(),
        },
      ]
    )
  }),

  //   팔로우 사용자 게시물 조회
  http.get('/api/followingPosts', async ({ request }) => {
    await delay(3000);
    return HttpResponse.json(
      [
        {
          postId: 1,
          User: User[0],
          content: `${1} Stop following me. I'm too famous.`,
          Images: [{imageId: 1, link: faker.image.urlLoremFlickr()}],
          createdAt: generateDate(),
        },
        {
          postId: 2,
          User: User[0],
          content: `${2} Stop following me. I'm too famous.`,
          Images: [{imageId: 1, link: faker.image.urlLoremFlickr()}],
          createdAt: generateDate(),
        },
        {
          postId: 3,
          User: User[0],
          content: `${3} Stop following me. I'm too famous.`,
          Images: [{imageId: 1, link: faker.image.urlLoremFlickr()}],
          createdAt: generateDate(),
        },
        {
          postId: 4,
          User: User[0],
          content: `${4} Stop following me. I'm too famous.`,
          Images: [{imageId: 1, link: faker.image.urlLoremFlickr()}],
          createdAt: generateDate(),
        },
        {
          postId: 5,
          User: User[0],
          content: `${5} Stop following me. I'm too famous.`,
          Images: [{imageId: 1, link: faker.image.urlLoremFlickr()}],
          createdAt: generateDate(),
        },
      ]
    )
  }),

  //   게시물 조회
  http.get('/api/search/:tag', ({ request, params }) => {
    const { tag } = params;
    return HttpResponse.json(
      [
        {
          postId: 1,
          User: User[0],
          content: `${1} 검색결과 ${tag}`,
          Images: [{imageId: 1, link: faker.image.urlLoremFlickr()}],
          createdAt: generateDate(),
        },
        {
          postId: 2,
          User: User[0],
          content: `${2} 검색결과 ${tag}`,
          Images: [{imageId: 1, link: faker.image.urlLoremFlickr()}],
          createdAt: generateDate(),
        },
        {
          postId: 3,
          User: User[0],
          content: `${3} 검색결과 ${tag}`,
          Images: [{imageId: 1, link: faker.image.urlLoremFlickr()}],
          createdAt: generateDate(),
        },
        {
          postId: 4,
          User: User[0],
          content: `${4} 검색결과 ${tag}`,
          Images: [{imageId: 1, link: faker.image.urlLoremFlickr()}],
          createdAt: generateDate(),
        },
        {
          postId: 5,
          User: User[0],
          content: `${5} 검색결과 ${tag}`,
          Images: [{imageId: 1, link: faker.image.urlLoremFlickr()}],
          createdAt: generateDate(),
        },
      ]
    )
  }),

  //   특정 사용자의 게시물 조회
  http.get('/api/users/:userId/posts', ({ request, params }) => {
    const { userId } = params;
    return HttpResponse.json(
      [
        {
          postId: 1,
          User: User[0],
          content: `${1} ${userId}의 게시글`,
          Images: [{imageId: 1, link: faker.image.urlLoremFlickr()}],
          createdAt: generateDate(),
        },
        {
          postId: 2,
          User: User[0],
          content: `${2} ${userId}의 게시글`,
          Images: [{imageId: 1, link: faker.image.urlLoremFlickr()}],
          createdAt: generateDate(),
        },
        {
          postId: 3,
          User: User[0],
          content: `${3} ${userId}의 게시글`,
          Images: [{imageId: 1, link: faker.image.urlLoremFlickr()}],
          createdAt: generateDate(),
        },
        {
          postId: 4,
          User: User[0],
          content: `${4} ${userId}의 게시글`,
          Images: [{imageId: 1, link: faker.image.urlLoremFlickr()}],
          createdAt: generateDate(),
        },
        {
          postId: 5,
          User: User[0],
          content: `${5} ${userId}의 게시글`,
          Images: [{imageId: 1, link: faker.image.urlLoremFlickr()}],
          createdAt: generateDate(),
        },
      ]
    )
  }),

  //   사용자 조회
  http.get('/api/users/:userId', ({ request, params }): StrictResponse<any> => {
    const {userId} = params;
    const found = User.find((v) => v.id === userId);
    if (found) {
      return HttpResponse.json(
        found,
      );
    }
    return HttpResponse.json({ message: 'no_such_user' }, {
      status: 404,
    })
  }),

  //   특정 게시물 조회
  http.get('/api/posts/:postId', ({ request, params }): StrictResponse<any> => {
    const {postId} = params;
    if (parseInt(postId as string) > 10) {
      return HttpResponse.json({ message: 'no_such_post' }, {
        status: 404,
      })
    }
    return HttpResponse.json(
      {
        postId,
        User: User[0],
        content: `${1} 게시글 아이디 ${postId}의 내용`,
        Images: [
          {imageId: 1, link: faker.image.urlLoremFlickr()},
          {imageId: 2, link: faker.image.urlLoremFlickr()},
          {imageId: 3, link: faker.image.urlLoremFlickr()},
        ],
        createdAt: generateDate(),
      },
    );
  }),

  //   특정 게시물 답변 조회
  http.get('/api/posts/:postId/comments', ({ request, params }) => {
    const { postId } = params;
    return HttpResponse.json(
      [
        {
          postId: 1,
          User: User[0],
          content: `${1} 게시글 ${postId}의 답글`,
          Images: [{imageId: 1, link: faker.image.urlLoremFlickr()}],
          createdAt: generateDate(),
        },
        {
          postId: 2,
          User: User[0],
          content: `${2} 게시글 ${postId}의 답글`,
          Images: [{imageId: 1, link: faker.image.urlLoremFlickr()}],
          createdAt: generateDate(),
        },
        {
          postId: 3,
          User: User[0],
          content: `${3} 게시글 ${postId}의 답글`,
          Images: [{imageId: 1, link: faker.image.urlLoremFlickr()}],
          createdAt: generateDate(),
        },
        {
          postId: 4,
          User: User[0],
          content: `${4} 게시글 ${postId}의 답글`,
          Images: [{imageId: 1, link: faker.image.urlLoremFlickr()}],
          createdAt: generateDate(),
        },
        {
          postId: 5,
          User: User[0],
          content: `${5} 게시글 ${postId}의 답글`,
          Images: [{imageId: 1, link: faker.image.urlLoremFlickr()}],
          createdAt: generateDate(),
        },
      ]
    )
  }),

  //   팔로우 추천
  http.get('/api/followRecommends', ({ request}) => {
    return HttpResponse.json(User);
  }),

  //   트렌드 추천
  http.get('/api/trends', ({ request }) => {
    return HttpResponse.json(
      [
        {tagId: 1, title: '트랜드1', count: 1264},
        {tagId: 2, title: '트랜드2', count: 1264},
        {tagId: 3, title: '트랜드3', count: 1264},
        {tagId: 4, title: '트랜드4', count: 1264},
        {tagId: 5, title: '트랜드5', count: 1264},
        {tagId: 6, title: '트랜드6', count: 1264},
        {tagId: 7, title: '트랜드7', count: 1264},
        {tagId: 8, title: '트랜드8', count: 1264},
        {tagId: 9, title: '트랜드9', count: 1264},
      ]
    )
  }),
];

실행 명령어 추가

package.json 에 MSW 실행 명령어를 추가해준다.

"mock": npx tsx watch ./src/mocks/http.ts

> npm run mock

/app/_component/MSWComponent.tsx

(afterLogin), (beforeLogin) 모두 적용되어야 하므로 /app/_component 에 위치시키고 /app/layout.tsx 에 추가해준다.

// /app/_component/MSWComponent.tsx

"use client";
import { useEffect } from "react";

export const MSWComponent = () => {
    useEffect(() => {
        if (typeof window !== 'undefined') {
            if (process.env.NEXT_PUBLIC_API_MOCKING === "enabled") {
                require("@/mocks/browser");
            }
        }
    }, []);

    return null;
};
// /app/layout.tsx
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import {MSWComponent} from "@/app/_component/MSWComponent";
import AuthSession from "@/app/_component/AuthSession";

const inter = Inter({ subsets: ['latin'] })

export const metadata: Metadata = {
    title: 'Z. 무슨 일이 일어나고 있나요? / Z',
    description: 'Z.com inspired by X.com',
}

type Props = {
    children: React.ReactNode
}
export default function RootLayout({children}: Props) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <MSWComponent/> // MSW 적용
        <AuthSession>
            {children}
        </AuthSession>
      </body>
    </html>
  )
}

환경변수 추가

개발환경에서만 MSW를 사용하면 되므로 .env.local 파일에 환경변수를 추가해준다.
환경변수 앞에 NEXT_PUBLIC_이 붙어 있으면 브라우저에서 접근 가능한 환경변수이고 없으면 서버에서만 접근이 가능하다. ( 브라우저에 노출되지 않는다.)

NEXT_PUBLIC_API_MOCKING = enabled
post-custom-banner

0개의 댓글