들어가며
지난 글에서 환경변수 관리와 데이터베이스 플러그인 설정까지 마쳤다. 이제 비즈니스 로직을 구현할 차례다.
스프링에서는 Controller, Service, Repository로 계층을 나눠서 개발했었는데 fastify에서도 비슷한 구조를 유지하려고 했다. Controller 대신 Routes라고 부르고, DTO 클래스 대신 Zod 스키마를 사용하는 등 약간의 차이점은 있었다.
이번 글에서는 Plan 도메인의 CRUD API를 node.js 환경으로 전환했던 과정을 정리해보려고 한다.
Zod를 사용하여 스키마 작성
핵심로직, 라우트 등을 먼저 구현하기 전에 스프링 코드와 대칭되는 DTO를 작성했다.
라우트 내부에서 schema 블럭에 요청DTO의 형식을 정할 수도 있지만 Zod라는 것을 사용해봤다.
Zod는 타입스크립트 런타임 스키마(validation) 라이브러리로, 입력 데이터의 검증 과 타입 유추(Type inference) 를 동시에 제공하는 도구다. 입력 검증 + 타입 안전성”을 한 번에 해결하는 TS 전용 유효성 검증기라고 보면 된다.
스키마 작성
// plan.schema.ts
import { z } from 'zod';
export const CreatePlanSchema = z.object({
title: z.string()
.min(1, '제목은 필수 입력 값입니다.')
.max(50, '제목은 최대 50자까지 가능합니다.'),
startDate: z.iso.date(),
endDate: z.iso.date(),
region: z.string()
.min(1, '지역은 필수 입력 값입니다.'),
visible: z.boolean().default(false),
copyAllowed: z.boolean().default(false),
});
export const UpdatePlanSchema = z.object({
title: z.string()
.min(1, '제목은 필수 입력 값입니다.')
.max(50, '제목은 최대 50자까지 가능합니다.'),
startDate: z.iso.date(),
endDate: z.iso.date(),
region: z.string(),
visible: z.boolean(),
copyAllowed: z.boolean(),
}).strict(); // -> 정의 되지 않은 필드 거부
export const GetPlansQuerySchema = z.object({
cursor: z.string()
.transform((raw) => {
try {
return JSON.parse(raw);
} catch {
throw new Error('cursor는 JSON 형식이어야 합니다.');
}
})
.pipe(z.object({
createdAt: z.iso.datetime(),
id: z.uuid()
})).optional(),
size: z.coerce.number()
.min(1, 'size는 최소 1 이상이어야 합니다.')
.max(100, 'size는 최대 100 이하이어야 합니다.')
.default(20)
.optional(),
});
export const PlanResponseSchema = z.object({
id: z.uuid(),
title: z.string(),
startDate: z.iso.date(),
endDate: z.iso.date(),
region: z.string(),
visible: z.boolean(),
copyAllowed: z.boolean(),
bookmarkCount: z.number(),
likeCount: z.number(),
createdAt: z.iso.datetime(),
});
export const PageResponseSchema = <T extends z.ZodTypeAny>(itemSchema: T) => z.object({
items: z.array(itemSchema),
nextCursor: z.object({
createdAt: z.iso.datetime(),
id: z.uuid(),
}).nullable(),
hasNext: z.boolean(),
size: z.number(),
});
// 타입 스크립트의 타입 자동 추론
export type CreatePlanRequest = z.infer<typeof CreatePlanSchema>;
export type UpdatePlanRequest = z.infer<typeof UpdatePlanSchema>;
export type GetPlansQuery = z.infer<typeof GetPlansQuerySchema>;
export type PlanResponse = z.infer<typeof PlanResponseSchema>;
z.infer를 사용하면 스키마에서 타입을 자동으로 추출해준다. zod의 사용법은 zod 공식문서를 참고하였다.
Repository 계층 구현
스프링의 JPA Repository는 인터페이스만 만들어놓으면 구현체를 자동으로 만들어줬다. prisma는 그런 게 없어서 직접 메서드를 작성해야 했다.
// plan.repository.ts
import { PrismaClient, Plan } from "@prisma/client";
export class PlanRepository {
constructor(private prisma: PrismaClient) { }
// 생성
async create(data: {
title: string;
startDate: string;
endDate: string;
region: string;
visible: boolean;
copyAllowed: boolean;
}): Promise<Plan> {
return this.prisma.plan.create({
data: {
title: data.title,
startDate: new Date(data.startDate),
endDate: new Date(data.endDate),
region: data.region,
visible: data.visible,
copyAllowed: data.copyAllowed,
}
})
}
// 단건 조회
async findById(id: string): Promise<Plan | null> {
return this.prisma.plan.findUnique({
where: { id }
});
}
// 존재 여부 확인
async existsById(id: string): Promise<boolean> {
const count = await this.prisma.plan.count({
where: { id }
});
return count > 0;
}
// 수정
async update(id: string, data: Partial<Omit<Plan, 'id' | 'createdAt'>>): Promise<Plan> {
return this.prisma.plan.update({
where: { id },
data
});
}
// 삭제
async delete(id: string): Promise<void> {
await this.prisma.plan.delete({
where: { id }
});
}
// 첫 페이지 조회 (페이징 시작용)
async findFirstPage(size: number): Promise<Plan[]> {
return this.prisma.plan.findMany({
orderBy: { createdAt: 'desc' },
take: size + 1, // hasNext 체크용
});
}
// 커서 기반 다음페이지 조회
async findWithCursor(cursorCreatedAt: string, cursorId: string, size: number): Promise<Plan[]> {
return this.prisma.plan.findMany({
take: size,
skip: 1,
cursor: {
createdAt_id: { // 복합 커서
createdAt: new Date(cursorCreatedAt),
id: cursorId
}
},
orderBy: [
{ createdAt: 'desc' },
{ id: 'desc' }
],
});
}
특이사항으로는 create 시 API 요청에서는 "2025-12-01" json 문자열로 받을테니, prisma에 넣을 때는 Date 객체로 변환해주어야 한다.
또, 커서로 다음페이지 조회를 하는 메서드(findWithCursor)에서 createdAt_id 라는 복합커서가 있다.
uuid 타입인 id(pk)는 순차성이 없다. 그러므로 createdAt 컬럼으로 데이터 생성시각으로 순차성을 만족시키기 위해 id와 createdAt을 묶어서 하나의 복합 커서로 사용한 것이다. createdAt만으로 정렬하면 같은 시각에 생성된 레코드들 사이의 순서를 보장할 수 없기 때문에, id를 보조 정렬/커서 기준으로 함께 사용해서 정렬의 안정성과 커서의 유일성을 동시에 확보했다.
Service 계층 구현
// plan.service.ts
import { Plan } from "@prisma/client";
import { PlanRepository } from "./plan.repository";
import { CreatePlanRequest, GetPlansQuery, PlanResponse, UpdatePlanRequest } from "./plan.schema";
import { mapPlanToResponse } from "./plan.mapper";
export class PlanService {
constructor(private planRepository: PlanRepository) { }
async createPlan(request: CreatePlanRequest): Promise<PlanResponse> {
const plan: Plan = await this.planRepository.create({
title: request.title,
startDate: request.startDate,
endDate: request.endDate,
region: request.region,
visible: request.visible,
copyAllowed: request.copyAllowed,
});
return mapPlanToResponse(plan);
}
async updatePlan(planId: string, request: UpdatePlanRequest): Promise<PlanResponse> {
const exists: boolean = await this.planRepository.existsById(planId);
if (!exists) {
throw new Error('찾을 수 없는 계획 ID: ' + planId);
}
const plan: Plan = await this.planRepository.update(planId, {
title: request.title,
startDate: new Date(request.startDate),
endDate: new Date(request.endDate),
region: request.region,
visible: request.visible,
copyAllowed: request.copyAllowed,
});
return mapPlanToResponse(plan);
}
async deletePlan(planId: string): Promise<void> {
const exists: boolean = await this.planRepository.existsById(planId);
if (!exists) {
throw new Error('찾을 수 없는 계획 ID: ' + planId);
}
await this.planRepository.delete(planId);
}
async getPlans(query: GetPlansQuery) {
const size = query.size ?? 20;
let plans: Plan[];
if (query.cursor == null) {
plans = await this.planRepository.findFirstPage(size + 1);
} else {
plans = await this.planRepository.findWithCursor(
query.cursor.createdAt,
query.cursor.id,
size + 1
);
}
const hasNext = plans.length > size;
const items = plans.slice(0, size);
const nextCursor = hasNext && items.length > 0
? {
createdAt: items[items.length - 1].createdAt.toISOString(),
id: items[items.length - 1].id,
}
: null;
return {
items: items.map(mapPlanToResponse),
nextCursor,
hasNext,
size
}
}
async getPlanDetails(planId: string): Promise<PlanResponse> {
const plan: Plan | null = await this.planRepository.findById(planId);
if (plan == null) {
throw new Error('찾을 수 없는 계획 ID: ' + planId);
}
return mapPlanToResponse(plan);
}
}
Service는 Repository를 호출하고 비즈니스 로직을 처리하는 부분이다. 스프링과 거의 비슷하게 구현할 수 있었다.
Routes 계층 구현
// src/modules/plan/plan.routes.ts
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { PlanService } from "./plan.service";
import {
CreatePlanSchema,
UpdatePlanSchema,
GetPlansQuerySchema,
PlanResponseSchema,
PageResponseSchema,
CreatePlanRequest,
GetPlansQuery,
UpdatePlanRequest
} from "./plan.schema";
const PlanIdParamSchema = z.object({
planId: z.uuid()
});
export async function planRoutes(fastify: FastifyInstance, service: PlanService) {
// 계획 생성
fastify.post('/api/plans',
{
schema: {
body: CreatePlanSchema,
response: {
201: PlanResponseSchema,
},
tags: ['Plan'],
description: '새 계획을 생성합니다.',
}
},
async (request, reply) => {
const data = request.body as CreatePlanRequest;
const result = await service.createPlan(data);
reply.code(201).send(result);
}
);
// 계획 목록 조회 (커서 페이지네이션)
fastify.get('/api/plans',
{
schema: {
querystring: GetPlansQuerySchema,
response: {
200: PageResponseSchema(PlanResponseSchema),
},
tags: ['Plan'],
description: '계획 목록을 조회합니다. (커서 기반 페이지네이션)',
}
},
async (request, reply) => {
const data = request.query as GetPlansQuery;
const result = await service.getPlans(data);
reply.code(200).send(result);
}
);
// 계획 단건 조회
fastify.get('/api/plans/:planId',
{
schema: {
params: PlanIdParamSchema,
response: {
200: PlanResponseSchema,
},
tags: ['Plan'],
description: '계획 상세 정보를 조회합니다.',
}
},
async (request, reply) => {
const { planId } = request.params as { planId: string };
const result = await service.getPlanDetails(planId);
reply.code(200).send(result);
}
);
// 계획 수정
fastify.patch('/api/plans/:planId',
{
schema: {
params: PlanIdParamSchema,
body: UpdatePlanSchema,
response: {
200: PlanResponseSchema,
},
tags: ['Plan'],
description: '계획을 수정합니다.',
}
},
async (request, reply) => {
const { planId } = request.params as { planId: string };
const data = request.body as UpdatePlanRequest;
const result = await service.updatePlan(planId, data);
reply.code(200).send(result);
}
);
// 계획 삭제
fastify.delete('/api/plans/:planId',
{
schema: {
params: PlanIdParamSchema,
response: {
204: z.void(),
},
tags: ['Plan'],
description: '계획을 삭제합니다.',
}
},
async (request, reply) => {
const { planId } = request.params as { planId: string };
await service.deletePlan(planId);
reply.code(204).send();
}
);
}
Controller에 해당하는 부분이다.
스프링과 다른 점이라면 매개변수의 두번 째 인자값으로 schema라는 것을 명시하는데 이곳에서 파라미터, 요청 바디, 응답 형식, 태그, 디스크립션을 정의 해놓을 수 있다.
그 후 함수 내에서 사용자의 요청을 처리하고 알맞게 응답한다.
타입 추론 문제
여기서 좀 답답했던 게 request.body가 unknown 타입으로 나와서 타입 에러가 났다는 점이다.
const result = await service.createPlan(request.body);
// Argument of type 'unknown' is not assignable.
원인을 찾아보니 fastify의 TypeProvider 설정이 필요했다. app.ts에 이렇게 추가했다.
// app.ts
import { serializerCompiler, validatorCompiler, ZodTypeProvider } from 'fastify-type-provider-zod';
const app = Fastify({
logger: {
level: env.NODE_ENV === 'dev' ? 'info' : 'warn',
},
}).withTypeProvider<ZodTypeProvider>();
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
이 후 request.body 데이터를 각각 알맞은 Schema 타입으로 타입 단언을 해서 해결이 됐다.
Module 통합
마지막으로 모든 계층을 플러그인으로 등록했다.
// plan.module.ts
import { FastifyPluginAsync } from "fastify";
import fp from 'fastify-plugin';
import { PlanRepository } from "./plan.repository";
import { PlanService } from "./plan.service";
import { planRoutes } from "./plan.routes";
const planModule: FastifyPluginAsync = async (fastify, options) => {
// 1. Repository 생성
const repository = new PlanRepository(fastify.prisma);
// 2. Service 생성
const service = new PlanService(repository);
// 3. Routes 등록
await planRoutes(fastify, service);
fastify.log.info("Plan 모듈이 등록되었습니다.");
};
export default fp(planModule, {
name: 'plan-module',
dependencies: ['database'], // database 플러그인 먼저 로드
});
스프링의 의존성 주입처럼 자동으로 되진 않지만, 이렇게 수동으로 주입해주면 된다. 코드가 명시적이라 오히려 이해하기 쉬웠다.
일단은 포스트맨으로 모든 라우트에 대한 응답결과가 정상으로 오는 것을 확인 했지만, 명확한 테스트는 다음 글에서 해 볼 생각이다.
마치며
이번 글에서는 Plan 도메인의 전체 CRUD API를 구현 및 node.js(fastify) 환경으로 전환하는 과정을 작성해보았다.
다음 글에서는 Playwright MCP를 Claude Code에 연결해서 테스트 자동화를 시도해볼 예정이다.