TechFeedTechFeed
Backend

NestJS 실전 튜토리얼 — 모듈 설계부터 JWT 인증, Swagger 문서화까지

NestJS 프로젝트 초기 설정부터 TypeORM PostgreSQL 연결, JWT 인증 모듈 구현, Swagger 자동 문서화, ValidationPipe 입력 검증까지 단계별 코드로 REST API 서버를 처음부터 구축하는 실전 튜토리얼.

NestJS는 Node.js 백엔드 프레임워크 중 가장 체계적인 구조를 제공한다. Express가 자유롭지만 프로젝트가 커질수록 혼란스러워지는 반면, NestJS는 Angular에서 가져온 모듈/컨트롤러/서비스 패턴으로 팀 규모가 커져도 코드베이스를 유지보수하기 쉽게 설계되어 있다. 이 튜토리얼에서는 프로젝트 초기화부터 TypeORM 데이터베이스 연결, JWT 인증, Swagger 문서 자동화까지 실제 API 서버를 처음부터 구축하는 과정을 단계별로 다룬다.

NestJS 아키텍처 다이어그램
NestJS의 모듈·컨트롤러·서비스 계층 구조

NestJS를 선택하는 이유 — Express와 무엇이 다른가

Express는 미니멀리스트 프레임워크다. 라우터, 미들웨어, 요청/응답 처리가 전부이며 나머지는 개발자가 알아서 구성해야 한다. 소규모 프로젝트에서는 이 자유도가 장점이지만, 팀원이 늘거나 코드베이스가 수만 줄을 넘어가면 구조적 혼란이 시작된다.

NestJS는 이 문제를 의존성 주입(Dependency Injection)과 명확한 레이어 분리로 해결한다. 각 기능은 독립적인 모듈로 캡슐화되며, 모듈 간 의존관계는 NestJS IoC 컨테이너가 관리한다. TypeScript를 일급 지원하며, 데코레이터 기반 API 덕분에 코드가 선언적이고 읽기 쉽다.

  • 구조적 일관성: 신규 팀원이 어디에 무엇을 작성해야 하는지 명확
  • 내장 DI 컨테이너: 서비스 간 의존관계를 수동으로 연결할 필요 없음
  • 플랫폼 독립성: Express 또는 Fastify 중 선택 가능
  • 풍부한 생태계: TypeORM, Prisma, Passport, Swagger 공식 통합 모듈 제공

프로젝트 초기 설정

NestJS CLI를 전역 설치하면 프로젝트 스캐폴딩, 모듈/서비스/컨트롤러 파일 생성을 자동화할 수 있다. Node.js 20+ 환경에서 아래 명령을 실행한다.

NestJS CLI 설치 및 프로젝트 생성
# NestJS CLI 전역 설치 npm install -g @nestjs/cli # 새 프로젝트 생성 (패키지 매니저 선택: npm) nest new my-api cd my-api # 생성된 폴더 구조 my-api/ ├── src/ │ ├── app.controller.ts # 루트 컨트롤러 │ ├── app.controller.spec.ts │ ├── app.module.ts # 루트 모듈 │ ├── app.service.ts # 루트 서비스 │ └── main.ts # 엔트리포인트 ├── test/ ├── nest-cli.json ├── package.json └── tsconfig.json # 개발 서버 실행 (파일 변경 감지 포함) npm run start:dev

main.ts에서 NestFactory로 앱을 부트스트랩한다. 기본 포트는 3000이며 app.listen()이 HTTP 서버를 시작한다. 이 파일에서 전역 미들웨어, ValidationPipe, CORS 설정을 등록하게 된다.

모듈·컨트롤러·서비스 — 핵심 3요소

NestJS 아키텍처의 핵심은 세 가지 빌딩 블록이다.

  • Module: 관련 기능을 하나의 단위로 묶는 컨테이너. 컨트롤러와 서비스를 선언하고, 다른 모듈과의 의존관계를 명시한다.
  • Controller: HTTP 요청을 받아 응답을 반환하는 레이어. 비즈니스 로직을 직접 처리하지 않고 서비스에 위임한다.
  • Service: 실제 비즈니스 로직이 위치하는 곳. 데이터베이스 조회, 계산, 외부 API 호출 등을 담당한다.

CLI로 새로운 리소스를 생성하면 이 세 파일을 한번에 만들어준다.

Users 리소스 생성 및 컨트롤러/서비스 구현
# CLI로 CRUD 리소스 생성 (컨트롤러, 서비스, 모듈, DTO 포함) nest generate resource users # REST API 선택 → Generate CRUD entry points? Yes # src/users/users.controller.ts import { Controller, Get, Post, Body, Param, Delete, Put } from '@nestjs/common'; import { UsersService } from './users.service'; import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto } from './dto/update-user.dto'; @Controller('users') export class UsersController { constructor(private readonly usersService: UsersService) {} @Post() create(@Body() createUserDto: CreateUserDto) { return this.usersService.create(createUserDto); } @Get() findAll() { return this.usersService.findAll(); } @Get(':id') findOne(@Param('id') id: string) { return this.usersService.findOne(+id); } @Put(':id') update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) { return this.usersService.update(+id, updateUserDto); } @Delete(':id') remove(@Param('id') id: string) { return this.usersService.remove(+id); } } # src/users/users.service.ts import { Injectable } from '@nestjs/common'; import { CreateUserDto } from './dto/create-user.dto'; @Injectable() export class UsersService { create(createUserDto: CreateUserDto) { return createUserDto; } findAll() { return []; } findOne(id: number) { return { id }; } update(id: number, updateUserDto: any) { return { id, ...updateUserDto }; } remove(id: number) { return { id }; } }
NestJS 모듈 의존관계 그래프
AppModule이 UsersModule과 AuthModule을 import하는 구조

TypeORM으로 PostgreSQL 연결하기

NestJS는 @nestjs/typeorm 패키지로 TypeORM을 공식 지원한다. TypeORM은 Active Record와 Data Mapper 두 패턴을 모두 지원하는데, NestJS 프로젝트에서는 서비스 계층과 잘 맞는 Data Mapper 패턴(Repository 방식)을 주로 사용한다.

먼저 필요한 패키지를 설치하고, AppModule에 TypeORM 연결 설정을 추가한다.

TypeORM 설치 및 엔티티 정의
# 패키지 설치 npm install @nestjs/typeorm typeorm pg # src/app.module.ts — TypeORM 연결 설정 import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { UsersModule } from './users/users.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), TypeOrmModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory: (config: ConfigService) => ({ type: 'postgres', host: config.get('DB_HOST'), port: config.get<number>('DB_PORT'), username: config.get('DB_USER'), password: config.get('DB_PASS'), database: config.get('DB_NAME'), entities: [__dirname + '/**/*.entity{.ts,.js}'], synchronize: config.get('NODE_ENV') !== 'production', }), }), UsersModule, ], }) export class AppModule {} # src/users/user.entity.ts import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'; @Entity('users') export class User { @PrimaryGeneratedColumn() id: number; @Column({ unique: true }) email: string; @Column() name: string; @Column({ select: false }) password: string; @CreateDateColumn() createdAt: Date; }
UsersService에 TypeORM Repository 주입
# src/users/users.module.ts — 엔티티 등록 import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { UsersService } from './users.service'; import { UsersController } from './users.controller'; import { User } from './user.entity'; @Module({ imports: [TypeOrmModule.forFeature([User])], controllers: [UsersController], providers: [UsersService], exports: [UsersService], }) export class UsersModule {} # src/users/users.service.ts — Repository 사용 import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from './user.entity'; import { CreateUserDto } from './dto/create-user.dto'; import * as bcrypt from 'bcrypt'; @Injectable() export class UsersService { constructor( @InjectRepository(User) private usersRepository: Repository<User>, ) {} async create(dto: CreateUserDto): Promise<User> { const hashed = await bcrypt.hash(dto.password, 10); const user = this.usersRepository.create({ ...dto, password: hashed }); return this.usersRepository.save(user); } async findByEmail(email: string): Promise<User | null> { return this.usersRepository .createQueryBuilder('user') .addSelect('user.password') .where('user.email = :email', { email }) .getOne(); } async findOne(id: number): Promise<User> { const user = await this.usersRepository.findOneBy({ id }); if (!user) throw new NotFoundException('User not found'); return user; } }
synchronize: true는 개발 환경에서만
TypeORM의 synchronize: true는 앱 시작 시 엔티티 구조에 맞게 테이블을 자동으로 변경한다. 편리하지만 프로덕션에서는 데이터 손실 위험이 있다. 운영 환경에서는 반드시 false로 설정하고 마이그레이션 파일을 별도 관리해야 한다.

JWT 인증 모듈 구현

NestJS에서 인증은 Passport 전략(Strategy)으로 구현한다. @nestjs/passportpassport-jwt를 조합하면 JWT 검증 로직을 Guard로 추상화할 수 있다. 로그인 엔드포인트에서 토큰을 발급하고, 보호된 라우트에 @UseGuards(JwtAuthGuard)를 붙이는 방식이다.

Auth 모듈 — JWT 전략 및 가드 구현
# 패키지 설치 npm install @nestjs/passport @nestjs/jwt passport passport-jwt bcrypt npm install -D @types/passport-jwt @types/bcrypt # src/auth/jwt.strategy.ts import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { ConfigService } from '@nestjs/config'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor(config: ConfigService) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, secretOrKey: config.get<string>('JWT_SECRET'), }); } async validate(payload: { sub: number; email: string }) { return { userId: payload.sub, email: payload.email }; } } # src/auth/jwt-auth.guard.ts import { Injectable } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; @Injectable() export class JwtAuthGuard extends AuthGuard('jwt') {} # src/auth/auth.service.ts import { Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { UsersService } from '../users/users.service'; import * as bcrypt from 'bcrypt'; @Injectable() export class AuthService { constructor( private usersService: UsersService, private jwtService: JwtService, ) {} async login(email: string, password: string) { const user = await this.usersService.findByEmail(email); if (!user || !(await bcrypt.compare(password, user.password))) { throw new UnauthorizedException('이메일 또는 비밀번호가 올바르지 않습니다'); } const payload = { sub: user.id, email: user.email }; return { accessToken: this.jwtService.sign(payload), }; } } # src/auth/auth.module.ts import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { AuthService } from './auth.service'; import { AuthController } from './auth.controller'; import { JwtStrategy } from './jwt.strategy'; import { UsersModule } from '../users/users.module'; @Module({ imports: [ UsersModule, PassportModule, JwtModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory: (config: ConfigService) => ({ secret: config.get('JWT_SECRET'), signOptions: { expiresIn: '1d' }, }), }), ], providers: [AuthService, JwtStrategy], controllers: [AuthController], }) export class AuthModule {} # 보호된 라우트에 가드 적용 예시 import { UseGuards, Get, Request } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; @UseGuards(JwtAuthGuard) @Get('profile') getProfile(@Request() req) { return req.user; // JwtStrategy.validate()가 반환한 객체 }

Swagger API 문서 자동화

NestJS의 @nestjs/swagger는 데코레이터를 분석해 OpenAPI 스펙을 자동으로 생성한다. 컨트롤러와 DTO에 데코레이터 몇 줄을 추가하면 /api 경로에 Swagger UI가 열린다. 별도 YAML 파일을 작성할 필요 없이 코드에서 문서가 관리된다.

Swagger 설정 및 DTO 데코레이터
# 패키지 설치 npm install @nestjs/swagger # src/main.ts — Swagger 등록 import { NestFactory } from '@nestjs/core'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { ValidationPipe } from '@nestjs/common'; import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); // 전역 ValidationPipe 등록 app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); // Swagger 설정 const config = new DocumentBuilder() .setTitle('My API') .setDescription('NestJS REST API 문서') .setVersion('1.0') .addBearerAuth() .build(); const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup('api', app, document); await app.listen(3000); console.log('Swagger UI: http://localhost:3000/api'); } bootstrap(); # src/users/dto/create-user.dto.ts import { ApiProperty } from '@nestjs/swagger'; import { IsEmail, IsString, MinLength } from 'class-validator'; export class CreateUserDto { @ApiProperty({ example: 'dev@example.com', description: '사용자 이메일' }) @IsEmail() email: string; @ApiProperty({ example: '홍길동', description: '사용자 이름' }) @IsString() name: string; @ApiProperty({ example: 'securepass123', description: '비밀번호 (최소 8자)' }) @IsString() @MinLength(8) password: string; } # 컨트롤러에 Swagger 데코레이터 추가 import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; @ApiTags('users') // Swagger UI에서 그룹화 @Controller('users') export class UsersController { @ApiOperation({ summary: '회원 생성' }) @Post() create(@Body() dto: CreateUserDto) { ... } @ApiBearerAuth() // JWT 필요 표시 @UseGuards(JwtAuthGuard) @Get() findAll() { ... } }
NestJS Swagger UI 화면
localhost:3000/api에서 열리는 자동 생성 Swagger 문서

환경변수 관리 — ConfigModule

@nestjs/configdotenv를 래핑해 NestJS DI 시스템과 통합한다. ConfigModule.forRoot({ isGlobal: true })로 한 번만 등록하면 모든 모듈에서 ConfigService를 주입받아 환경변수를 타입 안전하게 사용할 수 있다.

.env 파일 및 ConfigService 사용
# .env (절대로 Git에 커밋하지 말 것) NODE_ENV=development DB_HOST=localhost DB_PORT=5432 DB_USER=myuser DB_PASS=mypassword DB_NAME=mydb JWT_SECRET=your-super-secret-key-here # .env.example (Git에 커밋, 팀원 공유용) NODE_ENV= DB_HOST= DB_PORT=5432 DB_USER= DB_PASS= DB_NAME= JWT_SECRET= # src/auth/auth.service.ts — ConfigService 주입 예시 import { ConfigService } from '@nestjs/config'; @Injectable() export class SomeService { constructor(private config: ConfigService) { // 타입 명시 가능 const secret = this.config.get<string>('JWT_SECRET'); const port = this.config.get<number>('DB_PORT'); } } # Joi로 환경변수 유효성 검사 (선택) npm install joi # src/app.module.ts import * as Joi from 'joi'; ConfigModule.forRoot({ isGlobal: true, validationSchema: Joi.object({ NODE_ENV: Joi.string().valid('development', 'production', 'test').required(), DB_PORT: Joi.number().default(5432), JWT_SECRET: Joi.string().required(), }), })

ValidationPipe와 커스텀 예외 처리

NestJS의 ValidationPipeclass-validatorclass-transformer를 활용해 요청 DTO를 자동으로 검증한다. whitelist: true를 설정하면 DTO에 정의되지 않은 필드는 자동으로 제거되어 불필요한 데이터가 서비스로 넘어가지 않는다.

예외 처리는 NestJS 내장 HttpException 클래스 또는 NotFoundException, BadRequestException 같은 서브클래스를 사용한다. 전역 예외 필터를 등록하면 모든 에러 응답을 일관된 형식으로 반환할 수 있다.

DTO 검증 및 커스텀 에러 응답 필터
# 패키지 설치 npm install class-validator class-transformer # src/common/filters/http-exception.filter.ts import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common'; import { Request, Response } from 'express'; @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(); const exceptionResponse = exception.getResponse(); response.status(status).json({ statusCode: status, timestamp: new Date().toISOString(), path: request.url, message: typeof exceptionResponse === 'object' ? (exceptionResponse as any).message : exceptionResponse, }); } } # src/main.ts — 전역 필터 및 파이프 등록 app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalPipes( new ValidationPipe({ whitelist: true, // DTO 외 필드 제거 forbidNonWhitelisted: true, // 미허용 필드 있으면 400 반환 transform: true, // 타입 자동 변환 (string '1' → number 1) transformOptions: { enableImplicitConversion: true }, }), ); # 서비스에서 예외 던지기 import { NotFoundException, ConflictException } from '@nestjs/common'; async findOne(id: number): Promise<User> { const user = await this.usersRepository.findOneBy({ id }); if (!user) { throw new NotFoundException(`ID ${id}에 해당하는 사용자가 없습니다`); } return user; } async create(dto: CreateUserDto): Promise<User> { const exists = await this.usersRepository.findOneBy({ email: dto.email }); if (exists) { throw new ConflictException('이미 사용 중인 이메일입니다'); } // ... }
실전 체크리스트 — 프로덕션 배포 전 확인
  • TypeORM synchronizefalse로 설정하고 마이그레이션 파일 관리
  • JWT 시크릿 최소 32자 이상의 랜덤 문자열로 설정
  • .env.gitignore에 포함되어 있는지 확인
  • Swagger UI 엔드포인트를 프로덕션에서 비활성화하거나 인증 필요로 보호
  • helmet() 미들웨어로 HTTP 보안 헤더 추가
NestJSNode.jsTypeScriptTypeORMJWTSwaggerREST API백엔드인증ValidationPipe

관련 포스트

Node.js vs Bun vs Deno — 2026년 런타임 비교 실무 가이드2026-03-20PostgreSQL 커넥션 풀 고갈로 서비스가 멈춘 새벽 3시 — PgBouncer 도입과 연결 관리 재설계 실전 기록2026-04-21Node.js 22 LTS 새 기능 총정리2026-03-12FastAPI 실전 프로덕션 가이드 — 비동기 패턴, 인증, Docker 배포까지2026-04-15