모니터링과 로깅 시스템 구축
메트릭, 로그, 분산 추적을 수집해 관측 신호를 연결하고 SLI, SLO, 오류 예산을 owner의 조치와 검증으로 닫는 운영 기준을 세웁니다.
지난 절에서는 NestJS 애플리케이션을 여러 클라우드 실행 모델에 배포하는 기준을 정했습니다.
이번 절에서는 배포된 서비스가 사용자의 기대를 충족하는지 측정하고, 이상을 발견했을 때 담당자가 근거를 모아 조치한 뒤 결과를 다음 운영 기준에 반영하는 방법을 다룹니다. 여기서 관측성은 “모든 것을 실시간으로 본다”는 약속이 아닙니다. 각 신호의 scrape, sampling, 전송, 저장 지연을 알고도 필요한 판단을 제때 내릴 수 있게 만드는 계약입니다.
OBSERVABILITY · OPERATING FEEDBACK LOOP
신호를 owner의 조치와 검증으로 닫는다
누적 운영 기준을 중심으로 다섯 station이 한 바퀴를 이룹니다. 수집 주기와 지연을 드러내고, alert를 runbook과 명시적 조치로 연결한 뒤 검증된 결과만 다음 pass의 기준에 기록합니다.
누적 운영 기준
SLO · error budget · runbook
검증된 결과만 다음 pass의 기준으로 사용합니다.
-
Measure
SLI, metric, log, trace를 정한 주기와 sampling으로 수집합니다. 제한된 label을 써 cardinality를 통제하고 ECS 또는 EKS의 세부 telemetry는 서비스와 수집 설정 범위 안에서 해석합니다.
-
Correlate
시간, immutable release ID와 전파된 trace context로 신호를 연결합니다. driver, collector, retention이 정한 로그 수명과 signal freshness, drop을 함께 확인합니다.
-
Alert
SLO burn과 사용자 영향을 owner, runbook, dashboard, release ID와 함께 page 또는 ticket으로 전달합니다. alert는 자동 rollback을 뜻하지 않습니다.
-
Act
담당자가 근거를 확인하고 traffic 제한, scale, feature flag, failover 또는 명시적 rollback 가운데 runbook 조치를 선택합니다.
-
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를 검토해 선택하거나, 별도로 검증해 설정한 플랫폼 기능이 수행합니다.
누적 운영 기준: SLI, SLO, 오류 예산, runbook
먼저 사용자가 실제로 경험하는 결과를 기준으로 SLI와 SLO를 정합니다.
- SLI: 성공 요청 비율, 유효 응답의 지연시간처럼 서비스 수준을 수치로 나타낸 지표입니다.
- SLO: 합의한 기간 동안 SLI가 충족해야 할 목표입니다. 예를 들어 “최근 28일 동안 유효 요청의 99.9%가 성공”처럼 기간과 모집단을 함께 고정합니다.
- 오류 예산:
1 - SLO로 허용한 불량 이벤트의 비율 또는 수입니다. 팀은 예산 소진 정책을 기능 배포, 안정화 작업, incident review와 연결합니다. - runbook: alert가 울렸을 때 owner가 확인할 dashboard, query, release record, 첫 완화 조치, escalation과 종료 조건을 기록합니다.
p95 < 300ms나 99.9%는 설명을 위한 예일 뿐 모든 API의 기본값이 아닙니다. 사용자의 기대, 트래픽 분포, 의존성, 비용을 바탕으로 측정 가능한 목표를 합의해야 합니다. 오류 예산 정책도 숫자만 두지 말고 소진 시 누가 어떤 변경을 멈추고 어떤 신뢰성 작업을 우선할지 명시합니다.
Measure: 필요한 신호를 경계와 함께 수집
메트릭
NestJS 애플리케이션은 prom-client 등으로 /metrics를 노출하고 Prometheus 또는 관리형 Prometheus가 정해진 주기로 scrape할 수 있습니다. 요청 성공률, 지연 분포, 처리량, queue depth처럼 SLI와 원인 분석에 필요한 지표를 먼저 수집합니다.
Prometheus에서는 label 조합마다 새로운 time series가 생깁니다. 공식 metric과 label 지침에 따라 method, 정규화된 route, status_code처럼 값의 집합이 제한된 label을 사용하고, userId, 이메일, request ID처럼 값이 계속 늘어나는 식별자는 metric label에 넣지 않습니다.
클라우드 메트릭도 서비스와 설정 범위를 구분해야 합니다.
- Amazon ECS는 실행 중인 service의
CPUUtilization,MemoryUtilization같은 기본 service metric을 CloudWatch에 게시합니다. task, container, network, storage 수준의 더 세밀한 관측은 cluster에 Container Insights를 설정해야 합니다. - Amazon EKS의 pod와 node telemetry는 자동으로 모두 생기지 않습니다. CloudWatch Observability add-on 또는 Helm chart, 필요한 agent, IAM, 수집 설정을 설치해야 Container Insights와 관련 신호를 보낼 수 있습니다.
- 애플리케이션 SLI는 인프라 CPU metric이 대신하지 않습니다.
/metrics, OpenTelemetry instrumentation 또는 관리형 APM을 별도로 구성하고 수집 누락도 alert 대상으로 봅니다.
health endpoint
@nestjs/terminus는 health indicator를 조합해 readiness endpoint를 만들 수 있습니다. 다음 예시는 HTTP 서버가 응답할 수 있는 상태와 요청 처리에 필수인 데이터베이스 의존성을 분리합니다. TypeOrmModule과 실제 connection은 애플리케이션 모듈에서 이미 구성되었다고 가정합니다.
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([
() => this.database.pingCheck('database'),
]);
}
}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 완료를 뜻합니다. 서버를 먼저 열고 cache warm-up 같은 초기화를 계속한다면 startup이 그 완료 상태를 별도로 확인해야 합니다.
세 endpoint는 같은 의미가 아닙니다. Kubernetes probe 계약을 기준으로 보면 다음과 같습니다.
- startup은 느린 초기화가 끝날 때까지 readiness와 liveness 실행을 보류합니다. 허용 횟수를 넘겨 실패하면 container restart로 이어집니다.
- readiness는 지금 새 traffic을 받을 수 있는지 판단합니다. 실패하면 matching Service의 endpoint에서 제외되지만 container를 재시작하지 않습니다. 데이터베이스처럼 요청 처리에 필수인 의존성은 여기에 포함하고, 일시적인 외부 장애를 liveness에 넣어 연쇄 restart를 만들지 않습니다.
- liveness는 deadlock처럼 restart 없이는 회복하기 어려운 process 상태만 판단합니다. 반복 실패는 container restart를 유도합니다.
플랫폼마다 이름과 반응이 다르므로 이 세 endpoint를 모든 상품에 그대로 복사하지 않습니다. 이전 절에서 정한 traffic exclusion, restart, termination과 drain 계약에 맞춰 각 플랫폼 기능에 매핑합니다.
로그와 trace
컨테이너에서는 애플리케이션이 구조화 JSON을 stdout과 stderr로 내보내고 runtime의 logging driver나 collector가 destination으로 전달하는 구성이 단순합니다. 그러나 stdout에 썼다는 사실만으로 보존이 보장되지는 않습니다. Docker logging driver 설정처럼 driver, rotation, node storage, collector delivery mode가 로컬 수명을 결정하고, CloudWatch Logs 같은 destination의 retention 설정이 중앙 보존 기간을 결정합니다.
다음 Winston adapter는 undefined consoleFormat, 생략된 LoggerService method, container 내부 rotating file을 제거하고 하나의 JSON console transport만 사용합니다.
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;
this.logger.log({
level,
message: error?.message ?? message,
stack: error?.stack ?? stack,
context: contextName,
params: params.length ? params.map(normalizeParam) : undefined,
});
}
}이 adapter는 Nest의 일반 호출 관례에 맞춰 error의 stack과 마지막 context 문자열을 분리하고, 나머지 metadata를 params에 보존합니다. Error metadata는 기본 JSON 직렬화에서 핵심 필드가 사라지지 않도록 name, message, stack으로 정규화합니다. 임의 문자열만으로 모든 호출 의도를 완벽히 추론할 수는 없으므로 팀 wrapper의 호출 시그니처를 고정하고 대표 호출을 테스트하세요. 이 예시의 key 기반 redaction은 출발점입니다. message, params, stack, 중첩 object까지 포함한 실제 logging schema는 허용 목록과 테스트로 관리하고 credential, request body, cookie, email, 원본 userId를 기본 수집 대상에서 제외합니다. 사용자 상관관계가 꼭 필요하면 접근이 통제된 audit log에서 회전 가능한 pseudonymous ID를 검토하며 metric label과 trace baggage에는 넣지 않습니다.
분산 trace는 로그에 ID 문자열을 추가하는 것만으로 연결되지 않습니다. 신뢰한 내부 경계에서는 OpenTelemetry context propagation을 통해 inbound carrier에서 traceparent를 extract하고 outbound HTTP, queue, RPC carrier에 inject해야 downstream span이 같은 trace에 속합니다. 외부 ingress의 trace header는 신뢰하지 말고 허용한 형식과 tenant 경계에 따라 무시하거나 정제해 새 trace를 시작하며, public egress에서는 내부 trace ID와 baggage 전파를 정책으로 제한합니다. 활성 span이 있는 log에는 traceId와 spanId를 기록해 log와 trace를 이동할 수 있게 합니다.
Correlate: 같은 사건의 근거를 연결
대시보드에서 사용자 영향이 보이면 같은 시간 구간과 service.name, 환경, immutable release ID를 기준으로 metric, log, trace, deployment event를 좁힙니다. request ID는 한 서비스 안의 검색 보조 수단이고, 서비스 경계를 넘는 인과관계는 전파된 trace context로 확인합니다.
상관관계가 완전하다고 가정하지 않습니다. sampling으로 trace가 없을 수 있고 collector backpressure나 network 문제로 log가 늦거나 유실될 수 있습니다. dashboard에는 signal freshness, scrape failure, collector queue와 drop도 함께 표시합니다.
Alert: owner가 즉시 판단할 수 있게 전달
alert는 “문제가 있을 수 있다”는 메시지에서 끝나면 안 됩니다. owner, 심각도, 영향받는 SLI와 SLO window, error budget burn, dashboard, log/trace query, 현재 release ID, runbook을 함께 전달합니다.
Google SRE의 SLO alerting처럼 사용자 영향과 오류 예산 소진 속도를 기준으로 page와 ticket을 나누고, 저트래픽 서비스에서는 단일 실패가 과도한 burn rate로 보일 수 있음을 고려합니다. 절대적인 “실시간 alert” 대신 측정, 평가, 전달 interval과 허용 detection delay를 계약으로 둡니다.
Act: alert를 명시적 조치로 바꾸기
alert 자체는 rollback이 아닙니다. owner가 release diff, error trend, trace와 dependency 상태를 확인한 뒤 traffic 제한, scale 조정, feature flag 비활성화, dependency failover, 이전 정상 release로 rollback 같은 runbook 조치 중 하나를 선택합니다.
자동 rollback을 사용하려면 플랫폼별 기능을 별도로 설정하고 감지 조건, 대상 release, 권한, 중단 조건과 audit evidence를 검증해야 합니다. 예를 들어 ECS의 실패 감지 후 rollback은 deployment circuit breaker의 rollback 설정을 명시해야 하며, 일반적인 alert가 자동으로 활성화하지 않습니다.
Verify: 사용자 영향과 다음 기준을 검증
조치 직후 process가 떠 있다는 사실만 확인하지 않습니다. 합의한 recovery window 동안 사용자 SLI, readiness에 포함한 필수 dependency, liveness restart, traffic distribution, task 또는 pod 수, log와 trace error를 다시 확인합니다.
owner는 조치, 결과, 남은 위험, 후속 작업을 incident record에 남깁니다. 검증된 결과는 runbook과 dashboard query, alert routing, instrumentation gap을 갱신하고 다음 Measure의 기준이 됩니다. incident를 숨기기 위해 SLO를 느슨하게 바꾸는 것이 아니라, 이해관계자와의 합의와 충분한 기간의 evidence가 있을 때만 SLO 정책을 다시 검토합니다.
메트릭, 로그, trace, alert를 많이 보유하는 것이 완료 조건은 아닙니다. 사용자 영향을 측정하고, 서로 연결된 근거로 owner가 조치하며, 검증된 결과가 다음 운영 기준에 반영될 때 관측성 시스템이 닫힌 loop가 됩니다.