메인으로 진행하는 프로젝트에서 얘기이다. 우리는 ORM으로 TypeORM
을 사용했고 DB Schema
의 변경 점에 대해 동기화할 때 Entity
파일을 수정하고 synchronize
옵션을 true
로 변경하는 방법을 사용했었다.
하지만 이는 굉장히 위험한 방법이고 Migration
전략을 사용하는게 좋을 것이라고 조언을 받았었다.
이번 글에서는 어떻게 사용하는지에 대해서 정리 해보자.
참조 링크:
참조 링크:
문서의 해당 목차 초입에 나오는 내용은 이미 발단에 작성했다(production 환경에서도 synchronize를 통해 sync를 맞추는 것은 위험하다는 정도의 내용).
Migration
이 무엇인지 부터 간단하게 설명하면 영단어 뜻으로는 이주인데, 그 말 그대로 이해하면 된다.
TypeORM
을 통해 migration을 관리하게 되면 migration 파일들에 작성된 Query
를 데이터베이스에 반영해서 sync를 맞춘다.
백문불어일견.
synchronize 만을 고집하면 어떤 일이 벌어질 수 있는지 직접 한번 예시를 보자.
@Entity('example')
export class Example {
@PrimaryGeneratedColumn()
id: number;
@Column({
type: 'varchar',
length: 255
})
title: string;
}
// 예시를 위해 작성한 example 이라는 테이블의 엔티티
상황을 가정 해보겠다. 우리는 서비스를 배포해서 상용 환경에 돌아가고 있는 서버가 있다. 또한 해당 테이블의 로우는 이미 수천개가 쌓여있는 상태이다.
근데 여기서 title 이라는 컬럼의 이름을 description 이라는 이름으로 바꾸고 싶어서 title을 description으로 수정하고 synchronize 옵션을 true로 활성화 했다.
이후는...
ALTER TABLE `example` DROP COLUMN `title`;
ALTER TABLE `example` ADD COLUMN `description` VARCHAR(255);
기존의 컬럼을 DROP
시키고 새로운 컬럼을 만드는 참사가 벌어져서 해당 title 이라는 컬럼에 속하는 데이터들은 전부 증발하게 된다.
참사가 벌어지지 않게 하려면
ALTER TABLE `example` RENAME COLUMN `title` TO `description`;
이렇게 SQL 쿼리를 작성했어야 한다.
그럼 이제 안전하게 데이터를 보존할 수 있도록 migration 파일을 생성하는 방법을 알아 보자.
참조 링크:
- Creating a new migration
- Installing CLI
- 문서에 전제 조건으로 CLI 설치가 필요하다고 쓰여 있습니다.
migration 파일 생성 전에 DataSource를 setup 해줘야 한다.
export default new DataSource({
type: 'mysql',
host: 'localhost'
port: 3306,
username: example,
password: example,
database: example,
entities: [/*...*/],
migrationsTableName: 'migrations',
// migration 이력을 저장하는 테이블.
// migrations 이라는 이름으로 생성할 것이라면 지정 안해줘도 됨.
migrations: ['migrations/**/[0-9]*.ts'],
// migration 할 파일들이 있는 directory
});
본인들 환경에 맞게 설정해주시면 된다. 이제 진짜 migration을 생성해보자.
typeorm migration:create ./examples/example
보면 알겠지만 ./examples
부분이 migration 파일을 생성할 경로이고 경로의 끝은 파일의 이름이다.
// 실제 생성된 파일의 모습
import { MigrationInterface, QueryRunner } from 'typeorm';
export class Example1714680431845 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {}
public async down(queryRunner: QueryRunner): Promise<void> {}
}
일단 우리가 설정한 Example 이라는 이름 뒤에 이상한 숫자가 붙은게 보일 것이다. 이는 해당 파일이 생성된 TIMESTAMP
이고, 해당 TIMESTAMP
를 통해 각 migration 파일들의 실행 순서가 정해진다.
당연히 먼저 생성된 순서로 실행이 된다.
그리고 눈치가 빠른 사람은 이미 알았을 수도 있겠지만 up, down 메서드가 보이는데 up
은 migration이 실행될때, down
은 migration을 되돌릴때 실행이 된다.
이제 아까의 참사가 일어나지 않도록 메서드들의 로직을 채워보자.
export class Example1714680431845 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE example RENAME COLUMN title TO description`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE example RENAME COLUMN description TO title`);
}
}
이렇게 up 에서는 변경되는 사항을, drop 에서는 다시 이전으로 돌리는 쿼리를 작성해주면 된다.
사실 해당 상황에서는 queryRunner
에 renameColumn
이라는 메서드가 있어서(그 외에도 다양하게 있다) Raw Query
를 굳이 작성하지 않아도 되지만 이 부분의 설명은 생략하겠다.
참조 링크:
- Running and reverting migrations
- 명령어에 대한 자세한 실행 방법 및 옵션은 해당 목차의 링크에서 확인하시는게 좋을 것 같습니다.
일단 기본적으로 migration:run
은 대충 봐도 알다 싶이 아직 실행이 되지 않은 migration 파일들을 실행시켜서 DB에 반영한다.
이 때 실행이 되지 않은 migration 파일에 대한 판별은 어떻게 이루어질까?
처음에 setup한 DataSource
의 migrationsTableName
옵션에 지정해준 이름의 table이 db에 생성이 된다.
그리고 해당 테이블의 로우로 migration 파일의 이름과 아까 설명한 TIMESTAMP
가 기록된다.
해당 기록들을 통해 실행이 되지 않은 migration 파일들을 판별할 수 있는 것이다.
반대로 migration:revert
를 실행하면 가장 최신에 실행된 migration 파일의 down 메서드에 작성된 작업을 수행하고 해당 migration 파일의 기록을 테이블에서 지운다.
여기까지만 설명해도 문제는 없을 것 같다.
이 이상은 직접 찾아보는게 좋다고 생각한다.
혹시 잠시 언급한 queryRunner
의 renameColumn
메서드 라던가, 그런 것들이 궁금하다면 Using migration API to write migrations 해당 링크를 보면 될 것 같다.
그렇게 나는 메인 프로젝트에서 Migration
전략을 팀원들에게 전파했고 migration 파일 통합 및 관리 등등도 하게 되었다.