서비스를 배포 후에 reservation 테이블에 booking_number라는 새로운 칼럼을 추가해야했다.
이미 배포되어 서비스되고 있는 상태였기 때문에 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
하는 방식으로, 기존의 데이터를 보존해야 하는 상황에서는 부적합하다고 한다.
사실 이번 변경 사항은 배포된 서비스에 아직 사용자가 없으므로, 배포용 데이터베이스에 synchronize: true
옵션을 사용하여 변경해보았을 때, 기존의 데이터가 날아간다거나 하는 현상은 없었다. 그러나 만약의 문제 상황을 방지하고, 앞으로도 계속 변경사항이 있을 것이기 때문에 migration 세팅을 한 번 해보고 경험해보기로 했다.
// {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
폴더를 기준으로 넣어주어야 에러가 발생하지 않는다.
// {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 을 적으면 된다.)
아래와 같이 수정이 필요한 Reservation
Entity에 bookingNumber
컬럼을 추가했다.
@Entity()
export class Reservation {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'booking_number' })
bookingNumber: number;
위와 같이 스크립트를 추가한 상태에서 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
가 붙는 것을 알 수 있다.
// {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');
}
}
이제 실행하면 된다. 😀
위에서 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://orkhan.gitbook.io/typeorm/docs/migrations
https://stackoverflow.com/questions/73827983/nestjs-inject-service-into-typeorm-migration