Node.js API에서 DTO의 조용한 힘
Min-jun Kim
Dev Intern · Leapcell

소개
견고하고 확장 가능한 Node.js API를 구축하는 것은 종종 애플리케이션의 다양한 계층 간의 복잡한 상호 작용을 포함합니다. 프로젝트가 성장함에 따라 데이터베이스에서 예상하는 데이터, 비즈니스 로직에서 처리하는 데이터, API에서 노출하는 데이터 간의 경계가 모호해질 수 있습니다. 이러한 복잡성은 코드를 단단하게 결합시켜 애플리케이션을 유지 관리, 테스트 및 발전시키는 것을 어렵게 만듭니다. 이 글에서는 Node.js API 맥락에서 이러한 복잡성을 푸는 데 DTO(Data Transfer Object)가 하는 중요한 역할에 대해 자세히 알아보고, 비즈니스 로직을 기본 데이터 모델에서 효과적으로 분리하는 방법을 보여줍니다.
직접 모델 사용의 문제점
DTO에 대해 자세히 알아보기 전에 일반적인 문제점을 살펴보겠습니다. User Mongoose 모델이 있다고 가정해 보겠습니다.
// models/User.js const mongoose = require('mongoose'); const userSchema = new mongoose.Schema({ name: { type: String, required: true }, email: { type: String, required: true, unique: true }, password: { type: String, required: true, select: false }, // 비밀번호는 기본적으로 숨겨짐 isAdmin: { type: Boolean, default: false }, createdAt: { type: Date, default: Date.now }, updatedAt: { type: Date, default: Date.now } }); module.exports = mongoose.model('User', userSchema);
이제 사용자를 생성하는 API 엔드포인트를 생각해 보겠습니다.
// controllers/userController.js const User = require('../models/User'); exports.createUser = async (req, res) => { try { const { name, email, password, isAdmin } = req.body; const user = new User({ name, email, password, isAdmin }); await user.save(); res.status(201).json(user); // 전체 모델을 다시 보냄 } catch (error) { res.status(400).json({ message: error.message }); } };
이 간단한 예제에서는 몇 가지 문제가 발생합니다.
- 보안 위험:
User인스턴스를 생성하기 위해req.body를 직접 사용하고 있습니다.isAdmin이 일반 사용자에게 실수로req.body에 포함되어 있었다면, 적절한 유효성 검사 없이 권한을 상승시킬 수 있었습니다. - 데이터 과다 공유:
user.save()에서 반환된user객체에는 민감한 정보(예:password, 스키마에select: false가 있더라도 특정 컨텍스트에서 나타나거나 명시적으로 투영된 경우)가 포함될 수 있습니다. 데이터베이스 모델 구조를 클라이언트에게 직접 노출하는 것입니다. - 단단한 결합:
User모델의 변경(예: 필드 이름 변경, 내부 전용 필드 추가)은 API가 예상하고 반환하는 것에 직접적인 영향을 미칩니다. 이는 리팩토링을 어렵게 만듭니다. - 입력 유효성 검사/변환 없음: 컨트롤러는 Mongoose의 유효성 검사에만 의존합니다. 더 복잡한 유효성 검사 규칙이나 데이터 변환(예: 이메일을 소문자로 표준화)은 종종 모델 상호 작용 계층 이전에 처리하는 것이 좋습니다.
DTO(Data Transfer Object)란 무엇인가?
A **DTO(Data Transfer Object)**는 주로 애플리케이션 계층 간에 데이터를 전송하는 데 사용되는 객체입니다. 주요 목적은 데이터를 운반하는 것이며 비즈니스 로직을 포함하는 것이 아닙니다. API 맥락에서 DTO는 들어오는 요청(요청 DTO)에서 예상하는 데이터의 형태 또는 API 응답(응답 DTO)에서 반환하는 데이터의 형태를 정의합니다.
DTO의 주요 특징:
- 일반 객체: 주로 속성, getter 및 setter(JavaScript에서는 주로 속성만)를 포함합니다.
- 동작 없음: 비즈니스 로직, 데이터베이스 상호 작용 메서드 또는 복잡한 상태 관리가 없습니다.
- 계층별: 데이터베이스 모델 또는 내부 도메인 개체와 달리 특정 계층의 데이터 보기를 나타냅니다.
Node.js API에서 DTO 구현하기
DTO를 사용하여 createUser 예제를 리팩토링해 보겠습니다. 두 가지 유형을 도입할 것입니다. 들어오는 요청을 위한 CreateUserDto와 나가는 응답을 위한 UserResponseDto입니다.
1. 요청 DTO: 입력 형태 및 유효성 검사 정의
Joi 또는 Yup와 같은 스키마 유효성 검사 라이브러리 또는 사용자 지정 클래스를 사용하여 DTO를 정의할 수 있습니다. 데모를 위해 이점은 클래스 기반 접근 방식과 기본 유효성 검사를 결합합니다.
// dtos/CreateUserDto.js class CreateUserDto { constructor(data) { this.name = data.name; this.email = data.email; this.password = data.password; // 참고: isAdmin은 내부 관리자 제어 필드이므로 여기서는 의도적으로 생략되었습니다. } // 예시로서의 기본 유효성 검사 메서드 validate() { if (!this.name || typeof this.name !== 'string' || this.name.trim() === '') { throw new Error('이름은 필수이며 문자열이어야 합니다.'); } if (!this.email || !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(this.email)) { throw new Error('유효한 이메일이 필요합니다.'); } if (!this.password || this.password.length < 6) { throw new Error('비밀번호는 최소 6자 이상이어야 합니다.'); } return true; } // 선택 사항: 서비스에 전달하기 전에 데이터를 변환하는 메서드 toUserModelPayload() { return { name: this.name.trim(), email: this.email.toLowerCase(), password: this.password, // 비밀번호 해싱은 일반적으로 서비스 계층에서 발생합니다. }; } } module.exports = CreateUserDto;
2. 응답 DTO: 출력 데이터 형식 지정
// dtos/UserResponseDto.js class UserResponseDto { constructor(user) { this.id = user._id ? user._id.toString() : user.id; // Mongoose _id와 잠재적 id 필드 모두 처리 this.name = user.name; this.email = user.email; this.isAdmin = user.isAdmin; this.createdAt = user.createdAt; } static fromUser(user) { return new UserResponseDto(user); } static fromUsers(users) { return users.map(user => new UserResponseDto(user)); } } module.exports = UserResponseDto;
3. 리팩토링된 컨트롤러 및 서비스 계층
이제 DTO를 컨트롤러에 통합하고 비즈니스 로직을 위한 서비스 계층을 도입해 보겠습니다.
// services/userService.js const User = require('../models/User'); const bcrypt = require('bcryptjs'); // 비밀번호 해싱용 class UserService { async createUser(userDataPayload) { const { name, email, password } = userDataPayload; const hashedPassword = await bcrypt.hash(password, 10); // 비밀번호 해싱 const user = new User({ name, email, password: hashedPassword }); await user.save(); return user; // 생성된 모델 반환 } async getAllUsers() { const users = await User.find(); return users; } // ... 기타 사용자 관련 비즈니스 로직 } module.exports = new UserService();
// controllers/userController.js const CreateUserDto = require('../dtos/CreateUserDto'); const UserResponseDto = require('../dtos/UserResponseDto'); const userService = require('../services/userService'); exports.createUser = async (req, res) => { try { // 1. DTO 생성 및 유효성 검사 const createUserDto = new CreateUserDto(req.body); createUserDto.validate(); // 유효성 검사 수행 // 2. DTO 데이터를 서비스 계층으로 전달 const newUserModelPayload = createUserDto.toUserModelPayload(); const createdUser = await userService.createUser(newUserModelPayload); // 3. 모델을 응답 DTO로 변환 const userResponse = UserResponseDto.fromUser(createdUser); res.status(201).json(userResponse); } catch (error) { // 유효성 검사 오류 vs. 데이터베이스 오류에 대한 더 나은 오류 처리가 여기에 있을 것입니다. res.status(400).json({ message: error.message }); } }; exports.getUsers = async (req, res) => { try { const users = await userService.getAllUsers(); const usersResponse = UserResponseDto.fromUsers(users); res.status(200).json(usersResponse); } catch (error) { res.status(500).json({ message: error.message }); } };
DTO 사용의 이점:
- 개선된 보안:
CreateUserDto에서 허용되는 입력 필드를 명시적으로 정의하고UserResponseDto에서 민감한 출력 필드를 필터링함으로써 대량 할당 취약점을 방지하고 의도치 않은 데이터 노출을 막습니다. - 명확한 관심사 분리:
- 컨트롤러: HTTP 요청/응답을 처리하고, DTO 생성을 조정하며, 서비스를 호출합니다.
- DTO: API 계약(들어오는 것, 나가는 것)을 결정합니다.
- 서비스 계층: 비즈니스 로직을 포함하고, 모델을 통해 데이터베이스와 상호 작용하며, 변환을 적용합니다.
- 모델: 데이터베이스 스키마를 나타냅니다. 이러한 분리는 애플리케이션의 각 부분이 단일 책임에 집중하도록 만듭니다.
- 향상된 유지보수성: 데이터베이스
User모델의 변경(예: 내부lastLoginIp필드 추가)은UserResponseDto가 변경되지 않는 한 API 계약에 자동으로 영향을 미치지 않습니다. 이는 파급 효과를 줄입니다. - 간편한 테스트: 각 계층을 독립적으로 테스트할 수 있습니다. DTO 유효성 검사, 서비스 로직, 컨트롤러 통합을 더 효과적으로 단위 테스트할 수 있습니다.
- 더 나은 API 문서: DTO는 API의 입력 및 출력 구조를 정의하는 데 자연스럽게 적합하며, Swagger/OpenAPI와 같은 도구에서 직접 사용할 수 있습니다.
- 입력 유효성 검사 및 변환: DTO는 비즈니스 로직에 도달하기 전에 요청 유효성 검사 및 초기 데이터 변환(예: 문자열 공백 제거, 이메일 소문자화)을 정의하고 수행할 전용 장소를 제공합니다.
결론
DTO는 Node.js API 개발에서 강력하지만 종종 간과되는 패턴입니다. 애플리케이션 경계를 드나드는 데이터에 대한 명시적인 계약 역할을 함으로써 DTO는 API의 와이어 형식, 비즈니스 로직 및 기본 데이터 모델 간의 강력한 분리를 가능하게 합니다. DTO를 채택하면 시간이 지남에 따라 발전하기 쉬운 더 안전하고 유지보수 가능하며 테스트 가능한 API를 만들 수 있습니다. 본질적으로 DTO는 API 무결성 및 명확성의 조용한 수호자입니다.