모니터링 장애 대응 실습 1편 - 정상이 뭔지 모르면 장애도 못 본다

목차

“모니터링 장애 대응 실습” 4부작의 1편입니다. 1편(현재 글) · 2편. 자원이 마르면 생기는 일 · 3편. 앱이 죽어가는 신호 · 4편(예정)

장애 대응 경험은 기다린다고 오지 않는다

백엔드 채용공고에는 “모니터링 시스템을 이용한 장애 대응 경험”을 요구하는 경우가 많습니다. 개념은 말할 수 있지만, 실무에서 장애를 직접 마주하고 해결해 볼 기회는 아직 없었습니다.

그래서 기다리는 대신 직접 만들기로 했습니다. 모니터링 환경을 바닥부터 구성하고, 장애 4종(커넥션 풀 고갈, 느린 쿼리, 메모리 누수/OOM, 에러율 급증)을 일부러 일으켜서 탐지하고, 원인을 좁히고, 해결하고, 재발 방지까지. 사이클을 끝까지 돌려보는 프로젝트입니다.

모니터링 글은 많지만 대부분 “Grafana 설치하고 대시보드 띄우기”에서 끝납니다. 이 시리즈는 그 다음, 즉 대시보드에 실제로 이상이 나타나는 순간부터를 다룹니다. 다만 그 전에 반드시 해야 하는 일이 있었습니다. 이 글의 주제인 정상 상태가 어떤 모양인지부터 아는 것입니다.

도구는 질문에서 골랐다

장애를 일으키고 나면 답해야 할 질문은 두 개였습니다.

  • “언제부터 이상했는가?” 추세를 보여주는 축이 필요합니다. 메트릭의 몫입니다.
  • “왜 이상했는가?” 개별 요청의 맥락을 담는 축이 필요합니다. 로그의 몫입니다.

이 두 질문을 기준으로 스택을 골랐고, 두 축을 잇는 끈으로 요청마다 UUID(requestId)를 MDC에 심어 메트릭에서 찾은 이상 시점을 로그에서 바로 좁힐 수 있게 했습니다.

선택하면서 고민한 지점들:

  • 메트릭 축은 고민이 짧았습니다. Spring이면 Actuator + Micrometer + Prometheus가 사실상 표준입니다.
  • 로그 수집에는 OpenTelemetry를 쓰지 않았습니다. OTLP appender와 collector 층이 하나 더 생기는데, 장애를 일부러 내는 실습에서 수집 파이프라인에 먼저 장애가 나면 곤란합니다. 앱은 Logback으로 JSON 파일만 남기고, Grafana Alloy가 그 파일을 tail 해서 Loki로 push하는 단순한 구조를 택했습니다.
  • ELK 대신 Loki를 쓴 것도 같은 이유입니다. 노트북 도커에 컨테이너 6개(앱, PostgreSQL, Prometheus, Loki, Alloy, Grafana)를 띄워야 하는 환경에서, 전문 인덱싱을 하는 Elasticsearch는 이 실습엔 과했습니다. Loki는 라벨만 인덱싱해서 가볍습니다.
  • 트레이스(Tempo 등)는 뺐습니다. 단일 서비스에 DB 하나인 구조라 메트릭과 로그 2축으로 탐지에서 원인 추적까지 닫힌다고 판단했습니다. 이 판단이 맞았는지는 4편에서 돌아봅니다.

부하는 k6로 만듭니다. 대상 앱은 Spring Boot 3.5(Java 21) + PostgreSQL 16이고, 느린 쿼리 실습(2편)을 위해 product 테이블에 100만 행을 미리 시드해뒀습니다.

구축하며 막힌 곳들

전체 설정 파일을 나열하는 건 이 글의 목적이 아니니, 막혔던 지점만 남깁니다. 정상 상태를 믿을 수 있는 기준으로 만들기 위해 반드시 정리해야 했던 것들입니다.

1. p95/p99 패널이 빈 화면이었다

Micrometer Timer는 기본으로 count/sum/max만 노출합니다. 분위수 패널을 그리려면 히스토그램 버킷 노출 설정 한 줄이 필요했습니다.

management.metrics.distribution.percentiles-histogram.http.server.requests: true

대시보드의 p95 패널은 이렇게 노출된 버킷으로 계산합니다.

histogram_quantile(0.95, sum by (le) (rate(http_server_requests_seconds_bucket[1m])))

원본 응답시간을 정렬해 구한 값이 아니라 버킷 기반 추정치라는 것도 이때 알았습니다. 이 추정이 실제 값과 얼마나 어긋나는지는 응답 시간 백분위에 따로 정리했습니다. 참고로 _max는 슬라이딩 시간 창(기본 약 2분)의 최댓값일 뿐 p99가 아닙니다. 처음엔 이걸로 때우려다 그래프가 널뛰는 걸 보고 접었습니다.

2. 에러율 패널이 “No data”였다

5xx가 0건이면 분자 쿼리가 빈 결과를 반환해 패널 자체가 비어버립니다. or vector(0)을 붙여 0% 평선이 그려지게 했습니다. “정상이라 0%“와 “데이터가 없음”은 운영 화면에서 다른 메시지입니다.

3. requestId를 Loki 라벨로 넣으면 안 된다

요청마다 바뀌는 UUID를 라벨로 두면 시계열이 요청 수만큼 폭발합니다(카디널리티 문제). 라벨은 app, level 두 개만 두고, requestId는 로그 본문 필드로 넣어 | json | requestId=...로 검색하게 했습니다.

4. scrape 주기는 5초로 줄였다

기본 15초로는 빠르게 진행되는 장애의 결정적 구간을 놓칠 수 있다고 봤는데, 이 선택은 2편에서 바로 보상받습니다. 커넥션 풀 대기열이 첫 scrape 5초 만에 0에서 45로 점프하는 걸 잡아냈습니다. 물론 실습이라 부담 없이 줄인 것이고, 운영이라면 저장 비용과 rate 윈도우 크기를 함께 따져야 합니다.

5. 헬스체크, 포트, uid에서 시간을 잃었다

Loki 공식 이미지엔 wget이 없어 compose healthcheck를 걸 수 없었고(Alloy의 push 재시도로 대체), Windows에서는 Alloy 기본 포트 12345가 예약 포트 범위와 충돌해 12100으로 옮겼습니다. Grafana 데이터소스 uid를 고정하지 않으면 환경 재구축 시 대시보드 참조가 깨진다는 것도 한 번 깨지고 나서 알았습니다. 그래서 대시보드는 UI에서 클릭으로 만들지 않고 JSON으로 직접 작성해 Git으로 관리합니다. 스택을 지우고 다시 띄워도 같은 화면이 자동으로 뜹니다.

같은 조건의 부하로 비교 기준을 고정했다

스택이 다 떠 있는 것과 “정상이 뭔지 아는 것”은 다른 문제입니다. 대시보드가 처음 완성됐을 때의 화면은 이랬습니다.

관측 스택 구성 직후의 통합 대시보드

여기에 k6로 10 VU, 1분 부하를 흘려서 “평소의 우리 서비스”를 숫자로 박아뒀습니다.

지표baseline 값
처리량약 8.3 RPS
p95 레이턴시41.7ms (평균 10ms)
실패율0.00% (504건 전부 성공)
HikariCPactive 0~1, pending 0

이 수치는 로컬 Docker 환경에서 10 VU가 1분간 같은 API 믹스를 호출한 결과입니다. 절대 성능값이라기보다, 이후 장애 실험을 같은 조건에서 비교하기 위한 기준선입니다.

부하가 흐르는 동안의 baseline 대시보드

baseline p95/p99 레이턴시 실측

이 표가 이후 시리즈 전체의 비교 기준이 됩니다. 2편에서 p95가 450ms를 찍었을 때 “11배 급등”이라고 말할 수 있는 근거가 이 41.7ms이고, 에러율 패널의 0% 평선이 있어야 93% 수직 상승이 “이상”으로 보입니다. baseline 없이 incident 그래프만 보면 그게 원래 그런 서비스인지 장애인지 알 수 없습니다.

로그 축도 끝까지 검증했습니다. 요청 하나를 보내면 응답 헤더로 X-Request-Id가 돌아오고, 그 값을 Grafana 대시보드의 requestId 변수에 넣으면 해당 요청의 요청과 응답 로그 2라인이 정확히 걸립니다.

requestId로 특정 요청의 로그 2라인을 드릴다운

메트릭에서 “언제”를 찾고 로그에서 “왜”를 좁히는 이 동선은 이후 네 번의 장애에서 계속 쓰입니다. 그리고 한 번은 이 동선이 완전히 막히는 장애도 만납니다(3편).

이제 일부러 망가뜨린다

다음 편에서는 DB 커넥션 풀을 고갈시키고, 100만 행 풀스캔으로 응답을 지연시킵니다. 같은 “자원” 장애인데 하나는 에러율 93%까지 치솟았고, 하나는 에러 0건인 채 조용히 11배 느려졌습니다. 에러율 패널만 보고 있었다면 두 번째 장애는 놓쳤을 것입니다.

다음 편: 모니터링 장애 대응 실습 2편 - 자원이 마르면 생기는 일