# NestJS: 완전한 REST API 구축 가이드 > NestJS로 전문적인 REST API를 구축하는 완벽 가이드입니다. 컨트롤러, 서비스, 모듈 구성, class-validator를 활용한 유효성 검사, 에러 핸들링을 실전 코드로 설명합니다. - Published: 2026-01-13 - Updated: 2026-04-07 - Author: SharpSkill - Tags: nestjs, nodejs, typescript, rest api, backend - Reading time: 15 min --- NestJS는 확장 가능하고 유지보수가 용이한 서버 사이드 애플리케이션을 구축하기 위한 Node.js 프레임워크로 확고한 입지를 다지고 있습니다. Angular에서 영감을 받은 모듈러 아키텍처, 의존성 주입(DI), 네이티브 TypeScript 지원을 통해 전문적인 REST API 구축이 체계적이고 예측 가능해집니다. > **2026년에 NestJS를 선택하는 이유** > > NestJS 11에서는 대폭적인 성능 개선, 그레이스풀 셧다운을 위한 네이티브 시그널 지원, Prisma 및 Drizzle 등 최신 ORM과의 간소화된 통합이 도입되었습니다. 이전 버전과의 하위 호환성도 유지됩니다. ## 프로젝트 설치 및 초기 설정 NestJS CLI를 사용하면 프로덕션 환경에 대응하는 구성의 프로젝트가 자동 생성됩니다. TypeScript, ESLint, 단위 테스트가 자동으로 설정됩니다. ```bash # 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 ``` 이 명령어를 실행하면 정리된 파일 구조와 필수 의존성이 사전 설치된 프로젝트가 생성됩니다. ```typescript // src/main.ts 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 엔드포인트), 프로바이더(비즈니스 서비스), 임포트(의존 관계)를 선언합니다. ```typescript // src/app.module.ts 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 {} ``` 각 비즈니스 모듈은 동일한 구조를 따릅니다. 모듈 파일, 컨트롤러, 서비스의 세 가지로 구성됩니다. ```typescript // src/users/users.module.ts 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의 데코레이터를 통해 코드가 표현력 있고 자체 문서화됩니다. ```typescript // src/users/users.controller.ts 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 { // 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 { // 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 { 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 { await this.usersService.remove(id); } } ``` `ParseIntPipe` 같은 파이프는 파라미터의 변환과 유효성 검사를 자동으로 수행합니다. 변환에 실패하면 HTTP 400 에러가 자동으로 반환됩니다. > **내장 유효성 검사 파이프** > > NestJS는 `ParseIntPipe`, `ParseBoolPipe`, `ParseArrayPipe`, `ParseUUIDPipe` 등 여러 내장 파이프를 제공합니다. 각 파이프는 핸들러 실행 전에 수신 데이터의 유효성 검사와 변환을 수행합니다. ## 비즈니스 서비스 구현 서비스는 비즈니스 로직과 데이터 접근을 캡슐화합니다. `@Injectable()` 데코레이터로 표시된 서비스는 NestJS의 의존성 주입 컨테이너에 의해 관리됩니다. ```typescript // src/users/users.service.ts 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 { // 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 { 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 { 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 { 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 { return this.users.find(u => u.email === email); } } ``` `NotFoundException` 예외는 형식화된 에러 메시지와 함께 HTTP 404 응답을 자동으로 생성합니다. ## class-validator를 활용한 데이터 유효성 검사 DTO(Data Transfer Object)는 수신 데이터의 구조를 정의합니다. `class-validator`의 데코레이터로 유효성 검사 규칙을 지정하면 자동으로 적용됩니다. ```typescript // src/users/dto/create-user.dto.ts 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`을 사용합니다. 유효성 검사 규칙을 유지하면서 모든 필드가 선택 사항이 됩니다. ```typescript // src/users/dto/update-user.dto.ts 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) ) {} ``` 엔티티는 저장되는 데이터의 구조를 나타냅니다. ```typescript // src/users/entities/user.entity.ts export class User { id: number; email: string; password: string; firstName: string; lastName: string; phone?: string; createdAt: Date; updatedAt: Date; } ``` ## 중앙 집중식 에러 핸들링 NestJS에는 HTTP 예외가 내장되어 있습니다. 글로벌 예외 필터를 사용하면 에러 응답 형식을 커스터마이즈할 수 있습니다. ```typescript // src/common/filters/http-exception.filter.ts 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(); const request = ctx.getRequest(); 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 외의 에러(시스템 에러, 미처리 예외)를 캐치하기 위해 두 번째 필터로 완전한 커버리지를 확보합니다. ```typescript // src/common/filters/all-exceptions.filter.ts 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(); const request = ctx.getRequest(); // 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, }); } } ``` 메인 모듈에서 글로벌 필터를 등록합니다. ```typescript // src/main.ts (updated) 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와의 완전한 통합 절차를 아래에 제시합니다. ```bash # terminal # Install Prisma npm install prisma @prisma/client # Initialize Prisma with PostgreSQL npx prisma init --datasource-provider postgresql ``` 데이터 스키마 정의입니다. ```prisma // prisma/schema.prisma 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 모듈을 생성합니다. ```typescript // src/prisma/prisma.service.ts 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(); } } ``` ```typescript // src/prisma/prisma.module.ts 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입니다. ```typescript // src/users/users.service.ts (Prisma version) 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> { // 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> { 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; } async update(id: number, updateUserDto: UpdateUserDto): Promise> { // 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; } async remove(id: number): Promise { await this.findOne(id); await this.prisma.user.delete({ where: { id } }); } } ``` ## 인터셉터를 활용한 응답 변환 인터셉터를 사용하면 응답을 일관되게 변환할 수 있습니다. 변환 인터셉터를 통해 모든 API 응답의 형식을 표준화합니다. ```typescript // src/common/interceptors/transform.interceptor.ts 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 { success: boolean; data: T; timestamp: string; } @Injectable() export class TransformInterceptor implements NestInterceptor> { intercept(context: ExecutionContext, next: CallHandler): Observable> { return next.handle().pipe( map(data => ({ success: true, data, timestamp: new Date().toISOString(), })), ); } } ``` 로깅 인터셉터는 요청과 실행 시간을 기록합니다. ```typescript // src/common/interceptors/logging.interceptor.ts 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 { 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의 강점은 베스트 프랙티스로 자연스럽게 이끄는 의견이 반영된 구조에 있습니다. 의존성 주입과 계층 분리 같은 검증된 패턴을 통해 테스트 가능하고 확장성 있는 코드가 만들어지며, 엔터프라이즈 애플리케이션에도 대응할 수 있습니다. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ko/blog/node-nestjs/nestjs-building-complete-rest-api