[Nest.js / TypeORM] production 데이터베이스 migration하기

moongyu·2023년 3월 13일
0

TypeORM

목록 보기
1/1

문제 상황

서비스를 배포 후에 team 테이블에 updatedAt과 별개로 유저가 직접 정보를 수정한 일시를 저장하는 modifiedAt 컬럼을 추가할 필요가 생겼다.
이미 배포되어 서비스되고 있는 상태였기 때문에 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하는 방식으로, 기존의 데이터를 보존해야 하는 상황에서는 부적합하다고 한다.

사실 이번 변경 사항은 단순히 nullable한 컬럼 한 개를 추가하는 것으로, 개발용 데이터베이스에 synchronize: true 옵션을 사용하여 변경해보았을 때, 기존의 데이터가 날아간다거나 하는 현상은 없었다. 그러나 만약의 문제 상황을 방지하고, 앞으로도 계속 변경사항이 있을 것이기 때문에 migration 세팅을 한 번 해보고 경험해보기로 했다.

사용 방법

1. data source option 설정

{root directory}/src/database/data-source.js

import { config } from 'dotenv';
config({
  path:
    process.env.NODE_ENV === 'production'
      ? __dirname + '/../../.env.production'
      : __dirname + '/../../.env.development',
});
import { DataSource } from 'typeorm';

export const dataSource = new DataSource({
  type: 'mysql',
  host: process.env.DB_HOST,
  port: +process.env.DB_PORT, // Number
  username: process.env.DB_USERNAME,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_DATABASE,
  entities: ['dist/**/**/*.entity.js'],
  synchronize: process.env.DB_SYNCHRONIZE === 'true' ? true : false,
  timezone: 'Z',
  migrations: ['dist/database/migrations/*.js'],
});

클래스가 아닌 단순 js 파일이라 Nest.js의 configService를 injection할 수 없는 상황이었다. 따라서 dotenvconfigimport한 후 초기화하여 process.env.NODE_ENV 값을 가져와주었다.
DataSource option의 migrations에는 migration 파일들의 위치를 넣어주면 된다. 이때, dist 폴더를 기준으로 넣어주어야 에러가 발생하지 않는다.

2. package.json script 추가

{root directory}/package.json

  "scripts": {
  
    ...
  
    "typeorm:dev": "cross-env NODE_ENV=development typeorm-ts-node-commonjs",
    "typeorm:prod": "cross-env NODE_ENV=production typeorm-ts-node-commonjs",
    "typeorm:migration:dev": "npm run typeorm:dev migration:run -- -d dist/database/data-source.js",
    "typeorm:migration:prod": "npm run typeorm:prod migration:run -- -d dist/database/data-source.js",
    "typeorm:revert:dev": "npm run typeorm:dev migration:revert -- -d dist/database/data-source.js",
    "typeorm:revert:prod": "npm run typeorm:prod migration:revert -- -d dist/database/data-source.js"
  },

위와 같이 스크립트를 추가해주었다.
development 환경일 때와 production 환경일 때 data source option을 다르게 주기 위해 스크립트 역시 분리했다.

3. Entity 수정

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

@Entity()
@Unique(['id'])
export class Team extends BaseEntity {
  
  ...

  @Column({ type: 'timestamp', nullable: true })
  modifiedAt: Date;

  ...
  
}

3. migration 파일 생성

위와 같이 스크립트를 추가한 상태에서

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

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

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

npm run typeorm:dev migration:create ./src/database/migrations/TeamRefactoring

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

4. migration 파일 작성

{root directory}/src/database/migrations/1678001242575-TeamRefactoring.ts

import { MigrationInterface, QueryRunner } from "typeorm"

export class TeamRefactoring1678001242575 implements MigrationInterface {

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

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

}

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

import { MigrationInterface, QueryRunner } from 'typeorm';

export class TeamRefactoring1678001242575 implements MigrationInterface {
  async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`ALTER TABLE team ADD COLUMN modifiedAt timestamp(6) AFTER updatedAt;`);
  }

  async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`ALTER TABLE team DROP COLUMN modifiedAt;`);
  }
}

5. migration 실행

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

또한 데이터베이스에 migrations라는 테이블이 자동으로 생성되고, 아래와 같이 migration 파일을 실행한 시간이 기록된다.

이 상태에서 migration을 한 번 더 실행하면 어떻게 될까?

이미 migration이 실행됐다는 것이 기록되어 있기 때문에 실행할 migration이 없다고 뜬다.

반대로, 이미 실행한 migration을 되돌리고 싶으면 revert 명령어를 활용하면 된다.
root directory에서 npm run typeorm:revert:dev를 실행하면 이렇게 마지막으로 실행한 migration이 revert되는 것을 확인할 수 있다.

-끝-

data source를 세팅하는 부분에서 헷갈리고 막히는 부분이 있었지만, 막상 코드를 정리하고 보니 간단한 내용인 것 같다. production DB에서는 데이터 보존이 매우 중요한 만큼 변경사항이 있을 경우 꼭 migration 기능을 이용하자. 또 만일에 대비하여 DB 백업 기능도 항상 활성화 해두는 것이 좋을 듯 하다.


참고 자료

Migrations - TypeORM

profile
개발 블로그 (●'◡'●)

0개의 댓글