제목 : TypeORM CLI 와 데이터베이스 마이그레이션
TypeORM 은 어디서 마주치게 될까?
혹시 이 글을 읽고 있는 독자가 TypeORM 에 대해서 모를 수 있기 때문에 특징을 설명하자면,
- 데코레이터를 사용한다. - (Java 에 익숙한 분들은 "애너테이션" 으로 인식하면 됩니다.)
- 예시 :
@UseInerceptor(...)
- 예시 :
- 데코레이터로 정의된 클래스 속성을 이용하여 데이터베이스에 "자동으로" 스키마를 생성한다.
- TypeORM 에서 제공하는 추상 메서드 (EX -
findOne
) 를 통해
각 데이터베이스의 메서드 사용 차이가 없다. (거의)find
,findOne
,save
등등...
TypeORM
을 사용할 때,reflect-data
라이브러리는 필수이다.- 엔티티 등록을 위해 직접 등록하지 않고,
@Entity()
와 같이 등록한다.
- 엔티티 등록을 위해 직접 등록하지 않고,
reflect-data
라이브러리와 특정 데코레이터를 사용하여 매핑 정보를 수집한다.- 프레임워크가 시작하면서
reflect-data
를 통해 구조를 수집, 및 데이터를 주입한다. - 또한, 각 컴포넌트 간의 의존성을 파악하여 계층을 구성 해 준다.
- 프레임워크가 시작하면서
다시 돌아와서,
TypeORM 을 접하게 되는 가장 큰 경로는 NestJS 를 사용하면서부터라고 생각한다.
그 이유가 뭘까?
NestJS 는 reflect-metadata
라이브러리를 적극 활용하여
@Controller
, @Injectable
, @Inject
외의 수 많은 데코레이터를 활용한다.
위의 데코레이터들은 비즈니스 서비스 계층을 이루게 되는데,
단순히 데코레이터가 선언되었다고 비즈니스 서비스 계층을 이루진 않는다.
NestJS 는, 수집된 "클래스", "메서드", "파라미터" 와 같은 정보를 메타데이터로 저장 한 뒤,
순차적으로 계층을 구성한다.
우리가 직접 서비스 레이어 계층과 모듈을 구성하는 것은 맞지만, 대부분의 흐름을 프레임워크에 맡기는 것이다.
이를 통해, 데이터베이스 연결과 마이그레이션 과정을 맡길 수 있다는 장점이 크게 작용한다.
TypeORM 과 reflect-data 라이브러리
TypeORM 은 방금 위에서 말한 과정과 매우 흡사하다.
TypeORM 은 @Entity()
데코레이터와, 내부 클래스에 선언된 속성들의 데코레이터(Column()
)들도 인식한다.
여러 개의 @Entity()
로 선언된 클래스와, 연관되어 있는 다른 @Entity()
와의 관계를 인식하여
라이브러리와 호환되는 다양한 데이터베이스들을 쉽게 Migration 할 수 있게 해 준다.
모듈에 메서드로 직접 등록하지 않고, 동떨어진 데코레이터에 반응 할 수 있게 해 주는 것은
tsconfig.json
의 옵션과도 관련이 있는데,
experimental-decorator
가true
여야 한다.- 아직 정식 스펙에 추가되지 않은 데코레이터
@....
를 사용하기 위해서이다.
- 아직 정식 스펙에 추가되지 않은 데코레이터
emitDecoratorMetadata
가true
여야 한다.- 이는
reflect-metadata
와 관련된 라이브러리로, 데코레이터의 메타데이터를 JS 로 내보낸다.
- 이는
데코레이터라는 개념은 TS 에서 사용되지만, 정식 스펙에 추가되지는 않았기 때문이다.
위의 옵션이 존재해야만, 데코레이터를 사용 할 수 있고,
데코레이터 로직 내부에서 메타데이터를 등록 할 수 있게 해 주는 것은 relfect-data
라이브러리이다.
따라서, 다양한 메타데이터 데코레이터를 이용하여 의존성과 모듈을 등록하는 NestJS,
그리고 동일하게 데코레이터를 사용하는 TypeORM
라이브러리는 서로 공생관계에 있다고 할 수 있다.
결국 단독으로 사용해야 하는 TypeORM
혹시라도, 여기서 Express + TypeORM 을 사용하고 있는 분이라면,
이미 알고 있는 내용 일 수도 있다.
내가 위의 소제목 "결국 단독으로 사용 해야 하는 TypeORM" 이라는 문구를 달아놓은 이유는,
마지막 프로덕션 단계에서 TypeORM 을 단독으로 사용할 줄 알아야 하기 때문이다.
NestJS 는 typeorm
, @nestjs/typeorm
라이브러리로 편하게 실시간 마이그레이션이 가능하고,
app.module.ts
와 같은 파일에서 typeorm
과 내가 원하는 데이터베이스에 연결하기 위한 리소스를
매우 간편하게 작성 할 수 있다.
물론, 이 과정은 유닛 테스팅, E2E 테스팅, 개발 까지의 단계에서 해당한다.
프레임워크 내부에서 간편하게 엔티티 클래스를 작성하고, 실행하면 내가 만든 코드에 따라
실시간으로 변하는 컬럼을 볼 수 있다. 이는 typeorm
의 synchronize
옵션이 true
이기 때문이다.
테스팅과 개발 과정에서는, 프로덕션이 아니기 때문에, 대부분의 경우 실행과 동시에 바뀐 데이터 구조를 적용해도 괜찮을 수도 있다.
그러나, Production 과정을 생각 해 보자. 과연 Production 데이터베이스 스키마를 설정하면서,
방금 만든 컬럼 혹은 특정 속성을 곧바로 적용할까? 서비스가 터질 수도 있는데?
위의 상황은 실제로 팀 프로젝트를 진행하면서 겪었던 문제이다.
이 과정은 TypeORM
에서 지원하는 CLI 를 통해 직접 마이그레이션 과정을 옮겨야 한다.
내가 위에서 Express 와 TypeORM 을 사용 해 본 사람은 이미 알 내용 일 수도 있다는 이야기는,
우리가 TypeORM.forRoot(...)
에 들어가야 하는 DataSource
를 따로 파일을 만들어서 관리해야 하기 때문이다.
이제와서, TypeORM 옵션을 다른 파일로 옮겨두어야 한다고? 라고 생각했다.
그런데, TypeORM 마이그레이션을 안전하게 수행하기 위해서는,
따로 DataSource
를 export
혹은, module.exports
로 내보낸 단독 파일이 필요하다.
데이터베이스 스키마 마이그레이션 과정은 typeorm
명령어로 진행된다.
이 과정에서 typeorm
은 파일에 적시된 DataSource
를 읽어야 한다.
그러나 만약 app.module.ts
와 같은 장소에 TypeORM.forRoot(...)
로 작성 되어 있다면,
typeorm
CLI 는 DataSource
를 읽어 올 수가 없다.
그래서 내가 하고 싶은 말은,
NestJS 라는 프레임워크와 typeorm
라이브러리가 하나의 몸체라고 생각하면 오산이라는 것이다.
NestJS 의 메타데이터 주입 기능과 typeorm
에서의 메타데이터 추출 및 주입 기능이 매우 자연스러우나,
그건 어디까지나 NestJS 의 훌륭한 지원에서 비롯된 것이며, typeorm
을 프로덕션에서 안전하게 사용하려면
결국 DataSource
를 따로 파일로 빼서 관리해야 한다는 것이다.
그래야, 나중에 npm run typeorm:generate -- -d .....
와 같은 스크립트로
마이그레이션 파일을 만들 수 있다.
npm run typeorm:generate -- -n ...
의-n
옵션은
공식문서에도 없었으므로, 아마 deprecated 되지 않았나 싶습니다.
메타데이터 주입으로 인해 로직을 보기 어렵다면, 이 npm 패키지를 참조해 보자
NPM Sequelize 모듈의 공식 사이트
NPM 지원중인 mysql 모듈의 mysqljs/mysql 깃허브 코드 사이트
https://github.com/mysqljs/mysql
내가 왜 같은 기능을 하는 Sequelize 라이브러리 외, mysql 라이브러리를 참조했냐면,
Sequelize 라이브러리는 reflect-metadata
(데코레이터) 를 사용하지 않는 버전의 typeorm
이라고 생각한다.
mysql 라이브러리는 Sequelize 나, TypeORM 모두 필요로 하는 모듈이다.
즉, 말하고자 하는 것은, 어떠한 ORM 패키지를 선택하든간에,
내부에는 데이터베이스 순수 연결 로직이 작성되어 있지 않다는 것이다.
mysql
모듈 코드를 보면, 커넥션 Pool 생성 과정과 쿼리 커밋 과정을 날 것으로 볼 수 있다.
sequalize
모듈 코드를 보면, 다양한 DB에 연결하기 위한 공통된 비즈니스 로직을 어떻게 처리했는지 볼 수 있다.
이어서 typeorm
모듈 코드를 본다면, 데이터 마이그레이션을 위한 메타데이터를 어떻게 중앙에 모아 처리하는지 볼 수 있다.
TypeORM 데이터 리소스 이전 과정 (NestJS)
우선 먼저 TypeORM 에서 내가 어떤 개발 데이터베이스를 사용하는지 알아야 한다.
나는 sqlite
데이터베이스를 통해 개발 스키마를 검증하고 있는 상황이다.
그리고, 프로덕션 상황에서는 postgres
데이터베이스를 사용한다.
프로젝트 구조 상황
<rootDir>
/dist
<src 가 빌드된 JS 파일들..>
/src
main.ts # 어플 bootstrap 과정
data-source.ts # 중요!!!! typeorm 마이그레이션을 위해 따로 떼어놓은 파일
app.module.ts # 어플에 적용될 모든 모듈과 설정을 집약 해 놓은 파일
/users # 유저 도메인 처리
/...
/reports # 차량 보고서 도메인 처리
/...
/공통 모듈 # 인터셉터, 미들웨어, 공통 함수 등등...
package.json # 개발 및 그냥 의존성, jest 설정 등등
tsconfig.json # NestJS 개발을 위한 ts 설정들
app.module.ts
TypeOrmModule.forRoot({
type: 'sqlite',
database: process.env.NODE_ENV === 'test' ? 'test.sqlite' : 'db.sqlite',
entities: [User, Report],
synchronize: true,
}),
TypeOrmModule.forRoot(...)
를 통해,
NestJS 프로젝트에서 사용될 엔티티 매핑을 코드로 적용하고 있는 상황이다.
이는 따로 파일로 빼낸 상황은 아니며, 엔티티 매핑 또한 클래스로 적용하고 있는 상황이다.
프로덕션에 적용하지 않는 synchronize
가 true
였으니,
나는 development 와 test 환경에서, 즉시 마이그레이션이 적용되었다는 것을 알 수 있다.
data-source.ts
// app.module.ts 에, 따로 TypeOrmModule.forRoot 로 적용하기 위해 타입을 사용
import { DataSource, DataSourceOptions } from "typeorm";
// 현재 데이터 마이그레이션 하는 상황을 추출 "test" or "development" or "production"
const env = process.env.NODE_ENV;
/**
typeorm 명령어를 통한 데이터 마이그레이션 수행 시, ("development" or "production")
데이터 마이그레이션은 "빌드된 파일" 로부터 실행한다는 것이다.
이를 통해 갑자스럽게, 혹은 인가되지 않은 엔티티 파일이 실제 DB 서버에 추가되는 것을 방지 할 수 있다.
밑의 옵션들은, Nest 기본 시작에서는 사용하지 않으며, typeorm 명령어 마이그레이션 시 참조된다.
*/
const dbConfig = {
migrations: env === "test" ? ["src/migrations/*.ts"] : ["dist/migrations/*.js"],
cli: {
migrationsDir: env === "test" ? "src/migrations" : "dist/migrations"
},
} as Partial<DataSourceOptions>;
// DataSourceOptions 로 선언하면, 해당 객체에 대한 "모든" 옵션을 적어주어야 한다.
// 따라서, TS 의 문법인 Partial 을 이용하여, 입력하지 않은 옵션들도 존재하게 만든다. (꼼수 같긴 하다)
// typeorm CLI 와 NestJS 설정을 동시에 고려 한 옵션이다.
switch(env) {
case 'development':
Object.assign(dbConfig, {
type: 'sqlite',
database: 'db.sqlite',
// 개발 상황을 고려하여, "src/**/*.entity.ts" 로 해도 되지만,
// 나는 실제 개발 상황을 "빌드된 엔티티만" 인식 할 수 있도록 만든 것이다.
// 개발하면서, "start:dev" 시, 실시간으로 변경되는 엔티티 코드가 적용되지 않도록 만든 것이다.
entities: ["dist/**/*.entity.js"],
// 현재 "xx.entity.ts" 로 존재하는 파일들이 시작, 혹은 코드 변경 시 적용되지 않도록 설정.
synchronize : false,
})
break;
case 'test':
Object.assign(dbConfig, {
type: 'sqlite',
database: 'test.sqlite',
dropSchema : true,
entities: ["src/**/*.entity.ts"],
synchronize : true,
})
break;
case 'production':
Object.assign(dbConfig, {
type : 'postgres',
url: process.env.DATABASE_URL,
migrationsRun: true,
entities: ["dist/**/*.entity.js"],
})
break;
default:
throw new Error("Unknown Environment :: NOT('development' | 'test' | 'production')");
}
// 내보낼 DataSource 를 만들 때, 완전한 객체로서 내보낸다.
const dbSource = new DataSource(dbConfig as DataSourceOptions);
// NestJS 와 typeorm CLI 가 모두 인식 할 수 있도록 송출.
export default dbSource;
그리고, app.module.ts
에서는
.....
import dataSource from "./data-source"
@Module({
imports: [
...,
TypeOrmModule.forRoot(dbSource.options),
].
....
})
TypeOrmModule.forRoot 내부에 들어 갈 수 있는 Type 은,
DataSource
와는 다르다. 따라서, DataSource
클래스에서 .options
를 붙여주면,
모듈에 넣을 수 있는 타입으로 편하게 내보내 준다.
다시 복기하기
내가 위에서 Code 를 직접 적어놓은 이유는,
TypeORM 모듈을 app.module.ts
에서 직접 작성하여 사용하지만,
프로덕션에서의 데이터베이스 스키마 마이그레이션을 위해서 "따로 옵션을 빼야한다" 라는 것이다.
나는 따로 data-source.ts
라는 파일로 DataSource
객체를 송출했다.
이제, NestJS 프로젝트에서도, typeorm CLI 에서도 옵션을 인식하여,
각자 할 일을 할 수 있게 되었다.
변경점이 있다면, 나는 개발 상황에서 내가 변경한 엔티티 테이블 스키마가 곧바로 적용되지 않도록,
빌드된 파일만 매핑하게 만들었다.
그리고 Jest 테스팅 도구의 E2E 테스팅 환경은 .ts
이기도 하고,
딱히 빌드된 상황을 염두에 둘 필요가 없었다.
(만약에 Github Actions 에서 CI/CD 를 염두한다면, 빌드된 엔티티를 따로 적용하도록 바꿀 것 같긴 하다.)
TypeORM CLI 사용법
typeorm 라이브러리의 데코레이터를 이용하여 엔티티를 등록, 및 매핑하는 과정은
수많은 블로그들에서 다루는 것을 보았다.
그러나, 나는 최신화 버전의 typeorm 버전에서 마이그레이션 하는 과정을
정확히 담은 블로그를 거의 보지 못했다.
따라서, 나는 TypeORM CLI 를 이용하여 마이그레이션 하는 방법을 다룰 것이다.
현재 디렉토리 상황 예시
<rootDir>
/dist
<src 가 빌드된 JS 파일들..>
/src
main.ts
data-source.ts # 중요!!!! typeorm 마이그레이션을 위해 따로 떼어놓은 파일
app.module.ts
/users # 유저 도메인 처리
users.entity.ts # 테이블 엔티티 파일
...
/reports # 차량 보고서 도메인 처리
reports.entity.ts # 테이블 엔티티 파일
...
/공통 모듈 # 인터셉터, 미들웨어, 공통 함수 등등...
위와 같은 상황이라고 가정한다.
먼저 알아야 할 개념
TypeORM 에서는 로컬 머신에 typeorm
CLI 를 설치하는 방법과,
typeorm
NPM 라이브러리에 탑재된 CLI 를 다루는 2 가지 방법을 동시에 다루고 있다.
공식 사이트 자체에서 마이그레이션 하는 방법을 순서대로 다루고 있다고 보기에는 조금 어려울 수 있다.
내가 진행하는 상황은,
- NestJS 프로젝트를 생성했다. (node, npm, ts-node, 등등 이미 프로젝트 실행을 위한 프로그램이 있다는 가정)
- TypeORM 소스를 따로 파일로 빼 둔 상황이다. (위에서 빼 놓은 실제 파일을 참조)
- NestJS 프로젝트에
typeorm
,@nestjs/typeorm
,sqlite3
의존성이 설치 된 상황이다. - Linux, Mac, Windows 환경이 다름을 인지하여
cross-env
라이브러리로 환경 변수를 주입하는 상황이다.
나는, typeorm
마이그레이션을 위해서 실제로 NPM 전역 레포에 설치하는 것을 꺼리는 상황이다.
만약에 편하게 스크립트를 입력하고 싶다면, npm i -G typeorm
을 하면 된다.
그러나, npm 모듈에 이미 CLI 가 설치되어 있으므로, 나는 package.json
의 scripts
옵션을
활용 할 생각이다.
먼저, 타입스크립트로 작성된 엔티티 파일을 인식하기 위해서 이러한 스크립트를 package.json
에 추가한다.
"scripts": {
"..." : ".....",
"typeorm": "cross-env NODE_ENV=development typeorm-ts-node-commonjs",
},
typeorm-ts-node-commonjs
가 왜 스크립트에 들어가는지 의문 일 수 있는데,
node -r ts-node/register ./node_modules/typeorm/cli.js
스크립트를 요약하기 위해
typeorm
라이브러리 에서 마련 해 놓은 명령어이다.
나도 까먹었을 수도 있고, 정확성을 위해 이미 사용하고 있던 migrations 폴더와 소스를 모두 삭제하고 시작하겠다.
먼저, 프로젝트 내부에서 이러한 명령어를 실행하자 :
# 명령어
➜ npm run typeorm migration:create -- src/migrations/InitData
# 결과
> mycv@0.0.1 typeorm
> cross-env NODE_ENV=development typeorm-ts-node-commonjs migration:create src/migrations/InitData
Migration .../<rootDir>/src/migrations/1746806314329-InitData.ts has been generated successfully.
그 결과, 프로젝트 폴더는
<rootDir>
/dist
<src 가 빌드된 JS 파일들..>
/src
/... 다양한 폴더와 파일
/migrations # 방금 생성된 폴더
1746806314329-InitData.ts # 방금 생성된 파일
1746806314329-InitData.ts
== <현재 TimeStamp>-<입력한 파일 이름>.ts
import { MigrationInterface, QueryRunner } from "typeorm";
export class InitData1746806314329 implements MigrationInterface {
// 마이그레이션 시 적용
public async up(queryRunner: QueryRunner): Promise<void> {
}
// 마이그레이션 취소 시 적용
public async down(queryRunner: QueryRunner): Promise<void> {
}
}
migration:create
명령어로, 사용자가 직접 쿼리를 커스텀 할 수 있는 파일이 생성되었다.
커스텀의 목적으로 생성 할 수 있기도 하지만, 나는 디렉토리와 파일을 생성하기 위해 먼저 실행했다.
이 명령어는 현재 data-source.ts
의 영향을 받지 않고, 그냥 파일을 생성 한 것이다.
먼저, 다양한 의문이 들 것이다.
- 왜 명령어 script 에
--
가 들어갔는지, - 공식 홈페이지의 명령어를 입력해도 왜 제대로 실행되지 않는지
차례로 알아보자.
npm scripts 에 옵션을 보내기 위해 "--" 를 사용해야 한다.
일단, package.json
에 적힌 typeorm
을 보자 :
"scripts": {
"..." : ".....",
"typeorm": "cross-env NODE_ENV=development typeorm-ts-node-commonjs",
},
위에 적힌 typeorm
스크립트 덕분에, 전역 패키지로 typeorm
을 설치하지 않고도 CLI 를 사용 할 수 있다.
그런데, typeorm
전역 명령어와는 다른 점이 있다.
이걸 공식 홈페이지나 블로그에서 상세히 다루지 않아 굉장히 곤란했는데,
npm run typeorm migration:<원하는 명령어>
까지는 정상적으로 인식되지만,
만약에 --
없이 입력하면, 이후의 src/migrations/InitData
옵션을
npm 의 옵션으로 인식 해 버린다는 것이다.
만약에 내가 따로 입력한 스크립트의 CLI 에 특정 option 들을 주고 싶다면,
--
를 입력 한 뒤, 실행하면 된다. (이거 생각보다 진짜 중요하다)
만약에 공식 홈페이지나 특정 블로그에서 typeorm
마이그레이션 과정을 참조하고 싶다면,
위에서 언급 한 내용을 절대로 잊지 말자.
typeorm 마이그레이션 파일을 생성 해 보자.
현재 빌드된, 혹은 생성된 *.entity.ts
파일에 대한 마이그레이션 파일을 생성하기 위해서는,
다음과 같은 명령어를 입력하면 된다.
# npm run typeorm migration:generate -- <마이그레이션 파일이 생성될 디렉토리/파일이름> -d <빼 놓은 DataSource 파일>
➜ npm run typeorm migration:generate -- ./src/migrations/InitData -d ./src/data-source.ts
> mycv@0.0.1 typeorm
> cross-env NODE_ENV=development typeorm-ts-node-commonjs migration:generate ./src/migrations/InitData -d ./src/data-source.ts
development
Migration ...../<rootDir>/src/migrations/1746808169533-InitData.ts has been generated successfully.
그리고 생성된 1746808169533-InitData.ts
을 보면,
import { MigrationInterface, QueryRunner } from "typeorm";
export class InitData1746808169533 implements MigrationInterface {
name = 'InitData1746808169533'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "reports" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "approved" boolean NOT NULL DEFAULT (0), "price" integer NOT NULL, "make" varchar NOT NULL, "model" varchar NOT NULL, "year" integer NOT NULL, "lng" float NOT NULL, "lat" float NOT NULL, "mileage" integer NOT NULL, "userId" integer)`);
await queryRunner.query(`CREATE TABLE "users" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "password" varchar NOT NULL, "isAdmin" boolean NOT NULL DEFAULT (1))`);
await queryRunner.query(`CREATE TABLE "temporary_reports" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "approved" boolean NOT NULL DEFAULT (0), "price" integer NOT NULL, "make" varchar NOT NULL, "model" varchar NOT NULL, "year" integer NOT NULL, "lng" float NOT NULL, "lat" float NOT NULL, "mileage" integer NOT NULL, "userId" integer, CONSTRAINT "FK_bed415cd29716cd707e9cb3c09c" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`);
await queryRunner.query(`INSERT INTO "temporary_reports"("id", "approved", "price", "make", "model", "year", "lng", "lat", "mileage", "userId") SELECT "id", "approved", "price", "make", "model", "year", "lng", "lat", "mileage", "userId" FROM "reports"`);
await queryRunner.query(`DROP TABLE "reports"`);
await queryRunner.query(`ALTER TABLE "temporary_reports" RENAME TO "reports"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "reports" RENAME TO "temporary_reports"`);
await queryRunner.query(`CREATE TABLE "reports" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "approved" boolean NOT NULL DEFAULT (0), "price" integer NOT NULL, "make" varchar NOT NULL, "model" varchar NOT NULL, "year" integer NOT NULL, "lng" float NOT NULL, "lat" float NOT NULL, "mileage" integer NOT NULL, "userId" integer)`);
await queryRunner.query(`INSERT INTO "reports"("id", "approved", "price", "make", "model", "year", "lng", "lat", "mileage", "userId") SELECT "id", "approved", "price", "make", "model", "year", "lng", "lat", "mileage", "userId" FROM "temporary_reports"`);
await queryRunner.query(`DROP TABLE "temporary_reports"`);
await queryRunner.query(`DROP TABLE "users"`);
await queryRunner.query(`DROP TABLE "reports"`);
}
}
그리고 migrations
폴더를 보면,
각자의 타임스탬프에 따라 두 개의 <timestamp>-InitData.ts
파일이 존재한다.
이를 통해 순서도 파악 할 수 있고, 나중에 되돌리고 싶을 때 revert
할 수 있다.
즉, 우리는 현재 오류가 났을 때, 혹은 에러가 났을 때 되돌릴 수 있는 기록을 성공적으로 생성 한 것이다.
typeorm 을 이용하여 데이터베이스를 마이그레이션 하자.
이제 우리는 synchronize
옵션을 true
하지 않고,
데이터베이스에 변경된 사항을 적용 할 수 있다.
생성된 <timestamp>-InitData.ts
파일로 마이그레이션 하고 싶다면,
실제 main.ts
와 같은 파일에서,
import dataSource from './data-source';
async function bootstrap() {
// 데이터 소스 초기화 - 커넥션 생성
await dataSource.initialize();
// 미적용 마이그레이션 실행
await dataSource.runMigrations();
// 애플리케이션 시작
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();
이러한 방식으로 적용 할 수 있기도 하다.
그러나, CLI 로 쉽게 적용 할 수 있는데,
바로 typeorm migration:run
명령어를 사용하는 것이다.
이전까지 우리는 현재까지 데이터베이스에 적용되어야 하는 마이그레이션 파일을 생성했다.
이는 기록으로서, 위의 코드처럼 적용 할 수 있기도 하고, 명령어로 revert
(되돌리기) 할 수 있다.
그러나, run
옵션을 통해, 방금 기록한 마이그레이션 기록 그대로, 옵션을 그대로 가져와서
직빵으로 데이터베이스에 변경 사항을 적용 할 수 있다.
그런데, 내가 여기서 잘못 적용한 옵션이 있다!!!!!! (참조 필수)
내가 data-source.ts
에서 DataSource
객체를 만들 때,
맨 처음 부분에 잘못 적용한 부분이 있다.
따라서, 이처럼 바꿔주면 된다. dist/migrations/*.js
--> dist/src/migrations/*.js
const dbConfig = {
migrations: env === "test" ? ["src/migrations/*.ts"] : ["dist/src/migrations/*.js"],
cli: {
migrationsDir: env === "test" ? "src/migrations" : "dist/migrations"
},
} as Partial<DataSourceOptions>;
// ...
export default dataSource;
그리고, 이러한 명령어를 입력하자.
# 명령어
➜ npm run typeorm migration:run -- -d src/data-source.ts
# 결과물
> mycv@0.0.1 typeorm
> cross-env NODE_ENV=development typeorm-ts-node-commonjs migration:run -d src/data-source.ts
development
query: SELECT * FROM "sqlite_master" WHERE "type" = 'table' AND "name" = 'migrations'
query: SELECT * FROM "migrations" "migrations" ORDER BY "id" DESC
0 migrations are already loaded in the database.
2 migrations were found in the source code.
2 migrations are new migrations must be executed.
query: PRAGMA foreign_keys = OFF
query: BEGIN TRANSACTION
query: INSERT INTO "migrations"("timestamp", "name") VALUES (1746806314329, ?) -- PARAMETERS: ["InitData1746806314329"]
Migration InitData1746806314329 has been executed successfully.
query: CREATE TABLE "reports" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "approved" boolean NOT NULL DEFAULT (0), "price" integer NOT NULL, "make" varchar NOT NULL, "model" varchar NOT NULL, "year" integer NOT NULL, "lng" float NOT NULL, "lat" float NOT NULL, "mileage" integer NOT NULL, "userId" integer)
query: CREATE TABLE "users" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "password" varchar NOT NULL, "isAdmin" boolean NOT NULL DEFAULT (1))
query: CREATE TABLE "temporary_reports" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "approved" boolean NOT NULL DEFAULT (0), "price" integer NOT NULL, "make" varchar NOT NULL, "model" varchar NOT NULL, "year" integer NOT NULL, "lng" float NOT NULL, "lat" float NOT NULL, "mileage" integer NOT NULL, "userId" integer, CONSTRAINT "FK_bed415cd29716cd707e9cb3c09c" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)
query: INSERT INTO "temporary_reports"("id", "approved", "price", "make", "model", "year", "lng", "lat", "mileage", "userId") SELECT "id", "approved", "price", "make", "model", "year", "lng", "lat", "mileage", "userId" FROM "reports"
query: DROP TABLE "reports"
query: ALTER TABLE "temporary_reports" RENAME TO "reports"
query: INSERT INTO "migrations"("timestamp", "name") VALUES (1746808169533, ?) -- PARAMETERS: ["InitData1746808169533"]
Migration InitData1746808169533 has been executed successfully.
query: COMMIT
query: PRAGMA foreign_keys = ON
드디어, 데이터베이스 스키마를 적용했다!!
Result :
# 데이터베이스 실행
➜ sqlite3 db.sqlite
SQLite version 3.43.2 2023-10-10 13:08:14
Enter ".help" for usage hints.
# 테이블 "reports", "users" 있는지 확인
sqlite> .table
# 정상적으로 2 개의 테이블이 생성됨
migrations reports users
요약
1. TypeORM 은 어플 호환성이 높은, "독립적인" DB 마이그레이션 도구이다
나는 위의 문구를 꼭 기억해야 한다고 생각한다.
물론, TypeOrmModule.forRoot
, ...forRootAsync
, ... 등등,
아예 NestJS 의 모듈에서 "내부 코드로 등록하는" 간편한 절차가 존재한다.
그러나, 실제 데이터베이스 마이그레이션 과정을 거치기 위해서는, DataSource
객체를 따로 관리해야 한다.
따로 관리하게 된다면, 이는 NestJS 어플리케이션에서 옵션을 불러 올 수도 있고,
또한 이 글을 읽고 있는 독자가 typeorm
전역 명령어를 사용하던,
혹은 npm typeorm
모듈에 포함된 CLI 를 사용하던, 2 가지 방식을 모두 채택 할 수 있기 때문이다.
참고로, 버전이 최신화 되어서 그런지,
루트 프로젝트에 직접 orm config 파일을 작성한 뒤,app.module.ts
에서 단순히TypeOrmModule.forRoot()
형식으로는 불러 올 수 없었다. 이는 내가src
폴더에 DataSource 파일을 작성 한 이유이기도 하다.
TypeORM 은, NestJS 에서의 비즈니스 로직 편의성과, 독립적인 DB 마이그레이션 도구라는 것을 잊지 않았으면 좋겠다 (진심) - 혹시 더 전문적인 분이 있다면, 댓글 달아주세요. 배우고 싶어용
2. TypeORM 은 메타데이터(데코레이터)를 적극 활용한다
ORM 을 처음 접하거나, 코드를 통해 테이블 스키마와 연결을 구성하는 개념이 처음이라면,
TypeORM 에서 사용하는 데코레이터의 의미를 깊이 생각하지 않고 넘길 수 있다.
그러나, TypeORM 은 자기 자신을 참조할 수 있는 특정 객체를 넘겨주지 않는다.
즉, 프로젝트 루트 하위에 존재하는 파일 중, @Entity()
, @Column()
등등,
TypeORM 의 전체 메타데이터를 구성하고 이에 대한 정보를 데이터베이스에 넘겨 주기 전에,
위의 데코레이터와 같은 메타데이터를 먼저 추출 한 뒤, 정해진 흐름에 따라 데이터베이스 스키마를 결정한다.
단점이라면, 다양한 종류의 데코레이터를 알고 있지 않다면, TypeORM 자체의 흐름을 익히기 어려울 수 있다.
3. TypeORM 을 단순 토이 프로젝트용이 아니라, 프로덕션으로 올려야 한다면, 결국 CLI 가 필요하다
NestJS 에서, 원활한 호환을 위해 @nestjs/typeorm
이 있다는 것이 매우 좋으나,
결국 typeorm
라이브러리의 정석적인 마이그레이션을 수행하기 위해서는 명령어가 필요하다는 것이다.
내가 typeorm
을 npm 전역 패키지로 설치하지 않은 이유는, 물론 저장 공간을 낭비하지 않음도 있지만,
실제 프로덕션이 올라가 있는 서버에 굳이 typeorm
패키지를 설치하지 않고도,
node_modules
에 존재하는 typeorm
CLI 를 활용 할 수 있기 때문이다.
우리가 사용 했던 명령어들은 다음과 같았다는 것을 기억하며 된다.
npm run typeorm migration:create -- src/migrations/InitData
- 사용 할 마이그레이션 폴더 생성 및, 사용자가 커스텀 할 수 있는 migration 파일 생성
npm run typeorm migration:generate -- src/migrations/FirstSchema -d src/data-source.ts
- 따로 빼 놓은
DataSource
객체 파일을 참조하여migrations
폴더에<timestamp>-FirstSchema.ts
파일 생성
- 따로 빼 놓은
npm run typeorm migration:run -- -d src/data-source.ts
- 현재 정의되어 있는
DataSource
객체 파일을 참조하여, DB 마이그레이션 진행
- 현재 정의되어 있는
아직 부족 한 점이 많은 블로거입니다.
글에 틀린 부분이 있다면, 댓글로 남겨주시면 감사하겠습니다
참고 사이트
위키피디아 (Object-Relational Mapping)
https://en.wikipedia.org/wiki/Object%E2%80%93relational_mapping
TypeORM 공식 사이트
mysqljs/mysql 깃허브 코드 레포
https://github.com/mysqljs/mysql/blob/master/lib/Connection.js
'잡다 지식' 카테고리의 다른 글
Domain 과 SSL 에 대하여 (부제 : https 는 뭘까?) (0) | 2025.05.19 |
---|---|
브라우저의 동작 과정과 기능들 (0) | 2025.05.15 |
PostgreSQL 데이터베이스는 무엇일까? (2) | 2025.05.08 |
SQLite 데이터베이스는 무엇일까? (0) | 2025.04.01 |
IntelliJ IDEA - TypeScript 전역 모듈 타입 선택하기 (타입 에러 해결) (0) | 2025.03.31 |