avatar
nine_devlog

tRPC with Next.js 14: Server Side setting

이건 기회야 형님들에게 풀스택으로 보일 수 있는 기회
tRPCnextjsApp Router
2 months ago
·
10 min read

1899

회사에서 아주 낮은 수준의 MVP로 서비스를 하나 만들어야 하는 상황인데, 어쩌다 보니 내가 메인(이라 쓰고 혼자)으로 프로젝트를 작업하게 되었다. 백엔드까지 모두 다루는데다 최소 기능이 한 달 안에 나와야 하는 상황이라 기존 기술 스택으로는 한계가 있지 않을까 고민되던 찰나, 하나의 아티클을 우연히 읽게 되었다.

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

보자마자 아 이거다! 싶었고, 특히 기존에 회사에서 돌아가던 Next.js 플젝에 페이지 형태로 추가해야 하는 상황에도 잘 맞을 것 같아서 바로 써보기로 했다.

tRPC

보통 서버와 클라이언트 간에 데이터를 주고 받을 때 가장 귀찮은 부분은 타입이다. 뭐 물론 기본적인 데이터 직렬화를 거쳐서 각 프로그램에서 사용이야 한다지만, 여러 작업자가 붙는다던지 하면 API 명세부터 시작해 소통을 하는 과정에서 여간 신경쓰이는 부분들이 적지 않고 나 역시 마찬가지로 비슷한 경험을 겪었다. 특히나 웹, JS의 경우 타입에 대해 상당히 관대한 편이라 컴파일 환경은 물론 런타임 환경에서도 타입과 관련해 자주 오류를 마주칠 수 있다. (겪어보지 않은 분들도 많으시겠지만, 실제로 API 문서가 없거나 노션으로 직접 작성하는 경우들도 생각보다 많다..)

물론 REST API나 GraphQL도 타입 세이프 API를 만들 수 있지만 이런 타입을 생성하기 위해서는 추가적인 작업이 따라오기 마련인데, tRPC는 클라이언트와 서버를 유기적으로 연결하여 서버에서 API를 작성하면 별도의 작업 없이 클라이언트에서 API가 주는 데이터 타입을 TS로 바로 사용이 가능하다는 점에서 큰 장점이 있다.

단, tRPC는 TypeScript 기반이므로 서버와 클라이언트 모두 TS를 필수적으로 사용해야 하고, 유기적인 연결을 위해 동일한 환경에서 실행되어야 한다.

tRPC with NextJS

마침 내가 적용해야 하는 환경도 풀스택 프레임워크인 nextjs고(게다가 서버리스도 아니고), 기존 프로젝트에 react-query를 사용 중이었는데 react-query도 사용이 가능하다는 사실까지 발견하고는 이건 무조건이다! 하고 바로 세팅에 나섰다.

시작은 패키지 설치부터

npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query zod superjson

tRPC와 관련된 라이브러리를 제외하고 필요한 라이브러리 중에선 zodsuperjson이 있다. zod는 클라이언트에서 들어오는 데이터에 대한 타입 스키마 검증을 위해 사용하고, superjson은 데이터 직렬화를 자동화하기 위해 사용한다. 이외에도 DB와의 통신 및 데이터 변환을 위한 ORM이 필요한데, 나는 기존에 프로젝트에서 사용중이던 prisma를 그대로 사용하기로 했다.

tRPC with react-query 5

현재 tRPC의 정식 버전에서는 react-query v4버전까지만 지원하고, v5를 지원하진 않는다. 문제는 회사에서 기존에 사용 중인 버전이 v5라는 것. 다행히 이 문제는 정식 출시 이전인 tRPC v11에서 지원하고 있어서 해당 버전을 설치해주었다.

{
	// ...
  "dependencies": {
    "@prisma/client": "^5.5.2",
    "@t3-oss/env-nextjs": "^0.7.1",
    "@tanstack/react-query": "^5.0.5",
    "@trpc/client": "next", // 정식 출시 전인 v11 버전을 사용하기 위해 next로 표시
    "@trpc/next": "next", 
    "@trpc/react-query": "next",
    "@trpc/server": "next",
    "next": "^14.0.0",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "superjson": "^1.13.1",
    "zod": "^3.22.4"
  },
  // ...
}

server side

tRPC는 기본적으로 graphQL과 비슷하게 하나의 엔드포인트에서 모든 요청을 받는 형태로 동작한다. 하나의 엔드포인트로 들어오는 HTTP 요청을 사용하기 위해서 tRPC는 환경에 맞는 어댑터를 제공한다. 어댑터는 서버 환경 (nodejs http / express / nextjs)에 따라 다른 어댑터를 사용하고 있고, 환경에 맞는 어댑터를 사용해야 한다.

context

context는 이름 그대로 tRPC에서 사용할 데이터 환경 객체를 의미한다. context를 작성해야 HTTP에서 사용하는 header와 같은 것들을 가져와서 컨트롤러나 미들웨어에서 활용 할 수가 있다. 작성할 때는 위에 말했던 adapter에 맞는 context를 작성해주어야 한다. 기본적으로는 요청, 응답 헤더, 그 외의 정보를 가져올 수 있고, 나는 인가에 사용할 토큰을 미리 받아오면 좋을 것 같아서 아래와 같이 로직을 작성했다.

defaultContext.ts

import { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
// Nextjs에서 동작하므로 Fetch adapter에 있는 타입으로 context를 작성한다
// express나 nodejs http의 경우 다른 어댑터 타입을 사용

export const createContext = async ({
  req,
  resHeaders,
  info,
}: FetchCreateContextFnOptions) => {
  const authHeader = req?.headers.get("Authorization");
  let token: string | null = null;

  if (authHeader && authHeader.startsWith("Bearer ")) {
    token = authHeader.split(" ")[1];
  }

  return { req, resHeaders, info, token };
};

// 다른 로직에서 사용할 타입도 export 해준다.
export type Context = Awaited<ReturnType<typeof createContext>>;

tRPC Server object

다음은 서버 사이드에서 사용할 tRPC 객체를 init해준다. 위에서 작성한 context의 타입을 제네릭으로 넣어주고, 데이터 직렬화를 자동으로 해주는 superjson을 transformer에 넣어주면 된다. 이 객체를 이용해서 router를 생성하고 query나 mutation을 사용할 수 있다.

trcpServer.ts

import { Context } from "@/server/contexts/defaultContext";
import { initTRPC } from "@trpc/server";
import SuperJSON from "superjson";

export const t = initTRPC.context<Context>().create({
  transformer: SuperJSON,
});

export const route = t.router;
export const procedure = t.procedure;
export const use = procedure.use;
export const input = procedure.input;
export const query = procedure.query;
export const mutation = procedure.mutation;
export const middleware = t.middleware;

router

실질적인 연결을 담당하는 router 역시 객체 형식으로 작성할 수 있다. 여기서 작성하는 route들이 클라이언트에서 그대로 사용할 route로 이해하면 된다.

appRouter.ts

const userRouter = route({
  getUser: query(async ({ ctx, input }) => {
      // 컨트롤러 함수를 따로 넣거나 바로 로직을 작성하면 된다.
      // 코드 가독성을 위해 나는 별도로 분리하여 작성함
      return await getUser(ctx, input);
    }),
});

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

api route

내가 사용할 환경은 nextjs므로 route에 fetchRequestHandler를 사용해준다. 앞서 작성한 context와 router를 넣어주면 끝!

app/api/[trcp]/route.ts

import { createContext } from "@/server/contexts/defaultContext";
import { appRouter } from "@/server/routes/appRouter";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
// Nextjs에서 동작하므로 fetch adapter에 있는 핸들러를 사용한다.
// express나 nodejs http의 경우 다른 어댑터 핸들러를 사용

const handler = (request: Request) => {
  console.log(`incoming request ${request.url}`);
  return fetchRequestHandler({
    endpoint: "/api/trpc", // 실제 요청이 도달할 엔드포인트
    req: request,
    router: appRouter, // 서버에서 작성하고 클라이언트에서 사용할 라우터
    createContext, // tRPC에서 사용할 context
  });
};

// next app router에 맞게 타입캐스팅을 해주면 끝!
export { handler as GET, handler as POST };

이렇게 기본적인 세팅을 마치고 나면 본격적으로 서버 코드를 작성하기 위한 준비가 끝난다. 본격적인 서버 코드 작성은 이어질 포스팅에서 작성할 예정!

ref

https://www.wisp.blog/blog/setting-up-trpc-with-nextjs-14

https://blog.itsrakesh.com/lets-build-a-full-stack-app-with-trpc-and-nextjs-14


- 컬렉션 아티클






뭐가 됐든 뭐가 되어서 뭐든 합니다.