12/15/2023

마이그레이션

발단

NestJS 공식 문서의 TECHNIQUES의 Database를 보면 TypeORM 속성 중 synchronize 속성이 있다. 개발과 테스트 환경에서는 이 속성을 true로 설정해도 되지만 운영 환경에서는 반드시 false로 설정해야 한다고 공식 문서는 경고한다. 운영 환경에서 이 속성을 true로 설정하면 데이터를 잃을 수 있기 때문이다. 이 synchronize 속성은 무엇일까? synchronize 속성은 TypeORM이 엔티티와 데이터베이스를 자동으로 동기화한다. 예를 들어, 사용자 엔티티에 전화번호를 의미하는 phoneNumber 컬럼이 있는데 이를 삭제하고 애플리케이션을 실행하면 엔티티의 변경 사항이 자동으로 데이터베이스에 적용되어 모든 전화번호가 날라가는 끔찍한 상황에 직면한다. 따라서 데이터베이스 마이그레이션을 통해 데이터 구조(e.g., 테이블 추가 또는 삭제, 컬럼 변경, 데이터 타입 변경 등)를 안전하게 수정할 수 있다.


설정

마이그레이션을 적용하기 전에 TypeORM CLI에 대해 알아야 하는데 TypeORM CLI를 실행하여 마이그레이션 파일을 생성하거나 적용할 때 TypeORM CLI는 엔터티 파일과 마이그레이션 파일 안에 있는 코드만 실행한다. 따라서, TypeORM CLI는 타입은 데이터베이스에 연결하기 위해 사용하는 App 모듈과 Config 모듈이 무엇인지 알지 못한다. 일단, ormconfig.ts 파일을 생성하고 package.json 파일의 scripts 부분을 다음과 같이 수정한다. ormconfig.ts 파일에서 migrations 속성과 entities 속성에서 파일 경로가 '/'로 시작한다는 것이 중요하다. ormconfig.ts 파일과 src 디렉터리는 같은 디렉터리에 속하는데 TypeORM 속성과 달리 '/'을 추가하지 않아 오류가 발생했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// ormconfig.ts
import { ConfigService } from '@nestjs/config';
import * as dotenv from 'dotenv';
import { DataSource } from 'typeorm';
 
// ConfigService를 사용하기 전에 환경 변수를 가져오기 위해 dotenv 패키지를 사용한다.
dotenv.config();
 
const configService = new ConfigService();
 
export default new DataSource({
  type: 'mysql',
  host: configService.get<string>('DB_HOST'),
  port: parseInt(configService.get<string>('DB_PORT') as string),
  username: configService.get<string>('DB_USERNAME'),
  password: configService.get<string>('DB_PASSWORD'),
  database: configService.get<string>('DB_DATABASE'),
  // 환경에 따른 파일을 선택한다.
  entities:
    configService.get<string>('NODE_ENV'=== 'development' ||
    configService.get<string>('NODE_ENV'=== 'production'
      ? [__dirname + '/./dist/**/*.entity.js']
      : [__dirname + '/./src/**/*.entity.ts'],
  // 마이그레이션을 실행하려면 migrations에 마이그레이션 파일을 추가한다.
  migrations: [__dirname + '/./src/databases/migrations/*.ts'],
  // 애플리케이션을 시작할 때마다 마이그레이션을 실행한다.
  migrationsRun:
    configService.get<string>('NODE_ENV'=== 'development' ? false : true,
  synchronize: false,
});
 
// package.json
"scripts": {
  ...
  "typeorm""ts-node ./node_modules/typeorm/cli",
  "migration:generate""npm run typeorm -- -d ./ormconfig.ts migration:generate ./src/databases/migrations/$npm_config_name",
  "migration:create""npm run typeorm -- migration:create ./src/databases/migrations/$npm_config_name",
  "migration:show""npm run typeorm migration:show",
  "migration:run""npm run typeorm migration:run -- -d ./ormconfig.ts",
  "migration:revert""npm run typeorm migration:revert -- -d ./ormconfig.ts"
},
cs


마이그레이션 생성

기존의 사용자 엔티티에 나이를 의미하는 age 컬럼을 추가하고 package.json 파일의 scripts에 적힌대로 npm run migration:generate --name=update-users 명령을 실행하면 다음과 같은 마이그레이션 파일이 생성된다. 파일을 보면 up() 메서드는 마이그레이션을 데이터베이스에 적용하고 down() 메서드는 데이터베이스에서 적용을 해제한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 사용자 엔티티
@Entity('users')
export class User {
  ...
 
  @Column({ length500, nullable: false })
  password: string;
 
  // 기존의 사용자 엔티티에 age 컬럼을 추가한다.
  @Column({ nullable: false })
  age: number;
 
  @Column({
    name'phone_number',
    length300,
    nullable: false,
  })
  phoneNumber: string;
  
  ...
}
 
// 사용자 마이그레이션
export class UpdateUsers1699279036683 implements MigrationInterface {
  name = 'UpdateUsers1699279036683';
 
  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`ALTER TABLE \`users\` ADD \`age\` int NOT NULL`); // age 컬럼을 추가한다.
    await queryRunner.query(
      `ALTER TABLE \`users\` CHANGE \`roles\` \`roles\` enum ('USER''MANAGER''ADMIN,') NOT NULL DEFAULT 'USER'`,
    );
  }
 
  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      `ALTER TABLE \`users\` CHANGE \`roles\` \`roles\` enum ('USER''MANAGER''ADMI''''') NOT NULL DEFAULT 'USER'`,
    );
    await queryRunner.query(`ALTER TABLE \`users\` DROP COLUMN \`age\``); // age 컬럼을 삭제한다.
  }
}
cs


마이그레이션 적용/해제

마이그레이션을 적용하려면 ormconfig.ts 파일에서 migrations 속성에 마이그레이션 파일 혹은 경로를 추가해야 한다. 그리고 npm run migration:run 명령을 실행한다. 로그는 다음과 같고 데이터베이스에서 사용자 테이블을 보면 age 컬럼이 생성된 것을 볼 수 있다.
age 컬럼이 테이블에 추가되었다.

마이그레이션 명령을 실행하면 먼저 migrations 배열에서 실행되지 않은 마이그레이션을 확인하고 up() 메서드를 실행한다. 또한, 데이터베이스의 migrations 테이블에 항목을 추가한다. 이미지에서 UpdateUsers 이름에서 이를 확인할 수 있다.

적용된 마이그레이션을 해제하고 싶으면 npm run migrations:revert 명령을 실행한다. 가장 최근에 수행된 마이그레이션의 down() 메서드를 실행하고 해당하는 행을 migrations 배열에서 제거한다. 따라서 여러 마이그레이션을 되돌려야 하는 경우 명령을 여러 번 사용해야 한다. age 컬럼이 삭제된 것을 확인할 수 있다.
age 컬럼이 테이블에서 삭제되었다.

데이터베이스의 migrations 테이블에서 해당 항목이 삭제된다.


수동 마이그레이션 생성

npm run migration:generate 명령의 경우 마이그레이션 파일을 만들고 변경 사항을 자동으로 생성한다. 하지만 TypeORM를 사용하여 마이그레이션을 자동으로 생성하지 않고 수동으로 생성하고 싶으면 npm run migration:create 명령을 사용한다.
빈 마이그레이션 파일 생성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// up() 메서드와 down() 메서드의 내용은 직접 작성한다.
export class CreateUser1702630288539 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.addColumn(
      'users',
      new TableColumn({
        name'job',
        type: 'varchar',
      }),
    );
  }
 
  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.dropColumn('users''job');
  }
}
cs


의문점

검색을 이리저리 하다가 갑자기 의문점이 들었다. TypeORM 설정 코드를 보면 환경에 따라서 로드하는 엔티티 파일이 다르다. 그러니까 개발 환경 또는 운영 환경이면 컴파일된 JavaScript 파일을 로드하고 테스트 환경이면 TypeScript 파일을 로드한다. 개발 환경에서 애플리케이션을 실행하며 NestJS는 src 디렉터리 안의 모든 TypeScript 파일을 가져와서 JavaScript 파일로 변환하여 dist 디렉터리에 생성한다. NestJS는 NodeJS를 시작하고 main.js 파일을 실행한다. 즉, 애플리케이션을 실행할 때 JavaScript로 실행된다. 이는 운영 환경도 마찬가지다. 하지만 테스트 환경의 경우 NestJS는 ts-jest라는 툴을 사용하며 TypeScript 파일을 로드한 후 즉시 JavaScript로 변환하여 결과를 실행한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { ConfigService } from '@nestjs/config';
import * as dotenv from 'dotenv';
import { DataSource } from 'typeorm';
 
// ConfigService를 사용하기 전에 환경 변수를 져오기 위해 dotenv 패키지를 사용한다.
dotenv.config();
 
const configService = new ConfigService();
 
export default new DataSource({
  type: 'mysql',
  host: configService.get<string>('DB_HOST'),
  port: parseInt(configService.get<string>('DB_PORT'as string),
  username: configService.get<string>('DB_USERNAME'),
  password: configService.get<string>('DB_PASSWORD'),
  database: configService.get<string>('DB_DATABASE'),
  // 환경에 따른 다른 파일을 선택한다.
  entities:
    configService.get<string>('NODE_ENV'=== 'development' ||
    configService.get<string>('NODE_ENV'=== 'production'
      ? [__dirname + '/./dist/**/*.entity.js']
      : [__dirname + '/./src/**/*.entity.ts'],
  // 마이그레이션을 실행하려면 migrations에 마이그레이션 파일을 추가한다.
  migrations:
    configService.get<string>('NODE_ENV'=== 'development' ||
    configService.get<string>('NODE_ENV'=== 'production'
      ? [__dirname + '/./dist/src/databases/migrations/*.js']
      : [__dirname + '/./src/databases/migrations/*.ts'],
  synchronize: false,
});
cs

이러한 차이 때문에 다른 엔티티 파일을 선택하는데 현재 ormconfig.ts 파일을 보면 환경에 관계없이 전부 TypeScript 파일을 선택하며 migrations 속성도 TypeScript 파일을 선택한다. 하지만 아무런 오류가 나지 않는다. ormconfig.ts 파일의 entities 속성과 migrations 속성을 환경에 따른 엔티티 파일을 선택하도록 변경했는데 예상대로 정상적으로 작동한다. 왜 TypeScript 파일을 선택해도 그대로 작동할까? 아직은 모르겠다.


해결

TypeORM 공식 문서를 보다가 이유를 알아냈다! Advanced Topics의 Using CLI에 들어가면 엔티티 파일 여부에 따라서 ts-node 패키지를 설치하라고 말한다. 만약 엔티티 파일이 TypeScript라면 ts-node 패키지를 설치해야 하는데 이유는 CLI가 JavaScript로 작성되어서 TypeScript 파일을 해석할 줄 모른다는 것이다! 따라서, TypeScript 파일을 JavaScript 파일로 컴파일 하고 CLI를 사용하라고 한다. 정확하게 말하면 TypeScript을 JavaScript으로 즉시 컴파일(JIT) 해서 사전 컴파일(precompiling)없이 NodeJS에서 TypeScript을 실행할 수 있다. package.json 파일을 가보면 scripts 부분에서 typeork: ts-node ./node_modules/typeorm/cli에서 ts-node 패키지를 사용한다. 마이그레이션 적용 시 공식 문서를 꼼꼼하게 읽지 않은 나의 잘못... 더 알아보니 Migrations의 Running and reverting migrations에 아주 자세하게 설명한다~
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
  ...
 
  "scripts": {
   ...
 
    // ts-node 패키지가 적용된다! 따라서 TypeScript 엔티티 파일만 있으면 적용 완료!
    "typeorm": "ts-node ./node_modules/typeorm/cli",
    "migration:generate": "npm run typeorm -- -d ./ormconfig.ts migration:generate ./src/databases/migrations/$npm_config_name",
    "migration:create": "npm run typeorm -- migration:create ./src/databases/migrations/$npm_config_name",
    "migration:show": "npm run typeorm migration:show",
    "migration:run": "npm run typeorm migration:run -- -d ./ormconfig.ts",
    "migration:revert": "npm run typeorm migration:revert -- -d ./ormconfig.ts"
  },
  "dependencies": {
  },
 
  ...
}
cs

update: 2024.01.21

댓글 없음:

댓글 쓰기