AWS

CloudWatch EMF를 통해 지표 수집하기

Seongmin 2025. 5. 11. 17:38
반응형

모니터링

 

모니터링은 서비스의 상태와 이상 징후를 파악하고, 문제 발생 시 빠르게 대응하기 위한 인프라 운영에 필수적인 부분입니다. 이러한 모니터링을 위해 가장 필요한 것은 지표(Metric)입니다. 예를 들어, API 요청 수, 응답 시간, 에러 비율, CPU 사용량, 메모리 사용량 등이 대표적인 지표에 해당합니다.

앞선 글에서 설명한 것처럼 AWS에서는 CloudWatch를 통해 기본적인 지표들을 바로 확인할 수 있으며, EC2에 애플리케이션을 배포한 경우 CloudWatch Agent를 설치하여 추가적인 시스템 지표도 수집할 수 있습니다.

하지만, API 요청 수나 응답 시간, 레이턴시와 같은 애플리케이션 레벨 지표는 CloudWatch Agent를 연동하더라도 수집할 수 없습니다. CloudWatch Agent는 EC2 인스턴스의 OS 및 시스템 리소스(CPU, 메모리, 디스크 등)에 대한 메트릭 수집에 초점을 맞추고 있기 때문에, 애플리케이션 내부에서 발생하는 요청 처리 시간이나 API 호출 횟수 등은 기본적으로 관측 대상에 포함되지 않습니다. 물론 ELB를 사용하면 요청 수나 레이턴시 같은 지표를 수집할 수 있지만, 간단한 서버에 붙이기엔 비용 측면에서 부담될 수 있습니다.

이러한 상황에서 활용할 수 있는 방법 중 하나가 CloudWatch EMF입니다. 이 글에서는 NestJS 앱에서 EMF 활용하여 커스텀 지표를 수집하는 과정을 설명하려고 합니다.

 

1. CloudWatch Embedded Metric Format (EMF)

CloudWatch Embedded Metric Format(EMF)는 CloudWatch에서 메트릭으로 인식할 수 있도록 설계된 구조화된 JSON 포맷입니다. 애플리케이션 로그를 EMF 형식에 맞게 출력하면, CloudWatch는 이를 자동으로 파싱하여 지표로 추출할 수 있습니다.

이 방식을 사용하면 로그만으로 지표를 수집할 수 있고, 이미 CloudWatch Agent를 통해 로그를 전송하고 있다면 바로 CloudWatch Logs와 대시보드에서 지표를 확인할 수 있습니다. 또한, 애플리케이션 내부에서 발생하는 이벤트나 상태를 기준으로 원하는 지표를 자유롭게 정의하고 수집할 수 있기 때문에 시스템 자원 외에도 서비스에 특화된 커스텀 지표를 손쉽게 추적할 수 있다는 장점이 있습니다.

 

2. 애플리케이션 로그를 EMF 형식으로 출력하기

앞서 설명한대로 애플리케이션 로그를 EMF 형식으로 출력하기만 하면 CloudWatch에서 확인할 수 있습니다. EMF 형식으로 로그를 출력하는 방식에는 두 가지가 있습니다.

console.log로 직접 EMF 형식 출력하기

가장 단순한 방식은 EMF 형식의 JSON을 직접 만들어 console.log로 출력하는 것입니다. CloudWatch Agent가 로그를 수집하고, CloudWatch가 _aws 필드를 자동으로 파싱해 메트릭으로 인식합니다.

console.log(JSON.stringify({
  _aws: {
    Timestamp: Date.now(),
    CloudWatchMetrics: [
      {
        Namespace: "MyApp/Metrics",
        Dimensions: [["Service"]],
        Metrics: [
          { Name: "RequestCount", Unit: "Count" }
        ]
      }
    ]
  },
  RequestCount: 1
}));

aws-embedded-metrics 라이브러리 사용하기

aws-embedded-metrics는 AWS에서 공식으로 제공하는 라이브러리로, EMF 형식의 로그를 보다 손쉽고 안정적으로 생성할 수 있도록 도와줍니다.

(1) metricScope를 사용하는 방식

metricScope는 함수를 래핑하여, 그 내부에서 메트릭 로거를 주입받고 처리하는 방식입니다. 공식 문서에서 가장 많이 사용하는 형태이며, 메트릭 수집 범위가 함수 단위로 명확하게 구분됩니다.

metricScope((metrics) => () => {
  metrics.setNamespace('MyApp/NestJS');
  metrics.setDimensions({
    Path: path,
    Method: method,
  });

  metrics.putMetric('RequestCount', 1, 'Count');
  metrics.putMetric('Latency', latency, 'Milliseconds');
})();

 

! 간혹 metricScope를 사용하는 예제에서 await metrics.flush()를 명시적으로 호출하는 경우가 있습니다. 하지만 metricScope 는 내부적으로 flush 동작을 자동으로 수행하기 때문에, 직접 flush 를 호출하면 로그가 중복으로 전송됩니다.

(2) createMetricLogger를 사용하는 방식

createMetricsLogger()는 로거 객체를 직접 생성하여 사용할 수 있는 방식입니다. NestJS의 서비스, 미들웨어, 인터셉터 등 다양한 위치에서 재사용하기 편리합니다.

import { createMetricsLogger, Unit } from 'aws-embedded-metrics';

const metrics = createMetricsLogger();

metrics.setNamespace('MyApp/Metrics');
metrics.putDimensions({ Service: 'user-service' });
metrics.putMetric('RequestCount', 1, Unit.Count);

await metrics.flush();

Namespace,Dimension, Metric

(1) Namespace는 메트릭이 저장될 CloudWatch 네임스페이스를 의미합니다. 콘솔에서 지표를 탐색할 때 이 네임스페이스를 기준으로 그룹화되어 표시됩니다.

Namespace

 

(2) Dimension은 메트릭을 구분하기 위한 라벨입니다. 같은 이름의 메트릭이라도 Dimension 값에 따라 별도의 시계열로 분리되며, 필터링에도 사용됩니다.

Dimension

 

(3) Metric은 실제로 수집되는 지표 항목으로, 이름과 단위, 값으로 구성됩니다. 예를 들어 Latency, RequestCount 같은 지표 이름에 원하는 값을 기록할 수 있습니다.

Metric

3. NestJS 앱에서 EMF를 활용하여 지표 수집하기

먼저, NestJS 앱을 생성하고 EMF 로그를 출력하기 위해서 aws-embedded-metrics 를 설치합니다.

$ nest new my-project
$ cd ./my-project

$ pnpm install aws-embedded-metrics

 

코드를 작성하기 전에 AWS_EMF_ENVIRONMENT 환경 변수를 먼저 설정해야 합니다. 이 값은 애플리케이션이 실행되는 환경에 따라, EMF 로그를 어떤 방식으로 전송할지 결정하는 역할을 합니다. 아래의 값을 설정할 수 있습니다.

  • Local: 로그에 별도 정보를 추가하지 않고 stdout(표준 출력)으로 전송합니다.
  • Lambda: Lambda 메타데이터를 로그에 추가한 뒤 stdout으로 전송합니다.
  • Agent: 로그에 별도 정보를 추가하지 않고 TCP를 통해 전송합니다.
  • EC2: EC2 메타데이터를 로그에 추가한 뒤 TCP를 통해 전송합니다.
  • ECS: ECS 메타데이터를 로그에 추가하고, Firelens 연동을 지원합니다.

여기서는 Local을 사용합니다. 실행 스크립트에 해당 환경변수를 설정합니다.

 

[package.json]

{
  ...
  "scripts": {
    "start:debug": "AWS_EMF_ENVIRONMENT=Local nest start --debug --watch",
    ...
  }
}

API 요청 횟수, 레이턴시 수집

API 요청 횟수와 응답 시간을 수집하려면, 클라이언트의 요청이 들어올 때마다 해당 정보를 로그로 남겨야 합니다. 이는 보통 요청 처리 직전과 응답 직후의 시점을 기록해 레이턴시를 계산하거나, 요청 1건당 카운터를 증가시키는 방식으로 구현할 수 있습니다.

 

NestJS에서는 이런 처리를 요청과 응답의 흐름을 중간에서 가로채는 Interceptor를 통해 쉽게 구현할 수 있습니다.

 

[metric.interceptor.ts]

import { Request } from 'express';
import { metricScope, Unit } from 'aws-embedded-metrics';

import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';

@Injectable()
export class MetricsInterceptor implements NestInterceptor {
  intercept(
    context: ExecutionContext,
    next: CallHandler<any>,
  ): Observable<any> {
    const startTime = Date.now();
    const request = context.switchToHttp().getRequest<Request>();
    const path = request.path;
    const method = request.method;

    return next.handle().pipe(
      tap(() => {
        const endTime = Date.now();
        const latency = endTime - startTime;

        void metricScope((metrics) => () => {
          metrics.setNamespace('MyApp/NestJS');
          metrics.setDimensions({
            Path: path,
            Method: method,
          });

          metrics.putMetric('RequestCount', 1, Unit.Count);
          metrics.putMetric('Latency', latency, Unit.Milliseconds);
        })();
      }),
    );
  }
}

 

모든 요청에 대해 요청 수와 레이턴시를 기록하기 위해서 해당 인터셉터를 글로벌 인터셉터로 등록합니다.

 

[main.ts]

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

// Add
import { MetricsInterceptor } from './metrics.interceptor';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // Add
  app.useGlobalInterceptors(new MetricsInterceptor());

  await app.listen(process.env.PORT ?? 3000);
}
void bootstrap();

 

이제 앱을 실행하고 간단하게 요청을 보내면 로그가 출력되는 것을 확인할 수 있습니다.

$ curl localhost:3000

# 로그 출력
{"Path":"/","Method":"GET","_aws":{"Timestamp":1746933928487,"CloudWatchMetrics":[{"Dimensions":[["Path","Method"]],"Metrics":[{"Name":"RequestCount","Unit":"Count"},{"Name":"Latency","Unit":"Milliseconds"}],"Namespace":"MyApp/NestJS"}]},"RequestCount":1,"Latency":1}
{"Path":"/","Method":"GET","_aws":{"Timestamp":1746933957394,"CloudWatchMetrics":[{"Dimensions":[["Path","Method"]],"Metrics":[{"Name":"RequestCount","Unit":"Count"},{"Name":"Latency","Unit":"Milliseconds"}],"Namespace":"MyApp/NestJS"}]},"RequestCount":1,"Latency":0}
{"Path":"/","Method":"GET","_aws":{"Timestamp":1746933957657,"CloudWatchMetrics":[{"Dimensions":[["Path","Method"]],"Metrics":[{"Name":"RequestCount","Unit":"Count"},{"Name":"Latency","Unit":"Milliseconds"}],"Namespace":"MyApp/NestJS"}]},"RequestCount":1,"Latency":0}

CPU, 메모리, 디스크 사용량 수집

API 요청 수나 레이턴시 같은 애플리케이션 레벨 지표 외에도, 서버의 상태를 파악하기 위해서는 CPU 사용률, 메모리 사용량, 디스크 사용량과 같은 시스템 리소스 지표도 함께 모니터링하는 것이 중요합니다. 이러한 지표는 일반적으로 CloudWatch가 제공하는 기본 지표CloudWatch Agent를 통해 수집할 수 있지만, EMF를 사용해 애플리케이션에서 직접 수집하고 기록할 수도 있습니다.

 

이러한 시스템 지표는 일정한 간격으로 상태를 측정해 수집하며, NestJS에서는 @nestjs/schedule 모듈로 스케줄을 정의하고, Node.js의 os 모듈을 사용해 시스템 상태를 조회할 수 있습니다.

 

@nestjs/schdule 모듈은 NestJS 프로젝트에 기본적으로 포함되어 있지 않기 때문에 설치해야 합니다.

$ pnpm install @nestjs/schedule

 

이제 아래와 같이 MetricService를 만들어 시스템 상태를 조회하고 지표를 기록해보겠습니다. 시스템 상태는 Node.js의 os 모듈을 활용해 가져오며, 이 작업은 일정한 간격으로 실행되어야 하므로 @nestjs/schedule 모듈의 @Cron() 데코레이터를 사용합니다.

 

 

[metric.service.ts]

import { Injectable, OnModuleInit } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { createMetricsLogger } from 'aws-embedded-metrics';
import * as os from 'os';
import checkDiskSpace from 'check-disk-space';

@Injectable()
export class MetricService implements OnModuleInit {
  private readonly metricsLogger = createMetricsLogger();

  async onModuleInit(): Promise<void> {
    this.metricsLogger.setNamespace('MyApp/NestJS');
    // 로컬에서는 hostname을 사용
    if (process.env.NODE_ENV !== 'production') {
      const hostname = os.hostname();
      this.metricsLogger.setDimensions({
        Hostname: hostname,
      });
    } else {
      // IMDSv2 토큰 요청
      const tokenRes = await fetch('http://169.254.169.254/latest/api/token', {
        method: 'PUT',
        headers: {
          'X-aws-ec2-metadata-token-ttl-seconds': '21600',
        },
      });

      if (!tokenRes.ok) {
        throw new Error(`Failed to get IMDS token: ${tokenRes.status}`);
      }

      const token = await tokenRes.text();

      // Instance ID 요청
      const idRes = await fetch(
        'http://169.254.169.254/latest/meta-data/instance-id',
        {
          method: 'GET',
          headers: {
            'X-aws-ec2-metadata-token': token,
          },
        },
      );

      if (!idRes.ok) {
        throw new Error(`Failed to get instance-id: ${idRes.status}`);
      }

      const instanceId = await idRes.text();
      this.metricsLogger.setDimensions({
        InstanceId: instanceId,
      });
    }
  }

  @Cron('*/1 * * * *')
  async reportSystemUsage() {
    const totalMem = os.totalmem();
    const usedMem = totalMem - os.freemem();
    const memUsedPercent = Math.min(
      Math.round((usedMem / totalMem) * 1000) / 10,
      100,
    );
    this.metricsLogger.putMetric(
      'MemoryUsedPercent',
      memUsedPercent,
      'Percent',
    );

    const cpuLoadAvg1min = os.loadavg()[0];
    const cpuCount = os.cpus().length;
    const cpuLoadPercent = Math.min(
      Math.round((cpuLoadAvg1min / cpuCount) * 1000) / 10,
      100,
    );
    this.metricsLogger.putMetric('CPULoadPercent', cpuLoadPercent, 'Percent');

    const disk = await checkDiskSpace('/');
    const diskUsedPercent = Math.min(
      Math.round(((disk.size - disk.free) / disk.size) * 1000) / 10,
      100,
    );
    this.metricsLogger.putMetric('DiskUsedPercent', diskUsedPercent, 'Percent');

    await this.metricsLogger.flush();
  }
}

 

  • reportSystemUsage 메서드 위에 추가되어 있는 @Cron('*/1 * * * *') 데코레이터는 해당 메서드를 1분마다 주기적으로 실행하도록 설정합니다.
  • OnModuleInit 메서드는 애플리케이션이 시작될 때 한 번 실행되며, 이 앱이 실행되고 있는 EC2 인스턴스의 ID를 조회하기 위해서 사용합니다. 이 인스턴스 ID는 메트릭 로깅 시 Dimension으로 설정되어, CloudWatch에서 지표를 인스턴스별로 필터링하기 위해 사용합니다.

AppModule의 providers에 MetricService를 추가하고, 스케쥴링 작업이 실행될 수 있도록 @nestjs/schedule의 ScheduleModule을 imports에 추가합니다.

 

[app.module.ts]

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';

// Add
import { ScheduleModule } from '@nestjs/schedule';
import { MetricService } from './/metric.service';

@Module({
  // Add
  imports: [ScheduleModule.forRoot()],
  controllers: [AppController],
  // Add
  providers: [AppService, MetricService],
})
export class AppModule {}

 

이제 앱을 실행한 후 기다리면 1분마다 로그가 출력되는 것을 확인할 수 있습니다.

{"Hostname":"<HOST_NAME>","_aws":{"Timestamp":1746933926691,"CloudWatchMetrics":[{"Dimensions":[["Hostname"]],"Metrics":[{"Name":"MemoryUsedPercent","Unit":"Percent"},{"Name":"DiskUsedPercent","Unit":"Percent"},{"Name":"CPULoadPercent","Unit":"Percent"}],"Namespace":"MyApp/NestJS"}]},"MemoryUsedPercent":88.2,"DiskUsedPercent":34.7,"CPULoadPercent":23.2}
{"Hostname":"<HOST_NAME>","_aws":{"Timestamp":1746933960011,"CloudWatchMetrics":[{"Dimensions":[["Hostname"]],"Metrics":[{"Name":"MemoryUsedPercent","Unit":"Percent"},{"Name":"DiskUsedPercent","Unit":"Percent"},{"Name":"CPULoadPercent","Unit":"Percent"}],"Namespace":"MyApp/NestJS"}]},"MemoryUsedPercent":87.9,"DiskUsedPercent":34.7,"CPULoadPercent":21.9}
{"Hostname":"<HOST_NAME>","_aws":{"Timestamp":1746934020016,"CloudWatchMetrics":[{"Dimensions":[["Hostname"]],"Metrics":[{"Name":"MemoryUsedPercent","Unit":"Percent"},{"Name":"DiskUsedPercent","Unit":"Percent"},{"Name":"CPULoadPercent","Unit":"Percent"}],"Namespace":"MyApp/NestJS"}]},"MemoryUsedPercent":88,"DiskUsedPercent":34.7,"CPULoadPercent":20.4}
{"Hostname":"<HOST_NAME>","_aws":{"Timestamp":1746934080015,"CloudWatchMetrics":[{"Dimensions":[["Hostname"]],"Metrics":[{"Name":"MemoryUsedPercent","Unit":"Percent"},{"Name":"DiskUsedPercent","Unit":"Percent"},{"Name":"CPULoadPercent","Unit":"Percent"}],"Namespace":"MyApp/NestJS"}]},"MemoryUsedPercent":88.5,"DiskUsedPercent":34.7,"CPULoadPercent":18.6}

 

4. EC2에 배포하기

작성한 앱을 EC2 인스턴스에 배포하고 CloudWatch Agent를 설치하면, 로그가 CloudWatch Logs로 전송되어 대시보드에서 지표를 확인할 수 있습니다.

인스턴스 설정

먼저 인스턴스에 CloudWatch Agent를 설치하고 설정을 완료합니다. 연동 방법은 이 “EC2에 CloudWatch 연동”을 참고해 주세요.

 

앱을 실행하기 위해 Nodejs와 PM2를 설치합니다.

# nvm 설치
$ wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
$ source ~/.bashrc

# Nodejs 설치
$ nvm install v22

# pnpm 설치
$ npm install -g pnpm

# PM2 설치
$ npm install -g pm2

앱 업로드

앱은 PM2로 실행할 예정이기 때문에 PM2 설정 파일을 추가합니다. ecosystem.config.js 파일을 생성하고 아래와 같이 작성합니다.

 

[ecosystem.config.js]

module.exports = {
  apps: [
    {
      name: 'main',
      // 실행할 파일
      script: 'dist/main.js',
      // 로그를 기록할 파일
      // 이 파일을 CloudWatch Agent가 읽어서 CloudWatch Logs로 전송합니다.
      out_file: '/var/log/.pm2/logs/app-out.log',
      // 에러 로그를 기록할 파일
      error_file: '/var/log/.pm2/logs/app-error.log',
      // 환경 변수
      env: {
        AWS_EMF_ENVIRONMENT: 'Local',
        // CloudWatch 로그 그룹의 이름입니다.
        AWS_EMF_LOG_GROUP_NAME: '/ec2/emf',
      },
    },
  ],
};

 

앱을 빌드하고 필요한 파일들을 함께 압축합니다.

# 빌드
$ pnpm run build

# 압축
$ tar -czvf app.zip dist/* package.json ecosystem.config.js

 

압축한 app.zip을 인스턴스에 업로드합니다.

scp -i <키페어> app.zip ubuntu@<인스턴스 IP>:~/

앱 실행

인스턴스에 접속하면 홈 디렉토리에서 app.zip 파일을 확인할 수 있습니다.

$ cd $HOME
$ ls
app.zip

 

압축을 해제하고 의존성을 설치한 후 PM2로 앱을 실행합니다.

# 압축 해제
$ mkdir app
$ tar -xvf app.zip -C app
$ cd ./app

# 의존성 설치
$ pnpm install

# 앱 실행
$ pm2 start ecosystem.config.js

 

PM2로 앱 로그를 확인합니다. 요청 수/레이턴시 로그가 잘 출력되는지 확인하기 위해서 curl로 몇 번 요청을 보낸 후 로그를 확인해 보세요.

$ curl localhost:3000
$ curl localhost:3000
$ curl localhost:3000

$ pm2 log main

{"Hostname":"<HOST_NAME>","_aws":{"Timestamp":1746933926691,"CloudWatchMetrics":[{"Dimensions":[["Hostname"]],"Metrics":[{"Name":"MemoryUsedPercent","Unit":"Percent"},{"Name":"DiskUsedPercent","Unit":"Percent"},{"Name":"CPULoadPercent","Unit":"Percent"}],"Namespace":"MyApp/NestJS"}]},"MemoryUsedPercent":88.2,"DiskUsedPercent":34.7,"CPULoadPercent":23.2}
{"Hostname":"<HOST_NAME>","_aws":{"Timestamp":1746933960011,"CloudWatchMetrics":[{"Dimensions":[["Hostname"]],"Metrics":[{"Name":"MemoryUsedPercent","Unit":"Percent"},{"Name":"DiskUsedPercent","Unit":"Percent"},{"Name":"CPULoadPercent","Unit":"Percent"}],"Namespace":"MyApp/NestJS"}]},"MemoryUsedPercent":87.9,"DiskUsedPercent":34.7,"CPULoadPercent":21.9}
{"Hostname":"<HOST_NAME>","_aws":{"Timestamp":1746934020016,"CloudWatchMetrics":[{"Dimensions":[["Hostname"]],"Metrics":[{"Name":"MemoryUsedPercent","Unit":"Percent"},{"Name":"DiskUsedPercent","Unit":"Percent"},{"Name":"CPULoadPercent","Unit":"Percent"}],"Namespace":"MyApp/NestJS"}]},"MemoryUsedPercent":88,"DiskUsedPercent":34.7,"CPULoadPercent":20.4}
{"Hostname":"<HOST_NAME>","_aws":{"Timestamp":1746934080015,"CloudWatchMetrics":[{"Dimensions":[["Hostname"]],"Metrics":[{"Name":"MemoryUsedPercent","Unit":"Percent"},{"Name":"DiskUsedPercent","Unit":"Percent"},{"Name":"CPULoadPercent","Unit":"Percent"}],"Namespace":"MyApp/NestJS"}]},"MemoryUsedPercent":88.5,"DiskUsedPercent":34.7,"CPULoadPercent":18.6}

 

트러블 슈팅

  1. 로그가 전송되지 않는다면
    • CloudWatch 로그에서 로그 그룹이나 로그를 확인할 수 없다면 CloudWatch Agent 설정을 확인해보고, CloudWatch Agent가 실행되고 있는지 확인해 보세요.
  2. Error: connect ECONNREFUSED 0.0.0.0:25888
    • Error: connect ECONNREFUSED 0.0.0.0:25888 에러는 AWS_EMF_ENVIRONMENT 를 Local로 설정하지 않아서 발생하는 문제입니다. ecosystem.config.js에 해당 환경 변수가 설정되어 있는지 확인해 보세요.
    • AWS_EMF_ENVIRONMENT를 설정하지 않고 해결하려면 CloudWatch Agent 설정 파일에 아래의 내용을 추가하면 되지만, 복잡하니 환경 변수를 추가하는 것을 추천 드립니다.
{
	...
  "logs": {
    "metrics_collected": {
	    # 그냥 빈 블록을 추가하시면 됩니다.
      "emf": { }
    },
    ...
  }
}

 

5. CloudWatch 대시보드에서 확인하기

수집되는 지표를 확인하기 위해서 CloudWatch 대시보드로 이동합니다. 대시보드에서 위젯을 추가하면 위에서 지정한 Namespace, Dimension, Metric을 확인할 수 있습니다.

 

이제 해당 지표들을 대시보드에 추가해서 서비스 상태를 모니터링할 수 있습니다.

반응형