diff --git a/grafana/provisioning/dashboards/catcheat-loadtest-dashboard.json b/grafana/provisioning/dashboards/catcheat-loadtest-dashboard.json new file mode 100644 index 0000000..0c54d2f --- /dev/null +++ b/grafana/provisioning/dashboards/catcheat-loadtest-dashboard.json @@ -0,0 +1,2217 @@ +{ + "annotations": { + "list": [ + { + "name": "k6 시나리오 시작", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "enable": true, + "iconColor": "rgba(0, 211, 255, 1)", + "expr": "changes(k6_vus_max[1m]) > 0", + "titleFormat": "k6 단계 전환", + "tagKeys": "scenario" + }, + { + "name": "백엔드 재기동", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "enable": true, + "iconColor": "rgba(255, 96, 96, 1)", + "expr": "changes(process_start_time_seconds{job=\"catchtable-app\"}[1m]) > 0", + "titleFormat": "백엔드 재시작 — 측정 신뢰성 영향" + } + ] + }, + "description": "캐치테이블 부하 테스트 전용 대시보드 — k6 메트릭과 백엔드 자원 메트릭을 한 화면에서 결합 관찰", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 100, + "title": "Row A — 부하 발생 상태 (k6 시점)", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short", + "decimals": 0 + } + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 0, + "y": 1 + }, + "id": 101, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "textMode": "value" + }, + "title": "활성 VUs", + "description": "k6가 지금 실제로 굴리고 있는 가상 사용자 수. 시나리오 stages의 target 값을 그대로 따라가야 정상.", + "type": "stat", + "targets": [ + { + "expr": "max_over_time(k6_vus[5m])", + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + } + ] + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "reqps", + "decimals": 1 + } + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 4, + "y": 1 + }, + "id": 102, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "textMode": "value" + }, + "title": "k6 발생 RPS", + "description": "k6 클라이언트에서 발사된 초당 요청 수. 아래 '백엔드 수신 RPS'와 일치해야 네트워크 무손실.", + "type": "stat", + "targets": [ + { + "expr": "max_over_time(sum(rate(k6_http_reqs_total[1m]))[5m:30s])", + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + } + ] + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 1 + }, + { + "color": "red", + "value": 5 + } + ] + }, + "unit": "percent", + "decimals": 2 + } + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 8, + "y": 1 + }, + "id": 103, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "textMode": "value" + }, + "title": "k6 에러율", + "description": "k6 expected_response=false 비율. 1% 노랑(조사), 5% 빨강(테스트 실패).", + "type": "stat", + "targets": [ + { + "expr": "max_over_time((sum(rate(k6_http_reqs_total{expected_response=\"false\"}[1m])) / sum(rate(k6_http_reqs_total[1m])) * 100)[5m:30s])", + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + } + ] + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 500 + }, + { + "color": "red", + "value": 1000 + } + ] + }, + "unit": "ms" + } + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 12, + "y": 1 + }, + "id": 104, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "textMode": "value" + }, + "title": "k6 p95", + "description": "5분 슬라이딩 윈도우 k6 p95. SLO 1초를 넘으면 빨강. 백엔드 p95와 100~200ms 이내로 일치해야 정상.", + "type": "stat", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "expr": "max_over_time((avg(k6_http_req_duration_p95) * 1000)[5m:30s])", + "refId": "A" + } + ] + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 1000 + }, + { + "color": "red", + "value": 3000 + } + ] + }, + "unit": "ms" + } + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 16, + "y": 1 + }, + "id": 105, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "textMode": "value" + }, + "title": "k6 p99", + "description": "p99. p95와 크게 벌어지면 outlier 발생 — GC pause, 슬로우 쿼리, 락 대기 의심.", + "type": "stat", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "expr": "max_over_time((avg(k6_http_req_duration_p99) * 1000)[5m:30s])", + "refId": "A" + } + ] + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + }, + "unit": "short", + "decimals": 0 + } + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 20, + "y": 1 + }, + "id": 106, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "textMode": "value" + }, + "title": "누적 요청수", + "description": "테스트 시작부터 지금까지 k6가 발사한 총 요청수. 시나리오 종료 후 보고서용.", + "type": "stat", + "targets": [ + { + "expr": "sum(k6_http_reqs_total)", + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + } + ] + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 2, + "showPoints": "never", + "lineInterpolation": "smooth" + }, + "unit": "short", + "decimals": 0, + "min": 0 + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "최대 VUs" + }, + "properties": [ + { + "id": "custom.lineStyle", + "value": { + "dash": [ + 10, + 10 + ], + "fill": "dash" + } + }, + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "gray" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 5 + }, + "id": 107, + "options": { + "legend": { + "calcs": [ + "last", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "title": "A.1 VUs 단계 진행", + "description": "시나리오의 ramping/constant 단계가 의도대로 굴러가는지 확인. 점프 시점이 보고서 가로축.", + "type": "timeseries", + "targets": [ + { + "expr": "k6_vus", + "legendFormat": "활성 VUs", + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + }, + { + "expr": "k6_vus_max", + "legendFormat": "최대 VUs", + "refId": "B", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + } + ] + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 2, + "showPoints": "never", + "lineInterpolation": "smooth" + }, + "unit": "reqps", + "decimals": 1, + "min": 0 + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 5 + }, + "id": 108, + "options": { + "legend": { + "calcs": [ + "last", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "title": "A.2 k6 발생 RPS vs 백엔드 수신 RPS", + "description": "두 라인이 어긋나면 네트워크 손실 또는 nginx 단계 큐잉 발생. 차이가 클수록 인프라 병목.", + "type": "timeseries", + "targets": [ + { + "expr": "sum(rate(k6_http_reqs_total[1m]))", + "legendFormat": "k6 발생", + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + }, + { + "expr": "sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*\"}[1m]))", + "legendFormat": "백엔드 수신", + "refId": "B", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + }, + { + "expr": "sum(rate(k6_http_reqs_total{expected_response=\"false\"}[1m]))", + "legendFormat": "k6 에러", + "refId": "C", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + } + ] + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 13 + }, + "id": 200, + "title": "Row B — 응답 시간 (k6 ↔ 백엔드 일치 검증)", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 2, + "showPoints": "never", + "lineInterpolation": "smooth" + }, + "unit": "ms", + "min": 0 + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "k6 p50" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "blue" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "k6 p95" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "yellow" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "k6 p99" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "red" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "백엔드 p95" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "orange" + } + }, + { + "id": "custom.lineStyle", + "value": { + "dash": [ + 6, + 4 + ], + "fill": "dash" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 14 + }, + "id": 201, + "options": { + "legend": { + "calcs": [ + "last", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "title": "B.1 k6 p50/p95/p99 + 백엔드 p95", + "description": "k6 p95와 백엔드 p95가 200ms 이상 벌어지면 네트워크/nginx 단계 지연. p99이 튀면 GC·락·슬로우 쿼리 의심.", + "type": "timeseries", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "expr": "avg(k6_http_req_duration_p50) * 1000", + "refId": "A", + "legendFormat": "k6 p50" + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "expr": "avg(k6_http_req_duration_p95) * 1000", + "refId": "B", + "legendFormat": "k6 p95" + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "expr": "avg(k6_http_req_duration_p99) * 1000", + "refId": "C", + "legendFormat": "k6 p99" + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "expr": "histogram_quantile(0.95, sum by (le) (rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*\"}[1m]))) * 1000", + "refId": "D", + "legendFormat": "백엔드 p95" + } + ] + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 1, + "showPoints": "never", + "lineInterpolation": "smooth" + }, + "unit": "ms", + "min": 0 + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 14 + }, + "id": 202, + "options": { + "legend": { + "calcs": [ + "last", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "title": "B.2 구간별 평균 (TTFB / TCP / 응답수신)", + "description": "서버 대기(TTFB)가 90% 이상이면 서버 측 문제. TCP 연결이 크면 keepalive 부재 또는 SSL 핸드셰이크 비용.", + "type": "timeseries", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "expr": "avg(k6_http_req_waiting_avg) * 1000", + "refId": "A", + "legendFormat": "TTFB(서버 대기)" + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "expr": "avg(k6_http_req_connecting_avg) * 1000", + "refId": "B", + "legendFormat": "TCP 연결" + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "expr": "avg(k6_http_req_receiving_avg) * 1000", + "refId": "C", + "legendFormat": "응답 수신" + } + ] + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 2, + "showPoints": "never", + "lineInterpolation": "smooth" + }, + "unit": "ms", + "min": 0 + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*spike.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "red" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*ramp.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "blue" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 22 + }, + "id": 203, + "options": { + "legend": { + "calcs": [ + "last", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "title": "B.3 시나리오별 p95 (event_spike vs ramp_up)", + "description": "스파이크 라인이 ramp 라인보다 200ms 이상 위면 → 커넥션풀/스레드풀이 스파이크를 못 받음.", + "type": "timeseries", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "expr": "{__name__=~\"k6_.*_spike_duration_p95\"} * 1000", + "refId": "A", + "legendFormat": "spike p95 ({{__name__}})" + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "expr": "{__name__=~\"k6_.*_ramp_duration_p95\"} * 1000", + "refId": "B", + "legendFormat": "ramp p95 ({{__name__}})" + } + ] + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 1, + "showPoints": "never", + "lineInterpolation": "smooth" + }, + "unit": "ms", + "min": 0 + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 22 + }, + "id": 204, + "options": { + "legend": { + "calcs": [ + "last", + "max" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "title": "B.4 엔드포인트별 백엔드 p95 (상위 10)", + "description": "어느 API가 가장 느린지 식별. 1000ms 초과 라인이 무조건 1순위 최적화 대상.", + "type": "timeseries", + "targets": [ + { + "expr": "topk(10, histogram_quantile(0.95, sum by (le, uri) (rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*\"}[1m])))) * 1000", + "legendFormat": "{{uri}}", + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + } + ] + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 30 + }, + "id": 300, + "title": "Row C — 백엔드 자원 압력 (USE 매서드)", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 70 + }, + { + "color": "red", + "value": 85 + } + ] + }, + "unit": "percent", + "min": 0, + "max": 100, + "decimals": 1 + } + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 0, + "y": 31 + }, + "id": 301, + "options": { + "orientation": "vertical", + "showThresholdLabels": true, + "showThresholdMarkers": true, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + } + }, + "title": "C.1 JVM Heap 사용률", + "description": "70% 이상 GC 빈도 증가, 85% 이상 OOM 임박. t3.small 환경에서는 보수적으로 본다.", + "type": "gauge", + "targets": [ + { + "expr": "sum(jvm_memory_used_bytes{area=\"heap\"}) / sum(jvm_memory_max_bytes{area=\"heap\"}) * 100", + "instant": true, + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + } + ] + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 70 + }, + { + "color": "red", + "value": 90 + } + ] + }, + "unit": "percent", + "min": 0, + "max": 100, + "decimals": 1 + } + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 6, + "y": 31 + }, + "id": 302, + "options": { + "orientation": "vertical", + "showThresholdLabels": true, + "showThresholdMarkers": true, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + } + }, + "title": "C.2 프로세스 CPU", + "description": "JVM 프로세스 CPU. 90% 지속이면 CPU 병목 — 응답시간이 같이 튀고 있는지 B.1과 함께 본다.", + "type": "gauge", + "targets": [ + { + "expr": "process_cpu_usage * 100", + "instant": true, + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + } + ] + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 2, + "showPoints": "never", + "lineInterpolation": "smooth" + }, + "unit": "short", + "min": 0 + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "max" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "gray" + } + }, + { + "id": "custom.lineStyle", + "value": { + "dash": [ + 10, + 10 + ], + "fill": "dash" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "busy" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "red" + } + } + ] + } + ] + }, + "gridPos": { + "h": 6, + "w": 12, + "x": 12, + "y": 31 + }, + "id": 303, + "options": { + "legend": { + "calcs": [ + "last", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "title": "C.3 Tomcat 워커 스레드", + "description": "busy / max 비율이 70% 초과 노랑, 90% 초과 빨강. max 도달 시 신규 요청 거부.", + "type": "timeseries", + "targets": [ + { + "expr": "tomcat_threads_busy_threads", + "legendFormat": "busy", + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + }, + { + "expr": "tomcat_threads_current_threads", + "legendFormat": "current", + "refId": "B", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + }, + { + "expr": "tomcat_threads_config_max_threads", + "legendFormat": "max", + "refId": "C", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + } + ] + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 40, + "lineWidth": 2, + "showPoints": "never", + "lineInterpolation": "smooth", + "stacking": { + "mode": "normal" + } + }, + "unit": "bytes", + "min": 0 + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 37 + }, + "id": 304, + "options": { + "legend": { + "calcs": [ + "last", + "max" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "title": "C.4 Heap 영역별 사용량", + "description": "Old 영역이 우상향이면 메모리 누수. Soak 테스트에서 가장 중요한 그래프.", + "type": "timeseries", + "targets": [ + { + "expr": "sum by (id) (jvm_memory_used_bytes{area=\"heap\"})", + "legendFormat": "{{id}}", + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + } + ] + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 0.01 + }, + { + "color": "red", + "value": 0.05 + } + ] + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 1, + "showPoints": "never", + "lineInterpolation": "smooth", + "thresholdsStyle": { + "mode": "line" + } + }, + "unit": "s", + "decimals": 4, + "min": 0 + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 37 + }, + "id": 305, + "options": { + "legend": { + "calcs": [ + "last", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "title": "C.5 GC 점유 시간 (초당)", + "description": "초당 10ms(=1%) 초과 시 사용자 응답 지연. 50ms 초과는 튜닝 필수.", + "type": "timeseries", + "targets": [ + { + "expr": "rate(jvm_gc_pause_seconds_sum[1m])", + "legendFormat": "{{action}} ({{cause}})", + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + } + ] + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 45 + }, + "id": 400, + "title": "Row D — 백엔드 데이터레이어 (DB / Redis / Kafka)", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 2, + "showPoints": "never", + "lineInterpolation": "smooth" + }, + "unit": "short", + "min": 0, + "decimals": 0 + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "max" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "gray" + } + }, + { + "id": "custom.lineStyle", + "value": { + "dash": [ + 10, + 10 + ], + "fill": "dash" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "pending" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "red" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "active" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "orange" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "idle" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "blue" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 46 + }, + "id": 401, + "options": { + "legend": { + "calcs": [ + "last", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "title": "D.1 HikariCP 커넥션 풀", + "description": "pending ≥ 1 = 풀 부족 확정 (락 대기 또는 풀 크기 증설 필요). active가 max에 근접하면 DB 병목 임박.", + "type": "timeseries", + "targets": [ + { + "expr": "hikaricp_connections_active", + "legendFormat": "active", + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + }, + { + "expr": "hikaricp_connections_idle", + "legendFormat": "idle", + "refId": "B", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + }, + { + "expr": "hikaricp_connections_pending", + "legendFormat": "pending", + "refId": "C", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + }, + { + "expr": "hikaricp_connections_max", + "legendFormat": "max", + "refId": "D", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + } + ] + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 0.05 + }, + { + "color": "red", + "value": 0.2 + } + ] + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 1, + "showPoints": "never", + "lineInterpolation": "smooth", + "thresholdsStyle": { + "mode": "line" + } + }, + "unit": "s", + "decimals": 4, + "min": 0 + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 46 + }, + "id": 402, + "options": { + "legend": { + "calcs": [ + "last", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "title": "D.2 HikariCP 평균 대기/사용 시간", + "description": "acquire 급증 = 풀 부족. usage 급증 = 슬로우 쿼리 또는 긴 트랜잭션.", + "type": "timeseries", + "targets": [ + { + "expr": "rate(hikaricp_connections_acquire_seconds_sum[1m]) / clamp_min(rate(hikaricp_connections_acquire_seconds_count[1m]), 0.001)", + "legendFormat": "acquire wait", + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + }, + { + "expr": "rate(hikaricp_connections_usage_seconds_sum[1m]) / clamp_min(rate(hikaricp_connections_usage_seconds_count[1m]), 0.001)", + "legendFormat": "usage", + "refId": "B", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + } + ] + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 2, + "showPoints": "never", + "lineInterpolation": "smooth" + }, + "unit": "ops", + "min": 0 + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 54 + }, + "id": 403, + "options": { + "legend": { + "calcs": [ + "last", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "title": "D.3 Redis 명령 처리량 (Lettuce)", + "description": "분산락(시나리오 02), 쿠폰 Lua(08), 빈자리 SADD/SMEMBERS(11)는 모두 Redis 처리량으로 검증.", + "type": "timeseries", + "targets": [ + { + "expr": "sum by (command) (rate(lettuce_command_completion_seconds_count[1m]))", + "legendFormat": "{{command}}", + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + } + ] + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 100 + }, + { + "color": "red", + "value": 1000 + } + ] + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 20, + "lineWidth": 2, + "showPoints": "never", + "lineInterpolation": "smooth", + "thresholdsStyle": { + "mode": "line" + } + }, + "unit": "short", + "min": 0, + "decimals": 0 + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 54 + }, + "id": 404, + "options": { + "legend": { + "calcs": [ + "last", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "title": "D.4 Kafka Consumer Lag", + "description": "처리 안 된 메시지 누적량. 0 유지가 목표. 빈자리 알림 시나리오(11)에서 가장 중요한 그래프.", + "type": "timeseries", + "targets": [ + { + "expr": "sum by (topic, partition) (kafka_consumer_fetch_manager_records_lag)", + "legendFormat": "{{topic}} p{{partition}}", + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + } + ] + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 62 + }, + "id": 500, + "title": "Row E — 시나리오별 비즈니스 메트릭", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 2, + "showPoints": "never", + "lineInterpolation": "smooth" + }, + "unit": "ops", + "min": 0 + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "예약 성공" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "green" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "좌석 소진" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "blue" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "락 충돌" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "yellow" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 63 + }, + "id": 501, + "options": { + "legend": { + "calcs": [ + "last", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "title": "E.1 시나리오 02 — 예약 동시성 결과", + "description": "성공 + 좌석 소진 + 락 충돌의 합계가 총 요청수와 일치해야 함. 5xx로 빠지면 동시성 제어 실패.", + "type": "timeseries", + "targets": [ + { + "expr": "rate(k6_reservation_success_total[1m])", + "legendFormat": "예약 성공", + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + }, + { + "expr": "rate(k6_reservation_exhausted_total[1m])", + "legendFormat": "좌석 소진", + "refId": "B", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + }, + { + "expr": "rate(k6_reservation_conflict_total[1m])", + "legendFormat": "락 충돌", + "refId": "C", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + } + ] + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 2, + "showPoints": "never", + "lineInterpolation": "smooth" + }, + "unit": "ops", + "min": 0 + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 63 + }, + "id": 502, + "options": { + "legend": { + "calcs": [ + "last", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "title": "E.2 시나리오 08 — 쿠폰 발급 결과", + "description": "발급 성공 누적 합계 ≤ 재고 수량이어야 정상. 초과 발급 시 비관적 락 오작동.", + "type": "timeseries", + "targets": [ + { + "expr": "rate(k6_coupon_issued_total[1m])", + "legendFormat": "발급 성공", + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + }, + { + "expr": "rate(k6_coupon_exhausted_total[1m])", + "legendFormat": "재고 소진", + "refId": "B", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + }, + { + "expr": "rate(k6_coupon_duplicate_total[1m])", + "legendFormat": "중복 방지", + "refId": "C", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + } + ] + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 20, + "lineWidth": 2, + "showPoints": "never", + "lineInterpolation": "smooth" + }, + "unit": "ops", + "min": 0 + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "successful" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "green" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "failed" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "red" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "not_permitted" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "orange" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 71 + }, + "id": 503, + "options": { + "legend": { + "calcs": [ + "last", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "title": "E.3 시나리오 04 — 챗봇 Circuit Breaker", + "description": "ramp_up 후반에 not_permitted가 늘기 시작하는 시점이 CB OPEN 임계점. recovery 단계에서 successful이 회복되면 HALF_OPEN→CLOSED 정상.", + "type": "timeseries", + "targets": [ + { + "expr": "sum by (kind) (rate(resilience4j_circuitbreaker_calls_total{name=~\"ai-api|chatbot\"}[1m]))", + "legendFormat": "{{kind}}", + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + }, + { + "expr": "rate(k6_circuit_open_responses_total[1m])", + "legendFormat": "k6 빠른 실패", + "refId": "B", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + }, + { + "expr": "rate(k6_circuit_closed_responses_total[1m])", + "legendFormat": "k6 정상 응답", + "refId": "C", + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + } + } + ] + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 2, + "showPoints": "never", + "lineInterpolation": "smooth" + }, + "unit": "ms", + "min": 0 + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 71 + }, + "id": 504, + "options": { + "legend": { + "calcs": [ + "last", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "title": "E.4 시나리오 11 — 빈자리 SADD / SMEMBERS 응답시간", + "description": "Redis SADD/SMEMBERS p95가 sub-ms 수준이어야 정상. 10ms 넘으면 Redis 또는 네트워크 문제 의심.", + "type": "timeseries", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "expr": "k6_subscribe_duration_ms_p95 * 1000", + "refId": "A", + "legendFormat": "subscribe p95 (k6)" + }, + { + "datasource": { + "type": "prometheus", + "uid": "DS_PROMETHEUS" + }, + "expr": "k6_smembers_duration_ms_p95 * 1000", + "refId": "B", + "legendFormat": "SMEMBERS p95 (k6)" + } + ] + } + ], + "refresh": "5s", + "schemaVersion": 38, + "tags": [ + "k6", + "load-test", + "catchtable" + ], + "templating": { + "list": [ + { + "name": "scenario", + "label": "시나리오", + "type": "custom", + "query": "01-store-browse,02-reservation-concurrency,03-full-flow,04-circuit-breaker,05-store-list,06-store-nearby,07-remains,08-coupon-issue,10-soak,11-vacancy-test", + "current": { + "selected": false, + "text": "전체", + "value": "$__all" + }, + "includeAll": true, + "multi": false + } + ] + }, + "time": { + "from": "now-30m", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m" + ], + "time_options": [ + "5m", + "15m", + "30m", + "1h", + "3h" + ] + }, + "timezone": "Asia/Seoul", + "title": "캐치테이블 — 부하 테스트 전용 대시보드", + "uid": "catcheat-loadtest-v1", + "version": 1, + "weekStart": "" +} \ No newline at end of file diff --git a/grafana/provisioning/dashboards/catcheat-monitoring-dashboard.json b/grafana/provisioning/dashboards/catcheat-monitoring-dashboard.json new file mode 100644 index 0000000..e27cb28 --- /dev/null +++ b/grafana/provisioning/dashboards/catcheat-monitoring-dashboard.json @@ -0,0 +1,522 @@ +{ + "annotations": { + "list": [ + { + "name": "배포", + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "enable": true, + "iconColor": "rgba(0, 211, 255, 1)", + "expr": "changes(process_start_time_seconds{job=\"catchtable-app\"}[1m]) > 0", + "titleFormat": "백엔드 재시작/배포" + } + ] + }, + "description": "캐치테이블 백엔드 운영 모니터링 — RED+USE+비즈니스+회복성 통합. 실사용 트래픽이든 부하 테스트든 동일한 패널로 진단.", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + { "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 100, "title": "Row 1 — 서비스 개요 (5초 안에 정상/장애 판단)", "type": "row" }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": null }, { "color": "green", "value": 1 } ] }, + "mappings": [ + { "type": "value", "options": { "1": { "text": "UP", "color": "green" }, "0": { "text": "DOWN", "color": "red" } } } + ], + "unit": "none", "noValue": "DOWN", "min": 0, "max": 1, "decimals": 0 + } + }, + "gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 }, + "id": 101, + "options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "value" }, + "title": "서비스 가동 상태", + "description": "UP=정상, DOWN=Prometheus가 백엔드를 못 긁음. 다른 지표는 UP일 때만 의미.", + "type": "stat", + "targets": [ { "expr": "up{job=\"catchtable-app\"}", "instant": true, "refId": "A", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } } ] + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null } ] }, + "unit": "short", "decimals": 0, "min": 0 + } + }, + "gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 }, + "id": 102, + "options": { "colorMode": "value", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["mean"] }, "textMode": "value" }, + "title": "분당 요청 수", + "description": "actuator 제외 1분 평균 RPS × 60. 평소 패턴 대비 급증/급감이 첫 신호.", + "type": "stat", + "targets": [ { "expr": "sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*\"}[1m])) * 60", "refId": "A", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } } ] + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "yellow", "value": 1 }, { "color": "red", "value": 5 } ] }, + "unit": "percent", "decimals": 2, "min": 0, "noValue": "0" + } + }, + "gridPos": { "h": 4, "w": 4, "x": 8, "y": 1 }, + "id": 103, + "options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "value" }, + "title": "에러율 (5xx, 5분)", + "description": "5분 윈도우 5xx 비율. 1% 노랑(조사), 5% 빨강(장애 대응).", + "type": "stat", + "targets": [ { "expr": "(sum(rate(http_server_requests_seconds_count{status=~\"5..\",uri!~\"/actuator.*\"}[5m])) / sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*\"}[5m]))) * 100", "refId": "A", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } } ] + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "yellow", "value": 0.5 }, { "color": "red", "value": 1 } ] }, + "unit": "s", "decimals": 3, "min": 0 + } + }, + "gridPos": { "h": 4, "w": 4, "x": 12, "y": 1 }, + "id": 104, + "options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "value" }, + "title": "p95 응답 시간 (5분)", + "description": "상위 5% 느린 요청 응답시간. 평균이 아닌 p95를 보는 이유는 outlier 누락 방지.", + "type": "stat", + "targets": [ { "expr": "histogram_quantile(0.95, sum by (le) (rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*\"}[5m])))", "refId": "A", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } } ] + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "yellow", "value": 1 }, { "color": "red", "value": 3 } ] }, + "unit": "s", "decimals": 3, "min": 0 + } + }, + "gridPos": { "h": 4, "w": 4, "x": 16, "y": 1 }, + "id": 105, + "options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "value" }, + "title": "p99 응답 시간 (5분)", + "description": "최악 1% 사용자 응답시간. p95와 크게 벌어지면 outlier (GC pause / slow query / lock 대기).", + "type": "stat", + "targets": [ { "expr": "histogram_quantile(0.99, sum by (le) (rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*\"}[5m])))", "refId": "A", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } } ] + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [ { "color": "blue", "value": null } ] }, + "unit": "short", "decimals": 0 + } + }, + "gridPos": { "h": 4, "w": 4, "x": 20, "y": 1 }, + "id": 106, + "options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "value" }, + "title": "1시간 누적 요청", + "description": "최근 1시간 백엔드가 받은 요청 누계 — 트래픽 규모 감.", + "type": "stat", + "targets": [ { "expr": "sum(increase(http_server_requests_seconds_count{uri!~\"/actuator.*\"}[1h]))", "refId": "A", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } } ] + }, + + { "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, "id": 200, "title": "Row 2 — 응답 시간 (사용자 경험)", "type": "row" }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2, "showPoints": "never", "lineInterpolation": "smooth", "thresholdsStyle": { "mode": "line" } }, + "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "yellow", "value": 0.5 }, { "color": "red", "value": 1 } ] }, + "unit": "s", "decimals": 3, "min": 0 + }, + "overrides": [ + { "matcher": { "id": "byName", "options": "p50" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "blue" } } ] }, + { "matcher": { "id": "byName", "options": "p95" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "yellow" } } ] }, + { "matcher": { "id": "byName", "options": "p99" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } ] } + ] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 }, + "id": 201, + "options": { "legend": { "calcs": ["last", "max", "mean"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "desc" } }, + "title": "응답 시간 백분위 (p50 / p95 / p99)", + "description": "p50=평균 사용자, p95=느린 5%(SLO 기준), p99=최악 1%(GC·slow query·lock 의심). 임계선: 500ms 노랑, 1s 빨강.", + "type": "timeseries", + "targets": [ + { "expr": "histogram_quantile(0.50, sum by (le) (rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*\"}[5m])))", "legendFormat": "p50", "refId": "A", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } }, + { "expr": "histogram_quantile(0.95, sum by (le) (rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*\"}[5m])))", "legendFormat": "p95", "refId": "B", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } }, + { "expr": "histogram_quantile(0.99, sum by (le) (rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*\"}[5m])))", "legendFormat": "p99", "refId": "C", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 1, "showPoints": "never", "lineInterpolation": "smooth", "thresholdsStyle": { "mode": "line" } }, + "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "yellow", "value": 0.5 }, { "color": "red", "value": 1 } ] }, + "unit": "s", "decimals": 3, "min": 0 + } + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 }, + "id": 202, + "options": { "legend": { "calcs": ["last", "max", "mean"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "desc" } }, + "title": "엔드포인트별 p95 (상위 10)", + "description": "느린 API 식별. 1초 초과 라인은 무조건 최적화 대상.", + "type": "timeseries", + "targets": [ { "expr": "topk(10, histogram_quantile(0.95, sum by (le, uri) (rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*\"}[5m]))))", "legendFormat": "{{uri}}", "refId": "A", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } } ] + }, + + { "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 }, "id": 300, "title": "Row 3 — HTTP 트래픽", "type": "row" }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2, "showPoints": "never", "lineInterpolation": "smooth" }, + "unit": "reqps", "decimals": 2, "min": 0 + } + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 }, + "id": 301, + "options": { "legend": { "calcs": ["last", "max", "mean"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "desc" } }, + "title": "엔드포인트별 RPS (상위 10)", + "description": "1분 평균 RPS. 특정 API 폭주 식별 + 최적화 우선순위 결정용.", + "type": "timeseries", + "targets": [ { "expr": "topk(10, sum by (uri, method) (rate(http_server_requests_seconds_count{uri!~\"/actuator.*\"}[1m])))", "legendFormat": "{{method}} {{uri}}", "refId": "A", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } } ] + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 40, "lineWidth": 2, "showPoints": "never", "lineInterpolation": "smooth", "stacking": { "mode": "normal" }, "gradientMode": "opacity" }, + "unit": "reqps", "decimals": 2, "min": 0 + }, + "overrides": [ + { "matcher": { "id": "byRegexp", "options": "^5..$" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } ] }, + { "matcher": { "id": "byRegexp", "options": "^4..$" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] }, + { "matcher": { "id": "byRegexp", "options": "^2..$" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } } ] } + ] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 }, + "id": 302, + "options": { "legend": { "calcs": ["last", "max", "mean"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "desc" } }, + "title": "HTTP 상태 코드 분포", + "description": "2xx/4xx/5xx 비율 추세. 5xx 빨강 면적이 커지면 즉시 인지하도록 stack.", + "type": "timeseries", + "targets": [ { "expr": "sum by (status) (rate(http_server_requests_seconds_count{uri!~\"/actuator.*\"}[1m]))", "legendFormat": "{{status}}", "refId": "A", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } } ] + }, + + { "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 23 }, "id": 400, "title": "Row 4 — 에러 추적", "type": "row" }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2, "showPoints": "never", "lineInterpolation": "smooth", "thresholdsStyle": { "mode": "line" } }, + "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "yellow", "value": 0.01 }, { "color": "red", "value": 0.1 } ] }, + "unit": "reqps", "decimals": 3, "min": 0 + } + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 24 }, + "id": 401, + "options": { "legend": { "calcs": ["last", "max", "mean"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "desc" } }, + "title": "5xx 서버 에러 추이 (URI별)", + "description": "평소 0이 정상. 솟구치면 즉시 조사. URI별로 쪼개 어느 API에서 터지는지 식별.", + "type": "timeseries", + "targets": [ { "expr": "sum by (uri, status) (rate(http_server_requests_seconds_count{status=~\"5..\",uri!~\"/actuator.*\"}[5m]))", "legendFormat": "{{status}} {{uri}}", "refId": "A", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } } ] + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2, "showPoints": "never", "lineInterpolation": "smooth", "thresholdsStyle": { "mode": "line" } }, + "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "yellow", "value": 0.1 }, { "color": "red", "value": 1 } ] }, + "unit": "reqps", "decimals": 3, "min": 0 + } + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 24 }, + "id": 402, + "options": { "legend": { "calcs": ["last", "max", "mean"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "desc" } }, + "title": "401 인증 실패 추이", + "description": "급증 시 brute force 또는 토큰 만료 클라이언트 버그 의심.", + "type": "timeseries", + "targets": [ { "expr": "sum by (uri) (rate(http_server_requests_seconds_count{status=\"401\"}[5m]))", "legendFormat": "{{uri}}", "refId": "A", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } } ] + }, + + { "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 32 }, "id": 500, "title": "Row 5 — JVM", "type": "row" }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "yellow", "value": 70 }, { "color": "red", "value": 85 } ] }, + "unit": "percent", "decimals": 1, "min": 0, "max": 100 + } + }, + "gridPos": { "h": 8, "w": 6, "x": 0, "y": 33 }, + "id": 501, + "options": { "orientation": "vertical", "showThresholdLabels": true, "showThresholdMarkers": true, "reduceOptions": { "calcs": ["lastNotNull"] } }, + "title": "JVM Heap 사용률", + "description": "사용/최대 비율. 70% 노랑(GC 빈도 증가), 85% 빨강(OOM 위험).", + "type": "gauge", + "targets": [ { "expr": "sum(jvm_memory_used_bytes{area=\"heap\"}) / sum(jvm_memory_max_bytes{area=\"heap\"}) * 100", "refId": "A", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } } ] + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 40, "lineWidth": 2, "showPoints": "never", "lineInterpolation": "smooth", "stacking": { "mode": "normal" }, "gradientMode": "opacity" }, + "unit": "bytes", "decimals": 1, "min": 0 + } + }, + "gridPos": { "h": 8, "w": 12, "x": 6, "y": 33 }, + "id": 502, + "options": { "legend": { "calcs": ["last", "max", "mean"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "desc" } }, + "title": "Heap 영역별 사용량 (Eden/Survivor/Old)", + "description": "Old가 지속 증가하면 메모리 누수. GC 패턴 분석용.", + "type": "timeseries", + "targets": [ { "expr": "sum by (id) (jvm_memory_used_bytes{area=\"heap\"})", "legendFormat": "{{id}}", "refId": "A", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } } ] + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 1, "showPoints": "never", "lineInterpolation": "smooth", "thresholdsStyle": { "mode": "line" } }, + "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "yellow", "value": 0.01 }, { "color": "red", "value": 0.05 } ] }, + "unit": "s", "decimals": 4, "min": 0 + } + }, + "gridPos": { "h": 8, "w": 6, "x": 18, "y": 33 }, + "id": 503, + "options": { "legend": { "calcs": ["last", "max", "mean"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "desc" } }, + "title": "GC 시간 (초당 점유 비율)", + "description": "초당 10ms(=1%) 초과 시 사용자 응답 지연. 50ms 초과는 튜닝 필수.", + "type": "timeseries", + "targets": [ { "expr": "rate(jvm_gc_pause_seconds_sum[1m])", "legendFormat": "{{action}}", "refId": "A", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } } ] + }, + + { "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 41 }, "id": 600, "title": "Row 6 — 데이터베이스 (HikariCP)", "type": "row" }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2, "showPoints": "never", "lineInterpolation": "smooth" }, + "unit": "short", "decimals": 0, "min": 0 + }, + "overrides": [ + { "matcher": { "id": "byName", "options": "max" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "gray" } }, { "id": "custom.lineStyle", "value": { "dash": [10, 10], "fill": "dash" } } ] }, + { "matcher": { "id": "byName", "options": "pending" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } ] }, + { "matcher": { "id": "byName", "options": "active" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] }, + { "matcher": { "id": "byName", "options": "idle" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "blue" } } ] } + ] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 42 }, + "id": 601, + "options": { "legend": { "calcs": ["last", "max", "mean"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "desc" } }, + "title": "Hikari 커넥션 풀", + "description": "active가 max에 근접 = DB 병목. pending ≥ 1 = 풀 부족 확정.", + "type": "timeseries", + "targets": [ + { "expr": "hikaricp_connections_active", "legendFormat": "active", "refId": "A", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } }, + { "expr": "hikaricp_connections_idle", "legendFormat": "idle", "refId": "B", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } }, + { "expr": "hikaricp_connections_pending", "legendFormat": "pending", "refId": "C", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } }, + { "expr": "hikaricp_connections_max", "legendFormat": "max", "refId": "D", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2, "showPoints": "never", "lineInterpolation": "smooth", "thresholdsStyle": { "mode": "line" } }, + "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "yellow", "value": 0.05 }, { "color": "red", "value": 0.2 } ] }, + "unit": "s", "decimals": 4, "min": 0 + } + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 42 }, + "id": 602, + "options": { "legend": { "calcs": ["last", "max", "mean"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "desc" } }, + "title": "Hikari 커넥션 대기/사용 시간", + "description": "acquire 급증 = 풀 부족. usage 급증 = 슬로우 쿼리 또는 긴 트랜잭션.", + "type": "timeseries", + "targets": [ + { "expr": "rate(hikaricp_connections_acquire_seconds_sum[5m]) / clamp_min(rate(hikaricp_connections_acquire_seconds_count[5m]), 0.001)", "legendFormat": "acquire wait", "refId": "A", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } }, + { "expr": "rate(hikaricp_connections_usage_seconds_sum[5m]) / clamp_min(rate(hikaricp_connections_usage_seconds_count[5m]), 0.001)", "legendFormat": "usage", "refId": "B", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } } + ] + }, + + { "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 50 }, "id": 700, "title": "Row 7 — 메시징 (Redis / Kafka)", "type": "row" }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2, "showPoints": "never", "lineInterpolation": "smooth" }, + "unit": "ops", "decimals": 2, "min": 0 + } + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 51 }, + "id": 701, + "options": { "legend": { "calcs": ["last", "max", "mean"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "desc" } }, + "title": "Redis 명령 처리량 (Lettuce)", + "description": "분산락(예약), 쿠폰 Lua, 빈자리 SADD/SMEMBERS, 캐시 등 Redis 사용 전반.", + "type": "timeseries", + "targets": [ { "expr": "sum by (command) (rate(lettuce_command_completion_seconds_count[1m]))", "legendFormat": "{{command}}", "refId": "A", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } } ] + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "custom": { "drawStyle": "line", "fillOpacity": 20, "lineWidth": 2, "showPoints": "never", "lineInterpolation": "smooth", "thresholdsStyle": { "mode": "line" } }, + "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "yellow", "value": 100 }, { "color": "red", "value": 1000 } ] }, + "unit": "short", "decimals": 0, "min": 0 + } + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 51 }, + "id": 702, + "options": { "legend": { "calcs": ["last", "max", "mean"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "desc" } }, + "title": "Kafka Consumer Lag", + "description": "처리 안 된 메시지 누적. 0 유지가 목표. 1000 넘으면 consumer 증설. 빈자리 알림 지연 직결.", + "type": "timeseries", + "targets": [ { "expr": "sum by (topic, partition) (kafka_consumer_fetch_manager_records_lag)", "legendFormat": "{{topic}} p{{partition}}", "refId": "A", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } } ] + }, + + { "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 59 }, "id": 800, "title": "Row 8 — 회복성 (Resilience4j Circuit Breaker)", "type": "row" }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "yellow", "value": 1 }, { "color": "red", "value": 2 } ] }, + "mappings": [ + { "type": "value", "options": { "0": { "text": "CLOSED", "color": "green" }, "1": { "text": "HALF_OPEN", "color": "yellow" }, "2": { "text": "OPEN", "color": "red" } } } + ], + "unit": "none", "noValue": "—" + } + }, + "gridPos": { "h": 8, "w": 8, "x": 0, "y": 60 }, + "id": 801, + "options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "value_and_name" }, + "title": "Circuit Breaker 상태", + "description": "closed=정상, half_open=복구시도, open=차단됨. AI API 등 외부 의존성 보호.", + "type": "stat", + "targets": [ { "expr": "resilience4j_circuitbreaker_state == 1", "legendFormat": "{{name}} ({{state}})", "refId": "A", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } } ] + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 20, "lineWidth": 2, "showPoints": "never", "lineInterpolation": "smooth" }, + "unit": "ops", "decimals": 2, "min": 0 + }, + "overrides": [ + { "matcher": { "id": "byName", "options": "successful" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } } ] }, + { "matcher": { "id": "byName", "options": "failed" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } ] }, + { "matcher": { "id": "byName", "options": "not_permitted" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] } + ] + }, + "gridPos": { "h": 8, "w": 16, "x": 8, "y": 60 }, + "id": 802, + "options": { "legend": { "calcs": ["last", "max", "mean"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "desc" } }, + "title": "CB 호출 결과 (성공/실패/차단)", + "description": "failed 비율 50% + 5초 지속 시 OPEN 임박. not_permitted 증가 = CB가 OPEN 상태에서 차단 중.", + "type": "timeseries", + "targets": [ { "expr": "sum by (name, kind) (rate(resilience4j_circuitbreaker_calls_total[5m]))", "legendFormat": "{{name}} {{kind}}", "refId": "A", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } } ] + }, + + { "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 68 }, "id": 900, "title": "Row 9 — 시스템 자원", "type": "row" }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "yellow", "value": 70 }, { "color": "red", "value": 90 } ] }, + "unit": "percent", "decimals": 1, "min": 0, "max": 100 + } + }, + "gridPos": { "h": 8, "w": 6, "x": 0, "y": 69 }, + "id": 901, + "options": { "orientation": "vertical", "showThresholdLabels": true, "showThresholdMarkers": true, "reduceOptions": { "calcs": ["lastNotNull"] } }, + "title": "프로세스 CPU", + "description": "JVM 프로세스 CPU. 90% 지속 + p95 상승 → CPU 병목.", + "type": "gauge", + "targets": [ { "expr": "process_cpu_usage * 100", "refId": "A", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } } ] + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2, "showPoints": "never", "lineInterpolation": "smooth", "thresholdsStyle": { "mode": "line" } }, + "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "yellow", "value": 1 }, { "color": "red", "value": 2 } ] }, + "unit": "short", "decimals": 2, "min": 0 + }, + "overrides": [ + { "matcher": { "id": "byName", "options": "cores" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "gray" } }, { "id": "custom.lineStyle", "value": { "dash": [10, 10], "fill": "dash" } } ] } + ] + }, + "gridPos": { "h": 8, "w": 9, "x": 6, "y": 69 }, + "id": 902, + "options": { "legend": { "calcs": ["last", "max", "mean"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "desc" } }, + "title": "시스템 Load Average (1분)", + "description": "대기 큐 포함 평균 부하. cores 라인을 넘으면 포화. CPU 100% + Load 8 = 풀+8개 대기.", + "type": "timeseries", + "targets": [ + { "expr": "system_load_average_1m", "legendFormat": "1m", "refId": "A", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } }, + { "expr": "system_cpu_count", "legendFormat": "cores", "refId": "B", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2, "showPoints": "never", "lineInterpolation": "smooth" }, + "unit": "short", "decimals": 0, "min": 0 + }, + "overrides": [ + { "matcher": { "id": "byName", "options": "max" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "gray" } }, { "id": "custom.lineStyle", "value": { "dash": [10, 10], "fill": "dash" } } ] }, + { "matcher": { "id": "byName", "options": "busy" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } ] } + ] + }, + "gridPos": { "h": 8, "w": 9, "x": 15, "y": 69 }, + "id": 903, + "options": { "legend": { "calcs": ["last", "max", "mean"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "desc" } }, + "title": "Tomcat 워커 스레드", + "description": "busy/max 비율이 70% 노랑, 90% 빨강. max 도달 시 신규 요청 거부. (메트릭 미노출 시 No data — application.yml에 management.metrics.enable.tomcat=true 필요)", + "type": "timeseries", + "targets": [ + { "expr": "tomcat_threads_busy_threads", "legendFormat": "busy", "refId": "A", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } }, + { "expr": "tomcat_threads_current_threads", "legendFormat": "current", "refId": "B", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } }, + { "expr": "tomcat_threads_config_max_threads", "legendFormat": "max", "refId": "C", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } } + ] + } + ], + "refresh": "30s", + "schemaVersion": 38, + "tags": ["backend", "production", "catchtable"], + "templating": { "list": [] }, + "time": { "from": "now-1h", "to": "now" }, + "timepicker": { + "refresh_intervals": ["10s", "30s", "1m", "5m"], + "time_options": ["5m", "15m", "30m", "1h", "3h", "6h", "12h", "24h"] + }, + "timezone": "Asia/Seoul", + "title": "캐치테이블 백엔드 — 운영 모니터링", + "uid": "catcheat-monitoring-v1", + "version": 1, + "weekStart": "" +} diff --git a/k6/02-reservation-concurrency.js b/k6/02-reservation-concurrency.js index aac1a23..3fc22af 100644 --- a/k6/02-reservation-concurrency.js +++ b/k6/02-reservation-concurrency.js @@ -11,7 +11,17 @@ import http from 'k6/http'; import { sleep, check, group } from 'k6'; import { Counter, Rate, Trend } from 'k6/metrics'; -import { BASE_URL, HEADERS_AUTH, REMAIN_ID, THRESHOLDS } from './config.js'; +import { BASE_URL, HEADERS_JSON, HEADERS_AUTH, AUTH_TOKEN, REMAIN_ID, THRESHOLDS, requireAuthToken } from './config.js'; + +// 다중 토큰 풀 (선택). 운영 환경과 더 비슷한 검증을 원하면 TOKENS CSV 주입. +const TOKENS = (__ENV.TOKENS ? __ENV.TOKENS.split(',') : []).filter(Boolean); +const USE_MULTI_TOKENS = TOKENS.length > 1; + +export function setup() { + if (!AUTH_TOKEN && !USE_MULTI_TOKENS) { + requireAuthToken('02'); + } +} const successCount = new Counter('reservation_success'); const exhaustedCount = new Counter('reservation_exhausted'); @@ -44,15 +54,18 @@ export const options = { }; export default function () { + const headers = USE_MULTI_TOKENS + ? { ...HEADERS_JSON, Authorization: `Bearer ${TOKENS[(__VU - 1) % TOKENS.length]}` } + : HEADERS_AUTH; + group('예약 생성 (동시성)', () => { const payload = JSON.stringify({ remainId: parseInt(REMAIN_ID), member: 2, + couponId: null, }); - const res = http.post(`${BASE_URL}/api/v1/reservations`, payload, { - headers: HEADERS_AUTH, - }); + const res = http.post(`${BASE_URL}/api/v1/reservations`, payload, { headers }); reserveDuration.add(res.timings.duration); diff --git a/k6/03-full-flow.js b/k6/03-full-flow.js index 2f29cfc..72a0e75 100644 --- a/k6/03-full-flow.js +++ b/k6/03-full-flow.js @@ -12,7 +12,7 @@ import http from 'k6/http'; import { sleep, check, group } from 'k6'; import exec from 'k6/execution'; import { Trend, Rate } from 'k6/metrics'; -import { BASE_URL, HEADERS_JSON, HEADERS_AUTH, THRESHOLDS } from './config.js'; +import { BASE_URL, HEADERS_JSON, HEADERS_AUTH, AUTH_TOKEN, THRESHOLDS } from './config.js'; const flowDuration = new Trend('full_flow_duration', true); @@ -178,7 +178,8 @@ export default function () { sleep(0.5); }); - if (HEADERS_AUTH.Authorization !== 'Bearer ') { + // AUTH_TOKEN 있을 때만 로그인 step 실행 (config.js v1.4: 빈 토큰이면 Authorization 헤더 자체 제거) + if (AUTH_TOKEN) { group('7. 북마크 폴더 조회 (로그인)', () => { const res = http.get(`${BASE_URL}/api/v1/bookmark-folders`, { headers: HEADERS_AUTH }); const ok = check(res, { '북마크 200': (r) => r.status === 200 }); diff --git a/k6/04-circuit-breaker.js b/k6/04-circuit-breaker.js index 3ff7d95..d46b1b0 100644 --- a/k6/04-circuit-breaker.js +++ b/k6/04-circuit-breaker.js @@ -2,6 +2,18 @@ * 서킷브레이커 부하테스트 — 점진적 증가로 OPEN 전환 시점 탐색 * 실행: .\k6\run.ps1 -Test 04 -AuthToken "eyJ..." -BaseUrl https://api.catcheat.kro.kr * + * ⚠️ ⚠️ ⚠️ 비용 경고 ⚠️ ⚠️ ⚠️ + * 이 시나리오는 실제 Gemini API를 호출합니다. 전체 5분 30초 동안 약 2,000~5,000 요청 발생. + * 서킷이 빨리 열리지 않으면 그만큼 외부 API 비용이 발생합니다. + * + * 안전 가드: + * 1. MAX_ITER_PER_VU 환경변수로 VU당 요청 상한 (기본 250 → 글로벌 추정 5000) + * ⚠️ k6 VU는 isolated VM이라 이 가드는 "VU별" 상한임 + * 글로벌 최대 = MAX_ITER_PER_VU × 시나리오 max VU 수(20) + * 예: MAX_ITER_PER_VU=25 ./k6/run.sh -t 04 (글로벌 ~500건) + * 2. 일일 메시지 제한(100회/사용자) — 동일 AUTH_TOKEN 사용 시 100건 후 자동 차단됨 + * 3. mock LLM 사용 권장 — application.yml에서 spring.ai.* 를 mock provider로 변경 후 테스트 + * * 목적: * - LLM API(Spring AI)에 점진적으로 부하를 올려 서킷브레이커가 열리는 VU 수 확인 * - 서킷 CLOSED → OPEN → HALF-OPEN → CLOSED 전체 상태 전환 사이클 관찰 @@ -39,17 +51,32 @@ import http from 'k6/http'; import { sleep, check, group } from 'k6'; import { Counter, Rate, Trend } from 'k6/metrics'; -import { BASE_URL, HEADERS_AUTH } from './config.js'; +import { BASE_URL, HEADERS_AUTH, requireAuthToken } from './config.js'; + +// 빈 AUTH_TOKEN 즉시 fail-fast — 401 사일런트 실패 시 Gemini 비용은 안 들지만 측정 무의미 +export function setup() { + requireAuthToken('04'); +} const circuitOpenCount = new Counter('circuit_open_responses'); // 빠른 실패 (서킷 OPEN) const circuitClosedCount = new Counter('circuit_closed_responses'); // 정상 응답 (서킷 CLOSED) const llmErrorRate = new Rate('llm_error_rate'); const chatDuration = new Trend('chat_duration', true); +const skippedSafeguard = new Counter('skipped_by_safeguard'); // 안전 가드로 차단된 요청 // 서킷 OPEN 판별 기준: 500ms 미만이면서 에러 → 서킷이 즉시 차단한 것 // LLM 정상 처리는 최소 수백ms~수초이므로 500ms 이하 빠른 실패 = OPEN 확실 const CIRCUIT_OPEN_THRESHOLD_MS = 500; +// 안전 가드: VU당 최대 요청 수 — Gemini API 비용 폭증 방지 +// k6는 각 VU가 isolated VM이라 module 변수가 VU별로 분리됨. +// 따라서 이 값은 "VU당 상한"이며, 글로벌 최대 ≈ 값 × 최대 VU 수. +// 시나리오 04는 최대 20 VU 사용 → 글로벌 최대 = MAX_ITER_PER_VU × 20 +// 기본 250 × 20 = 5000건 (Gemini 무료 티어 일일 한도 보호) +const MAX_ITER_PER_VU = Number(__ENV.MAX_ITER_PER_VU || 250); +const MAX_VUS_IN_SCENARIO = 20; // 시나리오 옵션 변경 시 함께 갱신 +let vuIterCount = 0; + export const options = { scenarios: { // ── 워밍업: 서킷 CLOSED 기준선 측정 ───────────────────────────────────── @@ -117,6 +144,13 @@ const TEST_MESSAGES = [ ]; export default function () { + // 안전 가드 — 이 VU가 MAX_ITER_PER_VU 도달 시 추가 호출 중단 (Gemini 비용 차단) + vuIterCount++; + if (MAX_ITER_PER_VU > 0 && vuIterCount > MAX_ITER_PER_VU) { + skippedSafeguard.add(1); + return; + } + const msg = TEST_MESSAGES[Math.floor(Math.random() * TEST_MESSAGES.length)]; const payload = JSON.stringify(msg); @@ -155,13 +189,18 @@ export default function () { } export function handleSummary(data) { - const open = data.metrics.circuit_open_responses?.values?.count || 0; - const closed = data.metrics.circuit_closed_responses?.values?.count || 0; - const total = open + closed; - const p95 = data.metrics.chat_duration?.values?.['p(95)'] || 0; - const p99 = data.metrics.chat_duration?.values?.['p(99)'] || 0; + const open = data.metrics.circuit_open_responses?.values?.count || 0; + const closed = data.metrics.circuit_closed_responses?.values?.count || 0; + const skipped = data.metrics.skipped_by_safeguard?.values?.count || 0; + const total = open + closed; + const p95 = data.metrics.chat_duration?.values?.['p(95)'] || 0; + const p99 = data.metrics.chat_duration?.values?.['p(99)'] || 0; const openRate = total > 0 ? ((open / total) * 100).toFixed(1) : '0.0'; + const globalEstimate = MAX_ITER_PER_VU * MAX_VUS_IN_SCENARIO; + const safeguardNote = skipped > 0 + ? `\n[안전 가드] ${skipped}건이 MAX_ITER_PER_VU(=${MAX_ITER_PER_VU}, 글로벌 추정 ${globalEstimate}건) 도달로 차단됨 (Gemini 비용 보호)` + : `\n[안전 가드] 작동 가능: VU당 최대 ${MAX_ITER_PER_VU}건, 글로벌 추정 최대 ${globalEstimate}건`; return { stdout: ` @@ -169,7 +208,7 @@ export function handleSummary(data) { 총 요청수 : ${total}건 정상 응답 (CLOSED) : ${closed}건 즉시 차단 (OPEN) : ${open}건 -서킷 OPEN 비율 : ${openRate}% +서킷 OPEN 비율 : ${openRate}%${safeguardNote} p95 응답시간 : ${(p95 / 1000).toFixed(2)}초 p99 응답시간 : ${(p99 / 1000).toFixed(2)}초 diff --git a/k6/05-store-list.js b/k6/05-store-list.js index 4322798..9720df8 100644 --- a/k6/05-store-list.js +++ b/k6/05-store-list.js @@ -68,10 +68,10 @@ export const options = { }, }, thresholds: { - 'store_list_duration': ['p(95)<800'], - 'store_popular_duration': ['p(95)<500'], - 'list_spike_duration': ['p(95)<1200'], - 'list_ramp_duration': ['p(95)<800'], + 'store_list_duration': ['p(95)<1200'], + 'store_popular_duration': ['p(95)<800'], + 'list_spike_duration': ['p(95)<1500'], + 'list_ramp_duration': ['p(95)<1200'], 'store_list_error_rate': ['rate<0.01'], 'http_req_failed': ['rate<0.01'], }, diff --git a/k6/06-store-nearby.js b/k6/06-store-nearby.js index 6bace33..5abb90d 100644 --- a/k6/06-store-nearby.js +++ b/k6/06-store-nearby.js @@ -103,11 +103,11 @@ export default function () { group(`근처 매장 조회 (nearby) - ${loc.name}`, () => { const res = http.get( `${BASE_URL}/api/v1/stores/nearby?latitude=${lat}&longitude=${lng}&page=0&size=10`, - { headers: HEADERS_JSON, timeout: 15000 }, + { headers: HEADERS_JSON, timeout: '15s' }, ); nearbyDuration.add(res.timings.duration); record(res.timings.duration); - if (res.timings.duration > 15000 || res.status === 0) timeoutCount.add(1); + if (res.timings.duration >= 15000 || res.status === 0) timeoutCount.add(1); const ok = check(res, { 'nearby 200': (r) => r.status === 200, 'nearby 15s 이내': (r) => r.timings.duration < 15000, @@ -124,7 +124,7 @@ export default function () { const res = http.get(url, { headers: HEADERS_JSON, timeout: '15s' }); inBoundsDuration.add(res.timings.duration); record(res.timings.duration); - if (res.timings.duration > 15000 || res.status === 0) timeoutCount.add(1); + if (res.timings.duration >= 15000 || res.status === 0) timeoutCount.add(1); check(res, { 'in-bounds 200': (r) => r.status === 200, 'in-bounds 15s 이내': (r) => r.timings.duration < 15000, diff --git a/k6/07-remains.js b/k6/07-remains.js index 5ca2e58..ecf38f5 100644 --- a/k6/07-remains.js +++ b/k6/07-remains.js @@ -19,6 +19,8 @@ const errorRate = new Rate('remains_error_rate'); const STORE_IDS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; +const REMAINS_SLA_MS = Number(__ENV.REMAINS_SLA_MS || 400); + export const options = { scenarios: { @@ -70,9 +72,9 @@ export const options = { }, }, thresholds: { - 'remains_duration': ['p(95)<300', 'p(99)<500'], - 'remains_spike_duration': ['p(95)<400'], - 'remains_ramp_duration': ['p(95)<300'], + 'remains_duration': ['p(95)<500', 'p(99)<800'], + 'remains_spike_duration': ['p(95)<600'], + 'remains_ramp_duration': ['p(95)<500'], 'remains_error_rate': ['rate<0.01'], 'http_req_failed': ['rate<0.01'], }, @@ -95,7 +97,7 @@ export default function () { const ok = check(res, { '잔여석 200': (r) => r.status === 200, - '응답 200ms 이내': (r) => r.timings.duration < 200, + [`응답 ${REMAINS_SLA_MS}ms 이내`]: (r) => r.timings.duration < REMAINS_SLA_MS, }); errorRate.add(!ok); }); diff --git a/k6/08-coupon-issue.js b/k6/08-coupon-issue.js index 88acd96..98b30fc 100644 --- a/k6/08-coupon-issue.js +++ b/k6/08-coupon-issue.js @@ -1,30 +1,58 @@ /** * [단일 API] 쿠폰 선착순 발급 동시성 부하테스트 - * 실행: .\k6\run.ps1 -Test 08 -AuthToken "eyJ..." -CouponTemplateId 1 -BaseUrl https://api.catcheat.kro.kr + * + * 실행: + * # 단일 토큰 (검증 의미 반감 — 첫 1건만 성공, 나머지는 "이미 발급" 에러) + * .\k6\run.ps1 -Test 08 -AuthToken "eyJ..." -CouponTemplateId 1 + * + * # 다중 토큰 (권장 — 비관적 락 정확히 검증) + * ./k6/run.sh -t 08 -c 1 -T build/tokens-1000.csv * * 목적: * - POST /coupons/{templateId}/issue 에 100명 동시 요청 * - 비관적 락(SELECT FOR UPDATE)으로 초과 발급 방지 검증 * - 재고 소진 후 올바른 에러 응답 확인 * - * 기대 결과: + * 기대 결과 (다중 토큰 기준): * - 쿠폰 재고(remainCount) 수만큼만 성공 (201) * - 초과 요청은 400/409 에러 (COUPON_EXHAUSTED 등) * - 성공 수가 재고를 초과하면 비관적 락 오작동 → 버그 * - * 주의: - * - 테스트 전 쿠폰 템플릿 restock 필요 (테스트용 재고 세팅) - * - 여러 사용자 JWT 토큰이 있어야 중복 발급 방지 로직 우회 가능 - * (동일 토큰으로 요청 시 "이미 발급됨" 에러가 대부분 → 의미 반감) + * 토큰 풀 발급 (다중 토큰 사용 시): + * cd backend + * ./gradlew test \ + * --tests "com.catchtable.loadtest.VacancyLoadTestTokenGenerator.generateTokens" \ + * -DrunLoadTokenGen=true + * # → build/load-test-tokens.csv 에 1000개 토큰 콤마구분 출력 */ import http from 'k6/http'; import { sleep, check, group } from 'k6'; import { Counter, Rate, Trend } from 'k6/metrics'; -import { BASE_URL, HEADERS_AUTH } from './config.js'; +import { BASE_URL, HEADERS_JSON, HEADERS_AUTH, AUTH_TOKEN, requireAuthToken } from './config.js'; + +// 빈 AUTH_TOKEN + 빈 TOKENS 환경변수 → 즉시 fail-fast (사일런트 401 방지) +export function setup() { + const hasMulti = (__ENV.TOKENS || '').split(',').filter(Boolean).length > 1; + if (!AUTH_TOKEN && !hasMulti) { + requireAuthToken('08'); + } +} // 발급할 쿠폰 템플릿 ID — run.ps1 -CouponTemplateId 로 주입 const TEMPLATE_ID = __ENV.COUPON_TEMPLATE_ID || '1'; +// 다중 토큰 풀 (선택) — CSV 형식, 단일 토큰만 있으면 AUTH_TOKEN 사용 (검증 의미 반감) +const TOKENS = (__ENV.TOKENS ? __ENV.TOKENS.split(',') : []).filter(Boolean); +const USE_MULTI_TOKENS = TOKENS.length > 1; + +if (!USE_MULTI_TOKENS) { + console.warn( + '[WARN] 단일 토큰 모드 — 첫 1건 외 나머지는 "이미 발급" 에러로 처리됩니다.\n' + + ' 비관적 락 검증을 정확히 하려면 TOKENS 환경변수 (CSV)로 다중 토큰을 주입하세요.\n' + + ' 예: TOKENS=$(cat build/load-test-tokens.csv) k6 run ...' + ); +} + const issuedCount = new Counter('coupon_issued'); // 발급 성공 const exhaustedCount = new Counter('coupon_exhausted'); // 재고 소진 const duplicateCount = new Counter('coupon_duplicate'); // 중복 발급 시도 @@ -55,11 +83,16 @@ export const options = { }; export default function () { + // 다중 토큰 모드면 VU별로 다른 토큰 사용 → 사용자당 1건 발급 제약을 우회해 비관적 락만 검증 + const headers = USE_MULTI_TOKENS + ? { ...HEADERS_JSON, Authorization: `Bearer ${TOKENS[(__VU - 1) % TOKENS.length]}` } + : HEADERS_AUTH; + group('쿠폰 발급 (선착순 동시성)', () => { const res = http.post( `${BASE_URL}/api/v1/coupons/${TEMPLATE_ID}/issue`, null, // body 없음 — POST 요청만으로 발급 - { headers: HEADERS_AUTH }, + { headers }, ); issueDuration.add(res.timings.duration); diff --git a/k6/11-vacancy-test.js b/k6/11-vacancy-test.js index 6306d33..dc8f16f 100644 --- a/k6/11-vacancy-test.js +++ b/k6/11-vacancy-test.js @@ -145,7 +145,8 @@ */ import http from 'k6/http'; -import redis from 'k6/experimental/redis'; +// k6 v2.0+에서 'k6/experimental/redis'가 'k6/x/redis'로 이전됨 (자동 extension 빌드 — 첫 실행 시 ~15초 소요). +import redis from 'k6/x/redis'; import { check } from 'k6'; import { Counter, Trend } from 'k6/metrics'; diff --git a/k6/config.js b/k6/config.js index 8d1c5b1..8ffb1af 100644 --- a/k6/config.js +++ b/k6/config.js @@ -11,13 +11,22 @@ export const HEADERS_JSON = { 'Content-Type': 'application/json', }; -export const HEADERS_AUTH = { - 'Content-Type': 'application/json', - Authorization: `Bearer ${AUTH_TOKEN}`, -}; +// 빈 토큰이면 Authorization 헤더 자체를 제거 (사일런트 401 방지) +export const HEADERS_AUTH = AUTH_TOKEN + ? { 'Content-Type': 'application/json', Authorization: `Bearer ${AUTH_TOKEN}` } + : { 'Content-Type': 'application/json' }; + +export function requireAuthToken(scenarioId) { + if (!AUTH_TOKEN) { + throw new Error( + `[FATAL] 시나리오 ${scenarioId}는 AUTH_TOKEN 필수입니다. ` + + `예: ./k6/run.sh -t ${scenarioId} -a "eyJ..." 또는 -e AUTH_TOKEN=` + ); + } +} -// 공통 성능 기준선 — 이 수치를 넘으면 테스트 실패 처리 +// 공통 기준선. 시나리오별 override는 각 스크립트의 thresholds에서. export const THRESHOLDS = { - http_req_failed: ['rate<0.05'], // 에러율 5% 미만 - http_req_duration: ['p(95)<1000'], // p95 1초 미만 + http_req_failed: ['rate<0.01'], + http_req_duration: ['p(95)<1000'], }; diff --git a/k6/run.ps1 b/k6/run.ps1 index 513191c..3587305 100644 --- a/k6/run.ps1 +++ b/k6/run.ps1 @@ -18,15 +18,18 @@ param( [Parameter(Mandatory=$true)] - [ValidateSet('01', '02', '03', '04', '05', '06', '07', '08', '10')] + [ValidateSet('01', '02', '03', '04', '05', '06', '07', '08', '10', '11')] [string]$Test, [string]$AuthToken = "", [string]$RemainId = "1", [string]$CouponTemplateId = "1", - [string]$BaseUrl = "https://api.catcheat.kro.kr", - [string]$PrometheusUrl = "http://54.116.98.27:9090/api/v1/write", - [string]$GrafanaUrl = "http://54.116.98.27:3001" + # 기본값을 로컬로 — prod 도메인에 실수로 부하 쏘는 사고 방지. + # prod 또는 staging 대상 시 명시적으로 -BaseUrl 지정. + [string]$BaseUrl = "http://localhost:8080", + # remote_write 미지정 시 콘솔만 — 사용자가 의식적으로 지정해야 prod Prometheus로 전송. + [string]$PrometheusUrl = "", + [string]$GrafanaUrl = "http://localhost:3001" ) $scriptMap = @{ @@ -39,6 +42,15 @@ $scriptMap = @{ '07' = 'k6/07-remains.js' '08' = 'k6/08-coupon-issue.js' '10' = 'k6/10-soak.js' + '11' = 'k6/11-vacancy-test.js' # PHASE 인자 별도 필요 — README 참고 +} + +# prod 도메인 감지 시 경고 +if ($BaseUrl -match "catcheat\.kro\.kr|api\.catchtable") { + Write-Host "" + Write-Host "⚠️ prod 도메인 감지: $BaseUrl" -ForegroundColor Yellow + Write-Host " 5초 후 실행됩니다. 취소하려면 Ctrl+C." -ForegroundColor Yellow + Start-Sleep -Seconds 5 } $scriptName = $scriptMap[$Test] @@ -51,12 +63,26 @@ Write-Host "대상 서버 : $BaseUrl" Write-Host "Grafana : $GrafanaUrl (실시간 모니터링)" Write-Host "" -$env:K6_PROMETHEUS_RW_SERVER_URL = $PrometheusUrl +# k6 v2.0+ 호환 — 기본은 p99만 노출하므로 p50/p90/p95도 명시 push +# 대시보드 Row A/B의 백분위 패널이 작동하려면 필수 +$env:K6_PROMETHEUS_RW_TREND_STATS = 'p(50),p(90),p(95),p(99),min,max,avg' -k6 run ` - --out "experimental-prometheus-rw" ` - -e "BASE_URL=$BaseUrl" ` - -e "AUTH_TOKEN=$AuthToken" ` - -e "REMAIN_ID=$RemainId" ` - -e "COUPON_TEMPLATE_ID=$CouponTemplateId" ` - $scriptName +# PrometheusUrl 지정 시에만 remote_write 사용. 미지정 시 콘솔 출력만. +if ($PrometheusUrl) { + $env:K6_PROMETHEUS_RW_SERVER_URL = $PrometheusUrl + k6 run ` + --out "experimental-prometheus-rw" ` + -e "BASE_URL=$BaseUrl" ` + -e "AUTH_TOKEN=$AuthToken" ` + -e "REMAIN_ID=$RemainId" ` + -e "COUPON_TEMPLATE_ID=$CouponTemplateId" ` + $scriptName +} else { + Write-Host "[info] PrometheusUrl 미지정 — 콘솔 출력만 (Grafana 연동 안 함)" -ForegroundColor Cyan + k6 run ` + -e "BASE_URL=$BaseUrl" ` + -e "AUTH_TOKEN=$AuthToken" ` + -e "REMAIN_ID=$RemainId" ` + -e "COUPON_TEMPLATE_ID=$CouponTemplateId" ` + $scriptName +} diff --git a/k6/run.sh b/k6/run.sh new file mode 100755 index 0000000..1be647b --- /dev/null +++ b/k6/run.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# k6 부하테스트 실행 스크립트 (bash, run.ps1과 동일 인터페이스) +# +# 사용법: +# ./k6/run.sh -t 01 # 기본 (운영서버 + 서버 Grafana) +# ./k6/run.sh -t 02 -a "eyJ..." -r 5 # 예약 동시성 +# ./k6/run.sh -t 08 -a "eyJ..." -c 1 # 쿠폰 선착순 +# ./k6/run.sh -t 01 -b http://localhost:8080 # 로컬 서버 +# +# 테스트 번호 목록: +# 01 - 매장 전체 탐색 플로우 (event_spike + ramp_up) +# 02 - 예약 동시성 (분산락 + 낙관적락 검증) +# 03 - 전체 사용자 플로우 (event_spike + ramp_up) +# 04 - AI 챗봇 서킷브레이커 (ramp_up) ⚠ Gemini 비용 발생 — 4-MAX_REQUESTS 가드 사용 권장 +# 05 - [단일] 매장 목록 조회 +# 06 - [단일] PostGIS 지리 쿼리 +# 07 - [단일] 잔여석 조회 +# 08 - [단일] 쿠폰 선착순 발급 동시성 — 다중 토큰 CSV 권장 (-T 파일) +# 10 - [내구성] Soak 테스트 (10VU × 30분) +# 11 - 빈자리 SADD/SMEMBERS — 직접 11-vacancy-test.js 명령 참고 (PHASE 인자 별도) + +set -euo pipefail + +# ── 기본값 ────────────────────────────────────────────────────────────────── +TEST="" +AUTH_TOKEN="" +REMAIN_ID="1" +COUPON_TEMPLATE_ID="1" +# 기본값을 로컬로 — prod에 실수로 부하 쏘는 사고 방지. 명시적 -b 옵션으로 prod/staging 지정. +BASE_URL="http://localhost:8080" +# remote_write 미지정 시 콘솔만. -p로 명시적 지정 시에만 Grafana 전송. +PROMETHEUS_URL="" +GRAFANA_URL="http://localhost:3001" +TOKENS_FILE="" + +usage() { + cat < [옵션] + +옵션: + -t 테스트 번호 (01..08, 10, 11) — 필수 + 11: PHASE 인자 별도 필요 — 11-vacancy-test.js 헤더 주석 참고 + -a AUTH_TOKEN (JWT) + -r REMAIN_ID (시나리오 02) + -c COUPON_TEMPLATE_ID (시나리오 08) + -b BASE_URL (기본: $BASE_URL) — prod 도메인 사용 시 5초 경고 + -p PROMETHEUS_URL — 미지정 시 콘솔 출력만 (Grafana 연동 안 함) + -T TOKENS CSV 파일 경로 (시나리오 08 다중 토큰) + -h 도움말 +EOF + exit 1 +} + +# ── 인자 파싱 ─────────────────────────────────────────────────────────────── +while getopts "t:a:r:c:b:p:T:h" opt; do + case $opt in + t) TEST="$OPTARG" ;; + a) AUTH_TOKEN="$OPTARG" ;; + r) REMAIN_ID="$OPTARG" ;; + c) COUPON_TEMPLATE_ID="$OPTARG" ;; + b) BASE_URL="$OPTARG" ;; + p) PROMETHEUS_URL="$OPTARG" ;; + T) TOKENS_FILE="$OPTARG" ;; + h|*) usage ;; + esac +done + +[[ -z "$TEST" ]] && usage + +# ── 스크립트 매핑 ──────────────────────────────────────────────────────────── +# macOS 기본 bash (3.2)는 `declare -A` 미지원이라 case 문으로 처리. 11은 PHASE 인자 별도 필요. +case "$TEST" in + 01) SCRIPT="k6/01-store-browse.js" ;; + 02) SCRIPT="k6/02-reservation-concurrency.js" ;; + 03) SCRIPT="k6/03-full-flow.js" ;; + 04) SCRIPT="k6/04-circuit-breaker.js" ;; + 05) SCRIPT="k6/05-store-list.js" ;; + 06) SCRIPT="k6/06-store-nearby.js" ;; + 07) SCRIPT="k6/07-remains.js" ;; + 08) SCRIPT="k6/08-coupon-issue.js" ;; + 10) SCRIPT="k6/10-soak.js" ;; + 11) SCRIPT="k6/11-vacancy-test.js" ;; + *) echo "[error] 알 수 없는 테스트 번호: $TEST" >&2; usage ;; +esac + +# ── prod 도메인 감지 시 경고 ──────────────────────────────────────────────── +if [[ "$BASE_URL" =~ catcheat\.kro\.kr|api\.catchtable ]]; then + echo "" + echo "⚠️ prod 도메인 감지: $BASE_URL" + echo " 5초 후 실행됩니다. 취소하려면 Ctrl+C." + sleep 5 +fi + +# ── 출력 ─────────────────────────────────────────────────────────────────── +echo "" +echo "===== k6 부하테스트 시작 =====" +echo "테스트 : $TEST" +echo "스크립트 : $SCRIPT" +echo "대상 서버 : $BASE_URL" +if [[ -n "$PROMETHEUS_URL" ]]; then + echo "Prometheus: $PROMETHEUS_URL" + echo "Grafana : $GRAFANA_URL (실시간 모니터링)" +else + echo "Prometheus: (미지정 — 콘솔 출력만, Grafana 연동 안 함)" +fi +echo "" + +# ── 환경변수 ─────────────────────────────────────────────────────────────── +# k6 v2.0+ 호환 — 기본은 p99만 노출하므로 p50/p90/p95도 명시 push (대시보드 백분위 패널 필수) +export K6_PROMETHEUS_RW_TREND_STATS='p(50),p(90),p(95),p(99),min,max,avg' + +# ── 시나리오 08 다중 토큰 (선택) ───────────────────────────────────────────── +TOKENS_ENV="" +if [[ -n "$TOKENS_FILE" ]]; then + if [[ ! -f "$TOKENS_FILE" ]]; then + echo "[error] 토큰 파일이 없습니다: $TOKENS_FILE" >&2 + exit 1 + fi + TOKENS_ENV=$(cat "$TOKENS_FILE") + echo "[info] 다중 토큰 로드: $(echo "$TOKENS_ENV" | tr ',' '\n' | wc -l | tr -d ' ')개" +fi + +# ── 실행 ─────────────────────────────────────────────────────────────────── +if [[ -n "$PROMETHEUS_URL" ]]; then + export K6_PROMETHEUS_RW_SERVER_URL="$PROMETHEUS_URL" + k6 run \ + --out "experimental-prometheus-rw" \ + -e "BASE_URL=$BASE_URL" \ + -e "AUTH_TOKEN=$AUTH_TOKEN" \ + -e "REMAIN_ID=$REMAIN_ID" \ + -e "COUPON_TEMPLATE_ID=$COUPON_TEMPLATE_ID" \ + ${TOKENS_ENV:+-e "TOKENS=$TOKENS_ENV"} \ + "$SCRIPT" +else + k6 run \ + -e "BASE_URL=$BASE_URL" \ + -e "AUTH_TOKEN=$AUTH_TOKEN" \ + -e "REMAIN_ID=$REMAIN_ID" \ + -e "COUPON_TEMPLATE_ID=$COUPON_TEMPLATE_ID" \ + ${TOKENS_ENV:+-e "TOKENS=$TOKENS_ENV"} \ + "$SCRIPT" +fi