tRPC 처음 써본 후기

원장·2025년 10월 21일

생각

목록 보기
6/8

일단 다음에도 Next Project를 하게 된다면, 무조건 쓰지않을까..

일단 아래와 같은 구조로 tRPC를 처음 써봤음.

 TL;DR - tRPC 전체 구조

  Next.js App Router + tRPC + React Query + Prisma 조합으로 타입 안전한 풀스택 구성 완료.
  Server/Client 컴포넌트 양쪽에서 사용 가능하며, SSR prefetching까지 지원.

  ---
  📊 전체 아키텍처 다이어그램

  graph TB
      subgraph "Client Layer"
          Layout[layout.tsx<br/>TRPCReactProvider]
          PageServer[page.tsx<br/>Server Component]
          PageClient[client.tsx<br/>Client Component]
      end

      subgraph "tRPC Configuration"
          Init[trpc/init.ts<br/>createTRPCContext<br/>baseProcedure]
          Router[trpc/routers/_app.ts<br/>appRouter]
          ClientConfig[trpc/client.tsx<br/>TRPCReactProvider<br/>useTRPC]
          ServerConfig[trpc/server.tsx<br/>trpc proxy<br/>caller]
          QueryClient[trpc/query-client.ts<br/>QueryClient setup]
      end

      subgraph "API Layer"
          APIRoute[api/trpc/[trpc]/route.ts<br/>GET/POST handler]
      end

      subgraph "Data Layer"
          DB[lib/db.ts<br/>PrismaClient]
          Prisma[(Prisma DB)]
      end

      Layout --> ClientConfig
      PageServer --> ServerConfig
      PageClient --> ClientConfig

      ClientConfig --> QueryClient
      ServerConfig --> QueryClient
      ServerConfig --> Init
      ClientConfig --> APIRoute

      APIRoute --> Router
      Router --> Init
      Router --> DB
      DB --> Prisma

      Init -.Context.-> Router

      style Init fill:#e1f5ff
      style Router fill:#fff3cd
      style APIRoute fill:#d4edda
      style ClientConfig fill:#f8d7da
      style ServerConfig fill:#f8d7da

  ---
  🏗️ 계층별 상세 분석

  1. 초기화 계층 (src/trpc/init.ts)

  // tRPC 인스턴스 초기화 + Context 생성
  const t = initTRPC.create()
  export const createTRPCRouter = t.router
  export const baseProcedure = t.procedure

  역할:
  - tRPC의 핵심 인스턴스 생성
  - createTRPCContext: 각 요청마다 실행되는 컨텍스트 생성 (현재 userId 하드코딩)
  - baseProcedure: 모든 프로시저의 기본 빌더 (추후 미들웨어 추가 가능)

  포인트:
  - cache() 사용으로 동일 요청 내 컨텍스트 재사용
  - Transformer (superjson) 주석 처리 → 필요 시 Date/Map/Set 직렬화 활성화 가능

  ---
  2. 라우터 계층 (src/trpc/routers/_app.ts)

  export const appRouter = createTRPCRouter({
    getUsers: baseProcedure.query(async () => {
      return await prisma.user.findMany();
    }),
  });
  export type AppRouter = typeof appRouter;

  역할:
  - 실제 API 엔드포인트 정의 (현재 getUsers 하나)
  - Prisma로 DB 접근
  - 타입 익스포트: 클라이언트가 이 타입을 가져가서 end-to-end 타입 안전성 확보

  확장 예시:
  // 라우터 분할 시
  import { userRouter } from './users';
  import { postRouter } from './posts';

  export const appRouter = createTRPCRouter({
    user: userRouter,
    post: postRouter,
  });

  ---
  3. API 엔드포인트 (src/app/api/trpc/[trpc]/route.ts)

  const handler = (req: Request) =>
    fetchRequestHandler({
      endpoint: '/api/trpc',
      req,
      router: appRouter,
      createContext: createTRPCContext,
    });
  export { handler as GET, handler as POST };

  역할:
  - Next.js App Router의 Route Handler로 tRPC 통합
  - /api/trpc/* 경로의 모든 요청을 tRPC가 처리
  - GET/POST 모두 동일 핸들러 사용 (tRPC가 내부에서 구분)

  ---
  4. 클라이언트 설정 (src/trpc/client.tsx)

  export const { TRPCProvider, useTRPC } = createTRPCContext<AppRouter>();

  export function TRPCReactProvider({ children }) {
    const queryClient = getQueryClient();
    const [trpcClient] = useState(() =>
      createTRPCClient<AppRouter>({ ... })
    );

    return (
      <QueryClientProvider client={queryClient}>
        <TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
          {children}
        </TRPCProvider>
      </QueryClientProvider>
    );
  }

  역할:
  - React Query + tRPC 통합: useTRPC() 훅 생성
  - 싱글톤 패턴: 브라우저에서 QueryClient 재사용
  - 환경별 URL 처리: Vercel 배포 시 자동 URL 감지

  핵심 로직:
  - Server: 매 요청마다 새 QueryClient 생성 (격리)
  - Client: 한 번만 생성하여 재사용 (캐시 유지)

  ---
  5. 서버 설정 (src/trpc/server.tsx)

  import 'server-only'; // 클라이언트 번들에서 제외

  export const getQueryClient = cache(makeQueryClient);
  export const trpc = createTRPCOptionsProxy({
    ctx: createTRPCContext,
    router: appRouter,
    queryClient: getQueryClient,
  });
  export const caller = appRouter.createCaller(createTRPCContext());

  역할:
  - Server Component 전용: server-only 패키지로 보호
  - trpc proxy: React Query의 queryOptions()와 함께 사용
  - caller: React Query 없이 직접 호출 가능 (서버 액션, 미들웨어 등)

  사용 예시:
  // Server Component
  const queryClient = getQueryClient();
  void queryClient.prefetchQuery(trpc.getUsers.queryOptions());

  // 또는 직접 호출
  const users = await caller.getUsers();

  ---
  6. Query Client 설정 (src/trpc/query-client.ts)

  export function makeQueryClient() {
    return new QueryClient({
      defaultOptions: {
        queries: { staleTime: 30 * 1000 },
        dehydrate: { shouldDehydrateQuery: ... },
      },
    });
  }

  역할:
  - Stale Time: 30초 동안 캐시 유지 (재요청 방지)
  - Dehydration: SSR 시 서버 데이터를 클라이언트로 전달
  - Pending Query: 로딩 중인 쿼리도 dehydrate (Suspense와 함께 사용)

  ---
  7. 앱 통합

  7-1. Root Layout (src/app/layout.tsx:31)

  <TRPCReactProvider>
    {children}
  </TRPCReactProvider>
  → 앱 전체를 Provider로 감싸 모든 컴포넌트에서 tRPC 사용 가능

  ---
  7-2. Server Component (src/app/page.tsx)

  const queryClient = getQueryClient();
  void queryClient.prefetchQuery(trpc.getUsers.queryOptions());

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Suspense fallback={<div>Loading...</div>}>
        <Client />
      </Suspense>
    </HydrationBoundary>
  );

  핵심 패턴:
  1. Server에서 prefetchQuery로 데이터 미리 로드
  2. dehydrate로 데이터를 직렬화
  3. HydrationBoundary로 클라이언트에 전달
  4. Suspense로 로딩 처리

  ---
  7-3. Client Component (src/app/client.tsx)

  const trpc = useTRPC();
  const { data:users } = useSuspenseQuery(trpc.getUsers.queryOptions());

  핵심:
  - useTRPC(): 타입 안전한 tRPC 인스턴스 획득
  - useSuspenseQuery: 서버에서 prefetch한 데이터 즉시 사용 (워터폴 없음)
  - 타입 추론: users는 자동으로 User[] 타입

  ---
  ★ Insight ─────────────────────────────────────

  1. Server/Client 이원화 전략: trpc/server.tsx(RSC용)와 trpc/client.tsx(RCC용)를 분리하여 각 환경에 최적화된 로직
  제공. server-only 패키지로 번들 오염 방지.
  2. Prefetch + Suspense 조합: Server Component에서 prefetchQuery로 데이터를 미리 로드하고, Client Component에서
  useSuspenseQuery로 즉시 사용하여 워터폴 제거. HydrationBoundary가 서버 상태를 클라이언트로 전달하는 다리 역할.
  3. Type-Safe End-to-End: AppRouter 타입을 클라이언트가 import하여 컴파일 타임에 API 변경 감지. Zod 스키마 없이도
  Prisma 타입이 자동으로 클라이언트까지 전파.

  ─────────────────────────────────────────────────

  ---
  🔄 데이터 흐름 예시

  Server Component 렌더링 시:
  1. page.tsx(Server) → getQueryClient()로 QueryClient 생성
  2. trpc.getUsers.queryOptions() → Server에서 prefetchQuery 실행
  3. prisma.user.findMany() → DB 쿼리
  4. dehydrate(queryClient) → 데이터 직렬화
  5. HydrationBoundary → 클라이언트로 전달
  6. client.tsx(Client) → useSuspenseQuery로 캐시에서 즉시 읽기 (네트워크 요청 없음)

  Client Side Navigation 시:
  1. client.tsx → useTRPC().getUsers.queryOptions()
  2. /api/trpc/getUsers로 fetch 요청
  3. route.ts → fetchRequestHandler → appRouter.getUsers
  4. prisma.user.findMany() → DB 쿼리
  5. Response 반환 → React Query 캐시 업데이트

  ---
  📝 현재 구조 체크리스트

  - tRPC 초기 설정 (init.ts)
  - API 라우터 정의 (_app.ts)
  - Next.js Route Handler 통합
  - React Query + tRPC 클라이언트 설정
  - Server Component에서 prefetch
  - Client Component에서 Suspense 사용
  - Prisma 연결
  - Middleware (인증, 로깅 등)
  - Input Validation (Zod 스키마)
  - Error Handling (TRPCError)
  - Mutation 예시 추가
  - SuperJSON Transformer (Date 직렬화)

일단 첫 인상은 이 친구 껴서 코딩하면 머리 아프게 계층 나눌 필요 없을 것 같다.

지금 일하는 곳에서 api 계층 나눠놓은거 생각하면 굉장히 골이 아프다.

심지어 보기 편하게 만들긴했지만, 1년 뒤 다시 보면 까먹지 않을까? 라는 생각도 방금 들었다.

그런 측면에서 tRPC와 함께하면 항상 이 고정된 형식으로 편하게 서버 컴포넌트던 클라이언트 컴포넌트던 사용할 수 있을 것 같다는 느낌이 드는 시간이였다.

profile
나 원장이 아니다

0개의 댓글