본문 바로가기

모니터링과 로깅 시스템 구축

메트릭, 로그, 분산 추적을 수집해 관측 신호를 연결하고 SLI, SLO, 오류 예산을 운영 담당자(owner)의 조치·검증까지 연결하는 운영 기준을 세웁니다.

앞 글에서는 NestJS 애플리케이션을 여러 클라우드 실행 모델에 배포하는 기준을 정했습니다. 11장의 마지막인 이 글에서는 배포한 서비스가 사용자의 기대를 채우는지 측정하고, 이상을 발견했을 때 담당자가 자료를 모아 조치한 뒤 그 결과를 다음 운영 기준에 반영하는 방법을 다룹니다.

여기서 말하는 관측성(observability)은 “모든 것을 실시간으로 본다”는 약속이 아닙니다. 지표의 수집 주기, trace의 샘플링, 전송과 저장의 지연이 있다는 것을 알면서도, 필요한 판단을 제때 내릴 수 있게 만드는 운영 방식입니다.

관측성 운영 피드백 루프

Measure·Correlate·Alert·Act·Verify에서 같은 운영 기록의 증거와 결정이 누적된다.

SLI, SLO, 오류 예산과 runbook을 누적 운영 기준으로 삼아 Measure, Correlate, Alert, Act, Verify를 순환하는 관측성 운영 피드백 루프입니다. 검증 결과가 운영 기준에 기록되고 다음 측정의 기준으로 되돌아갑니다.

Nest · Observability feedback

측정에서 조치와 검증까지

사용자 SLI·SLO·오류 예산·runbook을 기준으로 측정·상관관계·알림·조치·검증을 반복합니다. 수집 지연과 누락을 확인하고 owner가 검증한 결과를 다음 운영 기준에 반영합니다.

관측성 운영 피드백 루프Measure가 제한된 label의 SLI와 metric, log, trace를 모으고 Correlate가 release와 trace context로 연결합니다. Alert는 SLO burn, owner, runbook을 전달하고 Act에서 담당자가 명시적으로 완화나 rollback을 선택합니다. Verify가 사용자 영향과 probe를 확인해 결과를 SLO, 오류 예산, runbook에 기록하며 이 기준이 다음 Measure로 돌아갑니다.MeasureSLI · metric · log · trace주기 · sampling · labelCorrelatetime · release · contextfreshness · dropAlertSLO burn · ownerrunbook · page/ticketActowner의 명시적 조치traffic · scale · rollbackVerifySLI · probe · 사용자 영향결과 · 잔여 위험누적 운영 기준SLO · error budget · runbook검증한 결과만 다음 기준신호owner기준 기록
관측성 운영 피드백 루프Measure→Correlate→Alert→owner의Act→Verify로 진행합니다. 검증 결과는 누적 운영 기준으로 기록되고 다음 Measure로 되돌아갑니다. alert에서 rollback이 자동 수행된다고 표시하지 않습니다. SLO 정책 변경은 별도 합의와 근거가 필요합니다.MeasureSLI · log · trace주기 · samplingCorrelatetime · releasecontext누락 확인AlertSLO burn · ownerrunbookpage / ticketAct명시적 조치scale · rollbackVerifySLI · probe · 영향결과 · 잔여 위험누적 운영 기준SLO · error budget검증한 runbook기준 기록
ACCUMULATED BASELINE

누적 운영 기준

SLO · error budget · runbook

검증된 결과만 다음 pass의 기준으로 사용합니다.

  1. Measure

    SLI, metric, log, trace를 정한 주기와 sampling으로 수집합니다. 제한된 label을 써 cardinality를 통제하고 ECS 또는 EKS의 세부 telemetry는 서비스와 수집 설정 범위 안에서 해석합니다.

  2. Correlate

    시간, immutable release ID와 전파된 trace context로 신호를 연결합니다. driver, collector, retention이 정한 로그 수명과 signal freshness, drop을 함께 확인합니다.

  3. Alert

    SLO burn과 사용자 영향을 owner, runbook, dashboard, release ID와 함께 page 또는 ticket으로 전달합니다. alert는 자동 rollback을 뜻하지 않습니다.

  4. Act

    담당자가 근거를 확인하고 traffic 제한, scale, feature flag, failover 또는 명시적 rollback 가운데 runbook 조치를 선택합니다.

  5. Verify

    사용자 SLI, dependency readiness, traffic 제외, liveness restart와 log, trace를 재확인하고 결과와 잔여 위험을 기록합니다.

    WRITE BACK: owner가 검증한 결과로 error budget 상태, runbook과 instrumentation gap을 갱신해 다음 Measure의 기준으로 되돌립니다. SLO 정책 변경은 이해관계자 합의와 충분한 기간의 evidence가 있을 때만 별도로 검토합니다.

  • Measure에서 Verify까지의 운영 흐름
  • 검증 결과의 기준 기록과 다음 pass
  • owner의 명시적 조치

이 구성의 startup은 필수 초기화를 마친 뒤에만 port를 여는 계약을 전제로 초기화를 보호합니다. readiness 실패는 traffic 제외, liveness 실패는 restart에 연결하며 필수 dependency readiness를 liveness와 섞지 않습니다. rollback은 alert의 자동 결과가 아니라 owner가 evidence를 검토해 선택하거나, 별도로 검증해 설정한 플랫폼 기능이 수행합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • Measure가 제한된 label의 SLI와 metric, log, trace를 모으고 Correlate가 release와 trace context로 연결합니다. Alert는 SLO burn, owner, runbook을 전달하고 Act에서 담당자가 명시적으로 완화나 rollback을 선택합니다. Verify가 사용자 영향과 probe를 확인해 결과를 SLO, 오류 예산, runbook에 기록하며 이 기준이 다음 Measure로 돌아갑니다.
  • Measure→Correlate→Alert→owner의 Act→Verify로 진행합니다. 검증 결과는 누적 운영 기준으로 기록되고 다음 Measure로 되돌아갑니다. alert에서 rollback이 자동 수행된다고 표시하지 않습니다. SLO 정책 변경은 별도 합의와 근거가 필요합니다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
28Measure→Correlate→Alert→owner의 Act→Verify로 진행합니다. 검증 결과는 누적 운영 기준으로 기록되고 다음 Measure로 되돌아갑니다. alert에서 rollback이 자동 수행된다고 표시하지 않습니다. SLO 정책 변경은 별도 합의와 근거가 필요합니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
Measure와 Correlate

제한된 label의 SLI·metric·log·trace를 release 문맥에 연결합니다.

자료: 관측·release·trace · 기록: 중앙 운영 근거흩어진 자료가 같은 사건의 증거가 됩니다.
Alert와 Act

SLO burn·owner·runbook으로 담당자가 완화·rollback을 선택합니다.

결정: 담당자 명시 · 자동 rollback: 가정 없음관측이 실제 조치 결정으로 바뀝니다.
Verify와 기준 갱신

영향·probe를 확인해 SLO·예산·runbook에 결과를 기록합니다.

결과: 검증 기록 · 다음: Measure 기준조치 효과가 다음 관측의 기준이 됩니다.

관측성 운영 피드백 루프

Measure·Correlate·Alert·Act·Verify에서 같은 운영 기록의 증거와 결정이 누적된다.

SLI, SLO, 오류 예산과 runbook을 누적 운영 기준으로 삼아 Measure, Correlate, Alert, Act, Verify를 순환하는 관측성 운영 피드백 루프입니다. 검증 결과가 운영 기준에 기록되고 다음 측정의 기준으로 되돌아갑니다.

Nest · Observability feedback

측정에서 조치와 검증까지

사용자 SLI·SLO·오류 예산·runbook을 기준으로 측정·상관관계·알림·조치·검증을 반복합니다. 수집 지연과 누락을 확인하고 owner가 검증한 결과를 다음 운영 기준에 반영합니다.

관측성 운영 피드백 루프Measure가 제한된 label의 SLI와 metric, log, trace를 모으고 Correlate가 release와 trace context로 연결합니다. Alert는 SLO burn, owner, runbook을 전달하고 Act에서 담당자가 명시적으로 완화나 rollback을 선택합니다. Verify가 사용자 영향과 probe를 확인해 결과를 SLO, 오류 예산, runbook에 기록하며 이 기준이 다음 Measure로 돌아갑니다.MeasureSLI · metric · log · trace주기 · sampling · labelCorrelatetime · release · contextfreshness · dropAlertSLO burn · ownerrunbook · page/ticketActowner의 명시적 조치traffic · scale · rollbackVerifySLI · probe · 사용자 영향결과 · 잔여 위험누적 운영 기준SLO · error budget · runbook검증한 결과만 다음 기준신호owner기준 기록
관측성 운영 피드백 루프Measure→Correlate→Alert→owner의Act→Verify로 진행합니다. 검증 결과는 누적 운영 기준으로 기록되고 다음 Measure로 되돌아갑니다. alert에서 rollback이 자동 수행된다고 표시하지 않습니다. SLO 정책 변경은 별도 합의와 근거가 필요합니다.MeasureSLI · log · trace주기 · samplingCorrelatetime · releasecontext누락 확인AlertSLO burn · ownerrunbookpage / ticketAct명시적 조치scale · rollbackVerifySLI · probe · 영향결과 · 잔여 위험누적 운영 기준SLO · error budget검증한 runbook기준 기록
ACCUMULATED BASELINE

누적 운영 기준

SLO · error budget · runbook

검증된 결과만 다음 pass의 기준으로 사용합니다.

  1. Measure

    SLI, metric, log, trace를 정한 주기와 sampling으로 수집합니다. 제한된 label을 써 cardinality를 통제하고 ECS 또는 EKS의 세부 telemetry는 서비스와 수집 설정 범위 안에서 해석합니다.

  2. Correlate

    시간, immutable release ID와 전파된 trace context로 신호를 연결합니다. driver, collector, retention이 정한 로그 수명과 signal freshness, drop을 함께 확인합니다.

  3. Alert

    SLO burn과 사용자 영향을 owner, runbook, dashboard, release ID와 함께 page 또는 ticket으로 전달합니다. alert는 자동 rollback을 뜻하지 않습니다.

  4. Act

    담당자가 근거를 확인하고 traffic 제한, scale, feature flag, failover 또는 명시적 rollback 가운데 runbook 조치를 선택합니다.

  5. Verify

    사용자 SLI, dependency readiness, traffic 제외, liveness restart와 log, trace를 재확인하고 결과와 잔여 위험을 기록합니다.

    WRITE BACK: owner가 검증한 결과로 error budget 상태, runbook과 instrumentation gap을 갱신해 다음 Measure의 기준으로 되돌립니다. SLO 정책 변경은 이해관계자 합의와 충분한 기간의 evidence가 있을 때만 별도로 검토합니다.

  • Measure에서 Verify까지의 운영 흐름
  • 검증 결과의 기준 기록과 다음 pass
  • owner의 명시적 조치

이 구성의 startup은 필수 초기화를 마친 뒤에만 port를 여는 계약을 전제로 초기화를 보호합니다. readiness 실패는 traffic 제외, liveness 실패는 restart에 연결하며 필수 dependency readiness를 liveness와 섞지 않습니다. rollback은 alert의 자동 결과가 아니라 owner가 evidence를 검토해 선택하거나, 별도로 검증해 설정한 플랫폼 기능이 수행합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • Measure가 제한된 label의 SLI와 metric, log, trace를 모으고 Correlate가 release와 trace context로 연결합니다. Alert는 SLO burn, owner, runbook을 전달하고 Act에서 담당자가 명시적으로 완화나 rollback을 선택합니다. Verify가 사용자 영향과 probe를 확인해 결과를 SLO, 오류 예산, runbook에 기록하며 이 기준이 다음 Measure로 돌아갑니다.
  • Measure→Correlate→Alert→owner의 Act→Verify로 진행합니다. 검증 결과는 누적 운영 기준으로 기록되고 다음 Measure로 되돌아갑니다. alert에서 rollback이 자동 수행된다고 표시하지 않습니다. SLO 정책 변경은 별도 합의와 근거가 필요합니다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
28Measure→Correlate→Alert→owner의 Act→Verify로 진행합니다. 검증 결과는 누적 운영 기준으로 기록되고 다음 Measure로 되돌아갑니다. alert에서 rollback이 자동 수행된다고 표시하지 않습니다. SLO 정책 변경은 별도 합의와 근거가 필요합니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
Measure와 Correlate

제한된 label의 SLI·metric·log·trace를 release 문맥에 연결합니다.

자료: 관측·release·trace · 기록: 중앙 운영 근거흩어진 자료가 같은 사건의 증거가 됩니다.
Alert와 Act

SLO burn·owner·runbook으로 담당자가 완화·rollback을 선택합니다.

결정: 담당자 명시 · 자동 rollback: 가정 없음관측이 실제 조치 결정으로 바뀝니다.
Verify와 기준 갱신

영향·probe를 확인해 SLO·예산·runbook에 결과를 기록합니다.

결과: 검증 기록 · 다음: Measure 기준조치 효과가 다음 관측의 기준이 됩니다.

위 그림은 측정(Measure), 연결(Correlate), 알림(Alert), 조치(Act), 검증(Verify)이 반복되는 운영 흐름을 보여 줍니다. 가운데에는 SLI, SLO, 오류 예산, runbook이라는 누적된 운영 기준이 있고, 각 단계는 이 기준을 바탕으로 움직입니다. 검증 단계의 결과는 다시 운영 기준에 기록되어 다음 측정의 출발점이 됩니다.


운영 기준: SLI, SLO, 오류 예산, runbook

먼저 사용자가 실제로 겪는 결과를 기준으로 SLI와 SLO를 정합니다.

  • SLI(Service Level Indicator): 성공한 요청의 비율, 유효한 응답의 지연 시간처럼 서비스 수준을 숫자로 나타낸 지표입니다.
  • SLO(Service Level Objective): 정해진 기간 동안 SLI가 채워야 할 목표입니다. 예를 들어 “최근 28일 동안 유효한 요청의 99.9%가 성공”처럼 기간과 대상 요청을 함께 고정합니다.
  • 오류 예산(error budget): 1 - SLO로 정해지는, 허용하는 실패의 비율이나 개수입니다. 팀은 예산을 다 썼을 때의 정책을 기능 배포, 안정화 작업, 장애 회고와 연결합니다.
  • runbook: 알림이 울렸을 때 담당자가 확인할 대시보드, 쿼리, 릴리스 기록, 첫 완화 조치, 상위 보고 절차, 종료 조건을 적어 둔 문서입니다.

p95 < 300ms나 99.9%는 설명을 위한 예일 뿐 모든 API의 기본값이 아닙니다. 사용자의 기대, 트래픽 분포, 의존성, 비용을 바탕으로 측정할 수 있는 목표를 합의해야 합니다. 오류 예산 정책에도 숫자만 적지 말고, 예산을 다 썼을 때 누가 어떤 변경을 멈추고 어떤 신뢰성 작업을 먼저 할지 적어 둡니다.

예를 들어 28일 동안 유효한 요청이 100,000건이고, 2xx와 3xx를 성공, 나머지 유효한 요청을 실패로 정했다고 가정합니다. 성공률 목표가 99.9%라면 허용하는 실패 비율은 0.1%이고, 허용하는 실패 수는 100000 × 0.001 = 100건입니다. 실패가 70건이었다면 같은 대상 요청에서 예산이 30건 남습니다. 어떤 요청을 분모에서 뺄지는 미리 합의해 두어야 하며, 장애가 난 뒤에 실패한 요청을 임의로 빼서 목표를 채운 것처럼 만들지 않습니다. 남은 예산만으로 배포 여부가 자동으로 정해지지도 않으므로, 팀의 정책과 사용자 영향을 함께 봅니다.


Measure: 필요한 신호를 범위와 함께 수집하기

지표

NestJS 애플리케이션은 9장에서 본 prom-client 등으로 /metrics를 노출하고, Prometheus나 관리형 Prometheus가 정해진 주기로 수집하게 할 수 있습니다. 요청 성공률, 지연 분포, 처리량, 큐 길이처럼 SLI와 원인 분석에 필요한 지표를 먼저 모읍니다.

Prometheus에서는 label 조합마다 새로운 시계열이 생깁니다. 공식 지표·label 지침에 따라 method, 정규화한 route, code처럼 가짓수가 제한된 label을 쓰고, userId, 이메일, 요청 ID처럼 값이 끝없이 늘어나는 식별자는 label에 넣지 않습니다.

클라우드 지표도 서비스와 설정에 따라 제공 범위가 다릅니다.

  • Amazon ECS는 실행 중인 서비스의 CPUUtilization, MemoryUtilization 같은 기본 서비스 지표를 CloudWatch에 게시합니다. 태스크, 컨테이너, 네트워크, 스토리지 수준의 더 자세한 관측은 클러스터에 Container Insights를 설정해야 합니다.
  • Amazon EKS의 Pod와 노드 지표는 저절로 모두 생기지 않습니다. CloudWatch Observability add-on이나 Helm chart, 필요한 agent, IAM, 수집 설정을 설치해야 Container Insights와 관련 신호를 보낼 수 있습니다.
  • 애플리케이션의 SLI는 인프라의 CPU 지표가 대신하지 않습니다. /metrics, OpenTelemetry 계측, 관리형 APM을 따로 구성하고, 수집이 끊기는 것도 알림 대상으로 삼습니다.

헬스 엔드포인트

@nestjs/terminus는 health indicator를 조합해 readiness 엔드포인트를 만들 수 있게 해 줍니다. 현재 버전은 12.1입니다.

npm install @nestjs/terminus

다음 예시는 HTTP 서버가 응답할 수 있는 상태와, 요청 처리에 꼭 필요한 데이터베이스 의존성을 나눕니다. TypeOrmModule과 실제 연결은 애플리케이션 모듈에서 이미 구성했다고 가정합니다.

src/health/health.controller.ts
import { Controller, Get } from '@nestjs/common';
import {
  HealthCheck,
  HealthCheckService,
  TypeOrmHealthIndicator,
} from '@nestjs/terminus';

@Controller('health')
export class HealthController {
  constructor(
    private readonly health: HealthCheckService,
    private readonly database: TypeOrmHealthIndicator,
  ) {}

  @Get('startup')
  startup() {
    return { status: 'up' };
  }

  @Get('live')
  live() {
    return { status: 'up' };
  }

  @Get('ready')
  @HealthCheck()
  ready() {
    return this.health.check([
      // 1초 안에 SELECT 1이 끝나지 않으면 실패로 봅니다.
      () => this.database.pingCheck('database').withTimeout(1000),
    ]);
  }
}

공식 문서에 따르면 TypeOrmHealthIndicator는 내부적으로 SELECT 1을 실행해 데이터베이스가 살아 있는지 확인합니다. @nestjs/terminus 12에서는 내장 indicator의 timeout 옵션이 deprecated되었으므로, 위처럼 반환된 확인 작업에 .withTimeout()을 이어 붙여 시간을 제한합니다. 직접 indicator를 만들 때는 예전의 HealthIndicator와 HealthCheckError 대신 HealthIndicatorService를 씁니다.

src/health/health.module.ts
import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { HealthController } from './health.controller';

@Module({
  imports: [TerminusModule],
  controllers: [HealthController],
})
export class HealthModule {}

예제의 startup과 live는 HTTP 라우터가 응답할 수 있는지만 보는 최소한의 probe입니다. 애플리케이션이 listen() 전에 필요한 초기화를 모두 끝낸다면 이 응답 자체가 시작 완료를 뜻합니다. 서버를 먼저 열고 캐시 미리 채우기 같은 초기화를 이어 간다면 startup이 그 완료 여부를 따로 확인해야 합니다.

따라 할 때는 HealthModule을 루트 모듈에 가져오고, 사용할 TypeOrmModule 연결을 실제로 구성합니다. 전역 JWT guard가 있다면 최소한의 probe가 인증 없이 호출될 수 있도록 4장의 공개 경로 정책을 적용하되, DB 상태나 내부의 자세한 결과를 인터넷에 그대로 공개할지는 따로 제한합니다. prefix와 URI 버전을 붙인 실제 URL을 플랫폼의 probe 설정과 맞추며, 이 파일을 추가했다고 앞 글의 CI 예시가 쓰는 /health나 플랫폼 probe가 저절로 연결되지는 않습니다.

세 엔드포인트의 의미는 서로 다릅니다. Kubernetes probe 문서를 기준으로 보면 다음과 같습니다.

  • startup은 느린 초기화가 끝날 때까지 readiness와 liveness 확인을 미룹니다. 허용 횟수를 넘겨 실패하면 컨테이너가 재시작됩니다.
  • readiness는 지금 새 트래픽을 받을 수 있는지 판단합니다. 실패하면 해당 Service의 엔드포인트에서 빠지지만 컨테이너가 재시작되지는 않습니다. 데이터베이스처럼 요청 처리에 꼭 필요한 의존성은 여기에 넣고, 일시적인 외부 장애를 liveness에 넣어 연쇄 재시작을 만들지 않습니다.
  • liveness는 교착 상태처럼 재시작하지 않으면 회복하기 어려운 프로세스 상태만 판단합니다. 실패가 반복되면 컨테이너가 재시작됩니다.

플랫폼마다 이름과 동작이 다르므로 이 세 엔드포인트를 모든 상품에 그대로 옮기지 않습니다. 앞 글에서 정한 트래픽 제외, 재시작, 종료와 요청 정리 규칙에 맞춰 각 플랫폼의 기능에 대응시킵니다.

로그와 trace

컨테이너에서는 애플리케이션이 구조화한 JSON을 stdout과 stderr로 내보내고, 런타임의 logging driver나 수집기가 목적지로 전달하는 구성이 단순합니다. 하지만 stdout에 썼다는 사실만으로 보존이 보장되지는 않습니다. Docker logging driver 설정처럼 driver, 파일 회전, 노드 저장소, 수집기의 전달 방식이 로컬에서의 수명을 정하고, CloudWatch Logs 같은 목적지의 보존 설정이 중앙 보존 기간을 정합니다.

다음 Winston 어댑터는 JSON 콘솔 출력 하나만 쓰며, 실행 중인 OpenTelemetry span이 있으면 그 traceId와 spanId를 로그에 붙입니다. 이 기능을 쓰려면 @opentelemetry/api 패키지(현재 1.10)가 필요합니다. 10장 3절에서 src/logging/winston.logger.ts를 이미 만들었다면, 아래 파일은 그것과 함께 쓰는 것이 아니라 그것 대신 쓸 수 있는 또 하나의 예시입니다. 10장의 어댑터를 계속 쓴다면 아래의 activeTrace format만 그 어댑터의 format.combine()에서 format.json() 앞에 넣으면 됩니다.

src/config/winston.logger.ts
import { type LoggerService } from '@nestjs/common';
import { context, trace } from '@opentelemetry/api';
import {
  createLogger,
  format,
  transports,
  type Logger,
} from 'winston';

type LogLevel = 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'verbose';

const sensitiveKeys = new Set([
  'authorization',
  'cookie',
  'password',
  'token',
  'userid',
  'email',
]);

const redact = format((info) => {
  for (const key of Object.keys(info)) {
    if (sensitiveKeys.has(key.toLowerCase())) {
      info[key] = '[REDACTED]';
    }
  }
  return info;
});

const activeTrace = format((info) => {
  const spanContext = trace.getSpan(context.active())?.spanContext();
  if (spanContext) {
    info.traceId = spanContext.traceId;
    info.spanId = spanContext.spanId;
  }
  return info;
});

export class WinstonLogger implements LoggerService {
  private readonly logger: Logger = createLogger({
    levels: {
      fatal: 0,
      error: 1,
      warn: 2,
      info: 3,
      debug: 4,
      verbose: 5,
    },
    level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
    format: format.combine(
      format.timestamp(),
      format.errors({ stack: true }),
      activeTrace(),
      redact(),
      format.json(),
    ),
    transports: [new transports.Console()],
    exitOnError: false,
  });

  log(message: unknown, ...optionalParams: unknown[]): void {
    this.write('info', message, optionalParams);
  }

  error(message: unknown, ...optionalParams: unknown[]): void {
    this.write('error', message, optionalParams);
  }

  warn(message: unknown, ...optionalParams: unknown[]): void {
    this.write('warn', message, optionalParams);
  }

  debug(message: unknown, ...optionalParams: unknown[]): void {
    this.write('debug', message, optionalParams);
  }

  verbose(message: unknown, ...optionalParams: unknown[]): void {
    this.write('verbose', message, optionalParams);
  }

  fatal(message: unknown, ...optionalParams: unknown[]): void {
    this.write('fatal', message, optionalParams);
  }

  private write(
    level: LogLevel,
    message: unknown,
    optionalParams: unknown[],
  ): void {
    const params = [...optionalParams];
    const isStack = (value: unknown): value is string =>
      typeof value === 'string' && /\n\s+at\s/u.test(value);
    let contextName: string | undefined;
    let stack: string | undefined;

    const tail = params[params.length - 1];
    if (level === 'error') {
      if (isStack(tail)) {
        stack = params.pop() as string;
      } else if (typeof tail === 'string') {
        contextName = params.pop() as string;
        const previous = params[params.length - 1];
        if (isStack(previous)) {
          stack = params.pop() as string;
        }
      }
    } else if (typeof tail === 'string') {
      contextName = params.pop() as string;
    }

    const error = message instanceof Error ? message : undefined;
    const normalizeParam = (value: unknown): unknown =>
      value instanceof Error
        ? { name: value.name, message: value.message, stack: value.stack }
        : value;

    // Winston의 log(entry)는 문자열 message를 요구하므로 객체는 JSON 문자열로 바꿉니다.
    const text =
      typeof message === 'string' ? message : JSON.stringify(message);

    this.logger.log({
      level,
      message: error?.message ?? text,
      stack: error?.stack ?? stack,
      context: contextName,
      params: params.length ? params.map(normalizeParam) : undefined,
    });
  }
}

이 어댑터는 Nest의 일반적인 호출 관례에 맞춰 오류의 스택과 마지막 context 문자열을 나누고, 나머지 메타데이터는 params에 보존합니다. Error 메타데이터는 JSON으로 바꿀 때 핵심 필드가 사라지지 않도록 name, message, stack으로 정리합니다. 임의의 문자열만으로 모든 호출 의도를 완벽히 추측할 수는 없으므로, 팀이 쓰는 래퍼의 호출 형태를 고정하고 대표적인 호출을 테스트합니다.

이 예시의 key 이름 기반 가리기(redact)는 출발점일 뿐입니다. redact는 최상위 key만 검사하므로, 이메일을 params 안의 객체로 넘기면 최상위에는 params만 보여 안쪽의 email이 가려지지 않습니다. 메시지 문자열이나 스택에 섞인 비밀도 key 이름 검사로는 지워지지 않습니다. 실제 로그 스키마는 허용 목록과 테스트로 관리하고, 자격 증명, 요청 본문, 쿠키, 이메일, 원본 userId는 기본적으로 수집하지 않습니다. 사용자를 연결해야 한다면 접근이 통제된 감사 로그에서 교체할 수 있는 가명 ID를 검토하며, 지표 label과 trace baggage에는 넣지 않습니다.

분산 trace는 로그에 ID 문자열을 붙이는 것만으로 연결되지 않습니다. 신뢰하는 내부 구간에서는 OpenTelemetry context propagation으로 들어오는 요청에서 traceparent를 꺼내고, 나가는 HTTP, 큐, RPC 요청에 넣어야 downstream의 span이 같은 trace에 속합니다. 외부에서 들어온 trace 헤더는 그대로 믿지 말고 허용한 형식과 tenant 범위에 따라 무시하거나 정리해 새 trace를 시작하며, 외부로 나가는 요청에서는 내부 trace ID와 baggage의 전달을 정책으로 제한합니다.


Correlate: 같은 사건의 자료 연결하기

대시보드에서 사용자 영향이 보이면, 같은 시간대와 service.name, 환경, 바뀌지 않는 릴리스 ID를 기준으로 지표, 로그, trace, 배포 이벤트를 좁혀 갑니다. 요청 ID는 한 서비스 안에서 검색을 돕는 수단이고, 서비스를 넘나드는 인과관계는 전달된 trace context로 확인합니다.

연결이 완벽하다고 가정하지 않습니다. 샘플링 때문에 trace가 없을 수 있고, 수집기의 부하나 네트워크 문제로 로그가 늦게 오거나 사라질 수 있습니다. 그래서 대시보드에는 신호가 얼마나 최신인지, 수집 실패, 수집기 큐와 버려진 데이터도 함께 표시합니다.


Alert: 담당자가 바로 판단할 수 있게 전달하기

알림은 “문제가 있을 수 있다”는 메시지로 끝나면 안 됩니다. 담당자, 심각도, 영향받는 SLI와 SLO 기간, 오류 예산의 소진 속도, 대시보드, 로그와 trace 쿼리, 현재 릴리스 ID, runbook을 함께 전달합니다.

Google SRE의 SLO 알림처럼 사용자 영향과 오류 예산의 소진 속도(burn rate)를 기준으로 호출(page)과 티켓을 나누고, 트래픽이 적은 서비스에서는 실패 한 건이 과도한 소진 속도처럼 보일 수 있다는 점을 고려합니다. “실시간 알림”을 약속하기보다 측정, 평가, 전달의 주기와 허용할 감지 지연을 미리 정해 둡니다.

더 깊이: 소진 속도 알림의 기준 숫자

소진 속도(burn rate)는 SLO에 비해 오류 예산을 얼마나 빨리 쓰고 있는지를 나타냅니다. 소진 속도가 1이면 SLO 기간이 끝날 때 예산을 정확히 다 쓰는 속도이고, 14.4이면 그보다 14.4배 빠릅니다. SRE 워크북은 30일 SLO를 기준으로, 예산의 2%를 1시간 만에 쓰는 소진 속도 14.4와 5%를 6시간 만에 쓰는 6을 호출(page) 기준으로, 10%를 3일 동안 쓰는 1을 티켓 기준으로 출발점에 제시합니다. 14.4라는 숫자는 30일이 720시간이므로 1시간 동안 14.4배로 쓰면 14.4 ÷ 720, 곧 예산의 2%가 사라진다는 계산에서 나옵니다.

이 기준은 긴 창 하나만 보지 않고 짧은 창을 함께 봅니다. 워크북은 짧은 창을 긴 창의 12분의 1로 두기를 권하므로, 1시간 기준에는 5분, 6시간 기준에는 30분, 3일 기준에는 6시간 창이 짝을 이룹니다. 두 창이 모두 기준을 넘을 때만 알림을 보내므로, 오류가 멈추면 긴 창이 다 비워지기를 기다리지 않고 몇 분 안에 알림이 풀립니다. 이 글의 예처럼 28일 SLO를 쓴다면 같은 비율이라도 소진 속도의 숫자를 그 기간에 맞춰 다시 계산해야 합니다.


Act: 알림을 명시적인 조치로 바꾸기

알림 자체가 롤백은 아닙니다. 담당자는 릴리스의 변경 내용, 오류 추이, trace와 의존성 상태를 확인한 뒤 트래픽 제한, 확장 조정, feature flag 끄기, 의존성 전환, 이전 정상 릴리스로의 롤백 같은 runbook의 조치 가운데 하나를 고릅니다.

자동 롤백을 쓰려면 플랫폼의 기능을 따로 설정하고, 감지 조건, 대상 릴리스, 권한, 중단 조건, 감사 기록을 검증해야 합니다. 예를 들어 ECS에서 실패를 감지한 뒤 롤백하려면 deployment circuit breaker의 rollback 설정을 명시해야 하며, 일반적인 알림이 이를 켜 주지 않습니다.


Verify: 사용자 영향과 다음 기준 확인하기

조치한 뒤 프로세스가 떠 있다는 사실만 확인하지 않습니다. 합의한 회복 시간 동안 사용자 SLI, readiness에 넣은 필수 의존성, liveness로 인한 재시작, 트래픽 분포, 태스크나 Pod 수, 로그와 trace의 오류를 다시 확인합니다.

담당자는 조치, 결과, 남은 위험, 후속 작업을 장애 기록에 남깁니다. 확인된 결과는 runbook, 대시보드 쿼리, 알림 경로, 빠진 계측을 고치는 데 쓰이고 다음 측정의 기준이 됩니다. 장애를 감추려고 SLO를 느슨하게 바꾸지 않으며, 이해관계자와 합의하고 충분한 기간의 자료가 쌓였을 때만 SLO 정책을 다시 검토합니다.

지표, 로그, trace, 알림을 많이 갖추는 것이 목표는 아닙니다. 사용자 영향을 측정하고, 연결된 자료로 담당자가 조치하며, 확인된 결과가 다음 운영 기준에 반영될 때 관측 체계가 하나의 반복 과정으로 완성됩니다.


정리

  • SLI는 사용자가 겪는 서비스 수준을 숫자로 나타내고, SLO는 기간과 대상 요청을 정한 목표이며, 오류 예산은 1 - SLO로 정해지는 허용 실패입니다.
  • 지표 label에는 가짓수가 제한된 값만 쓰고, 클라우드의 인프라 지표가 애플리케이션 SLI를 대신하지 않습니다.
  • @nestjs/terminus 12에서는 TypeOrmHealthIndicator.pingCheck()에 .withTimeout()을 이어 시간을 제한하고, startup·readiness·liveness의 역할을 나눕니다.
  • 로그는 구조화한 JSON을 표준 출력으로 내보내고, 서비스를 넘나드는 연결은 OpenTelemetry의 trace context 전달로 확인합니다.
  • 알림은 담당자와 runbook을 함께 전달하며, 조치 뒤에는 프로세스 생존이 아니라 사용자 SLI의 회복을 확인합니다.

연습 문제

1. 오류 예산을 넘었을 때

다른 28일 구간에 유효한 요청이 200,000건, 성공률 SLO가 99.9%이고, 같은 분류 기준으로 실패가 260건이었습니다. 허용하는 실패 수와 초과분을 계산해 보세요. 알림이 울렸다는 사실만으로 자동 롤백이 되었다고 볼 수 있을까요? 조치 뒤에도 DB 연결이 끊겨 있는데 live가 2xx를 반환한다면 회복을 확인한 것일까요?

해설 보기

허용하는 실패는 200000 × 0.001 = 200건이므로 실패는 60건을 넘었고, 예산의 260 ÷ 200 = 1.3, 즉 130%를 썼습니다. 이 계산만으로 특정 시간대의 소진 속도나 원인을 안 것은 아니며, 담당자가 사용자 영향과 릴리스·의존성 자료를 확인해 합의한 정책과 runbook에 따라 조치를 고릅니다.

알림은 조치가 수행되었거나 성공했다는 표시가 아닙니다. 또 live의 최소 응답은 DB 준비 상태나 사용자 SLI의 회복을 보장하지 않으므로, readiness, 성공률과 지연, 트래픽 분포, 관측 누락까지 다시 확인해야 합니다.

2. 가려지지 않은 이메일

이 글의 Winston 어댑터를 쓰면서 logger.log('login attempt', { email: '[email protected]' })를 호출했습니다. 출력된 JSON에서 이메일은 가려질까요? 최상위 필드로 email을 넘기는 다른 래퍼를 썼다면 어땠을까요?

해설 보기

어댑터는 메시지 뒤의 객체를 params 배열에 넣으므로, 출력의 최상위 key는 message, context, params 같은 것뿐입니다. redact는 최상위 key만 검사하기 때문에 params 안의 email은 가려지지 않고 그대로 기록됩니다.

최상위에 email key가 오는 래퍼였다면 [REDACTED]로 바뀌었을 것입니다. 이처럼 호출 형태에 따라 결과가 달라지므로, 가리기에 기대기보다 처음부터 이메일 같은 값을 로그로 넘기지 않게 스키마와 테스트로 막는 편이 안전합니다.

3. CPU는 여유로운데

ECS 서비스의 CPUUtilization은 30%로 여유롭습니다. 그런데 사용자들이 결제 API가 자주 실패한다고 알려 왔고, 애플리케이션 /metrics의 결제 route 성공률은 95%로 떨어져 있었습니다. 어느 지표를 기준으로 알림을 정했어야 할까요?

해설 보기

CPU 사용률은 인프라의 자원 상태를 보여 줄 뿐, 사용자가 요청에 성공하는지는 알려 주지 않습니다. 외부 결제 서비스의 오류나 데이터베이스 잠금처럼 CPU를 거의 쓰지 않는 원인으로도 요청은 실패할 수 있습니다.

알림은 결제 route의 성공률처럼 사용자 영향을 직접 나타내는 SLI와, 그 SLO의 오류 예산 소진 속도를 기준으로 정해야 합니다. CPU 같은 인프라 지표는 원인을 좁히는 보조 자료로 대시보드에 함께 둡니다.

이것으로 11장을 마칩니다. 11장에서는 Docker 컨테이너화, CI/CD 파이프라인, 클라우드 배포, 모니터링과 로깅까지 애플리케이션을 배포하고 운영하는 흐름을 이어 보았습니다. 다음 장의 서버리스 NestJS (AWS Lambda 통합)부터는 서버리스, 실시간 통신, CQRS, DDD 같은 고급 주제를 다룹니다.