본문 바로가기
개발/typescript, web

type safety한 RESTful을 위한 zodios (nextjs13 appDir, monorepo)

by 꾸루루 2022. 12. 16.

zodios는 기존 axios 통신에 zod를 resolver처럼 활용하여 type safety하고 미리 에러 검출을 할 수 있게 해줍니다.

type alias도 지원해주고 zod를 기반으로 타입 추론을 해주므로 안전하고 편한 RESTful이 가능합니다.

클라이언트단을 위한 react-query wrapper도 지원하므로 그야말로 완벽

https://www.zodios.org/

 

Zodios | Zodios

End-to-end typesafe REST API toolbox

www.zodios.org

 

먼저 제 monorepo 패키지 구조는 다음과 같이 되어 있습니다.

 

여기서 dashboard-web에서 nextjs13의 appDir feature을 사용하여 작업하겠습니다.

 

pnpm add @zodios/core @zodios/react @tanstack/react-query zod --filter=dashboard-web

 

 

 

패키지들 설치 후 상수들 정의하고 apiClient와 apiHooks를 생성해줄게요.

 

 

package.json

"scripts": {
  ...
  "dev-mock": "NEXT_PUBLIC_API_MOCKING=enabled next dev -p 3667",
  ...
}

 

 

src > constants > dev.ts

export const serverApiBaseUrl = 'https://...realapi_path...' as const;

export const mockApiHost = 'http://localhost:3667' as const;
export const mockApiPathname = '/api/mock' as const;
export const mockApiBaseUrl = `${mockApiHost}${mockApiPathname}` as const;

export const apiBaseUrl =
  process.env.NEXT_PUBLIC_API_MOCKING === 'enabled'
    ? mockApiBaseUrl
    : serverApiBaseUrl;

 

src > utils > apiClient.ts

import { Zodios } from '@zodios/core';
import { ZodiosHooks } from '@zodios/react';

import { apiBaseUrl } from '@/constants/dev';
// import * as User from '@/domain/User';

export const apiClient = new Zodios(
  apiBaseUrl,
  // API definition
  // [...User.api],
);

export const apiHooks = new ZodiosHooks('myAPI', apiClient);

 

잠시 주석처리 한 것들은, 아래 도메인 api 정의하시고 푸시면 되요.

 

src > domain > User > index.ts

import { makeApi, makeEndpoint } from '@zodios/core';
import { z } from 'zod';

export const schema = z.object({
  id: z.string(),
  name: z.string(),
  profileImage: z.string(),
});

export const getUser = makeEndpoint({
  method: 'get',
  path: '/user/:id',
  alias: 'getUser',
  description: 'Get a user',
  response: schema,
});
export const getUsers = makeEndpoint({
  method: 'get',
  path: '/users',
  alias: 'getUsers',
  description: 'Get a users',
  response: z.array(schema),
});

export const api = makeApi([getUser, getUsers]);

다음과 같이 도메인 스키마를 정해주고, makeEndpoint를 이용하여 수신할 각각의 REST api 엔드포인트를 생성 할게요.

그 다음에 makeApi를 통해 Api로 만들어주시면 됩니다.

 

 

그러면 위에 apiClient.ts에 있는 파일에서 도메인 관련 주석을 해제해주시면 되겠죠?

 

근데 apiHooks를 사용하시려면 client단 Context로 감싸주어야 하는데요. 아래와 같이 하시면 됩니다.

 

src > app > ClientContext.tsx

'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

type Props = {
  children: React.ReactNode;
};

const queryClient = new QueryClient();

export default function ClientContext({ children }: Props) {
  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
}

  

src > app > layout.tsx

import type { ReactNode } from 'react';

import ClientContext from './ClientContext';

type Props = {
  children: ReactNode;
};

export default function RootLayout({ children }: Props) {
  return (
    <html lang="ko">
      <body>
        <ClientContext>
          {children}
        </ClientContext>
      </body>
    </html>
  );
}

 

간단지게 api를 통해서 목업 데이터를 가져올 수 있도록 세팅해봤어요.

 

src > pages > api > mock > [...paths].ts

import type { NextApiRequest, NextApiResponse } from 'next';
import { getPathMatch } from 'next/dist/shared/lib/router/utils/path-match';
import type { z } from 'zod';

import { mockApiPathname } from '@/constants/dev';
import { getUser, getUsers } from '@/domain/User';

const urlMatchDynamicPath = (url: string) => (dynamicPath: string) =>
  getPathMatch(`${mockApiPathname}${dynamicPath}`, { strict: true })(url);

export default function handler(
  { url, method, query }: NextApiRequest,
  res: NextApiResponse,
) {
  console.log('/---------------- MOCK API ----------------/');
  console.log('method: ', method);
  console.log('url: ', url);
  console.log('query: ', query);

  if (!url) return res.status(404).end();
  const matchDynamicPath = urlMatchDynamicPath(url);
  const res200 = res.status(200);

  switch (method) {
    case 'GET':
      if (matchDynamicPath(getUser.path)) return res200.json(mockUsers[0]);
      if (matchDynamicPath(getUsers.path)) return res200.json(mockUsers);
      break;
    case 'POST':
      break;
    case 'PUT':
      break;
    case 'DELETE':
      break;
    case 'PATCH':
      break;
  }
}

const mockUsers: z.infer<typeof getUsers['response']> = [
  {
    id: 'dkdlel1',
    name: '홍길동',
    profileImage: '',
  },
  {
    id: 'dkdlel2',
    name: '이순갓',
    profileImage: '',
  },
  {
    id: 'dkdlel3',
    name: '세종',
    profileImage: '',
  },
];

 

getPathMatch는 nextjs 팀이 만들어 놓은 함수로서 dynamic path를 매칭시키기에 아주 좋은 유틸 펑션이에요. 개꿀 함수에요.

 

이러면 이제 서버 컴포넌트에서의 예제와 클라이언트 컴포넌트에서의 예제를 보여드릴꼐요.

서버는 함수 선언해서 가져오시면 되고요.

클라이언트는 훅을 통해서 가져오시면 됩니다.

 

src > app > page.tsx - 서버 컴포넌트에서 데이터를 가져오는 예제

import { apiClient } from '@/utils/apiClient';

import ClientTest from './ClientTest';

export function getData() {
  return apiClient.getUsers();
}

export default async function RootPage() {
  const data = await getData();
  return (
    <div>
      {data.map((d) => (
        <p key={d.id}>
          users: {d.id} {d.name}
        </p>
      ))}
      <ClientTest />
    </div>
  );
}

 

src > app > ClientTest.tsx - 클라이언트 컴포넌트에서 데이터를 가져오는 예제

'use client';

import { apiHooks } from '@/utils/apiClient';

export default function clientTest() {
  const d = apiHooks.useGetUser({ params: { id: 1 } });

  return (
    <div>
      current User: {d.data?.id} {d.data?.name}
    </div>
  );
}

 

보시면 api에서 설정해놓은 alias 덕에 굉장히 편하죠? 타입 추론도 완벽하게 다 되고요.

서버에서 보내준 데이터가 스키마에 맞지 않을 경우 에러도 잘 발생합니다. 

 

전체 폴더 구조는 다음과 같습니다. 회사 프로젝트라 깃헙 공개하지 않는거 양해하세요.....

 

 

 참고로 monorepo에서 yarn3나 pnpm을 사용하면 react-query가 Attempted import error 에러 가 뜨는데요... 패키지들을 루트에서 관리하다보니 런타임으로 실행되는 것이 폴더 경로를 잘 잡지 못하는 것 같아요.

 

그래서 next.config.js의 webpack 설정으로 alias 추가해주시면 됩니다.

제 next.config.js 보시고 참고하세요. nextjs13 + vercel에서 너무 큰 파일들을 서버리스에 올리는 버그도 막는 처리도 해놨습니다. 

 

next.config.js

const path = require('path');
/** @type {import('next').NextConfig} */
const config = {
  reactStrictMode: true,
  swcMinify: true,
  i18n: {
    locales: ['ko'],
    defaultLocale: 'ko',
  },
  webpack: (config, options) => {
    config.module.rules.push({
      test: /\.svg$/,
      use: [
        options.defaultLoaders.babel,
        {
          loader: '@svgr/webpack',
          options: { babel: false },
        },
      ],
    });
    
    
    // See https://github.com/TanStack/query/issues/3595#issuecomment-1353601727
    const aliasPackages = ['@tanstack/react-query'];

    if (options.isServer) {
      config.externals = [...aliasPackages, ...config.externals];
    }

    for (const aliasPackage of aliasPackages) {
      config.resolve.alias[aliasPackage] = path.resolve(
        __dirname,
        '../../node_modules',
        aliasPackage,
      );
    }

    return config;
  },
  experimental: {
    // See https://github.com/vercel/next.js/issues/42641#issuecomment-1320713368
    outputFileTracingIgnores: ['**swc/core**'],
    appDir: true,
    transpilePackages: ['@dothis/share'],
  },
};
module.exports = config;