콘텐츠로 이동

NestJS

확장 가능한 서버 사이드 애플리케이션을 구축하기 위한 프로그레시브 TypeScript 우선 Node.js 프레임워크.

명령어설명
npm i -g @nestjs/cliNestJS CLI 전역 설치
nest new project-name새 NestJS 프로젝트 생성
nest new project-name --strict엄격한 TypeScript로 프로젝트 생성
nest new project-name -p pnpmpnpm을 사용하여 프로젝트 생성
nest new project-name -p yarnYarn을 사용하여 프로젝트 생성
명령어설명
npm run start:dev핫 리로드로 개발 서버 시작
npm run start:debug디버그 모드와 핫 리로드로 시작
npm run start:prod프로덕션 서버 시작
npm run build프로젝트 빌드
npm run test단위 테스트 실행
npm run test:watch감시 모드로 테스트 실행
npm run test:cov커버리지 리포트와 함께 테스트 실행
npm run test:e2e엔드투엔드 테스트 실행
npm run lint소스 코드 린트
src/
├── app.controller.ts       # 루트 컨트롤러
├── app.controller.spec.ts  # 컨트롤러 단위 테스트
├── app.module.ts           # 루트 모듈
├── app.service.ts          # 루트 서비스
├── main.ts                 # 애플리케이션 진입점
├── users/
│   ├── users.module.ts     # 기능 모듈
│   ├── users.controller.ts # 기능 컨트롤러
│   ├── users.service.ts    # 기능 서비스
│   ├── dto/
│   │   ├── create-user.dto.ts
│   │   └── update-user.dto.ts
│   ├── entities/
│   │   └── user.entity.ts
│   └── users.controller.spec.ts
├── auth/
│   ├── auth.module.ts
│   ├── auth.guard.ts
│   ├── auth.service.ts
│   └── strategies/
│       └── jwt.strategy.ts
└── common/
    ├── filters/
    │   └── http-exception.filter.ts
    ├── interceptors/
    │   └── logging.interceptor.ts
    └── pipes/
        └── validation.pipe.ts
명령어설명
nest generate module users새 모듈 생성
nest generate controller users새 컨트롤러 생성
nest generate service users새 서비스 생성
nest generate resource users전체 CRUD 리소스 생성 (모듈 + 컨트롤러 + 서비스 + DTO)
nest generate guard auth인증 가드 생성
nest generate pipe validation유효성 검사 파이프 생성
nest generate interceptor logging인터셉터 생성
nest generate middleware logger미들웨어 생성
nest generate filter http-exception예외 필터 생성
nest generate decorator roles커스텀 데코레이터 생성
nest generate class user.entity일반 클래스 생성
nest generate interface user인터페이스 생성
nest g res users --no-spec테스트 파일 없이 리소스 생성
nest g mo users --flat하위 폴더 없이 생성
nest info프로젝트 정보 및 의존성 표시
# 전체 CRUD 리소스 (가장 일반적인 워크플로우)
nest g resource users

# CLI 프롬프트:
# ? What transport layer do you use? REST API
# ? Would you like to generate CRUD entry points? Yes

# 생성되는 파일:
# src/users/users.module.ts
# src/users/users.controller.ts
# src/users/users.service.ts
# src/users/dto/create-user.dto.ts
# src/users/dto/update-user.dto.ts
# src/users/entities/user.entity.ts
# src/users/users.controller.spec.ts
# 업데이트: src/app.module.ts (UsersModule 임포트)
명령어설명
@Module({ imports: [UsersModule] })다른 모듈 임포트
@Module({ controllers: [UsersController] })컨트롤러 등록
@Module({ providers: [UsersService] })프로바이더/서비스 등록
@Module({ exports: [UsersService] })다른 모듈용으로 프로바이더 내보내기
@Global()모듈을 전역으로 사용 가능하게 설정 (사용 자제)
import { Module, DynamicModule } from '@nestjs/common'

@Module({})
export class DatabaseModule {
  static forRoot(options: DatabaseOptions): DynamicModule {
    return {
      module: DatabaseModule,
      global: true,
      providers: [
        { provide: 'DATABASE_OPTIONS', useValue: options },
        DatabaseService,
      ],
      exports: [DatabaseService],
    }
  }

  static forRootAsync(options: DatabaseAsyncOptions): DynamicModule {
    return {
      module: DatabaseModule,
      global: true,
      imports: options.imports || [],
      providers: [
        {
          provide: 'DATABASE_OPTIONS',
          useFactory: options.useFactory,
          inject: options.inject || [],
        },
        DatabaseService,
      ],
      exports: [DatabaseService],
    }
  }
}

// AppModule에서 사용
@Module({
  imports: [
    DatabaseModule.forRootAsync({
      imports: [ConfigModule],
      useFactory: (config: ConfigService) => ({
        host: config.get('DB_HOST'),
        port: config.get('DB_PORT'),
      }),
      inject: [ConfigService],
    }),
  ],
})
export class AppModule {}
명령어설명
@Controller('users')라우트 접두사로 컨트롤러 정의
@Get()GET 요청 처리
@Post()POST 요청 처리
@Put(':id')파라미터가 있는 PUT 요청 처리
@Patch(':id')PATCH 요청 처리
@Delete(':id')DELETE 요청 처리
@All('*')모든 HTTP 메서드 처리
명령어설명
@Param('id') id: string라우트 파라미터 추출
@Param('id', ParseIntPipe) id: number정수로 추출 및 파싱
@Query('page') page: string쿼리 파라미터 추출
@Query() query: PaginationDto모든 쿼리 파라미터를 DTO로 추출
@Body() dto: CreateUserDto요청 본문 추출 및 타입 지정
@Body('email') email: string특정 본문 필드 추출
@Headers('authorization') auth: string요청 헤더 추출
@Ip() ip: string클라이언트 IP 주소 추출
@Req() request: Request원시 요청 객체 접근
@Res() response: Response원시 응답 객체 접근
@HttpCode(201)커스텀 HTTP 상태 코드 설정
@Header('Cache-Control', 'none')응답 헤더 설정
@Redirect('/new-url', 301)리다이렉트 응답
import {
  Controller, Get, Post, Put, Delete, Param, Query, Body,
  HttpCode, HttpStatus, ParseIntPipe, UseGuards, UseInterceptors,
} from '@nestjs/common'
import { UsersService } from './users.service'
import { CreateUserDto } from './dto/create-user.dto'
import { UpdateUserDto } from './dto/update-user.dto'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { LoggingInterceptor } from '../common/interceptors/logging.interceptor'

@Controller('users')
@UseInterceptors(LoggingInterceptor)
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  @HttpCode(HttpStatus.CREATED)
  create(@Body() createUserDto: CreateUserDto) {
    return this.usersService.create(createUserDto)
  }

  @Get()
  findAll(
    @Query('page', new ParseIntPipe({ optional: true })) page = 1,
    @Query('limit', new ParseIntPipe({ optional: true })) limit = 10,
  ) {
    return this.usersService.findAll(page, limit)
  }

  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    return this.usersService.findOne(id)
  }

  @Put(':id')
  @UseGuards(JwtAuthGuard)
  update(
    @Param('id', ParseIntPipe) id: number,
    @Body() updateUserDto: UpdateUserDto,
  ) {
    return this.usersService.update(id, updateUserDto)
  }

  @Delete(':id')
  @UseGuards(JwtAuthGuard)
  @HttpCode(HttpStatus.NO_CONTENT)
  remove(@Param('id', ParseIntPipe) id: number) {
    return this.usersService.remove(id)
  }
}
명령어설명
@Injectable()클래스를 주입 가능한 프로바이더로 표시
constructor(private usersService: UsersService)생성자를 통한 서비스 주입
@Inject('TOKEN') private config커스텀 토큰으로 주입
{ provide: 'TOKEN', useValue: value }값 프로바이더 등록
{ provide: 'TOKEN', useFactory: () => ... }팩토리 프로바이더 등록
{ provide: 'TOKEN', useClass: MyClass }클래스 프로바이더 등록
{ provide: 'TOKEN', useExisting: OtherService }기존 프로바이더 별칭
@Optional() private service?: MyService선택적 의존성 주입
{ provide: 'TOKEN', scope: Scope.REQUEST }요청 범위 프로바이더
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository } from 'typeorm'
import { User } from './entities/user.entity'
import { CreateUserDto } from './dto/create-user.dto'
import { UpdateUserDto } from './dto/update-user.dto'

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private readonly usersRepo: Repository<User>,
  ) {}

  async create(dto: CreateUserDto): Promise<User> {
    const exists = await this.usersRepo.findOneBy({ email: dto.email })
    if (exists) {
      throw new ConflictException('Email already registered')
    }
    const user = this.usersRepo.create(dto)
    return this.usersRepo.save(user)
  }

  async findAll(page: number, limit: number) {
    const [items, total] = await this.usersRepo.findAndCount({
      skip: (page - 1) * limit,
      take: limit,
      order: { createdAt: 'DESC' },
    })
    return { items, total, page, limit }
  }

  async findOne(id: number): Promise<User> {
    const user = await this.usersRepo.findOneBy({ id })
    if (!user) throw new NotFoundException(`User #${id} not found`)
    return user
  }

  async update(id: number, dto: UpdateUserDto): Promise<User> {
    const user = await this.findOne(id)
    Object.assign(user, dto)
    return this.usersRepo.save(user)
  }

  async remove(id: number): Promise<void> {
    const result = await this.usersRepo.delete(id)
    if (result.affected === 0) {
      throw new NotFoundException(`User #${id} not found`)
    }
  }
}
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,           // 페이로드를 DTO 타입으로 자동 변환
    transformOptions: {
      enableImplicitConversion: true, // 쿼리 문자열을 숫자/불리언으로 변환
    },
  }))
  await app.listen(3000)
}
import {
  IsString, IsEmail, IsOptional, IsInt, Min, Max,
  MinLength, MaxLength, IsEnum, ValidateNested, IsArray,
} from 'class-validator'
import { Type } from 'class-transformer'
import { PartialType, OmitType, PickType, IntersectionType } from '@nestjs/mapped-types'

export class CreateUserDto {
  @IsString()
  @MinLength(2)
  @MaxLength(50)
  name: string

  @IsEmail()
  email: string

  @IsString()
  @MinLength(8)
  password: string

  @IsOptional()
  @IsEnum(Role)
  role?: Role

  @IsOptional()
  @ValidateNested()
  @Type(() => AddressDto)
  address?: AddressDto
}

// 업데이트 DTO 자동 생성 (모든 필드 선택적)
export class UpdateUserDto extends PartialType(CreateUserDto) {}

// 특정 필드 선택
export class LoginDto extends PickType(CreateUserDto, ['email', 'password']) {}

// 특정 필드 제외
export class PublicUserDto extends OmitType(CreateUserDto, ['password']) {}
명령어설명
@UseGuards(AuthGuard)컨트롤러 또는 라우트에 가드 적용
@UseGuards(AuthGuard, RolesGuard)여러 가드 체이닝
app.useGlobalGuards(new AuthGuard())전역 가드 등록
canActivate(context: ExecutionContext)가드 로직 구현
@SetMetadata('roles', ['admin'])가드용 커스텀 메타데이터 설정
Reflector가드에서 메타데이터 읽기
npm install @nestjs/jwt @nestjs/passport passport passport-jwt
npm install -D @types/passport-jwt
// auth/auth.module.ts
@Module({
  imports: [
    UsersModule,
    JwtModule.registerAsync({
      imports: [ConfigModule],
      useFactory: (config: ConfigService) => ({
        secret: config.get('JWT_SECRET'),
        signOptions: { expiresIn: '1h' },
      }),
      inject: [ConfigService],
    }),
  ],
  providers: [AuthService, JwtStrategy],
  controllers: [AuthController],
  exports: [AuthService],
})
export class AuthModule {}

// auth/jwt.strategy.ts
import { Injectable } from '@nestjs/common'
import { PassportStrategy } from '@nestjs/passport'
import { ExtractJwt, Strategy } from 'passport-jwt'

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(config: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: config.get('JWT_SECRET'),
    })
  }

  validate(payload: { sub: number; email: string }) {
    return { id: payload.sub, email: payload.email }
  }
}

// auth/jwt-auth.guard.ts
import { Injectable } from '@nestjs/common'
import { AuthGuard } from '@nestjs/passport'

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

// 사용법
@Controller('profile')
@UseGuards(JwtAuthGuard)
export class ProfileController {
  @Get()
  getProfile(@Req() req) {
    return req.user // JwtStrategy.validate()에 의해 주입됨
  }
}
// roles.decorator.ts
import { SetMetadata } from '@nestjs/common'
export const Roles = (...roles: string[]) => SetMetadata('roles', roles)

// 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.getAllAndOverride<string[]>('roles', [
      context.getHandler(),
      context.getClass(),
    ])
    if (!requiredRoles) return true
    const { user } = context.switchToHttp().getRequest()
    return requiredRoles.some(role => user.roles?.includes(role))
  }
}

// 사용법
@Controller('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
export class AdminController {
  @Get('dashboard')
  @Roles('admin')
  getDashboard() {
    return { message: 'Admin dashboard' }
  }

  @Delete('users/:id')
  @Roles('admin', 'superadmin')
  removeUser(@Param('id', ParseIntPipe) id: number) {
    return this.usersService.remove(id)
  }
}
import { Injectable, NestMiddleware } from '@nestjs/common'
import { Request, Response, NextFunction } from 'express'

@Injectable()
export class LoggerMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    const start = Date.now()
    res.on('finish', () => {
      const duration = Date.now() - start
      console.log(`${req.method} ${req.path} ${res.statusCode} ${duration}ms`)
    })
    next()
  }
}

// 모듈에서 등록
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(LoggerMiddleware)
      .forRoutes('*')
  }
}
import {
  Injectable, NestInterceptor, ExecutionContext, CallHandler,
} from '@nestjs/common'
import { Observable, map, tap } from 'rxjs'

// 응답 형태 변환
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, { data: T }> {
  intercept(context: ExecutionContext, next: CallHandler): Observable<{ data: T }> {
    return next.handle().pipe(
      map(data => ({ data, timestamp: new Date().toISOString() })),
    )
  }
}

// 실행 시간 측정
@Injectable()
export class TimingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const start = Date.now()
    return next.handle().pipe(
      tap(() => {
        const duration = Date.now() - start
        const response = context.switchToHttp().getResponse()
        response.setHeader('X-Response-Time', `${duration}ms`)
      }),
    )
  }
}
명령어설명
npm install @nestjs/typeorm typeorm pgPostgreSQL과 함께 TypeORM 설치
TypeOrmModule.forRoot({ type: 'postgres', ... })AppModule에서 연결 설정
TypeOrmModule.forFeature([User])기능 모듈에서 엔티티 등록
@InjectRepository(User) private repo: Repository<User>리포지토리 주입
npm install @prisma/client
npm install -D prisma
npx prisma init
// 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 {
  async onModuleInit() {
    await this.$connect()
  }

  async onModuleDestroy() {
    await this.$disconnect()
  }
}

// prisma/prisma.module.ts
@Global()
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}

// 서비스에서 사용
@Injectable()
export class UsersService {
  constructor(private prisma: PrismaService) {}

  findAll() {
    return this.prisma.user.findMany()
  }

  findOne(id: number) {
    return this.prisma.user.findUniqueOrThrow({ where: { id } })
  }
}
명령어설명
throw new BadRequestException('Invalid input')400 잘못된 요청
throw new UnauthorizedException('Login required')401 인증 필요
throw new ForbiddenException('Access denied')403 접근 거부
throw new NotFoundException('User not found')404 찾을 수 없음
throw new ConflictException('Email taken')409 충돌
throw new UnprocessableEntityException('Validation failed')422 처리 불가
throw new InternalServerErrorException()500 내부 서버 오류
import {
  ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus,
} from '@nestjs/common'
import { Request, Response } from 'express'

@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>()

    const status = exception instanceof HttpException
      ? exception.getStatus()
      : HttpStatus.INTERNAL_SERVER_ERROR

    const message = exception instanceof HttpException
      ? exception.getResponse()
      : 'Internal server error'

    response.status(status).json({
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
      message: typeof message === 'string' ? message : (message as any).message,
    })
  }
}

// main.ts에서 전역으로 등록
app.useGlobalFilters(new AllExceptionsFilter())
npm install @nestjs/config
// app.module.ts
import { ConfigModule, ConfigService } from '@nestjs/config'

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: ['.env.local', '.env'],
      validationSchema: Joi.object({
        NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'),
        PORT: Joi.number().default(3000),
        DATABASE_URL: Joi.string().required(),
        JWT_SECRET: Joi.string().required(),
      }),
    }),
  ],
})
export class AppModule {}

// 모든 서비스에서 사용
@Injectable()
export class AppService {
  constructor(private config: ConfigService) {}

  getPort(): number {
    return this.config.get<number>('PORT', 3000)
  }
}
import { Test, TestingModule } from '@nestjs/testing'
import { UsersService } from './users.service'
import { getRepositoryToken } from '@nestjs/typeorm'
import { User } from './entities/user.entity'

describe('UsersService', () => {
  let service: UsersService

  const mockRepository = {
    create: jest.fn(),
    save: jest.fn(),
    findOneBy: jest.fn(),
    findAndCount: jest.fn(),
    delete: jest.fn(),
  }

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UsersService,
        { provide: getRepositoryToken(User), useValue: mockRepository },
      ],
    }).compile()

    service = module.get<UsersService>(UsersService)
  })

  it('should create a user', async () => {
    const dto = { name: 'Alice', email: 'alice@example.com', password: 'secret123' }
    mockRepository.findOneBy.mockResolvedValue(null)
    mockRepository.create.mockReturnValue(dto)
    mockRepository.save.mockResolvedValue({ id: 1, ...dto })

    const result = await service.create(dto)
    expect(result.id).toBe(1)
    expect(mockRepository.save).toHaveBeenCalled()
  })
})
import { Test, TestingModule } from '@nestjs/testing'
import { INestApplication, ValidationPipe } from '@nestjs/common'
import * as request from 'supertest'
import { AppModule } from '../src/app.module'

describe('UsersController (e2e)', () => {
  let app: INestApplication

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile()

    app = moduleFixture.createNestApplication()
    app.useGlobalPipes(new ValidationPipe({ whitelist: true }))
    await app.init()
  })

  afterAll(async () => {
    await app.close()
  })

  it('/users (POST) creates a user', () => {
    return request(app.getHttpServer())
      .post('/users')
      .send({ name: 'Alice', email: 'alice@example.com', password: 'secret123' })
      .expect(201)
      .expect((res) => {
        expect(res.body.name).toBe('Alice')
        expect(res.body.id).toBeDefined()
      })
  })

  it('/users (GET) returns paginated list', () => {
    return request(app.getHttpServer())
      .get('/users?page=1&limit=10')
      .expect(200)
      .expect((res) => {
        expect(res.body.items).toBeInstanceOf(Array)
        expect(res.body.total).toBeDefined()
      })
  })
})
  1. 리소스 생성기 사용nest g resource는 전체 CRUD 스캐폴드 (모듈, 컨트롤러, 서비스, DTO, 엔티티, 테스트)를 몇 초 만에 생성하고 프로젝트 일관성을 유지합니다.

  2. whitelist로 전역 ValidationPipe 활성화whitelist: trueforbidNonWhitelisted: true를 설정하여 모든 요청에서 예상치 못한 필드를 자동으로 제거하거나 거부합니다.

  3. DTO에 매핑 타입 사용PartialType, PickType, OmitType, IntersectionType은 생성 DTO에서 업데이트/쿼리 DTO를 파생하여 DRY를 유지합니다.

  4. 생성자 주입 선호 — 커스텀 프로바이더가 필요하지 않은 한 수동 @Inject() 토큰 대신 생성자를 통한 의존성 주입을 사용합니다.

  5. 컨트롤러를 가볍게 유지 — 컨트롤러는 입력 파싱과 출력 반환만 해야 합니다. 테스트 가능성과 재사용을 위해 모든 비즈니스 로직을 서비스로 이동합니다.

  6. 모든 환경 접근에 ConfigModule 사용 — 서비스에서 process.env를 직접 읽지 마세요. 유효성 검사 스키마와 함께 ConfigService를 사용하여 누락된 변수가 시작 시 빠르게 실패하도록 합니다.

  7. 가드와 인터셉터의 적절한 범위 지정 — 정말로 모든 곳에 적용되지 않는 한 전역이 아닌 데코레이터로 컨트롤러 또는 라우트 수준에서 적용합니다.

  8. 단위 및 E2E 테스트 작성Test.createTestingModule()로 모의 의존성을 사용하여 서비스를 단위 테스트하고, supertest로 HTTP 엔드포인트를 E2E 테스트합니다.

  9. Prisma 또는 TypeORM을 일관되게 사용 — 하나의 ORM을 선택하여 프로젝트 전체에서 사용합니다. Prisma는 더 나은 타입 안전성을, TypeORM은 데코레이터로 더 많은 유연성을 제공합니다.

  10. 타입이 아닌 기능별로 구조화 — 타입별(controllers/, services/)이 아닌 도메인별(users/, auth/, orders/)로 파일을 그룹화합니다. 이렇게 하면 관련 코드가 함께 유지되고 모듈이 자체 포함됩니다.

  11. 예외로 오류 처리 — 서비스에서 내장 HTTP 예외(NotFoundException, ConflictException)를 던집니다. 커스텀 오류 형식에는 예외 필터를 사용합니다.

  12. async/await를 일관되게 사용 — NestJS는 기본적으로 프로미스를 처리합니다. 서비스 메서드에서 비동기 값을 반환하고 프레임워크가 응답을 직렬화하도록 합니다.