이 글은 누구를 위한 것인가
- 프론트/백엔드 타입을 완전히 공유하는 풀스택 앱을 만들고 싶은 팀
- 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 시간을 단축하면 모노레포의 단점인 빌드 속도도 해결된다.