NestJS 완벽 가이드: 엔터프라이즈급 Node.js 프레임워크
이 글의 핵심
NestJS는 TypeScript 기반의 확장 가능한 서버사이드 프레임워크로 Angular 아키텍처에서 영감을 받았습니다. 의존성 주입(DI), 모듈 시스템, 데코레이터로 코드 구조화 및 테스트가 용이합니다. REST API, GraphQL, WebSocket, 마이크로서비스를 모두 지원하여 다양한 아키텍처 구현이 가능합니다.
요즘 백엔드 글은 다 비슷비슷하죠. 목차가 한 페이지고, 비교 표가 나오고, “모범 사례”가 줄줄이 이어집니다. 읽다 보면 정답지 같아서 오히려 현장 감각이 덜 느껴지기도 해요. 그래서 이번 글은 일부러 패턴을 깨보려고 합니다. 표는 없애고, 엔터프라이즈에서 겪은 이야기(가상이지만, 구조는 실제랑 꽤 비슷해요)를 섞고, 제가 느낀 취향과 단점도 그대로 적을게요.
우선 솔직히 말하면 NestJS는 “Express를 싫어해서” 고르는 프레임워크가 아닙니다. 팀이 커질수록, 레거시가 쌓일수록, 누가 어디에 무엇을 넣는지가 먼저 문제가 되거든요. 제가 봤던 케이스는 이랬어요. 결제·정산이 엮인 8년짜리 Express 모노리스였는데, 라우터 파일에 if문이 꼬이고, 미들웨어 순서가 팀마다 달라서 온콜이 말이 아니었죠. PM은 “마이크로서비스로 쪼개자”를 외쳤고, 엔지니어들은 “일단 경계(Bounded Context)부터”라고 했고요. 결국 1년 넘게 걸친 이전은, 서비스 12개를 한꺼번에 갈아엎는 게 아니라, BFF를 Nest로 올리고, 결제·유저·정산만 먼저 떼어내는 쪽으로 갔어요. 데이터베이스는 당장 못 쪼개도, HTTP/메시징 경계는 먼저 잡는 전략이었죠. 이때 Nest가 도움이 된 건, CLI로 모듈·컨트롤러·서비스 뼈대를 통일해줘서 “우리 팀의 표준”을 코드로 강제할 수 있었던 점이에요. 다만 딱 잘라 말해요, DI는 양날의 검이에요. 테스트랑 모킹은 쾌적해지는데, forwardRef로 억지 끼워 맞추다 보면 암묵적 의존성이 더 은밀해지기도 해요. 그때는 “DI가 주는 구조”가 아니라, 모듈 경계 설계를 다시 봐야 합니다. Angular 해본 분들은 느끼겠지만, DI가 만능이 아니라 “규율이 필요한 힘”이에요.
Express랑 Koa, Nest를 표로 늘여 놓는 대신 한 줄씩 감으로 말해볼게요. Express는 바닥이 넓고 자유롭죠. Koa는 미들웨어 체인이 아름답고요. Nest는 그 위에 TypeScript, 데코레이터, DI, CLI를 올려서 “큰 팀이 동시에 갈겨도 덜 흔들리게” 설계한 느낌이에요. TypeScript·GraphQL·마이크로서비스 트랜스포터·Swagger 쪽이 한 스택에 모여 있어서, “여기는 미니멀리즘”보다 “여기는 엔지니어링을 걸겠다” 쪽 취향이면 잘 맞아요. 반대로, 라우트 세 개짜리 PoC는 Express가 훨씬 가볍죠.
시작은 CLI가 제일 덜 괴로워요. 전역으로 깔아두면 어디서든 nest를 쓸 수 있고, 생성되는 tsconfig·jest·lint 덕에 첫날 삽질이 줄어요.
npm install -g @nestjs/cli
nest --version
프로젝트는 대화형으로 nest new my-project 정도 쓰면 끝이에요. 패키지 매니저 고르고 나면 src/main.ts, app.module.ts가 기본으로 생깁니다. npm run start:dev로 올리면 HMR 느낌으로 개발하다가, 예전엔 3000번에서 “Hello World!” 보면서 안도했었죠.
src 아래가 대충 이렇게 생깁니다. 딱딱한 “프로젝트 구조” 절이 아니라, 그냥 첫 화면만 보고도 “아 여기에 붙이면 되겠다” 느낌이 드는 정도로만 봐도 충분해요.
src/
├── app.controller.ts
├── app.module.ts
├── app.service.ts
└── main.ts
모듈·컨트롤러·서비스는 한 덩어리로 읽는 게 편해요. 모듈이 테두리, 컨트롤러가 입, 서비스가 뇌 정도? 아래는 users 한 바퀴 돌릴 때 흔한 모양새입니다.
// users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
imports: [],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
// users/users.controller.ts
import {
Controller, Get, Post, Body, Param, Put, Delete, Query, HttpCode, HttpStatus,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto, UpdateUserDto } from './dto';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
@HttpCode(HttpStatus.CREATED)
async create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
@Get()
async findAll(@Query('page') page: number = 1) {
return this.usersService.findAll(page);
}
@Get(':id')
async findOne(@Param('id') id: string) {
return this.usersService.findOne(+id);
}
@Put(':id')
async update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
return this.usersService.update(+id, updateUserDto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id') id: string) {
return this.usersService.remove(+id);
}
}
// users/users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateUserDto, UpdateUserDto } from './dto';
@Injectable()
export class UsersService {
private users = [];
create(createUserDto: CreateUserDto) {
const user = { id: Date.now(), ...createUserDto, createdAt: new Date() };
this.users.push(user);
return user;
}
findAll(page: number = 1) {
const limit = 10;
const start = (page - 1) * limit;
return { data: this.users.slice(start, start + limit), page, total: this.users.length };
}
findOne(id: number) {
const user = this.users.find(u => u.id === id);
if (!user) throw new NotFoundException(`User #${id} not found`);
return user;
}
update(id: number, updateUserDto: UpdateUserDto) {
const user = this.findOne(id);
Object.assign(user, updateUserDto);
return user;
}
remove(id: number) {
const index = this.users.findIndex(u => u.id === id);
if (index === -1) throw new NotFoundException(`User #${id} not found`);
this.users.splice(index, 1);
}
}
DTO는 class-validator로 붙이면 요청이 한결 편해요. Swagger 쓰는 팀이면 @ApiProperty도 같이 가져가죠.
// users/dto/create-user.dto.ts
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateUserDto {
@ApiProperty({ description: '사용자 이름', example: '홍길동' })
@IsString()
@MinLength(2)
name: string;
@ApiProperty({ description: '이메일', example: '[email protected]' })
@IsEmail()
email: string;
@ApiProperty({ description: '비밀번호', minLength: 8 })
@IsString()
@MinLength(8)
password: string;
@ApiPropertyOptional({ description: '프로필 이미지 URL' })
@IsOptional()
@IsString()
avatar?: string;
}
// users/dto/update-user.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';
export class UpdateUserDto extends PartialType(CreateUserDto) {}
nest g resource로 CRUD 뼈대를 통째로 뽑을 수도 있어요. 솔직히 이거 없었으면 엔터프라이즈 팀이 표준을 맞추느라 더 싸웠을 거예요.
nest generate resource posts
# REST / GraphQL / WebSocket / Microservice 중 선택
nest g module cats
nest g controller cats
nest g service cats
DB는 TypeORM 쓰는 팀이 많죠. synchronize: true는 로컬에서만 써요. 운영에서 true 켰다가 사고나는 이야기는 은근히 들음.
npm install @nestjs/typeorm typeorm mysql2
# PostgreSQL이면 pg, SQLite면 sqlite3
// app.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersModule } from './users/users.module';
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'mysql',
host: 'localhost',
port: 3306,
username: 'root',
password: 'password',
database: 'test',
entities: [__dirname + '/**/*.entity{.ts,.js}'],
synchronize: true,
}),
UsersModule,
],
})
export class AppModule {}
// users/entities/user.entity.ts
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
import { Post } from '../../posts/entities/post.entity';
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 100 })
name: string;
@Column({ unique: true })
email: string;
@Column({ select: false })
password: string;
@Column({ nullable: true })
avatar?: string;
@Column({ default: true })
isActive: boolean;
@OneToMany(() => Post, post => post.author)
posts: Post[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
Repository 주입은 이런 느낌이죠. NotFoundException 쓰는 것도, 예전엔 404를 숫자로 흩뿌리던 팀이랑 비교되면 감사한 편이에요.
// users/users.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
// users/users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { CreateUserDto, UpdateUserDto } from './dto';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private usersRepository: Repository<User>,
) {}
async create(createUserDto: CreateUserDto): Promise<User> {
const user = this.usersRepository.create(createUserDto);
return this.usersRepository.save(user);
}
async findAll(): Promise<User[]> {
return this.usersRepository.find({ relations: ['posts'], order: { createdAt: 'DESC' } });
}
async findOne(id: number): Promise<User> {
const user = await this.usersRepository.findOne({ where: { id }, relations: ['posts'] });
if (!user) throw new NotFoundException(`User #${id} not found`);
return user;
}
async update(id: number, updateUserDto: UpdateUserDto): Promise<User> {
await this.usersRepository.update(id, updateUserDto);
return this.findOne(id);
}
async remove(id: number): Promise<void> {
const result = await this.usersRepository.delete(id);
if (result.affected === 0) throw new NotFoundException(`User #${id} not found`);
}
}
전역 파이프로 DTO를 밀어넣는 패턴도 많죠. whitelist 키면 “몰래 온 필드”를 잘라줘서 방어력이 올라가요. 이건 실제로 취약점 이슈랑 엮입니다.
npm install class-validator class-transformer
// main.ts
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}));
await app.listen(3000);
}
bootstrap();
가드는 “장식으로 읽는 보안”에 가깝죠. 아래는 JWT/역할 예시를 한 힙에.
// auth/guards/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.get<string[]>('roles', context.getHandler());
if (!requiredRoles) return true;
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some(role => user.roles?.includes(role));
}
}
인터셉터로 응답을 감싸거나 로깅을 찍는 팀도 많고요, 예외 필터로 JSON 형식을 통일하죠. 운영에서 클라이언트가 “에러 JSON 모양”을 믿을 수 있게 해주는 건 생각보다 큰 일이에요.
// common/filters/http-exception.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpException } 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();
const error = typeof exceptionResponse === 'string'
? { message: exceptionResponse }
: (exceptionResponse as object);
response.status(status).json({ success: false, ...error, timestamp: new Date().toISOString(), path: request.url });
}
}
문서는 @nestjs/swagger 쓰면 /api 같은 곳에 UI가 뜨죠. “문서 = 코드” 쪽 취향이면 꽤 잘 맞아요. 환경 변수는 ConfigModule.forRoot로 끌고 오는 식. 운영·스테이징 나눌 때 envFilePath만 잘 잡으면 돼요.
npm install @nestjs/config
@Module({
imports: [ConfigModule.forRoot({ isGlobal: true, envFilePath: `.env.${process.env.NODE_ENV}` })],
})
export class AppModule {}
테스트는 Jest 끼고 Repository 토큰을 목으로 꽂는 전형적인 그림. DI 덕에 여기가 편해지는 건 사실이에요. 대신, 목이 20개면 설계 냄새가 날 수도 있죠. 그때는 “목을 늘이지 말고 모듈을 쪼개자” 쪽이 제 취향이에요.
// users/users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';
describe('UsersService', () => {
let service: UsersService;
let mockRepository: { find: jest.Mock; findOne: jest.Mock; create: jest.Mock; save: jest.Mock; update: jest.Mock; delete: jest.Mock };
beforeEach(async () => {
mockRepository = {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [UsersService, { provide: getRepositoryToken(User), useValue: mockRepository }],
}).compile();
service = module.get<UsersService>(UsersService);
});
it('findAll', async () => {
const users = [{ id: 1, name: 'Test User' }];
mockRepository.find.mockResolvedValue(users);
await expect(service.findAll()).resolves.toEqual(users);
});
});
JWT는 Passport 조합이 실무에서 많죠. 취향 차이는 있는데, 가드에 선언적으로 붙이는 느낌은 익숙해지면 편해요.
@Injectable()
export class AuthService {
constructor(private usersService: UsersService, private jwtService: JwtService) {}
// register / login ... bcrypt + this.jwtService.sign(payload)
}
@Controller('profile')
export class ProfileController {
@UseGuards(JwtAuthGuard)
@Get()
getProfile(@Request() req) {
return req.user;
}
}
마이크로서비스 쪽은 connectMicroservice로 게이트웨이·TCP 같은 걸 엮는 패턴. 모노리스에서 쪼갤 때, 프로세스 먼저, DB는 나중 전략이 흔해요. Nest가 트랜스포터를 잘 묶어주는 건 편한데, 메시징이 늘수록 운영 난이도는 확 올라가요. “프레임워크가 대신 절망감을 없애주지는” 않죠.
const app = await NestFactory.create(AppModule);
app.connectMicroservice({ transport: Transport.TCP, options: { port: 3001 } });
await app.startAllMicroservices();
마지막으로 제 짧은 취향 정리. 순환 의존성이 보이면 forwardRef만 늘리지 말고 경계를 다시 잡는 게 먼저예요. DTO 검증은 켜두는 게 이득이고, 전역 예외 필터는 클라이언트·클라이언트팀·온콜 모두를 살려요. 그리고 한 번 더: DI는 양날의 검 — 잘 쓰면 팀이 빨라지고, 남용하면 “보이지 않는 그래프”만 남을 수 있어요. 엔터프라이즈 이전 이야기로 시작했으니, 여기서 끝을 맺을게요. 모노리스에서 한 번에 뜯는 것보다, BFF+경계+표준화된 모듈 구조로 팀이 같은 언어를 쓰게 만드는 쪽이, 제 경험엔 훨씬 승률이 높았고요. Nest는 그 “같은 언어”를 코드로 박아줄 수 있는 옵션 중 하나입니다. 취향 안 맞으면 말고요.