--- name: observability-and-instrumentation description: "프로덕션 동작을 볼 수 있고 진단할 수 있도록 코드를 계측합니다. 로깅, 메트릭, 트레이싱, 알림을 추가할 때 사용하세요. 프로덕션에서 실행되는 기능을 출시하면서 동작한다는 증거가 필요할 때 사용하세요. 프로덕션 이슈가 보고되었지만 있는 데이터만으로는 무슨 일이 있었는지 알 수 없을 때 사용하세요." --- # Observability and Instrumentation ## 개요 관찰할 수 없는 코드는 운영할 수 없는 코드입니다. 옵저버빌리티는 코드가 내보내는 텔레메트리로, 바깥에서 "시스템이 무엇을 하고 있고, 왜 그런가?"에 답하는 능력입니다. 계측은 출시 뒤에 붙이는 부가 기능이 아닙니다. 테스트와 같이 기능과 함께 작성합니다. 텔레메트리 없이 기능이 출시되면, 사용자가 처음 제보한 버그는 쿼리로 찾는 일이 아니라 발굴 작업이 됩니다. ## 언제 사용하나 - 프로덕션에서 실행될 기능을 만들 때 - 새 서비스, 엔드포인트, 백그라운드 작업, 외부 연동을 추가할 때 - 프로덕션 인시던트를 진단하는 데 너무 오래 걸렸을 때 ("무슨 일이 있었는지 알 수 없었다") - 알림 규칙을 설정하거나 리뷰할 때 - I/O, 재시도, 큐, 서비스 간 호출을 추가하는 풀 리퀘스트를 리뷰할 때 **대상이 아닌 경우:** - 지금 당장 일어나고 있는 실패를 진단할 때. `debugging-and-error-recovery` 스킬을 사용하세요. 옵저버빌리티는 다음에 그 스킬이 빨라지게 만드는 것입니다. - 측정된 느림을 프로파일링하고 최적화할 때. `performance-optimization` 스킬을 사용하세요. - 출시 당일의 모니터링 체크리스트와 롤백 트리거. `shipping-and-launch` 스킬을 보세요. 이 스킬은 거기에 데이터를 공급하는 계측을 다룹니다. ## 프로세스 ### 1. 계측하기 전에 "동작함"을 정의하세요 질문 없는 텔레메트리는 잡음입니다. 계측을 추가하기 전에, 온콜 엔지니어가 이 기능에 대해 물을 질문 2~4개를 적으세요. ``` 기능: 결제 재시도 (checkout payment retry) 온콜이 물을 질문: 1. 첫 시도에 성공하는 결제 비율과, 재시도 후에 성공하는 비율은 각각 얼마인가? 2. 결제가 최종적으로 실패할 때 이유는 무엇인가? (제공자 오류? 타임아웃? 검증 실패?) 3. 결제 제공자가 평소보다 느린가? → 아래의 모든 시그널은 이 질문 중 하나를 답하는 데 도움이 되어야 한다. ``` 질문을 말할 수 없다면 계측할 준비가 되지 않은 것입니다. 모든 것을 로그로 남기고도 아무것도 배우지 못합니다. ### 2. 각 질문에 맞는 시그널을 고르세요 | 시그널 | 답하는 것 | 비용 특성 | 예시 | |---|---|---|---| | **구조화 로그** | "이 특정 경우에 무슨 일이 있었나?" | 이벤트마다 발생하며, 트래픽에 따라 늘어남 | 제공자 오류 코드가 있는 `payment_failed` | | **메트릭** | "얼마나 자주, 얼마나 빠른가? 집계로는?" | 시계열마다 고정이며, 조회 비용이 낮음 | 제공자 호출의 p99 지연 시간 | | **트레이스** | "서비스들 사이에서 시간은 어디로 갔나?" | 요청마다 발생하며, 보통 샘플링됨 | 느린 결제 하나, 홉별로 나눔 | 경험칙: 메트릭은 무언가가 잘못되었다는 **사실**을, 트레이스는 **어디**를, 로그는 **왜**를 알려 줍니다. ### 3. 구조화 로깅 산문이 아니라 이벤트를 로그로 남기세요. 모든 로그 줄은 안정적인 이벤트 이름과, 기계가 읽을 수 있는 필드가 있는 JSON 객체입니다. ```typescript // BAD: string interpolation — unqueryable, inconsistent logger.info(`Payment ${id} failed for user ${userId} after ${n} retries`); // GOOD: stable event name + structured fields logger.warn({ event: 'payment_failed', paymentId: id, provider: 'stripe', errorCode: err.code, attempt: n, }, 'payment failed'); ``` **로그 레벨은 일관되게 사용하세요:** | 레벨 | 의미 | 온콜 조치 | |---|---|---| | `error` | 불변 조건이 깨짐. 누군가 조치해야 할 수 있음 | 조사 | | `warn` | 저하되었지만 처리됨 (재시도 성공, 폴백 사용) | 추세를 지켜봄 | | `info` | 중요한 비즈니스 이벤트 (주문 접수, 작업 완료) | 없음 | | `debug` | 진단용 세부 정보 | 프로덕션에서는 기본적으로 끔 | **상관 ID는 필수입니다.** 시스템 경계에서 요청 ID를 만들거나 받아들이고, 모든 로그 줄, 스팬, 나가는 호출에 붙이세요. 이것이 없으면 뒤섞인 로그에서 요청 하나를 다시 조립할 수 없습니다. ```typescript // Express: child logger per request, ID propagated downstream app.use((req, res, next) => { req.id = req.headers['x-request-id'] ?? crypto.randomUUID(); req.log = logger.child({ requestId: req.id }); res.setHeader('x-request-id', req.id); next(); }); ``` **여러 진입점이 로그 하나에 쓰면, 진입점의 이름을 남기세요.** 상관 ID는 실행 하나를 식별합니다. 어느 코드 경로가 그 실행을 시작했는지는 말하지 않습니다. 스케줄러, 리플레이 엔드포인트, 수동 CLI 실행으로 도달한 같은 작업은 로그 싱크 하나 안에서 서로 구분되지 않는 줄을 만듭니다. 그러면 한 줄을 귀속하려면 소거에 기대게 됩니다. 스케줄러 이력, 프로세스 테이블, 배포 로그를 교차로 읽는 것입니다. 그 주장은 그 외부 기록이 아직 남아 있을 때에만 성립합니다. 실행이 시작되는 곳에, 상관 ID 옆에 진입점을 찍고, 둘을 같은 방식으로 전파하세요. ```typescript // One helper for every entry point: the run's own logger carries both fields. // `entryPoint`, not `source` — ECS reserves `source.*` for network fields. export const runLog = (entryPoint: 'scheduler' | 'replay_endpoint' | 'cli', runId: string) => logger.child({ entryPoint, requestId: runId }); // scheduler tick -> runLog('scheduler', crypto.randomUUID()) // POST /jobs/:id/replay -> runLog('replay_endpoint', req.id) // CLI invocation -> runLog('cli', process.env.RUN_ID ?? crypto.randomUUID()) ``` 두 필드 모두 상관 ID와 같은 경계, 즉 큐 메타데이터와 HTTP 헤더를 넘어야 합니다. 그렇지 않으면 워커가 진입점을 다시 유도하면서 추측합니다. 진입점과 단지 상관관계가 있을 뿐인 필드는 힌트이지 귀속이 아닙니다. 그 작업을 호출할 수 있는 것이면 무엇이든 그 필드를 재현할 수 있습니다. **시크릿, 토큰, 비밀번호, 전체 PII를 로그에 남기지 마세요.** 이것은 `security-and-hardening` 스킬의 절대 규칙입니다. 텔레메트리 파이프라인은 데이터가 새는 전형적인 경로입니다. 필드는 허용 목록으로 고르세요. 요청 본문 전체를 로그에 남기지 마세요. ### 4. 메트릭 요청 기반 서비스에서는 모든 엔드포인트와 모든 외부 의존성에 **RED**를 계측하세요. **R**ate(초당 요청 수), **E**rrors(실패율), **D**uration(평균이 아닌 지연 시간 히스토그램)입니다. 리소스(큐, 풀, 호스트)에는 **USE**를 쓰세요. **U**tilization(사용률), **S**aturation(포화), **E**rrors(오류)입니다. 트레이싱과 같이, 벤더에 묶이지 않는 경로는 OpenTelemetry 메트릭 API입니다(5단계와 같은 SDK와 컨텍스트). 아래 예시는 Prometheus의 `prom-client`를 사용합니다. 흔한 백엔드 선택지 하나이며, 유일한 방법은 아닙니다. RED/USE와 카디널리티 규칙은 어느 쪽이든 같습니다. ```typescript import { Histogram } from 'prom-client'; const httpDuration = new Histogram({ name: 'http_request_duration_seconds', help: 'HTTP request duration', labelNames: ['method', 'route', 'status_class'], // '2xx', not '200' buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], }); ``` **실패가 나는 지점은 카디널리티입니다.** 고유한 라벨 조합마다 별도의 시계열이 됩니다. 라벨은 작고 고정된 집합에서 와야 합니다(라우트 템플릿, 상태 클래스, 제공자 이름). 사용자 ID, 원본 URL, 오류 메시지, 그 밖의 한도 없는 값을 라벨로 쓰지 마세요. 그런 값은 로그와 트레이스에 둡니다. ``` 라벨로 적합: route="/api/tasks/:id" status_class="5xx" provider="stripe" 라벨로 금지: user_id, email, request_id, 전체 URL, 오류 메시지 본문 ``` 평균은 추적하지 말고, 백분위는 항상 추적하세요. 평균은 매우 나쁜 경험을 하는 사용자 1%를 숨깁니다. 히스토그램을 쓰고 p50/p95/p99를 읽으세요. ### 5. 분산 트레이싱 OpenTelemetry를 사용하세요. 벤더에 묶이지 않는 표준이고, 자동 계측이 HTTP, gRPC, 흔한 DB 클라이언트를 코드 거의 없이 덮습니다. ```typescript // tracing.ts — must be imported before anything else import { NodeSDK } from '@opentelemetry/sdk-node'; import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'; const sdk = new NodeSDK({ serviceName: 'checkout-service', instrumentations: [getNodeAutoInstrumentations()], }); sdk.start(); ``` 수동 스팬은 의미 있는 내부 작업 단위(예: `applyDiscounts`, `chargeProvider`) 주위에만 추가하고, 온콜이 걸러 볼 속성을 붙이세요. 모든 비동기 경계(HTTP 헤더, 큐 메시지 메타데이터)를 넘어 컨텍스트를 전파하세요. 그렇지 않으면 트레이스가 그 틈에서 끊깁니다. 기본값은 낮은 비율의 헤드 기반 샘플링으로 두세요. 백엔드가 테일 샘플링을 지원하면 오류는 100% 남기세요. ### 6. 알림 원인이 아니라, 사용자가 체감하는 **증상**에 알림을 거세요. ``` 증상 (페이지할 가치): 원인 (대시보드용, 페이지 아님): 5분간 오류율 > 1% CPU 85% p99 지연 시간 > 2초 파드 하나 재시작 큐 대기 > 10분 디스크 70% ``` 원인 기반 알림은 아무 문제도 없을 때 울리고, 예측하지 못한 실패는 놓칩니다. 증상 기반 알림은 원인과 상관없이, 사용자가 피해를 보는 바로 그때 울립니다. 만드는 알림마다 다음 규칙을 적용하세요. 1. **조치할 수 있어야 합니다.** 대응이 "무시하세요, 저절로 회복됩니다"라면 그 알림을 지우세요. 2. **런북으로 연결되어야 합니다.** 세 줄이라도 됩니다. 무슨 의미인지, 먼저 실행할 쿼리, 에스컬레이션 경로. 3. **임계값과 지속 시간**이 있어야 하며, 추측이 아니라 SLO나 과거 데이터로 근거가 있어야 합니다. 4. 심각도는 두 가지만 쓰세요. **페이지**(사용자에게 영향이 있고, 지금 조치)와 **티켓**(저하이며, 이번 주에 조치). 세 번째 등급은 사람들이 전부를 무시하도록 길들이는 잡음이 됩니다. #### 런북 작성 위의 규칙 2는 모든 알림이 런북으로 연결될 것을 요구합니다. 런북의 역할은 읽는 사람이 생각하지 않아도 세 가지 질문에 답하는 것입니다. 무슨 일이 일어나고 있는가, 먼저 무엇을 확인할 것인가, 그것으로 해결되지 않으면 누구에게 연락할 것인가. `docs/runbooks/`에 알림 이름을 따서 저장하세요. **최소로 쓸 수 있는 런북 (세 줄):** ```markdown # 런북: /api/tasks의 높은 오류율 **의미:** DB 커넥션 풀이 고갈되었거나, 잘못된 배포일 가능성이 큽니다. **첫 확인:** `SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend';` 개수가 풀 한도를 넘으면 2단계를 보세요. (사용 중인 데이터베이스에 맞는 쿼리로 바꾸세요.) **에스컬레이션:** #db-oncall 또는 엔지니어링 온콜 로테이션. ``` **세 줄을 넘어 늘릴 때:** 첫 확인만으로는 판단하기에 부족할 때만 단계를 추가하세요. 가장 흔한 원인 세 가지를 다루는 다섯 단계 런북이, 모든 엣지 케이스를 다루지만 훑히기만 하는 스무 단계 문서보다 낫습니다. **런북을 최신으로 유지하세요.** 그 런북을 쓴 인시던트를 닫을 때마다 런북을 업데이트하세요. 오래된 런북은 잘못된 확신을 만듭니다. 단계가 틀렸거나 빠져 있으면, 인시던트를 해결됨으로 표시하기 전에 고치세요. ### 7. 텔레메트리 자체를 검증하세요 계측도 코드입니다. 틀릴 수 있습니다. 작업을 끝났다고 하기 전에, 해당 경로를 발생시키고 실제 출력을 보세요. - 스테이징에서 오류를 강제로 일으키세요. 로그에서 `requestId`로 찾고, 필드가 구조화되어 있는지 확인하세요 (`[object Object]`가 아니어야 합니다). - 테스트 트래픽을 보내세요. 메트릭 시계열이 기대한 라벨과 타당한 값으로 나타나는지 확인하세요. - 트레이싱 UI에서 요청 하나를 서비스들에 걸쳐 따라가 보세요. 끊긴 스팬이 없어야 합니다. - 새 알림을 각각 한 번씩 발생시키세요 (일시적으로 임계값을 낮추세요). 올바른 채널에 도착하고 런북 링크가 동작하는지 확인하세요. ## 흔한 합리화 | 합리화 | 현실 | |---|---| | "동작한 다음에 로깅을 넣겠다" | "다음"은 "첫 인시던트 다음"이 됩니다. 눈이 멀어 있었다는 사실을 깨닫기에 가장 비싼 순간입니다. 만들면서 계측하세요. | | "로그가 많을수록 옵저버빌리티도 많다" | 구조화되지 않은 잡음은 인시던트를 더 느리게 만듭니다. 조회할 수 있는 이벤트 세 개가 산문 삼백 줄보다 낫습니다. | | "당장은 `console.log`면 된다" | 구조화되지 않은 출력은 걸러 낼 수도, 서로 엮을 수도, 알림을 걸 수도 없습니다. 구조화 로거는 한 번 설정하는 데 5분이 더 들 뿐입니다. | | "뭔가 깨지면 대시보드를 보면 된다" | 질문이 정해지지 않은 채 만든 대시보드는 답을 빼놓고 모든 것을 보여 줍니다. 온콜 질문에서 시작하세요. | | "중요한 것에는 전부 알림을 걸고, 튜닝은 나중에 하자" | 시끄러운 페이저는 사람들이 그것을 무시하도록 길들입니다. 튜닝은 일어나지 않고, 진짜 페이지를 놓치는 일은 일어납니다. | | "메트릭 라벨에 사용자 ID를 넣으면 디버깅이 쉬워진다" | 메트릭 백엔드가 주저앉기도 합니다. 카디널리티가 높은 조회는 로그와 트레이스에 둡니다. | | "서비스가 두 개뿐인데 트레이싱은 과하다" | 서비스가 두 개만 되어도, 로그로는 답할 수 없는 서비스 간 지연 시간 질문이 생깁니다. 자동 계측은 그 비용을 사소하게 만듭니다. | ## 레드 플래그 - 재시도, 큐, 외부 호출이 있는데 새 텔레메트리가 없는 기능 풀 리퀘스트 - 구조화 필드가 아니라 문자열 보간으로 만든 로그 줄 - 상관 ID나 요청 ID가 없다. 각 로그 줄이 고립되어 있다 - 스케줄러, 웹훅, 수동 실행이 로그 스트림 하나에 쓰이는데, 어느 것이 그 줄을 만들었는지 말하는 필드가 없다 - 사용자 ID, 원본 URL, 오류 메시지 본문으로 라벨이 붙은 메트릭 (카디널리티 폭탄) - 백분위 없이 평균으로만 추적하는 지연 시간 - 매일 울리고, 조치 없이 인지 처리만 되는 알림 - 사용자에게 보이는 오류율은 모니터링하지 않은 채, 원인(CPU, 메모리) 알림이 사람에게 페이지를 보낸다 - 로그에 시크릿, 토큰, 요청 본문 전체가 나타난다 - 프로덕션 기능이 건강하다는 유일한 증거가 "It works on my machine"(내 컴퓨터에서는 됩니다)이다 ## 검증 기능을 계측한 뒤 다음을 확인하세요. - [ ] 이 기능의 온콜 질문이 적혀 있고, 각 시그널이 그 중 하나에 대응한다 - [ ] 모든 로그 출력이 구조화되어 있고(JSON), 안정적인 이벤트 이름이 있으며, 모든 줄에 상관 ID가 있다 - [ ] 진입점이 둘 이상인 모든 로그 싱크에 진입점 필드가 있다. 그 필드는 실행이 시작되는 곳에서 설정되고, 하류에서 추론되지 않으며, 상관 ID와 함께 전파된다 - [ ] 어떤 로그 줄에도 시크릿, 토큰, 마스킹하지 않은 PII가 없다 (실제 출력을 표본으로 확인) - [ ] 모든 새 엔드포인트와 모든 외부 의존성에 RED 메트릭이 있고, 라벨 집합은 한도가 있다 - [ ] 지연 시간은 히스토그램이며, p95/p99를 조회할 수 있다 - [ ] 트레이싱 UI에서 요청 하나를 끝까지 따라갈 수 있고, 끊긴 스팬이 없다 - [ ] 모든 새 알림은 증상 기반이고, 런북 링크가 있으며, 테스트로 한 번씩 발생시켜 보았다 - [ ] 스테이징에서 일부러 일으킨 실패를, 소스를 읽지 않고 텔레메트리만으로 찾았다 이 목록을 한눈에 보는 버전과 출시 전 계측 게이트는 `../../references/observability-checklist.md`를 보세요.