
Next.js에서 프로덕션 수준 API 만들기
oRPC로 입출력 검증, 컨트랙트 우선 개발, OpenAPI 문서 자동 생성까지 한 번에 잡는 방법을 정리했어요.
Next.js Route Handler만으로는 부족해요
Next.js의 Route Handler(라우트 핸들러)로 API를 만들 수 있죠. GET, POST 요청을 받고 응답을 반환하는 건 간단해요.
export async function GET() {
return Response.json({ message: "Hello World" });
}그런데 여기서부터가 문제예요. 이 코드에는 입력 검증이 없어요. 출력 스키마(Schema)도 없고요. 에러 응답 포맷도 제각각이에요. 프론트엔드 팀이 API 문서를 달라고 하면? 2023년에 만든 Google Docs 링크를 보내게 되죠.
프로덕션 수준 API의 조건
런타임(Runtime) 입출력 검증, 컨트랙트 우선 개발, OpenAPI 문서 자동 생성, 일관된 에러 응답, 레이어 분리, 미들웨어 확장성. 이 중 하나라도 빠지면 프로덕션에서 문제가 돼요.
Next.js Route Handler는 "API 엔드포인트를 만들 수 있다"는 기본 기능만 제공해요. 검증, OpenAPI, 미들웨어(Middleware) 같은 건 전부 직접 구현해야 해요. API를 만드는 시간보다 인프라를 구성하는 시간이 더 걸리는 거죠.
tRPC는? 거의 다 맞는데 한 가지가 빠져요
tRPC를 쓰면 End-to-End(종단 간) 타입 안전한 API를 만들 수 있어요. 하지만 치명적인 문제가 하나 있어요. OpenAPI를 공식 지원하지 않아요. 서드파티 플러그인이 있긴 하지만, 안정적이지 않아요.
외부 클라이언트(모바일 앱, 파트너 API)에 문서를 제공해야 한다면 tRPC만으로는 부족해요.
oRPC라는 대안
oRPC는 tRPC의 대안이에요. 타입 안전한 API를 만들면서 OpenAPI를 기본 지원해요.
tRPC와 비교했을 때 차이점:
| tRPC | oRPC | |
|---|---|---|
| 타입 안전성 | O | O |
| OpenAPI 지원 | 서드파티 플러그인 | 기본 내장 |
| 컨트랙트(Contract) 우선 개발 | 제한적 | 기본 지원 |
| 보일러플레이트(Boilerplate) | 많음 | 적음 |
oRPC를 Next.js와 함께 쓰면, Next.js의 기존 백엔드 위에 검증/문서화/미들웨어 레이어를 얹는 형태가 돼요. 별도의 백엔드 서버 없이요.
이 글에서는 oRPC + Next.js + Prisma로 튜토리얼 CRUD API를 만드는 전체 과정을 다뤄요.
전체 워크플로우
완성된 프로젝트의 구조부터 볼게요.
schemas → contract → router → api 순서로 만들어가요. 각 단계가 다음 단계의 기반이 되는 구조예요.
1단계: Zod로 런타임 스키마 정의하기
먼저 리소스 모델을 Prisma로 정의해요.
model Tutorial {
id String @id @default(uuid())
title String
slug String @unique
content String
status TutorialStatus @default(Draft)
tags String[]
authorId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
publishedAt DateTime?
@@index([authorId])
@@index([status])
@@index([createdAt, id])
}
enum TutorialStatus {
Draft
Published
Archived
}이 모델을 기반으로 Zod 스키마를 만들어요. 입력과 출력을 분리하는 게 핵심이에요.
import { z } from "zod";
import { TutorialStatus } from "@/app/generated/prisma/enums";
export const TutorialStatusEnum = z.enum(
Object.values(TutorialStatus) as [TutorialStatus, ...TutorialStatus[]],
);
// 리소스 모델의 전체 스키마
export const TutorialSchema = z.object({
id: z.uuid(),
title: z.string().min(1).max(200),
slug: z.string().min(1).max(100).regex(
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
"Slug must be lowercase alphanumeric with hyphens",
),
content: z.string(),
status: TutorialStatusEnum,
tags: z.array(z.string().min(1).max(50)).max(10),
authorId: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
publishedAt: z.date().nullable(),
});
// ── 출력 스키마 ──
export const TutorialOutput = TutorialSchema;
export const TutorialListOutput = z.object({
items: z.array(TutorialOutput),
nextCursor: z.string().nullable(),
});
// ── 입력 스키마 ──
export const CreateTutorialInput = z.object({
title: z.string().min(1).max(200),
slug: z.string().min(1).max(100).regex(
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
"Slug must be lowercase alphanumeric with hyphens",
),
content: z.string().default(""),
status: TutorialStatusEnum.exclude(["Archived"]).default("Draft"),
tags: z.array(z.string().min(1).max(50)).max(10).default([]),
});
export const UpdateTutorialInput = z.object({
id: z.uuid(),
title: z.string().min(1).max(200).optional(),
slug: z.string().min(1).max(100).regex(
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
"Slug must be lowercase alphanumeric with hyphens",
).optional(),
content: z.string().optional(),
status: TutorialStatusEnum.exclude(["Archived"]).optional(),
tags: z.array(z.string().min(1).max(50)).max(10).optional(),
});
export const DeleteTutorialInput = z.object({ id: z.uuid() });
export const GetBySlugInput = z.object({ slug: z.string().min(1).max(100) });
export const ListTutorialsInput = z.object({
cursor: z.uuid().optional(),
limit: z.number().int().min(1).max(100).default(20),
status: TutorialStatusEnum.exclude(["Archived"]).optional(),
tag: z.string().min(1).optional(),
authorId: z.string().optional(),
});입력 스키마에서 CreateTutorialInput은 status에 "Archived"를 허용하지 않아요. 생성 시점에 아카이브 상태로 만드는 건 말이 안 되니까요. 이런 비즈니스 규칙을 스키마 레벨에서 강제하는 거예요.
출력 스키마는 단건(TutorialOutput)과 목록(TutorialListOutput)을 분리했어요. 목록 응답에는 커서 기반 페이지네이션(Pagination)을 위한 nextCursor가 포함돼요.
2단계: oRPC 컨트랙트 정의하기
컨트랙트는 "이 API가 무엇을 받고, 무엇을 반환하는지"를 선언하는 거예요. 코드 한 줄 구현하기 전에 먼저 합의하는 청사진이죠.
먼저 공통 에러를 정의해요.
import { oc } from "@orpc/contract";
import z from "zod";
import {
CreateTutorialInput, DeleteTutorialInput, GetBySlugInput,
ListTutorialsInput, TutorialListOutput, TutorialOutput,
UpdateTutorialInput,
} from "../schemas/tutorial.schema";
export const base = oc.errors({
UNAUTHORIZED: {
status: 401,
message: "Authentication required",
},
FORBIDDEN: {
status: 403,
message: "You do not have permission to perform this action",
},
NOT_FOUND: {
status: 404,
message: "Resource not found",
data: z.object({
resourceType: z.string(),
resourceId: z.string(),
}),
},
CONFLICT: {
status: 409,
message: "Resource conflict",
data: z.object({
field: z.string(),
value: z.string(),
}),
},
DOMAIN_RULE_VIOLATION: {
status: 422,
message: "Business rule violation",
data: z.object({ rule: z.string() }),
},
});모든 에러가 status + message 구조를 따르고, 필요한 경우 data로 추가 정보를 강제해요. NOT_FOUND를 발생시킬 때는 반드시 resourceType과 resourceId를 넘겨야 하는 거죠. 이게 "일관된 에러 응답"의 핵심이에요.
이제 이 base 위에 각 엔드포인트의 컨트랙트를 쌓아요.
export const createTutorialContract = base
.route({
method: "POST",
path: "/tutorials",
successStatus: 201,
summary: "Create a new tutorial",
description: "Creates a tutorial in draft or published state. Requires authentication",
tags: ["Tutorials"],
})
.input(CreateTutorialInput)
.output(TutorialOutput);
export const updateTutorialContract = base
.route({
method: "PATCH",
path: "/tutorials/{id}",
summary: "Update a tutorial",
description: "Partially updates a tutorial. Requires authentication",
tags: ["Tutorials"],
})
.input(UpdateTutorialInput)
.output(TutorialOutput);
export const deleteTutorialContract = base
.route({
method: "DELETE",
path: "/tutorials/{id}",
summary: "Delete (archive) a tutorial",
description: "Soft-deletes a tutorial by setting status to archived. Idempotent.",
tags: ["Tutorials"],
})
.input(DeleteTutorialInput)
.output(TutorialOutput);
export const getBySlugContract = base
.route({
method: "GET",
path: "/tutorials/{slug}",
summary: "Get a tutorial by slug",
description: "Fetches a single tutorial. Published tutorials are public; drafts are only visible to the author.",
tags: ["Tutorials"],
})
.input(GetBySlugInput)
.output(TutorialOutput);
export const listTutorialsContract = base
.route({
method: "GET",
path: "/tutorials",
summary: "List tutorials",
description: "Lists tutorials with cursor-based pagination. Public users see only published tutorials.",
tags: ["Tutorials"],
})
.input(ListTutorialsInput)
.output(TutorialListOutput);.route()에 method, path, summary, description, tags를 넣었어요. 이 정보가 나중에 OpenAPI 문서로 자동 변환돼요. 코드 변경 시 문서가 자동으로 따라오는 구조예요.
마지막으로 컨트랙트 라우터를 조립해요.
import {
createTutorialContract, deleteTutorialContract,
getBySlugContract, listTutorialsContract, updateTutorialContract,
} from "./tutorial.contract";
export const contract = {
tutorial: {
create: createTutorialContract,
update: updateTutorialContract,
delete: deleteTutorialContract,
getBySlug: getBySlugContract,
list: listTutorialsContract,
},
};3단계: 미들웨어 만들기
프로시저(Procedure)를 만들기 전에 인증 미들웨어부터 준비해요. 인증이 필요한 엔드포인트(Endpoint)와 선택적 인증 엔드포인트가 있으니까, 두 가지를 만들어요.
import { implement } from "@orpc/server";
import { contract } from "../contract";
export interface User { id: string }
export interface BaseContext { headers: Headers }
function parseToken(authorization: string | null): User | null {
if (!authorization) return null;
const token = authorization.split(" ")[1];
if (!token) return null;
return { id: token }; // 데모용. 프로덕션에서는 JWT 검증 필요
}
const os = implement(contract);
/** 유효한 Bearer 토큰 필수. 없으면 UNAUTHORIZED 에러 */
export const authMiddleware = os
.$context<BaseContext>()
.middleware(async ({ context, next, errors }) => {
const user = parseToken(context.headers.get("authorization"));
if (!user) {
throw errors.UNAUTHORIZED();
}
return next({ context: { user } });
});
/** Bearer 토큰이 있으면 사용자 정보 전달, 없으면 null */
export const optionalAuthMiddleware = os
.$context<BaseContext>()
.middleware(async ({ context, next }) => {
const user = parseToken(context.headers.get("authorization"));
return next({ context: { user } });
});errors.UNAUTHORIZED()는 2단계에서 정의한 컨트랙트의 에러를 그대로 사용해요. 미들웨어에서도 일관된 에러 응답이 유지되는 거죠.
데모용 인증
parseToken은 토큰 값을 그대로 userId로 쓰는 데모 코드예요. 프로덕션에서는 JWT 검증이나 세션 조회를 넣어야 해요.
4단계: 프로시저 구현하기
컨트랙트를 구현하는 단계예요. 컨트랙트에서 입출력 스키마를 이미 정의했기 때문에, 프로시저에서는 비즈니스 로직에만 집중하면 돼요.
import { implement } from "@orpc/server";
import { contract } from "../contract";
import prisma from "../lib/db";
import { authMiddleware, BaseContext, optionalAuthMiddleware } from "./middleware";
const os = implement(contract).$context<BaseContext>();
export const createTutorial = os.tutorial.create
.use(authMiddleware) // [!code highlight]
.handler(async ({ input, errors, context }) => {
const existing = await prisma.tutorial.findUnique({
where: { slug: input.slug },
select: { id: true },
});
if (existing) {
throw errors.CONFLICT({ // [!code highlight]
data: { field: "slug", value: input.slug },
cause: "SLUG_ALREADY_EXISTS",
});
}
const data = await prisma.tutorial.create({
data: {
...input,
authorId: context.user.id, // [!code highlight]
publishedAt: input.status === "Published" ? new Date() : null,
},
});
return data;
});핵심 포인트:
.use(authMiddleware)- 인증된 사용자만 접근 가능errors.CONFLICT()- 컨트랙트에서 정의한 에러를 발생시켜요.data에field와value를 넣어야 타입 체크를 통과해요context.user.id- 미들웨어가 주입한 사용자 정보를 그대로 사용return data- 반환값이TutorialOutput스키마와 맞지 않으면 타입 에러가 발생해요
업데이트 프로시저도 같은 패턴이에요. 소유자 확인과 slug 중복 검사가 추가돼요.
export const updateTutorial = os.tutorial.update
.use(authMiddleware)
.handler(async ({ input, context, errors }) => {
const { id, ...updates } = input;
const tutorial = await prisma.tutorial.findUnique({ where: { id } });
if (!tutorial) {
throw errors.NOT_FOUND({
data: { resourceId: id, resourceType: "Tutorial" },
});
}
if (tutorial.authorId !== context.user.id) {
throw errors.FORBIDDEN();
}
if (updates.slug && updates.slug !== tutorial.slug) {
const slugTaken = await prisma.tutorial.findUnique({
where: { slug: updates.slug },
select: { id: true },
});
if (slugTaken) {
throw errors.CONFLICT({
data: { field: "slug", value: updates.slug },
});
}
}
return await prisma.tutorial.update({
where: { id },
data: {
...updates,
publishedAt:
updates.status === "Published" && !tutorial.publishedAt
? new Date()
: undefined,
},
});
});목록 조회 프로시저는 optionalAuthMiddleware를 사용해요. 비인증 사용자는 Published 글만, 인증된 사용자는 자기 Draft까지 볼 수 있어요.
export const listTutorials = os.tutorial.list
.use(optionalAuthMiddleware) // [!code highlight]
.handler(async ({ input, context, errors }) => {
const { cursor, limit, status, tag, authorId } = input;
const userId = context.user?.id ?? null;
if (status === "Draft" && (!userId || (authorId && authorId !== userId))) {
throw errors.FORBIDDEN();
}
const where: any = {};
if (status) {
where.status = status;
} else if (!userId) {
where.status = "Published"; // 비인증: Published만
} else {
where.OR = [
{ status: "Published" },
{ status: "Draft", authorId: userId }, // 인증: 내 Draft 포함
];
}
if (authorId) where.authorId = authorId;
if (tag) where.tags = { has: tag };
const rows = await prisma.tutorial.findMany({
where,
orderBy: { createdAt: "desc" },
take: limit + 1,
...(cursor ? { skip: 1, cursor: { id: cursor } } : {}),
});
const hasMore = rows.length > limit;
const items = hasMore ? rows.slice(0, limit) : rows;
return {
items,
nextCursor: hasMore ? items[items.length - 1].id : null,
};
});프로시저를 다 만들었으면 라우터로 조립해요.
import { implement } from "@orpc/server";
import { contract } from "../contract";
import { BaseContext } from "./middleware";
import {
createTutorial, deleteTutorial, getBySlug,
listTutorials, updateTutorial,
} from "./tutorial";
const os = implement(contract).$context<BaseContext>();
export const router = os.router({
tutorial: {
create: createTutorial,
update: updateTutorial,
delete: deleteTutorial,
getBySlug: getBySlug,
list: listTutorials,
},
});컨트랙트에 정의된 프로시저 중 하나라도 빠지면 타입 에러가 나요. 컨트랙트가 청사진 역할을 하기 때문에, 구현이 누락되는 걸 컴파일 타임에 잡아줘요.
5단계: Next.js 연결 + OpenAPI 문서 생성
마지막으로 Next.js Route Handler에 oRPC를 연결하고, OpenAPI 문서를 자동 생성하도록 설정해요.
import { OpenAPIHandler } from "@orpc/openapi/fetch";
import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins";
import { SmartCoercionPlugin } from "@orpc/json-schema";
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
import { CORSPlugin } from "@orpc/server/plugins";
import { onError } from "@orpc/server";
import { router } from "@/app/router";
const schemaConverters = [new ZodToJsonSchemaConverter()];
const handler = new OpenAPIHandler(router, {
plugins: [
new CORSPlugin(),
new SmartCoercionPlugin({ schemaConverters }),
new OpenAPIReferencePlugin({
schemaConverters,
specGenerateOptions: {
info: {
title: "Tutorial API",
version: "1.0.0",
description: "Production-grade Tutorial API built with oRPC and Next.js",
},
security: [{ bearerAuth: [] }],
components: {
securitySchemes: {
bearerAuth: { type: "http", scheme: "bearer" },
},
},
},
}),
],
interceptors: [
onError((error) => { console.error(error) }),
],
});
async function handleRequest(request: Request) {
const { response } = await handler.handle(request, {
prefix: "/api",
context: { headers: request.headers },
});
return response ?? new Response("Not found", { status: 404 });
}
export const HEAD = handleRequest;
export const GET = handleRequest;
export const POST = handleRequest;
export const PUT = handleRequest;
export const PATCH = handleRequest;
export const DELETE = handleRequest;[[...rest]] Catch-All(포괄) 라우트가 모든 /api/* 요청을 oRPC 핸들러로 넘겨줘요. OpenAPIReferencePlugin이 컨트랙트에서 정의한 route(), summary, description 정보를 읽어서 OpenAPI 스펙을 생성해요.
이제 localhost:3000/api에 접속하면 자동 생성된 API 문서가 나타나요. 컨트랙트를 변경하면 문서도 자동으로 업데이트돼요. Google Docs 동기화는 이제 필요 없어요.
동작 확인
dev 서버를 실행하고 API를 테스트해볼게요.
curl http://localhost:3000/api/tutorialscurl -X POST http://localhost:3000/api/tutorials \
-H "Content-Type: application/json" \
-H "Authorization: Bearer user-1" \
-d '{"title":"My First Tutorial","slug":"my-first-tutorial","content":"Hello","status":"Draft","tags":["next.js"]}'인증 없이 생성을 시도하면 컨트랙트에서 정의한 형태의 에러가 반환돼요.
{
"code": "UNAUTHORIZED",
"status": 401,
"message": "Authentication required"
}출력 스키마에 맞지 않는 데이터를 반환하려고 하면? 서버에서 타입 에러가 발생해요. 입력과 출력 양쪽 모두 런타임에 검증되는 거예요.
마무리
체크리스트
- 런타임 입출력 검증: Zod 스키마가 입력과 출력을 런타임에 검증해요
- 컨트랙트 우선 개발: 구현 전에 컨트랙트부터 합의. 빠진 프로시저는 컴파일 타임에 에러
- OpenAPI 자동 생성: 컨트랙트의
route()정보가 문서로 변환. 수동 동기화 불필요 - 일관된 에러 응답:
base.errors()로 정의한 에러가 모든 프로시저에서 재사용 - 미들웨어 확장성: 인증, 권한 검사 등을
.use()로 체이닝(Chaining)
TL;DR
- Next.js Route Handler는 기본 기능만 제공해요. 검증, 문서화, 에러 처리를 직접 구현해야 해요
- tRPC는 타입 안전하지만 OpenAPI 지원이 없어요
- oRPC는 타입 안전성 + OpenAPI를 기본 지원해요
- 스키마 → 컨트랙트 → 프로시저 → 라우트 핸들러 순서로 만들면 각 레이어가 다음 레이어의 기반이 돼요
- 컨트랙트를 바꾸면 OpenAPI 문서가 자동으로 업데이트돼요. 수동 문서 동기화는 더 이상 필요 없어요
참고 자료
- 영상: Production Grade APIs — 이 글의 원본 영상 (Jan Marshal)
- GitHub 레포 — 전체 소스 코드
- oRPC 공식 문서 — oRPC 설치 및 API 레퍼런스