nest.js와 typeORM을 사용하여 데이터베이스에 대한 CRUD 기능이 구현되가끼자의 절차(과정)에 대해 정리해보자.
이번 글은 nest.js 초기 세팅에 관한 설명이다. typeORM 연결 방법은 다음 글에서 다룰 예정이다.
nest.js는 Express를 기반으로 만들어진 웹 프레임워크다. Java의 Spring와 비슷한 아키텍쳐 구조를 제공하며, 라우팅, 보안과 관련하여 다양한 기능을 제공하기 때문에 웹 개발의 생산성을 높은 특징을 가진다.
typeORM은 node.js 환경에서 사용하는 ORM이다.
nest.js cli 설치: npm i -g @nestjs/cli
프로젝트 생성: (원하는 경로로 이동 후) nest new 프로젝트 폴더명(or 프로젝트로 폴더로 사용할 기존 폴더명)
패키지 매니저 설정: npm
선택
tsconfig.json 편집: esModuleInterop": true
옵션 추가
src/main.ts 편집(포트 번호 설정 가능하도록 수정)
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const port = process.env.PORT || 8000;
await app.listen(port);
console.log(`listening on port ${port}`);
}
bootstrap();
사용 중인 에디터에서 eslint, prettier 사용이 가능하도록 설정
vscode 사용할 경우 cmd + shift + p
로 Preferences: Open Settings를 열어 다음 설정 추가
# setting.json
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"eslint.alwaysShowStatus": true,
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
}
.prettierrc 설정
# .prettierrc
{
"printWidth": 150,
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": true,
"semi": true,
"arrowParens": "always",
"endOfLine": "lf",
"proseWrap": "preserve"
}
hot reload 설정
코드 변경이 있을 때마다 전체 코드를 컴파일할 필요 없이 빠르게 애플리케이션을 재실행시켜주는 해주는 기능. express의 nodemon과 같은 역할이다.
공식 문서를 참고하면 쉽게 설정할 수 있다. 참고 링크
세팅 완료 후 package.json에 다음과 같이 script를 작성하자.
# package.json
"scripts": {
"start:dev-backup": "nest start --watch",
"start:dev": "nest build --webpack --webpackPath webpack-hmr.config.js --watch",
...
},
서버 실행: npm run start
일반적으로 node.js 환경에선 애플리케이션 환경(production, development) 별 상이한 변수 값을 .env
파일을 활용하여 설정한다. nest.js에서도 이러한 설정이 가능한데, 차이점은 ConfigMoudel을 활용한다는 점이다. 아래의 순서를 따르면 nest.js에서도 .env
파일 활용이 가능해진다.
관련 패키지 다운로드: npm i --save @nestjs/config
app.module.ts
에서 사용할 모듈 가져오기: import { ConfigModule } from '@nestjs/config';
AppModul
에서 사용할 모듈 가져오기: ConfigModule.forRoot({ isGlobal: true })
를 imports: []
배열 안에 입력
{ isGLobal: true }
: ConfigMoudel을 AppModul
이외의 모듈에서 반복 import할 필요 없는 전역 모듈로 설정해준다.
.env
, .env.development
, .env.production
파일 생성
.gitingore
에 아래 내용 추가
# environment variables file
.env*
예시 코드
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@Module({
imports: [ConfigModule.forRoot({ isGlobal: true })],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
ConfigService 설정
.env
파일에 등록된 환경변수를 사용하려면 process.env
명령어를 활용하는 게 일반적이었다. 하지만 nest.js에선 ConfigService를 활용하여 환경변수 값을 불러오는 걸 권장하고 있다. 구체적인 사용법은 공식문서를 참고하면 된다. 설정 방법은 아래와 같다.
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@Module({
imports: [ConfigModule.forRoot({ isGlobal: true })],
controllers: [AppController],
providers: [AppService, ConfigService],
})
export class AppModule {}
일반적으로 morgan, winston 처럼 로그 관리 패키지를 사용하지만, 사용자가 직접 미들웨어를 생성해서 사용할 수도 있다. 방법은 아래를 참고하면 된다.
폴더 생성: mkdir src/middlewares
파일 생성: touch src/middlewares/logger.middleware.ts
// src/middlewares/logger.middleware.ts
import { Injectable, Logger, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
private logger = new Logger('HTTP');
use(request: Request, response: Response, next: NextFunction): void {
const { ip, method, originalUrl } = request;
const userAgent = request.get('user-agent') || '';
response.on('finish', () => {
const { statusCode } = response;
const contentLegnth = response.get('content-length');
this.logger.log(`${method} ${originalUrl} ${statusCode} ${contentLegnth} - ${userAgent} ${ip}`);
});
next();
}
미들웨어 등록
// src/app.module.ts
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { LoggerMiddleware } from './middlewares/logger.middleware';
@Module({
imports: [ConfigModule.forRoot({ isGlobal: true })],
controllers: [AppController],
providers: [AppService, ConfigService],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): any {
consumer.apply(LoggerMiddleware).forRoutes('*');
}
}
module 생성: nest g mo users(모듈 이름)
-> 생성 모듈은 app.module.ts
에 import해줘야 한다.
service 생성: nest g s users(서비스 이름)
-> 생성 서비스는 생성한 모듈(users.module.ts
)에 import 해줘야 한다.
controller 생성: nest g co users(컨트롤러 이름)
-> 생성 컨트롤러는 생성한 모듈(users.module.ts
)에 import 해줘야 한다.
라우터는 http 메소드에 맞는 데코레이터를 가져와서(import) 생성한다.
라우터 함수명은 너무 고민하지 말고 식별 가능할 정도로 짓는 것을 권장한다.
컨트롤러에서 최대한 req,res 사용은 어쩔 수 없는 경우(아래 예시의 logout 기능처럼)를 제외하곤 지양하는 것이 좋다.
service를 사용하려면 constructor(private usersService: UsersService) {}
처럼 의존성 주입을 해야 한다.
controller 예시
import { Body, Controller, Get, Post, Req, Res } from '@nestjs/common';
import { JoinRequestDto } from './dto/join.request.dto';
import { UsersService } from './users.service';
@Controller('api/users')
export class UsersController {
constructor(private usersService: UsersService) {}
@Get()
getAllUsers(@Req() req) {
return req.user;
}
@Post()
createtUser(@Body() data: JoinRequestDto) {
this.usersService.postUsers(data.email, data.nickname, data.password);
}
@Post('login')
login(@Req() req) {
return req.user;
}
@Post('logout')
logout(@Req() req, @Res() res) {
req.logOut();
// 로그아웃 풀기
res.clearCookie('connect.sid', { httpOnly: true });
res.send('ok');
}
@Get('friends/:id')
getFriendById(@Param() param) {
console.log(param.id);
}
@Get('friends')
getAllFriends(@query() query) {
console.log(query.perPage, query.page);
}
}
주로 생성 모듈(users) 하위 경로에 DTO 폴더를 생성하여 관리한다.
파일 이름 예시: join.request.dto.ts
nest.js에선 DTO 생성 시 interface 보다 class 사용을 선호한다. class의 경우 컴파일 이후에도 사라지지 않아 type 검증에 활용할 수 있기 때문이다.
DTO 예시
export class JoinRequestDto {
public email: string;
public nickname: string;
public password: string;
}
패키지 설치: npm install --save @nestjs/swagger
main.ts
설정
// main.ts
import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';
declare const module: any;
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// sagger 문서 설정(문서 title, 문서 description, 문서 버전, 문서 태그 등을 설정)
const config = new DocumentBuilder()
.setTitle('Sleact API')
.setDescription('Sleact 개발을 위한 API 문서입니다.')
.setVersion('1.0')
.addTag('connect.sid')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('swagger', app, document);
const port = process.env.PORT || 8000;
await app.listen(port);
console.log(`listening on port ${port}`);
if (module.hot) {
module.hot.accept();
module.hot.dispose(() => app.close());
}
}
bootstrap();
api 문서화
nest.js에선 데코레이터 추가만으로 swagger 문서를 쉽게 작성할 수 있다. 자주 사용하는 데코레이터를 정리하면 아래와 같다.
@ApiTag()
: controller 설명을 문서화한다. controller class에 추가하여 사용한다.
@ApiTags('USER')
@ApiOperation()
: api 설명을 문서화한다. controller 함수에 추가하여 사용한다.
@ApiOperation({ summary: '회원가입' })
@ApiProperty()
: body 파라미터 변수에 관한 설명을 문서화한다. DTO class 내부 변수에 추가하여 사용한다.
// join.request.dto.ts
export class JoinRequestDto {
@ApiProperty({
example: 'fcfargo90@gmail.com',
description: '이메일',
required: true,
})
public email: string;
}
@ApiQuery()
: query 파라미터 변수에 관한 설명을 문서화한다. query 파라미터를 사용하는 controller 함수에 추가한다.
@ApiQuery({ name: 'page', required: true, description: '불러올 페이지 번호' })
@ApiParam()
: path 파라미터 변수에 관한 설명을 문서화한다. DTO class 내부 변수에 추가하여 사용한다. path 파라미터를 사용하는 controller 함수에 추가한다.
@ApiParam({ name: 'id', required: true, description: '사용자 id' })
@ApiResponse()
: api 응답 방식(형태)에 관한 설명을 문서화한다. controller 함수에 추가하여 사용한다.
@ApiResponse({ status: 200, description: '요청 성공', type: UserDto }
@ApiResponse({ status: 500, description: '서버 에러' })
DTO 확장: 기존에 사용하던 DTO를 활용하여 확장된 새로운 DTO 생성하는 것. extends
를 활용하면 쉽게 구현할 수 있다.
// user.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { JoinRequestDto } from './join.request.dto';
export class UserDto extends JoinRequestDto {
@ApiProperty({
example: 1,
description: '유저 id',
required: true,
})
public id: number;
}
프레임워크에선 api 요청 및 응답 처리를 위해 request, response 객체를 제공한다. express에서 제공하는 req
, res
객체가 대표적인 예로, 해당 객체에 접근하면 클라이언트 요청의 상세 정보를 확인하하거나, 클라이언트에게 보낼 응답 방식 설정이 가능했다.
nest.js 역시 @Req
, @Res
데코레이터로 request, response 객체를 제공한다. 중요한 점은 nest.js에서 제공하는 해당 객체들이 express에서 제공하는 객체들과 똑같다는 사실이다.(nest.js 내부적으로 express 프레임워크를 사용하고 있기 때문이다.) 문제는 nest.js가 express 대신 다른 프레임워크(가령, fastify)로 변경할 때 발생한다. 이러한 경우 기존에 사용하던 @Req
, @Res
데코레이터는 사용 불가능해진다.
때문에 @Req
, @Res
데코레이터로 제공되는 request, response 객체를, 추후 유지 보수가 편리해지도록 관리하는 게 좋다. 방법은으론 직접 관련 데코레이터를 생성(커스텀 데코레이터)하는 것이다. 방법은 아래와 같다.
.src/common/
경로에 폴더 생성: mkdir decorators
파일 생성: touch user.decorator.ts
user.decorator.ts
설정
// ./src/common/decorators/user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const User = createParamDecorator((data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
// 데코레이터에선 request 객체의 변수 user 값을 반환한다.
return request.user;
});
nest.js 인터셉터(interceptor)는 Java Spring 프레임워크의 인터셉터와 의미가 동일하다. 뜻을 요약하면 아래와 같다.
컨트롤러(Controller)의 '핸들러(Handler)'를 호출하기 전과 후에 요청과 응답을 참조하거나 가공할수 있는 일종의 필터
관점 지향 프로그래밍 AOP(Aspect Oriented Programming) 철학을 구체화시킨 기능들의 집할을 인터셉터라라고 부른다고 한다. 자세한 개념은 추가 검색으로 알아보길 바란다.
nest.js에서도 인터셉터를 활용하면 요청(request) 이전, 응답(response) 이후에 로직을 추가할 수 있다. 이는 여러 api에서 사용되는 공통 로직의 중복을 없애고, 유지 보수도 편리하게 해준다.
기본 세팅에서 생성할 인터셉터는 응답(reponse) 시 undefined
데이터를 null
로 변경해주는 모듈이다. 아래 내용을 참고하자.
.src/common/
경로에 폴더 생성: mkdir interceptor
파일 생성: touch undefinedToNull.interceptor.ts
touch undefinedToNull.interceptor.ts
설정
// ./src/common/interceptors/undefinedToNull.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class undefinedToNullInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
// controller 이전 로직은 여기에 추가하면 된다.
// return은 controller에서 반환하는 데이터와 동일하다.
return next.handle().pipe(map((data) => (data === undefined ? null : data)));
}
}
Controller 적용: @UseInterceptors(undefinedToNullInterceptor)
를 전체 controller 혹은 특정 controller 함수에 추가
implements
class를 정의할 때 interface의 형태(shape)와 일치시키기 위해 사용한다. 만약 interface에서 정의된 변수나 메서드가 새롭게 정의하려는 class에서 사용되지 않거나, 불일치(변수명, type의 불일치)한다면 에러를 발생시킨다. 한마디로 class의 interface 준수를 강제하는 역할이다.
interface란 함수, 클래스에서 사용되는 변수 타입의 일관성을 유지시키기 위해 정의된 규칙이다.
의존성 주입(dependency injection)
의존성 주입이란 구현된 기능들을 클래스로 분리하여, 필요할 때마다 주입하여 사용할 수 있도록 하는 것이다. nest.js에서 지원하고 있으며, 특정 기능을 의존성 주입의 대상으로 만드려면 데코레이터 @Injectable()
를 추가하면 된다. 의존성 주입은 아래 코드처럼 providers
배열 안에 기능을 추가함으로써 이뤄진다.
@Module({
imports: [ConfigModule.forRoot({ isGlobal: true })],
controllers: [AppController],
providers: [AppService, ConfigService],
})
의존성 주입은 코드의 재사용성을 높이고 객체 간의 결합도를 낮춰주는 장점이 있다.