AWS

CloudWatch EMF를 통해 상태 코드별 지표 수집하기

Seongmin 2025. 5. 15. 22:43
반응형

모니터링

 

앞선 글에서는 API 요청 수를 지표로 수집했지만, 이 수치만으로는 서비스의 정상 여부를 판단하기 어렵다는 한계가 있습니다. 예를 들어 요청 수는 꾸준히 늘고 있지만, 그 중 절반이 500 에러라면 단순 요청 수만 봐서는 문제를 인지하지 못할 수 있습니다.

이럴 때는 HTTP 응답 상태 코드별로 요청 수를 분류해 지표를 수집하면, 에러 비율이나 특정 상태 코드의 급증 여부를 통해 보다 정밀한 모니터링이 가능해집니다.

 

1. MetricInterceptor 수정하기

앞선 글에서 만들었던 MetricInterceptor는 단순히 API 요청 수만 기록하도록 구현되어 있었습니다. 이제 여기에 HTTP 응답 상태 코드를 함께 수집할 수 있도록 로직을 확장해보겠습니다.

 

[metric.interceptor.ts]

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

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

@Injectable()
export class MetricsInterceptor implements NestInterceptor {
  intercept(
    context: ExecutionContext,
    next: CallHandler<any>,
  ): Observable<any> {
    const request = context.switchToHttp().getRequest<Request>();
    // Add
    const response = context.switchToHttp().getResponse<Response>();

    const startTime = Date.now();
    const path = request.path;
    const method = request.method;

	// Add
    const recordMetrics = (args: { statusCode: number; latency: number }) => {
      const { statusCode, latency } = args;
      const statusClass = `${Math.floor(statusCode / 100)}XX`;

      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);
        metrics.putMetric(`Status${statusClass}`, 1, Unit.Count);
      })();
    };

	// Add
    return next.handle().pipe(
      tap(() => {
        const statusCode = response.statusCode;

        const endTime = Date.now();
        const latency = endTime - startTime;

        recordMetrics({ statusCode, latency });
      }),
      catchError((err: unknown) => {
        const statusCode = err instanceof HttpException ? err.getStatus() : 500;

        const endTime = Date.now();
        const latency = endTime - startTime;

        recordMetrics({ statusCode, latency });
        return throwError(() => err);
      }),
    );
  }
}

 

(1) 응답 상태 코드는 응답 객체에서 가져와야 하기 때문에 Response 객체를 가져와야 합니다.

const response = context.switchToHttp().getResponse<Response>();

 

(2) 기존에는 metricScope를 직접 호출하여 지표를 기록했지만, 이번에는 이를 재사용 가능한 함수로 분리했습니다. 그 이유는, NestJS에서 비즈니스 로직 중 예외가 발생하면 tap()이 아닌 catchError()로 흐름이 분기되기 때문에, 정상 응답과 에러 응답에 대해 지표를 각각 기록할 수 있도록 분리된 구조가 필요했기 때문입니다.

지표를 기록하는 코드도 함께 변경되었습니다. 기존에는 metricScope를 바로 호출하는 방식이었지만, 이번에는 이를 함수로 래핑하고, HTTP 상태 코드를 파라미터로 전달받는 형태로 변경했습니다.

이렇게 변경한 이유는, 비즈니스 로직이 정상적으로 실행된 경우에는 Response 객체에서 상태 코드를 확인할 수 있지만, 에러가 발생한 경우에는 Exception 객체에서 상태 코드를 가져와야 하기 때문입니다.

statusClass는 상태 코드를 2xx, 4xx, 5xx 등 클래스 단위로 분류하기 위해 사용됩니다. 이렇게 분류하는 이유는, 개별 상태 코드보다 상태 코드 그룹 단위로 지표를 관리하면 전체적인 서비스 상태를 더 직관적으로 파악할 수 있기 때문입니다.

const recordMetrics = (args: { statusCode: number; latency: number }) => {
  const { statusCode, latency } = args;
  const statusClass = `${Math.floor(statusCode / 100)}XX`;

  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);
    metrics.putMetric(`Status${statusClass}`, 1, Unit.Count);
  })();
};

 

(3) 4xx, 5xx 상태 코드도 지표로 수집할 수 있도록 catchError 블록을 추가했습니다. 정상 요청은 tap()에서, 예외가 발생한 경우는 catchError()에서 지표를 기록하게 되며, 에러가 발생한 경우에는 HttpException에서 상태 코드를 추출하거나, 그렇지 않으면 기본값으로 500을 사용합니다.

return next.handle().pipe(
  tap(() => {
    const statusCode = response.statusCode;

    const endTime = Date.now();
    const latency = endTime - startTime;

    recordMetrics({ statusCode, latency });
  }),
  catchError((err: unknown) => {
    const statusCode = err instanceof HttpException ? err.getStatus() : 500;

    const endTime = Date.now();
    const latency = endTime - startTime;

    recordMetrics({ statusCode, latency });
    return throwError(() => err);
  }),
);

 

2. 배포 및 임의로 지표 생성하기

배포 후 실제 요청이 들어오면, 상태 코드별로 지표가 기록되고 CloudWatch 대시보드에서 이를 확인할 수 있습니다. 여기서는 간단한 테스트를 위해 부하 테스트 도구를 사용해 일부러 다양한 상태 코드의 요청을 보내고, 지표가 정상적으로 수집되는지 확인해 보겠습니다.

 

먼저 앱에 4xx 에러와 5xx 에러를 발생시키는 API를 추가합니다.

 

[app.controller.ts]

import {
  BadRequestException,
  Controller,
  Get,
  InternalServerErrorException,
} from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }

  // Add
  @Get('/400')
  throw4XX() {
    throw new BadRequestException();
  }

  // Add
  @Get('/500')
  throw5XX() {
    throw new InternalServerErrorException();
  }
}

 

이제 아래와 같이 부하를 발생시키는 간단한 스크립트를 작성합니다. 아래의 스크립트는 hey 도구와 Bash를 활용하여 5분 동안 무작위 경로에 요청을 보내며, 각 요청은 2xx, 4xx, 5xx 응답을 의도적으로 포함하도록 구성되어 있습니다.

 

[load_test.sh]

#/bin/bash

END=$((SECONDS + 300))

paths=("/" "/400" "/500")

while [ $SECONDS -lt $END ]; do
	path=${paths[$RANDOM % ${#paths[@]}]}

	qps=$((RANDOM % 6 + 5))

	echo "⏱ $(date +%T) - Hitting $path with QPS $qps"

	hey -z 5s -q ${qps} -c 5 "http://localhost:3000${path}" > /dev/null

	sleep 1
done

echo "✔ done."

 

(1) SECONDS는 스크립트가 시작된 이후 흐른 시간을 초 단위로 나타내는 Bash에서 제공하는 내장 변수입니다. 이를 활용해 테스트를 5분 동안 실행하도록 설정합니다.

END=$((SECONDS + 300))

 

(2) RANDOM은 임의의 정수를 반환하는 Bash에서 제공하는 내장 변수입니다. 이를 이용해 요청 경로와 QPS(초당 요청 수)를 랜덤하게 결정합니다.

path=${paths[$RANDOM % ${#paths[@]}]}

qps=$((RANDOM % 6 + 5))

 

(3) hey는 HTTP 부하 테스트 도구로, 지정한 URL에 대해 일정 시간 동안 요청을 보내고 응답 속도와 성공률 등을 확인할 수 있습니다. -z 5s는 5초 동안 테스트를 실행하고, -q는 초당 요청 수(QPS), -c는 동시 접속 수를 의미합니다.

hey -z 5s -q ${qps} -c 5 "http://localhost:3000${path}" > /dev/null

 

 

이제 인스턴스에 수정된 앱을 배포하고, hey를 설치한 후 위에서 작성한 스크립트를 실행합니다.

$ sudo apt install hey
$ chmod +x ./load_test.sh
$ ./load_test.sh
⏱ 11:06:42 - Hitting / with QPS 8
⏱ 11:06:48 - Hitting / with QPS 5
⏱ 11:06:54 - Hitting / with QPS 5
⏱ 11:07:00 - Hitting /500 with QPS 10
⏱ 11:07:06 - Hitting / with QPS 5
⏱ 11:07:12 - Hitting / with QPS 9
⏱ 11:07:18 - Hitting /500 with QPS 8
⏱ 11:07:24 - Hitting /400 with QPS 6
⏱ 11:07:30 - Hitting /400 with QPS 6
...
✔ done.
! 스크립트를 실행할 때는 sh ./load_test.sh가 아닌 ./load_test.sh 방식으로 실행해야 합니다. sh ./load_test.sh로 실행하면 Bash 전용 기능인 SECONDS나 RANDOM이 제대로 동작하지 않을 수 있기 때문입니다. 스크립트 상단에 #!/bin/bash가 명시되어 있다면, ./load_test.sh로 실행해야 Bash가 올바르게 적용됩니다.

 

3. 대시보드에서 확인하기

대시보드에서 지표를 확인해보면 Path + Method별로 2xx, 4xx, 5xx 지표가 수집된 것을 확인할 수 있습니다.

Namespace

 

Dimension
Metric

 

위젯을 구성할 땐 아래와 같이 ‘통계’는 ‘합계’로 설정하고, 기간은 1분으로 설정합니다. 부하 테스트를 5분간 진행했기 때문에 1분으로 표시해야 그래프에 나타납니다.

위젯 생성
생성된 위젯

 

반응형