반응형
"scripts": {
    ...
    "start:dev": "cross-env NODE_ENV=dev nest start --watch",
    "start:prod": "cross-env NODE_ENV=prod node dist/main",
    ...
},

환경을 dev와 prod로 나누기 위해서 실행 명령어를 확인하던 도중 둘 다 실행을 하는 것이지만, start:dev는 nest start --watch라는 명령어로 실행을 하고, start:prod는 node dist/main으로 실행을 했습니다.

 

둘 다 실행해 보면 동일하게 프로젝트를 실행하는 것 같은데 뭐가 다를까라는 의문이 들었습니다.

 

 

 

1. nest start --watch


출처: https://docs.nestjs.com/cli/scripts

Nest 공식 문서에 들어가 보면 nest start 명령어에 대한 설명이 존재합니다.

  1. "nest start" 명령어는 프로젝트를 빌드하고, 실행하는 데 사용되는 명령어입니다.
  2. "nest start"는 "nest build"와 유사한 빌드 프로세스를 수행하고, 빌드된 Application을 실행합니다.

즉, "nest start"를 사용하면 프로젝트를 간단하게 빌드하고, 실행할 수 있도록 도와줍니다.

 

 

출처: https://docs.nestjs.com/cli/usages#nest-build

추가적으로 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을 실행하면 안 됩니다. 그렇기 때문에 의도에 맞게 적절하게 사용해야 할 것 같습니다.

 

 

 

 

🔗 Reference


https://docs.nestjs.com/cli/usages#nest-start

 

Documentation | NestJS - A progressive Node.js framework

Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Programming), FP (Functional Programming), and FRP (Functional Rea

docs.nestjs.com

https://docs.nestjs.com/cli/scripts

 

Documentation | NestJS - A progressive Node.js framework

Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Programming), FP (Functional Programming), and FRP (Functional Rea

docs.nestjs.com

 

반응형
반응형

1. S3 버킷 생성


S3 서비스를 이용하려면 AWS 사이트에 들어가서 S3 버킷을 생성해야 합니다.

S3

AWS 사이트에 로그인한 후 위에 검색창에 S3라고 검색하면, S3를 시작할 수 있습니다.

버킷 만들기

노란색 버튼 "버킷 만들기"를 클릭해서 버킷 생성을 시작합니다.

버킷 이름, AWS 리전

버킷 이름을 먼저 작성해 줍니다. 버킷 이름에는 지켜야 하는 규칙이 있습니다. "버킷 이름 지정 규칙 보기"라는 파란색 링크를 클릭하면 확인해보실 수 있습니다. 규칙을 지켜서 내 버킷이름을 입력해 줍니다.

객체 소유권, 퍼블릭 엑세스 차단 설정

객체 소유권은 권장되는 방향인 "ACL 비활성화됨"을 선택했습니다. 저는 역할별로 IAM 사용자를 만들어서 사용할 예정입니다.

 

일단 저는 퍼블릭 액세스 차단 설정을 기본적으로 선택되어 있는 대로 하고 넘어갔습니다. 나중에 버킷에 가서 권한에 대한 설정은 변경할 수 있습니다. 저는 버킷에 대한 정책을 직접 작성해서 사용할 예정이지만, 여기서는 권장되는 방법으로 설정하고 넘어가겠습니다.

 

나머지 선택 사항들은 그대로 놔두고 버킷을 생성하면 버킷 생성이 완료됩니다.

버킷 폴더 만들기

생성된 버킷에 들어가 보면 처음에는 비어있는데, "폴더 만들기"를 눌러서 원하는 폴더명을 입력해서 만들어줍니다. 저는 "images"라는 이름의 폴더를 생성했습니다.

 

 

 

2. IAM 사용자 생성 & 정책 적용


S3라고 치고 들어갔던 검색창에 "IAM"이라고 검색하면 아래 화면으로 이동할 수 있습니다.

IAM 사용자 생성

버킷 생성을 끝냈으면 이제 IAM 사용자를 생성합니다. "사용자 생성" 버튼을 클릭합니다.

IAM 사용자 이름

IAM 사용자의 이름을 설정합니다. 이 이름은 나중에 AWS Console에 IAM 사용자로 로그인할 때 사용되는 정보입니다. 너무 어렵고 길게 만들면... 의미 있게 작성해 주시면 됩니다.

권한 설정, 정책 생성

다음으로 넘어가면 권한 옵션을 설정합니다. 저는 직접 정책을 생성해서 사용할 것이기 때문에 "직접 정책 연결"을 선택합니다.

IAM 사용자를 만든 후에 정책을 따로 만들어도 되지만, 저는 이 페이지에서 바로 사용할 정책을 생성해서 등록하겠습니다.

"정책 생성" 버튼을 클릭합니다.

정책 편집기

정책 생성으로 들어와서 JSON을 선택해 주면 정책을 작성할 수 있는 정책 편집기가 나옵니다.

여기에 이제 정책을 작성해 줍니다.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllowToAllBuckets",
            "Effect": "Allow",
            "Action": [
                "s3:GetBucketLocation",
                "s3:ListAllMyBuckets"
            ],
            "Resource": "*"
        },
        {
            "Sid": "AllowToAllObjects",
            "Effect": "Allow",
            "Action": "s3:ListBucket",
            "Resource": "arn:aws:s3:::my-bucket-name"
        },
        {
            "Sid": "AllowToGetPutDeleteObjects",
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:GetObject",
                "s3:DeleteObject"
            ],
            "Resource": "arn:aws:s3:::my-bucket-name/*"
        }
    ]
}

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로 주입하는 방법을 이용했습니다.

 

반응형
반응형

1. 문제 상황


자동화 배포 과정에서 Nest 애플리케이션을 빌드하는데, 빌드 실패가 나면서 이런 오류 메시지가 출력됐습니다.

FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory

말 그대로 할당된 Javascript 힙 메모리를 넘어섰다는 것입니다.

 

 

 

2. 문제 해결 방법


node -e 'console.log(v8.getHeapStatistics().heap_size_limit/(1024*1024))'

위 명령어를 터미널에 쳐보면 현재 설정되어 있는 힙 메모리를 확인할 수 있습니다. 확인해 보니 저는 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이라는 라이브러리를 사용한다.

 

Winston Github에 가서 Winston이 제공하는 기능, 사용법을 확인해 볼 수 있습니다.

 

GitHub - winstonjs/winston: A logger for just about everything.

A logger for just about everything. Contribute to winstonjs/winston development by creating an account on GitHub.

github.com

 

Winston 라이브러리 설치


npm install winston nest-winston

 

 

GlobalExceptionFilter에서 Winston 사용하여 파일에 로그 남기기


import { LocalDateTime } from "@js-joda/core";
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from "@nestjs/common";
import { HttpArgumentsHost } from "@nestjs/common/interfaces";
import { Response } from "express";
import * as winston from 'winston';

const { simple } = winston.format;

@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
  private readonly apiLogger;
  private readonly basicLogger;

  constructor() {
    this.apiLogger = winston.createLogger({
      transports: [
        new winston.transports.File({
          level: 'error',
          filename: 'api.error.log',
          dirname: 'logs',
          format: simple()
        })
    ]});

    this.basicLogger = winston.createLogger({
      transports: [
        new winston.transports.File({
          level: 'error',
          filename: 'basic.error.log',
          dirname: 'logs',
          format: simple()
        })
    ]});
  }

  catch(exception: any, host: ArgumentsHost) {
    const httpArgumentHosts: HttpArgumentsHost = host.switchToHttp();
    const response: Response = httpArgumentHosts.getResponse();
    const requestUrl: string = host.getArgs()[0].url;

    if (exception instanceof HttpException) {
      let status: number = exception.getStatus();

      if (status === HttpStatus.INTERNAL_SERVER_ERROR) {
        this.apiLogger.error(`[${LocalDateTime.now()}][${requestUrl}] ${exception}`);
      }

      response
        .status(status)
        .json({
          success: false,
          error: exception.message
        });
      
      return;
    }

    this.basicLogger.error(`[${LocalDateTime.now()}][${requestUrl}] ${exception}`);

    response
      .status(HttpStatus.INTERNAL_SERVER_ERROR)
      .json({
        success: false,
        error: exception.message
      });
  }
}

 

Service에서 발생 시키는 예외들을 한 곳으로 모아서 처리하기 위해서 ExceptionFilter를 구현하는 GlobalExceptionFilter를 구현했습니다.

 

InternalServerError(500)로서 error 로그를 찍는 경우를 아래와 같이 2가지로 정했습니다.

 

  1. throw new InternalServerException()으로 던져져서 Status 코드가 500인 경우
  2. 예상하지 못한 곳에서 예외가 터져서 핸들링하지 못한 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 로그가 파일에 찍혀서 저장된 것을 확인할 수 있습니다.

 

 

참고


https://docs.nestjs.com/techniques/logger#use-external-logger

 

Documentation | NestJS - A progressive Node.js framework

Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Programming), FP (Functional Programming), and FRP (Functional Rea

docs.nestjs.com

 

반응형

+ Recent posts