TypeORM 은 TypeScript 와 JavaScript 환경에서 사용할 수 있는 ORM(Object Relational Mapper) 라이브러리입니다. 즉, 데이터베이스의 테이블을 코드에서 직접 SQL 쿼리를 작성하지 않고, 클래스와 객체 지향적으로 매핑해서 다룰 수 있게 해주는 도구입니다.
typeorm model generator는 데이터베이스에 이미 존재하는 테이블 구조(스키마)를 기반으로 자동으로 TypeORM 엔티티(Entity) 클래스 파일을 생성해주는 도구입니다.
npm i typeorm-model-generator -D
npx typeorm-model-generator \
-h localhost \ # DB 호스트
-d sleact \ # DB 이름
-u root \ # DB 사용자
-x nodejsbook \ # 비밀번호
-e mysql # DB 종류 (mysql, postgres 등)
프로젝트 시작할 때 이미 DB 테이블이 다 설계·구현되어 있다면, 일일이 엔티티 클래스를 만들 필요 없이 해당 툴을 사용하면 됩니다.
@Entity({ schema: 'sleact', name: 'workspaces' })
export class Workspaces {
@PrimaryGeneratedColumn({ type: 'int', name: 'id' })
id: number;
@Column('varchar', { name: 'name', unique: true, length: 30 })
newName: string; // DB 테이블명 변경이 가능하다
@Column('varchar', { name: 'url', unique: true, length: 30 })
url: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@DeleteDateColumn()
deletedAt: Date | null;
@Column('int', { name: 'OwnerId', nullable: true })
OwnerId: number | null;
}
데코레이터로 DB 컬럼 정의 가능합니다. 또한 클래스 단위로 작성하기 때문에 중복 로직을 추상화하거나 공통 속성 상속이 가능합니다.
//workspaces
@OneToMany(() => Channels, (channels) => channels.Workspace)
Channels: Channels[];
// channels
@ManyToOne(() => Workspaces, (workspaces) => workspaces.Channels, {
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
})
@JoinColumn([{ name: 'WorkspaceId', referencedColumnName: 'id' }])
Workspace: Workspaces; // 보통 forien key가 있는 곳에 넣음
Workspaces 는 여러개의 Channels 을 가질 수 있으므로 두 엔티티 간의 1:N 관계를 설정합니다.
@OneToMany 를 통해 Workspaces 가 여러개의 Channels 을 가질 수 있음을 정의합니다.
@ManyToOne 를 통해 하나의 Channels은 하나의 Workspaces 에 속함을 정의합니다.
@JoinColumn 를 통해 Foreign Key 를 역할을 수행합니다.
//workspaces
@ManyToMany(() => Users, (users) => users.Workspaces)
Members: Users[];
// Users
@ManyToMany(() => Workspaces, (workspaces) => workspaces.Members)
@JoinTable({
name: 'workspacemembers',
joinColumn: { // 현재 엔티티
name: 'UserId',
referencedColumnName: 'id',
},
inverseJoinColumn: { // 참조 엔티티
name: 'WorkspaceId',
referencedColumnName: 'id',
},
})
Workspaces: Workspaces[];
Workspaces 하나에 여러 명의 Users 가 참여 가능하고 또한 Users 한명이 여러 Workspaces 에 참여가 가능하기 때문에 두 엔티티 간의 N:M 관계를 설정합니다. 각각 @ManyToMany 를 통해 관계를 설정하고 @JoinTable 를 통해 중간 테이블 을 구성합니다. @JoinTable 은 한 곳에서만 정의하여합니다.
NestJS는 SQL 및 NoSQL 데이터베이스와의 통합을 위해 TypeORM @nestjs/typeorm 패키지를 제공합니다.
$ npm install --save @nestjs/typeorm typeorm mysql2
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'mysql',
host: 'localhost',
port: 3306,
username: 'root',
password: 'root',
database: 'test',
entities: [],
synchronize: true,
}),
],
})
export class AppModule {}
npm i typeorm-extension
typeorm-extension 패키지를 통해 데이터베이스 Seeding, Factory ( Faker.js 와 같은 라이브러리와 함께 사용하여 더미 데이터 생성) 등의 기능을 도와줍니다.
// 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'],
synchronize: false,
logging: true,
});
export default dataSource;
위의 파일과 같이 DB 연결 정보 + 엔티티 + 마이그레이션 정보를 모아둔 설정 파일을 생성합니다.
"scripts": {
"db:create": "ts-node ./node_modules/typeorm-extension/bin/cli.cjs db:create -d ./dataSource.ts",
"db:drop": "ts-node ./node_modules/typeorm-extension/bin/cli.cjs db:drop -d ./dataSource.ts",
}
해당 명령어를 통해 DB 생성 및 제거가 가능합니다.
// back/src/database/seeds/create-initial-data.ts
import { Seeder, SeederFactoryManager } from 'typeorm-extension';
import { DataSource } from 'typeorm';
import { Workspaces } from '../../entities/Workspaces';
import { Channels } from '../../entities/Channels';
export default class UserSeeder implements Seeder {
public async run(dataSource: DataSource, factoryManager: SeederFactoryManager): Promise<any> {
const workspacesRepository = dataSource.getRepository(Workspaces);
await workspacesRepository.insert([
{
id: 1,
name: 'Sleact',
url: 'sleact',
},
]);
const channelsRepository = dataSource.getRepository(Channels);
await channelsRepository.insert([
{
id: 1,
name: '일반',
WorkspaceId: 1,
private: false,
},
]);
}
}
데이터베이스 Seeding을 위한 파일을 생성합니다.
"scripts": {
"seed": "ts-node ./node_modules/typeorm-extension/bin/cli.cjs seed -d ./dataSource.ts",
}
해당 명령어를 통해 데이터베이스 Seeding이 가능합니다.
@Module({
imports: [
TypeOrmModule.forRoot({
synchronize: false,
}),
],
})
AppModule에서 설정한 TypeOrmModule의 synchronize 옵션은 엔티티 파일 기반으로 DB의 테이블을 작성해줍니다. 그러므로 DB 생성 후에는 해당 옵션을 꺼주어야 기존 데이터가 손실될 위험을 막을 수 있습니다.
테이블 수정은 SQL로 직접 바꾸는게 정석적인 방법이지만 엔티티 파일도 같이 변경해주어야 합니다. 이러한 과정은 중복 작업이며 실수를 유발할 수 있어 Migration 을 통해 테이블을 수정을 합니다.
"scripts": {
"typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js",
"db:migrate": "npm run typeorm migration:run -- -d ./dataSource.ts",
"db:migrate:revert": "npm run typeorm migration:revert -- -d ./dataSource.ts",
"db:create-migration": "npm run typeorm migration:create -- ./src/migrations/",
"db:generate-migration": "npm run typeorm migration:generate -- ./src/migrations -d ./dataSource.ts"
}
db:create-migration 명령어를 통해 파일을 만들도 src/migrations 경로에 넣어줍니다. ( dataSource.ts에 경로가 정의되어 있습니다. )
// back/src/migrations/1757050171429-categoryToType.ts
import { MigrationInterface, QueryRunner } from 'typeorm';
export class categoryToType1757050171429 implements MigrationInterface {
name = 'categoryToType1757050171429';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE `mentions` RENAME COLUMN `category` To `type`');
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE `mentions` RENAME COLUMN `type` To `category`');
}
}
up 함수는 수행할 명령어 이며 down 함수는 다시 롤백하는 명령어 입니다.
이 후 db:migrate 명령어를 통해 Migration 이 가능합니다.
// back/src/entities/Users.ts
@Entity({ schema: 'sleact', name: 'users' })
export class Users {
@ApiProperty({
example: 1,
description: '사용자 아이디',
})
@PrimaryGeneratedColumn({ type: 'int', name: 'id' })
id: number;
@ApiProperty({
example: 'test@test.com',
description: '이메일',
})
@Column('varchar', { name: 'email', unique: true, length: 30 })
email: string;
@IsNotEmpty()
@ApiProperty({
example: 'test',
description: '닉네임',
})
@Column('varchar', { name: 'nickname', length: 30 })
nickname: string;
}
Entity에서 @ApiProperty 데코레이터를 통해 DTO 를 작성할 수 있습니다.
// back/src/users/dto/join.request.dto.ts
import { ApiProperty, PickType } from '@nestjs/swagger';
import { Users } from 'src/entities/Users';
export class JoinRequestDto extends PickType(Users, ['email', 'nickname', 'password'] as const) {}
PickType 으로 Entity에서 @ApiProperty 데코레이터 통해 작성한 DTO 가져와 확장할 수 있습니다.
// back/src/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Users } from 'src/entities/Users';
@Module({
imports: [TypeOrmModule.forFeature([Users])],
providers: [UsersService],
controllers: [UsersController],
})
export class UsersModule {}
TypeORM을 사용할 모듈의 imports 에 TypeOrmModule 를 연결시켜줍니다.
// back/src/users/users.service.ts
import { HttpException, Injectable, UnauthorizedException } from '@nestjs/common';
import bcrypt from 'bcrypt';
import { Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { Users } from 'src/entities/Users';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(Users)
private userRepository: Repository<Users>,
) {}
async join(email: string, nickname: string, password: string) {
const user = await this.userRepository.findOne({ where: { email } });
if (user) {
throw new UnauthorizedException('이미 존재하는 사용자입니다.');
}
const hashsedPassword = await bcrypt.hash(password, 12);
await this.userRepository.save({ email, nickname, password: hashsedPassword });
}
}
// back/src/users/users.module.ts
@Controller('api/users')
export class UsersController {
constructor(private usersService: UsersService) {}
@ApiOperation({ summary: '회원가입' })
@Post()
async Join(@Body() data: JoinRequestDto) {
await this.usersService.join(data.email, data.nickname, data.password);
}
}
@InjectRepository 를 통해 Repository 를 Dependency Injection 합니다. 이를 통해 서비스 클래스에서 엔티티와 연결된 Repository를 사용할 수 있습니다.
// back/src/http-exception.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 타이핑
if (typeof err !== 'string' && err.statusCode === 400) {
// class-validator 에러
return response.status(status).json({
success: false,
code: status,
data: err.message,
});
}
response.status(status).json({
success: false,
code: status,
data: err.message,
});
}
}
// back/src/main.ts
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './http-exception.filter';
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const port = process.env.PORT || 3000;
app.useGlobalFilters(new HttpExceptionFilter()); // Exception Filter
await app.listen(port);
console.log(`listening on port ${port}`);
}
bootstrap();
// back/src/users/users.service.ts
throw new HttpException('이메일이 없습니다.', 400)
throw new UnauthorizedException('이미 존재하는 사용자입니다.');
Exception Filter 처리를 위한 파일입니다. 해당 파일을 통해 Http Exception을 정형화된 형식으로 출력이 가능합니다.
npm install class-validator class-transformer
class-validator 를 통해 DTO의 값들을 validation 할 수 있습니다. 이를 통해 클라이언트가 보낸 데이터가 유효한지 검사가 가능합니다. class-transformer는 클래스로 변환하거나 데코레이터 기반 validation 을 위해 함께 사용됩니다.
// back/src/entities/Users.ts
@Entity({ schema: 'sleact', name: 'users' })
export class Users {
@ApiProperty({
example: 1,
description: '사용자 아이디',
})
@PrimaryGeneratedColumn({ type: 'int', name: 'id' })
id: number;
@IsEmail()
@ApiProperty({
example: 'test@test.com',
description: '이메일',
})
@Column('varchar', { name: 'email', unique: true, length: 30 })
email: string;
@IsString()
@IsNotEmpty()
@ApiProperty({
example: 'test',
description: '닉네임',
})
@Column('varchar', { name: 'nickname', length: 30 })
nickname: string;
}
// back/src/main.ts
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './http-exception.filter';
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const port = process.env.PORT || 3000
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalPipes(new ValidationPipe()); // ValidationPipe
await app.listen(port);
console.log(`listening on port ${port}`);
}
bootstrap();
DTO와 함께 ValidationPipe를 사용하면 자동으로 요청을 검증합니다.
Request lifecycle 순서의 선후관계에 따라 Exception Filter Interceptor 등 이 실행되지 않을 수 있습니다.
TypeORM과 NestJS 연동, 엔티티 설계, 시드, 마이그레이션, DTO 검증을 배워 보았습니다. 이를 바탕으로 직접 코드를 작성해야만 이해할 수 있을 것 같습니다.