[NestJS 강의] Slack 클론 코딩[백엔드 with NestJS + TypeORM] 섹션 3

강경서·2025년 9월 8일
post-thumbnail

🕹️ TypeORM 사용하기

TypeORM

TypeORMTypeScriptJavaScript 환경에서 사용할 수 있는 ORM(Object Relational Mapper) 라이브러리입니다. 즉, 데이터베이스의 테이블을 코드에서 직접 SQL 쿼리를 작성하지 않고, 클래스와 객체 지향적으로 매핑해서 다룰 수 있게 해주는 도구입니다.


TypeORM Entity

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 컬럼 정의 가능합니다. 또한 클래스 단위로 작성하기 때문에 중복 로직을 추상화하거나 공통 속성 상속이 가능합니다.


TypeORM 관계 설정하기

//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 은 한 곳에서만 정의하여합니다.


TypeORM 커넥션 맺기

NestJSSQLNoSQL 데이터베이스와의 통합을 위해 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 {}

TypeORM Seeding

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에서 설정한 TypeOrmModulesynchronize 옵션은 엔티티 파일 기반으로 DB의 테이블을 작성해줍니다. 그러므로 DB 생성 후에는 해당 옵션을 꺼주어야 기존 데이터가 손실될 위험을 막을 수 있습니다.


TypeORM Migration

테이블 수정은 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을 사용할 모듈의 importsTypeOrmModule 를 연결시켜줍니다.

// 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 를 통해 RepositoryDependency Injection 합니다. 이를 통해 서비스 클래스에서 엔티티와 연결된 Repository를 사용할 수 있습니다.


Exception Filter

// 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을 정형화된 형식으로 출력이 가능합니다.


class-validator

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 검증을 배워 보았습니다. 이를 바탕으로 직접 코드를 작성해야만 이해할 수 있을 것 같습니다.


🧾 Reference

profile
기록하고 배우고 시도하고

0개의 댓글