이번 글에선 typeORM 연결 및 migration, class-validator 설정 방법을 다룬다.
typeorm-model-generator 설치
npm i typeorm-model-generator -D
ORM을 사용하려면 모델(model)이 필요하다. model은 일반적으로 객체와 데이터베이스를 매핑해주는 class를 의미한다. typeORM에선 entity라는 개념으로 존재한다. entity를 직접 생성하는 방법도 있지만, typeorm-model-generator
패키지를 활용하여 기존 데이터베이스를 토대로 entity를 자동 생성할 수도 있다. 비슷한 역할을 하는 패키지로, sequelize의 모델(model)을 자동으로 만들어주는 sequelize-auto
패키지가 있다.
entity 자동 생성
npx typeorm-model-generator -h localhost -d {databaseName} -u {username} -x {password} -e {engine}
npx typeorm-model-generator -h 127.0.0.1 -d clone_slack -i root -x 1q2w3e4r! -e mysql
entity 자동 생성 명령어를 입력하면, root 경로에 output
폴더가 생성된다. 해당 폴더 내부에 entities
폴더가 있는데, 폴더 자체를 그대로 복사하여 ./src
하위 경로에 붙여 넣는다.(entity 파일 저장 경로는 사용자기 원하는대로 설정해도 상관 없다. 필자의 경우 ./src/entities
하위 경로에서 관리하고 있다. )
주의 사항
자동 생성한 entity가 실제 DB와 오류 없이 매핑되려면 코드 수정이 필요하다.
createdAt 컬럼이 있다면, 자동 생성된 @Column()
대신 @CreateDateColumn()
을 적용한다.
// ./src/entities/Users.ts
@CreateDateColumn()
createdAt: Date;
updatedAt 컬럼이 있다면, 자동 생성된 @Column()
대신 @UpdateDateColumn()
을 적용한다.
// ./src/entities/Users.ts
@UpdateDateColumn()
createdAt: Date;
중복된 index 데코레이터 삭제
아래의 경우 처럼 동일한 index 데코레이터가 중복 생성될 수 있다. 이 중 한 가지를 삭제하여 중복을 제거한다.
// ./src/entities/Workspaces.ts
...
//@Index('IDX_22a04f0c0bf6ffd5961a28f5b7', ['url'], { unique: true }) <- 제거
//@Index('IDX_de659ece27e93d8fe29339d0a4', ['name'], { unique: true }) <- 제거
@Index('name', ['name'], { unique: true })
@Index('OwnerId', ['ownerId'], {})
@Index('url', ['url'], { unique: true })
@Entity('workspaces', { schema: 'clone_slack' })
export class Workspaces {
@PrimaryGeneratedColumn({ type: 'int', name: 'id' })
id: number;
...
}
swagger 데코레이터 적용
DTO를 쉽게 문서화하려면, 아래 예시처럼 entity 컬럼에 swagger 데코레이터 @ApiProperty()
적용을 적극 권장한다. nest.js에선 @Entity
자체가 DTO로 인식되기 때문에, 추후 DTO 생성 시 코드 중복을 줄일 수 있다.
// ./src/entities/Users.ts
...
@Entity('users', { schema: 'clone_slack' })
export class Users {
@ApiProperty({
example: 1,
description: '사용자 id',
})
@PrimaryGeneratedColumn({ type: 'int', name: 'id' })
id: number;
@ApiProperty({
example: 'fcfargo90@gmail.com',
description: '사용자 이메일',
})
@Column('varchar', { name: 'email', unique: true, length: 30 })
email: string;
...
}
DTO 수정
nest.js에선 @Entity
를 DTO로 인식한다고 얘기했다. 만약 위처럼 entity(Users) 객체에 swagger 데코레이터 적용을 완료했다면, 기존 Users관련 DTO를 아래와 같이 수정할 수 있다. 변수의 이름, type, swagger 데코레이터 등을 지정할 필요가 없어 코드 중복이 감소했다.
// ./src/users/dto/join.request.dto.ts
import { PickType } from '@nestjs/swagger';
import { Users } from '../../entities/Users';
export class JoinRequestDto extends PickType(Users, ['email', 'nickname', 'password'] as const) {}
// ./src/users/dto/user.response.dto.ts
import { PickType } from '@nestjs/swagger';
import { Users } from '../../entities/Users';
export class UserResponseDto extends PickType(Users, ['id', 'email', 'nickname', 'password'] as const) {}
패키지 설치
npm install --save @nestjs/typeorm typeorm mysql2
설치 중 dependency conflict
오류가 발생할 경우, --force
명령어를 추가하면 된다.
TypeOrmModule 추가
app.module.ts
에 아래 형식에 맞게 설정을 추가한다.
synchronize
: true
옵션은 DB 연결 시 테이블이 자동 생성되므로 false
로 관리하는 것이 안전하다.
entities
: utoLoadEntities: true
옵션을 추가하면 일일이 entity 파일을 추가할 필요가 없지만, 해당 옵션 사용 시 버그가 발생하는 경우가 있기 때문에 아래처럼 작성했다.
keepConnectionsAlive
: true
옵션을 설정할 경우 Hot reload 시 DB 연결을 유지해준다.
TypeOrmModule.forRoot({
type: 'mysql',
host: 'localhost',
port: 3306,
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
entities: [Users, Channelchats, Channels, Channelmembers, Dms, Mentions, Workspacemembers, Workspaces],
keepConnectionAlive: true,
migrations: [__dirname + '/migrations/*.ts'],
charset: 'utf8mb4',
synchronize: false,
logging: true,
}),
app.module.ts
전체 코드
// .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';
import { UsersModule } from './users/users.module';
import { DmsModule } from './dms/dms.module';
import { ChannelsModule } from './channels/channels.module';
import { WorkspacesModule } from './workspaces/workspaces.module';
import { UsersService } from './users/users.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Users } from './entities/Users';
import { Channelchats } from './entities/Channelchats';
import { Channelmembers } from './entities/Channelmembers';
import { Channels } from './entities/Channels';
import { Dms } from './entities/Dms';
import { Mentions } from './entities/Mentions';
import { Workspacemembers } from './entities/Workspacemembers';
import { Workspaces } from './entities/Workspaces';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRoot({
type: 'mysql',
host: 'localhost',
port: 3306,
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
autoLoadEntities: true,
entities: [Users, Channelchats, Channels, Channelmembers, Dms, Mentions, Workspacemembers, Workspaces],
keepConnectionAlive: true,
migrations: [__dirname + '/migrations/*.ts'],
charset: 'utf8mb4',
synchronize: false,
logging: true,
}),
UsersModule,
DmsModule,
ChannelsModule,
WorkspacesModule,
],
controllers: [AppController],
providers: [AppService, ConfigService, UsersService],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): any {
consumer.apply(LoggerMiddleware).forRoutes('*');
}
}
서버 실행
npm run start:dev
synchronize
옵션을 true
로 설정했다면 DB와 연결되는 동시에 테이블이 자동 생성될 것이고, 옵션을 false
로 설정했다면 테이블 생성을 생략한 채 DB와 연결된다.
DB에 새로운 테이블이나 컬럼을 추가할 때, 쿼리문으로 직접 DB를 정의해도 되지만, ORM의 migration 기능을 활용하면 훨씬 안정적인(실수가 발생했을 시 되돌리기가 가능하기 때문이다.) 작업이 가능하다. TypeORM migration 사용 방법은 다음과 같다.
dataSource.ts
생성
root 경로에 dataSource.ts
파일 생성
아래 형식에 맞게 설정 추가
// ./dataSource.ts
import { DataSource } from 'typeorm';
import dotenv from 'dotenv';
import { Channelchats } from './src/entities/Channelchats';
import { Channelmembers } from './src/entities/Channelmembers';
import { Channels } from './src/entities/Channels';
import { Dms } from './src/entities/Dms';
import { Mentions } from './src/entities/Mentions';
import { Users } from './src/entities/Users';
import { Workspacemembers } from './src/entities/Workspacemembers';
import { Workspaces } from './src/entities/Workspaces';
dotenv.config();
const dataSource = new DataSource({
type: 'mysql',
host: 'localhost',
port: 3306,
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
entities: [Channelchats, Channelmembers, Channels, Dms, Mentions, Users, Workspacemembers, Workspaces],
migrations: [__dirname + '/src/migrations/*.ts'],
charset: 'utf8mb4',
synchronize: false,
logging: true,
});
export default dataSource;
package.json
에 스크립트 추가
#./package.json
"scripts": {
...
"typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js",
"schema:drop": "ts-node ./node_modules/typeorm/cli.js schema:drop",
"schema:sync": "ts-node ./node_modules/typeorm/cli.js schema:sync",
"db:migrate": "npm run typeorm migration:run -- -d ./dataSource.ts",
"db:migrate:revert": "npm run typeorm -- -d ./dataSource.ts migration:revert",
"db:create-migration": "npm run typeorm -- migration:create ./src/migrations/$npm_config_name",
"db:generate-migration": "npm run typeorm -- migration:generate ./src/migrations/$npm_config_name -d ./dataSource.ts"
},
migration 파일 생성
npm run db:create-migration --name={파일 이름}
생성된 migration 파일은 dataSource.ts
의 migrations
에서 지정한 경로에 저장된다.
생성한 migration 파일에서 up
함수에는 DB 구조 변경에 사용할 정의문을 추가하고, down
함수에는 변경 사항을 되돌릴 때 사용할 정의문을 추가하면 된다.
//.src/migrations/1661003507277-changeColumn
import { MigrationInterface, QueryRunner } from 'typeorm';
export class changeColumn1661003507277 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE users ADD COLUMN testColumn int`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE users DROP COLUMN testColumn`);
}
}
npm run db:migrate
: migration 파일의 up
함수에서 작성한 쿼리문을 DB에 반영한다.
npm run db:migrate:revert
: DB에 적용된 migration 파일의 down
함수에서 작성한 쿼리문을 DB에 반영한다.
npm run schema: drop
: DB 테이블 모두 삭제
npm run schema: sync
: entities
폴더 내부에 정의된 모든 @Entity
를 DB에 반영
nest.js에서 제공하는 class-validator
를 활용하면 데이터 유효성 검사가 매우 편리해진다. api 요청(request) 시 넘겨받은 데이터의 타입이 DTO에서 정의된 것과 다를 경우, 별도의 로직 추가 없이 에러 코드를 반환할 수 있다.
라이브러리 설치
npm i --save class-validator class-transformer
DTO에 데코레이터 추가
앞에서 언급했듯 nest.js는 @Entity
를 DTO로 인식하므로, entities
폴더 내부 entity 파일에 데코레이터를 추가한다.
// ./src/entities/Users.ts
...
@Entity('users', { schema: 'clone_slack' })
export class Users {
@ApiProperty({
example: 1,
description: '사용자 id',
})
@PrimaryGeneratedColumn({ type: 'int', name: 'id' })
id: number;
@IsEmail() // 이메일 타입이 아닌 경우 에러 코드를 반환한다.
@ApiProperty({
example: 'fcfargo90@gmail.com',
description: '사용자 이메일',
})
@Column('varchar', { name: 'email', unique: true, length: 30 })
email: string;
@IsString() // 문자열 타입이 아닌 경우 에러 코드를 반환한다.
@IsNotEmpty() // 빈 값을 넘겨받은 경우 에러 코드를 반환한다.
@ApiProperty({
example: 'fcfargo',
description: '사용자 닉네임',
required: true,
})
@Column('varchar', { name: 'nickname', length: 30 })
nickname: string;
...
}
Exception filter 생성
./src
하위 경로에 httpException.filter.ts
파일 생성
생성 파일에 아래 코드를 추가
// ./src/httpException.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
import { 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 status = exception.getStatus();
const err = exception.getResponse() as { message: any; statusCode: number } | { error: string; statusCode: 400; message: string[] }; // class-validator 타이핑
console.log(status, err);
if (typeof err !== 'string' && err.statusCode === 400) {
// class-validator 발생 에러
return response.status(status).json({
success: false,
code: status,
data: err.message,
});
}
// 사용자 정의 에러(HttpException, BadRequestException 등..)
response.status(status).json({
success: false,
code: status,
data: err,
});
}
}
ValidationPipe
, HttpExceptionFilter
추가
생성한 Exception filter(HttpExceptionFilter
)가 controller 이후 실행될 수 있도록 main.ts
를 설정한다. nest.js가 요청(request)을 처리 및 응답(response)하기까지의 과정(lifecycle)을 자세히 알고 싶다면 아래 링크를 확인하자.
main.ts
설정
// ./src/main.ts
import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import { HttpExceptionFilter } from './httpException.filter';
declare const module: any;
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 포트 번호 정의
const port = process.env.PORT || 8000;
// exception filter
app.useGlobalFilters(new HttpExceptionFilter()); // Exception filter 추가
// class-validator
app.useGlobalPipes(new ValidationPipe()); // ValidationPip 추가
// sagger 문서 설정
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);
await app.listen(port);
console.log(`listening on port ${port}`);
if (module.hot) {
module.hot.accept();
module.hot.dispose(() => app.close());
}
}
bootstrap();
이상의 과정을 마친 후 서버가 오류 없이 실행된다면, typeORM 연결 및 migration, class-validator 설정이 완료된 것이다.