Nest.JS에서의 REST API 작성 예시 (3/4)

2025. 4. 26. 22:47·Study

본 문서는 REST API 시리즈 중 3편입니다. 1편에서는 REST API의 기본 개념과 설계 원칙을, 2편에서는 Spring Boot를 활용한 구현 방법을 다루었으며, 이번 편에서는 Nest.JS를 활용한 구현 방법을 설명합니다.

1. Nest.JS 소개

Nest.JS는 효율적이고 확장 가능한 Node.js 서버 사이드 애플리케이션을 구축하기 위한 프레임워크입니다. TypeScript를 기본적으로 지원하며, 객체 지향 프로그래밍(OOP), 함수형 프로그래밍(FP), 함수 반응형 프로그래밍(FRP)의 요소를 결합하여 사용합니다.

특징 설명

구조적 설계 Angular에서 영감을 받은 모듈화된 구조 제공
TypeScript 지원 강력한 타입 시스템으로 개발 안정성 향상
의존성 주입 느슨한 결합과 테스트 용이성 제공
다양한 HTTP 플랫폼 Express(기본값) 또는 Fastify와 같은 HTTP 서버 라이브러리 선택 가능
마이크로서비스 지원 분산 시스템 개발을 위한 마이크로서비스 아키텍처 지원
생태계 호환성 다양한 Node.js 모듈 및 라이브러리와 호환

 

2. 개발 환경 설정

2.1 필수 도구

  • Node.js 16.x 이상
  • npm 8.x 이상 또는 Yarn 1.22.x 이상
  • TypeScript 4.7.x 이상
  • IDE (Visual Studio Code 권장)

2.2 Nest CLI 설치 및 프로젝트 생성

# Nest CLI 전역 설치
npm install -g @nestjs/cli

# 새 프로젝트 생성
nest new rest-api-demo

# 디렉토리 이동
cd rest-api-demo

# 개발 서버 실행
npm run start:dev

 

3. 프로젝트 구조

Nest.JS 프로젝트의 기본 구조:

src/
├── app.controller.spec.ts   # 컨트롤러 테스트 파일
├── app.controller.ts        # 기본 컨트롤러
├── app.module.ts            # 루트 모듈
├── app.service.ts           # 기본 서비스
├── main.ts                  # 애플리케이션 엔트리 포인트
├── users/                   # 사용자 모듈 디렉토리
│   ├── dto/
│   │   ├── create-user.dto.ts
│   │   └── update-user.dto.ts
│   ├── entities/
│   │   └── user.entity.ts
│   ├── users.controller.spec.ts
│   ├── users.controller.ts
│   ├── users.module.ts
│   ├── users.service.spec.ts
│   └── users.service.ts
├── common/                  # 공통 기능 디렉토리
│   ├── filters/
│   │   └── http-exception.filter.ts
│   ├── guards/
│   │   └── auth.guard.ts
│   ├── interceptors/
│   │   └── logging.interceptor.ts
│   └── pipes/
│       └── validation.pipe.ts
└── config/                  # 설정 관련 디렉토리
    ├── configuration.ts
    └── validation.ts

 

4. 의존성 설정

4.1 기본 의존성

Nest.JS 프로젝트를 생성하면 기본적으로 다음 의존성이 설치됩니다:

{
  "dependencies": {
    "@nestjs/common": "^10.0.0",
    "@nestjs/core": "^10.0.0",
    "@nestjs/platform-express": "^10.0.0",
    "reflect-metadata": "^0.1.13",
    "rxjs": "^7.8.1"
  },
  "devDependencies": {
    "@nestjs/cli": "^10.0.0",
    "@nestjs/schematics": "^10.0.0",
    "@nestjs/testing": "^10.0.0",
    "@types/express": "^4.17.17",
    "@types/jest": "^29.5.2",
    "@types/node": "^20.3.1",
    "@types/supertest": "^2.0.12",
    "@typescript-eslint/eslint-plugin": "^6.0.0",
    "@typescript-eslint/parser": "^6.0.0",
    "eslint": "^8.42.0",
    "jest": "^29.5.0",
    "source-map-support": "^0.5.21",
    "supertest": "^6.3.3",
    "ts-jest": "^29.1.0",
    "ts-loader": "^9.4.3",
    "ts-node": "^10.9.1",
    "tsconfig-paths": "^4.2.0",
    "typescript": "^5.1.3"
  }
}

4.2 추가 의존성 설치

REST API 구현에 필요한 추가 패키지를 설치합니다:

# 유효성 검증
npm install class-validator class-transformer

# 데이터베이스 (TypeORM + SQLite)
npm install @nestjs/typeorm typeorm sqlite3

# 환경 변수 및 설정
npm install @nestjs/config

# API 문서화 (Swagger)
npm install @nestjs/swagger swagger-ui-express

# 인증 및 인가
npm install @nestjs/jwt @nestjs/passport passport passport-jwt bcrypt
npm install --save-dev @types/passport-jwt @types/bcrypt

 

5. 애플리케이션 설정

5.1 main.ts 설정

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  
  // 글로벌 접두사 설정
  app.setGlobalPrefix('api');
  
  // 글로벌 파이프 설정
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,          // DTO에 정의되지 않은 속성 제거
      forbidNonWhitelisted: true, // DTO에 정의되지 않은 속성이 있으면 요청 거부
      transform: true,          // 요청 데이터를 DTO 클래스 인스턴스로 변환
    }),
  );
  
  // 글로벌 필터 설정
  app.useGlobalFilters(new HttpExceptionFilter());
  
  // Swagger 설정
  const config = new DocumentBuilder()
    .setTitle('REST API Demo')
    .setDescription('Nest.JS를 이용한 REST API 예제')
    .setVersion('1.0')
    .addTag('users')
    .addBearerAuth()
    .build();
  const document = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup('api/docs', app, document);
  
  // CORS 활성화
  app.enableCors();
  
  await app.listen(3000);
  console.log(`Application is running on: ${await app.getUrl()}`);
}
bootstrap();

5.2 환경 변수 설정 (.env)

# 애플리케이션
PORT=3000
NODE_ENV=development

# 데이터베이스
DB_TYPE=sqlite
DB_NAME=rest_api_demo.db

# JWT
JWT_SECRET=your_jwt_secret_key
JWT_EXPIRATION=60m

5.3 환경 설정 모듈 (app.module.ts)

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module';
import * as Joi from 'joi';

@Module({
  imports: [
    // 환경 설정
    ConfigModule.forRoot({
      isGlobal: true,
      validationSchema: Joi.object({
        NODE_ENV: Joi.string()
          .valid('development', 'production', 'test')
          .default('development'),
        PORT: Joi.number().default(3000),
        DB_TYPE: Joi.string().required(),
        DB_NAME: Joi.string().required(),
        JWT_SECRET: Joi.string().required(),
        JWT_EXPIRATION: Joi.string().required(),
      }),
    }),
    
    // 데이터베이스
    TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => ({
        type: configService.get('DB_TYPE'),
        database: configService.get('DB_NAME'),
        entities: [__dirname + '/**/*.entity{.ts,.js}'],
        synchronize: configService.get('NODE_ENV') !== 'production',
      }),
    }),
    
    // 기능 모듈
    UsersModule,
    AuthModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

 

6. 엔티티 및 DTO 정의

6.1 사용자 엔티티 (user.entity.ts)

import { 
  Entity, 
  Column, 
  PrimaryGeneratedColumn, 
  CreateDateColumn, 
  UpdateDateColumn,
  BeforeInsert,
  BeforeUpdate
} from 'typeorm';
import { Exclude } from 'class-transformer';
import * as bcrypt from 'bcrypt';

@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ length: 50 })
  name: string;

  @Column({ unique: true, length: 100 })
  email: string;

  @Column({ nullable: true, length: 20 })
  phone: string;

  @Column()
  @Exclude()  // 응답에서 비밀번호 제외
  password: string;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  @BeforeInsert()
  @BeforeUpdate()
  async hashPassword() {
    // 패스워드가 변경되었을 때만 해시 생성
    if (this.password) {
      this.password = await bcrypt.hash(this.password, 10);
    }
  }

  async comparePassword(attempt: string): Promise<boolean> {
    return await bcrypt.compare(attempt, this.password);
  }
}

6.2 DTO (Data Transfer Objects)

create-user.dto.ts

import { ApiProperty } from '@nestjs/swagger';
import { 
  IsEmail, 
  IsNotEmpty, 
  IsOptional, 
  IsString, 
  Length, 
  Matches 
} from 'class-validator';

export class CreateUserDto {
  @ApiProperty({
    description: '사용자 이름',
    example: 'John Doe',
  })
  @IsNotEmpty({ message: '이름은 필수 입력 항목입니다' })
  @IsString()
  @Length(2, 50, { message: '이름은 2~50자 사이여야 합니다' })
  name: string;

  @ApiProperty({
    description: '이메일 주소',
    example: 'john@example.com',
  })
  @IsNotEmpty({ message: '이메일은 필수 입력 항목입니다' })
  @IsEmail({}, { message: '유효한 이메일 형식이 아닙니다' })
  email: string;

  @ApiProperty({
    description: '전화번호',
    example: '1234567890',
    required: false,
  })
  @IsOptional()
  @Matches(/^[0-9]{10,15}$/, { 
    message: '전화번호는 10~15자의 숫자여야 합니다' 
  })
  phone?: string;

  @ApiProperty({
    description: '비밀번호',
    example: 'Password123!',
  })
  @IsNotEmpty({ message: '비밀번호는 필수 입력 항목입니다' })
  @IsString()
  @Length(8, 30, { message: '비밀번호는 8~30자 사이여야 합니다' })
  @Matches(/((?=.*\d)|(?=.*\W+))(?![.\n])(?=.*[A-Z])(?=.*[a-z]).*$/, {
    message: '비밀번호는 대문자, 소문자, 숫자 또는 특수 문자를 포함해야 합니다',
  })
  password: string;
}

update-user.dto.ts

import { ApiProperty } from '@nestjs/swagger';
import { 
  IsEmail, 
  IsOptional, 
  IsString, 
  Length, 
  Matches 
} from 'class-validator';

export class UpdateUserDto {
  @ApiProperty({
    description: '사용자 이름',
    example: 'John Doe',
    required: false,
  })
  @IsOptional()
  @IsString()
  @Length(2, 50, { message: '이름은 2~50자 사이여야 합니다' })
  name?: string;

  @ApiProperty({
    description: '이메일 주소',
    example: 'john@example.com',
    required: false,
  })
  @IsOptional()
  @IsEmail({}, { message: '유효한 이메일 형식이 아닙니다' })
  email?: string;

  @ApiProperty({
    description: '전화번호',
    example: '1234567890',
    required: false,
  })
  @IsOptional()
  @Matches(/^[0-9]{10,15}$/, { 
    message: '전화번호는 10~15자의 숫자여야 합니다' 
  })
  phone?: string;

  @ApiProperty({
    description: '비밀번호',
    example: 'NewPassword123!',
    required: false,
  })
  @IsOptional()
  @IsString()
  @Length(8, 30, { message: '비밀번호는 8~30자 사이여야 합니다' })
  @Matches(/((?=.*\d)|(?=.*\W+))(?![.\n])(?=.*[A-Z])(?=.*[a-z]).*$/, {
    message: '비밀번호는 대문자, 소문자, 숫자 또는 특수 문자를 포함해야 합니다',
  })
  password?: string;
}

user-response.dto.ts

import { ApiProperty } from '@nestjs/swagger';
import { Exclude, Transform } from 'class-transformer';

export class UserResponseDto {
  @ApiProperty({
    description: '사용자 ID',
    example: 1,
  })
  id: number;

  @ApiProperty({
    description: '사용자 이름',
    example: 'John Doe',
  })
  name: string;

  @ApiProperty({
    description: '이메일 주소',
    example: 'john@example.com',
  })
  email: string;

  @ApiProperty({
    description: '전화번호',
    example: '1234567890',
    required: false,
  })
  phone: string;

  @ApiProperty({
    description: '생성 일시',
    example: '2023-04-01T10:30:00Z',
  })
  @Transform(({ value }) => value.toISOString())
  createdAt: Date;

  @ApiProperty({
    description: '수정 일시',
    example: '2023-04-01T10:30:00Z',
    required: false,
  })
  @Transform(({ value }) => value?.toISOString())
  updatedAt: Date;

  @Exclude()
  password: string;

  constructor(partial: Partial<UserResponseDto>) {
    Object.assign(this, partial);
  }
}

 

7. Repository 계층

Nest.JS에서는 TypeORM을 통해 데이터베이스 리포지토리를 사용합니다. 기본 리포지토리는 별도로 정의할 필요 없이 TypeORM의 Repository를 모듈에 주입하여 바로 사용할 수 있습니다.

 

8. 서비스 계층

8.1 사용자 서비스 (users.service.ts)

import { Injectable, ConflictException, NotFoundException } 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';
import { UserResponseDto } from './dto/user-response.dto';

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

  async create(createUserDto: CreateUserDto): Promise<UserResponseDto> {
    // 이메일 중복 확인
    const existingUser = await this.usersRepository.findOne({ 
      where: { email: createUserDto.email } 
    });
    
    if (existingUser) {
      throw new ConflictException('이미 등록된 이메일입니다');
    }

    // 새 사용자 생성
    const user = this.usersRepository.create(createUserDto);
    const savedUser = await this.usersRepository.save(user);
    
    return new UserResponseDto(savedUser);
  }

  async findAll(): Promise<UserResponseDto[]> {
    const users = await this.usersRepository.find();
    return users.map(user => new UserResponseDto(user));
  }

  async findOneById(id: number): Promise<UserResponseDto> {
    const user = await this.usersRepository.findOne({ where: { id } });
    
    if (!user) {
      throw new NotFoundException(`ID가 ${id}인 사용자를 찾을 수 없습니다`);
    }
    
    return new UserResponseDto(user);
  }

  async findOneByEmail(email: string): Promise<User> {
    const user = await this.usersRepository.findOne({ where: { email } });
    
    if (!user) {
      throw new NotFoundException(`이메일이 ${email}인 사용자를 찾을 수 없습니다`);
    }
    
    return user;
  }

  async findByName(name: string): Promise<UserResponseDto[]> {
    const users = await this.usersRepository.createQueryBuilder('user')
      .where('LOWER(user.name) LIKE LOWER(:name)', { name: `%${name}%` })
      .getMany();
      
    return users.map(user => new UserResponseDto(user));
  }

  async update(id: number, updateUserDto: UpdateUserDto): Promise<UserResponseDto> {
    const user = await this.usersRepository.findOne({ where: { id } });
    
    if (!user) {
      throw new NotFoundException(`ID가 ${id}인 사용자를 찾을 수 없습니다`);
    }

    // 이메일이 변경되었고, 그 이메일이 이미 존재하는 경우
    if (updateUserDto.email && updateUserDto.email !== user.email) {
      const existingUser = await this.usersRepository.findOne({ 
        where: { email: updateUserDto.email } 
      });
      
      if (existingUser) {
        throw new ConflictException('이미 등록된 이메일입니다');
      }
    }

    // 사용자 정보 업데이트
    Object.assign(user, updateUserDto);
    const updatedUser = await this.usersRepository.save(user);
    
    return new UserResponseDto(updatedUser);
  }

  async remove(id: number): Promise<void> {
    const result = await this.usersRepository.delete(id);
    
    if (result.affected === 0) {
      throw new NotFoundException(`ID가 ${id}인 사용자를 찾을 수 없습니다`);
    }
  }
}

8.2 인증 서비스 (auth.service.ts)

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UsersService } from '../users/users.service';
import { LoginDto } from './dto/login.dto';

@Injectable()
export class AuthService {
  constructor(
    private usersService: UsersService,
    private jwtService: JwtService,
  ) {}

  async validateUser(email: string, password: string): Promise<any> {
    try {
      const user = await this.usersService.findOneByEmail(email);
      const isPasswordValid = await user.comparePassword(password);
      
      if (isPasswordValid) {
        const { password, ...result } = user;
        return result;
      }
      
      return null;
    } catch (error) {
      return null;
    }
  }

  async login(loginDto: LoginDto) {
    const user = await this.validateUser(loginDto.email, loginDto.password);
    
    if (!user) {
      throw new UnauthorizedException('이메일 또는 비밀번호가 올바르지 않습니다');
    }
    
    const payload = { email: user.email, sub: user.id };
    
    return {
      access_token: this.jwtService.sign(payload),
      user: {
        id: user.id,
        email: user.email,
        name: user.name,
      },
    };
  }
}

 

9. 컨트롤러 계층

9.1 사용자 컨트롤러 (users.controller.ts)

import { 
  Controller, 
  Get, 
  Post, 
  Body, 
  Patch, 
  Param, 
  Delete, 
  Query, 
  UseGuards,
  HttpCode,
  HttpStatus,
  ParseIntPipe
} from '@nestjs/common';
import { 
  ApiBearerAuth, 
  ApiCreatedResponse, 
  ApiNoContentResponse, 
  ApiNotFoundResponse, 
  ApiOkResponse, 
  ApiQuery, 
  ApiTags,
  ApiOperation
} from '@nestjs/swagger';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UserResponseDto } from './dto/user-response.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';

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

  @Post()
  @ApiOperation({ summary: '사용자 생성', description: '새로운 사용자를 생성합니다.' })
  @ApiCreatedResponse({ 
    description: '사용자가 성공적으로 생성되었습니다.', 
    type: UserResponseDto 
  })
  async create(@Body() createUserDto: CreateUserDto): Promise<UserResponseDto> {
    return this.usersService.create(createUserDto);
  }

  @Get()
  @UseGuards(JwtAuthGuard)
  @ApiBearerAuth()
  @ApiOperation({ 
    summary: '사용자 목록 조회', 
    description: '모든 사용자 목록을 조회하거나 이름으로 검색합니다.' 
  })
  @ApiOkResponse({ 
    description: '사용자 목록', 
    type: [UserResponseDto] 
  })
  @ApiQuery({ 
    name: 'name', 
    required: false, 
    description: '검색할 사용자 이름 (선택사항)' 
  })
  async findAll(@Query('name') name?: string): Promise<UserResponseDto[]> {
    if (name) {
      return this.usersService.findByName(name);
    }
    return this.usersService.findAll();
  }

  @Get(':id')
  @UseGuards(JwtAuthGuard)
  @ApiBearerAuth()
  @ApiOperation({ summary: '사용자 조회', description: 'ID로 사용자를 조회합니다.' })
  @ApiOkResponse({ description: '사용자 정보', type: UserResponseDto })
  @ApiNotFoundResponse({ description: '사용자를 찾을 수 없습니다.' })
  async findOne(@Param('id', ParseIntPipe) id: number): Promise<UserResponseDto> {
    return this.usersService.findOneById(id);
  }

  @Patch(':id')
  @UseGuards(JwtAuthGuard)
  @ApiBearerAuth()
  @ApiOperation({ summary: '사용자 수정', description: 'ID로 사용자 정보를 수정합니다.' })
  @ApiOkResponse({ description: '수정된 사용자 정보', type: UserResponseDto })
  @ApiNotFoundResponse({ description: '사용자를 찾을 수 없습니다.' })
  async update(
    @Param('id', ParseIntPipe) id: number, 
    @Body() updateUserDto: UpdateUserDto
  ): Promise<UserResponseDto> {
    return this.usersService.update(id, updateUserDto);
  }

  @Delete(':id')
  @UseGuards(JwtAuthGuard)
  @ApiBearerAuth()
  @HttpCode(HttpStatus.NO_CONTENT)
  @ApiOperation({ summary: '사용자 삭제', description: 'ID로 사용자를 삭제합니다.' })
  @ApiNoContentResponse({ description: '사용자가 성공적으로 삭제되었습니다.' })
  @ApiNotFoundResponse({ description: '사용자를 찾을 수 없습니다.' })
  async remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
    return this.usersService.remove(id);
  }
}

9.2 인증 컨트롤러 (auth.controller.ts)

import { Body, Controller, Post, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';

@ApiTags('auth')
@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @Post('login')
  @HttpCode(HttpStatus.OK)
  @ApiOperation({ summary: '로그인', description: '이메일과 비밀번호로 로그인합니다.' })
  @ApiResponse({ 
    status: 200, 
    description: '로그인 성공', 
    schema: {
      properties: {
        access_token: { type: 'string' },
        user: {
          type: 'object',
          properties: {
            id: { type: 'number' },
            email: { type: 'string' },
            name: { type: 'string' }
          }
        }
      }
    }
  })
  @ApiResponse({ status: 401, description: '인증 실패' })
  async login(@Body() loginDto: LoginDto) {
    return this.authService.login(loginDto);
  }
}

 

10. 모듈 설정

10.1 사용자 모듈 (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 './entities/user.entity';

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

10.2 인증 모듈 (auth.module.ts)

import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UsersModule } from '../users/users.module';
import { JwtStrategy } from './strategies/jwt.strategy';

@Module({
  imports: [
    UsersModule,
    PassportModule,
    JwtModule.registerAsync({
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => ({
        secret: configService.get<string>('JWT_SECRET'),
        signOptions: { 
          expiresIn: configService.get<string>('JWT_EXPIRATION') 
        },
      }),
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService, JwtStrategy],
  exports: [AuthService],
})
export class AuthModule {}

 

11. 인증 및 보안 구현

11.1 JWT 전략 (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(
    private configService: ConfigService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: configService.get<string>('JWT_SECRET'),
    });
  }

  async validate(payload: any) {
    return { userId: payload.sub, email: payload.email };
  }
}

11.2 JWT 가드 (jwt-auth.guard.ts)

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

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

 

12. 예외 처리

12.1 HTTP 예외 필터 (http-exception.filter.ts)

import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
  Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  private readonly logger = new Logger(HttpExceptionFilter.name);

  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const status = exception.getStatus();
    const errorResponse = exception.getResponse();

    const error = {
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
      method: request.method,
      ...(typeof errorResponse === 'object' ? errorResponse : { message: errorResponse }),
    };

    this.logger.error(
      `${request.method} ${request.url} ${status}`,
      exception.stack,
    );

    response.status(status).json(error);
  }
}

 

13. 단위 테스트

13.1 사용자 서비스 테스트 (users.service.spec.ts)

import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConflictException, NotFoundException } from '@nestjs/common';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';

const mockRepository = () => ({
  create: jest.fn(),
  save: jest.fn(),
  find: jest.fn(),
  findOne: jest.fn(),
  createQueryBuilder: jest.fn(() => ({
    where: jest.fn().mockReturnThis(),
    getMany: jest.fn(),
  })),
  delete: jest.fn(),
});

type MockRepository<T = any> = Partial<Record<keyof Repository<T>, jest.Mock>>;

describe('UsersService', () => {
  let service: UsersService;
  let repository: MockRepository<User>;

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

    service = module.get<UsersService>(UsersService);
    repository = module.get<MockRepository<User>>(getRepositoryToken(User));
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  describe('create', () => {
    it('should successfully create a user', async () => {
      const createUserDto: CreateUserDto = {
        name: 'John Doe',
        email: 'john@example.com',
        phone: '1234567890',
        password: 'Password123!',
      };

      const user = {
        id: 1,
        ...createUserDto,
        createdAt: new Date(),
        updatedAt: new Date(),
      };

      repository.findOne.mockResolvedValue(null);
      repository.create.mockReturnValue(user);
      repository.save.mockResolvedValue(user);

      const result = await service.create(createUserDto);

      expect(repository.findOne).toHaveBeenCalledWith({ 
        where: { email: createUserDto.email } 
      });
      expect(repository.create).toHaveBeenCalledWith(createUserDto);
      expect(repository.save).toHaveBeenCalledWith(user);
      expect(result.id).toEqual(user.id);
      expect(result.email).toEqual(user.email);
    });

    it('should throw ConflictException when email already exists', async () => {
      const createUserDto: CreateUserDto = {
        name: 'John Doe',
        email: 'john@example.com',
        phone: '1234567890',
        password: 'Password123!',
      };

      repository.findOne.mockResolvedValue({ id: 1, email: createUserDto.email });

      await expect(service.create(createUserDto)).rejects.toThrow(ConflictException);
      expect(repository.findOne).toHaveBeenCalledWith({ 
        where: { email: createUserDto.email } 
      });
      expect(repository.create).not.toHaveBeenCalled();
      expect(repository.save).not.toHaveBeenCalled();
    });
  });

  describe('findAll', () => {
    it('should return an array of users', async () => {
      const users = [
        {
          id: 1,
          name: 'John Doe',
          email: 'john@example.com',
          phone: '1234567890',
          password: 'hashedPassword',
          createdAt: new Date(),
          updatedAt: new Date(),
        },
        {
          id: 2,
          name: 'Jane Doe',
          email: 'jane@example.com',
          phone: '9876543210',
          password: 'hashedPassword',
          createdAt: new Date(),
          updatedAt: new Date(),
        },
      ];

      repository.find.mockResolvedValue(users);

      const result = await service.findAll();

      expect(repository.find).toHaveBeenCalled();
      expect(result.length).toEqual(2);
      expect(result[0].id).toEqual(users[0].id);
      expect(result[1].email).toEqual(users[1].email);
    });
  });

  describe('findOneById', () => {
    it('should return a user if found', async () => {
      const user = {
        id: 1,
        name: 'John Doe',
        email: 'john@example.com',
        phone: '1234567890',
        password: 'hashedPassword',
        createdAt: new Date(),
        updatedAt: new Date(),
      };

      repository.findOne.mockResolvedValue(user);

      const result = await service.findOneById(1);

      expect(repository.findOne).toHaveBeenCalledWith({ where: { id: 1 } });
      expect(result.id).toEqual(user.id);
      expect(result.email).toEqual(user.email);
    });

    it('should throw NotFoundException if user not found', async () => {
      repository.findOne.mockResolvedValue(null);

      await expect(service.findOneById(999)).rejects.toThrow(NotFoundException);
      expect(repository.findOne).toHaveBeenCalledWith({ where: { id: 999 } });
    });
  });

  // 추가 테스트 케이스...
});

13.2 사용자 컨트롤러 테스트 (users.controller.spec.ts)

import { Test, TestingModule } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UserResponseDto } from './dto/user-response.dto';
import { NotFoundException } from '@nestjs/common';

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

  beforeEach(async () => {
    const mockUsersService = {
      create: jest.fn(),
      findAll: jest.fn(),
      findByName: jest.fn(),
      findOneById: jest.fn(),
      update: jest.fn(),
      remove: jest.fn(),
    };

    const module: TestingModule = await Test.createTestingModule({
      controllers: [UsersController],
      providers: [
        {
          provide: UsersService,
          useValue: mockUsersService,
        },
      ],
    }).compile();

    controller = module.get<UsersController>(UsersController);
    service = module.get<UsersService>(UsersService);
  });

  it('should be defined', () => {
    expect(controller).toBeDefined();
  });

  describe('create', () => {
    it('should create a new user', async () => {
      const createUserDto: CreateUserDto = {
        name: 'John Doe',
        email: 'john@example.com',
        phone: '1234567890',
        password: 'Password123!',
      };

      const userResponseDto: UserResponseDto = {
        id: 1,
        name: 'John Doe',
        email: 'john@example.com',
        phone: '1234567890',
        createdAt: new Date(),
        updatedAt: null,
        password: 'hashedPassword',
      };

      jest.spyOn(service, 'create').mockResolvedValue(userResponseDto);

      const result = await controller.create(createUserDto);

      expect(service.create).toHaveBeenCalledWith(createUserDto);
      expect(result).toBe(userResponseDto);
    });
  });

  describe('findAll', () => {
    it('should return all users when no name is provided', async () => {
      const users: UserResponseDto[] = [
        {
          id: 1,
          name: 'John Doe',
          email: 'john@example.com',
          phone: '1234567890',
          createdAt: new Date(),
          updatedAt: null,
          password: 'hashedPassword',
        },
        {
          id: 2,
          name: 'Jane Doe',
          email: 'jane@example.com',
          phone: '9876543210',
          createdAt: new Date(),
          updatedAt: null,
          password: 'hashedPassword',
        },
      ];

      jest.spyOn(service, 'findAll').mockResolvedValue(users);

      const result = await controller.findAll();

      expect(service.findAll).toHaveBeenCalled();
      expect(service.findByName).not.toHaveBeenCalled();
      expect(result).toBe(users);
    });

    it('should search users by name when name is provided', async () => {
      const name = 'John';
      const users: UserResponseDto[] = [
        {
          id: 1,
          name: 'John Doe',
          email: 'john@example.com',
          phone: '1234567890',
          createdAt: new Date(),
          updatedAt: null,
          password: 'hashedPassword',
        },
      ];

      jest.spyOn(service, 'findByName').mockResolvedValue(users);

      const result = await controller.findAll(name);

      expect(service.findAll).not.toHaveBeenCalled();
      expect(service.findByName).toHaveBeenCalledWith(name);
      expect(result).toBe(users);
    });
  });

  // 추가 테스트 케이스...
});

 

14. E2E 테스트

14.1 사용자 E2E 테스트 (users.e2e-spec.ts)

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { getRepositoryToken } from '@nestjs/typeorm';
import { AppModule } from '../src/app.module';
import { User } from '../src/users/entities/user.entity';
import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter';
import { Repository } from 'typeorm';

describe('UsersController (e2e)', () => {
  let app: INestApplication;
  let userRepository: Repository<User>;
  let jwtToken: string;

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

    app = moduleFixture.createNestApplication();
    app.useGlobalPipes(
      new ValidationPipe({
        whitelist: true,
        forbidNonWhitelisted: true,
        transform: true,
      }),
    );
    app.useGlobalFilters(new HttpExceptionFilter());
    
    userRepository = moduleFixture.get<Repository<User>>(getRepositoryToken(User));
    
    await app.init();

    // 테스트를 위한 초기 데이터 생성
    await userRepository.clear(); // 기존 데이터 삭제
  });

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

  describe('Authentication', () => {
    it('should register a new user', () => {
      return request(app.getHttpServer())
        .post('/users')
        .send({
          name: 'Test User',
          email: 'test@example.com',
          phone: '1234567890',
          password: 'Password123!',
        })
        .expect(201)
        .expect((res) => {
          expect(res.body.id).toBeDefined();
          expect(res.body.name).toBe('Test User');
          expect(res.body.email).toBe('test@example.com');
          expect(res.body.password).toBeUndefined(); // 비밀번호는 응답에 포함되지 않아야 함
        });
    });

    it('should login with the registered user', () => {
      return request(app.getHttpServer())
        .post('/auth/login')
        .send({
          email: 'test@example.com',
          password: 'Password123!',
        })
        .expect(200)
        .expect((res) => {
          expect(res.body.access_token).toBeDefined();
          jwtToken = res.body.access_token;
        });
    });
  });

  describe('Users CRUD operations', () => {
    let userId: number;

    it('should get all users (authenticated)', () => {
      return request(app.getHttpServer())
        .get('/users')
        .set('Authorization', `Bearer ${jwtToken}`)
        .expect(200)
        .expect((res) => {
          expect(Array.isArray(res.body)).toBe(true);
          expect(res.body.length).toBeGreaterThan(0);
          userId = res.body[0].id;
        });
    });

    it('should get a specific user by ID (authenticated)', () => {
      return request(app.getHttpServer())
        .get(`/users/${userId}`)
        .set('Authorization', `Bearer ${jwtToken}`)
        .expect(200)
        .expect((res) => {
          expect(res.body.id).toBe(userId);
          expect(res.body.name).toBe('Test User');
          expect(res.body.email).toBe('test@example.com');
        });
    });

    it('should update a user (authenticated)', () => {
      return request(app.getHttpServer())
        .patch(`/users/${userId}`)
        .set('Authorization', `Bearer ${jwtToken}`)
        .send({
          name: 'Updated Name',
        })
        .expect(200)
        .expect((res) => {
          expect(res.body.id).toBe(userId);
          expect(res.body.name).toBe('Updated Name');
          expect(res.body.email).toBe('test@example.com');
        });
    });

    it('should delete a user (authenticated)', () => {
      return request(app.getHttpServer())
        .delete(`/users/${userId}`)
        .set('Authorization', `Bearer ${jwtToken}`)
        .expect(204);
    });

    it('should return 404 when getting deleted user (authenticated)', () => {
      return request(app.getHttpServer())
        .get(`/users/${userId}`)
        .set('Authorization', `Bearer ${jwtToken}`)
        .expect(404);
    });
  });
});

 

15. 배포 설정

15.1 Docker 설정 (Dockerfile)

# 빌드 스테이지
FROM node:16-alpine AS build

WORKDIR /app

COPY package*.json ./

RUN npm ci

COPY . .

RUN npm run build

# 프로덕션 스테이지
FROM node:16-alpine

WORKDIR /app

COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY package*.json ./

ENV NODE_ENV=production

EXPOSE 3000

CMD ["node", "dist/main"]

15.2 docker-compose.yml

version: '3.8'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    env_file:
      - .env.production
    volumes:
      - ./data:/app/data
    restart: always

15.3 프로덕션 환경 설정 (.env.production)

# 애플리케이션
PORT=3000
NODE_ENV=production

# 데이터베이스
DB_TYPE=sqlite
DB_NAME=/app/data/rest_api_demo.db

# JWT
JWT_SECRET=your_production_jwt_secret_key
JWT_EXPIRATION=1h

 

16. 결론

Nest.JS는 TypeScript를 기반으로 하는 모던한 Node.js 프레임워크로, 강력한 타입 시스템과 객체 지향 프로그래밍 패턴을 통해 안정적이고 확장 가능한 REST API를 구축할 수 있습니다.

이 문서에서는 다음과 같은 Nest.JS의 핵심 기능을 살펴보았습니다:

  1. 모듈화된 아키텍처: 애플리케이션을 기능별로 모듈화하여 관리할 수 있습니다.
  2. 의존성 주입: 결합도가 낮고 테스트하기 쉬운 코드를 작성할 수 있습니다.
  3. 데코레이터 패턴: 클래스와 메서드에 메타데이터를 선언적으로 추가할 수 있습니다.
  4. TypeORM 통합: 데이터베이스 작업을 객체 지향적인 방식으로 처리할 수 있습니다.
  5. 유효성 검증: DTO와 파이프를 사용하여 입력 데이터의 유효성을 검증할 수 있습니다.
  6. 예외 처리: 글로벌 필터를 통해 일관된 방식으로 예외를 처리할 수 있습니다.
  7. 인증 및 인가: JWT를 사용한 사용자 인증 및 권한 관리를 구현할 수 있습니다.
  8. 테스트: 단위 테스트 및 E2E 테스트를 통해 애플리케이션의 품질을 보장할 수 있습니다.
  9. 문서화: Swagger를 사용하여 API 문서를 자동으로 생성할 수 있습니다.

Nest.JS는 Express의 빠른 개발 속도와 Angular의 체계적인 구조를 결합한 프레임워크로, 확장 가능하고 유지보수가 용이한 엔터프라이즈급 애플리케이션을 개발하는 데 적합합니다.

 

17. 후속 시리즈 안내

본 문서는 REST API 구현 시리즈의 세 번째 편입니다. 시리즈의 주제는 다음과 같습니다:

시리즈 제목 주요 내용

1편 REST API의 기본 개념과 설계 원칙 REST 아키텍처, HTTP 메서드, 상태 코드, 설계 모범 사례 등
2편 Spring Boot에서의 REST API 작성 예시 Java 기반 Spring Boot 프레임워크를 이용한 REST API 구현
3편 Nest.JS에서의 REST API 작성 예시 TypeScript 기반 Nest.JS 프레임워크를 이용한 REST API 구현
4편 그 외 플랫폼에서의 REST API 작성 예시 Django, Flask, Express.js 등 다양한 프레임워크에서의 구현

각 편에서는 해당 프레임워크의 특성을 살린 REST API 설계 및 구현 방법, 보안 처리, 테스트 방법 등을 실제 코드 예제와 함께 제공합니다.

 

18. 참고 자료

  • Nest.JS 공식 문서
  • TypeORM 공식 문서
  • TypeScript 공식 문서
  • JWT 인증에 대한 이해
  • Swagger 공식 문서
  • 클래스 유효성 검증
  • Node.js 모범 사례

 

19. 마무리

Nest.JS는 Node.js 생태계에서 엔터프라이즈급 애플리케이션 개발을 위한 효율적인 솔루션을 제공합니다. TypeScript의 강력한 타입 시스템과 객체 지향 프로그래밍 패턴을 결합하여, 코드의 품질과 유지보수성을 크게 향상시킬 수 있습니다.

Nest.JS의 주요 장점은 다음과 같습니다:

  1. 개발자 경험 향상 - 명확한 프로젝트 구조와 타입 안전성으로 개발 효율성을 높입니다.
  2. 확장성 - 모듈 시스템을 통해 애플리케이션을 체계적으로 확장할 수 있습니다.
  3. 생산성 - 데코레이터, 파이프, 가드, 인터셉터 등의 기능으로 반복 작업을 줄입니다.
  4. 유연성 - 다양한 HTTP 프레임워크(Express, Fastify)와 통합 가능합니다.
  5. 테스트 용이성 - 의존성 주입 패턴으로 단위 테스트가 간편합니다.
  6. 진보적 개발 - 마이크로서비스, GraphQL, WebSocket 등 최신 기술 지원이 우수합니다.

이 문서에서 제공한 예제 코드와 설명을 통해 Nest.JS로 견고하고 확장 가능한 REST API를 개발하는 데 필요한 기본 지식을 얻으셨기를 바랍니다. 1편에서 다룬 REST 설계 원칙과 모범 사례를 함께 적용하면, 더욱 효과적인 API를 구축할 수 있을 것입니다.

다음 편에서는 Django, Flask, Express.js 등 다양한 프레임워크에서의 REST API 구현 방법을 살펴보겠습니다. 이를 통해 각 플랫폼의 특징과 장단점을 비교하고, 프로젝트 요구사항에 맞는 최적의 기술 스택을 선택하는 데 도움이 될 것입니다.

 

감사합니다.

반응형
저작자표시 비영리 변경금지 (새창열림)

'Study' 카테고리의 다른 글

그 외 플랫폼에서의 REST API 작성 예시 (4/4)  (0) 2025.04.26
Spring Boot에서의 REST API 작성 예시 (2/4)  (0) 2025.04.26
REST API 란 무엇인가? (1/4)  (0) 2025.04.26
Spring JPA에서 PostgreSQL JSONB 타입 활용하기  (1) 2025.04.21
'Study' 카테고리의 다른 글
  • 그 외 플랫폼에서의 REST API 작성 예시 (4/4)
  • Spring Boot에서의 REST API 작성 예시 (2/4)
  • REST API 란 무엇인가? (1/4)
  • Spring JPA에서 PostgreSQL JSONB 타입 활용하기
모리군
모리군
    반응형
  • 모리군
    나의 일상 그리고 취미
    모리군
  • 전체
    오늘
    어제
    • 분류 전체보기 (23)
      • 독백 (0)
      • Study (11)
        • Authentication (4)
        • Supabase (2)
      • Javascript (3)
        • node.js (0)
        • react.js (0)
        • vue.js (0)
        • LeetCode (3)
      • AI (0)
      • PHP (0)
      • HTML, CSS (0)
      • 툴, 플러그인 (0)
      • 취미 (1)
        • 보드게임 (1)
      • 교통 (0)
        • 철도 (0)
        • 도로 (0)
      • 부동산 (2)
        • 서울 (0)
        • 경기 성남 (0)
        • 경기 수원 (0)
        • 경기 화성 (2)
        • 경기 남양주 (0)
        • 광주 (0)
      • 역사 (4)
        • 이 주의 역사 (1)
      • 영어기사 읽기 (1주일 1번) (0)
        • 스포츠 (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    프롬프트엔지니어링
    FastAPI
    supabase
    백엔드개발
    typescript
    ChatGPT
    대리운전
    LeetCode
    윌리엄 딕슨
    express
    PostgreSQL
    javascript
    node.js
    벡터데이터베이스
    REST API
    웹 서비스
    JWT
    Spring Boot
    java
    초기 영화
    백엔드
    임베딩
    광주민주화운동
    슈파베이스
    카카오T 대리
    algorithm
    토큰관리
    Rag
    OpenAI
    한국발명
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
모리군
Nest.JS에서의 REST API 작성 예시 (3/4)
상단으로

티스토리툴바