서비스를 배포 후에 team
테이블에 updatedAt
과 별개로 유저가 직접 정보를 수정한 일시를 저장하는 modifiedAt
컬럼을 추가할 필요가 생겼다.
이미 배포되어 서비스되고 있는 상태였기 때문에 DB 스키마를 함부로 수정하기가 조심스러웠고, 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 세팅을 한 번 해보고 경험해보기로 했다.
{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할 수 없는 상황이었다. 따라서 dotenv
의 config
를 import
한 후 초기화하여 process.env.NODE_ENV
값을 가져와주었다.
DataSource option의 migrations
에는 migration 파일들의 위치를 넣어주면 된다. 이때, dist
폴더를 기준으로 넣어주어야 에러가 발생하지 않는다.
{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을 다르게 주기 위해 스크립트 역시 분리했다.
아래와 같이 수정이 필요한 Team
Entity에 modifiedAt
컬럼을 추가했다.
@Entity()
@Unique(['id'])
export class Team extends BaseEntity {
...
@Column({ type: 'timestamp', nullable: true })
modifiedAt: Date;
...
}
위와 같이 스크립트를 추가한 상태에서
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
가 붙는 것을 알 수 있다.
{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;`);
}
}
이제 실행하면 된다. 😀
위에서 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 백업 기능도 항상 활성화 해두는 것이 좋을 듯 하다.