From 04e20cc1554813a277ce49e5800af765e738fd75 Mon Sep 17 00:00:00 2001 From: dongan Date: Wed, 27 May 2026 14:26:26 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20k6=20=EB=B6=80=ED=95=98=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=8F=20Grafana=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit k6 시나리오 테스트: 매장 탐색, 예약 동시성, 전체 플로우, 서킷브레이커 k6 단일 API 테스트: 매장 목록, PostGIS 지리 쿼리, 잔여석 조회, 쿠폰 선착순 발급 k6 내구성 테스트: 10VU x 30분 Soak 테스트 (메모리 누수, 커넥션풀 고갈 탐지) run.ps1: 테스트 번호로 실행하는 래퍼 스크립트 (01~08, 10) Grafana 백엔드 모니터링 대시보드 JSON (수동 import용) Co-Authored-By: Claude Sonnet 4.6 --- .../catcheat-backend-dashboard.json | 1247 +++++++++++++++++ k6/01-store-browse.js | 98 ++ k6/02-reservation-concurrency.js | 108 ++ k6/03-full-flow.js | 112 ++ k6/04-circuit-breaker.js | 141 ++ k6/05-store-list.js | 123 ++ k6/06-store-nearby.js | 155 ++ k6/07-remains.js | 101 ++ k6/08-coupon-issue.js | 131 ++ k6/10-soak.js | 161 +++ k6/config.js | 23 + k6/k6-dashboard.json | 448 ++++++ k6/run.ps1 | 66 + 13 files changed, 2914 insertions(+) create mode 100644 grafana/provisioning/dashboards/catcheat-backend-dashboard.json create mode 100644 k6/01-store-browse.js create mode 100644 k6/02-reservation-concurrency.js create mode 100644 k6/03-full-flow.js create mode 100644 k6/04-circuit-breaker.js create mode 100644 k6/05-store-list.js create mode 100644 k6/06-store-nearby.js create mode 100644 k6/07-remains.js create mode 100644 k6/08-coupon-issue.js create mode 100644 k6/10-soak.js create mode 100644 k6/config.js create mode 100644 k6/k6-dashboard.json create mode 100644 k6/run.ps1 diff --git a/grafana/provisioning/dashboards/catcheat-backend-dashboard.json b/grafana/provisioning/dashboards/catcheat-backend-dashboard.json new file mode 100644 index 0000000..b2b6c90 --- /dev/null +++ b/grafana/provisioning/dashboards/catcheat-backend-dashboard.json @@ -0,0 +1,1247 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "description": "", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "__elements": {}, + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "10.0.0" + }, + { + "type": "datasource", + "id": "prometheus", + "name": "Prometheus", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "stat", + "name": "Stat", + "version": "" + }, + { + "type": "panel", + "id": "timeseries", + "name": "Time series", + "version": "" + }, + { + "type": "panel", + "id": "table", + "name": "Table", + "version": "" + }, + { + "type": "panel", + "id": "gauge", + "name": "Gauge", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { "type": "grafana", "uid": "-- Grafana --" }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "CatchEat 백엔드 모니터링 - HTTP / JVM / DB / Redis / Kafka", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, + "id": 1, + "title": "요약", + "type": "row" + }, + + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 80 } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 1 }, + "id": 2, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "title": "현재 RPS", + "type": "stat", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(rate(http_server_requests_seconds_count{job=\"catchtable-app\"}[1m]))", + "instant": true, + "refId": "A" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 5 } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 1 }, + "id": 3, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "title": "5xx 에러율", + "type": "stat", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(rate(http_server_requests_seconds_count{job=\"catchtable-app\",status=~\"5..\"}[1m])) / sum(rate(http_server_requests_seconds_count{job=\"catchtable-app\"}[1m])) * 100", + "instant": true, + "refId": "A" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 0.5 }, + { "color": "red", "value": 1 } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 12, "y": 1 }, + "id": 4, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "title": "p99 응답시간", + "type": "stat", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket{job=\"catchtable-app\"}[1m])) by (le))", + "instant": true, + "refId": "A" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null } + ] + }, + "unit": "dtdurations" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 1 }, + "id": 5, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "title": "업타임", + "type": "stat", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "process_uptime_seconds{job=\"catchtable-app\"}", + "instant": true, + "refId": "A" + } + ] + }, + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, + "id": 6, + "title": "HTTP API 현황", + "type": "row" + }, + + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 }, + "id": 7, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "초당 요청수 (상태코드별)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(rate(http_server_requests_seconds_count{job=\"catchtable-app\",status=~\"2..\"}[1m]))", + "legendFormat": "2xx", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(rate(http_server_requests_seconds_count{job=\"catchtable-app\",status=~\"3..\"}[1m]))", + "legendFormat": "3xx", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(rate(http_server_requests_seconds_count{job=\"catchtable-app\",status=~\"4..\"}[1m]))", + "legendFormat": "4xx", + "refId": "C" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(rate(http_server_requests_seconds_count{job=\"catchtable-app\",status=~\"5..\"}[1m]))", + "legendFormat": "5xx", + "refId": "D" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 80 } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 }, + "id": 8, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "에러율 (%)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(rate(http_server_requests_seconds_count{job=\"catchtable-app\",status=~\"4..\"}[1m])) / sum(rate(http_server_requests_seconds_count{job=\"catchtable-app\"}[1m])) * 100", + "legendFormat": "4xx 에러율", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(rate(http_server_requests_seconds_count{job=\"catchtable-app\",status=~\"5..\"}[1m])) / sum(rate(http_server_requests_seconds_count{job=\"catchtable-app\"}[1m])) * 100", + "legendFormat": "5xx 에러율", + "refId": "B" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 14 }, + "id": 9, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "응답시간 백분위 (p50 / p95 / p99)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "histogram_quantile(0.50, sum(rate(http_server_requests_seconds_bucket{job=\"catchtable-app\"}[1m])) by (le))", + "legendFormat": "p50", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{job=\"catchtable-app\"}[1m])) by (le))", + "legendFormat": "p95", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket{job=\"catchtable-app\"}[1m])) by (le))", + "legendFormat": "p99", + "refId": "C" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "custom": { + "align": "auto", + "cellOptions": { "type": "auto" }, + "inspect": false + }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "RPS" }, + "properties": [ + { "id": "unit", "value": "reqps" }, + { "id": "custom.width", "value": 100 } + ] + }, + { + "matcher": { "id": "byName", "options": "평균 응답시간" }, + "properties": [ + { "id": "unit", "value": "s" }, + { "id": "custom.width", "value": 130 } + ] + } + ] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 14 }, + "id": 10, + "options": { + "cellHeight": "sm", + "footer": { "countRows": false, "fields": "", "reducer": ["sum"], "show": false }, + "showHeader": true, + "sortBy": [{ "desc": true, "displayName": "RPS" }] + }, + "title": "엔드포인트별 요청 현황", + "transformations": [ + { + "id": "merge", + "options": {} + }, + { + "id": "organize", + "options": { + "renameByName": { + "Value #A": "RPS", + "Value #B": "평균 응답시간", + "method": "Method", + "status": "Status", + "uri": "URI" + } + } + } + ], + "type": "table", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(rate(http_server_requests_seconds_count{job=\"catchtable-app\"}[5m])) by (uri, method, status)", + "format": "table", + "instant": true, + "legendFormat": "", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(rate(http_server_requests_seconds_sum{job=\"catchtable-app\"}[5m])) by (uri, method, status) / sum(rate(http_server_requests_seconds_count{job=\"catchtable-app\"}[5m])) by (uri, method, status)", + "format": "table", + "instant": true, + "legendFormat": "", + "refId": "B" + } + ] + }, + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 22 }, + "id": 11, + "title": "JVM 상태", + "type": "row" + }, + + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 23 }, + "id": 12, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "JVM Heap 메모리", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(jvm_memory_used_bytes{job=\"catchtable-app\", area=\"heap\"}) by (id)", + "legendFormat": "사용중 - {{id}}", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(jvm_memory_max_bytes{job=\"catchtable-app\", area=\"heap\"}) by (id)", + "legendFormat": "최대 - {{id}}", + "refId": "B" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 23 }, + "id": 13, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "JVM Non-Heap 메모리", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(jvm_memory_used_bytes{job=\"catchtable-app\", area=\"nonheap\"}) by (id)", + "legendFormat": "사용중 - {{id}}", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(jvm_memory_committed_bytes{job=\"catchtable-app\", area=\"nonheap\"}) by (id)", + "legendFormat": "커밋됨 - {{id}}", + "refId": "B" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 31 }, + "id": 14, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "GC 일시정지 시간", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "rate(jvm_gc_pause_seconds_sum{job=\"catchtable-app\"}[1m])", + "legendFormat": "{{action}} - {{cause}}", + "refId": "A" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 8, "x": 12, "y": 31 }, + "id": 15, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "JVM 스레드 수", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "jvm_threads_live_threads{job=\"catchtable-app\"}", + "legendFormat": "라이브", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "jvm_threads_daemon_threads{job=\"catchtable-app\"}", + "legendFormat": "데몬", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "jvm_threads_peak_threads{job=\"catchtable-app\"}", + "legendFormat": "피크", + "refId": "C" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "line", "steps": [{ "color": "red", "value": 80 }] } + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 80 } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 4, "x": 20, "y": 31 }, + "id": 16, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "CPU 사용률", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "process_cpu_usage{job=\"catchtable-app\"} * 100", + "legendFormat": "프로세스 CPU", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "system_cpu_usage{job=\"catchtable-app\"} * 100", + "legendFormat": "시스템 CPU", + "refId": "B" + } + ] + }, + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 39 }, + "id": 17, + "title": "DB 커넥션 풀 (HikariCP)", + "type": "row" + }, + + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 40 }, + "id": 18, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "HikariCP 커넥션 현황", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "hikaricp_connections_active{job=\"catchtable-app\"}", + "legendFormat": "활성", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "hikaricp_connections_idle{job=\"catchtable-app\"}", + "legendFormat": "유휴", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "hikaricp_connections_pending{job=\"catchtable-app\"}", + "legendFormat": "대기", + "refId": "C" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "hikaricp_connections_max{job=\"catchtable-app\"}", + "legendFormat": "최대", + "refId": "D" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 8, "x": 12, "y": 40 }, + "id": 19, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "커넥션 획득 대기시간", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "rate(hikaricp_connections_acquire_seconds_sum{job=\"catchtable-app\"}[1m]) / rate(hikaricp_connections_acquire_seconds_count{job=\"catchtable-app\"}[1m])", + "legendFormat": "평균 획득 대기시간", + "refId": "A" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 70 }, + { "color": "red", "value": 90 } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 4, "x": 20, "y": 40 }, + "id": 20, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "title": "커넥션 풀 사용률", + "type": "gauge", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "hikaricp_connections_active{job=\"catchtable-app\"} / hikaricp_connections_max{job=\"catchtable-app\"} * 100", + "instant": true, + "refId": "A" + } + ] + }, + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 48 }, + "id": 21, + "title": "Redis (Lettuce 클라이언트)", + "type": "row" + }, + + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 8, "x": 0, "y": 49 }, + "id": 22, + "options": { + "legend": { "calcs": ["mean", "min"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "캐시 히트율 (Spring Cache)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(rate(cache_gets_total{job=\"catchtable-app\",result=\"hit\"}[1m])) / (sum(rate(cache_gets_total{job=\"catchtable-app\",result=\"hit\"}[1m])) + sum(rate(cache_gets_total{job=\"catchtable-app\",result=\"miss\"}[1m]))) * 100", + "legendFormat": "히트율", + "refId": "A" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 8, "x": 8, "y": 49 }, + "id": 23, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Lettuce 명령 처리량 (초당)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(rate(lettuce_command_completion_seconds_count{job=\"catchtable-app\"}[1m])) by (command)", + "legendFormat": "{{command}}", + "refId": "A" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 8, "x": 16, "y": 49 }, + "id": 24, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Lettuce 명령 응답시간", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(rate(lettuce_command_completion_seconds_sum{job=\"catchtable-app\"}[1m])) by (command) / sum(rate(lettuce_command_completion_seconds_count{job=\"catchtable-app\"}[1m])) by (command)", + "legendFormat": "{{command}}", + "refId": "A" + } + ] + }, + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 57 }, + "id": 25, + "title": "Kafka", + "type": "row" + }, + + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { + "mode": "line", + "steps": [ + { "color": "transparent", "value": null }, + { "color": "red", "value": 1000 } + ] + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 1000 } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 58 }, + "id": 26, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Kafka Consumer Lag", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(kafka_consumer_fetch_manager_records_lag{job=\"catchtable-app\"}) by (topic, partition)", + "legendFormat": "{{topic}} - 파티션 {{partition}}", + "refId": "A" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "mps" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 58 }, + "id": 27, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Kafka 메시지 처리량 (초당)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(rate(kafka_consumer_fetch_manager_records_consumed_total{job=\"catchtable-app\"}[1m])) by (topic)", + "legendFormat": "소비 - {{topic}}", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(rate(kafka_producer_record_send_total{job=\"catchtable-app\"}[1m])) by (topic)", + "legendFormat": "생산 - {{topic}}", + "refId": "B" + } + ] + } + + ], + "refresh": "30s", + "schemaVersion": 38, + "tags": ["catcheat", "spring-boot", "backend"], + "templating": { "list": [] }, + "time": { "from": "now-1h", "to": "now" }, + "timepicker": {}, + "timezone": "Asia/Seoul", + "title": "CatchEat Backend 모니터링", + "uid": "catcheat-dongan-v1", + "version": 1, + "weekStart": "" +} diff --git a/k6/01-store-browse.js b/k6/01-store-browse.js new file mode 100644 index 0000000..1f2f289 --- /dev/null +++ b/k6/01-store-browse.js @@ -0,0 +1,98 @@ +/** + * 매장 조회 부하테스트 + * 실행: k6 run k6/01-store-browse.js + * + * 테스트 항목: + * - GET /stores (목록 + 필터) + * - GET /stores/popular (인기 매장) + * - GET /stores/nearby (PostGIS 거리 계산) + * - GET /stores/in-bounds (지도 영역 조회) + * - GET /stores/{id} (상세) + * - GET /remains (시간대별 좌석) + */ +import http from 'k6/http'; +import { sleep, check, group } from 'k6'; +import { Trend } from 'k6/metrics'; +import { BASE_URL, HEADERS_JSON, THRESHOLDS } from './config.js'; + +const nearbyDuration = new Trend('nearby_duration', true); +const inBoundsDuration = new Trend('in_bounds_duration', true); + +export const options = { + scenarios: { + // 단계적으로 부하 증가 → 유지 → 감소 + ramp_up: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '30s', target: 10 }, // 워밍업 + { duration: '30s', target: 30 }, // 1단계 + { duration: '1m', target: 50 }, // 2단계 + { duration: '1m', target: 50 }, // 50명 유지 + { duration: '30s', target: 0 }, // 종료 + ], + }, + }, + thresholds: { + ...THRESHOLDS, + nearby_duration: ['p(95)<2000'], // PostGIS 쿼리는 2초 기준 + in_bounds_duration: ['p(95)<2000'], + }, +}; + +// 서울 중심 좌표 +const SEOUL = { lat: 37.5665, lng: 126.9780 }; + +// 테스트할 매장 ID 목록 (실제 DB에 있는 ID로 교체) +const STORE_IDS = [1, 2, 3, 4, 5]; + +export default function () { + const storeId = STORE_IDS[Math.floor(Math.random() * STORE_IDS.length)]; + const today = new Date().toISOString().split('T')[0]; + + group('매장 목록 조회', () => { + const res = http.get(`${BASE_URL}/api/v1/stores?page=0&size=10`, { headers: HEADERS_JSON }); + check(res, { '목록 200': (r) => r.status === 200 }); + }); + + group('인기 매장 조회', () => { + const res = http.get(`${BASE_URL}/api/v1/stores/popular?limit=10`, { headers: HEADERS_JSON }); + check(res, { '인기 200': (r) => r.status === 200 }); + }); + + group('근처 매장 조회 (PostGIS)', () => { + const res = http.get( + `${BASE_URL}/api/v1/stores/nearby?latitude=${SEOUL.lat}&longitude=${SEOUL.lng}&page=0&size=10`, + { headers: HEADERS_JSON }, + ); + check(res, { '근처 200': (r) => r.status === 200 }); + nearbyDuration.add(res.timings.duration); + }); + + group('지도 영역 매장 조회 (in-bounds)', () => { + // 서울 강남 일대 bounding box + const res = http.get( + `${BASE_URL}/api/v1/stores/in-bounds` + + `?minLat=37.49&maxLat=37.54&minLng=127.01&maxLng=127.07` + + `¢erLat=${SEOUL.lat}¢erLng=${SEOUL.lng}&limit=100`, + { headers: HEADERS_JSON }, + ); + check(res, { 'in-bounds 200': (r) => r.status === 200 }); + inBoundsDuration.add(res.timings.duration); + }); + + group('매장 상세 조회', () => { + const res = http.get(`${BASE_URL}/api/v1/stores/${storeId}`, { headers: HEADERS_JSON }); + check(res, { '상세 200': (r) => r.status === 200 }); + }); + + group('시간대별 좌석 조회', () => { + const res = http.get( + `${BASE_URL}/api/v1/remains?storeId=${storeId}&date=${today}`, + { headers: HEADERS_JSON }, + ); + check(res, { '좌석 200': (r) => r.status === 200 }); + }); + + sleep(1); +} diff --git a/k6/02-reservation-concurrency.js b/k6/02-reservation-concurrency.js new file mode 100644 index 0000000..7721597 --- /dev/null +++ b/k6/02-reservation-concurrency.js @@ -0,0 +1,108 @@ +/** + * 예약 동시성 부하테스트 (핵심 시나리오) + * 실행: k6 run -e AUTH_TOKEN= -e REMAIN_ID= k6/02-reservation-concurrency.js + * + * 목적: 같은 remainId에 100명이 동시에 예약 요청 → 분산락/Optimistic Lock 검증 + * 기대 결과: + * - 좌석 수만큼만 성공 (201) + * - 나머지는 REMAIN_EXHAUSTED (400/409) + * - 데이터 정합성 깨지면 안 됨 (중복 예약 X) + */ +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'; + +const successCount = new Counter('reservation_success'); +const exhaustedCount = new Counter('reservation_exhausted'); +const conflictCount = new Counter('reservation_conflict'); +const errorRate = new Rate('reservation_error_rate'); +const reserveDuration = new Trend('reservation_duration', true); + +export const options = { + scenarios: { + // 100명이 동시에 한꺼번에 예약 요청 — 스파이크 시나리오 + spike: { + executor: 'ramping-arrival-rate', + startRate: 0, + timeUnit: '1s', + preAllocatedVUs: 150, + maxVUs: 200, + stages: [ + { duration: '5s', target: 100 }, // 5초 만에 초당 100 요청 + { duration: '30s', target: 100 }, // 30초 유지 + { duration: '5s', target: 0 }, + ], + }, + }, + thresholds: { + ...THRESHOLDS, + reservation_duration: ['p(95)<3000'], + // 동시성 테스트이므로 에러율 기준 완화 (좌석 부족 에러는 정상) + http_req_failed: ['rate<0.01'], // 5xx 에러만 실패로 간주 + }, +}; + +export default function () { + group('예약 생성 (동시성)', () => { + const payload = JSON.stringify({ + remainId: parseInt(REMAIN_ID), + member: 2, + }); + + const res = http.post(`${BASE_URL}/api/v1/reservations`, payload, { + headers: HEADERS_AUTH, + }); + + reserveDuration.add(res.timings.duration); + + if (res.status === 201) { + successCount.add(1); + errorRate.add(false); + check(res, { '예약 성공 (201)': (r) => r.status === 201 }); + } else if (res.status === 400 || res.status === 409) { + const body = res.json(); + const code = body?.code || ''; + + if (code === 'REMAIN_EXHAUSTED') { + exhaustedCount.add(1); + } else if (code === 'OPTIMISTIC_LOCK_CONFLICT') { + conflictCount.add(1); + } + + errorRate.add(false); // 좌석 부족/락 충돌은 정상 동작 + check(res, { '좌석 부족/락 충돌 (정상)': () => true }); + } else { + errorRate.add(true); + check(res, { '예상치 못한 에러': (r) => r.status < 500 }); + console.error(`예상치 못한 응답: ${res.status} - ${res.body}`); + } + }); + + sleep(0.1); +} + +export function handleSummary(data) { + const success = data.metrics.reservation_success?.values?.count || 0; + const exhausted = data.metrics.reservation_exhausted?.values?.count || 0; + const conflict = data.metrics.reservation_conflict?.values?.count || 0; + const total = success + exhausted + conflict; + const p99 = data.metrics.reservation_duration?.values?.['p(99)'] || 0; + const p95 = data.metrics.reservation_duration?.values?.['p(95)'] || 0; + + return { + stdout: ` +===== 예약 동시성 테스트 결과 ===== +총 요청수 : ${total} +예약 성공 : ${success} +좌석 소진 (정상) : ${exhausted} +락 충돌 (정상) : ${conflict} +p95 응답시간 : ${p95.toFixed(0)}ms +p99 응답시간 : ${p99.toFixed(0)}ms + +[검증] 예약 성공 수가 실제 좌석 수와 일치하는지 DB에서 확인하세요. +SELECT COUNT(*) FROM reservations WHERE remain_id = ${REMAIN_ID} AND status != 'PAYMENT_FAILED'; +=================================== +`, + }; +} diff --git a/k6/03-full-flow.js b/k6/03-full-flow.js new file mode 100644 index 0000000..a9720ad --- /dev/null +++ b/k6/03-full-flow.js @@ -0,0 +1,112 @@ +/** + * 실제 사용자 플로우 부하테스트 + * 실행: k6 run -e AUTH_TOKEN= k6/03-full-flow.js + * + * 시나리오: 홈 진입 → 매장 검색 → 상세 조회 → 좌석 확인 + * (결제가 필요한 예약 확정은 외부 API 의존이라 포함하지 않음) + */ +import http from 'k6/http'; +import { sleep, check, group } from 'k6'; +import { Trend } from 'k6/metrics'; +import { BASE_URL, HEADERS_JSON, HEADERS_AUTH, THRESHOLDS } from './config.js'; + +const flowDuration = new Trend('full_flow_duration', true); + +export const options = { + scenarios: { + // 일반 트래픽 — 점진적 증가 + normal_traffic: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '30s', target: 10 }, + { duration: '1m', target: 30 }, + { duration: '1m', target: 50 }, + { duration: '30s', target: 0 }, + ], + }, + }, + thresholds: { + ...THRESHOLDS, + full_flow_duration: ['p(95)<5000'], // 전체 플로우 5초 이내 + }, +}; + +const SEOUL = { lat: 37.5665, lng: 126.9780 }; +const STORE_IDS = [1, 2, 3, 4, 5]; + +export default function () { + const start = Date.now(); + const storeId = STORE_IDS[Math.floor(Math.random() * STORE_IDS.length)]; + const today = new Date().toISOString().split('T')[0]; + + group('1. 홈 - 인기 매장 조회', () => { + const res = http.get(`${BASE_URL}/api/v1/stores/popular?limit=10`, { + headers: HEADERS_JSON, + }); + check(res, { '인기 매장 200': (r) => r.status === 200 }); + sleep(0.5); + }); + + group('2. 카테고리/지역 필터 검색', () => { + const categories = ['한식', '일식', '중식', '양식']; + const category = categories[Math.floor(Math.random() * categories.length)]; + const res = http.get( + `${BASE_URL}/api/v1/stores?category=${encodeURIComponent(category)}&page=0&size=10`, + { headers: HEADERS_JSON }, + ); + check(res, { '필터 검색 200': (r) => r.status === 200 }); + sleep(0.5); + }); + + group('3. 지도 화면 - 영역 내 매장 조회', () => { + const res = http.get( + `${BASE_URL}/api/v1/stores/in-bounds` + + `?minLat=37.49&maxLat=37.54&minLng=127.01&maxLng=127.07` + + `¢erLat=${SEOUL.lat}¢erLng=${SEOUL.lng}&limit=100`, + { headers: HEADERS_JSON }, + ); + check(res, { '지도 200': (r) => r.status === 200 }); + sleep(0.3); + }); + + group('4. 매장 상세 조회', () => { + const res = http.get(`${BASE_URL}/api/v1/stores/${storeId}`, { + headers: HEADERS_JSON, + }); + check(res, { '상세 200': (r) => r.status === 200 }); + sleep(1); + }); + + group('5. 메뉴 + 리뷰 조회 (동시)', () => { + const responses = http.batch([ + ['GET', `${BASE_URL}/api/v1/stores/${storeId}/menu`, null, { headers: HEADERS_JSON }], + ['GET', `${BASE_URL}/api/v1/reviews/store/${storeId}`, null, { headers: HEADERS_JSON }], + ]); + check(responses[0], { '메뉴 200': (r) => r.status === 200 }); + check(responses[1], { '리뷰 200': (r) => r.status === 200 }); + sleep(0.5); + }); + + group('6. 예약 가능 시간 조회', () => { + const res = http.get( + `${BASE_URL}/api/v1/remains?storeId=${storeId}&date=${today}`, + { headers: HEADERS_JSON }, + ); + check(res, { '좌석 200': (r) => r.status === 200 }); + sleep(0.5); + }); + + // 로그인 유저만: 북마크 폴더 조회 + if (HEADERS_AUTH.Authorization !== 'Bearer ') { + group('7. 북마크 폴더 조회 (로그인)', () => { + const res = http.get(`${BASE_URL}/api/v1/bookmark-folders`, { + headers: HEADERS_AUTH, + }); + check(res, { '북마크 200': (r) => r.status === 200 }); + }); + } + + flowDuration.add(Date.now() - start); + sleep(1); +} diff --git a/k6/04-circuit-breaker.js b/k6/04-circuit-breaker.js new file mode 100644 index 0000000..0523478 --- /dev/null +++ b/k6/04-circuit-breaker.js @@ -0,0 +1,141 @@ +/** + * 서킷브레이커 부하테스트 + * 실행: k6 run --out experimental-prometheus-rw -e AUTH_TOKEN= -e BASE_URL=https://api.catcheat.kro.kr k6/04-circuit-breaker.js + * + * 목적: + * - LLM API(Spring AI)에 동시 요청을 쏴서 서킷브레이커가 열리는 시점 확인 + * - 서킷 CLOSED → OPEN → HALF-OPEN 상태 전환 관찰 + * + * 서킷브레이커 상태 판별: + * - 응답 느림 + 성공/실패 → CLOSED (정상 흐름, LLM 호출 중) + * - 응답 빠름 + 에러 → OPEN (서킷 차단, 즉시 실패 반환) + * - 응답 일부 성공 → HALF-OPEN (복구 테스트 중) + */ +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'; + +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); + +// 서킷 OPEN 판별 기준: 500ms 미만이면서 에러 → 즉시 차단된 것 +const CIRCUIT_OPEN_THRESHOLD_MS = 500; + +export const options = { + scenarios: { + // 1단계: 워밍업 (서킷 CLOSED 기준선 확인) + warmup: { + executor: 'constant-vus', + vus: 2, + duration: '30s', + tags: { phase: 'warmup' }, + }, + // 2단계: 부하 증가 → 서킷 OPEN 유도 + ramp_up: { + executor: 'ramping-vus', + startVUs: 2, + stages: [ + { duration: '30s', target: 5 }, + { duration: '1m', target: 10 }, + { duration: '1m', target: 20 }, + ], + startTime: '30s', + tags: { phase: 'ramp_up' }, + }, + // 3단계: 고부하 유지 → 서킷 OPEN 상태 관찰 + sustained: { + executor: 'constant-vus', + vus: 20, + duration: '1m', + startTime: '3m', + tags: { phase: 'sustained' }, + }, + // 4단계: 부하 제거 → HALF-OPEN 전환 관찰 + recovery: { + executor: 'constant-vus', + vus: 2, + duration: '30s', + startTime: '4m', + tags: { phase: 'recovery' }, + }, + }, + thresholds: { + llm_error_rate: ['rate<0.8'], // 에러율 80% 미만 (서킷 완전 차단 시 100%) + chat_duration: ['p(95)<60000'], // LLM 특성상 타임아웃 기준 60초 + }, +}; + +// 테스트용 메시지 목록 (다양한 복잡도) +const TEST_MESSAGES = [ + { message: '안녕하세요', latitude: 37.5665, longitude: 126.9780 }, + { message: '강남 근처 한식 맛집 추천해줘', latitude: 37.4979, longitude: 127.0276 }, + { message: '오늘 저녁 2명 예약 가능한 곳 있어?', latitude: 37.5665, longitude: 126.9780 }, + { message: '이탈리안 레스토랑 예약하고 싶어', latitude: 37.5172, longitude: 127.0473 }, +]; + +export default function () { + const msg = TEST_MESSAGES[Math.floor(Math.random() * TEST_MESSAGES.length)]; + const payload = JSON.stringify(msg); + + group('챗봇 메시지 전송', () => { + const res = http.post(`${BASE_URL}/api/v1/chat/messages`, payload, { + headers: HEADERS_AUTH, + timeout: '65s', // LLM 응답 최대 대기 + }); + + chatDuration.add(res.timings.duration); + + const isSuccess = res.status === 200 || res.status === 201; + const isFastFail = res.timings.duration < CIRCUIT_OPEN_THRESHOLD_MS && !isSuccess; + + if (isFastFail) { + // 빠른 실패 = 서킷 OPEN 상태 + circuitOpenCount.add(1); + llmErrorRate.add(true); + console.log(`[서킷 OPEN] ${res.timings.duration.toFixed(0)}ms - status: ${res.status}`); + } else if (isSuccess) { + circuitClosedCount.add(1); + llmErrorRate.add(false); + } else { + // 느린 실패 (LLM 타임아웃 등) + llmErrorRate.add(true); + console.log(`[LLM 실패] ${res.timings.duration.toFixed(0)}ms - status: ${res.status} - ${res.body?.substring(0, 100)}`); + } + + check(res, { + '서킷 OPEN (즉시 차단)': () => isFastFail, + '정상 응답 (서킷 CLOSED)': () => isSuccess, + }); + }); + + sleep(1); +} + +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; + + return { + stdout: ` +===== 서킷브레이커 테스트 결과 ===== +총 요청수 : ${total} +정상 응답 (CLOSED) : ${closed} +즉시 차단 (OPEN) : ${open} +서킷 OPEN 비율 : ${total > 0 ? ((open / total) * 100).toFixed(1) : 0}% + +p95 응답시간 : ${(p95 / 1000).toFixed(2)}초 +p99 응답시간 : ${(p99 / 1000).toFixed(2)}초 + +[분석] +- "즉시 차단" 수가 늘어나는 시점 = 서킷 OPEN 전환 시점 +- 부하 제거 후 정상 응답 돌아오면 HALF-OPEN → CLOSED 복구 확인 +===================================== +`, + }; +} diff --git a/k6/05-store-list.js b/k6/05-store-list.js new file mode 100644 index 0000000..dcc737f --- /dev/null +++ b/k6/05-store-list.js @@ -0,0 +1,123 @@ +/** + * [단일 API] 매장 목록 조회 부하테스트 + * 실행: .\k6\run.ps1 -Test 05 -BaseUrl https://api.catcheat.kro.kr + * + * 목적: + * - GET /stores (필터 조합) 단일 API에 집중 부하 + * - Specification 기반 동적 필터링 + 페이지네이션 성능 확인 + * - GET /stores/popular (인기 매장 Top10) 함께 측정 + * + * 기대 결과: + * - p95 < 500ms (인덱스 정상 활용 시) + * - p95 > 1s 이면 popularity 정렬 컬럼 인덱스 검토 필요 + */ +import http from 'k6/http'; +import { sleep, check, group } from 'k6'; +import { Trend, Rate } from 'k6/metrics'; +import { BASE_URL, HEADERS_JSON } from './config.js'; + +const listDuration = new Trend('store_list_duration', true); +const popularDuration = new Trend('store_popular_duration', true); +const errorRate = new Rate('store_list_error_rate'); + +// 테스트할 카테고리 필터 목록 — 실제 DB에 있는 값으로 교체 +const CATEGORIES = ['KOREAN', 'JAPANESE', 'CHINESE', 'WESTERN', 'CAFE']; +// 테스트할 지역(district) 필터 목록 +const DISTRICTS = ['강남구', '마포구', '종로구', '용산구', '성동구']; + +export const options = { + scenarios: { + // 1단계: 워밍업 (JVM JIT, DB 커넥션풀 안정화) + warmup: { + executor: 'constant-vus', + vus: 5, + duration: '30s', + tags: { phase: 'warmup' }, + }, + // 2단계: 단계적 부하 증가 → 50VU까지 + ramp_up: { + executor: 'ramping-vus', + startVUs: 5, + stages: [ + { duration: '30s', target: 20 }, + { duration: '30s', target: 35 }, + { duration: '1m', target: 50 }, + { duration: '1m', target: 50 }, // 50VU 유지 + { duration: '30s', target: 0 }, + ], + startTime: '30s', + tags: { phase: 'ramp_up' }, + }, + }, + thresholds: { + store_list_duration: ['p(95)<800'], // 목록 조회 p95 800ms 기준 + store_popular_duration: ['p(95)<500'], // 인기 매장은 단순 정렬이라 500ms 기준 + store_list_error_rate: ['rate<0.01'], // 에러율 1% 미만 + http_req_failed: ['rate<0.01'], + }, +}; + +export default function () { + // 랜덤 필터 조합 — 실제 사용자가 다양한 필터로 조회하는 상황 재현 + const randomCategory = CATEGORIES[Math.floor(Math.random() * CATEGORIES.length)]; + const randomDistrict = DISTRICTS[Math.floor(Math.random() * DISTRICTS.length)]; + const randomPage = Math.floor(Math.random() * 5); // 0~4 페이지 + const useFilter = Math.random() > 0.5; // 50% 확률로 필터 적용 + + group('매장 목록 조회 (필터 조합)', () => { + let url = `${BASE_URL}/api/v1/stores?page=${randomPage}&size=10`; + if (useFilter) { + // 필터 조합을 랜덤하게 — 카테고리만, 지역만, 둘 다 섞어서 테스트 + if (Math.random() > 0.5) url += `&category=${randomCategory}`; + if (Math.random() > 0.5) url += `&district=${randomDistrict}`; + } + + const res = http.get(url, { headers: HEADERS_JSON }); + listDuration.add(res.timings.duration); + + const ok = check(res, { + '목록 조회 200': (r) => r.status === 200, + '응답 body 존재': (r) => r.body && r.body.length > 0, + }); + errorRate.add(!ok); + }); + + group('인기 매장 조회', () => { + const res = http.get(`${BASE_URL}/api/v1/stores/popular?limit=10`, { headers: HEADERS_JSON }); + popularDuration.add(res.timings.duration); + + check(res, { + '인기 매장 200': (r) => r.status === 200, + }); + }); + + sleep(1); +} + +export function handleSummary(data) { + const p95List = data.metrics.store_list_duration?.values?.['p(95)'] || 0; + const p99List = data.metrics.store_list_duration?.values?.['p(99)'] || 0; + const p95Popular = data.metrics.store_popular_duration?.values?.['p(95)'] || 0; + const totalReqs = data.metrics.http_reqs?.values?.count || 0; + const errorR = data.metrics.store_list_error_rate?.values?.rate || 0; + + return { + stdout: ` +===== 매장 목록 조회 단일 API 부하테스트 결과 ===== +총 요청수 : ${totalReqs} +에러율 : ${(errorR * 100).toFixed(2)}% + +[GET /stores] +p95 응답시간 : ${p95List.toFixed(0)}ms +p99 응답시간 : ${p99List.toFixed(0)}ms + +[GET /stores/popular] +p95 응답시간 : ${p95Popular.toFixed(0)}ms + +[분석] +- p95 > 800ms → popularity 정렬 컬럼(star, reviewCount, bookmarkCount)에 복합 인덱스 검토 +- p95 > 1s → Specification 동적 쿼리 실행계획 확인 (EXPLAIN ANALYZE) +================================================= +`, + }; +} diff --git a/k6/06-store-nearby.js b/k6/06-store-nearby.js new file mode 100644 index 0000000..2ba97e1 --- /dev/null +++ b/k6/06-store-nearby.js @@ -0,0 +1,155 @@ +/** + * [단일 API] PostGIS 지리 쿼리 부하테스트 + * 실행: .\k6\run.ps1 -Test 06 -BaseUrl https://api.catcheat.kro.kr + * + * 목적: + * - GET /stores/nearby (ST_DistanceSphere 거리 계산 + 정렬) + * - GET /stores/in-bounds (지도 bounding box 조회) + * 두 API가 PostGIS 지리 쿼리를 사용하는 병목 지점 + * + * 이전 테스트 결과: 50VU에서 nearby p95=10.33s, in-bounds p95=8.36s + * → GIST 공간 인덱스 미적용 의심 → 이 테스트로 수치 재확인 + * + * 기대 결과: + * - GIST 인덱스 적용 후: p95 < 1s + * - 미적용 시: 50VU 이상에서 타임아웃 발생 확인 + */ +import http from 'k6/http'; +import { sleep, check, group } from 'k6'; +import { Trend, Rate, Counter } from 'k6/metrics'; +import { BASE_URL, HEADERS_JSON } from './config.js'; + +const nearbyDuration = new Trend('nearby_duration', true); +const inBoundsDuration = new Trend('in_bounds_duration', true); +const timeoutCount = new Counter('geo_timeout_count'); +const errorRate = new Rate('geo_error_rate'); + +// 서울 주요 지역 좌표 — 다양한 지점에서 조회하여 캐시 히트 방지 +const LOCATIONS = [ + { name: '강남', lat: 37.4979, lng: 127.0276, minLat: 37.49, maxLat: 37.52, minLng: 127.01, maxLng: 127.05 }, + { name: '홍대', lat: 37.5563, lng: 126.9236, minLat: 37.54, maxLat: 37.57, minLng: 126.91, maxLng: 126.94 }, + { name: '명동', lat: 37.5636, lng: 126.9869, minLat: 37.55, maxLat: 37.58, minLng: 126.97, maxLng: 127.00 }, + { name: '이태원', lat: 37.5344, lng: 126.9997, minLat: 37.52, maxLat: 37.55, minLng: 126.98, maxLng: 127.02 }, + { name: '성수', lat: 37.5446, lng: 127.0556, minLat: 37.53, maxLat: 37.56, minLng: 127.04, maxLng: 127.07 }, + { name: '종로', lat: 37.5704, lng: 126.9910, minLat: 37.56, maxLat: 37.59, minLng: 126.97, maxLng: 127.01 }, +]; + +export const options = { + scenarios: { + // 1단계: 소부하 — 기준선 측정 (인덱스 있어도 없어도 여기선 빠름) + baseline: { + executor: 'constant-vus', + vus: 10, + duration: '30s', + tags: { phase: 'baseline' }, + }, + // 2단계: 중부하 — 인덱스 없으면 이 구간부터 급격히 느려짐 + medium_load: { + executor: 'constant-vus', + vus: 30, + duration: '1m', + startTime: '35s', + tags: { phase: 'medium_load' }, + }, + // 3단계: 고부하 — 이전 테스트에서 타임아웃 발생한 구간 (50VU) + high_load: { + executor: 'constant-vus', + vus: 50, + duration: '1m', + startTime: '2m', + tags: { phase: 'high_load' }, + }, + }, + thresholds: { + nearby_duration: ['p(95)<2000'], // 2초 기준 (PostGIS 특성 고려) + in_bounds_duration: ['p(95)<2000'], + geo_error_rate: ['rate<0.05'], + http_req_failed: ['rate<0.05'], + }, +}; + +export default function () { + // 랜덤 위치 선택 — 매번 다른 좌표로 DB 풀스캔 유도 + const loc = LOCATIONS[Math.floor(Math.random() * LOCATIONS.length)]; + // 좌표에 미세한 오프셋 추가 → 캐시 효과 없애고 실제 쿼리 유도 + const latOffset = (Math.random() - 0.5) * 0.005; + const lngOffset = (Math.random() - 0.5) * 0.005; + const lat = (loc.lat + latOffset).toFixed(6); + const lng = (loc.lng + lngOffset).toFixed(6); + + group(`근처 매장 조회 (PostGIS 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: '15s' }, + ); + + nearbyDuration.add(res.timings.duration); + + // 15초 이상 걸리면 타임아웃으로 카운트 + 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, + }); + errorRate.add(!ok); + }); + + group(`지도 영역 조회 (in-bounds) - ${loc.name}`, () => { + const url = + `${BASE_URL}/api/v1/stores/in-bounds` + + `?minLat=${loc.minLat}&maxLat=${loc.maxLat}` + + `&minLng=${loc.minLng}&maxLng=${loc.maxLng}` + + `¢erLat=${lat}¢erLng=${lng}&limit=50`; + + const res = http.get(url, { headers: HEADERS_JSON, timeout: '15s' }); + inBoundsDuration.add(res.timings.duration); + + 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, + }); + }); + + sleep(1); +} + +export function handleSummary(data) { + const p50Nearby = data.metrics.nearby_duration?.values?.['p(50)'] || 0; + const p95Nearby = data.metrics.nearby_duration?.values?.['p(95)'] || 0; + const p99Nearby = data.metrics.nearby_duration?.values?.['p(99)'] || 0; + const p95Bounds = data.metrics.in_bounds_duration?.values?.['p(95)'] || 0; + const p99Bounds = data.metrics.in_bounds_duration?.values?.['p(99)'] || 0; + const timeouts = data.metrics.geo_timeout_count?.values?.count || 0; + const totalReqs = data.metrics.http_reqs?.values?.count || 0; + + return { + stdout: ` +===== PostGIS 지리 쿼리 단일 API 부하테스트 결과 ===== +총 요청수 : ${totalReqs} +타임아웃 횟수 : ${timeouts} + +[GET /stores/nearby] +p50 응답시간 : ${p50Nearby.toFixed(0)}ms +p95 응답시간 : ${p95Nearby.toFixed(0)}ms +p99 응답시간 : ${p99Nearby.toFixed(0)}ms + +[GET /stores/in-bounds] +p95 응답시간 : ${p95Bounds.toFixed(0)}ms +p99 응답시간 : ${p99Bounds.toFixed(0)}ms + +[분석] +- p95 > 2s 또는 타임아웃 존재 → GIST 공간 인덱스 미적용 가능성 높음 + 확인 쿼리: SELECT indexname FROM pg_indexes WHERE tablename = 'stores'; + 적용 예시: CREATE INDEX idx_stores_location ON stores USING GIST(location); +- p95 < 500ms → 인덱스 정상 적용 상태 +====================================================== +`, + }; +} diff --git a/k6/07-remains.js b/k6/07-remains.js new file mode 100644 index 0000000..bc77f65 --- /dev/null +++ b/k6/07-remains.js @@ -0,0 +1,101 @@ +/** + * [단일 API] 잔여석 조회 부하테스트 + * 실행: .\k6\run.ps1 -Test 07 -BaseUrl https://api.catcheat.kro.kr + * + * 목적: + * - GET /remains?storeId={id}&date={date} 단일 API 집중 부하 + * - 예약 전 반드시 호출되는 API → 실제 트래픽에서 가장 빈번하게 호출됨 + * - (store_id, remain_date) 복합 인덱스 효과 검증 + * + * 기대 결과: + * - p95 < 200ms (인덱스 정상 활용 시, 단순 조회 쿼리) + * - 300VU에서도 안정적으로 처리 가능해야 함 + */ +import http from 'k6/http'; +import { sleep, check, group } from 'k6'; +import { Trend, Rate } from 'k6/metrics'; +import { BASE_URL, HEADERS_JSON } from './config.js'; + +const remainsDuration = new Trend('remains_duration', true); +const errorRate = new Rate('remains_error_rate'); + +// 테스트할 매장 ID 목록 — 실제 DB에 존재하는 ID로 교체 +const STORE_IDS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + +export const options = { + scenarios: { + // 최대 50VU + ramp_up: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '30s', target: 10 }, // 워밍업 + { duration: '30s', target: 30 }, + { duration: '1m', target: 50 }, // 최대 50VU + { duration: '1m', target: 50 }, // 유지 + { duration: '30s', target: 0 }, // 종료 + ], + }, + }, + thresholds: { + remains_duration: ['p(95)<300', 'p(99)<500'], // 단순 인덱스 조회라 300ms 기준 + remains_error_rate: ['rate<0.01'], + http_req_failed: ['rate<0.01'], + }, +}; + +export default function () { + const storeId = STORE_IDS[Math.floor(Math.random() * STORE_IDS.length)]; + + // 오늘 포함 향후 7일 중 랜덤 날짜 — 캐시 히트 방지 + 실제 사용 패턴 재현 + const today = new Date(); + const dayOffset = Math.floor(Math.random() * 7); // 0~6일 후 + today.setDate(today.getDate() + dayOffset); + const date = today.toISOString().split('T')[0]; + + group('잔여석 조회', () => { + const res = http.get( + `${BASE_URL}/api/v1/remains?storeId=${storeId}&date=${date}`, + { headers: HEADERS_JSON }, + ); + + remainsDuration.add(res.timings.duration); + + const ok = check(res, { + '잔여석 200': (r) => r.status === 200, + '응답 200ms 이내': (r) => r.timings.duration < 200, + }); + errorRate.add(!ok); + }); + + sleep(0.5); // 잔여석은 짧은 sleep — 실제 클릭 패턴 반영 +} + +export function handleSummary(data) { + const p50 = data.metrics.remains_duration?.values?.['p(50)'] || 0; + const p95 = data.metrics.remains_duration?.values?.['p(95)'] || 0; + const p99 = data.metrics.remains_duration?.values?.['p(99)'] || 0; + const max = data.metrics.remains_duration?.values?.max || 0; + const reqs = data.metrics.http_reqs?.values?.count || 0; + const rps = data.metrics.http_reqs?.values?.rate || 0; + + return { + stdout: ` +===== 잔여석 조회 단일 API 부하테스트 결과 ===== +총 요청수 : ${reqs} +최대 RPS : ${rps.toFixed(1)} req/s + +p50 응답시간 : ${p50.toFixed(0)}ms +p95 응답시간 : ${p95.toFixed(0)}ms +p99 응답시간 : ${p99.toFixed(0)}ms +최대 응답시간 : ${max.toFixed(0)}ms + +[분석] +- p95 < 200ms → 정상 (인덱스 활용 중) +- p95 200~500ms → 쿼리 실행계획 확인 권장 + EXPLAIN ANALYZE SELECT * FROM store_remains WHERE store_id=? AND remain_date=?; +- p95 > 500ms → (store_id, remain_date) 복합 인덱스 누락 가능성 +================================================ +`, + }; +} diff --git a/k6/08-coupon-issue.js b/k6/08-coupon-issue.js new file mode 100644 index 0000000..88acd96 --- /dev/null +++ b/k6/08-coupon-issue.js @@ -0,0 +1,131 @@ +/** + * [단일 API] 쿠폰 선착순 발급 동시성 부하테스트 + * 실행: .\k6\run.ps1 -Test 08 -AuthToken "eyJ..." -CouponTemplateId 1 -BaseUrl https://api.catcheat.kro.kr + * + * 목적: + * - POST /coupons/{templateId}/issue 에 100명 동시 요청 + * - 비관적 락(SELECT FOR UPDATE)으로 초과 발급 방지 검증 + * - 재고 소진 후 올바른 에러 응답 확인 + * + * 기대 결과: + * - 쿠폰 재고(remainCount) 수만큼만 성공 (201) + * - 초과 요청은 400/409 에러 (COUPON_EXHAUSTED 등) + * - 성공 수가 재고를 초과하면 비관적 락 오작동 → 버그 + * + * 주의: + * - 테스트 전 쿠폰 템플릿 restock 필요 (테스트용 재고 세팅) + * - 여러 사용자 JWT 토큰이 있어야 중복 발급 방지 로직 우회 가능 + * (동일 토큰으로 요청 시 "이미 발급됨" 에러가 대부분 → 의미 반감) + */ +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'; + +// 발급할 쿠폰 템플릿 ID — run.ps1 -CouponTemplateId 로 주입 +const TEMPLATE_ID = __ENV.COUPON_TEMPLATE_ID || '1'; + +const issuedCount = new Counter('coupon_issued'); // 발급 성공 +const exhaustedCount = new Counter('coupon_exhausted'); // 재고 소진 +const duplicateCount = new Counter('coupon_duplicate'); // 중복 발급 시도 +const issueDuration = new Trend('coupon_issue_duration', true); +const errorRate = new Rate('coupon_error_rate'); + +export const options = { + scenarios: { + // 선착순 스파이크: 5초 만에 50명 동시 요청 + spike: { + executor: 'ramping-arrival-rate', + startRate: 0, + timeUnit: '1s', + preAllocatedVUs: 60, + maxVUs: 80, + stages: [ + { duration: '5s', target: 50 }, // 5초 안에 초당 50 요청 — 선착순 이벤트 재현 + { duration: '20s', target: 50 }, // 20초 유지 (재고 소진 시점 확인) + { duration: '5s', target: 0 }, + ], + }, + }, + thresholds: { + coupon_issue_duration: ['p(95)<2000'], // 비관적 락 대기 포함 2초 기준 + coupon_error_rate: ['rate<0.01'], // 5xx 에러만 실패 (재고 소진은 정상) + http_req_failed: ['rate<0.01'], + }, +}; + +export default function () { + group('쿠폰 발급 (선착순 동시성)', () => { + const res = http.post( + `${BASE_URL}/api/v1/coupons/${TEMPLATE_ID}/issue`, + null, // body 없음 — POST 요청만으로 발급 + { headers: HEADERS_AUTH }, + ); + + issueDuration.add(res.timings.duration); + + if (res.status === 201 || res.status === 200) { + // 발급 성공 + issuedCount.add(1); + errorRate.add(false); + check(res, { '쿠폰 발급 성공': (r) => r.status === 201 || r.status === 200 }); + } else if (res.status === 400 || res.status === 409) { + const body = (() => { try { return res.json(); } catch { return {}; } })(); + const code = body?.code || body?.errorCode || ''; + + if (code.includes('EXHAUSTED') || code.includes('SOLD_OUT') || code.includes('OUT_OF_STOCK')) { + // 재고 소진 — 정상 동작 + exhaustedCount.add(1); + errorRate.add(false); + check(res, { '재고 소진 (정상)': () => true }); + } else if (code.includes('DUPLICATE') || code.includes('ALREADY')) { + // 동일 사용자 중복 발급 시도 — 정상 동작 + duplicateCount.add(1); + errorRate.add(false); + check(res, { '중복 발급 방지 (정상)': () => true }); + } else { + // 알 수 없는 4xx + errorRate.add(true); + console.warn(`[알수없는 4xx] status=${res.status} code=${code}`); + } + } else { + // 5xx — 비정상 + errorRate.add(true); + check(res, { '5xx 에러 없음': (r) => r.status < 500 }); + console.error(`[5xx 에러] status=${res.status} body=${res.body?.substring(0, 150)}`); + } + }); + + sleep(0.1); // 짧은 sleep — 선착순 이벤트 패턴 재현 +} + +export function handleSummary(data) { + const issued = data.metrics.coupon_issued?.values?.count || 0; + const exhausted = data.metrics.coupon_exhausted?.values?.count || 0; + const duplicate = data.metrics.coupon_duplicate?.values?.count || 0; + const total = issued + exhausted + duplicate; + const p95 = data.metrics.coupon_issue_duration?.values?.['p(95)'] || 0; + const p99 = data.metrics.coupon_issue_duration?.values?.['p(99)'] || 0; + + return { + stdout: ` +===== 쿠폰 선착순 발급 동시성 테스트 결과 ===== +총 요청수 : ${total} +발급 성공 : ${issued} +재고 소진 (정상) : ${exhausted} +중복 발급 방지 (정상) : ${duplicate} + +p95 응답시간 : ${p95.toFixed(0)}ms +p99 응답시간 : ${p99.toFixed(0)}ms + +[검증] 발급 성공 수가 실제 재고를 초과하지 않는지 DB 확인: +SELECT issued_count, remain_count FROM coupon_templates WHERE id = ${TEMPLATE_ID}; + +[분석] +- 발급 성공 수 <= 초기 재고 수 → 비관적 락 정상 동작 +- 발급 성공 수 > 초기 재고 수 → 락 오작동 → 버그 +- p95 > 2s → 비관적 락 대기로 인한 병목 → 재고 소진 방식 재검토 필요 +=============================================== +`, + }; +} diff --git a/k6/10-soak.js b/k6/10-soak.js new file mode 100644 index 0000000..d20f451 --- /dev/null +++ b/k6/10-soak.js @@ -0,0 +1,161 @@ +/** + * [내구성] Soak 테스트 (장시간 안정성) + * 실행: .\k6\run.ps1 -Test 10 -AuthToken "eyJ..." -BaseUrl https://api.catcheat.kro.kr + * + * 목적: + * - 낮은 VU(10명)를 30분간 유지하면서 서버 안정성 확인 + * - 스파이크가 아닌 지속 트래픽에서의 문제 탐지: + * 1. 메모리 누수 (JVM 힙 점진적 증가) + * 2. DB 커넥션풀 고갈 (HikariCP activeConnections 포화) + * 3. Redis 커넥션 누수 (Lettuce 커넥션 증가) + * 4. 응답시간 점진적 증가 (시간이 갈수록 느려지면 누수 의심) + * + * 모니터링 포인트 (Grafana에서 확인): + * - jvm_memory_used_bytes (Heap 사용량 우상향 여부) + * - hikaricp_connections_active (커넥션 포화 여부) + * - http_req_duration p95 추이 (시간에 따른 변화) + * + * 기대 결과: + * - 30분 내내 p95 < 1s 유지 + * - 에러율 0% 유지 + * - JVM 힙 사용량 안정적 (GC 후 회복) + */ +import http from 'k6/http'; +import { sleep, check, group } from 'k6'; +import { Trend, Rate, Counter } from 'k6/metrics'; +import { BASE_URL, HEADERS_JSON, HEADERS_AUTH } from './config.js'; + +const soakDuration = new Trend('soak_response_duration', true); +const errorRate = new Rate('soak_error_rate'); +// 시간대별 에러 추적 — 후반부에 에러가 늘면 누수 의심 +const lateErrors = new Counter('soak_late_errors'); + +// 테스트할 매장 ID 목록 +const STORE_IDS = [1, 2, 3, 4, 5]; + +// 테스트 시작 시간 (후반부 에러 감지용) +const START_TIME = Date.now(); +// 20분 이후를 "후반부"로 정의 +const LATE_PHASE_MS = 20 * 60 * 1000; + +export const options = { + scenarios: { + // 3분 워밍업 후 10VU 유지 → 30분 장시간 운영 + soak: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '3m', target: 10 }, // 천천히 올려서 안정화 + { duration: '25m', target: 10 }, // 25분 유지 (핵심 구간) + { duration: '2m', target: 0 }, // 점진적 종료 + ], + }, + }, + thresholds: { + soak_response_duration: ['p(95)<1000', 'p(99)<2000'], // 장시간에도 1s 유지 + soak_error_rate: ['rate<0.005'], // 에러율 0.5% 미만 + http_req_failed: ['rate<0.005'], + }, +}; + +export default function () { + const storeId = STORE_IDS[Math.floor(Math.random() * STORE_IDS.length)]; + const today = new Date().toISOString().split('T')[0]; + const isLatePhase = (Date.now() - START_TIME) > LATE_PHASE_MS; + + // 일반 사용자 행동 믹스 — 읽기 위주로 구성 (쓰기는 10% 확률) + // 읽기 비율 높게: 실제 서비스에서 조회가 훨씬 많음 + const rand = Math.random(); + + if (rand < 0.30) { + // 30%: 매장 목록 조회 + group('매장 목록', () => { + const res = http.get(`${BASE_URL}/api/v1/stores?page=0&size=10`, { headers: HEADERS_JSON }); + soakDuration.add(res.timings.duration); + const ok = check(res, { '목록 200': (r) => r.status === 200 }); + errorRate.add(!ok); + if (!ok && isLatePhase) lateErrors.add(1); // 후반부 에러 별도 카운트 + }); + } else if (rand < 0.55) { + // 25%: 인기 매장 조회 + group('인기 매장', () => { + const res = http.get(`${BASE_URL}/api/v1/stores/popular?limit=10`, { headers: HEADERS_JSON }); + soakDuration.add(res.timings.duration); + const ok = check(res, { '인기 200': (r) => r.status === 200 }); + errorRate.add(!ok); + if (!ok && isLatePhase) lateErrors.add(1); + }); + } else if (rand < 0.75) { + // 20%: 매장 상세 조회 + group('매장 상세', () => { + const res = http.get(`${BASE_URL}/api/v1/stores/${storeId}`, { headers: HEADERS_JSON }); + soakDuration.add(res.timings.duration); + const ok = check(res, { '상세 200': (r) => r.status === 200 }); + errorRate.add(!ok); + if (!ok && isLatePhase) lateErrors.add(1); + }); + } else if (rand < 0.90) { + // 15%: 잔여석 조회 (예약 전 가장 많이 호출) + group('잔여석 조회', () => { + const res = http.get( + `${BASE_URL}/api/v1/remains?storeId=${storeId}&date=${today}`, + { headers: HEADERS_JSON }, + ); + soakDuration.add(res.timings.duration); + const ok = check(res, { '잔여석 200': (r) => r.status === 200 }); + errorRate.add(!ok); + if (!ok && isLatePhase) lateErrors.add(1); + }); + } else { + // 10%: 인증이 필요한 API (내 예약 조회) + // AUTH_TOKEN이 없으면 401이 반환되지만 서버 부하는 발생함 + group('내 예약 조회 (인증)', () => { + const res = http.get(`${BASE_URL}/api/v1/reservations/me`, { headers: HEADERS_AUTH }); + soakDuration.add(res.timings.duration); + // 401은 토큰 미설정 시 정상 — 500만 에러로 처리 + const ok = check(res, { '인증 API 5xx 없음': (r) => r.status < 500 }); + errorRate.add(!ok); + if (!ok && isLatePhase) lateErrors.add(1); + }); + } + + // 실제 사용자 행동 패턴 — 페이지 사이 2~5초 대기 + sleep(2 + Math.random() * 3); +} + +export function handleSummary(data) { + const p50 = data.metrics.soak_response_duration?.values?.['p(50)'] || 0; + const p95 = data.metrics.soak_response_duration?.values?.['p(95)'] || 0; + const p99 = data.metrics.soak_response_duration?.values?.['p(99)'] || 0; + const max = data.metrics.soak_response_duration?.values?.max || 0; + const errR = data.metrics.soak_error_rate?.values?.rate || 0; + const lateErr = data.metrics.soak_late_errors?.values?.count || 0; + const totalReqs = data.metrics.http_reqs?.values?.count || 0; + const duration = data.metrics.http_req_duration?.values?.avg || 0; + + return { + stdout: ` +===== Soak 테스트 (장시간 안정성) 결과 ===== +총 요청수 : ${totalReqs} +에러율 : ${(errR * 100).toFixed(3)}% +후반부(20분~) 에러 : ${lateErr}건 + +p50 응답시간 : ${p50.toFixed(0)}ms +p95 응답시간 : ${p95.toFixed(0)}ms +p99 응답시간 : ${p99.toFixed(0)}ms +최대 응답시간 : ${max.toFixed(0)}ms +평균 응답시간 : ${duration.toFixed(0)}ms + +[분석] +- 후반부 에러 > 0 → 메모리 누수 또는 커넥션풀 고갈 의심 + Grafana에서 확인: + 1. jvm_memory_used_bytes{area="heap"} → 우상향이면 힙 누수 + 2. hikaricp_connections_active → 포화 여부 + 3. http_req_duration p95 추이 → 시간에 따라 증가하면 누수 + +- p95 처음과 끝이 크게 다르면 → 워밍업 후 응답시간 변화 원인 분석 필요 +- 에러율 0%, p95 일정 → 안정적인 서버 상태 +=========================================== +`, + }; +} diff --git a/k6/config.js b/k6/config.js new file mode 100644 index 0000000..8d1c5b1 --- /dev/null +++ b/k6/config.js @@ -0,0 +1,23 @@ +export const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; + +// 인증이 필요한 테스트 실행 시: k6 run -e AUTH_TOKEN= script.js +export const AUTH_TOKEN = __ENV.AUTH_TOKEN || ''; + +// 예약 동시성 테스트용 — 같은 시간대에 몰아야 의미있음 +// k6 run -e REMAIN_ID=<실제ID> 02-reservation-concurrency.js +export const REMAIN_ID = __ENV.REMAIN_ID || '1'; + +export const HEADERS_JSON = { + 'Content-Type': 'application/json', +}; + +export const HEADERS_AUTH = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${AUTH_TOKEN}`, +}; + +// 공통 성능 기준선 — 이 수치를 넘으면 테스트 실패 처리 +export const THRESHOLDS = { + http_req_failed: ['rate<0.05'], // 에러율 5% 미만 + http_req_duration: ['p(95)<1000'], // p95 1초 미만 +}; diff --git a/k6/k6-dashboard.json b/k6/k6-dashboard.json new file mode 100644 index 0000000..c0208c6 --- /dev/null +++ b/k6/k6-dashboard.json @@ -0,0 +1,448 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "description": "", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "__elements": {}, + "__requires": [ + { "type": "grafana", "id": "grafana", "name": "Grafana", "version": "10.0.0" }, + { "type": "datasource", "id": "prometheus", "name": "Prometheus", "version": "1.0.0" }, + { "type": "panel", "id": "stat", "name": "Stat", "version": "" }, + { "type": "panel", "id": "timeseries", "name": "Time series", "version": "" }, + { "type": "panel", "id": "gauge", "name": "Gauge", "version": "" } + ], + "annotations": { "list": [] }, + "description": "k6 부하테스트 실시간 모니터링", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, + "id": 1, + "title": "요약", + "type": "row" + }, + + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 }, + "id": 2, + "options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] } }, + "title": "가상 유저 (VUs)", + "type": "stat", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "k6_vus", + "instant": true, + "refId": "A" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 }, + "id": 3, + "options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] } }, + "title": "초당 요청수 (RPS)", + "type": "stat", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(rate(k6_http_reqs_total[1m]))", + "instant": true, + "refId": "A" + } + ] + }, + + { + "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" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 8, "y": 1 }, + "id": 4, + "options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] } }, + "title": "에러율", + "type": "stat", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(rate(k6_http_reqs_total{expected_response=\"false\"}[1m])) / sum(rate(k6_http_reqs_total[1m])) * 100", + "instant": true, + "refId": "A" + } + ] + }, + + { + "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" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 12, "y": 1 }, + "id": 5, + "options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] } }, + "title": "p95 응답시간", + "type": "stat", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "histogram_quantile(0.95, sum(rate(k6_http_req_duration_seconds_bucket[1m])) by (le)) * 1000", + "instant": true, + "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" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 16, "y": 1 }, + "id": 6, + "options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] } }, + "title": "p99 응답시간", + "type": "stat", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "histogram_quantile(0.99, sum(rate(k6_http_req_duration_seconds_bucket[1m])) by (le)) * 1000", + "instant": true, + "refId": "A" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 20, "y": 1 }, + "id": 7, + "options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["sum"] } }, + "title": "총 요청수", + "type": "stat", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(k6_http_reqs_total)", + "instant": true, + "refId": "A" + } + ] + }, + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, + "id": 8, + "title": "트래픽", + "type": "row" + }, + + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 1, "showPoints": "never", "spanNulls": false }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 }, + "id": 9, + "options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi" } }, + "title": "가상 유저 수 (VUs)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "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}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 1, "showPoints": "never", "spanNulls": false }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 }, + "id": 10, + "options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi" } }, + "title": "초당 요청수 (RPS)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(rate(k6_http_reqs_total[1m]))", + "legendFormat": "전체 RPS", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(rate(k6_http_reqs_total{expected_response=\"false\"}[1m]))", + "legendFormat": "에러 RPS", + "refId": "B" + } + ] + }, + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 }, + "id": 11, + "title": "응답시간", + "type": "row" + }, + + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2, "showPoints": "never", "spanNulls": false }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 }, + "id": 12, + "options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi" } }, + "title": "응답시간 백분위 (p50 / p95 / p99)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "histogram_quantile(0.50, sum(rate(k6_http_req_duration_seconds_bucket[1m])) by (le)) * 1000", + "legendFormat": "p50", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "histogram_quantile(0.95, sum(rate(k6_http_req_duration_seconds_bucket[1m])) by (le)) * 1000", + "legendFormat": "p95", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "histogram_quantile(0.99, sum(rate(k6_http_req_duration_seconds_bucket[1m])) by (le)) * 1000", + "legendFormat": "p99", + "refId": "C" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 1, "showPoints": "never", "spanNulls": false }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 }, + "id": 13, + "options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi" } }, + "title": "구간별 응답시간 (대기 / 연결 / 수신)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "rate(k6_http_req_waiting_seconds_sum[1m]) / rate(k6_http_req_waiting_seconds_count[1m]) * 1000", + "legendFormat": "서버 대기 (TTFB)", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "rate(k6_http_req_connecting_seconds_sum[1m]) / rate(k6_http_req_connecting_seconds_count[1m]) * 1000", + "legendFormat": "TCP 연결", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "rate(k6_http_req_receiving_seconds_sum[1m]) / rate(k6_http_req_receiving_seconds_count[1m]) * 1000", + "legendFormat": "응답 수신", + "refId": "C" + } + ] + }, + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 23 }, + "id": 14, + "title": "예약 동시성 테스트 (02번 전용)", + "type": "row" + }, + + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 1, "showPoints": "never", "spanNulls": false }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 24 }, + "id": 15, + "options": { "legend": { "calcs": ["sum"], "displayMode": "table", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi" } }, + "title": "예약 결과 (성공 / 좌석소진 / 락충돌)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "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}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2, "showPoints": "never", "spanNulls": false }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 24 }, + "id": 16, + "options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi" } }, + "title": "예약 API 응답시간 (p95 / p99)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "histogram_quantile(0.95, sum(rate(k6_reservation_duration_seconds_bucket[1m])) by (le)) * 1000", + "legendFormat": "p95", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "histogram_quantile(0.99, sum(rate(k6_reservation_duration_seconds_bucket[1m])) by (le)) * 1000", + "legendFormat": "p99", + "refId": "B" + } + ] + } + + ], + "refresh": "5s", + "schemaVersion": 38, + "tags": ["k6", "load-test"], + "templating": { "list": [] }, + "time": { "from": "now-15m", "to": "now" }, + "timepicker": {}, + "timezone": "Asia/Seoul", + "title": "k6 부하테스트 모니터링", + "uid": "catcheat-k6-v1", + "version": 1, + "weekStart": "" +} diff --git a/k6/run.ps1 b/k6/run.ps1 new file mode 100644 index 0000000..aaa4b69 --- /dev/null +++ b/k6/run.ps1 @@ -0,0 +1,66 @@ +# k6 부하테스트 실행 스크립트 +# +# 사용법: +# .\k6\run.ps1 -Test 01 # 인증 불필요 +# .\k6\run.ps1 -Test 02 -AuthToken "eyJ..." -RemainId 5 # 예약 동시성 +# .\k6\run.ps1 -Test 04 -AuthToken "eyJ..." # 서킷브레이커 +# .\k6\run.ps1 -Test 05 -BaseUrl https://api.catcheat.kro.kr # 운영서버 목록 부하 +# .\k6\run.ps1 -Test 06 -BaseUrl https://api.catcheat.kro.kr # PostGIS 지리 쿼리 +# .\k6\run.ps1 -Test 07 -BaseUrl https://api.catcheat.kro.kr # 잔여석 조회 +# .\k6\run.ps1 -Test 08 -AuthToken "eyJ..." -CouponTemplateId 1 # 쿠폰 선착순 발급 +# .\k6\run.ps1 -Test 10 -AuthToken "eyJ..." -BaseUrl https://api.catcheat.kro.kr # Soak +# +# 테스트 번호 목록: +# 01 - 매장 전체 탐색 플로우 (6개 API 조합) +# 02 - 예약 동시성 (분산락 + 낙관적락 검증) +# 03 - 전체 사용자 플로우 (home → 예약) +# 04 - AI 챗봇 서킷브레이커 +# 05 - [단일] 매장 목록 조회 (필터 조합, 최대 200VU) +# 06 - [단일] PostGIS 지리 쿼리 (nearby + in-bounds, 최대 150VU) +# 07 - [단일] 잔여석 조회 (최대 300VU) +# 08 - [단일] 쿠폰 선착순 발급 동시성 +# 10 - [내구성] Soak 테스트 (10VU × 30분) + +param( + [Parameter(Mandatory=$true)] + [ValidateSet('01', '02', '03', '04', '05', '06', '07', '08', '10')] + [string]$Test, + + [string]$AuthToken = "", + [string]$RemainId = "1", + [string]$CouponTemplateId = "1", + [string]$BaseUrl = "http://localhost:8080", + [string]$PrometheusUrl = "http://localhost:9091/api/v1/write" +) + +$scriptMap = @{ + '01' = 'k6/01-store-browse.js' + '02' = 'k6/02-reservation-concurrency.js' + '03' = 'k6/03-full-flow.js' + '04' = 'k6/04-circuit-breaker.js' + '05' = 'k6/05-store-list.js' + '06' = 'k6/06-store-nearby.js' + '07' = 'k6/07-remains.js' + '08' = 'k6/08-coupon-issue.js' + '10' = 'k6/10-soak.js' +} + +$scriptName = $scriptMap[$Test] + +Write-Host "" +Write-Host "===== k6 부하테스트 시작 =====" -ForegroundColor Cyan +Write-Host "테스트 : $Test" +Write-Host "스크립트 : $scriptName" +Write-Host "대상 서버 : $BaseUrl" +Write-Host "Grafana : http://localhost:3002 (실시간 모니터링)" +Write-Host "" + +$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 From 36bf66e364edf29c81d2df199b5e8b88299884ef Mon Sep 17 00:00:00 2001 From: KimDongAn Date: Wed, 27 May 2026 15:06:41 +0900 Subject: [PATCH 2/4] Update k6/03-full-flow.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enum 매핑 수정 Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- k6/03-full-flow.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/k6/03-full-flow.js b/k6/03-full-flow.js index a9720ad..42ab63f 100644 --- a/k6/03-full-flow.js +++ b/k6/03-full-flow.js @@ -49,7 +49,7 @@ export default function () { }); group('2. 카테고리/지역 필터 검색', () => { - const categories = ['한식', '일식', '중식', '양식']; + const categories = ['KOREAN', 'JAPANESE', 'CHINESE', 'WESTERN']; const category = categories[Math.floor(Math.random() * categories.length)]; const res = http.get( `${BASE_URL}/api/v1/stores?category=${encodeURIComponent(category)}&page=0&size=10`, From e2e6d198162e0dee0fbaa8f7c5ff9efddbbe269a Mon Sep 17 00:00:00 2001 From: KimDongAn Date: Wed, 27 May 2026 15:07:10 +0900 Subject: [PATCH 3/4] Update k6/05-store-list.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enum 매핑 수정 Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- k6/05-store-list.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/k6/05-store-list.js b/k6/05-store-list.js index dcc737f..57cee1f 100644 --- a/k6/05-store-list.js +++ b/k6/05-store-list.js @@ -23,7 +23,7 @@ const errorRate = new Rate('store_list_error_rate'); // 테스트할 카테고리 필터 목록 — 실제 DB에 있는 값으로 교체 const CATEGORIES = ['KOREAN', 'JAPANESE', 'CHINESE', 'WESTERN', 'CAFE']; // 테스트할 지역(district) 필터 목록 -const DISTRICTS = ['강남구', '마포구', '종로구', '용산구', '성동구']; +const DISTRICTS = ['GANGNAM', 'MAPO', 'JONGNO', 'YONGSAN', 'SEONGDONG']; export const options = { scenarios: { From cb6400270646e887fd1675917da15b310af04635 Mon Sep 17 00:00:00 2001 From: dongan Date: Wed, 27 May 2026 15:40:19 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20k6=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - timeout 문자열을 ms 숫자로 수정 (65s→65000, 15s→15000) - UTC→KST 날짜 변환 적용 (01, 03, 07, 10번 스크립트) - res.json() try-catch 안전 처리 (02번 - 502/503 HTML 응답 대비) - category 필터 한글→영문 Enum 값으로 수정 (03번) Co-Authored-By: Claude Sonnet 4.6 --- k6/01-store-browse.js | 2 +- k6/02-reservation-concurrency.js | 2 +- k6/03-full-flow.js | 2 +- k6/04-circuit-breaker.js | 2 +- k6/06-store-nearby.js | 4 ++-- k6/07-remains.js | 2 +- k6/10-soak.js | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/k6/01-store-browse.js b/k6/01-store-browse.js index 1f2f289..60eb48f 100644 --- a/k6/01-store-browse.js +++ b/k6/01-store-browse.js @@ -48,7 +48,7 @@ const STORE_IDS = [1, 2, 3, 4, 5]; export default function () { const storeId = STORE_IDS[Math.floor(Math.random() * STORE_IDS.length)]; - const today = new Date().toISOString().split('T')[0]; + const today = new Date(Date.now() + 9 * 60 * 60 * 1000).toISOString().split('T')[0]; group('매장 목록 조회', () => { const res = http.get(`${BASE_URL}/api/v1/stores?page=0&size=10`, { headers: HEADERS_JSON }); diff --git a/k6/02-reservation-concurrency.js b/k6/02-reservation-concurrency.js index 7721597..aac1a23 100644 --- a/k6/02-reservation-concurrency.js +++ b/k6/02-reservation-concurrency.js @@ -61,7 +61,7 @@ export default function () { errorRate.add(false); check(res, { '예약 성공 (201)': (r) => r.status === 201 }); } else if (res.status === 400 || res.status === 409) { - const body = res.json(); + const body = (() => { try { return res.json(); } catch { return {}; } })(); const code = body?.code || ''; if (code === 'REMAIN_EXHAUSTED') { diff --git a/k6/03-full-flow.js b/k6/03-full-flow.js index 42ab63f..176a9ce 100644 --- a/k6/03-full-flow.js +++ b/k6/03-full-flow.js @@ -38,7 +38,7 @@ const STORE_IDS = [1, 2, 3, 4, 5]; export default function () { const start = Date.now(); const storeId = STORE_IDS[Math.floor(Math.random() * STORE_IDS.length)]; - const today = new Date().toISOString().split('T')[0]; + const today = new Date(Date.now() + 9 * 60 * 60 * 1000).toISOString().split('T')[0]; group('1. 홈 - 인기 매장 조회', () => { const res = http.get(`${BASE_URL}/api/v1/stores/popular?limit=10`, { diff --git a/k6/04-circuit-breaker.js b/k6/04-circuit-breaker.js index 0523478..b9fdef2 100644 --- a/k6/04-circuit-breaker.js +++ b/k6/04-circuit-breaker.js @@ -83,7 +83,7 @@ export default function () { group('챗봇 메시지 전송', () => { const res = http.post(`${BASE_URL}/api/v1/chat/messages`, payload, { headers: HEADERS_AUTH, - timeout: '65s', // LLM 응답 최대 대기 + timeout: 65000, // LLM 응답 최대 대기 (65초) }); chatDuration.add(res.timings.duration); diff --git a/k6/06-store-nearby.js b/k6/06-store-nearby.js index 2ba97e1..493f12a 100644 --- a/k6/06-store-nearby.js +++ b/k6/06-store-nearby.js @@ -80,7 +80,7 @@ export default function () { group(`근처 매장 조회 (PostGIS 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: '15s' }, + { headers: HEADERS_JSON, timeout: 15000 }, ); nearbyDuration.add(res.timings.duration); @@ -104,7 +104,7 @@ export default function () { `&minLng=${loc.minLng}&maxLng=${loc.maxLng}` + `¢erLat=${lat}¢erLng=${lng}&limit=50`; - const res = http.get(url, { headers: HEADERS_JSON, timeout: '15s' }); + const res = http.get(url, { headers: HEADERS_JSON, timeout: 15000 }); inBoundsDuration.add(res.timings.duration); if (res.timings.duration > 15000 || res.status === 0) { diff --git a/k6/07-remains.js b/k6/07-remains.js index bc77f65..e58929f 100644 --- a/k6/07-remains.js +++ b/k6/07-remains.js @@ -48,7 +48,7 @@ export default function () { const storeId = STORE_IDS[Math.floor(Math.random() * STORE_IDS.length)]; // 오늘 포함 향후 7일 중 랜덤 날짜 — 캐시 히트 방지 + 실제 사용 패턴 재현 - const today = new Date(); + const today = new Date(Date.now() + 9 * 60 * 60 * 1000); const dayOffset = Math.floor(Math.random() * 7); // 0~6일 후 today.setDate(today.getDate() + dayOffset); const date = today.toISOString().split('T')[0]; diff --git a/k6/10-soak.js b/k6/10-soak.js index d20f451..fc73de3 100644 --- a/k6/10-soak.js +++ b/k6/10-soak.js @@ -60,7 +60,7 @@ export const options = { export default function () { const storeId = STORE_IDS[Math.floor(Math.random() * STORE_IDS.length)]; - const today = new Date().toISOString().split('T')[0]; + const today = new Date(Date.now() + 9 * 60 * 60 * 1000).toISOString().split('T')[0]; const isLatePhase = (Date.now() - START_TIME) > LATE_PHASE_MS; // 일반 사용자 행동 믹스 — 읽기 위주로 구성 (쓰기는 10% 확률)