모니터링 장애 대응 실습 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건 전부 성공) |
| HikariCP | active 0~1, pending 0 |
이 수치는 로컬 Docker 환경에서 10 VU가 1분간 같은 API 믹스를 호출한 결과입니다. 절대 성능값이라기보다, 이후 장애 실험을 같은 조건에서 비교하기 위한 기준선입니다.


이 표가 이후 시리즈 전체의 비교 기준이 됩니다. 2편에서 p95가 450ms를 찍었을 때 “11배 급등”이라고 말할 수 있는 근거가 이 41.7ms이고, 에러율 패널의 0% 평선이 있어야 93% 수직 상승이 “이상”으로 보입니다. baseline 없이 incident 그래프만 보면 그게 원래 그런 서비스인지 장애인지 알 수 없습니다.
로그 축도 끝까지 검증했습니다. 요청 하나를 보내면 응답 헤더로 X-Request-Id가 돌아오고, 그 값을 Grafana 대시보드의 requestId 변수에 넣으면 해당 요청의 요청과 응답 로그 2라인이 정확히 걸립니다.

메트릭에서 “언제”를 찾고 로그에서 “왜”를 좁히는 이 동선은 이후 네 번의 장애에서 계속 쓰입니다. 그리고 한 번은 이 동선이 완전히 막히는 장애도 만납니다(3편).
이제 일부러 망가뜨린다
다음 편에서는 DB 커넥션 풀을 고갈시키고, 100만 행 풀스캔으로 응답을 지연시킵니다. 같은 “자원” 장애인데 하나는 에러율 93%까지 치솟았고, 하나는 에러 0건인 채 조용히 11배 느려졌습니다. 에러율 패널만 보고 있었다면 두 번째 장애는 놓쳤을 것입니다.