NestJS: 완전한 REST API 구축 가이드
NestJS로 전문적인 REST API를 구축하는 완벽 가이드입니다. 컨트롤러, 서비스, 모듈 구성, class-validator를 활용한 유효성 검사, 에러 핸들링을 실전 코드로 설명합니다.

NestJS는 확장 가능하고 유지보수가 용이한 서버 사이드 애플리케이션을 구축하기 위한 Node.js 프레임워크로 확고한 입지를 다지고 있습니다. Angular에서 영감을 받은 모듈러 아키텍처, 의존성 주입(DI), 네이티브 TypeScript 지원을 통해 전문적인 REST API 구축이 체계적이고 예측 가능해집니다.
NestJS 11에서는 대폭적인 성능 개선, 그레이스풀 셧다운을 위한 네이티브 시그널 지원, Prisma 및 Drizzle 등 최신 ORM과의 간소화된 통합이 도입되었습니다. 이전 버전과의 하위 호환성도 유지됩니다.
프로젝트 설치 및 초기 설정
NestJS CLI를 사용하면 프로덕션 환경에 대응하는 구성의 프로젝트가 자동 생성됩니다. TypeScript, ESLint, 단위 테스트가 자동으로 설정됩니다.
# terminal
# Global installation of the NestJS CLI
npm install -g @nestjs/cli
# Create a new project
nest new my-api
# Navigate to the project
cd my-api
# Start in development mode with hot-reload
npm run start:dev이 명령어를 실행하면 정리된 파일 구조와 필수 의존성이 사전 설치된 프로젝트가 생성됩니다.
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
// Create the NestJS application
const app = await NestFactory.create(AppModule);
// Enable global validation
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // Removes non-decorated properties
forbidNonWhitelisted: true, // Rejects requests with unknown properties
transform: true, // Transforms payloads to DTO instances
}));
// Configure global prefix for all routes
app.setGlobalPrefix('api');
await app.listen(3000);
}
bootstrap();이 설정을 통해 요청의 자동 유효성 검사가 활성화되고, 모든 라우트에 /api 접두사가 부여됩니다.
모듈러 아키텍처 이해하기
NestJS는 코드를 모듈 단위로 구성하며, 각 모듈이 기능 도메인을 캡슐화합니다. 모듈은 컨트롤러(HTTP 엔드포인트), 프로바이더(비즈니스 서비스), 임포트(의존 관계)를 선언합니다.
import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';
import { ProductsModule } from './products/products.module';
import { AuthModule } from './auth/auth.module';
// The root module imports all application modules
@Module({
imports: [
UsersModule, // User management
ProductsModule, // Product catalog
AuthModule, // Authentication
],
})
export class AppModule {}각 비즈니스 모듈은 동일한 구조를 따릅니다. 모듈 파일, 컨트롤러, 서비스의 세 가지로 구성됩니다.
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
// Controllers handle HTTP requests
controllers: [UsersController],
// Providers are injectable throughout the module
providers: [UsersService],
// Exports make providers available to other modules
exports: [UsersService],
})
export class UsersModule {}UsersService를 내보내면 다른 모듈이 UsersModule을 임포트하고 의존성 주입을 통해 이 서비스를 사용할 수 있습니다.
완전한 CRUD 컨트롤러 구축
컨트롤러는 HTTP 라우트를 정의하고 비즈니스 로직을 서비스에 위임합니다. NestJS의 데코레이터를 통해 코드가 표현력 있고 자체 문서화됩니다.
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
ParseIntPipe,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { User } from './entities/user.entity';
// Route prefix: /api/users
@Controller('users')
export class UsersController {
// Inject service via constructor
constructor(private readonly usersService: UsersService) {}
// POST /api/users - Create a user
@Post()
@HttpCode(HttpStatus.CREATED)
async create(@Body() createUserDto: CreateUserDto): Promise<User> {
// The DTO is automatically validated before reaching here
return this.usersService.create(createUserDto);
}
// GET /api/users - List with pagination
@Get()
async findAll(
@Query('page', new ParseIntPipe({ optional: true })) page: number = 1,
@Query('limit', new ParseIntPipe({ optional: true })) limit: number = 10,
): Promise<{ data: User[]; total: number }> {
return this.usersService.findAll(page, limit);
}
// GET /api/users/:id - Retrieve by ID
@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number): Promise<User> {
// ParseIntPipe automatically converts and validates the parameter
return this.usersService.findOne(id);
}
// PUT /api/users/:id - Full update
@Put(':id')
async update(
@Param('id', ParseIntPipe) id: number,
@Body() updateUserDto: UpdateUserDto,
): Promise<User> {
return this.usersService.update(id, updateUserDto);
}
// DELETE /api/users/:id - Deletion
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
await this.usersService.remove(id);
}
}ParseIntPipe 같은 파이프는 파라미터의 변환과 유효성 검사를 자동으로 수행합니다. 변환에 실패하면 HTTP 400 에러가 자동으로 반환됩니다.
NestJS는 ParseIntPipe, ParseBoolPipe, ParseArrayPipe, ParseUUIDPipe 등 여러 내장 파이프를 제공합니다. 각 파이프는 핸들러 실행 전에 수신 데이터의 유효성 검사와 변환을 수행합니다.
비즈니스 서비스 구현
서비스는 비즈니스 로직과 데이터 접근을 캡슐화합니다. @Injectable() 데코레이터로 표시된 서비스는 NestJS의 의존성 주입 컨테이너에 의해 관리됩니다.
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { User } from './entities/user.entity';
@Injectable()
export class UsersService {
// In-memory database simulation
private users: User[] = [];
private idCounter = 1;
async create(createUserDto: CreateUserDto): Promise<User> {
// Create the user entity
const user: User = {
id: this.idCounter++,
...createUserDto,
createdAt: new Date(),
updatedAt: new Date(),
};
this.users.push(user);
return user;
}
async findAll(page: number, limit: number): Promise<{ data: User[]; total: number }> {
// Calculate pagination
const start = (page - 1) * limit;
const end = start + limit;
return {
data: this.users.slice(start, end),
total: this.users.length,
};
}
async findOne(id: number): Promise<User> {
const user = this.users.find(u => u.id === id);
// Throw exception if user doesn't exist
if (!user) {
throw new NotFoundException(`User with ID ${id} not found`);
}
return user;
}
async update(id: number, updateUserDto: UpdateUserDto): Promise<User> {
const user = await this.findOne(id);
// Merge existing data with updates
Object.assign(user, updateUserDto, { updatedAt: new Date() });
return user;
}
async remove(id: number): Promise<void> {
const index = this.users.findIndex(u => u.id === id);
if (index === -1) {
throw new NotFoundException(`User with ID ${id} not found`);
}
this.users.splice(index, 1);
}
// Utility method for other services
async findByEmail(email: string): Promise<User | undefined> {
return this.users.find(u => u.email === email);
}
}NotFoundException 예외는 형식화된 에러 메시지와 함께 HTTP 404 응답을 자동으로 생성합니다.
Node.js / NestJS 면접 준비가 되셨나요?
인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.
class-validator를 활용한 데이터 유효성 검사
DTO(Data Transfer Object)는 수신 데이터의 구조를 정의합니다. class-validator의 데코레이터로 유효성 검사 규칙을 지정하면 자동으로 적용됩니다.
import {
IsEmail,
IsNotEmpty,
IsString,
MinLength,
MaxLength,
IsOptional,
Matches,
} from 'class-validator';
export class CreateUserDto {
@IsNotEmpty({ message: 'Email is required' })
@IsEmail({}, { message: 'Invalid email format' })
email: string;
@IsNotEmpty({ message: 'Password is required' })
@MinLength(8, { message: 'Password must be at least 8 characters' })
@MaxLength(50, { message: 'Password cannot exceed 50 characters' })
@Matches(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
{ message: 'Password must contain at least one uppercase, one lowercase, and one number' }
)
password: string;
@IsNotEmpty({ message: 'First name is required' })
@IsString()
@MinLength(2)
@MaxLength(50)
firstName: string;
@IsNotEmpty({ message: 'Last name is required' })
@IsString()
@MinLength(2)
@MaxLength(50)
lastName: string;
@IsOptional()
@IsString()
@MaxLength(20)
phone?: string;
}부분 업데이트에는 PartialType을 사용합니다. 유효성 검사 규칙을 유지하면서 모든 필드가 선택 사항이 됩니다.
import { PartialType, OmitType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';
// All fields from CreateUserDto become optional
// Password is excluded from standard updates
export class UpdateUserDto extends PartialType(
OmitType(CreateUserDto, ['password'] as const)
) {}엔티티는 저장되는 데이터의 구조를 나타냅니다.
export class User {
id: number;
email: string;
password: string;
firstName: string;
lastName: string;
phone?: string;
createdAt: Date;
updatedAt: Date;
}중앙 집중식 에러 핸들링
NestJS에는 HTTP 예외가 내장되어 있습니다. 글로벌 예외 필터를 사용하면 에러 응답 형식을 커스터마이즈할 수 있습니다.
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Request, Response } from 'express';
// Catches all HttpExceptions
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();
// Retrieve error message (can be string or object)
const exceptionResponse = exception.getResponse();
const message = typeof exceptionResponse === 'string'
? exceptionResponse
: (exceptionResponse as any).message;
// Standardized response format
response.status(status).json({
success: false,
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
method: request.method,
message: message,
});
}
}HTTP 외의 에러(시스템 에러, 미처리 예외)를 캐치하기 위해 두 번째 필터로 완전한 커버리지를 확보합니다.
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Request, Response } from 'express';
// Catches ALL exceptions (including system errors)
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
// Determine HTTP code and message
const status = exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message = exception instanceof HttpException
? exception.message
: 'Internal server error';
// Log error for debugging
console.error('Exception caught:', exception);
response.status(status).json({
success: false,
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
message: message,
});
}
}메인 모듈에서 글로벌 필터를 등록합니다.
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Global validation
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}));
// Global exception filters
app.useGlobalFilters(
new AllExceptionsFilter(),
new HttpExceptionFilter(),
);
app.setGlobalPrefix('api');
await app.listen(3000);
}
bootstrap();필터의 등록 순서는 중요합니다. 먼저 등록된 필터가 마지막에 실행됩니다. AllExceptionsFilter가 폴백으로 기능하도록 HttpExceptionFilter보다 먼저 등록해야 합니다.
Prisma ORM 통합
Prisma는 자동 생성되는 타입 안전 클라이언트를 통해 데이터베이스 작업을 간소화합니다. NestJS와의 완전한 통합 절차를 아래에 제시합니다.
# terminal
# Install Prisma
npm install prisma @prisma/client
# Initialize Prisma with PostgreSQL
npx prisma init --datasource-provider postgresql데이터 스키마 정의입니다.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
email String @unique
password String
firstName String @map("first_name")
lastName String @map("last_name")
phone String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
posts Post[]
@@map("users")
}
model Post {
id Int @id @default(autoincrement())
title String
content String
published Boolean @default(false)
authorId Int @map("author_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relation to User
author User @relation(fields: [authorId], references: [id])
@@map("posts")
}재사용 가능한 Prisma 모듈을 생성합니다.
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
// Automatic connection on module startup
async onModuleInit() {
await this.$connect();
}
// Clean disconnection on application shutdown
async onModuleDestroy() {
await this.$disconnect();
}
}import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
// @Global makes the service available throughout the application
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}Prisma를 사용하도록 업데이트된 UsersService입니다.
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { User } from '@prisma/client';
import * as bcrypt from 'bcrypt';
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
async create(createUserDto: CreateUserDto): Promise<Omit<User, 'password'>> {
// Check email uniqueness
const existingUser = await this.prisma.user.findUnique({
where: { email: createUserDto.email },
});
if (existingUser) {
throw new ConflictException('This email is already in use');
}
// Hash the password
const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
// Create the user
const user = await this.prisma.user.create({
data: {
...createUserDto,
password: hashedPassword,
},
});
// Exclude password from response
const { password, ...result } = user;
return result;
}
async findAll(page: number, limit: number): Promise<{ data: User[]; total: number }> {
// Parallel execution of count and paginated query
const [data, total] = await Promise.all([
this.prisma.user.findMany({
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
phone: true,
createdAt: true,
updatedAt: true,
},
}),
this.prisma.user.count(),
]);
return { data: data as User[], total };
}
async findOne(id: number): Promise<Omit<User, 'password'>> {
const user = await this.prisma.user.findUnique({
where: { id },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
phone: true,
createdAt: true,
updatedAt: true,
},
});
if (!user) {
throw new NotFoundException(`User with ID ${id} not found`);
}
return user as Omit<User, 'password'>;
}
async update(id: number, updateUserDto: UpdateUserDto): Promise<Omit<User, 'password'>> {
// Check existence
await this.findOne(id);
const user = await this.prisma.user.update({
where: { id },
data: updateUserDto,
select: {
id: true,
email: true,
firstName: true,
lastName: true,
phone: true,
createdAt: true,
updatedAt: true,
},
});
return user as Omit<User, 'password'>;
}
async remove(id: number): Promise<void> {
await this.findOne(id);
await this.prisma.user.delete({ where: { id } });
}
}인터셉터를 활용한 응답 변환
인터셉터를 사용하면 응답을 일관되게 변환할 수 있습니다. 변환 인터셉터를 통해 모든 API 응답의 형식을 표준화합니다.
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
// Interface for standardized response format
export interface ApiResponse<T> {
success: boolean;
data: T;
timestamp: string;
}
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> {
return next.handle().pipe(
map(data => ({
success: true,
data,
timestamp: new Date().toISOString(),
})),
);
}
}로깅 인터셉터는 요청과 실행 시간을 기록합니다.
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger(LoggingInterceptor.name);
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const { method, url } = request;
const now = Date.now();
return next.handle().pipe(
tap(() => {
const response = context.switchToHttp().getResponse();
const { statusCode } = response;
const duration = Date.now() - now;
// Structured log format
this.logger.log(
`${method} ${url} ${statusCode} - ${duration}ms`
);
}),
);
}
}결론
NestJS는 전문적인 REST API를 구축하기 위한 견고하고 확장 가능한 아키텍처를 제공합니다. TypeScript, 의존성 주입, 표현력 있는 데코레이터의 조합을 통해 유지보수성과 테스트 용이성이 뛰어난 애플리케이션 구축이 가능합니다.
NestJS API 품질 체크리스트
- 관심사의 분리를 실현하는 모듈러 구조
- 모든 입력에 대한 class-validator 기반 DTO 유효성 검사
- 비즈니스 로직 전용 서비스 계층
- 예외 필터를 통한 중앙 집중식 에러 핸들링
- 변환 및 로깅을 위한 인터셉터
- 데이터베이스 접근을 위한 Prisma 통합
- 화이트리스트가 활성화된 글로벌 ValidationPipe
- 모든 라우트에 대한 통일된 API 접두사
연습을 시작하세요!
면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.
NestJS의 강점은 베스트 프랙티스로 자연스럽게 이끄는 의견이 반영된 구조에 있습니다. 의존성 주입과 계층 분리 같은 검증된 패턴을 통해 테스트 가능하고 확장성 있는 코드가 만들어지며, 엔터프라이즈 애플리케이션에도 대응할 수 있습니다.
태그
공유
