풀스택 모노레포: Next.js + Prisma + tRPC 실전 구성

프론트엔드

Next.jsPrismatRPC모노레포Turborepo

이 글은 누구를 위한 것인가

  • 프론트/백엔드 타입을 완전히 공유하는 풀스택 앱을 만들고 싶은 팀
  • REST API 없이 타입 안전한 RPC로 클라이언트-서버 통신을 구현하려는 개발자
  • Turborepo 모노레포로 여러 앱과 패키지를 관리하려는 팀

들어가며

tRPC는 REST API 없이 TypeScript 타입을 프론트-백엔드 사이에서 완전히 공유한다. Prisma ORM과 결합하면 DB 스키마 → API 타입 → 클라이언트까지 end-to-end 타입 안전성을 얻는다.

이 글은 bluefoxdev.kr의 풀스택 모노레포 가이드 를 참고하여 작성했습니다.


1. 모노레포 구조

[Turborepo 모노레포 구조]

apps/
  web/          Next.js 프론트엔드
  admin/        Next.js 관리자 앱
packages/
  db/           Prisma 스키마 + 클라이언트
  api/          tRPC 라우터 정의
  auth/         NextAuth.js 설정
  ui/           공유 컴포넌트 (shadcn/ui 기반)
  config/       ESLint, TypeScript 공유 설정

[tRPC 아키텍처]
  packages/api/src/
    root.ts     → appRouter (모든 라우터 합치기)
    trpc.ts     → createTRPCRouter, publicProcedure, protectedProcedure
    routers/
      product.ts
      order.ts
      user.ts

[데이터 흐름]
  Prisma Schema → PrismaClient (packages/db)
  → tRPC router input/output (packages/api)
  → React Query hooks (apps/web)
  완전한 타입 공유, 런타임 검증 없음

2. tRPC + Prisma 풀스택 구현

// packages/db/prisma/schema.prisma
// generator client {
//   provider = "prisma-client-js"
// }
// datasource db {
//   provider = "postgresql"
//   url      = env("DATABASE_URL")
// }
// model Product {
//   id          String   @id @default(cuid())
//   name        String
//   description String?
//   price       Decimal  @db.Decimal(10, 2)
//   stock       Int      @default(0)
//   category    String
//   createdAt   DateTime @default(now())
//   updatedAt   DateTime @updatedAt
//   orders      OrderItem[]
// }

// packages/db/src/index.ts
import { PrismaClient } from '@prisma/client';

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

export const prisma = globalForPrisma.prisma ?? new PrismaClient({
  log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma;
}

export * from '@prisma/client';
// packages/api/src/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { type Session } from 'next-auth';
import superjson from 'superjson';
import { ZodError } from 'zod';
import { prisma } from '@myapp/db';

interface CreateContextOptions {
  session: Session | null;
}

export const createTRPCContext = async (opts: CreateContextOptions) => ({
  ...opts,
  prisma,
});

type Context = Awaited<ReturnType<typeof createTRPCContext>>;

const t = initTRPC.context<Context>().create({
  transformer: superjson,  // Date, Map, Set 직렬화 지원
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError: error.cause instanceof ZodError
          ? error.cause.flatten()
          : null,
      },
    };
  },
});

export const createTRPCRouter = t.router;

// 공개 프로시저
export const publicProcedure = t.procedure;

// 인증 필요 프로시저
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({ ctx: { ...ctx, session: ctx.session } });
});
// packages/api/src/routers/product.ts
import { z } from 'zod';
import { createTRPCRouter, publicProcedure, protectedProcedure } from '../trpc';
import { TRPCError } from '@trpc/server';

const createProductSchema = z.object({
  name: z.string().min(1).max(100),
  description: z.string().optional(),
  price: z.number().positive().multipleOf(0.01),
  stock: z.number().int().min(0),
  category: z.string().min(1),
});

export const productRouter = createTRPCRouter({
  // 목록 조회 (공개)
  list: publicProcedure
    .input(z.object({
      category: z.string().optional(),
      search: z.string().optional(),
      page: z.number().int().min(1).default(1),
      limit: z.number().int().min(1).max(100).default(20),
    }))
    .query(async ({ ctx, input }) => {
      const { category, search, page, limit } = input;
      
      const where = {
        ...(category && { category }),
        ...(search && {
          OR: [
            { name: { contains: search, mode: 'insensitive' as const } },
            { description: { contains: search, mode: 'insensitive' as const } },
          ],
        }),
      };
      
      const [products, total] = await Promise.all([
        ctx.prisma.product.findMany({
          where,
          skip: (page - 1) * limit,
          take: limit,
          orderBy: { createdAt: 'desc' },
        }),
        ctx.prisma.product.count({ where }),
      ]);
      
      return {
        products,
        total,
        pages: Math.ceil(total / limit),
      };
    }),
  
  // 단건 조회
  byId: publicProcedure
    .input(z.string().cuid())
    .query(async ({ ctx, input }) => {
      const product = await ctx.prisma.product.findUnique({
        where: { id: input },
      });
      
      if (!product) {
        throw new TRPCError({ code: 'NOT_FOUND', message: '상품을 찾을 수 없습니다' });
      }
      
      return product;
    }),
  
  // 생성 (인증 필요)
  create: protectedProcedure
    .input(createProductSchema)
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.product.create({ data: input });
    }),
  
  // 수정
  update: protectedProcedure
    .input(z.object({
      id: z.string().cuid(),
      data: createProductSchema.partial(),
    }))
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.product.update({
        where: { id: input.id },
        data: input.data,
      });
    }),
});
// apps/web/src/utils/api.ts - 클라이언트 설정
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@myapp/api';

export const api = createTRPCReact<AppRouter>();

// apps/web/src/app/products/page.tsx - 클라이언트 사용
'use client';

import { api } from '~/utils/api';

export default function ProductsPage() {
  // 자동 완성, 타입 안전한 API 호출
  const { data, isLoading } = api.product.list.useQuery({
    category: 'electronics',
    page: 1,
    limit: 20,
  });
  
  const createProduct = api.product.create.useMutation({
    onSuccess: () => {
      // 목록 자동 갱신
      utils.product.list.invalidate();
    },
  });
  
  const utils = api.useUtils();
  
  if (isLoading) return <div>로딩 중...</div>;
  
  return (
    <div>
      <ul>
        {data?.products.map(product => (
          <li key={product.id}>
            {/* product 타입이 DB 스키마에서 자동 추론됨 */}
            {product.name} - ₩{Number(product.price).toLocaleString()}
          </li>
        ))}
      </ul>
      <button
        onClick={() => createProduct.mutate({
          name: '새 상품',
          price: 10000,
          stock: 100,
          category: 'electronics',
        })}
      >
        상품 추가
      </button>
    </div>
  );
}

마무리

tRPC + Prisma 조합은 백엔드 API를 작성하면 프론트엔드에서 자동으로 타입이 완성되는 경험을 제공한다. REST API에서 발생하는 타입 불일치, 과도한 응답, 부족한 응답 문제가 구조적으로 불가능해진다. Turborepo의 --filter 옵션으로 영향받는 앱만 빌드하고 캐싱으로 CI 시간을 단축하면 모노레포의 단점인 빌드 속도도 해결된다.