[NestJs] DB migration

정도영·2024년 2월 18일
0
post-thumbnail

개요

서비스를 배포 후에 reservation 테이블에 booking_number라는 새로운 칼럼을 추가해야했다.
이미 배포되어 서비스되고 있는 상태였기 때문에 DB 스키마를 함부로 수정하기가 조심스러웠고, TypeORM에서 지원하는 migration 기능을 사용해보기로 했다.

TypeORM migration

How migrations work
Once you get into production you'll need to synchronize model changes into the database. Typically, it is unsafe to use synchronize: true for schema synchronization on production once you get data in your database. Here is where migrations come to help.
A migration is just a single file with sql queries to update a database schema and apply new changes to an existing database.

TypeORM 공식 문서에 따르면 production 환경에서 synchronize: true 옵션을 사용하는 것은 안전하지 않다고 한다. 이러한 경우에 필요한게 migration인데, migration은 sql 쿼리들로 이루어진 파일로, 기존 데이터베이스 스키마를 업데이트 하는데 사용된다고 한다.

실제로 synchronize: true 옵션은 개발하는데 매우 편리하다. 기획이 변경됨에 따라 매번 일일이 스키마를 변경해줄 필요가 없고, Entity 정의만 수정하면 알아서 변경사항을 반영해주기 때문이다.
그러나 이러한 방식은 테이블을 drop하고 새로 create하는 방식으로, 기존의 데이터를 보존해야 하는 상황에서는 부적합하다고 한다.

사실 이번 변경 사항은 배포된 서비스에 아직 사용자가 없으므로, 배포용 데이터베이스에 synchronize: true 옵션을 사용하여 변경해보았을 때, 기존의 데이터가 날아간다거나 하는 현상은 없었다. 그러나 만약의 문제 상황을 방지하고, 앞으로도 계속 변경사항이 있을 것이기 때문에 migration 세팅을 한 번 해보고 경험해보기로 했다.

사용 방법

1. data source option 설정

// {root directory}/typeOrm.config.ts
import { DataSource } from 'typeorm';
import { config } from 'dotenv';
import { User } from 'src/models/user/entities/user.entity';
import { Reservation } from 'src/models/reservation/entities/reservation.entity';
import { Good } from 'src/models/goods/entities/good.entity';

config({
  path:
    process.env.NODE_ENV === 'production'
      ? '.production.env'
      : '.development.env',
});

export default new DataSource({
  type: 'mysql',
  host: process.env.DB_HOST,
  port: parseInt(process.env.DB_PORT),
  username: process.env.DB_USERNAME,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  synchronize: false,
  timezone: 'Z',
  logging: true,
  migrations: ['dist/src/database/migrations/*.js'],
  migrationsTableName: 'migration_table',
  migrationsRun: false,
  entities: [User, Reservation, Good],
});

Nest.js의 configService를 injection 해서 환경변수의 값들을 가져와줬다.
DataSource option의 migrations에는 migration 파일들의 위치를 넣어주면 된다. 이때, dist 폴더를 기준으로 넣어주어야 에러가 발생하지 않는다.

2. package.json script 추가

// {root directory}/package.json
 "scripts": {
  
    ...
  
	"typeorm:dev": "yarn build && cross-env NODE_ENV=development yarn typeorm-ts-node-commonjs",
    "typeorm:prod": "yarn build && cross-env NODE_ENV=production yarn typeorm-ts-node-commonjs",
    "typeorm:migration:dev": "yarn typeorm:dev migration:run -d dist/typeOrm.config.js",
    "typeorm:migration:prod": "yarn typeorm:prod migration:run -d dist/typeOrm.config.js",
    "typeorm:revert:dev": "yarn typeorm:dev migration:revert -d dist/typeOrm.config.js",
    "typeorm:revert:prod": "yarn typeorm:prod migration:revert -d dist/typeOrm.config.js",
  },

위와 같이 스크립트를 추가해주었다.
development 환경일 때와 production 환경일 때 data source option을 다르게 주기 위해 스크립트 역시 분리했다.
(나는 yarn을 사용했지만 npm을 사용한다면, yarn 대신 npm run 을 적으면 된다.)

3. Entity 수정

아래와 같이 수정이 필요한 Reservation Entity에 bookingNumber 컬럼을 추가했다.

@Entity()
export class Reservation {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ name: 'booking_number' })
  bookingNumber: number;

4. migration 파일 생성

위와 같이 스크립트를 추가한 상태에서 create나 generate을 하게되면 마이그레이션 파일을 만들어 줍니다.

yarn typeorm:dev migration:create ./{path-to-migrations-dir}/{filename}

위 명령어를 통해 migration 파일을 생성할 수 있다.

예를들면, root directory에서 아래와 같이 실행하면

yarn typeorm:dev migration:create ./src/database/migrations/ReservationRefactoring

migrations 폴더 내에 1708238815437-ReservationRefactoring.ts라는 이름으로 파일이 생성된다. 앞에 자동으로 파일을 생성한 시간의 timestamp가 붙는 것을 알 수 있다.

5. migration 파일 작성

// {root directory}/src/database/migrations/1708238815437-ReservationRefactoring.ts

import { MigrationInterface, QueryRunner } from 'typeorm';

export class ReservationRefactoring1708238815437 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {}

  public async down(queryRunner: QueryRunner): Promise<void> {}
}

파일을 생성한 직후에는 이렇게 기본적인 틀만 존재하는 상태이다. up 메소드는 migration될 때 실행할 sql 쿼리가 들어가면 되고, 반대로 down 메소드에는 해당 migration을 revert(되돌리기)할 때 실행할 쿼리가 들어가면 된다.
나의 경우는 Reservation 테이블에 booking_number 컬럼을 추가할 것이므로 아래와 같이 작성했다.

import { Logger } from '@nestjs/common';
import { generateBookingNumber } from 'src/common/utils';
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';

export class AddBookingNumberToReservation1708238815437
  implements MigrationInterface
{
  name?: string;
  transaction?: boolean;

  private readonly logger = new Logger(
    'AddBookingNumberToReservation1708238815437',
  );

  public async up(queryRunner: QueryRunner): Promise<void> {
    this.logger.log('Migration up');

    await queryRunner.addColumn(
      'reservation',
      new TableColumn({
        name: 'booking_number',
        type: 'varchar(255)',
      }),
    );

    // reservation ID 목록을 가져옴
    const ids = await queryRunner.query('SELECT id FROM reservation');

    // 각 사용자 ID에 대해 유니크한 bookingNumber를 생성하고 저장
    for (const id of ids) {
      const bookingNumber = await generateUniqueBookingNumber();

      await queryRunner.query(
        `UPDATE reservation SET booking_number = ? WHERE id = ?`,
        [bookingNumber, id.id],
      );
    }

    async function generateUniqueBookingNumber() {
      let bookingNumber;

      do {
        bookingNumber = generateBookingNumber(); // 8자리 랜덤 문자열 생성 함수
      } while (await isBookingNumberExists(bookingNumber));

      return bookingNumber;
    }

    async function isBookingNumberExists(bookingNumber) {
      const result = await queryRunner.query(
        `SELECT COUNT(*) FROM reservation WHERE booking_number = ?`,
        [bookingNumber],
      );

      return result[0]['COUNT(*)'] > 0;
    }
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    this.logger.log('Migration down');
    await queryRunner.dropColumn('reservation', 'booking_number');
  }
}

6. migration 실행

이제 실행하면 된다. 😀
위에서 package.json에 작성한 script를 통해 실행해보자.
root directory에서 yarn typeorm:migration:dev를 실행하면 아래처럼 새로운 컬럼이 잘 생성되는 것을 확인할 수 있다.

migration:run 실행 시,


(log를 찍어서 Migration up이 실행되는 것을 확인할 수 있다!)

migration:revert 실행 시,

(log를 찍어서 Migration down 실행되는 것을 확인할 수 있다!)

추가

Migration 파일에는 inject가 되지 않는다.
해당 부분에 query가 아닌 로직으로 마이그레이션을 진행하고 싶다면 아래와 같이 queryRunner 안의 manager를 통해 레파지토리를 가져와 실행하면 된다


export class AddBookingNumberToReservation170823881543 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
  
      const userRepository: Repository<UserEntity> = queryRunner.manager.getRepository(UserEntity);
  }

  public async down(queryRunner: QueryRunner): Promise<void> {}
}

후기

하루종일 이것만 했네... ㅠㅠㅠㅠ

참고 자료

https://typeorm.biunav.com/en/migrations.html#using-migration-api-to-write-migrations

https://velog.io/@moongyu1/Nest.js-TypeORM-production-%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%B2%A0%EC%9D%B4%EC%8A%A4-migration%ED%95%98%EA%B8%B0

https://orkhan.gitbook.io/typeorm/docs/migrations

https://stackoverflow.com/questions/73827983/nestjs-inject-service-into-typeorm-migration

profile
대한민국 최고 개발자가 될거야!

0개의 댓글