콘텐츠로 이동

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

CLI 코드 생성

생성 명령어

명령어설명
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`)
    }
  }
}

유효성 검사 & DTO

Class Validator 설정

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)
}

DTO 예제

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가드에서 메타데이터 읽기

JWT 인증

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`)
      }),
    )
  }
}

데이터베이스 통합

TypeORM 설정

명령어설명
npm install @nestjs/typeorm typeorm pgPostgreSQL과 함께 TypeORM 설치
TypeOrmModule.forRoot({ type: 'postgres', ... })AppModule에서 연결 설정
TypeOrmModule.forFeature([User])기능 모듈에서 엔티티 등록
@InjectRepository(User) private repo: Repository<User>리포지토리 주입

Prisma 설정

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

E2E 테스트

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는 기본적으로 프로미스를 처리합니다. 서비스 메서드에서 비동기 값을 반환하고 프레임워크가 응답을 직렬화하도록 합니다.