(번역) tRPC와 리액트를 사용해 풀 스택 타입스크립트 앱 만들기

Chanhee Kim·2023년 3월 19일
31

FE 글 번역

목록 보기
12/22
post-thumbnail

원문: https://www.robinwieruch.de/react-trpc/

tRPC를 사용하면 개발자가 풀 스택 애플리케이션에서 타입스크립트로 완전한 타입 세이프 API를 만들 수 있습니다. 서버 애플리케이션이 타입 세이프 함수(예: user 만들기, 식별자별 user 가져오기, 모든 user 가져오기 등과 같은 CRUD 연산)가 포함된 타입 세이프 라우터를 생성하면 클라이언트 애플리케이션은 추론된 타입 세이프 라우터에서 이러한 함수를 직접 호출할 수 있습니다. 내부적으로는 클라이언트와 서버 간의 통신에 여전히 HTTP가 사용됩니다.

tRPC를 사용하려면 클라이언트와 서버에서 타입스크립트를 사용해야 합니다. 클라이언트는 서버에서 타입 세이프 라우터를 가져와야 하므로 두 애플리케이션이 환경을 공유하는 것이 합리적입니다(폴더부터 Monorepo까지 어느 것이든 괜찮습니다.). 라우터가 클라이언트와 서버 사이의 접착제 역할을 하므로 스키마(예: OpenAPI를 사용한 REST) 또는 코드 생성(예: GraphQL 코드 생성기를 사용한 GraphQL) 없이 완전히 타입이 정의된 API를 얻을 수 있습니다.

GraphQL과 REST 모두 타입 세이프 API를 생성할 수 있지만, 추가로 생성된 파일에 이러한 타입을 생성하려면 항상 중간 과정이 필요합니다. 예를 들어 GraphQL 코드 생성기는 새로운 타입 세이프 스키마 파일을 생성합니다. tRPC를 사용하면 간단하게 서버에서 코드를 실행해 클라이언트 애플리케이션을 위한 타입 세이프 함수를 얻을 수 있습니다.

GraphQL이나 REST와 비교했을 때, tRPC는 (지금까지는) 주로 많은 서비스를 오케스트레이션 할 필요가 없는 소규모 프로젝트(예: GraphQL)에 주로 사용되거나 반드시 표준화된 RESTful 접근 방식으로 리소스에서 작업하지 않아도 되는 경우에 사용됩니다. 그러나 tRPC는 결국 REST 라우터나 GraphQL 리졸버에서 직접 사용할 수 있는 서버의 함수일 뿐이므로 언제든 tRPC에서 GraphQL/REST로 마이그레이션 할 수 있습니다.

다음 튜토리얼은 서버에서 Node + Express를 사용하고 클라이언트에서 React를 사용하여 작은 규모의 CRUD 애플리케이션을 만드는 방법을 안내합니다. tRPC를 사용해 두 세계 사이의 통신을 구축할 것입니다. 표시된 폴더 구조는 다음 단계에서 부분적으로 생성되지만 루트 폴더를 만드는 것부터 시작할 수도 있습니다.

- my-project/
--- client/
--- server/

tRPC를 사용한 풀스택 Node 앱

이 섹션에서는 Node, tRPC 및 Express를 사용하여 서버 애플리케이션을 만들겠습니다. 먼저 커맨드라인에서 프로젝트에 서버용 폴더를 새로 생성합니다.

mkdir server

그런 다음 조그마한 자바스크립트 프로젝트를 생성하고 이를 타입스크립트 프로젝트로 업그레이드합니다.

이 튜토리얼은 3부로 이루어진 시리즈 중 3부입니다.

1부: 모던 자바스크립트 프로젝트를 셋업 하는 방법
2부: Node.js와 타입스크립트 사용하기

타입스크립트 애플리케이션을 실행한 후에는 서버에 대한 실제 tRPC 구현부터 시작하여 최종적으로 클라이언트에 완전히 타입 세이프 한 API를 노출할 수 있습니다. 먼저 커맨드라인에서 tRPC 서버 패키지를 설치합니다.

npm install @trpc/server

그다음, 타입 세이프 스키마 유효성 검사를 위해 Zod도 설치합니다. 예를 들어, Zod를 사용하면 서버 API에 도달하는 사용자의 입력에 대한 유효성을 검사할 수 있습니다.

npm install zod

여기서 구축 하려는 애플리케이션의 구조는 다음과 같습니다.

- client/
- server/
--- src/
----- index.ts
----- context.ts
----- router.ts
----- trpc.ts
----- user/
------- router.ts
------- types.ts
------- db.ts

앞으로의 과정에서 위에서 본 모든 폴더/파일을 작성할 것입니다. 보시다시피 서버 애플리케이션에는 user/폴더가 있습니다. 여기서 user 도메인에 대한 모든 CRUD 작업(예: user 생성)을 구현할 것입니다. src/user/types.ts 파일에서 User 타입 정의부터 시작하겠습니다. 먼저 각 user에는 idname만 있습니다.

export type User = {
  id: string;
  name: string;
};

둘째로, src/user/db.ts라는 의사(pseudo) 데이터베이스 파일을 두 명의 user로 채웁니다.

import { User } from './types';

export const users: User[] = [
  {
    id: '0',
    name: 'Robin Wieruch',
  },
  {
    id: '1',
    name: 'Neil Kammen',
  },
];

세 번째로, 이 튜토리얼의 핵심인 src/user/router.ts 파일에서 tRPC를 사용하여 user 도메인에 대한 CRUD API를 구현할 것입니다. 쿼리(읽기) 또는 뮤테이션(쓰기)을 사용하여 getUsers, getUserByIdcreateUser 함수를 만들겠습니다. updateUserByIddeleteUserById 함수는 스스로 자유롭게 추가해 보세요.

import { z } from 'zod';

import { router, publicProcedure } from '../trpc';

import { users } from './db';
import { User } from './types';

export const userRouter = router({
  getUsers: publicProcedure.query(() => {
    return users;
  }),
  getUserById: publicProcedure
    .input((val: unknown) => {
      if (typeof val === 'string') return val;
      throw new Error(`Invalid input: ${typeof val}`);
    })
    .query((req) => {
      const { input } = req;

      const user = users.find((user) => user.id === input);

      return user;
    }),
  createUser: publicProcedure
    .input(z.object({ name: z.string() }))
    .mutation((req) => {
      const { input } = req;

      const user: User = {
        id: `${Math.random()}`,
        name: input.name,
      };

      users.push(user);

      return user;
    }),
});

마지막 파일은 최종적으로 클라이언트 애플리케이션에 모든 user 관련 기능을 노출하는 userRouter를 생성합니다. 이 도메인별 라우터는 나중에 애플리케이션의 모든 도메인별 라우터를 합치는 루트 라우터에서 사용됩니다.

또한 앞서 생성한 User 타입 정의와 의사 데이터베이스를 사용하고 있습니다. createUser 함수와 체이닝 된 input 함수를 자세히 살펴보면, Zod가 어떻게 입력이 문자열 데이터 타입의 name인지 유효성을 검사하는 데 사용되는지 알 수 있습니다. Zod는 타입스크립트 프로젝트의 스키마 유효성 검사에 적합하지만, tRPC에서는 Zod 뿐 아니라 모든 유효성 검사 라이브러리(예: Yup)를 사용할 수 있습니다.

다음으로, 실제 tRPC router와 이전 userRouter에서 이미 사용한 publicProcedure를 구현해야 합니다. 새 src/trpc.ts 파일을 생성하고 다음 구현을 추가합니다.

import { initTRPC, inferAsyncReturnType } from '@trpc/server';

import { createContext } from './context';

export type Context = inferAsyncReturnType<typeof createContext>;

const t = initTRPC.context<Context>().create();

export const middleware = t.middleware;
export const router = t.router;

/**
 * Public procedures
 **/
export const publicProcedure = t.procedure;

기본적으로 타입 세이프 라우터와 제한이 없는 공개 프로시저의 기반을 만들었습니다. 이는 기본적인 tRPC 셋업의 일종입니다. 나중에 미들웨어(예: 인증된 사용자 확인)를 사용하여 보호된 프로시저(예: 인증)를 추가할 수 있는 곳이기도 합니다. 남은 것은 src/context.ts 파일입니다.

import * as trpcExpress from '@trpc/server/adapters/express';

export const createContext = ({
  req,
  res,
}: trpcExpress.CreateExpressContextOptions) => {
  return {};
};

이것 또한 모든 API 호출에 사용되는 컨텍스트의 기본 토대입니다. 하지만 여기서는 이미 tRPC와 함께 Express를 사용하도록 지정했습니다. Fastify와 같은 다른 어댑터도 사용할 수 있습니다. 아직 반환된 컨텍스트에 아무것도 전달하지 않았지만, 요청에서 유효한 세션 토큰이 있을 때 인증된 사용자를 추가할 수 있는 위치가 될 것입니다. 그러면 이전 파일의 미들웨어가 보호된 프로시저를 사용하기 위해 인증된 사용자를 확인할 수 있습니다.

앞서 tRPC로 사용자별 라우터를 만들었습니다. 다음으로 모든 도메인별 라우터를 통합하는 새 src/router.ts 파일에 루트 라우터를 생성합니다. 여기에 나중에 다른 도메인별 라우터를 추가할 수 있습니다:

import { router } from './trpc';
import { userRouter } from './user/router';

export const appRouter = router({
  user: userRouter,
});

export type AppRouter = typeof appRouter;

마지막으로 실제 서버를 설정해야 합니다. 여기서는 이미 컨텍스트에 tRPC + Express 어댑터를 사용했기 때문에 Express를 사용하겠지만 다른 옵션도 사용할 수 있습니다. 먼저 Express를 설치하겠습니다.

npm install express cors
npm install @types/express @types/cors --save-dev

둘째로, 앞서 최상위의 src/index.ts 파일에서 생성한 라우터 및 컨텍스트와 함께 사용합니다.

import cors from 'cors';
import express from 'express';
import * as trpcExpress from '@trpc/server/adapters/express';

import { appRouter } from './router';
import { createContext } from './context';

const app = express();

app.use(cors());

app.use(
  '/trpc',
  trpcExpress.createExpressMiddleware({
    router: appRouter,
    createContext,
  })
);

app.listen(4000);

export type AppRouter = typeof appRouter;

구현은 독립 실행형 Express 서버와 크게 다르지 않습니다. 유일한 두 가지 차이점은 tRPC의 Express 인식 통합입니다. 또한 클라이언트 애플리케이션에서 최종적으로 사용할 AppRouter 타입을 내보냅니다. AppRouter는 라우터의 구현이 아니라 타입 정의일 뿐이라는 점에 유의하세요.

npm start로 서버를 시작하고 오류가 표시되지 않는지 확인합니다. 다음으로 내보낸 AppRouter 타입 정의와 해당 타입에 입력된 함수(예: createUser)를 사용하는 클라이언트 애플리케이션을 계속 진행하겠습니다.

tRPC를 사용한 풀스택 리액트 앱

부연 설명: 앞으로 리액트를 사용하는 프런트엔드 애플리케이션은 Next.js 대신 Vite를 사용할 것입니다. 왜냐하면 저는 일반적으로 The Road to React와 같은 제 책에서도 초보자를 위해 서버 측 라우팅보다 클라이언트 측 라우팅을 가르치기 때문입니다. 제 콘텐츠의 독자들에게는 React와 Node로 프런트엔드 및 백엔드 개발의 기초를 배운 후에 이어서 볼 수 있는 완벽한 콘텐츠가 될 것입니다.

먼저 Vite를 사용해 타입스크립트 프런트엔드 애플리케이션으로 React를 만들어 보겠습니다. 커맨드라인에서 프로젝트의 폴더로 이동합니다. 커맨드라인에 다음 지침에 따라 client/ 폴더를 (server/ 폴더 옆에)생성합니다.

npm create vite@latest client -- --template react-ts

Vite는 리액트와 타입스크립트로 최소한의 프런트엔드 애플리케이션을 생성하는 작업을 처리합니다. 마지막 명령어를 입력하면 client/ 폴더에 폴더/파일 구조가 표시됩니다. 그런 다음 새 client/ 폴더로 이동하여 모든 종속성을 설치하고 애플리케이션을 시작합니다. 그러면 브라우저에서 기본으로 제공되는 웹 애플리케이션을 확인할 수 있을 것입니다.

cd client
npm install
npm start

여기서부터 이 리액트/타입스크립트 애플리케이션에 대한 tRPC 구체적인 구현을 시작하겠습니다. 이 구현을 위해 프런트엔드 프로젝트에 이 두 가지 종속성을 설치해야 합니다. tRPC 서버에 요청하는 데 도움이 되는 tRPC 클라이언트와 프런트엔드에서 직접 종속성으로 사용하지는 않지만 tRPC 클라이언트의 필수 피어 종속성인 tRPC 서버입니다.

npm install @trpc/client @trpc/server

여기서 궁극적인 목표는 프런트엔드 및 백엔드 애플리케이션의 전체 스택에 걸쳐 타입스크립트를 사용하여 종단 간 타입 안정성을 만드는 것입니다. 따라서 이 마법이 일어나는 곳에 src/trpc.ts 라는 파일을 만들겠습니다.

import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';

import type { AppRouter } from '../../server/src/index';

export const trpc = createTRPCProxyClient<AppRouter>({
  links: [
    httpBatchLink({
      url: 'http://localhost:4000/trpc',
    }),
  ],
});

기본적으로 우리는 여기에서 tRPC 클라이언트를 인스턴스화하고 있습니다. 이는 서버 애플리케이션의 API에 대한 최소한의 URL을 사용합니다. 그러나 마법은 server/ 프로젝트에서 client/ 프로젝트로 임포트 된 AppRouter 유형에 있습니다. 그다음 AppRouter 타입을 클라이언트 측 tRPC 인스턴스를 생성하기 위한 타입스크립트 제네릭으로 사용할 수 있습니다. 결과적으로 백엔드의 모든 타입을 프런트엔드에서 상속합니다. 즉, 타입스크립트로 종단 간 타입 안전성을 달성한 것입니다.

이제 리액트 컴포넌트에서 앞에서 서술한 것을 증명해 보겠습니다. 다음 구현은 src/App.tsx 파일이 이전 파일에서 tRPC 클라이언트 인스턴스를 어떻게 가져오는지 보여줍니다. 컴포넌트가 렌더링 될 때 서버에서 실제 user를 가져오는 리액트의 useEffect 훅에서 사용됩니다. 중요한 것은 trpc 클라이언트의 모든 것이 타입 세이프하므로 IDE(예: VSCode)에서 자동으로 완성될 수 있다는 것입니다(예: usergetUserById).

import * as React from 'react';

import { trpc } from './trpc';

const App = () => {
  const fetchUser = async () => {
    const user = await trpc.user.getUserById.query('0');

    console.log(user);
  };

  React.useEffect(() => {
    fetchUser();
  }, []);

  return <></>;
};

export default App;

브라우저를 열면 사용자가 서버측에 로그인하는 것을 볼 수 있습니다. 리액트 프래그먼트를 사용했기 때문에 아무것도 렌더링되지 않습니다. 로깅이 보이지 않고 대신 오류가 표시되는 경우 클라이언트 애플리케이션에서 사용할 수 없으므로 커맨드라인에서 server/ 프로젝트를 시작해야 합니다.

React Query를 사용한 TRPC

캐싱, 리페칭, 실패 시 재시도 같은 강력한 기능을 제공하기 때문에 리액트에서 데이터를 페칭 할 때 React Query는 필수적입니다. 하지만 React Query는 항상 우리가 제어 불가능한 로딩 상태로 시작됩니다. 다행히도 tRPC에는 다음에 설정할 React Query 통합 기능이 함께 제공됩니다. 먼저, React Query(RQ)와 이를 위한 tRPC의 React Query 통합 기능을 설치합니다.

npm install @tanstack/react-query @trpc/react-query

다음으로 src/trpc.ts 파일로 이동하여 새로운 tRPC to React Query 어댑터를 포함 시킵니다. 이 파일에서 두 tRPC 인스턴스를 모두 내보내는 것을 잊지 마세요.

import { createTRPCReact, httpBatchLink } from '@trpc/react-query';

import type { AppRouter } from '../../server/src/index';

export const trpc = createTRPCReact<AppRouter>();

export const trpcClient = trpc.createClient({
  links: [
    httpBatchLink({
      url: 'http://localhost:4000/trpc',
    }),
  ],
});

src/main.tsx 파일에서 React Query와 tRPC를 모두 전역적으로 제공합니다.

import React from 'react';
import ReactDOM from 'react-dom/client';
import {
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query';

import { trpc, trpcClient } from './trpc';
import App from './App';

const queryClient = new QueryClient();

ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
).render(
  <trpc.Provider client={trpcClient} queryClient={queryClient}>
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </trpc.Provider>
);

마지막으로 React Query와 함께 tRPC를 사용할 수 있습니다. tRPC 인스턴스에서 user 라우터에 액세스하고, 거기서 getUserById 프로시저에 액세스하고, 마지막으로 React Query의 useQuery 훅에 액세스할 수 있습니다. 반환된 결과는 네이티브 React Query와 동일하며 완전히 타입 정의되어 있습니다. IDE의 자동 완성 기능으로 trpc의 모든 프로퍼티와 데이터에 액세스할 수 있습니다.

import * as React from 'react';

import { trpc } from './trpc';

const App = () => {
  const { data, isLoading } = trpc.user.getUserById.useQuery('0');

  if (isLoading) return <div>Loading ...</div>;

  return <div>{data?.name}</div>;
};

export default App;

메서드를 getUserById에서 getUsers로 변경하면 즉시 타입 에러가 발생합니다.

import * as React from 'react';

import { trpc } from './trpc';

const App = () => {
  const { data, isLoading } = trpc.user.getUsers.useQuery();

  if (isLoading) return <span>Loading ...</span>;

  return <div>{data?.name}</div>;
  // Property 'name' ^ does not exist on type 'User[]'.
};

export default App;

사용자 목록을 렌더링 하는 적절한 구현으로 이 문제를 해결할 수 있습니다.

import * as React from 'react';

import { trpc } from './trpc';

const App = () => {
  const { data, isLoading } = trpc.user.getUsers.useQuery();

  if (isLoading) return <span>Loading ...</span>;

  return (
    <div>
      <ul>
        {(data ?? []).map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
};

export default App;

지금까지 getUserByIdgetUsers 함수를 사용했습니다. 마지막으로 서버의 라우터에 정의한 createUser 함수를 사용해 UI에서 user를 생성하겠습니다. 사용자가 이름을 입력하고 요청을 전송할 수 있는 폼을 리액트로 구현하겠습니다. tRPC 인스턴스를 통해 user 도메인과 그것의 함수(여기서는 getUserscreateUser), 그리고 React Query에 동일하며 완전히 타입 정의된 각각의 쿼리/뮤테이션 함수에 액세스 하는 방법을 다시 한번 살펴보겠습니다.

import * as React from 'react';

import { trpc } from './trpc';

const App = () => {
  const [name, setName] = React.useState('');

  const { data, isLoading, refetch } = trpc.user.getUsers.useQuery();

  const mutation = trpc.user.createUser.useMutation({
    onSuccess: () => refetch(),
  });

  const handleChange = (
    event: React.ChangeEvent<HTMLInputElement>
  ) => {
    setName(event.target.value);
  };

  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    setName('');
    mutation.mutate({ name });
    event.preventDefault();
  };

  if (isLoading) return <span>Loading ...</span>;

  return (
    <div>
      <ul>
        {(data ?? []).map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>

      <form onSubmit={handleSubmit}>
        <label htmlFor="name">Name:</label>
        <input
          id="name"
          type="text"
          value={name}
          onChange={handleChange}
        />

        <button type="submit">Create</button>
      </form>
    </div>
  );
};

export default App;

이게 전부입니다. 서버와 클라이언트 애플리케이션을 tRPC로 구현했습니다. 서버는 Express를 사용하는 반면, 클라이언트는 리액트와 React Query를 사용합니다. 여기서는 몇 가지 타입 세이프 API 엔드포인트만 구현했지만, 더 많은 도메인별 라우터(예: Post, Comment)와 라우터 내 쿼리/뮤테이션 함수(예: deleteUserById)를 추가하면 어떻게 확장될지 상상할 수 있을 것입니다.

tRPC는 클라이언트와 서버 모두에서 타입스크립트를 사용하고 코드 베이스를 공유하는 풀 스택 타입 세이프 애플리케이션을 위한 훌륭한 솔루션입니다. 기본 설정이 완료되고 나면 타입 세이프 API를 확장할 수 있는 놀라운 개발자 환경을 제공하기 때문에 새 프로젝트를 시작하는 데 매우 적합합니다. 나중에 필요한 경우에만 GraphQL 또는 REST로 마이그레이션 하여 그곳에서 함수를 재사용할 수 있습니다.

🚀 한국어로 된 프런트엔드 아티클을 빠르게 받아보고 싶다면 Korean FE Article(https://kofearticle.substack.com/)을 구독해주세요!

profile
FE 개발을 하고 있어요🌱

2개의 댓글

comment-user-thumbnail
2023년 3월 23일

I am grateful to you and expect more number of posts like these.
Kantime Healthcare Software

답글 달기
comment-user-thumbnail
2023년 4월 8일

잘 읽었습니다. 고맙습니다!!

답글 달기