추가적으로 Nest 공식문서를 더 살펴보면 nest start의 --watch 옵션은 Watch 모드로 실행을 해서 Live Reload를 해주는 옵션이라고 합니다. 그래서 로컬에서 npm run start:dev로 실행을 한 후 코드에 변경이 생기면 자동으로 변경을 적용해 주는 것이 이 옵션 덕분입니다.
2. node dist/main
NestJS는 javascript로 컴파일되어 Node.js 환경에서 실행이 됩니다. npm run build를 실행하면 nest build 명령어가 실행되며 NestJS 프로젝트가 빌드됩니다. 이때 루트 경로에 dist라는 폴더를 만들게 됩니다. 빌드되고 번들링 된 파일들이 이 dist 폴더 안에 저장됩니다.
프로젝트는 보통 main이라는 곳을 시작점으로 해서 Application을 실행하게 됩니다. Nest JS도 이 main.js에서부터 Application이 실행됩니다. node dist/main은 빌드된 파일 중 Application을 시작하기 위한 main.js를 실행하는 명령어입니다.
즉, node dist/main을 사용하기 위해서는 nest build와 같은 빌드 명령어가 선행되어야 합니다.
3. 결론
코드 변경을 감지하고, Live Reload를 통해서 개발 효율을 높이기 위해서 nest start --watch를 사용할 수 있습니다. 코드 변경에 대해서 직접 build를 하고, 다시 시작하면서 코드를 테스트하지 않아도 됩니다. 그래서 start:dev에서는 nest start --watch를 사용합니다.
운영 환경에서는 Live Reload가 필요하지 않습니다. 그리고 직접 build를 하는 경우가 대부분이기 때문에 빌드된 파일인 dist/main을 실행만 해주면 됩니다. 그렇기 때문에 start:prod에서는 node dist/main을 사용합니다.
start:prod에서도 nest start를 사용한다면 프로젝트 실행을 할 수는 있겠지만, build 과정을 두 번 거치는 경우가 발생할 수 있습니다. 보통 서버에 배포해서 build를 진행하고, build에 실패하면 Application을 실행하면 안 됩니다. 그렇기 때문에 의도에 맞게 적절하게 사용해야 할 것 같습니다.
Sid가 있는 객체 하나하나를 Statemen라고 합니다. Sid는 해당 Statement를 식별하는 id 값입니다. 여러 개의 Statement를 입력해서 목적에 맞게 정책을 여러 개 추가할 수 있습니다.
먼저 AllowToAllBuckets는 모든 버킷에 접근할 수 있도록 허용한 것이고, AllowToAllObjects는 객체 리스트에 접근할 수 있고, AllowToGetPutDeleteObjects는 객체 조회, 수정, 삭제에 대한 정책입니다.
Resource에는 해당 Statement가 적용되는 대상을 적어줘야 합니다. "*"는 모든 대상을 의미하고, "arn:aws:s3:::my-bucket-name"은 제가 이번 포스트에서 생성한 "my-bucket-name"이라는 이름을 갖는 버킷에 대해서만 적용되는 정책입니다.
이렇게 정책 생성을 완료하고, 다시 IAM 생성 과정으로 넘어가서 만들었던 정책을 검색해서 선택한 후 내가 만들고 있는 IAM 사용자에게 내가 만든 정책을 적용시킵니다.
IAM 사용자 검토, 정책 등록 확인
이제 다음으로 넘어가면 최종적으로 지금까지 만들었던 IAM 사용자의 내용을 검토할 수 있습니다. 여기서 내가 생성해서 적용해 준 정책이 정상적으로 등록되었는지 확인합니다. 검토가 완료되었다면 "사용자 생성"을 통해서 IAM 사용자 생성을 완료합니다.
3. Access Key 발급
이제 IAM 사용자를 이용해서 Nest 프로젝트에 적용하기 위해서 필요한 Access Key를 발급해야 합니다.
IAM > 사용자 > 보안 자격 증명
이제 생성한 IAM 사용자를 누르고 들어가서 밑에 "보안 자격 증명"이라는 버튼을 클릭합니다. 그리고 쭉 밑으로 내려봅니다.
엑세스 키 만들기
밑에 내려보면 "액세스 키 만들기" 버튼이 있습니다. 눌러서 엑세스 키를 만들어줍니다.
사용 사례
사용하시는 목적에 맞게 선택해 주시면 됩니다. 저는 CLI로 선택을 하고 넘어가겠습니다.
설명 태그 설정
선택 사항이기 때문에 작성하든 말든 상관없습니다. 저는 간단하게 "aws-s3-access-key"라고 적은 후 "액세스 키 만들기" 버튼을 눌러 생성을 완료하겠습니다. 생성 완료 후 "Access key ID"와 "Secret access key"를 확인할 수 있는데, CSV 파일을 저장해서 보관하시기 바랍니다. 나중에 Nest 프로젝트에 연결하기 위해서 필요한 정보입니다. 해당 정보는 노출이 절대 절대 안 되게 잘 관리해 주시길 바랍니다. Github에도 올라가지 않도록요!
4. Nest 프로젝트에 적용
이제 Nest 프로젝트에 적용하기 위해서 필요한 라이브러리를 설치해 줍니다.
npm install multer-s3 @aws-sdk/client-s3
.env 파일에 S3 관련 설정 정보를 입력합니다. (dotenv가 설치되어있다는 가정하에 진행합니다.)
# AWS S3 Properties
AWS_S3_BUCKET_NAME=my-bucket-name
AWS_REGION=ap-northeast-2
AWS_ACCESS_KEY_ID={엑세스 토큰 발급 시 받은 ACCESS KEY ID)
AWS_SECRET_ACCESS_KEY={엑세스 토큰 발급 시 받은 SECRET ACCESS KEY}
위에서 AWS_ACCESS_KEY_ID와 AWS_SECRET_ACCESS_KEY의 이름은 반드시 동일하게 작성해 주세요.
이제 Nest 프로젝트에서 S3의 이미지 저장 로직을 처리할 코드를 작성합니다. 저는 해당 이미지 저장 로직을 여러 곳에서 사용할 것이기 때문에 core 패키지의 utils에 만들도록 하겠습니다.
import { BadRequestException, Injectable } from "@nestjs/common";
import * as AWS from "aws-sdk";
import { PromiseResult } from 'aws-sdk/lib/request';
import * as path from "path";
@Injectable()
export class S3Service {
private readonly s3: AWS.S3;
private readonly MAXIMUM_IMAGE_SIZE: number;
private readonly ACCEPTABLE_MIME_TYPES: string[];
public readonly S3_BUCKET_NAME: string;
constructor() {
this.s3 = new AWS.S3({
region: process.env.AWS_REGION
})
this.MAXIMUM_IMAGE_SIZE = 3000000; // 이미지 용량 3MB 제한
this.ACCEPTABLE_MIME_TYPES = ['image/jpg', 'image/png', 'image/jpeg']; // 이미지 확장자 제한
this.S3_BUCKET_NAME = process.env.AWS_S3_BUCKET_NAME
}
async uploadToS3(file: Express.Multer.File): Promise<S3UploadResponse> {
try {
if (!this.ACCEPTABLE_MIME_TYPES.includes(file.mimetype)) {
throw new BadRequestException('이미지 파일 확장자는 jpg, png, jpeg만 가능합니다.');
}
if (file.size > this.MAXIMUM_IMAGE_SIZE) {
throw new BadRequestException('업로드 가능한 이미지 최대 용량은 3MB입니다.');
}
const key = `images/${Date.now()}_${path.basename(file.originalname,)}`
.replace(/ /g, ''); // 공백을 제거하기 위한 regex입니다.
const s3Object = await this.s3
.putObject({
Bucket: this.S3_BUCKET_NAME,
Key: key,
Body: file.buffer,
ContentType: file.mimetype
})
.promise();
const imgUrl = `https://${this.S3_BUCKET_NAME}.s3.amazonaws.com/${key}`;
return {
key: key,
s3Object: s3Object,
contentType: file.mimetype,
url: imgUrl
}
} catch (error) {
throw error;
}
}
}
export type S3UploadResponse = {
key: string;
s3Object: PromiseResult<AWS.S3.PutObjectOutput, AWS.AWSError>;
contentType: string;
url: string;
}
S3에 이미지를 업로드하는데 필요한 정보들을 S3Service에서 처리하고, 이미지를 업로드하는 메서드인 uploadToS3를 구현했습니다.
업로드 가능한 이미지의 최대 용량은 3MB로 제한을 두고, 이미지 확장자는 png, jpg, jpge로 제한을 두었습니다.
위에서 보면 AWS_ACCESS_KEY_ID와 AWS_SECRET_ACCESS_KEY에 대한 설정을 해주지 않는 것을 볼 수 있습니다. 실제로 accessKeyId라고 코드를 쳐보면 deprecated 되었다고 합니다. env 파일에 AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY라는 이름으로 만들어 두면, S3에 요청할 때 알아서 확인을 한다고 합니다. (싱기방기)
import { Module } from '@nestjs/common';
import { S3Service } from './utils/s3.service';
@Module({
providers: [S3Service],
exports: [S3Service]
})
export class CoreModule {}
이제 S3Service를 CoreModule의 providers와 exports에 등록을 하고, 필요한 곳에서 CoreModule을 import 받아 사용할 수 있습니다. CoreModule을 @Global()로 만들어도 되지만, 일단은 직접 import로 주입하는 방법을 이용했습니다.
위 명령어를 터미널에 쳐보면 현재 설정되어 있는 힙 메모리를 확인할 수 있습니다. 확인해 보니 저는 495라고 떴습니다.
로컬에서는 동작이 됐으니 로컬에서 확인한 값과 동일한 수준까지 올려주기로 했습니다.
export NODE_OPTIONS=--max_old_space_size=2096
2096까지 올려서 다시 첫 번째 명령어로 495에서 2096으로 변경된 것을 확인할 수 있습니다.
이렇게 하고 수동으로 서버에 작성해뒀던 ./deploy.sh을 실행해서 빌드 성공 후 배포에도 성공을 했습니다.
3. 터미널을 재시작하면 설정이 초기화되는 문제
됐겠지 하고 작업 후에 다시 자동화 배포를 하는 과정에서 빌드 시 동일한 문제가 발생했습니다.
찾아보니 터미널에서 설정해줬던 "max_old_space_size=2096"이 터미널을 재시작하면 다시 초기화된다고 합니다.
그래서 프로젝트 규모가 커지면서 점점 더 많은 힙 메모리를 요구하게 되었지만, 기본적으로 설정된 힙 메모리는 512MB입니다.
Github Actions에서 SSH를 통해 서버에 접근하여./deploy.sh를 실행하도록 설정해뒀습니다.
배포 과정을 진행할 때마다 터미널에 새로 접근을 해서 2096MB이 아닌 512MB로 적용되어서 동일하게 빌드가 실패했습니다.
영구적으로 바꾸는 방법이 존재한다고는 하지만, 세상 일 어떻게 될지 모르니 일단 배포 스크립트인 deploy.sh에 들어가서 프로젝트 빌드를 시작하기 전에 "export NODE_OPTIONS=--max_old_space_size=2096" 명령어를 실행하도록 수정해서 빌드, 배포에 성공했습니다.
근본적인 문제 해결은 메모리 누수를 확인해서 메모리가 누수되는 부분을 분석하여 찾아내야 한다고 합니다. 하지만, 지금은 메모리 누수를 확인할 만큼의 프로젝트 규모가 되지 않았다고 생각을 해서 일단은 NodeJS의 힙 메모리 할당량을 늘리자는 판단을 했습니다.
로컬에서도 빌드되지 않을 정도가 된다면 메모리 누수 방법과 해결 방법을 알아두면 좋을 거 같아서 이 부분도 찾아서 공부해 볼 생각입니다. 미리미리 알아두면 언젠간 도움이 됩니다! 미리 대비합시다.
Nest로 진행하고 있던 프로젝트가 있었습니다. InternalServerError(500)가 터지면 디버깅을 하거나, 오류에 대응할 때 효율을 높일 수 있도록 로그를 파일에 남기려고 적용하고자 했습니다. Java에서는 LogBack으로 로그를 관리하는 것 같은데, Nest에서는 Winston이라는 라이브러리를 사용한다.
Service에서 발생 시키는 예외들을 한 곳으로 모아서 처리하기 위해서 ExceptionFilter를 구현하는 GlobalExceptionFilter를 구현했습니다.
InternalServerError(500)로서 error 로그를 찍는 경우를 아래와 같이 2가지로 정했습니다.
throw new InternalServerException()으로 던져져서 Status 코드가 500인 경우
예상하지 못한 곳에서 예외가 터져서 핸들링하지 못한 Exception인 경우
1번의 경우에는 의도적으로 InternalServerException을 이용해서 던지는 경우이기 때문에 api.error.log 파일에 로그를 저장했습니다. 반대로 2번의 경우에는 의도하지 않은 예외를 잡은 경우이기 때문에 basic.error.log 파일에 로그를 저장하도록 했습니다. 추가로 디버깅이 편리하도록 어느 API 요청에서 발생했던 예외인지 확인을 위해 Request URL도 함께 로그에 남겼습니다.
포맷에 combine()을 이용해서 timestamp를 찍는 방법도 있었는데, js-joda의 LocalDateTime.now()가 더 편하고, 코드로 봤을 때 직관적인 것 같아서 simple() 포맷을 사용해서 로그를 남겼습니다. Winston Github 링크에 가보면 다양한 포맷에 대한 설명이 있습니다. 의도에 맞게 사용하시는 것을 추천합니다.
logs 디렉터리 내부에 생긴 log 파일 2개
이렇게 하고 재시작을 하면 logs/api.error.log와 logs/basic.error.log 파일이 생깁니다. 이제 해당 필터를 거치게할 Controller에 @UseFilters()를 붙여서 테스트를 해봐야 합니다. 500 에러가 발생하도록 해서 로그가 파일에 정상적으로 찍히는지 확인해봅시다.
@UseFilters(GlobalExceptionFilter)
@Controller('letters')
export class LettersController {
// 코드 생략
}
로그 확인하기
500 Internal Server Error 발생
위에서 Postman을 통해 확인한 Internal Server Error입니다. 이는 throw new InternalServerException을 통해 의도된 에러였기 때문에 api.error.log에 저장됩니다. 정상적으로 저장이 됐는지 확인해 봅니다.
logs/api.error.log 파일
정상적으로 저장됐습니다. 의도했던 error 로그가 파일에 찍혀서 저장된 것을 확인할 수 있습니다.