tRPC로 타입 안전한 풀스택 타입스크립트 프로젝트 만들기

6 minute read

tRPC는 풀스택 타입스크립트 환경에서 타입 안전한 백엔드 API를 작성할 수 있도록 돕는 라이브러리입니다. tRPC를 사용하면 백엔드와 프론트엔드 간에 API 스키마를 공유하는 데 필요한 시간과 노력을 크게 줄일 수 있고, 풀스택 타입스크립트 개발의 생산성을 크게 향상시킬 수 있습니다. 이 글에서는 백엔드와 프론트엔드간의 일반적인 HTTP 호출은 어떤 면에서 문제가 있는지, tRPC를 사용하는 것이 어떻게 이런 문제들을 해결하면서 생산성은 높일 수 있는지 살펴보고자 합니다.

HTTP 호출

tRPC의 이점을 더 자세히 살펴보기 위해, 일반적인 타입스크립트 백엔드와 프론트엔드간의 HTTP 호출 방식을 먼저 살펴봅시다. 예를 들어서, 아래와 같이 백엔드단에서 PostInput 을 받아 Post 를 만들어 반환하는 createPost 함수를 확인해 봅시다.

type PostInput = {
  title: string;
  content: string;
};

type Post = {
  id: string;
  title: string;
  content: string;
  createdAt: Date;
};

function createPost(input: PostInput): Post {
  // DB 호출 등 작업 진행
  return {
    id: "1",
    title: input.title,
    content: input.content,
    createdAt: new Date(),
  }
}

이 함수를 프론트엔드에서 사용하기 위해서는, 우선 백엔드에서 이 함수를 호출하는 API 엔드포인트를 만들고, 프론트엔드에서 해당 엔드포인트를 호출합니다.

// 백엔드 코드: Next.js API Route 로 API 정의
export default function handler(req: NextApiRequest, res: NextApiResponse) {
  if(req.method === 'POST') {
    const input: PostInput = req.body;
    const post = createPost(input);
    res.status(200).json(post);
  } else {
    res.status(405).end();
  }
}

// 프론트엔드 코드: 백엔드에서 정의한 API 호출
const input = { title: "Hello", content: "World" };
const response = await fetch("/api/post", {
  method: "POST",
  headers: {"Content-Type": "application/json"},
  body: JSON.stringify(input),
});
  
const data = await response.json();

이렇게 HTTP 호출을 이용해 백엔드의 로직을 프론트에서 호출할 수 있도록 하는 코드를 간단히 작성할 수 있지만, 여기서는 타입 안전과 관련된 세 가지 문제점을 짚어볼 수 있습니다.

문제 1: 프론트엔드에서 요청을 보낼 때

먼저, 위 코드는 프론트엔드에서 요청을 보낼 때, 보내는 데이터의 타입이 백엔드에서 요구하는 타입과 같은지 타입스크립트 레벨에서의 확인이 없습니다. 예를 들어서 아래와 같이 잘못된 필드명으로 요청하더라도, 타입스크립트 컴파일 시점이나 런타임에 요청을 시작하는 시점에서는 에러가 발생하지 않습니다.

const input = { name: "Hello", content: "World" };
//              ^ 잘못된 필드명이지만, 요청할 때는 문제가 없음

이 문제의 해결방법 중 하나로, 아래와 같이 백엔드의 PostInput 타입을 가져와, 프론트에서 보내는 타입과 백엔드에서 요구하는 타입이 일치하는지 type annotation으로 확인해볼 수 있습니다. 하지만 이 방식도 완전하지 않은데, 예를 들어서 실제로 해당 타입을 가진 변수가 백엔드로 전달되는지 여부는 강제되지 않기 때문에, 여전히 개발자가 이 부분을 조심스럽게 챙겨야 합니다.

const input: PostInput = { name: "Hello", content: "World" };
//           ^ 이렇게 type annotation을 활용하면, 필드명이 잘못되었을 때 에러 발생
const response = await fetch("/api/post", {
  method: "POST",
  headers: {"Content-Type": "application/json"},
  body: JSON.stringify(anotherUncheckedInput), // 하지만 여기서 어떤 값이 전달되는지는 강제되지 않음
});

문제 2: 백엔드에서 요청을 받을 때

두 번째 문제는, 백엔드에서 받은 데이터 타입이 실제로 기대하는 타입과 같은지에 대한 확인이 없습니다. 백엔드 코드에서 프론트엔드에서 받은 요청 데이터인 req.body 를 어떻게 사용하는지 다시 살펴보겠습니다.

const input: PostInput = req.body;

이 코드는 type annotation을 사용하기 때문에,req.body 가 실제로 PostInput 타입을 가지는지 런타임에 검증되지 않습니다. (Type assertion이 실제로 데이터를 검증하지 않는 것과 같은 원리) 만약 프론트엔드에서의 구현 문제나, 악의적인 요청자로 인해 req.body 의 타입이 PostInput 과 다르더라도, 백엔드에서는 여전히 이 데이터를 처리할려고 시도할 것입니다.

이 문제를 해결하기 위해서는 Zod와 같은 런타임 검증 라이브러리를 이용해, 벡엔드에서 데이터를 받는 시점에 해당 데이터가 원하는 데이터 타입과 같은지 검증해볼 수 있습니다.

const postSchema = z.object({
  id: z.string(),
  title: z.string(),
  content: z.string(),
  createdAt: z.date()
});

const input: PostInput = postSchema.parse(req.body);

문제 3: 백엔드에서 프론트엔드로 응답을 전달할 때

세 번째 문제는 백엔드에서 프론트엔드로 응답을 전달할 때, 백엔드의 응답 타입을 프론트엔드에서 바로 확인할 수 없다는 점입니다. 위 예시를 다시 살펴보자면, 비록 백엔드에서는 Post 로 타입이 정해진 응답이 내려가지만, 프론트엔드 측에서는 any 타입을 가진 데이터를 받게 됩니다.

// 백엔드 코드:
res.status(200).json(post); // <- `Post` 타입이 응답으로 내려간다는 걸 알 수 있음

// 프론트엔드 코드: 
const data = await response.json(); // 하지만 프론트엔드에서는 `any`를 받음

이 문제를 해결하기 위해서는 백엔드 타입인 Post 와 일치하는 타입을 만들어, 프론트엔드의 data 변수에 type annotation을 적용할 수 있지만, 이떈 백엔드와 프론트엔드 타입이 일치하도록 관리하는 것 또한 어려운 문제가 됩니다.

tRPC

위 문단에서 우리는 엔드포인트를 간단하게 만들면 타입 안전하지 않고, 타입 안전하게 만들려면 많은 보일러플레이트 코드가 필요해지는 딜레마를 확인할 수 있었습니다. 이 때, tRPC를 사용하면 위 문단에서 API를 타입 안전하게 만들기 위해 시도했던 다양한 방법들이 자동으로 적용되는 효과를 얻을 수 있습니다. 이를 아래 예시 코드를 통해서 구체적으로 살펴보겠습니다.

create-t3-app

tRPC의 예시 코드를 보여드리기에 앞서서, 우선 create-t3-app에 대해서 소개드리고자 합니다. create-t3-app은가장 쉽게 tRPC를 사용해볼 수 있는 풀스택 Next.js 템플릿입니다. tRPC를 타입스크립트 코드베이스에 적용하기 위해서는 약간의 설정 코드가 필요한데, create-t3-app를 사용하면 이런 설정이 되어 있어 tRPC를 곧바로 사용할 수 있는 Next.js 레포지토리가 생성됩니다. 해당 템플릿의 tRPC 설정을 살펴보면, tRPC를 기존 레포에 어떻게 추가하면 좋을지 더 잘 알 수 있을 것입니다.

먼저, 커멘드 라인에서 아래와 같은 명령어를 실행해 create-t3-app 템플릿으로 초기화된 Next.js 프로젝트를 생성합니다.

npm create t3-app@latest

엔드포인트 정의 및 호출

create-t3-app으로 프로젝트를 생성한 후에는, src/server/api/routers 디렉토리 내에 아래와 같은 방식으로 프론트엔드에서 호출할 수 있는 엔드포인트를 정의할 수 있습니다. 그 후에는 src/server/api/root.ts 파일에 새롭게 만든 postRouter 를 추가해줍니다.

import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; // create-t3-app에서 생성

export const postRouter = createTRPCRouter({
  createPost: publicProcedure
    .input(z.object({
      title: z.string(),
      content: z.string(),
    }))
    // 데이터 조회 요청(HTTP GET 요청)은 `.query()` 로 정의
    .mutation(({ input }) => {
      return {
        id: "1",
        title: input.title,
        content: input.content,
        createdAt: new Date(),
      }
    }),
})

그 후에는, 프론트엔드에서 아래와 같은 코드로 해당 엔드포인트를 호출할 수 있습니다.

import { api } from "~/utils/api"; // create-t3-app에서 생성

// React 컴포넌트 내부에서 아래 hook 정의
// `.query()`로 정의한 데이터 조회 요청은 `.useQuery()`로 실행 가능
const createPostMutation = api.post.createPost.useMutation();

// `.mutate()`또는 `.mutateAsync()` 로 createPost 엔드포인트 호출
const createPost = async () => {
  const result = await createPostMutation.mutateAsync({
    title: "Hello",
    content: "World",
  });

  console.log(result);
};

내부적으로 [useMutation()](https://trpc.io/docs/client/react/useMutation)[useQuery()](https://trpc.io/docs/client/react/useQuery) 는 react-query 라이브러리를 사용하며, react-query에서 사용가능한 설정은 모두 tRPC에서도 사용할 수 있습니다.

타입 안정성

tRPC는 앞서 보았던 일반 HTTP 요청의 타입 안정성 문제를 모두 해결합니다.

먼저, 프론트엔드에서 tRPC 클라이언트를 통해 요청을 보내는 코드를 작성하면, 자동으로 타입스크립트의 타입 시스템이 요청 타입이 백엔드에서 요구하는 타입과 일치하는지 검사해줍니다.

프론트엔드에서 요청을 보내는 타입이 서버에서 요구하는 타입과 다를 때, 에러가 발생하는 모습 프론트엔드에서 요청을 보내는 타입이 서버에서 요구하는 타입과 다를 때, 에러가 발생하는 모습

물론 타입 정보가 있기 때문에, 자동완성도 됩니다 물론 타입 정보가 있기 때문에, 자동완성도 됩니다

또한, procedure를 정의할 때 아래와 같이 .input() 함수와 zod schema를 통해 Input Validators를 정의했다면, tRPC가 자동으로 클라이언트에서 받은 요청값이 유효한지 확인할 수 있습니다.

createPost: publicProcedure
  .input(z.object({
    title: z.string(),
    content: z.string(),
  }))

서버에서 정의한 응답값 타입 또한, 프론트엔드에서 tRPC 클라이언트를 사용하면 자동으로 프론트엔드에서 사용할 수 있게 됩니다. 아래와 같이 createPostMutation 의 결과로 얻어진 result 변수가 서버에서 정의한 Post 타입과 일치하게 추론되는 모습을 확인할 수 있습니다.

result 변수가 자동으로 서버에서 정의한 타입으로 추론되는 모습 result 변수가 자동으로 서버에서 정의한 타입으로 추론되는 모습

tRPC 더 자세히 살펴보기

코드 생성 없음

tRPC와 비슷하게 백엔드의 타입 정의를 프론트엔드에서 사용할 수 있게 해주는 GraphQL은 타입 공유를 위해 코드 생성(code generation, 또는 codegen)이 필요합니다. 이는 근본적으로 GraphQL의 타입은 언어와 무관한 형식인 .graphql 으로 쓰여지기 때문에, 이를 타입스크립트 타입으로 변환하는 과정이 필요하기 때문입니다.

참고: [How does GraphQL Code Generator Work?](https://the-guild.dev/graphql/codegen/docs/advanced/how-does-it-work) 참고: How does GraphQL Code Generator Work?

반면에, tRPC를 사용하면 백엔드에서 API를 작성하며 정의한 요청과 응답 타입을 프론트엔드에서 별도의 codegen 단계 없이 사용할 수 있습니다. 이는 백엔드와 프론트엔드가 둘 다 타입스크립트로 단일 레포에서 작성되었다는 점을 tRPC가 최대한 활용한 결과이며, 백엔드 / 프론트엔드를 오가며 개발하는 속도를 높이는 데에 큰 도움을 줍니다.

백엔드 / 프론트엔드 코드 간 빠른 컨텍스트 스위칭

tRPC를 사용한다면 백엔드의 API코드와 해당 API를 사용하는 프론트엔드의 코드 간에 오고가는 것도 간단합니다. 위 예시를 다시 보자면, 백엔드의 router에 정의된 createPost: publicProcedure.input(..).mutation(..) 과 프론트엔드에서 이를 사용하는api.post.createPost.useMutation(); 간에는 타입 정의를 공유하고 있습니다.

이는 즉 프론트엔드의 createPost 에 대해 IDE의 “Go to definition” 기능을 사용해 해당 API를 정의한 백엔드 코드로 이동할 수 있고, 백엔드의 createPost 에 대해서도 “Go to reference” 기능을 이용해 해당 API를 사용하는 프론트엔드 코드로 바로 이동할 수 있음을 의미합니다. 이런 측면에서 tRPC는 독보적인 개발자 경험을 가지고 있습니다.

백엔드의 API 정의에 대해 Go to Reference 기능을 사용해서, 프론트 코드를 확인하는 모습 백엔드의 API 정의에 대해 Go to Reference 기능을 사용해서, 프론트 코드를 확인하는 모습

마치며

이렇게 tRPC에 대해 간단히 살펴보았습니다. 제 생각에 tRPC는 타입스크립트로 풀스택 프로젝트를 구현할 때, 백엔드와 프론트엔드간에 빠른 속도로 긴밀하게 협업하며 제품을 만들어나가야 할 경우에 매우 적합합니다. 개인적으로는 지난 1월에 참가한 해커톤에서도 2박3일의 제한된 일정 속에서 tRPC를 사용한 것이 높은 개발 속도를 유지하는 데에 큰 도움을 주었습니다. 타입스크립트로 풀스택 웹 앱을 만들고 싶다면, tRPC(그리고 create-t3-app)를 한번 시도해 보세요!

레퍼런스

Categories:

Updated:

Comments