티스토리 뷰
DTO와 Schema를 왜 따로 둬야 할까? 입력 검증과 요청 모델 설계 제대로 시작하기 — FastAPI · Spring Boot · Node.js
octo54 2026. 3. 30. 10:50DTO와 Schema를 왜 따로 둬야 할까? 입력 검증과 요청 모델 설계 제대로 시작하기 — FastAPI · Spring Boot · Node.js
백엔드 프로젝트를 조금만 만들다 보면 꼭 한 번은 이런 유혹이 옵니다.
“굳이 DTO까지 나눠야 하나?”
“그냥 들어온 JSON 바로 받아서 쓰면 안 되나?”
“엔티티나 모델 하나로 요청/응답 다 처리하면 편한데…”
저도 처음엔 그렇게 생각했어요.
근데 이 방식은 초반엔 빨라 보여도, 기능이 붙을수록 아주 높은 확률로 문제를 만듭니다.
- 요청값이 이상한데도 서비스까지 들어가고
- DB 컬럼 구조가 그대로 외부 API에 노출되고
- 응답에서 숨겨야 할 필드가 같이 나가고
- 프론트 요구사항이 바뀔 때 엔티티까지 흔들리고
- 나중엔 “이 클래스는 요청용인가, 응답용인가, DB용인가”가 헷갈립니다
그래서 이번 글에서는 입력 검증(Validation) 과 DTO/Schema 분리를 다룹니다.
그리고 이번에도 같은 기준으로 FastAPI / Spring Boot / Node.js(Express) 세 가지 구현을 나란히 놓고 보겠습니다.
FastAPI는 요청 본문을 Pydantic 모델로 선언해 검증하고, Field로 추가 제약과 메타데이터를 줄 수 있으며, response_model로 응답 직렬화와 필드 필터링도 지원합니다. (FastAPI)
Spring MVC는 @RequestBody와 @Valid 또는 @Validated를 함께 사용해 Bean Validation을 적용할 수 있고, 검증 오류는 400 응답으로 처리됩니다. (Home)
Express는 프레임워크 자체에 강한 검증 계층이 내장돼 있지는 않아서, 보통 미들웨어 조합으로 입력 검증을 붙이며 express-validator가 널리 쓰입니다. (Express)
왜 엔티티나 DB 모델을 그대로 받으면 안 될까
이건 정말 많이들 겪는 문제라 먼저 짚고 가고 싶어요.
예를 들어 사용자 생성 API가 있다고 해볼게요.
DB에는 이런 필드가 있을 수 있죠.
- id
- password_hash
- role
- created_at
- updated_at
그런데 회원가입 요청에서 진짜 필요한 건 보통 이 정도예요.
- password
- nickname
즉, DB 모델과 요청 모델은 목적이 다릅니다.
DB 모델은 저장을 위한 구조고,
요청 DTO/Schema는 외부에서 받을 데이터의 계약이에요.
응답 DTO/Schema는 외부로 내보낼 데이터의 계약이고요.
이걸 안 나누면 생기는 대표 문제는 이런 겁니다.
- 클라이언트가 보내면 안 되는 필드까지 같이 받게 됨
- 응답에서 내부 필드가 노출될 수 있음
- 검증 규칙이 모델 목적과 섞임
- API 변경이 DB 구조 변경으로 번짐
FastAPI 문서도 요청 바디를 Pydantic 모델로 선언하는 방식을 강조하고, 응답은 response_model을 통해 원하는 형태로 문서화·검증·필터링할 수 있다고 설명합니다. (FastAPI)
이번 글에서 맞출 공통 규칙
세 스택 모두 아래 기준으로 맞추겠습니다.
요청 DTO/Schema
- email: 이메일 형식
- password: 최소 길이 8
- nickname: 2~20자
응답 DTO/Schema
- id
- nickname
즉, 요청에는 password가 있지만
응답에는 절대 password가 나가면 안 됩니다.
이게 오늘 글의 핵심 감각이에요.
“받는 모델”과 “내보내는 모델”은 다르다.
1) FastAPI에서 DTO/Schema와 입력 검증 설계하기
FastAPI는 요청 본문을 Pydantic 모델로 선언하면 타입 변환과 검증을 자동으로 수행하고, Field로 길이 제한 같은 제약을 줄 수 있습니다. 응답에는 response_model을 선언해 문서화와 직렬화 필터링을 적용할 수 있습니다. (FastAPI)
추천 구조
fastapi-backend/
├── app/
│ ├── api/
│ │ └── user.py
│ ├── schemas/
│ │ └── user.py
│ ├── services/
│ │ └── user_service.py
│ └── main.py
└── requirements.txt
requirements.txt
fastapi
uvicorn[standard]
pydantic
email-validator
email-validator는 Pydantic의 이메일 타입 검증에 자주 함께 쓰입니다.
app/schemas/user.py
from pydantic import BaseModel, EmailStr, Field
class CreateUserRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=100)
nickname: str = Field(min_length=2, max_length=20)
class UserResponse(BaseModel):
id: int
email: EmailStr
nickname: str
app/services/user_service.py
from app.schemas.user import CreateUserRequest, UserResponse
class UserService:
_next_id = 1
@classmethod
def create_user(cls, request: CreateUserRequest) -> UserResponse:
user = UserResponse(
id=cls._next_id,
email=request.email,
nickname=request.nickname,
)
cls._next_id += 1
return user
app/api/user.py
from fastapi import APIRouter
from app.schemas.user import CreateUserRequest, UserResponse
from app.services.user_service import UserService
router = APIRouter(prefix="/api/users", tags=["users"])
@router.post("", response_model=UserResponse, status_code=201)
def create_user(request: CreateUserRequest) -> UserResponse:
return UserService.create_user(request)
app/main.py
from fastapi import FastAPI
from app.api.user import router as user_router
app = FastAPI(title="backend-series-fastapi-validation")
app.include_router(user_router)
실행
uvicorn app.main:app --reload
성공 테스트
curl -X POST http://127.0.0.1:8000/api/users \
-H "Content-Type: application/json" \
-d '{
"email": "alice@example.com",
"password": "password123",
"nickname": "alice"
}'
응답:
{
"id": 1,
"email": "alice@example.com",
"nickname": "alice"
}
실패 테스트
curl -X POST http://127.0.0.1:8000/api/users \
-H "Content-Type: application/json" \
-d '{
"email": "not-email",
"password": "123",
"nickname": "a"
}'
FastAPI는 이런 검증 실패를 기본적으로 422로 응답합니다. 이는 요청 데이터 검증을 Pydantic 모델과 함께 자동 수행하는 FastAPI의 기본 동작입니다. (FastAPI)
FastAPI에서 기억할 포인트
FastAPI는 진짜 이 부분이 강력해요.
- 요청 모델 선언만으로 검증이 붙고
- 타입 힌트가 문서로 이어지고
- response_model이 응답 필드를 걸러줍니다
특히 response_model은 “응답을 원하는 형태로 제한하는 역할”까지 해주기 때문에, 실수로 내부 필드가 나가는 걸 줄이는 데 꽤 유용합니다. FastAPI 문서도 response_model이 문서화, 검증, 변환/필터링에 사용된다고 설명합니다. (FastAPI)
즉 FastAPI에서는
Request Schema / Response Schema를 따로 두는 게 거의 정석이라고 봐도 됩니다.
2) Spring Boot에서 DTO와 검증 분리하기
Spring MVC는 @RequestBody로 JSON 본문을 객체로 역직렬화하고, @Valid나 @Validated를 함께 쓰면 Bean Validation 제약을 적용할 수 있습니다. 검증 제약은 jakarta.validation 어노테이션으로 선언합니다. (Home)
추천 구조
springboot-backend/
├── src/main/java/com/example/backend/
│ ├── BackendApplication.java
│ ├── controller/
│ │ └── UserController.java
│ ├── dto/
│ │ ├── CreateUserRequest.java
│ │ └── UserResponse.java
│ └── service/
│ └── UserService.java
└── build.gradle
build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '4.0.3'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
Spring Boot는 validation starter를 통해 Bean Validation을 쉽게 붙일 수 있고, 메서드 검증도 지원합니다. (Home)
src/main/java/com/example/backend/BackendApplication.java
package com.example.backend;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class BackendApplication {
public static void main(String[] args) {
SpringApplication.run(BackendApplication.class, args);
}
}
src/main/java/com/example/backend/dto/CreateUserRequest.java
package com.example.backend.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CreateUserRequest(
@NotBlank(message = "이메일은 필수입니다.")
@Email(message = "이메일 형식이 올바르지 않습니다.")
String email,
@NotBlank(message = "비밀번호는 필수입니다.")
@Size(min = 8, max = 100, message = "비밀번호는 8자 이상 100자 이하여야 합니다.")
String password,
@NotBlank(message = "닉네임은 필수입니다.")
@Size(min = 2, max = 20, message = "닉네임은 2자 이상 20자 이하여야 합니다.")
String nickname
) {
}
src/main/java/com/example/backend/dto/UserResponse.java
package com.example.backend.dto;
public record UserResponse(
Long id,
String email,
String nickname
) {
}
src/main/java/com/example/backend/service/UserService.java
package com.example.backend.service;
import com.example.backend.dto.CreateUserRequest;
import com.example.backend.dto.UserResponse;
import org.springframework.stereotype.Service;
@Service
public class UserService {
private long nextId = 1L;
public UserResponse createUser(CreateUserRequest request) {
UserResponse response = new UserResponse(
nextId,
request.email(),
request.nickname()
);
nextId++;
return response;
}
}
src/main/java/com/example/backend/controller/UserController.java
package com.example.backend.controller;
import com.example.backend.dto.CreateUserRequest;
import com.example.backend.dto.UserResponse;
import com.example.backend.service.UserService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public UserResponse createUser(@Valid @RequestBody CreateUserRequest request) {
return userService.createUser(request);
}
}
실행
./gradlew bootRun
성공 테스트
curl -X POST http://127.0.0.1:8080/api/users \
-H "Content-Type: application/json" \
-d '{
"email": "alice@example.com",
"password": "password123",
"nickname": "alice"
}'
응답:
{
"id": 1,
"email": "alice@example.com",
"nickname": "alice"
}
실패 테스트
curl -X POST http://127.0.0.1:8080/api/users \
-H "Content-Type: application/json" \
-d '{
"email": "wrong-email",
"password": "123",
"nickname": ""
}'
Spring MVC는 @Valid가 붙은 @RequestBody에서 Bean Validation을 적용하고, 검증 오류가 있으면 400 계열 응답으로 처리합니다. (Home)
Spring Boot에서 기억할 포인트
Spring 쪽은 DTO 분리의 맛이 가장 강하게 드러나는 편이에요.
왜냐면 Spring은 대형 서비스, 팀 협업, 장기 운영으로 많이 가다 보니
“엔티티를 그대로 컨트롤러 입출력에 쓰지 말자”는 감각이 진짜 중요하거든요.
보통은 이렇게 나눕니다.
- CreateUserRequest : 생성 요청 DTO
- UpdateUserRequest : 수정 요청 DTO
- UserResponse : 응답 DTO
- UserEntity : DB 저장용 엔티티
이렇게 나누면 API 요구사항이 바뀌어도 DB 구조가 덜 흔들립니다.
그리고 Bean Validation은 정말 실무 친화적이에요.
필드마다 제약을 선언해두면, “검증 로직이 어디 있지?”를 헤매지 않게 됩니다. Spring 문서도 검증을 선언형 제약 기반으로 사용하는 Bean Validation을 공식적으로 지원한다고 설명합니다. (Home)
3) Node.js(Express)에서 요청 검증과 DTO 느낌 살리기
Express는 기본 철학이 얇은 프레임워크에 가깝기 때문에, 입력 검증도 보통 미들웨어로 붙입니다. express-validator는 validator.js 기반 검증/정제 기능을 Express 미들웨어로 제공한다고 설명합니다. (express-validator.github.io)
추천 구조
node-backend/
├── src/
│ ├── middleware/
│ │ └── validate-request.js
│ ├── routes/
│ │ └── user.route.js
│ ├── services/
│ │ └── user.service.js
│ └── server.js
└── package.json
package.json
{
"name": "backend-series-node-validation",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "node src/server.js"
},
"dependencies": {
"express": "^5.1.0",
"express-validator": "^7.3.0"
}
}
src/middleware/validate-request.js
import { validationResult, matchedData } from "express-validator";
export function validateRequest(req, res, next) {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: "요청값이 올바르지 않습니다.",
data: null,
error: {
code: "VALIDATION_ERROR",
message: errors.array()[0].msg,
},
});
}
req.validatedBody = matchedData(req, { locations: ["body"] });
next();
}
matchedData()는 검증에 통과한 데이터만 뽑아내는 데 유용합니다. 이런 식으로 쓰면 “클라이언트가 추가로 보낸 불필요한 필드”를 줄이는 데 도움이 됩니다. express-validator는 검증 결과를 확인하고 매치된 데이터를 다루는 도구를 제공한다고 설명합니다. (express-validator.github.io)
src/services/user.service.js
let nextId = 1;
export function createUser(request) {
const user = {
id: nextId,
email: request.email,
nickname: request.nickname,
};
nextId += 1;
return user;
}
src/routes/user.route.js
import { Router } from "express";
import { body } from "express-validator";
import { validateRequest } from "../middleware/validate-request.js";
import { createUser } from "../services/user.service.js";
const router = Router();
router.post(
"/api/users",
[
body("email")
.isEmail()
.withMessage("이메일 형식이 올바르지 않습니다."),
body("password")
.isLength({ min: 8, max: 100 })
.withMessage("비밀번호는 8자 이상 100자 이하여야 합니다."),
body("nickname")
.isLength({ min: 2, max: 20 })
.withMessage("닉네임은 2자 이상 20자 이하여야 합니다."),
],
validateRequest,
(req, res) => {
const user = createUser(req.validatedBody);
return res.status(201).json(user);
}
);
export default router;
src/server.js
import express from "express";
import userRouter from "./routes/user.route.js";
const app = express();
const PORT = 3000;
app.use(express.json());
app.use(userRouter);
app.listen(PORT, () => {
console.log(`server running on http://localhost:${PORT}`);
});
Express에서 JSON 바디를 다루려면 express.json() 같은 body parsing 미들웨어가 먼저 필요합니다. Express 관련 문서도 요청 본문을 파싱해 req.body에 넣는 미들웨어 계층을 설명합니다. (Express)
실행
npm install
npm run dev
성공 테스트
curl -X POST http://127.0.0.1:3000/api/users \
-H "Content-Type: application/json" \
-d '{
"email": "alice@example.com",
"password": "password123",
"nickname": "alice"
}'
응답:
{
"id": 1,
"email": "alice@example.com",
"nickname": "alice"
}
실패 테스트
curl -X POST http://127.0.0.1:3000/api/users \
-H "Content-Type: application/json" \
-d '{
"email": "wrong",
"password": "123",
"nickname": "a"
}'
Node.js에서 기억할 포인트
Node.js 쪽은 아무래도 DTO가 Java record나 Pydantic model처럼 “언어 차원에서 강하게 보이는” 느낌은 덜해요.
그래서 오히려 더 의식적으로 분리해줘야 합니다.
예를 들어 이런 감각이 중요합니다.
- req.body를 서비스에 그대로 넘기지 않기
- 검증 통과 데이터만 따로 추리기
- 응답 객체도 서비스/컨트롤러에서 명시적으로 만들기
즉, Node.js에서는 DTO를 “클래스”보다
검증된 입력 객체와 명시적인 응답 객체로 보는 편이 더 현실적일 때가 많습니다.
Express는 미들웨어를 조합하는 구조가 핵심이고, express-validator도 그 흐름에 맞게 작동합니다. (Express)
세 스택을 나란히 놓고 보면
셋 다 결국 같은 목표예요.
“이상한 입력은 빨리 막고, 외부 계약은 명확하게 유지하자.”
FastAPI
- Pydantic 모델 하나로 검증과 문서화가 자연스럽게 이어짐
- Field와 타입 선언만으로도 표현력이 좋음
- response_model이 응답 필터링에 강함 (FastAPI)
Spring Boot
- Bean Validation과 DTO 분리 패턴이 아주 정석적
- 팀 협업과 장기 유지보수에 강함
- 엔티티/요청/응답 분리 감각이 특히 중요함 (Home)
Node.js
- 가장 자유롭지만 가장 쉽게 무너질 수도 있음
- 검증 미들웨어와 validated data 분리가 핵심
- 규칙을 코드로 직접 세워야 함 (express-validator.github.io)
실무에서 자주 하는 실수
이 부분은 진짜 많이 봤어요.
1. 요청 DTO와 응답 DTO를 같은 클래스로 씀
그러면 password 같은 필드가 응답에 섞여 나가기 쉬워집니다.
2. 엔티티를 그대로 API 입출력에 사용
처음엔 편한데, DB 구조 변경이 API 변경으로 직결됩니다.
3. 검증을 서비스 안에서 문자열 if문으로만 처리
이 방식은 나중에 누락도 많고, 위치도 분산되고, 재사용도 어렵습니다.
4. 검증 실패 메시지 정책이 없음
어떤 건 “invalid”, 어떤 건 “wrong request”, 어떤 건 한국어, 어떤 건 영어…
이렇게 섞이기 시작하면 API 전체 인상이 흐트러집니다.
5. 입력 검증과 비즈니스 검증을 구분하지 않음
예를 들면 이런 차이예요.
- 입력 검증: 이메일 형식이 맞는가
- 비즈니스 검증: 이미 가입된 이메일인가
이 둘은 다른 단계입니다.
형식 검증은 API 경계에서 빠르게 막고,
비즈니스 검증은 서비스 로직에서 처리하는 쪽이 보통 더 깔끔합니다.
Spring과 FastAPI 문서가 보여주는 검증도 기본적으로는 이런 “입력 구조와 제약” 중심입니다. (Home)
제가 추천하는 기본 분리 규칙
실무 초반에는 아래 정도만 기억해도 꽤 좋아요.
요청용
- CreateUserRequest
- UpdateUserRequest
- LoginRequest
응답용
- UserResponse
- UserSummaryResponse
- LoginResponse
내부/DB용
- UserEntity
- UserModel
핵심은 이름만 예쁘게 짓는 게 아니라,
“이 객체는 어디 경계에서 쓰는가” 를 분명히 하는 겁니다.
이번 글 핵심 정리
이번 글의 핵심은 딱 하나입니다.
백엔드는 데이터를 처리하는 프로그램이 아니라, 외부 입력과 내부 구조 사이의 경계를 관리하는 프로그램이다.
그래서 DTO와 Schema를 나누는 건 귀찮은 작업이 아니라
경계를 지키는 작업에 가깝습니다.
오늘 정리한 감각을 다시 압축하면 이렇습니다.
- 요청 모델과 응답 모델은 다르게 본다
- DB 모델은 API 모델과 분리한다
- 형식 검증은 API 경계에서 최대한 빨리 막는다
- 응답에는 필요한 필드만 명시적으로 내보낸다
- 검증 규칙은 선언적으로 모아두는 편이 오래 간다
이 단계가 익숙해지면, 그다음부터는 인증, DB, 트랜잭션, 테스트를 붙일 때도 훨씬 덜 흔들립니다.
다음 글 예고
다음 글에서는 이어서
서비스 계층(Service Layer)와 비즈니스 로직 분리를 다뤄보겠습니다.
많이들 처음에 컨트롤러에서 다 처리하다가
코드가 점점 두꺼워지고 테스트가 어려워지는데요,
다음 글에서는
- 컨트롤러는 어디까지 해야 하는지
- 서비스는 무엇을 책임지는지
- 검증/변환/비즈니스 로직/저장 흐름을 어떻게 나눌지
이걸 FastAPI, Spring Boot, Node.js 기준으로 정리해보겠습니다.
출처
- FastAPI 공식 문서 — Request Body (FastAPI)
- FastAPI 공식 문서 — Body Fields (FastAPI)
- FastAPI 공식 문서 — Response Model (FastAPI)
- Spring Framework 공식 문서 — @RequestBody와 @Valid (Home)
- Spring Framework 공식 문서 — Validation / Bean Validation (Home)
- Spring Boot 공식 문서 — Validation (Home)
- express-validator 공식 문서 — Overview / Getting Started / API (express-validator.github.io)
- Express 공식 문서 — Using Middleware / body parsing 관련 자료 (Express)
백엔드개발, FastAPI, SpringBoot, Nodejs, Express, DTO, Schema, Validation, API설계, 백엔드시리즈
'study > 백엔드' 카테고리의 다른 글
- Total
- Today
- Yesterday
- REACT
- 웹개발
- nextJS
- flax
- Redis
- NestJS
- Prisma
- 개발블로그
- Next.js
- 쿠버네티스
- JWT
- CI/CD
- DevOps
- SEO최적화
- node.js
- Python
- LangChain
- fastapi
- PostgreSQL
- JAX
- Docker
- Express
- 압박면접
- 백엔드개발
- 딥러닝
- ai철학
- rag
- kotlin
- llm
- seo 최적화 10개
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
