Search

16. 운영 환경 배포

이번 강에서는 NestJS 애플리케이션을 운영 환경에 배포하는 과정에 대해 이야기해보려고 한다.
사실 기능 개발이 끝났다고 해서 바로 배포할 수 있는 건 아니다.
운영 환경에 애플리케이션을 배포하는 과정은 생각보다 쉽지 않다.
NestJS 공식 문서에서도 배포에 대한 권장 사항을 제시하고 있지만, 현실에서는 문서를 그대로 따라 했는데도 제대로 작동하지 않는 경우가 많다.
그래서 이번 포스팅에서는 실제로 어떤 과정을 거쳐야 하는지, 하나하나 살펴보도록 하자.

개발 환경과 운영 환경의 차이

먼저 개발 환경과 운영 환경의 차이에 대해 알아보자. 개발 및 테스트 환경에서는 SQLite 데이터베이스를 사용하고 있다. SQLite를 선택한 이유는 별도의 소프트웨어를 설치할 필요가 없어, 시작이 매우 간편했기 때문이다. 하지만 운영 환경에서는 이야기가 다르다. 운영 환경에서는 PostgreSQL 데이터베이스를 사용하여 보다 안정적이고 확장성 있는 서비스를 구축하려고 한다.
운영 환경에 배포하기 위해선 몇 가지 중요한 작업을 진행해야 한다.
1.
데이터베이스 전환
기존 SQLite 대신 PostgreSQL 데이터베이스를 사용하도록 변경한다.
이를 위해 데이터베이스 연결 설정을 수정해야 한다.
2.
환경 변수 설정
개발과 테스트 환경에서도 데이터베이스 접속 정보를 환경 변수로 관리했었다.
마찬가지로 운영 환경에서도 필요한 정보를 환경 변수로 등록해야 한다.
3.
쿠키 키 환경 변수화
애플리케이션에서는 cookie-session을 사용해 세션을 관리하고 있다.
현재는 프로젝트 내부에 평문으로 쿠키 키가 저장되어 있는데, 이는 보안상 매우 위험하다.
따라서 쿠키 키도 환경 변수로 분리하고, 개발/테스트/운영 환경별로 각각 설정해줘야 한다.

쿠키 키 환경 변수화

먼저 쿠키 키를 환경 변수로 분리하는 작업부터 진행해보자.
현재 app.module.ts 파일을 보면 cookie-session 미들웨어 설정 부분이 있다. 여기서 keys 속성에 하드코딩된 문자열을 사용해 쿠키를 암호화하고 있다.
export class AppModule { configure(consumer: MiddlewareConsumer) { consumer .apply( cookieSession({ keys: ['asdfasdf'], // 문제가 되는 부분 }), ) .forRoutes('*'); } }
TypeScript
복사
하지만 이 방식에는 심각한 문제가 있다.
만약 이 문자열이 악의적인 사용자에게 노출되면, 쿠키를 조작해서 다른 사람인 것처럼 가장할 수 있다.
또한, 하드코딩된 키가 Github 같은 곳에 실수로 올라갈 위험도 있다.
따라서 쿠키 키를 코드에 직접 작성하지 않고, 환경 변수로 관리해야 한다.

환경 변수 파일 만들기

우선 개발 환경과 테스트 환경에서 사용할 .env 파일에 쿠키 키를 추가한다.
1.
.env.development
COOKIE_KEY=개발환경용_임의의_문자열
Plain Text
복사
2.
.env.test
COOKIE_KEY=테스트환경용_임의의_문자열
Plain Text
복사

AppModule 수정하기

쿠키 키를 환경 변수에서 읽어오기 위해, AppModuleConfigService를 주입하고 사용한다.
export class AppModule { constructor(private configService: ConfigService) {} configure(consumer: MiddlewareConsumer) { consumer .apply( cookieSession({ keys: [this.configService.get('COOKIE_KEY')], }), ) .forRoutes('*'); } }
TypeScript
복사
변경 사항을 저장한 후 터미널에서 애플리케이션을 실행해 보면, 아무런 오류 없이 정상 작동하는 것을 확인할 수 있다.

데이터베이스 전환

데이터베이스를 전환하기에 앞서 프로젝트 구조를 복습해보자. app.module.ts 파일을 보면, TypeOrmModule 설정 부분에서 데이터베이스 연결 구성을 반환하는 로직을 확인할 수 있다. 그중에서도 synchronize 속성이 true로 설정되어 있는데, 이 속성은 정말 매우 중요한 의미를 가진다. 얼마나 중요한지 아무리 강조해도 부족할 정도이다.

synchronize 옵션이란?

synchronize의 역할을 이해하기 위해 다이어그램 형태로 정리해보자.
만약 synchronize: true 설정이 되어 있으면, 애플리케이션이 시작할 때 TypeORM이 다음 작업을 수행한다.
1.
엔터티(User 클래스)를 읽고, 필드들과 타입들을 확인한다.
2.
데이터베이스의 테이블 구조를 검사해, 현재 엔터티 구조와 비교한다.
3.
차이가 있을 경우, 데이터베이스를 엔터티 구조에 맞춰 수정한다.
예를 들어
User 엔터티에서 password 필드를 삭제한 뒤 서버를 재시작하면 데이터베이스에서도 password 열이 삭제된다. (데이터까지)
새로운 name 필드를 추가하면 데이터베이스에도 name 열이 추가된다.
즉, 애플리케이션 코드의 엔터티 구조를 기준으로 데이터베이스를 자동 수정하는 기능이 synchronize이다.

synchronize의 위험성

synchronize는 매우 편리한 기능이지만, 동시에 매우 위험할 수 있다.
따라서, 실제 운영 환경에서는 절대 사용해서는 안 되는 설정이다.
만약 코드를 수정하다가 사용자 엔터티의 password 속성을 실수로 삭제했다고 가정해보자. 이 코드가 운영 서버에 배포된다면 TypeORM이 운영 데이터베이스의 password 열을 자동으로 삭제해버릴 것이다. 당연히 우리가 절대 원하지 않는 끔찍한 상황이다.
개발 환경에서는 이 synchronize 옵션을 사용해도 된다. 엔터티를 수정하면 즉시 데이터베이스 구조가 업데이트되어, 개발 생산성이 올라간다. 하지만 운영 환경에서는 절대 사용하지 말아야 한다.

데이터베이스 구조를 변경하고 싶을 때는?

그렇다면, 운영 환경에서 데이터베이스에 열을 추가하거나, 삭제하거나, 이름을 바꾸고 싶을 때는 어떻게 해야 할까? 이때 필요한 것이 바로 마이그레이션(Migration)이다.

마이그레이션(Migration)이란?

마이그레이션은 데이터베이스 구조를 변경할 때 사용하는 일종의 업데이트 스크립트이다. 단순히 테이블을 추가하거나 삭제하는 것을 넘어, 모든 데이터베이스 변경 과정을 체계적으로 기록하고 관리할 수 있다.
마이그레이션 파일에는 기본적으로 두 개의 함수가 정의되어 있다.
함수 이름
역할
up()
데이터베이스를 업데이트하는 작업을 정의
down()
up()에서 했던 작업을 되돌리는 작업을 정의
up 함수에서는 테이블 생성, 열 추가, 열 이름 변경, 인덱스 추가 등 데이터베이스를 어떻게 수정할지 단계별로 작성한다.
반대로 down 함수에서는 테이블 삭제, 열 삭제 등 업데이트에 문제가 생겼을 때 쉽게 롤백할 수 있도록 만들어 놓는다.

마이그레이션을 연속으로 실행하는 방법

데이터베이스가 커질수록 하나의 마이그레이션 파일로 모든 변경사항을 관리하기 어렵다.
그래서 여러 개의 마이그레이션 파일을 순서대로 실행한다.

마이그레이션은 어떻게 진행될까?

이번에는 마이그레이션을 실제로 활용할 때 겪게 되는 어려움과 그 이유를 솔직하게 이야기해보려고 한다.
아마 다소 실망스러울 수도 있다. 지금까지는 NestJS와 TypeORM이 잘 연동되는 것처럼 보였지만, 마이그레이션 작업에 들어가면 이야기가 조금 달라진다.
데이터베이스 구조를 배포 이후에도 안전하게 변경하려면 다음과 같은 과정이 필요하다.
1.
개발 서버 중단
2.
TypeORM CLI를 사용해 빈 마이그레이션 파일 생성
3.
생성된 파일의 up() 함수와 down() 함수에 데이터베이스 변경 작업 정의
4.
TypeORM CLI로 마이그레이션 실행
5.
다시 서버 재시작
정리하면, 마이그레이션 파일을 작성하고 CLI로 실행하여 데이터베이스를 업데이트하는 식이다.
이 과정은 개발 환경, 테스트 환경, 운영 환경 모두에서 제대로 작동해야 한다.
그런데 왜 어려울까?
가장 큰 문제는 TypeORM CLI는 NestJS를 모른다는 점이다.
우리 애플리케이션은 보통 AppModule에서 다음처럼 환경에 따라 .env 파일을 읽어 개발용 DB, 테스트용 DB를 다르게 연결하도록 했다.
TypeOrmModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService) => { return { type: 'sqlite', database: config.get<string>('DB_NAME'), synchronize: true, entities: [User, Report], }; }, }),
TypeScript
복사
문제는 TypeORM CLI는 NestJS 모듈(AppModule)을 로드하지 않기 때문에 CLI 입장에서 보면 데이터베이스에 어떻게 연결해야 할지 알 수 가 없다.
그러면 NestJS에서 사용한 설정 객체를 복사해서 TypeORM CLI 전용으로 따로 만들어주면 해결할 수 있을 것처럼 보인다. 하지만 이 방법은 여러 문제를 일으킬 수 있다.
1.
설정이 변경할 때마다 양쪽 모두 수정해야 한다.
2.
환경별 설정(개발/운영/테스트)이 꼬이기 쉽다.
3.
유지보수가 어렵다.
따라서 공통 설정 파일을 만들어, NestJS와 TypeORM CLI 양쪽에서 모두 공유하는 구조로 만드는 것이 가장 좋은 방법이다.

TypeORM 설정 분리하기

설정을 분리해두면 개발 환경, 테스트 환경, 그리고 마이그레이션 작업 시에도 TypeORM CLI만 실행하여 필요한 설정을 쉽게 불러올 수 있다.
우선 app.module.ts 파일을 열어 기존에 TypeOrmModule.forRoot({...}) 형태로 직접 작성했던 데이터베이스 설정 부분을 전부 삭제한다. 그리고 다음과 같이 수정해서 설정을 외부에서 가져오게 된다.
const typeOrmConfig = require('../ormconfig'); TypeOrmModule.forRoot(typeOrmConfig)
TypeScript
복사
ormconfig.js 파일은 반드시 프로젝트 루트 디렉터리에 생성해야 한다. src 폴더 안에 만들면 TypeORM CLI가 인식하지 못하니 주의하자. ormcofnig.js 파일을 생성해, 다음과 같이 기본 구조를 작성한다.
const dbConfig = { synchronize: false, }; switch (process.env.NODE_ENV) { case 'development': Object.assign(dbConfig, { type: 'sqlite', database: 'db.sqlite', entities: ['**/*.entity.js'], }); break; case 'test': Object.assign(dbConfig, { type: 'sqlite', database: 'test.sqlite', entities: ['**/*.entity.ts'], }); break; case 'production': // production 설정 추가 break; default: throw new Error('unknown environment'); } module.exports = dbConfig;
TypeScript
복사
process.env.NODE_ENV 값에 따라 개발, 테스트, 운영 환경을 구분했다.
개발 환경에서는 빌드된 JavaScript 파일을 사용하기 때문에 **/*.entity.js 패턴을 사용한다.
테스트 환경에서는 보통 TypeScript 원본 파일을 기준으로 수행하기 때문에 **/*.entity.ts 패턴을 사용한다.
운영 환경은 추후에 다시 작성한다.
모든 환경에서 synchronize: false로 설정하여 애플리케이션이 실행될 때 DB 구조를 자동으로 변경하지 않도록 한다.