5편. Layered 모듈 적용 하기: Schema-First, DTO, TypeBox
📚 목차
1. Fastify 기반 모듈 설계의 기본 철학
2. Fastify 서버 구조 설계: Layer 분리와 DTO 책임 정리
3. Schema-First API 설계와 TypeBox 적용
4. Type Provider로 완성하는 Schema-First 타입 추론
5. Fastify 모듈 아키텍처 예제 코드

📂 [GitHub 코드 보러가기] : https://github.com/cericube/nodejs-practice-lab/tree/main/fastify-api-rest
1. Fastify 기반 모듈 설계의 기본 철학
애플리케이션의 규모가 커질수록, 하나의 파일에 모든 라우트를 정의하고 관리하는 방식에는 명확한 한계가 존재합니다.
Module 단위 설계란, User, Post, Payment와 같이 하나의 도메인에 속한 모든 책임과 관심사를 하나의 경계 안에 모으는 방식을 의미합니다. 이를 통해 도메인 내부의 응집도(Cohesion)는 높이고, 서로 다른 도메인 간의 결합도(Coupling)는 최소화할 수 있습니다.
1. Fastify Plugin 모델과의 궁합
Fastify는 설계 단계부터 플러그인 기반 아키텍처를 핵심 개념으로 삼고 있는 프레임워크입니다. Fastify에서 플러그인은 단순한 기능 추가 수단이 아니라, 독립된 실행 컨텍스트를 구성하는 단위로 동작합니다.
각 도메인 모듈을 하나의 Fastify Plugin으로 캡슐화하면, 해당 모듈에서 정의한 라우트, 훅, 데코레이터, 설정 값들이 모듈 내부에 한정되어 적용됩니다. 이를 통해 모듈 간의 불필요한 결합을 방지할 수 있으며, 플러그인 등록 순서를 통해 애플리케이션의 로딩 흐름을 명확하게 관리할 수 있습니다.
이러한 특성 덕분에 Fastify의 Plugin 모델은 Module 단위 설계와 매우 잘 어울리며, 애플리케이션 규모가 커지더라도 구조적인 안정성을 유지하는 데 효과적입니다.
2. "컨트롤러 중심"이 아닌 "도메인 중심"
많은 웹 프레임워크에서는 컨트롤러가 라우팅 정의와 비즈니스 로직을 함께 담당하는 경우가 많습니다. 그러나 이러한 구조는 시간이 지남에 따라 컨트롤러가 비대해지고, 핵심 비즈니스 규칙의 위치와 책임을 파악하기 어렵게 만드는 한계를 가집니다.
본 프로젝트에서는 이러한 문제를 피하기 위해 컨트롤러 중심이 아닌 도메인 중심 설계를 지향합니다. 핵심 유스케이스와 비즈니스 규칙은 Service 계층에 위치하며, 컨트롤러는 외부 요청을 도메인으로 전달하는 최소한의 역할만 수행합니다.
이로써 애플리케이션의 실제 행위와 규칙은 Service를 중심으로 명확하게 드러나며, 테스트와 확장 측면에서도 보다 안정적인 구조를 확보할 수 있습니다.
2. Fastify 서버 구조 설계: Layer 분리와 DTO 책임 정리
레이어를 나누는 목적은 "관심사의 분리"입니다. 각 레이어는 자신이 맡은 일 외에는 알 필요가 없어야 합니다.
🔸 Route는 계약을 정의하고,
🔸 Controller는 전달하며,
🔸 Service는 결정하고,
🔸 Repository는 저장한다.
🔸 DTO는 그 사이를 명확하게 연결한다.
1. 책임 분리 원칙
| 레이어 | 레이어 성격/ 역할 | 책임 |
| Route | - Transport / Contract Layer - API 계약(Contract) 정의 |
- 어떤 URL과 HTTP Method를 제공할지 정의 - 요청/응답 데이터 형식(JSON Schema) 명확히 규정 - 인증·권한 검사 연결 - 요청을 Controller로 전달 |
| Controller | - Application Adapter Layer - HTTP ↔ Domain 변환 |
- params / query / body 해석 - Service 호출 및 결과 수신 - 성공/실패에 따른 HTTP 상태 코드 결정 - 응답 데이터 형태 구성 |
| DTO/Schema | - Contract / Data Model Layer - 데이터 전달 모델 계층 |
- Controller ↔ Service 간 데이터 구조 정의 - 클라이언트 입력을 서비스용 형태로 정리 - 내부 데이터 구조를 숨기고 응답 형태 제공 |
| Service | - Domain / Business Layer - 비즈니스 흐름 |
- 핵심 비즈니스 로직 처리 - 여러 Repository 조합 - 도메인 규칙 및 정책 검증 - 트랜잭션 및 흐름 제어 |
| Repository | - Persistence Layer - 데이터 접근 추상화 |
- Prisma를 이용한 DB 쿼리 수행 - 데이터 저장·조회·수정·삭제 - DB 구조와 비즈니스 로직 분리 |
Fastify + Prisma 구조 예시
user/
├─ user.routes.ts # Route + Schema
├─ user.controller.ts # HTTP 처리
├─ user.service.ts # 비즈니스 로직
├─ user.repository.ts # Prisma 접근
└─ user.dto.ts # Service ↔ Controller 데이터 모델
2. DTO 전달 범위에 따른 3가지 대표 아키텍처 패턴
DTO(Data Transfer Object)를 어느 계층까지 전달할 것인지는 프로젝트 규모, 팀 구성, 유지보수 전략에 따라 중요한 설계 포인트가 됩니다.
실무에서 자주 사용되는 대표적인 3가지 패턴을 아래와 같이 정리할 수 있습니다.
1) 패턴 A - 단순 CRUD 구조 (소규모 서비스)
Request DTO → Controller → Service → Prisma
Response DTO ← Controller ← Service ← Prisma Model
특징
▸ DTO는 Controller까지만 사용
▸ Service/Repository는 Prisma 타입 직접 사용
▸ Service 및 Repository 계층에서는 Prisma Model 타입을 직접 사용
한계
▸ Prisma 스키마 변경이 API 스펙에 직접적인 영향을 미침
▸ 비즈니스 로직이 DB 모델에 강하게 결합됨
▸ Service 단위 테스트 작성이 상대적으로 어려움
2) 패턴 B - 실무에서 많이 사용하는 구조
대부분의 Fastify + Prisma 기반 실무 프로젝트에서 가장 현실적인 선택지에 해당합니다.
Request DTO → Controller → Service (DTO) → Repository → Prisma
Response DTO ← Presenter ← Service ← Repository ← Prisma Model
특징
▸ DTO가 Service 계층까지 전달됨
▸ Repository는 도메인 기준 인터페이스를 가짐
▸ Response DTO는 Presenter에서 명확히 변환
장점
▸ DB 모델과 비즈니스 로직의 결합도가 낮아짐
▸ API 스펙 변경과 DB 변경을 따로 관리 가능
▸ Service / Repository 단위 테스트 쉬움
▸ 팀 단위 개발 시 역할 분담이 명확
📌 전통적으로 Repository는 Entity(도메인 객체)를 다루고 DTO는 상위 계층에서만 사용하는 것이 이상적입니다.
그러나 Fastify + TypeBox + Prisma 환경에서는 타입 안정성과 개발 생산성을 위해 Repository에서도 DTO(또는 Input 타입)를 그대로 사용하는 경우가 많습니다.
3) 패턴 C - Clean Architecture / DDD 기반 구조
도메인 중심 설계를 적용한 구조로, 장기적인 확장성과 복잡한 비즈니스 로직을 전제로 합니다.
Clean Architecture
비즈니스 규칙을 프레임워크·DB·UI로부터 분리해서 보호하자
DDD (Domain-Driven Design)
복잡한 비즈니스 문제를 도메인 모델로 정확히 표현하자
Request DTO → Controller → UseCase (Command/Query) → Domain Entity → Repository → Prisma
Response DTO ← Presenter ← UseCase ← Domain Entity ← Mapper ← Prisma Model
특징
▸ Domain 계층이 완전히 독립적 (DB, 프레임워크 모름)
▸ Prisma는 Infrastructure 계층에만 존재
▸ 이벤트 기반, 메시지 큐, MSA 확장에 유리
단점
▸ 초기 설계 비용과 학습 곡선이 큼
▸ 단순 CRUD 위주의 서비스에는 과설계가 될 가능성 높음
▸ 개발 속도가 상대적으로 느림
3. DTO 명명규칙 예시
1) 기본 원칙
| 항목 | 규칙 |
| 접두어(Prefix) | 항상 {Entity} 사용 (User, Order, Payment 등) |
| 접미어(Suffix) | Schema = 런타임 검증 / Dto = TS 타입 |
| Swagger 노출명 | $id 로 Request / Response 계약 명칭 명시 |
| Response 계층 | Base / Detail 명확히 분리 |
엔티티(User)를 접두어로 사용하는 이유는 IDE에서 User...를 입력했을 때 모든 관련 스키마가 한눈에 나타나게 하여 가독성을 높이기 위해서 입니다.
2) Request / Query / Params
| 용도 | Schema | Dto | Swagger $id |
| Create Body | UserCreateBodySchema | UserCreateBodyDto | UserCreateRequest |
| Update Body | UserUpdateBodySchema | UserUpdateBodyDto | UserUpdateRequest |
| Query (List/Search) | UserListQuerySchema | UserListQueryDto | UserListQuery |
| Path Params | UserIdParamsSchema | UserIdParamsDto | UserIdParams |
3) Response
| 용도 | Schema | Dto | Swagger $id |
| Base Item (리스트용) | UserBaseSchema | UserBaseDto | UserBase |
| Detail Item (Join 포함) | UserDetailSchema | UserDetailDto | UserDetail |
| 단건 (Base) | UserResponseSchema | UserResponseDto | UserResponse |
| 단건 (Detail) | UserDetailResponseSchema | UserDetailResponseDto | UserDetailResponse |
| 목록 | UserListResponseSchema | UserListResponseDto | UserListResponse |
Fastify + TypeBox + Prisma 스택에서는 DTO가 스키마와 ORM 타입에서 파생되기 때문에, 타입 조합과 추론에 강한 type이 구조적으로 더 적합하다.
| 항목 | interface | type |
| 확장 (extends) | 강점 | 가능 |
| Union / Intersection | 불가 | 강점 |
| Prisma / Zod / TypeBox 조합 | 불리 | 매우 유리 |
| 타입 연산 | 약함 | 매우 강함 |
| 실무 선호도 (API 서버) | ↓ | ↑↑↑ |
3. Schema-First API 설계와 TypeBox 적용
1. Schema-first란
Schema-First는 비즈니스 로직을 구현하기 전에 API의 데이터 구조(Contract)를 먼저 정의하는 개발 방식입니다.
Fastify는 내부적으로 모든 요청과 응답을 JSON Schema 기반으로 처리하기 때문에, Schema-First 전략을 사용했을 때 가장 강력한 성능과 안정성을 발휘합니다.
Client → JSON Schema Validation(Ajv) → Handler → Reply Serialization
Schema-First의 핵심 장점
▸ 단일 진실 공급원 (SSOT): 스키마가 곧 API의 명세서가 됩니다.
프론트엔드와 백엔드가 동일한 데이터 구조를 기준으로 협업할 수 있습니다.
▸ 런타임 검증 (Ajv): 데이터가 핸들러에 도달하기 전, Fastify가 Ajv를 통해 유효성을 자동 검증합니다.
잘못된 요청은 비즈니스 로직 근처에도 가지 못합니다.
▸ 직렬화 최적화: fast-json-stringify를 사용하여 JSON.stringify보다 최대 2배 빠른 응답 속도를 제공합니다.
▸ 문서 자동화: 정의된 스키마는 곧바로 Swagger(OpenAPI) 문서로 변환됩니다.
문제점:
하지만 JSON Schema는 순수 JavaScript 객체입니다.
TypeScript 환경에서는 런타임 검증을 위한 JSON Schema와 컴파일 타임 타입을 위한 TypeScript Interface를 각각 따로 관리해야 했습니다.
// 런타임용 (JS 객체)
const UserSchema = { type: 'object', properties: { name: { type: 'string' } } };
// 컴파일용 (TS 인터페이스)
interface User { name: string; }
2. TypeBox: Schema와 Type의 단일 소스
TypeBox는 이 문제를 완벽하게 해결합니다.
TypeScript 코드로 스키마를 작성하면, 런타임에 사용할 JSON Schema와 컴파일 타임에 사용할 Type을 동시에 생성해 줍니다.
패키지 설치
▸ @sinclair/typebox : JSON Schema + TypeScript 타입 생성
npm install @sinclair/typebox
TypeScript 코드로 스키마를 정의하면, 이를 통해 JSON Schema를 생성하고 동시에 Static 타입을 추출할 수 있습니다.
import { Type, Static } from '@sinclair/typebox';
export const UserCreateBodySchema = Type.Object({
email: Type.String({ format: 'email' }),
displayName: Type.Optional(Type.String()),
});
export type UserCreateBodyDto = Static<typeof UserCreateBodySchema>;
3. DTO/Schema 파일 코드 예시 : TypeBox로 Schema와 DTO를 동시에 정의하기
TypeBox를 사용하여 요청(Request)과 응답(Response)의 데이터 구조를 스키마로 먼저 정의하고, 동일한 정의로부터 TypeScript 타입(DTO)을 자동 생성하는 Schema-First 방식을 적용한 예시입니다.
이를 통해 런타임 데이터 검증, API 문서(Swagger) 스펙, 컴파일 타임 타입 안정성을 하나의 소스에서 동시에 관리할 수 있습니다.
import { Type, Static } from '@sinclair/typebox';
// 회원 생성 요청 Body
export const UserCreateBodySchema = Type.Object(
{
email: Type.String({ format: 'email' }),
phoneNumber: Type.String(),
displayName: Type.Optional(Type.String({ minLength: 2, maxLength: 50 })),
},
{ $id: 'UserCreateRequest' }, // Swagger(OpenAPI)에서 $ref 대상 컴포넌트 이름
);
export type UserCreateBodyDto = Static<typeof UserCreateBodySchema>;;
// 기본 응답 아이템
export const UserResponseSchema = Type.Object(
{
id: Type.Integer(),
email: Type.String({ format: 'email' }),
displayName: Type.Optional(Type.String()),
createdAt: Type.String({ format: 'date-time' }),
},
{ $id: 'UserResponse' }, // Swagger(OpenAPI)에서 $ref 대상 컴포넌트 이름
);
export type UserResponseDto = Static<typeof UserResponseSchema>;
4. Route와 Controller코드 예시
Fastify 라우트 계층과 Controller 계층을 분리한 구조에서, TypeScript 제네릭과 DTO를 활용해 요청/응답 타입을 명확히 보장하는 방식을 보여주는 예시입니다.
전제: Type Provider 미사용 환경
// ❌ 사용 안 함
// fastify().withTypeProvider<TypeBoxTypeProvider>()
// ❌ 사용 안 함
// FastifyPluginAsyncTypebox
1) 방식 A - Route 제네릭으로 타입 지정
Fastify의 route 메서드는 제네릭을 지원합니다.
// userRoute 플러그인: HTTP 엔드포인트 정의 역할
export const userRoute: FastifyPluginAsync = async (fastify: FastifyInstance) => {
const controller = new UserController();
// Route 제네릭으로 타입 지정
// # Body: 요청 본문의 타입을 UserCreateBodyDto로 고정
// # Reply: 응답 데이터의 타입을 UserResponseDto로 고정
fastify.post<{ Body: UserCreateBodyDto; Reply: UserResponseDto }>(
'/',
{ ... },
// 제네릭 <{ Body: UserCreateBodyDto }> 에 의해
// 명확하게 고정되어 자동 추론된다.
async (request, reply) => {
const result = await controller.createUser(request.body);
return reply.code(200).send(result);
},
);
};
// UserController: 비즈니스 로직 진입 지점
export class UserController {
async createUser(data: UserCreateBodyDto): Promise<UserResponseDto> {
...
return user;
}
}
2) 방식 B - Handler에서 FastifyRequest 제네릭 사용
Handler 함수에서 직접 타입을 지정하는 방식입니다.
// userRoute 플러그인: HTTP 엔드포인트 정의 역할
export const userRoute: FastifyPluginAsync = async (fastify: FastifyInstance) => {
const controller = new UserController();
fastify.post('/',
{ ... },
async (
request: FastifyRequest<{ Body: UserCreateBodyDto }>,
reply: FastifyReply,
) => {
const result = await controller.createUser(request.body);
return reply.code(200).send(result);
},
);
};
// UserController: 비즈니스 로직 진입 지점
export class UserController {
async createUser(data: UserCreateBodyDto): Promise<UserResponseDto> {
...
return user;
}
}
5. Zod 대신 TypeBox를 쓰는 이유 (Fastify 기준)
Fastify에서는 API 계약용 스키마는 TypeBox가 가장 잘 맞고, Zod는 Service/Domain 검증에 더 적합하다.
| 항목 | TypeBox | Zod |
| JSON Schema 생성 | ✅ | ❌ |
| Fastify native schema | ✅ | ❌ |
| Type Provider 지원 | ✅ | ❌ |
| OpenAPI 자동화 | 쉬움 | 별도 작업 필요 |
| 런타임 비용 | 매우 낮음 | 상대적으로 높음 |
4. Type Provider로 완성하는 Schema-First 타입 추론
앞선 섹션에서는 Schema-First 설계와 TypeBox를 사용해 스키마와 타입을 하나의 소스로 관리하는 구조를 만들었습니다.
하지만 이 상태만으로는 Fastify의 Route Handler에서 스키마를 기반으로 한 타입 추론이 자동으로 동작하지는 않습니다.
1. Type Provider가 필요한 이유
TypeBox로 스키마와 DTO 타입을 함께 정의하더라도, Fastify 기본 타입 시스템은 다음과 같이 동작합니다.
async (req) => {
req.body; // unknown
}
이는 Fastify가 schema와 handler 타입 간의 관계를 알지 못하기 때문입니다. 따라서 Type Provider를 적용하지 않으면 다음과 같은 방식이 필요해집니다.
async (req: FastifyRequest<{ Body: UserCreateBodyDto }>) => {}
또는 route 제네릭을 직접 명시하는 패턴을 사용
app.post<{ Body: UserCreateBodyDto; Reply: UserResponseDto }>(...)
Type Provider를 적용하면 Fastify는 schema를 기준으로 handler 타입을 자동 추론하게 되며, route 제네릭이나 DTO 캐스팅이 필요하지 않게 됩니다.
TypeBoxTypeProvider는 Fastify에게 다음과 같은 규칙을 알려주는 역할을 합니다.
“이 Fastify 인스턴스에 등록되는 모든 route schema는 TypeBox 기반이므로, schema에서 TypeScript 타입을 자동으로 추론하라.”
패키지 설치
TypeBoxTypeProvider를 사용하려면 아래 두 개의 패키지를 함께 설치하셔야 합니다.
▸ @fastify/type-provider-typebox : Fastify와 TypeBox 스키마를 타입 수준에서 연결해 주는 브리지
npm install @fastify/type-provider-typebox @sinclair/typebox
2. Fastify 인스턴스에 전역 적용 : app.ts
Type Provider는 반드시 Fastify 인스턴스 생성 시점에 전역으로 한 번만 적용하는 것이 권장됩니다.
// src/app.ts
import type { TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
export const createApp = async () => {
const app = Fastify({
logger: {
...
},
// Fastify schema → TypeScript 타입 자동 연결
}).withTypeProvider<TypeBoxTypeProvider>();
return app;
};
📌 Promise<FastifyInstance> 반환 타입 지정 시 문제점
export const createApp = async (): Promise<FastifyInstance> => {
이렇게 반환 타입을 FastifyInstance로 고정하면, .withTypeProvider<TypeBoxTypeProvider>()로 설정한 Type Provider 정보가 타입에서 소실됩니다.
그 결과, route에서 TypeBox 스키마 기반 타입 추론이 동작하지 않게 됩니다.
방식 1 - 반환 타입을 명시하지 않는다 (권장)
export const createApp = async () => {
TypeScript가 실제 반환 타입을 자동 추론하여 FastifyInstance<..., TypeBoxTypeProvider> 형태가 그대로 유지됩니다.
방식 2 - Type Provider 포함한 제네릭을 명시한다
export const createApp = async (): Promise<
FastifyInstance<any, any, any, any, TypeBoxTypeProvider>
> => {
3. Route - Schema 기반 자동 타입 추론
Type Provider가 적용된 Fastify 인스턴스를 통해 Route를 정의하면, 스키마가 곧 handler 타입이 됩니다.
TypeBox를 사용하는 프로젝트에서는 FastifyPluginAsync가 아니라 FastifyPluginAsyncTypebox를 사용해야 schema 기반 자동 타입 추론이 활성화됩니다.
import type { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox';
export const userRoutes: FastifyPluginAsyncTypebox = async (fastify) => {
const controller = new UserController();
fastify.post(
'/',
{
schema: {
tags: ['User'], // Swagger용 필드, ✅ 에러 안 남
body: UserCreateBodySchema,
response: {
200: UserResponseSchema,
},
},
},
async (request, reply) => {
const req = request.body;
const result = await controller.createUser(req);
return reply.code(200).send(result);
},
);
}
📌 fastify 파라미터에 타입을 쓰지 않는 이유
FastifyPluginAsyncTypebox를 사용하고 있는 상황에서 fastify: FastifyInstance로 파라미터 타입을 다시 지정하면
타입 오류가 발생하거나, Type Provider 정보가 소실됩니다.
export const userRoutes: FastifyPluginAsyncTypebox = async (fastify : FastifyInstance) => {
방식 1 - 타입을 명시하지 않는다
export const userRoutes: FastifyPluginAsyncTypebox = async (fastify) => {
fastify의 타입은 이미 다음과 같이 완전하게 추론됩니다.
FastifyInstance<..., TypeBoxTypeProvider>
따라서 별도로 타입을 지정하지 않아도 fastify.post()의 schema 타입 및 route handler 내부의 req.body 타입 추론
이 모두가 정상적으로 동작합니다.
5. Fastify 모듈 아키텍처 예제 코드
1. Fastify App 생성 및 Type Provider 적용 : app.ts
// src/app.ts
import type { TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
import { routes } from './route';
export const createApp = async () => {
const app = Fastify({
logger: {
...
},
....
}).withTypeProvider<TypeBoxTypeProvider>();
// 3. Infrastructure Plugins
await app.register(prismaPlugin);
// 4. Global Error Policy
app.setErrorHandler(errorHandler);
// 5. Routes (Application Layer)
// 실제 비즈니스 API 엔드포인트 등록
// prefix를 통해 API versioning 또는 gateway routing 대응 가능
app.register(routes, { prefix: '/api' }); // → /api/users, /api/posts ...
return app;
};
2. API 요청/응답 스키마 및 DTO 정의: user.dto.js
import { Type, Static } from '@sinclair/typebox';
// 회원 생성 요청 Body
export const UserCreateBodySchema = Type.Object(
{
email: Type.String({ format: 'email' }),
phoneNumber: Type.String(),
displayName: Type.Optional(Type.String({ minLength: 2, maxLength: 50 })),
},
{ $id: 'UserCreateRequest' }, // Swagger(OpenAPI)에서 $ref 대상 컴포넌트 이름
);
export type UserCreateBodyDto = Static<typeof UserCreateBodySchema>;;
// 기본 응답 아이템
export const UserResponseSchema = Type.Object(
{
id: Type.Integer(),
email: Type.String({ format: 'email' }),
displayName: Type.Optional(Type.String()),
createdAt: Type.String({ format: 'date-time' }),
},
{ $id: 'UserResponse' }, // Swagger(OpenAPI)에서 $ref 대상 컴포넌트 이름
);
export type UserResponseDto = Static<typeof UserResponseSchema>;
3. User Route Plugin 구성 및 의존성 조립 : user.route.ts
// src/module/user/user.route.ts
export const userRoutes: FastifyPluginAsyncTypebox = async (fastify) => {
// 1. infra
const prisma = fastify.prisma;
// 2. repository
const userRepository = new UserRepository(prisma);
// 3. service
const userService = new UserService(userRepository);
// 4. controller
const userController = new UserController(userService);
fastify.post(
'/',
{
schema: {
//Swagger 전용 필드: TypeBoxTypeProvider를 사용해야 오류 안나고 무시함
tags: ['User'],
body: UserCreateBodySchema,
response: {
200: UserResponseSchema,
},
},
},
async (request, reply) => {
const result = await userController.createUser(request.body);
return reply.code(200).send(result);
},
);
...
};
4. Controller에서 DTO 기반 Use Case 진입 : user.controller.ts
export class UserController {
constructor(private readonly userService: UserService) {}
/**
* 사용자 생성 API 핸들러 (비즈니스 진입점)
*
* @param data UserCreateBodyDto
* - Route 계층에서 이미 Schema 검증 완료된 데이터
*/
async createUser(data: UserCreateBodyDto): Promise<UserResponseDto> {
return await this.userService.register(data);
}
}
5. Service에서 비즈니스 처리 및 DTO ↔ ORM 매핑 : user.service.ts
📌 DTO ↔ ORM(Prisma) 매핑시 주의점
▸ undefined와 null을 혼돈하지 말것
| 구분 | 의미 | 사용 위치 |
| undefined | 값이 아예 없음 | Request DTO |
| null | 값은 있으나 비어 있음 | DB / ORM |
DTO (optional) → Service → ORM (nullable)
string | undefined ?? null string | null
▸ Date는 항상 Service에서 직렬화 / 역직렬화
| 계층 | 타입 |
| ORM | Date |
| DTO | string (date-time) |
createdAt: row.createdAt.toISOString()
export class UserService {
constructor(private readonly repository: UserRepository) {}
async register(input: UserCreateBodyDto): Promise<UserResponseDto> {
/**
* Repository 호출 시 Prisma Generated Type 형태로 변환
* - Service → Repository 경계에서 ORM 타입으로 매핑
* - DTO가 DB 계층으로 직접 내려가지 않도록 차단
*/
const result = await this.repository.createUser({
email: input.email,
phoneNumber: input.phoneNumber,
displayName: input.displayName ?? null,
});
/**
* ORM 결과 → Response DTO 변환
*
* Prisma는 Date 객체를 반환하지만,
* API 스키마(TypeBox)는 ISO 문자열(date-time)을 요구하므로
* Service 계층에서 직렬화 책임을 가진다.
*
* → Controller는 데이터 변환 로직을 몰라도 됨
*/
return {
...result,
createdAt: result.createdAt.toISOString(), // 기존 값 덮어 씀
};
// return {
// id: result.id,
// email: result.email,
// displayName: result.displayName,
// createdAt: result.createdAt.toISOString(),
// };
}
}
6. Repository에서 Prisma ORM 직접 접근 : user.repository.ts
import { PrismaClient, Prisma } from '../../generated/client';
export class UserRepository {
constructor(private readonly prisma: PrismaClient) {}
/**
* 사용자 생성
*
* @param data Prisma UserCreateInput
* - Service 계층에서 가공된 값
*
* - create 시 필요한 필드만 명시적으로 매핑 (무분별한 spread 방지)
* - select를 사용해 외부로 노출할 필드 최소화
*/
async createUser(data: Prisma.UserCreateInput) {
return this.prisma.user.create({
data: {
email: data.email,
phoneNumber: data.phoneNumber,
displayName: data.displayName ?? null, // optional→ DB null 처리
},
select: {
id: true,
email: true,
displayName: true,
createdAt: true,
},
});
}
}
※ 게시된 글 및 이미지 중 일부는 AI 도구의 도움을 받아 생성되거나 다듬어졌습니다.
'4.Node.js > 실무익히기' 카테고리의 다른 글
| [REST API] 7편. User API 설계와 구현:DTO,Repository,Service,Route (0) | 2026.03.26 |
|---|---|
| [REST API] 6편. Fastify 스키마 검증, 전역 에러 처리, 로그 설계 : try/catch 지옥 벗어나기 (0) | 2026.03.25 |
| [REST API] 4편. Fastify + Prisma 구조 설계 하기: env, config, plugin (0) | 2026.03.18 |
| [REST API] 3편. Fastify API 서버 기본 골격 구현하기 : app, server (0) | 2026.03.17 |
| [REST API] 2편. API 서버 아키텍처 설계하기 : 프로젝트 구조, ER 설계, Prisma 모델링 (0) | 2026.03.11 |