[REST API] 7편. User API 설계와 구현:DTO,Repository,Service,Route
7편. User API 설계와 구현 : DTO,Repository,Service,Route
📚 목차
1. User API 전체 구조와 요청 처리 흐름
2. 요청·응답 스키마 정의 (TypeBox DTO 설계)
3. Prisma 기반 데이터 접근 구현 (Repository 구현)
4. 요청 처리 로직과 DTO 변환 (Service 구현)
5. API 엔드포인트 구성과 검증 (Route 구현)
6. 공통 성공 응답 구조 구현

📂 [GitHub 코드 보러가기] : https://github.com/cericube/nodejs-practice-lab/tree/main/fastify-api-rest
1. User API 전체 구조와 요청 처리 흐름
✔️ 요청/응답 처리 흐름
User 도메인은 다음과 같은 계층 구조로 구현되어 있습니다.
# 요청
Route → Controller → Service → Repository → Database
# 응답
Repository → Service → Controller → Route → Client
1) Route (입력 검증)
▸ TypeBox 스키마를 통해 Body, Params, Query를 검증합니다.
▸ 유효하지 않은 요청은 이 단계에서 차단됩니다.
→ 입력 데이터의 정합성을 보장하는 역할을 수행합니다.
2) Controller (전달 및 매핑)
▸ 검증된 데이터를 Service 계층으로 전달합니다.
▸ 비즈니스 로직은 포함하지 않습니다.
▸ 요청 DTO와 응답 DTO 변환을 담당합니다.
→ 계층 간 연결 역할만 수행합니다.
3) Service (비즈니스 로직)
▸ 도메인 규칙을 적용합니다.
▸ Soft Delete 여부 확인
▸ 존재 여부 검증
▸ 데이터 가공 및 예외 처리
→ 실제 비즈니스 로직이 집중된 계층입니다.
4) Repository (데이터 접근)
▸ Prisma Client를 사용하여 DB 쿼리를 수행합니다.
▸ Soft Delete 조건을 포함한 조회/수정 쿼리가 구현되어 있습니다.
→ 데이터 영속성에 대한 책임만 가집니다.
✔️ 코드 구현 순서
User 도메인은 다음 순서로 구현되었습니다
DTO → Repository → Service → Controller → Route
이는 HTTP 계층부터 작성하는 방식이 아니라, 도메인 내부에서 외부 인터페이스로 확장하는 방식입니다.
1) API 계약을 먼저 고정하기 위함 (DTO 우선)
DTO를 먼저 정의함으로써 입력·출력 구조와 타입 제약을 명확히 확정합니다.
이렇게 API 계약을 선행 고정하면, 이후 계층은 해당 계약을 기준으로 구현되므로 타입 안정성과 일관성을 확보할 수 있습니다.
2) 데이터 접근을 조기에 검증하기 위함 (Repository 선행)
Repository를 먼저 구현하면 Prisma 쿼리의 정확성, 인덱스 활용, Soft Delete 조건, Pagination 안정성을 초기 단계에서 확인할 수 있습니다.
데이터 접근 계층을 먼저 안정화함으로써 상위 계층 구현의 불확실성을 제거합니다.
3) 비즈니스 로직을 중심에 두기 위함 (Service 핵심화)
Service는 도메인 규칙이 집중되는 핵심 계층입니다.
Repository가 준비된 상태에서 Service를 구현하면, 존재 여부 검증, 정책 적용, 예외 처리 등 비즈니스 로직에만 집중할 수 있습니다.
4) Controller를 얇은 계층으로 유지
Controller는 DTO와 Service가 완성된 이후에 작성됩니다.
이로 인해 Controller는 단순 매핑과 호출만 담당하게 되며, 비즈니스 로직이 침투하지 않는 얇은 계층으로 유지됩니다.
5) HTTP 인터페이스를 마지막에 연결하기 위함 (Route)
Route는 외부 진입점이지만 도메인의 핵심은 아닙니다.
마지막에 연결함으로써 HTTP 레벨의 변경이 내부 로직에 영향을 주지 않도록 분리할 수 있습니다.
✔️ 구현된 API 목록
현재 User 도메인에는 다음 API가 구현되어 있습니다.
| Method | Path | 기능 | 주요 스키마 |
| POST | /api/users | 사용자 생성 | UserCreateBodySchema |
| PATCH | /api/users/:id | 사용자 수정 | UserIdParamsSchema UserUpdateBodySchema |
| GET | /api/users | 사용자 단건 조회 | UserQuerySchema |
| GET | /api/users/count | 사용자 수 조회 | UserCountParamsSchema |
| GET | /api/users/exists | 사용자 존재 여부 | UserQuerySchema |
| DELETE | /api/users/:id | 사용자 Soft Delete | UserIdParamsSchema |
| PATCH | /api/users/:id/restore | 사용자 복구 | UserIdParamsSchema |
| GET | /api/users/list | 사용자 목록 조회 | UserListQuerySchema |
2. 요청·응답 스키마 정의 (TypeBox DTO 설계)
User 도메인은 @sinclair/typebox 기반 DTO 설계를 통해 런타임 검증과 정적 타입 안정성을 동시에 확보하고 있습니다.
Fastify + @fastify/type-provider-typebox 조합에 최적화된 구조로, 스키마 정의가 곧 타입이 되도록 일관되게 구성되어 있습니다.
typebox 1.x는 구조 개선이 진행 중이지만, Fastify ecosystem 사례가 아직 제한적이므로 현재 프로젝트에서는 안정성을 우선하여 0.x 계열을 사용하고 있습니다.
✔️ additionalProperties: false의 핵심 역할
모든 Object 스키마에 additionalProperties: false를 명시적으로 설정하였습니다.
요청과 응답 모두에서 정의되지 않은 필드를 차단하는 것입니다.
이를 통해 악의적 필드 주입을 방지하고, DB 내부 필드(예: passwordHash)가 응답에 노출되는 것을 원천적으로 차단합니다.
공통 정책은 Atom Schema로 분리하여 재사용성과 일관성을 유지합니다.
const emailSchema = Type.String({ format: 'email' });
const krNumberSchema = Type.String({
pattern: '^\\+8210\\d{8}$', // E.164 (+8210xxxxxxxx)
});
✔️ 요청 DTO 예시
▸ 명시된 필드만 허용
▸ 전화번호는 E.164 형식 강제
▸ displayName은 길이 제한 적용
export const UserCreateBodySchema = Type.Object(
{
email: emailSchema,
phoneNumber: krNumberSchema,
displayName: Type.Optional(Type.String({ minLength: 2, maxLength: 50 })),
},
{ $id: 'UserCreateRequest', additionalProperties: false },
);
✔️ Type.Partial을 활용
선택적 업데이트를 지원하되, 정의되지 않은 속성은 여전히 차단합니다.
export const UserUpdateBodySchema = Type.Partial(
Type.Object({
displayName: Type.Optional(Type.String({ minLength: 2, maxLength: 50 })),
email: emailSchema,
phoneNumber: krNumberSchema,
}),
{ $id: 'UserUpdateRequest', additionalProperties: false },
);
✔️ Union 기반 조회 예시
ID, 이메일, 전화번호 중 하나로 조회하도록 설계하였으며, Union 내부 각 객체에 개별적으로 additionalProperties: false를 적용합니다.
export const UserQuerySchema = Type.Union(
[
Type.Object({ id: Type.Integer() }, { additionalProperties: false }),
Type.Object({ email: emailSchema }, { additionalProperties: false }),
Type.Object({ phoneNumber: krNumberSchema }, { additionalProperties: false }),
],
{ $id: 'UserQuery' },
);
주의: Union 전체에 additionalProperties: false를 설정하지 않고, 내부 객체별로 설정해야 의도한 검증이 동작합니다.
✔️ 응답 DTO 예시
export const UserResponseSchema = Type.Object(
{
id: Type.Integer(),
email: emailSchema,
phoneNumber: Type.String(),
displayName: Type.Union([Type.String(), Type.Null()]),
createdAt: Type.String({ format: 'date-time' }),
updatedAt: Type.String({ format: 'date-time' }),
},
{ $id: 'UserResponse', additionalProperties: false },
);
응답 또한 스키마에 정의된 필드만 반환되도록 강제하여, 내부 모델과 외부 노출 모델을 명확히 분리합니다.
3. Prisma 기반 데이터 접근 구현 (Repository 구현)
user.repository.ts는 데이터 영속성 계층으로서, DB 접근을 Prisma Client에 위임하고 Soft Delete 정책을 일관되게 강제하는 역할을 수행합니다.
✔️ Soft Delete 일관성 보장
모든 조회·수정 로직에 deletedAt 조건을 포함하여 논리 삭제된 데이터가 접근되지 않도록 합니다.
Prisma 4.5+의 Extended Where Unique를 활용하여, id + deletedAt: null 조건을 DB 레벨에서 강제합니다.
따라서 “존재하지만 이미 삭제된 데이터”는 조회 단계에서 차단됩니다.
where: {
...searchCondition,
deletedAt: null,
}
✔️ 타입 안전한 조회 조건 (UserBaseWhere)
▸ id / email / phoneNumber 중 하나만 허용
▸ 잘못된 조합을 컴파일 단계에서 차단
▸ Service 계층에서 불필요한 방어 코드 감소
type UserBaseWhere =
| { id: number; email?: never; phoneNumber?: never }
| { id?: never; email: string; phoneNumber?: never }
| { id?: never; email?: never; phoneNumber: string };
✔️ Select 기반 데이터 최소화 및 조건부 Join
▸ 공통 필드만 명시적으로 조회
▸ includeProfile이 true일 때만 Profile JOIN
▸ 네트워크 트래픽 최소화 및 민감 정보 차단
→ 필요할 때만 관계를 로딩하는 Lazy Join 전략입니다.
select: {
...userBaseSelect,
...(includeProfile && {
profile: { select: profileSelect },
}),
}
✔️ 조건부 필터링 (부분 검색)
▸ displayName이 존재할 때만 부분 일치 검색 적용
▸ 항상 deletedAt: null 조건 포함 → Soft Delete 정책 강제
→ Repository는 “삭제되지 않은 사용자만 조회한다”는 정책을 일관되게 유지합니다.
where: {
...(displayName && { displayName: { contains: displayName } }),
deletedAt: null,
}
✔️ 안전한 옵션 처리
▸ options ?? {}로 런타임 에러 방지
▸ 기본값을 명시하여 예측 가능한 동작 유지
▸ includeProfile 기본값을 false로 설정하여 불필요한 JOIN 방지
→ 호출자가 옵션을 생략해도 항상 안전하게 동작합니다.
const { includeProfile = false, displayName = undefined, orderBy = undefined } = options ?? {};
✔️ 정렬 조건의 동적 구성
▸ 클라이언트 요청이 있으면 해당 필드와 방향 적용
▸ 요청이 없으면 { id: 'asc' } 기본 정렬 유지
▸ 결과 순서의 예측 가능성 확보
const orderByClause: Prisma.UserOrderByWithRelationInput = orderBy
? { [orderBy.field]: orderBy.direction }
: { id: 'asc' };
4. 요청 처리 로직과 DTO 변환 (Service 구현)
UserService는 Repository 위에서 동작하는 유스케이스 실행 계층으로, 비즈니스 흐름 제어와 엔티티 → DTO 변환 책임을 가집니다.
DB 접근은 Repository에 위임하고, Service는 “어떤 흐름으로 실행할 것인가”와 “어떤 형태로 응답할 것인가”를 결정합니다.
✔️ 요청 DTO → 가공 → Repository → 가공 → 응답 DTO 흐름
Service는 요청 DTO를 그대로 전달하지 않고, 도메인 친화적 형태로 가공한 뒤 Repository에 위임하고, 반환된 엔티티를 다시 응답 DTO로 변환합니다.
▸ 입력 DTO를 Repository 요구 형태로 정제
▸ DB 접근은 Repository에 위임
▸ 반환 엔티티는 반드시 응답 DTO로 변환
→ Service는 데이터 모델과 API 계약 사이의 완충 계층 역할을 수행합니다.
async getUser(query: UserQueryDto): Promise<UserDetailResponseDto> {
const { includeProfile, ...searchCondition } = query;
const isIncludeProfile = String(includeProfile) === 'true';
const user = await this.repository.selectOne({
...searchCondition,
includeProfile: isIncludeProfile,
});
return toUserDetailResponse(user, isIncludeProfile);
}
✔️ 비즈니스 흐름 제어
Service는 단순히 Repository를 호출하는 계층이 아니라, 도메인 정책을 반영한 실행 흐름을 구성합니다.
▸ Soft Delete 여부는 Repository에서 이미 강제됨
▸ Service는 정책을 신뢰하고 흐름만 제어
▸ 반환 전 반드시 DTO로 변환
async updateUser(userId: UserIdParamsDto, input: UserUpdateBodyDto): Promise<UserResponseDto> {
const user = await this.repository.update(userId.id, input);
return toUserDetailResponse(user);
}
✔️ exactOptionalPropertyTypes 대응
exactOptionalPropertyTypes: true 설정 시 선택적 속성에 undefined를 명시적으로 넣는 것이 허용되지 않습니다.
▸ Prisma는 “속성 부재”와 “undefined 값”을 다르게 해석
▸ 잘못 전달하면 컬럼이 의도치 않게 null로 갱신될 수 있음
▸ 따라서 구조 분해 후 필요한 값만 새 객체로 재구성
const { includeProfile, ...searchCondition } = query;
const isIncludeProfile = String(includeProfile) === 'true';
✔️ 조건 기반 흐름 분기
Service는 “어떤 메서드를 호출할지”를 결정하는 계층입니다.
▸ 페이징 요청 여부에 따라 다른 Repository 메서드 호출
▸ Service가 유스케이스 레벨의 흐름을 제어
▸ Repository는 단일 책임 유지
if (query.skip !== undefined || query.take !== undefined) {
const { data, total } = await this.repository.selectManyWithCount({...});
...
} else {
const users = await this.repository.selectMany({...});
}
5. API 엔드포인트 구성과 검증 (Route 구현)
Route는 외부 HTTP 요청이 내부 도메인으로 진입하는 첫 관문입니다.
핵심 역할은 요청 검증(Validation)과 응답 직렬화(Serialization)입니다.
▸ Body / Params / Querystring 런타임 검증
▸ 정의되지 않은 필드 자동 차단 (additionalProperties: false)
▸ 응답 스키마 기반 필터링으로 민감 정보 제거
▸ Controller 호출만 수행 (비즈니스 로직 없음)
→ Route는 입력과 출력의 형식만 보장하며, 도메인 로직은 포함하지 않습니다.
fastify.patch(
'/:id',
{
schema: {
tags: ['User'],
params: UserIdParamsSchema,
body: UserUpdateBodySchema,
response: { 200: SuccessResponseSchema(UserResponseSchema) },
},
},
async (request, reply) => {
const result = await userController.updateUser(request.params, request.body);
return reply.code(200).send(success(result));
},
);
6. 공통 성공 응답 구조 구현
response.success.ts는 모든 API 성공 응답을 일관된 구조로 표준화하기 위한 공통 모듈입니다.
핵심 목적은 다음 세 가지입니다.
▸ 응답 포맷의 일관성 유지
▸ 런타임 스키마 기반 직렬화 보장
▸ 정의되지 않은 필드 노출 차단
✔️ SuccessResponseSchema – 런타임 응답 스키마 생성기
▸ success는 항상 true로 고정
▸ 실제 데이터는 body에만 위치
▸ additionalProperties: false로 정의되지 않은 필드 차단
▸ Fastify의 response schema 직렬화 기능과 직접 연결
→ 응답 단계에서 민감 정보가 자동으로 필터링됩니다.
export const SuccessResponseSchema = <T extends TSchema>(dataSchema: T) =>
Type.Object(
{
success: Type.Literal(true),
body: dataSchema,
},
{ additionalProperties: false },
);
이 함수는 실제 DTO 스키마를 받아 다음과 같은 표준 구조로 감쌉니다.
{
"success": true,
"body": { ...실제 데이터... }
}
✔️ SuccessResponseDto – 정적 타입 정의
TypeBox는 런타임 검증을 담당하고, 이 타입은 컴파일 단계에서 응답 구조를 보장합니다.
▸ Controller/Service 반환 타입 추론 가능
▸ success 구조가 항상 유지됨
▸ 응답 계약(API Contract) 고정
export type SuccessResponseDto<T> = {
success: true;
body: T;
};
✔️ success() – 응답 생성 팩토리 함수
▸ 응답 객체 생성 코드 중복 제거
▸ 실수로 다른 구조를 반환하는 문제 방지
▸ 모든 성공 응답을 동일한 포맷으로 강제
export function success<T>(data: T): SuccessResponseDto<T> {
return {
success: true,
body: data,
};
}
※ 게시된 글 및 이미지 중 일부는 AI 도구의 도움을 받아 생성되거나 다듬어졌습니다.