일단 다음에도 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와 함께하면 항상 이 고정된 형식으로 편하게 서버 컴포넌트던 클라이언트 컴포넌트던 사용할 수 있을 것 같다는 느낌이 드는 시간이였다.