Claude Code와 tRPC로 타입 안전 API 만들기
Claude Code와 tRPC로 타입 안전 API를 만드는 방법. Next.js, Zod, 권한, 캐시 무효화, 리뷰 프롬프트를 다룹니다.
tRPC와 Claude Code를 함께 쓰는 이유
tRPC는 서버의 TypeScript router 정의를 클라이언트 호출 타입으로 그대로 공유하게 해 주는 프레임워크입니다. 별도의 OpenAPI 파일이나 손으로 만든 SDK 없이도, React 쪽에서 AppRouter 타입을 가져오면 입력값, 반환값, 에러 처리 흐름을 개발 중에 확인할 수 있습니다. 쉽게 말하면 서버와 클라이언트가 같은 API 설명서를 보는 구조입니다.
Claude Code는 단순히 initTRPC 코드를 만들어 주는 도구로 쓰면 아깝습니다. 실제 장점은 기존 프로젝트의 인증 방식, 폴더 구조, DB 접근 방식, React Query 사용 규칙을 읽고, 그 규칙에 맞춰 router, procedure, Zod 검증, 권한 체크, 캐시 무효화까지 일관되게 정리하게 하는 데 있습니다. tRPC는 타입 연결이 강력하지만, 타입이 맞는다고 해서 권한이나 런타임 데이터가 안전한 것은 아닙니다. 그래서 Claude Code에는 구현 생성과 동시에 비판적인 리뷰 역할을 맡기는 편이 좋습니다.
이 글은 Next.js App Router 기반의 작은 프로젝트 관리 API를 예로 듭니다. 예제를 복사해 실험할 수 있도록 데이터 저장소는 메모리의 projectStore로 단순화했습니다. 실제 서비스에서는 Prisma, Drizzle, Supabase 같은 영속 저장소로 바꾸고, 데모 인증을 실제 세션 조회로 교체해야 합니다.
잘 맞는 유스케이스
tRPC는 서버와 프런트엔드가 같은 TypeScript 코드베이스에서 함께 변경되는 경우에 특히 좋습니다. 외부 파트너에게 장기간 공개 API를 제공해야 한다면 OpenAPI가 더 적합할 수 있지만, 사내 화면이나 관리 기능에서는 tRPC의 생산성이 큽니다.
| 유스케이스 | tRPC의 장점 | Claude Code에 맡길 일 | 주의점 |
|---|---|---|---|
| 관리 화면 CRUD | 목록, 생성, 수정, 삭제의 입력과 반환 타입을 공유 | router, Zod schema, mutation, invalidation 생성 | 버튼 숨김을 권한 체크로 착각하지 않기 |
| 사내 도구 | 업무 변경을 API와 UI에 빠르게 반영 | 기존 모델에서 procedure 만들기 | context에 모든 의존성을 넣지 않기 |
| 폼 제출 | 이메일, 길이, enum을 런타임에도 검증 | 에러 메시지와 재전송 방지 구현 | TypeScript 타입만 믿고 Zod를 생략하지 않기 |
| 얇은 BFF | 화면에 필요한 응답 형태만 만들기 | 외부 API 응답 정리 | 캐시와 재조회 타이밍 정리 |
flowchart LR
UI["React component"]
Client["tRPC React client"]
Router["AppRouter"]
Procedure["protected/admin procedure"]
Zod["Zod validation"]
Store["DB or store"]
Review["Claude Code review"]
UI --> Client --> Router --> Procedure --> Zod --> Store
Router --> Review
Review --> Procedure
설치와 파일 배치
먼저 필요한 패키지를 설치합니다. 이미 React Query나 Zod를 쓰는 프로젝트라면 버전을 확인한 뒤 진행하는 것이 좋습니다.
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod superjson
예제는 다음 구조를 사용합니다. 서버 전용 코드는 src/server에 두고, App Router의 HTTP 진입점은 api/trpc에 둡니다.
src/
app/api/trpc/[trpc]/route.ts
app/projects/project-list.tsx
server/trpc.ts
server/routers/_app.ts
server/routers/project.ts
trpc/client.tsx
Claude Code에 요청할 때는 이 경계를 명시하세요. 서버 파일은 client component를 import하지 않고, client component는 저장소를 직접 import하지 않으며, 공유 타입은 별도 DTO가 아니라 tRPC router에서 흘러가게 한다는 규칙입니다.
context와 procedure 만들기
context는 각 요청에서 procedure로 전달되는 정보입니다. 세션, 팀 ID, 역할, DB 핸들 정도만 넣고 작게 유지하는 것이 좋습니다. 아래 코드는 로컬 데모를 위해 header에서 사용자를 읽습니다.
// src/server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
type Role = "admin" | "member";
type Session = { userId: string; teamId: string; role: Role };
export type Context = { session: Session | null };
export async function createContext({
headers,
}: {
headers: Headers;
}): Promise<Context> {
const roleHeader = headers.get("x-user-role");
const role: Role =
roleHeader === "admin"
? "admin"
: roleHeader === "member"
? "member"
: process.env.NODE_ENV === "production"
? "member"
: "admin";
return {
session: {
userId: headers.get("x-user-id") ?? "demo-user",
teamId: headers.get("x-team-id") ?? "demo-team",
role,
},
};
}
const t = initTRPC.context<Context>().create({ transformer: superjson });
export const createTRPCRouter = t.router;
export const publicProcedure = t.procedure;
const requireUser = t.middleware(({ ctx, next }) => {
if (!ctx.session) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Login is required." });
}
return next({ ctx: { session: ctx.session } });
});
export const protectedProcedure = t.procedure.use(requireUser);
export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.session.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN", message: "Admin role is required." });
}
return next();
});
protectedProcedure와 adminProcedure를 나누면 리뷰가 쉬워집니다. 삭제, 초대, 결제 변경처럼 위험한 mutation이 일반 보호 procedure를 쓰고 있다면 바로 질문할 수 있기 때문입니다.
Zod 검증이 있는 router
TypeScript는 빌드 시점의 약속입니다. 브라우저나 스크립트가 보내는 JSON이 실제로 올바른지는 런타임에서 확인해야 합니다. Zod는 이 경계에서 값의 형태를 검증합니다.
// src/server/routers/project.ts
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc";
type ProjectStatus = "todo" | "doing" | "done";
type Project = {
id: string;
teamId: string;
title: string;
ownerEmail: string;
status: ProjectStatus;
createdAt: string;
};
const projects = new Map<string, Project>();
const projectStatus = z.enum(["todo", "doing", "done"]);
const listProjectsInput = z
.object({
status: projectStatus.optional(),
query: z.string().trim().max(60).optional(),
limit: z.number().int().min(1).max(50).default(20),
})
.default({ limit: 20 });
const createProjectInput = z.object({
title: z.string().trim().min(2).max(80),
ownerEmail: z.string().email(),
});
export const projectRouter = createTRPCRouter({
list: protectedProcedure.input(listProjectsInput).query(({ ctx, input }) => {
return [...projects.values()]
.filter((project) => project.teamId === ctx.session.teamId)
.filter((project) => !input.status || project.status === input.status)
.filter((project) => !input.query || project.title.includes(input.query))
.slice(0, input.limit);
}),
create: adminProcedure.input(createProjectInput).mutation(({ ctx, input }) => {
const project: Project = {
id: crypto.randomUUID(),
teamId: ctx.session.teamId,
title: input.title,
ownerEmail: input.ownerEmail,
status: "todo",
createdAt: new Date().toISOString(),
};
projects.set(project.id, project);
return project;
}),
updateStatus: protectedProcedure
.input(z.object({ id: z.string().uuid(), status: projectStatus }))
.mutation(({ ctx, input }) => {
const project = projects.get(input.id);
if (!project || project.teamId !== ctx.session.teamId) {
throw new TRPCError({ code: "NOT_FOUND" });
}
const nextProject = { ...project, status: input.status };
projects.set(input.id, nextProject);
return nextProject;
}),
});
여기서 핵심은 teamId 확인입니다. UUID 형식이 맞아도 현재 사용자의 팀 데이터인지 확인하지 않으면 다른 팀의 데이터를 바꿀 수 있습니다. 타입 안전 API라도 업무 규칙은 직접 넣어야 합니다.
App Router와 React 클라이언트 연결
router를 모은 뒤 Route Handler에 연결합니다.
// src/server/routers/_app.ts
import { createTRPCRouter } from "../trpc";
import { projectRouter } from "./project";
export const appRouter = createTRPCRouter({
project: projectRouter,
});
export type AppRouter = typeof appRouter;
// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/routers/_app";
import { createContext } from "@/server/trpc";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => createContext({ headers: req.headers }),
});
export { handler as GET, handler as POST };
// src/trpc/client.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import { useState, type ReactNode } from "react";
import superjson from "superjson";
import type { AppRouter } from "@/server/routers/_app";
export const trpc = createTRPCReact<AppRouter>();
export function TRPCProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
transformer: superjson,
links: [httpBatchLink({ url: "/api/trpc" })],
}),
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
);
}
// src/app/projects/project-list.tsx
"use client";
import { useState } from "react";
import { trpc } from "@/trpc/client";
export function ProjectList() {
const utils = trpc.useUtils();
const [title, setTitle] = useState("");
const projects = trpc.project.list.useQuery({ limit: 20 });
const createProject = trpc.project.create.useMutation({
onSuccess: async () => {
setTitle("");
await utils.project.list.invalidate();
},
});
return (
<form
onSubmit={(event) => {
event.preventDefault();
createProject.mutate({ title, ownerEmail: "owner@example.com" });
}}
>
<input value={title} onChange={(event) => setTitle(event.target.value)} />
<button type="submit">Add</button>
<pre>{JSON.stringify(projects.data, null, 2)}</pre>
</form>
);
}
mutation이 성공했을 때 utils.project.list.invalidate()를 호출하면 목록을 다시 가져옵니다. 이 규칙을 정하지 않으면 생성은 성공했는데 화면에는 보이지 않는 문제가 생깁니다.
Claude Code 리뷰 프롬프트
이 Next.js App Router + tRPC 구현을 리뷰해 주세요.
확인할 항목:
1. publicProcedure / protectedProcedure / adminProcedure 사용이 맞는가
2. 외부 입력이 모두 Zod runtime validation을 거치는가
3. teamId와 userId의 tenant boundary가 읽기/쓰기 전에 확인되는가
4. mutation 후 영향을 받는 query가 invalidate되는가
5. router가 도메인별로 분리되어 있고 너무 커지지 않았는가
6. context에 불필요한 의존성이 들어 있지 않은가
7. 클라이언트에 반환하면 안 되는 비밀 필드가 없는가
심각도, 파일, 문제, 수정안, 추가 테스트를 표로 정리해 주세요.
이 프롬프트를 CLAUDE.md나 PR 템플릿에 넣으면 매번 같은 기준으로 리뷰할 수 있습니다. 특히 권한 변경은 Claude Code가 제안한 패치를 바로 적용하기보다, 먼저 diff와 위험 설명을 확인하는 편이 안전합니다.
흔한 실패와 함정
첫째, context 비대화입니다. 편하다는 이유로 모든 클라이언트와 상태를 넣으면 테스트가 어려워집니다. 둘째, UI 권한만 믿는 것입니다. 버튼을 숨겨도 API가 열려 있으면 의미가 없습니다. 셋째, TypeScript만 믿고 Zod를 빼는 것입니다. 실제 요청 JSON은 런타임에서 검증해야 합니다. 넷째, router를 나누지 않는 것입니다. 하나의 파일이 커지면 리뷰 품질이 떨어집니다. 다섯째, cache와 invalidation 설계가 없는 것입니다. 어떤 mutation이 목록, 상세, 카운터에 영향을 주는지 구현 전에 적어야 합니다.
공식 문서와 다음 단계
구현 전에는 tRPC 공식 Routers, Procedures, React Query integration을 확인하세요. 런타임 검증은 Zod 문서, App Router는 Next.js Route Handlers를 기준으로 삼으면 됩니다.
입력 검증을 더 깊게 보려면 Claude Code Zod 검증 가이드, TypeScript 설계를 정리하려면 Claude Code TypeScript 팁을 함께 읽어 보세요. 기존 관리 화면이나 사내 도구의 tRPC 구조를 점검하고 싶다면 Claude Code 컨설팅과 교육에서 권한 설계와 리뷰 프롬프트까지 같이 다룰 수 있습니다.
실제로 해 본 결과
작은 관리 화면 프로토타입에 적용해 보니, CRUD를 먼저 만들기보다 protectedProcedure와 adminProcedure를 먼저 정의한 것이 가장 효과적이었습니다. Claude Code가 mutation마다 권한 수준을 지적하기 쉬웠고, Zod schema를 procedure 근처에 둔 덕분에 폼 필드와 API 입력을 함께 고칠 수 있었습니다. 반대로 context에 편의 값을 많이 넣을수록 테스트가 무거워졌기 때문에, 최종 구조에서는 업무 로직을 별도 함수로 분리했습니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.