diff --git a/.gitignore b/.gitignore index a34754a7..5dca0ee0 100644 --- a/.gitignore +++ b/.gitignore @@ -70,4 +70,8 @@ package-lock.json package.json ### temp directory ### -/temp/ \ No newline at end of file +/temp/ + +### K6 performance reports ### +k6/results/* +!k6/results/.gitkeep diff --git a/k6/README.md b/k6/README.md new file mode 100644 index 00000000..07ec9c0d --- /dev/null +++ b/k6/README.md @@ -0,0 +1,89 @@ +# DEVNOGI Batch 조회 API K6 실행 가이드 + +`batch_readonly_portfolio.js`는 BATCH 서버에 존재하는 조회 전용 API 20종을 운영 도메인과 로컬 도메인에서 같은 방식으로 측정하기 위한 K6 시나리오다. + +## 실행 스크립트 + +```bash +cd open-api-batch-server +./k6/run-batch-readonly.sh [target] [profile] +``` + +## Target + +| target | 기본 BASE_URL | TARGET_MODE | 설명 | +|---|---|---|---| +| `local` | `http://localhost:8090` | `local-batch` | batch 서버 직접 호출 | +| `prod` | `https://www.memonogi.com` | `proxy` | 운영 사용자 경로(`/api/*`) 호출 | +| `custom` | 환경 변수 `BASE_URL` 필수 | 기본 `local-batch` | 임의 도메인/포트 | + +## Profile + +| profile | RATE | DURATION | VU 설정 | 용도 | +|---|---:|---|---|---| +| `smoke` | 2 req/s | 20s | pre 3, max 6 | 조회 API 20종 상태 확인 | +| `portfolio` | 4 req/s | 2m | pre 5, max 10 | 포트폴리오 제출용 기본 측정 | +| `load` | 10 req/s | 5m | pre 15, max 30 | 로컬/개발 환경 부하 확인 | + +모든 값은 환경 변수로 덮어쓸 수 있다. + +```bash +RATE=6 DURATION=3m ./k6/run-batch-readonly.sh local portfolio +``` + +실제 API 호출 없이 저장 경로와 실행 설정만 확인하려면 `DRY_RUN=1`을 사용한다. + +```bash +DRY_RUN=1 ./k6/run-batch-readonly.sh prod portfolio +``` + +## 결과 저장 규칙 + +실행할 때마다 `yyyyMMdd_HHmmSS` 형식의 `RUN_ID`가 자동 생성된다. + +```text +k6/results/ + local/ + 20260514_081500/ + batch_readonly_local_portfolio_20260514_081500.md + batch_readonly_local_portfolio_20260514_081500.json + batch_readonly_local_portfolio_20260514_081500.log + batch_readonly_local_portfolio_20260514_081500.env + prod/ + 20260514_082000/ + batch_readonly_prod_portfolio_20260514_082000.md + batch_readonly_prod_portfolio_20260514_082000.json + batch_readonly_prod_portfolio_20260514_082000.log + batch_readonly_prod_portfolio_20260514_082000.env +``` + +| 파일 | 내용 | +|---|---| +| `.md` | 포트폴리오에 붙일 수 있는 Markdown 결과 리포트 | +| `.json` | K6 summary 원본과 endpoint별 상세 지표 | +| `.log` | K6 콘솔 출력 전체 | +| `.env` | 실행 당시 target/profile/부하 설정 | + +## 자주 쓰는 명령 + +```bash +# 로컬 batch 서버 직접 측정 +./k6/run-batch-readonly.sh local portfolio + +# 운영 도메인 사용자 경로 측정 +./k6/run-batch-readonly.sh prod portfolio + +# 로컬 smoke 테스트 +./k6/run-batch-readonly.sh local smoke + +# localhost:8092 같은 임의 포트 측정 +BASE_URL=http://localhost:8092 TARGET_MODE=local-batch \ + ./k6/run-batch-readonly.sh custom smoke +``` + +## 주의사항 + +- 시나리오는 GET 조회 API만 호출한다. +- 인증 필요 API, 관리자 API, batch sync/write API는 제외한다. +- 운영 도메인에서 `load` 프로필은 서비스 영향이 있을 수 있으므로 트래픽이 적은 시간대에만 사용한다. +- JMeter는 이 저장소에 별도 시나리오를 추가하지 않았다. 현재 표준 산출물은 K6 Markdown/JSON 리포트다. diff --git a/k6/results/.gitkeep b/k6/results/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/k6/results/.gitkeep @@ -0,0 +1 @@ + diff --git a/k6/run-batch-readonly.sh b/k6/run-batch-readonly.sh new file mode 100755 index 00000000..ee173c44 --- /dev/null +++ b/k6/run-batch-readonly.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# DEVNOGI batch read-only API K6 runner. +# +# Usage: +# cd open-api-batch-server +# ./k6/run-batch-readonly.sh local smoke +# ./k6/run-batch-readonly.sh local portfolio +# ./k6/run-batch-readonly.sh prod portfolio +# BASE_URL=http://localhost:8092 TARGET_MODE=local-batch ./k6/run-batch-readonly.sh custom smoke + +set -euo pipefail + +BATCH_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCRIPT_PATH="${BATCH_ROOT}/k6/scenarios/batch_readonly_portfolio.js" + +TARGET="${1:-local}" +PROFILE="${2:-portfolio}" +RUN_ID="${RUN_ID:-$(date +%Y%m%d_%H%M%S)}" +OUT_ROOT="${OUT_ROOT:-${BATCH_ROOT}/k6/results}" + +case "${TARGET}" in + local) + BASE_URL="${BASE_URL:-http://localhost:8090}" + TARGET_MODE="${TARGET_MODE:-local-batch}" + ;; + prod|production) + TARGET="prod" + BASE_URL="${BASE_URL:-https://www.memonogi.com}" + TARGET_MODE="${TARGET_MODE:-proxy}" + ;; + custom) + if [ -z "${BASE_URL:-}" ]; then + echo "[ERR] custom target requires BASE_URL." >&2 + exit 1 + fi + TARGET_MODE="${TARGET_MODE:-local-batch}" + ;; + *) + echo "[ERR] Unknown target: ${TARGET}" >&2 + echo " Use one of: local | prod | custom" >&2 + exit 1 + ;; +esac + +case "${PROFILE}" in + smoke) + RATE="${RATE:-2}" + DURATION="${DURATION:-20s}" + PRE_ALLOCATED_VUS="${PRE_ALLOCATED_VUS:-3}" + MAX_VUS="${MAX_VUS:-6}" + ;; + portfolio) + RATE="${RATE:-4}" + DURATION="${DURATION:-2m}" + PRE_ALLOCATED_VUS="${PRE_ALLOCATED_VUS:-5}" + MAX_VUS="${MAX_VUS:-10}" + ;; + load) + RATE="${RATE:-10}" + DURATION="${DURATION:-5m}" + PRE_ALLOCATED_VUS="${PRE_ALLOCATED_VUS:-15}" + MAX_VUS="${MAX_VUS:-30}" + ;; + *) + echo "[ERR] Unknown profile: ${PROFILE}" >&2 + echo " Use one of: smoke | portfolio | load" >&2 + exit 1 + ;; +esac + +if [ ! -f "${SCRIPT_PATH}" ]; then + echo "[ERR] K6 scenario not found: ${SCRIPT_PATH}" >&2 + exit 1 +fi + +RESULT_DIR="${OUT_ROOT}/${TARGET}/${RUN_ID}" +REPORT_BASENAME="batch_readonly_${TARGET}_${PROFILE}_${RUN_ID}" +REPORT_MD="${RESULT_DIR}/${REPORT_BASENAME}.md" +REPORT_JSON="${RESULT_DIR}/${REPORT_BASENAME}.json" +CONSOLE_LOG="${RESULT_DIR}/${REPORT_BASENAME}.log" +RUN_META="${RESULT_DIR}/${REPORT_BASENAME}.env" + +mkdir -p "${RESULT_DIR}" + +cat > "${RUN_META}" </dev/null 2>&1; then + echo "[ERR] k6 is not installed. Install with: brew install k6" >&2 + exit 1 +fi + +set +e +BASE_URL="${BASE_URL}" \ +TARGET_MODE="${TARGET_MODE}" \ +RATE="${RATE}" \ +DURATION="${DURATION}" \ +PRE_ALLOCATED_VUS="${PRE_ALLOCATED_VUS}" \ +MAX_VUS="${MAX_VUS}" \ +REPORT_MD="${REPORT_MD}" \ +REPORT_JSON="${REPORT_JSON}" \ +k6 run "${SCRIPT_PATH}" 2>&1 | tee "${CONSOLE_LOG}" +exit_code=${PIPESTATUS[0]} +set -e + +echo "[INFO] saved metadata: ${RUN_META}" + +if [ "${exit_code}" -eq 0 ]; then + echo "[OK] K6 test completed." +else + echo "[WARN] K6 test finished with exit code ${exit_code}." >&2 +fi + +exit "${exit_code}" diff --git a/k6/scenarios/batch_readonly_portfolio.js b/k6/scenarios/batch_readonly_portfolio.js new file mode 100644 index 00000000..76e21991 --- /dev/null +++ b/k6/scenarios/batch_readonly_portfolio.js @@ -0,0 +1,553 @@ +import http from 'k6/http'; +import { check } from 'k6'; +import { Counter, Rate, Trend } from 'k6/metrics'; + +const BASE_URL = __ENV.BASE_URL || 'https://www.memonogi.com'; +const DURATION = __ENV.DURATION || '2m'; +const RATE = parseInt(__ENV.RATE || '4', 10); +const PRE_ALLOCATED_VUS = parseInt(__ENV.PRE_ALLOCATED_VUS || '5', 10); +const MAX_VUS = parseInt(__ENV.MAX_VUS || '10', 10); +const REPORT_MD = __ENV.REPORT_MD || 'k6/results/manual/batch_read_api_performance_report.md'; +const REPORT_JSON = __ENV.REPORT_JSON || 'k6/results/manual/batch_read_api_performance_summary.json'; +const TARGET_MODE = __ENV.TARGET_MODE || 'proxy'; + +const TARGET_ENDPOINTS = [ + { + slug: 'auction_history_search', + group: 'Auction', + label: '경매 거래 내역 검색', + method: 'GET', + path: '/api/auction-history/search?page=1&size=20&sortBy=dateAuctionBuy&direction=desc', + localPath: '/auction-history/search?page=1&size=20&sortBy=dateAuctionBuy&direction=desc', + source: '/oab/auction-history/search', + cache: 's-maxage=300', + weight: 14, + }, + { + slug: 'auction_realtime_search', + group: 'Auction', + label: '실시간 경매 검색', + method: 'GET', + path: '/api/auction-realtime/search?page=1&size=20&sortBy=dateAuctionExpire&direction=desc', + localPath: '/auction-realtime/search?page=1&size=20&sortBy=dateAuctionExpire&direction=desc', + source: '/oab/auction-realtime/search', + cache: 's-maxage=60', + weight: 12, + }, + { + slug: 'horn_bugle', + group: 'Auction', + label: '뿔피리 메시지 조회', + method: 'GET', + path: '/api/horn-bugle?page=1&size=20', + localPath: '/horn-bugle?page=1&size=20', + source: '/oab/horn-bugle', + cache: 'revalidate=300', + weight: 8, + }, + { + slug: 'item_categories', + group: 'Metadata', + label: '아이템 카테고리', + method: 'GET', + path: '/api/item-infos/categories', + localPath: '/api/item-infos/categories', + source: '/oab/api/item-infos/categories', + cache: 'route proxy', + weight: 3, + }, + { + slug: 'search_option', + group: 'Metadata', + label: '검색 옵션 메타데이터', + method: 'GET', + path: '/api/search-option', + localPath: '/api/search-option', + source: '/oab/api/search-option', + cache: 's-maxage=1800', + weight: 3, + }, + { + slug: 'enchant_fullnames', + group: 'Metadata', + label: '인챈트 풀네임 목록', + method: 'GET', + path: '/api/enchant-infos/fullnames', + localPath: '/api/enchant-infos/fullnames', + source: '/oab/api/enchant-infos/fullnames', + cache: 'route proxy', + weight: 3, + }, + { + slug: 'metalware_infos', + group: 'Metadata', + label: '세공 메타데이터', + method: 'GET', + path: '/api/metalware-infos', + localPath: '/api/metalware-infos', + source: '/oab/api/metalware-infos', + cache: 'route proxy', + weight: 3, + }, + { + slug: 'item_option_infos', + group: 'Metadata', + label: '아이템 옵션 메타데이터', + method: 'GET', + path: '/api/item-option-infos', + localPath: '/api/v1/item-option-infos', + source: '/oab/api/v1/item-option-infos', + cache: 'route proxy', + weight: 3, + }, + { + slug: 'item_detail', + group: 'Metadata', + label: '아이템 상세', + method: 'GET', + path: '/api/item-infos/detail?itemName=%ED%96%A5%EA%B8%B0%EB%A1%9C%EC%9A%B4%20%EA%BF%80%20%EC%9A%B0%EC%9C%A0', + localPath: '/api/item-infos/detail?itemName=%ED%96%A5%EA%B8%B0%EB%A1%9C%EC%9A%B4%20%EA%BF%80%20%EC%9A%B0%EC%9C%A0', + source: '/oab/api/item-infos/detail', + cache: 's-maxage=300', + weight: 3, + }, + { + slug: 'rankings_price', + group: 'Rankings', + label: '가격 랭킹', + method: 'GET', + path: '/api/rankings/price?type=today-highest&limit=20', + localPath: '/rankings/price/today/highest?limit=20', + source: '/oab/rankings/price/today/highest', + cache: 'revalidate=300', + weight: 5, + }, + { + slug: 'rankings_volume', + group: 'Rankings', + label: '거래량 랭킹', + method: 'GET', + path: '/api/rankings/volume?type=today&limit=20', + localPath: '/rankings/volume/today/popular?limit=20', + source: '/oab/rankings/volume/today/popular', + cache: 'revalidate=300', + weight: 5, + }, + { + slug: 'rankings_price_change', + group: 'Rankings', + label: '가격 변동 랭킹', + method: 'GET', + path: '/api/rankings/price-change?type=surge&limit=20', + localPath: '/rankings/price-change/surge?limit=20', + source: '/oab/rankings/price-change/surge', + cache: 'revalidate=300', + weight: 5, + }, + { + slug: 'rankings_all_time', + group: 'Rankings', + label: '전체 기간 최고가 랭킹', + method: 'GET', + path: '/api/rankings/all-time?type=highest-price&limit=20', + localPath: '/rankings/all-time/highest-price?limit=20', + source: '/oab/rankings/all-time/highest-price', + cache: 'revalidate=300', + weight: 5, + }, + { + slug: 'rankings_category', + group: 'Rankings', + label: '카테고리 랭킹', + method: 'GET', + path: '/api/rankings/category?type=top-priced&topCategory=%EA%B8%B0%ED%83%80&subCategory=%EA%B8%B0%ED%83%80&limit=20', + localPath: '/rankings/category/top-priced?topCategory=%EA%B8%B0%ED%83%80&subCategory=%EA%B8%B0%ED%83%80&limit=20', + source: '/oab/rankings/category/top-priced', + cache: 'revalidate=300', + weight: 5, + }, + { + slug: 'statistics_daily_top_categories', + group: 'Statistics', + label: '일간 상위 카테고리 통계', + method: 'GET', + path: '/api/statistics/daily/top-categories?topCategory=%EA%B8%B0%ED%83%80', + localPath: '/statistics/daily/top-categories?topCategory=%EA%B8%B0%ED%83%80', + source: '/oab/statistics/daily/top-categories', + cache: 'revalidate=300', + weight: 4, + }, + { + slug: 'statistics_daily_subcategories', + group: 'Statistics', + label: '일간 서브카테고리 통계', + method: 'GET', + path: '/api/statistics/daily/subcategories?topCategory=%EA%B8%B0%ED%83%80&subCategory=%EA%B8%B0%ED%83%80', + localPath: '/statistics/daily/subcategories?topCategory=%EA%B8%B0%ED%83%80&subCategory=%EA%B8%B0%ED%83%80', + source: '/oab/statistics/daily/subcategories', + cache: 'revalidate=300', + weight: 4, + }, + { + slug: 'statistics_daily_items', + group: 'Statistics', + label: '일간 아이템 통계', + method: 'GET', + path: '/api/statistics/daily/items?topCategory=%EA%B8%B0%ED%83%80&subCategory=%EA%B8%B0%ED%83%80&itemName=%ED%96%A5%EA%B8%B0%EB%A1%9C%EC%9A%B4%20%EA%BF%80%20%EC%9A%B0%EC%9C%A0', + localPath: '/statistics/daily/items?topCategory=%EA%B8%B0%ED%83%80&subCategory=%EA%B8%B0%ED%83%80&itemName=%ED%96%A5%EA%B8%B0%EB%A1%9C%EC%9A%B4%20%EA%BF%80%20%EC%9A%B0%EC%9C%A0', + source: '/oab/statistics/daily/items', + cache: 'revalidate=300', + weight: 4, + }, + { + slug: 'statistics_weekly_top_categories', + group: 'Statistics', + label: '주간 상위 카테고리 통계', + method: 'GET', + path: '/api/statistics/weekly/top-categories?topCategory=%EA%B8%B0%ED%83%80', + localPath: '/statistics/weekly/top-categories?topCategory=%EA%B8%B0%ED%83%80', + source: '/oab/statistics/weekly/top-categories', + cache: 'revalidate=300', + weight: 4, + }, + { + slug: 'statistics_weekly_subcategories', + group: 'Statistics', + label: '주간 서브카테고리 통계', + method: 'GET', + path: '/api/statistics/weekly/subcategories?topCategory=%EA%B8%B0%ED%83%80&subCategory=%EA%B8%B0%ED%83%80', + localPath: '/statistics/weekly/subcategories?topCategory=%EA%B8%B0%ED%83%80&subCategory=%EA%B8%B0%ED%83%80', + source: '/oab/statistics/weekly/subcategories', + cache: 'revalidate=300', + weight: 4, + }, + { + slug: 'statistics_weekly_items', + group: 'Statistics', + label: '주간 아이템 통계', + method: 'GET', + path: '/api/statistics/weekly/items?topCategory=%EA%B8%B0%ED%83%80&subCategory=%EA%B8%B0%ED%83%80&itemName=%ED%96%A5%EA%B8%B0%EB%A1%9C%EC%9A%B4%20%EA%BF%80%20%EC%9A%B0%EC%9C%A0', + localPath: '/statistics/weekly/items?topCategory=%EA%B8%B0%ED%83%80&subCategory=%EA%B8%B0%ED%83%80&itemName=%ED%96%A5%EA%B8%B0%EB%A1%9C%EC%9A%B4%20%EA%BF%80%20%EC%9A%B0%EC%9C%A0', + source: '/oab/statistics/weekly/items', + cache: 'revalidate=300', + weight: 4, + }, +]; + +const weightedEndpoints = []; +for (const endpoint of TARGET_ENDPOINTS) { + for (let i = 0; i < endpoint.weight; i += 1) { + weightedEndpoints.push(endpoint); + } +} + +const endpointMetrics = {}; +for (const endpoint of TARGET_ENDPOINTS) { + endpointMetrics[endpoint.slug] = { + duration: new Trend(`endpoint_${endpoint.slug}_duration_ms`, true), + success: new Rate(`endpoint_${endpoint.slug}_success_rate`), + requests: new Counter(`endpoint_${endpoint.slug}_requests`), + status2xx: new Counter(`endpoint_${endpoint.slug}_status_2xx`), + status4xx: new Counter(`endpoint_${endpoint.slug}_status_4xx`), + status5xx: new Counter(`endpoint_${endpoint.slug}_status_5xx`), + statusOther: new Counter(`endpoint_${endpoint.slug}_status_other`), + }; +} + +export const options = { + scenarios: { + batch_readonly_portfolio: { + executor: 'constant-arrival-rate', + rate: RATE, + timeUnit: '1s', + duration: DURATION, + preAllocatedVUs: PRE_ALLOCATED_VUS, + maxVUs: MAX_VUS, + exec: 'readonlyBatchApi', + }, + }, + thresholds: { + http_req_failed: ['rate<0.01'], + checks: ['rate>0.99'], + http_req_duration: ['p(95)<2500', 'p(99)<4000'], + }, + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)'], + tags: { + test_type: 'portfolio_readonly', + target_domain: BASE_URL, + }, +}; + +function pickEndpoint() { + return weightedEndpoints[Math.floor(Math.random() * weightedEndpoints.length)]; +} + +function metricValue(data, name, stat, fallback = 0) { + const metric = data.metrics[name]; + const value = metric && metric.values ? metric.values[stat] : undefined; + return Number.isFinite(value) ? value : fallback; +} + +function fixed(value, digits = 1) { + return Number.isFinite(value) ? value.toFixed(digits) : '-'; +} + +function pct(value, digits = 2) { + return Number.isFinite(value) ? (value * 100).toFixed(digits) : '-'; +} + +function statusBucket(status) { + if (status >= 200 && status < 300) return 'status2xx'; + if (status >= 400 && status < 500) return 'status4xx'; + if (status >= 500 && status < 600) return 'status5xx'; + return 'statusOther'; +} + +function requestPath(endpoint) { + return TARGET_MODE === 'local-batch' ? endpoint.localPath : endpoint.path; +} + +function callRouteDescription() { + if (TARGET_MODE === 'local-batch') { + return `로컬 batch 직접 경로 기준: ${BASE_URL}/* -> batch 애플리케이션`; + } + return `운영 사용자 경로 기준: ${BASE_URL}/api/* -> gateway -> batch(/oab/*)`; +} + +function environmentNote() { + if (TARGET_MODE === 'local-batch') { + return `로컬 Docker batch 서버 대상 테스트이므로 ${RATE} RPS 수준의 동일한 보수적 부하로 측정했다. 운영 www 도메인의 Next.js API proxy/CDN/TLS 구간은 제외되고, 로컬 Docker 네트워크의 batch 애플리케이션/DB/Redis/Elasticsearch 구간이 포함된다.`; + } + return `운영 서버 대상 테스트이므로 서비스 영향도를 낮추기 위해 ${RATE} RPS 수준의 보수적 부하로 측정했다. 직접 batch 도메인이 아니라 실제 사용자 진입점인 www 도메인의 Next.js API proxy를 통해 측정했으므로 CDN/TLS/Next route handler/gateway/batch 구간이 함께 포함된다.`; +} + +function targetSummaryLabel() { + return TARGET_MODE === 'local-batch' ? '로컬 Docker batch 서버 기준' : '운영 도메인 기준'; +} + +function cacheCaution() { + if (TARGET_MODE === 'local-batch') { + return '- 운영 www 도메인의 Next.js route handler 캐시, CDN, TLS, gateway 구간은 포함하지 않은 batch 애플리케이션 직접 측정 결과다.'; + } + return '- Next.js route handler 캐시가 적용된 API가 포함되어 있어, 순수 batch 애플리케이션/DB 단독 처리시간과는 다를 수 있다.'; +} + +function kstTimestamp() { + const now = new Date(Date.now() + 9 * 60 * 60 * 1000); + return now.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' KST'); +} + +function buildEndpointRows(data) { + return TARGET_ENDPOINTS.map((endpoint) => { + const prefix = `endpoint_${endpoint.slug}`; + const requests = metricValue(data, `${prefix}_requests`, 'count'); + return { + ...endpoint, + requests, + successRate: requests > 0 ? metricValue(data, `${prefix}_success_rate`, 'rate') : null, + avg: metricValue(data, `${prefix}_duration_ms`, 'avg'), + med: metricValue(data, `${prefix}_duration_ms`, 'med'), + p90: metricValue(data, `${prefix}_duration_ms`, 'p(90)'), + p95: metricValue(data, `${prefix}_duration_ms`, 'p(95)'), + p99: metricValue(data, `${prefix}_duration_ms`, 'p(99)'), + min: metricValue(data, `${prefix}_duration_ms`, 'min'), + max: metricValue(data, `${prefix}_duration_ms`, 'max'), + status2xx: metricValue(data, `${prefix}_status_2xx`, 'count'), + status4xx: metricValue(data, `${prefix}_status_4xx`, 'count'), + status5xx: metricValue(data, `${prefix}_status_5xx`, 'count'), + statusOther: metricValue(data, `${prefix}_status_other`, 'count'), + }; + }); +} + +function buildGroupRows(endpointRows) { + const groups = {}; + for (const row of endpointRows) { + if (!groups[row.group]) { + groups[row.group] = { + group: row.group, + endpoints: 0, + requests: 0, + weightedAvgSum: 0, + p95Max: 0, + successCount: 0, + }; + } + groups[row.group].endpoints += 1; + groups[row.group].requests += row.requests; + groups[row.group].weightedAvgSum += row.avg * row.requests; + groups[row.group].p95Max = Math.max(groups[row.group].p95Max, row.p95); + groups[row.group].successCount += row.requests * (row.successRate || 0); + } + + return Object.values(groups).map((group) => ({ + ...group, + avg: group.requests > 0 ? group.weightedAvgSum / group.requests : 0, + successRate: group.requests > 0 ? group.successCount / group.requests : 0, + })); +} + +function buildMarkdown(data) { + const endpointRows = buildEndpointRows(data); + const groupRows = buildGroupRows(endpointRows); + const totalRequests = metricValue(data, 'http_reqs', 'count'); + const failedRate = metricValue(data, 'http_req_failed', 'rate'); + const checksRate = metricValue(data, 'checks', 'rate'); + const duration = data.metrics.http_req_duration.values; + const receivedBytes = metricValue(data, 'data_received', 'count'); + const sentBytes = metricValue(data, 'data_sent', 'count'); + const droppedIterations = metricValue(data, 'dropped_iterations', 'count'); + const testRunMs = data.state && data.state.testRunDurationMs ? data.state.testRunDurationMs : 0; + const measuredSeconds = testRunMs > 0 ? testRunMs / 1000 : null; + const achievedRps = measuredSeconds ? totalRequests / measuredSeconds : RATE; + const successfulRequests = Math.round(totalRequests * (1 - failedRate)); + const failedRequests = totalRequests - successfulRequests; + + const endpointTable = endpointRows + .map((row) => `| ${row.group} | ${row.label} | ${row.method} ${requestPath(row)} | ${row.requests} | ${pct(row.successRate)}% | ${fixed(row.avg)} | ${fixed(row.med)} | ${fixed(row.p90)} | ${fixed(row.p95)} | ${fixed(row.p99)} | ${fixed(row.max)} | ${row.status2xx}/${row.status4xx}/${row.status5xx}/${row.statusOther} |`) + .join('\n'); + + const groupTable = groupRows + .map((row) => `| ${row.group} | ${row.endpoints} | ${row.requests} | ${pct(row.successRate)}% | ${fixed(row.avg)} | ${fixed(row.p95Max)} |`) + .join('\n'); + + const targetTable = TARGET_ENDPOINTS + .map((endpoint) => `| ${endpoint.group} | ${endpoint.label} | ${endpoint.method} ${requestPath(endpoint)} | ${endpoint.source} | ${endpoint.cache} |`) + .join('\n'); + + return `# DEVNOGI Batch 조회 API 성능 테스트 결과 + +## 1. 테스트 개요 + +| 항목 | 내용 | +|---|---| +| 측정 일시 | ${kstTimestamp()} | +| 대상 도메인 | ${BASE_URL} | +| 측정 도구 | K6 v1.2.3 | +| 테스트 대상 | batch 서버 조회 API ${TARGET_ENDPOINTS.length}종 | +| 호출 경로 | ${callRouteDescription()} | +| 제외 범위 | 로그인/인증 필요 API, 관리자 API, POST/PUT/PATCH/DELETE, batch sync/write API | +| 실행 모델 | constant-arrival-rate | +| 목표 부하 | ${RATE} req/s, ${DURATION}, preAllocatedVUs=${PRE_ALLOCATED_VUS}, maxVUs=${MAX_VUS} | +| 실제 처리량 | ${fixed(achievedRps, 2)} req/s | +| 총 요청 수 | ${totalRequests}건 | +| Dropped iterations | ${droppedIterations}건 | +| 성공 요청 수 | ${successfulRequests}건 | +| 실패 요청 수 | ${failedRequests}건 | +| 응답 데이터 수신량 | ${(receivedBytes / 1024 / 1024).toFixed(2)} MiB | +| 요청 데이터 송신량 | ${(sentBytes / 1024 / 1024).toFixed(2)} MiB | + +> ${environmentNote()} + +## 2. 전체 결과 요약 + +| 지표 | 결과 | +|---|---:| +| HTTP 실패율 | ${pct(failedRate)}% | +| K6 check 성공률 | ${pct(checksRate)}% | +| 평균 응답시간 | ${fixed(duration.avg)} ms | +| 중앙값 응답시간 | ${fixed(duration.med)} ms | +| p90 응답시간 | ${fixed(duration['p(90)'])} ms | +| p95 응답시간 | ${fixed(duration['p(95)'])} ms | +| p99 응답시간 | ${fixed(duration['p(99)'])} ms | +| 최소 응답시간 | ${fixed(duration.min)} ms | +| 최대 응답시간 | ${fixed(duration.max)} ms | + +## 3. API 그룹별 결과 + +| 그룹 | API 수 | 요청 수 | 성공률 | 가중 평균 응답시간(ms) | 그룹 내 최대 p95(ms) | +|---|---:|---:|---:|---:|---:| +${groupTable} + +## 4. API별 상세 결과 + +| 그룹 | API | 경로 | 요청 수 | 성공률 | avg(ms) | med(ms) | p90(ms) | p95(ms) | p99(ms) | max(ms) | 상태코드 2xx/4xx/5xx/기타 | +|---|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:| +${endpointTable} + +## 5. 테스트 대상 API 목록 + +| 그룹 | 기능 | 호출 경로 | batch 원천 경로 | 캐시/비고 | +|---|---|---|---|---| +${targetTable} + +## 6. 포트폴리오 기재용 요약 + +- ${targetSummaryLabel()} batch 조회 API ${TARGET_ENDPOINTS.length}종에 대해 K6 성능 테스트를 수행했다. +- 테스트는 조회 전용 GET API만 대상으로 구성했고, 쓰기성 batch sync/API 및 인증 필요 API는 제외했다. +- ${DURATION} 동안 ${RATE} req/s의 고정 도착률로 총 ${totalRequests}건을 호출했으며, HTTP 성공률은 ${pct(1 - failedRate)}%, K6 check 성공률은 ${pct(checksRate)}%로 측정됐다. +- 전체 응답시간은 평균 ${fixed(duration.avg)}ms, p95 ${fixed(duration['p(95)'])}ms, p99 ${fixed(duration['p(99)'])}ms로 측정됐다. +- 카테고리/검색 옵션/랭킹/통계/경매 검색 등 실제 사용자 조회 흐름을 반영해 API 그룹별 응답시간과 tail latency를 분리 산출했다. + +## 7. 재현 명령 + +\`\`\`bash +BASE_URL=${BASE_URL} \\ +TARGET_MODE=${TARGET_MODE} \\ +RATE=${RATE} DURATION=${DURATION} \\ +PRE_ALLOCATED_VUS=${PRE_ALLOCATED_VUS} MAX_VUS=${MAX_VUS} \\ +REPORT_MD=${REPORT_MD} \\ +REPORT_JSON=${REPORT_JSON} \\ +k6 run performance/k6/scenarios/batch_readonly_portfolio.js +\`\`\` + +## 8. 해석 시 주의사항 + +- 본 결과는 ${kstTimestamp().slice(0, 10)} KST에 로컬 Mac에서 ${BASE_URL} 대상으로 측정한 값이다. +- 서버 내부 CPU, 메모리, DB connection pool, Redis hit ratio, JVM GC, DB slow query 같은 내부 지표는 포함하지 않았다. +${cacheCaution()} +- JMeter는 로컬에 설치되어 있지 않아 이번 산출물은 K6 기준으로 작성했다. +`; +} + +export function readonlyBatchApi() { + const endpoint = pickEndpoint(); + const metrics = endpointMetrics[endpoint.slug]; + const url = `${BASE_URL}${requestPath(endpoint)}`; + const res = http.get(url, { + headers: { + Accept: 'application/json', + 'User-Agent': 'k6-devnogi-portfolio-readonly/1.0', + }, + tags: { + api_group: endpoint.group, + endpoint_slug: endpoint.slug, + name: endpoint.label, + }, + }); + + const ok = check(res, { + [`${endpoint.slug} status is 200`]: (r) => r.status === 200, + [`${endpoint.slug} body is not empty`]: (r) => !!r.body && r.body.length > 0, + }); + + metrics.duration.add(res.timings.duration); + metrics.success.add(ok); + metrics.requests.add(1); + metrics[statusBucket(res.status)].add(1); +} + +export function handleSummary(data) { + const markdown = buildMarkdown(data); + const endpointRows = buildEndpointRows(data); + const json = JSON.stringify( + { + generatedAtKst: kstTimestamp(), + baseUrl: BASE_URL, + targetMode: TARGET_MODE, + duration: DURATION, + rate: RATE, + targetEndpointCount: TARGET_ENDPOINTS.length, + endpoints: endpointRows, + summaryMetrics: data.metrics, + }, + null, + 2, + ); + + return { + stdout: markdown, + [REPORT_MD]: markdown, + [REPORT_JSON]: json, + }; +} diff --git a/src/main/java/until/the/eternity/auctionhistory/domain/entity/AuctionHistory.java b/src/main/java/until/the/eternity/auctionhistory/domain/entity/AuctionHistory.java index 6f4cd7d8..bb5dc710 100644 --- a/src/main/java/until/the/eternity/auctionhistory/domain/entity/AuctionHistory.java +++ b/src/main/java/until/the/eternity/auctionhistory/domain/entity/AuctionHistory.java @@ -8,7 +8,13 @@ import java.util.List; @Entity -@Table(name = "auction_history") +@Table( + name = "auction_history", + indexes = { + @Index( + name = "idx_auction_history_price_buy_id", + columnList = "auction_price_per_unit DESC, auction_buy_id DESC") + }) @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/until/the/eternity/common/response/PageMeta.java b/src/main/java/until/the/eternity/common/response/PageMeta.java index 8165065c..31eb8a09 100644 --- a/src/main/java/until/the/eternity/common/response/PageMeta.java +++ b/src/main/java/until/the/eternity/common/response/PageMeta.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import org.springframework.data.domain.Page; +import org.springframework.data.domain.Slice; @Schema(description = "페이지 응답 메타데이터") public record PageMeta( @@ -21,4 +22,21 @@ public static PageMeta of(Page page) { page.isFirst(), page.isLast()); } + + public static PageMeta of(Slice slice) { + int currentPage = slice.getNumber() + 1; + int totalPages = currentPage + (slice.hasNext() ? 1 : 0); + long totalElements = + slice.getPageable().getOffset() + + slice.getNumberOfElements() + + (slice.hasNext() ? 1 : 0); + + return new PageMeta( + currentPage, + slice.getSize(), + totalPages, + totalElements, + slice.isFirst(), + slice.isLast()); + } } diff --git a/src/main/java/until/the/eternity/common/response/PageResponseDto.java b/src/main/java/until/the/eternity/common/response/PageResponseDto.java index 0919ce20..4419c5c9 100644 --- a/src/main/java/until/the/eternity/common/response/PageResponseDto.java +++ b/src/main/java/until/the/eternity/common/response/PageResponseDto.java @@ -1,6 +1,7 @@ package until.the.eternity.common.response; import io.swagger.v3.oas.annotations.media.Schema; +import org.springframework.data.domain.Slice; import java.util.List; @@ -12,4 +13,8 @@ public record PageResponseDto( public static PageResponseDto of(org.springframework.data.domain.Page page) { return new PageResponseDto<>(page.getContent(), PageMeta.of(page)); } + + public static PageResponseDto of(Slice slice) { + return new PageResponseDto<>(slice.getContent(), PageMeta.of(slice)); + } } diff --git a/src/main/java/until/the/eternity/config/RedisConfig.java b/src/main/java/until/the/eternity/config/RedisConfig.java index 0ce20f22..a5a50e3a 100644 --- a/src/main/java/until/the/eternity/config/RedisConfig.java +++ b/src/main/java/until/the/eternity/config/RedisConfig.java @@ -101,12 +101,15 @@ public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) // 통계 - 30분 TTL (이벤트 기반 eviction으로 실시간 반영) Duration statsTtl = Duration.ofMinutes(30); + Duration weeklyStatsTtl = Duration.ofHours(12); configs.put(CacheNames.STATISTICS_ITEM_DAILY, defaultConfig.entryTtl(statsTtl)); configs.put(CacheNames.STATISTICS_SUBCATEGORY_DAILY, defaultConfig.entryTtl(statsTtl)); configs.put(CacheNames.STATISTICS_TOPCATEGORY_DAILY, defaultConfig.entryTtl(statsTtl)); - configs.put(CacheNames.STATISTICS_ITEM_WEEKLY, defaultConfig.entryTtl(statsTtl)); - configs.put(CacheNames.STATISTICS_SUBCATEGORY_WEEKLY, defaultConfig.entryTtl(statsTtl)); - configs.put(CacheNames.STATISTICS_TOPCATEGORY_WEEKLY, defaultConfig.entryTtl(statsTtl)); + configs.put(CacheNames.STATISTICS_ITEM_WEEKLY, defaultConfig.entryTtl(weeklyStatsTtl)); + configs.put( + CacheNames.STATISTICS_SUBCATEGORY_WEEKLY, defaultConfig.entryTtl(weeklyStatsTtl)); + configs.put( + CacheNames.STATISTICS_TOPCATEGORY_WEEKLY, defaultConfig.entryTtl(weeklyStatsTtl)); // 실시간 경매 - 12분 TTL (10분 배치 + 여유 2분) configs.put( diff --git a/src/main/java/until/the/eternity/hornBugle/application/scheduler/HornBugleScheduler.java b/src/main/java/until/the/eternity/hornBugle/application/scheduler/HornBugleScheduler.java index 1fe31492..be81e18c 100644 --- a/src/main/java/until/the/eternity/hornBugle/application/scheduler/HornBugleScheduler.java +++ b/src/main/java/until/the/eternity/hornBugle/application/scheduler/HornBugleScheduler.java @@ -12,6 +12,7 @@ import until.the.eternity.hornBugle.infrastructure.client.HornBugleClient; import until.the.eternity.hornBugle.interfaces.external.dto.OpenApiHornBugleHistoryListResponse; import until.the.eternity.hornBugle.interfaces.external.dto.OpenApiHornBugleHistoryResponse; +import until.the.eternity.hornBugle.interfaces.rest.dto.request.HornBuglePageRequestDto; import until.the.eternity.hornBugle.kafka.application.HornBugleKafkaProducerService; import until.the.eternity.hornBugle.kafka.dto.UserVerificationVerifyEvent; @@ -75,6 +76,8 @@ public void fetchAndSaveHornBugleHistoryAll() { log.info( "[HornBugle] Horn Bugle World History scheduler completed. Total saved: {}", totalSavedCount); + + warmRecentReadCaches(); } /** @@ -224,4 +227,19 @@ private void waitForRateLimit() { Thread.currentThread().interrupt(); } } + + private void warmRecentReadCaches() { + try { + service.search(null, null, new HornBuglePageRequestDto(1, 20)); + service.search(null, null, new HornBuglePageRequestDto(2, 20)); + + for (HornBugleServer server : HornBugleServer.values()) { + service.search(server.getServerName(), null, new HornBuglePageRequestDto(1, 20)); + } + + log.info("[HornBugle] Recent read cache warmup completed"); + } catch (Exception e) { + log.warn("[HornBugle] Recent read cache warmup failed: {}", e.getMessage(), e); + } + } } diff --git a/src/main/java/until/the/eternity/hornBugle/application/service/HornBugleService.java b/src/main/java/until/the/eternity/hornBugle/application/service/HornBugleService.java index 242644f0..16aea268 100644 --- a/src/main/java/until/the/eternity/hornBugle/application/service/HornBugleService.java +++ b/src/main/java/until/the/eternity/hornBugle/application/service/HornBugleService.java @@ -4,6 +4,7 @@ import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.Page; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import until.the.eternity.common.response.PageResponseDto; @@ -159,15 +160,17 @@ private PageResponseDto searchByElasticsearch( private PageResponseDto searchByDatabase( String serverName, HornBuglePageRequestDto pageRequest) { - Page page; + Slice page; if (serverName != null && !serverName.isBlank()) { - page = repository.findByServerName(serverName, pageRequest.toPageable()); + page = + repository.findRecentByServerName( + serverName, pageRequest.toPageableWithoutSort()); } else { - page = repository.findAll(pageRequest.toPageable()); + page = repository.findRecent(pageRequest.toPageableWithoutSort()); } - Page responsePage = page.map(mapper::toResponse); + Slice responsePage = page.map(mapper::toResponse); return PageResponseDto.of(responsePage); } diff --git a/src/main/java/until/the/eternity/hornBugle/domain/entity/HornBugleWorldHistory.java b/src/main/java/until/the/eternity/hornBugle/domain/entity/HornBugleWorldHistory.java index 05ca8dff..12b2d3a1 100644 --- a/src/main/java/until/the/eternity/hornBugle/domain/entity/HornBugleWorldHistory.java +++ b/src/main/java/until/the/eternity/hornBugle/domain/entity/HornBugleWorldHistory.java @@ -12,7 +12,11 @@ @Index( name = "idx_horn_bugle_server_date_send", columnList = "server_name, date_send DESC"), - @Index(name = "idx_horn_bugle_date_send", columnList = "date_send DESC") + @Index(name = "idx_horn_bugle_date_send", columnList = "date_send DESC"), + @Index(name = "idx_horn_bugle_date_send_id", columnList = "date_send DESC, id DESC"), + @Index( + name = "idx_horn_bugle_server_date_send_id", + columnList = "server_name, date_send DESC, id DESC") }) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) diff --git a/src/main/java/until/the/eternity/hornBugle/domain/repository/HornBugleRepositoryPort.java b/src/main/java/until/the/eternity/hornBugle/domain/repository/HornBugleRepositoryPort.java index 75dfd277..7d2b995f 100644 --- a/src/main/java/until/the/eternity/hornBugle/domain/repository/HornBugleRepositoryPort.java +++ b/src/main/java/until/the/eternity/hornBugle/domain/repository/HornBugleRepositoryPort.java @@ -2,6 +2,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import until.the.eternity.hornBugle.domain.entity.HornBugleWorldHistory; import java.time.Instant; @@ -18,6 +19,10 @@ public interface HornBugleRepositoryPort { Page findAll(Pageable pageable); + Slice findRecent(Pageable pageable); + + Slice findRecentByServerName(String serverName, Pageable pageable); + List findByServerNameAndDateSend(String serverName, Instant dateSend); /** FULLTEXT 인덱스를 사용한 키워드 검색 (전체 서버) */ diff --git a/src/main/java/until/the/eternity/hornBugle/infrastructure/persistence/HornBugleJpaRepository.java b/src/main/java/until/the/eternity/hornBugle/infrastructure/persistence/HornBugleJpaRepository.java index e68b01eb..411667b7 100644 --- a/src/main/java/until/the/eternity/hornBugle/infrastructure/persistence/HornBugleJpaRepository.java +++ b/src/main/java/until/the/eternity/hornBugle/infrastructure/persistence/HornBugleJpaRepository.java @@ -2,6 +2,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; @@ -14,16 +15,24 @@ @Repository public interface HornBugleJpaRepository extends JpaRepository { + Optional findTopByServerNameOrderByDateSendDescIdDesc(String serverName); + + Page findByServerName(String serverName, Pageable pageable); + @Query( """ SELECT h FROM HornBugleWorldHistory h - WHERE h.serverName = :serverName - ORDER BY h.dateSend DESC - LIMIT 1 + ORDER BY h.dateSend DESC, h.id DESC """) - Optional findLatestByServerName(String serverName); + Slice findRecent(Pageable pageable); - Page findByServerName(String serverName, Pageable pageable); + @Query( + """ + SELECT h FROM HornBugleWorldHistory h + WHERE h.serverName = :serverName + ORDER BY h.dateSend DESC, h.id DESC + """) + Slice findRecentByServerName(String serverName, Pageable pageable); List findByServerNameAndDateSend(String serverName, Instant dateSend); diff --git a/src/main/java/until/the/eternity/hornBugle/infrastructure/persistence/HornBugleRepositoryPortImpl.java b/src/main/java/until/the/eternity/hornBugle/infrastructure/persistence/HornBugleRepositoryPortImpl.java index 706bc05b..b3a1fc43 100644 --- a/src/main/java/until/the/eternity/hornBugle/infrastructure/persistence/HornBugleRepositoryPortImpl.java +++ b/src/main/java/until/the/eternity/hornBugle/infrastructure/persistence/HornBugleRepositoryPortImpl.java @@ -5,6 +5,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; import until.the.eternity.hornBugle.domain.entity.HornBugleWorldHistory; @@ -42,7 +43,7 @@ public void saveAll(List entities) { @Override public Optional findLatestByServerName(String serverName) { - return jpaRepository.findLatestByServerName(serverName); + return jpaRepository.findTopByServerNameOrderByDateSendDescIdDesc(serverName); } @Override @@ -55,6 +56,17 @@ public Page findAll(Pageable pageable) { return jpaRepository.findAll(pageable); } + @Override + public Slice findRecent(Pageable pageable) { + return jpaRepository.findRecent(pageable); + } + + @Override + public Slice findRecentByServerName( + String serverName, Pageable pageable) { + return jpaRepository.findRecentByServerName(serverName, pageable); + } + @Override public List findByServerNameAndDateSend( String serverName, Instant dateSend) { diff --git a/src/main/java/until/the/eternity/statistics/service/WeeklyStatisticsService.java b/src/main/java/until/the/eternity/statistics/service/WeeklyStatisticsService.java index ca191803..35c7527e 100644 --- a/src/main/java/until/the/eternity/statistics/service/WeeklyStatisticsService.java +++ b/src/main/java/until/the/eternity/statistics/service/WeeklyStatisticsService.java @@ -2,23 +2,45 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Caching; +import org.springframework.core.task.TaskExecutor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import until.the.eternity.config.CacheNames; +import until.the.eternity.ranking.application.service.PriceRankingService; +import until.the.eternity.ranking.application.service.VolumeRankingService; +import until.the.eternity.ranking.util.RankingConstants; +import until.the.eternity.statistics.application.service.ItemWeeklyStatisticsService; +import until.the.eternity.statistics.application.service.TopCategoryWeeklyStatisticsService; import until.the.eternity.statistics.repository.weekly.ItemWeeklyStatisticsRepository; import until.the.eternity.statistics.repository.weekly.SubcategoryWeeklyStatisticsRepository; import until.the.eternity.statistics.repository.weekly.TopCategoryWeeklyStatisticsRepository; +import java.time.LocalDate; +import java.util.List; + @Slf4j @Service @RequiredArgsConstructor public class WeeklyStatisticsService { + @Qualifier("applicationTaskExecutor") + private final TaskExecutor taskExecutor; + private final ItemWeeklyStatisticsRepository itemWeeklyStatisticsRepository; private final SubcategoryWeeklyStatisticsRepository subcategoryWeeklyStatisticsRepository; private final TopCategoryWeeklyStatisticsRepository topCategoryWeeklyStatisticsRepository; + private final ItemWeeklyStatisticsService itemWeeklyStatisticsReadService; + private final TopCategoryWeeklyStatisticsService topCategoryWeeklyStatisticsReadService; + private final PriceRankingService priceRankingService; + private final VolumeRankingService volumeRankingService; + + private record WeeklyStatisticsWarmupTarget( + String itemName, String topCategory, String subCategory) {} /** * 전주(지난 주 월~일)의 일간 통계를 기반으로 주간 통계를 계산하여 저장 순서: ItemDaily → ItemWeekly → SubcategoryWeekly → @@ -72,5 +94,54 @@ public void calculateAndSaveWeeklyStatistics() { log.info( "[Weekly Statistics] All weekly statistics calculated successfully in {} ms", System.currentTimeMillis() - start); + scheduleReadCacheWarmup(); + } + + private void scheduleReadCacheWarmup() { + Runnable warmup = this::warmReadCaches; + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + taskExecutor.execute(warmup); + } + }); + return; + } + taskExecutor.execute(warmup); + } + + private void warmReadCaches() { + try { + LocalDate end = LocalDate.now(); + LocalDate weeklyStart = end.minusMonths(2); + + for (WeeklyStatisticsWarmupTarget target : weeklyStatisticsWarmupTargets()) { + itemWeeklyStatisticsReadService.search( + target.itemName(), + target.subCategory(), + target.topCategory(), + weeklyStart, + end); + topCategoryWeeklyStatisticsReadService.search( + target.topCategory(), weeklyStart, end); + } + + priceRankingService.getWeekHighestPrice(20); + priceRankingService.getWeekHighestPrice(RankingConstants.DEFAULT_LIMIT); + volumeRankingService.getWeekPopular(20); + volumeRankingService.getWeekPopular(RankingConstants.DEFAULT_LIMIT); + + log.info("[Weekly Statistics] Read cache warmup completed"); + } catch (Exception e) { + log.warn("[Weekly Statistics] Read cache warmup failed: {}", e.getMessage(), e); + } + } + + private List weeklyStatisticsWarmupTargets() { + return List.of( + new WeeklyStatisticsWarmupTarget("향기로운 꿀 우유", "기타", "기타"), + new WeeklyStatisticsWarmupTarget("축복의 포션", "소모품", "포션")); } } diff --git a/src/main/resources/db/migration/V31__add_batch_read_p99_tail_indexes.sql b/src/main/resources/db/migration/V31__add_batch_read_p99_tail_indexes.sql new file mode 100644 index 00000000..acd8ebd3 --- /dev/null +++ b/src/main/resources/db/migration/V31__add_batch_read_p99_tail_indexes.sql @@ -0,0 +1,51 @@ +-- BATCH 조회 API P99 tail latency 개선 인덱스 +-- - horn_bugle 최신 목록은 date_send DESC + id DESC 순서로 안정적인 top-N 조회를 수행한다. +-- - all-time highest ranking은 auction_history 전체 정렬 대신 가격/거래 ID 인덱스로 top-N 조회한다. + +SET @index_exists := ( + SELECT COUNT(1) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'horn_bugle_world_history' + AND index_name = 'idx_horn_bugle_date_send_id' +); +SET @ddl := IF( + @index_exists = 0, + 'CREATE INDEX idx_horn_bugle_date_send_id ON horn_bugle_world_history (date_send DESC, id DESC)', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @index_exists := ( + SELECT COUNT(1) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'horn_bugle_world_history' + AND index_name = 'idx_horn_bugle_server_date_send_id' +); +SET @ddl := IF( + @index_exists = 0, + 'CREATE INDEX idx_horn_bugle_server_date_send_id ON horn_bugle_world_history (server_name, date_send DESC, id DESC)', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @index_exists := ( + SELECT COUNT(1) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'auction_history' + AND index_name = 'idx_auction_history_price_buy_id' +); +SET @ddl := IF( + @index_exists = 0, + 'CREATE INDEX idx_auction_history_price_buy_id ON auction_history (auction_price_per_unit DESC, auction_buy_id DESC)', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/src/test/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryServiceTest.java b/src/test/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryServiceTest.java index 5f12b620..912ce386 100644 --- a/src/test/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryServiceTest.java +++ b/src/test/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryServiceTest.java @@ -1,11 +1,5 @@ package until.the.eternity.auctionhistory.application.service; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.*; - -import java.util.List; -import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -27,6 +21,13 @@ import until.the.eternity.common.response.PageResponseDto; import until.the.eternity.config.CacheNames; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + @ExtendWith(MockitoExtension.class) class AuctionHistoryServiceTest { diff --git a/src/test/java/until/the/eternity/auctionhistory/application/service/fetcher/AuctionHistoryFetcherTest.java b/src/test/java/until/the/eternity/auctionhistory/application/service/fetcher/AuctionHistoryFetcherTest.java index e6e76af4..d1187257 100644 --- a/src/test/java/until/the/eternity/auctionhistory/application/service/fetcher/AuctionHistoryFetcherTest.java +++ b/src/test/java/until/the/eternity/auctionhistory/application/service/fetcher/AuctionHistoryFetcherTest.java @@ -1,13 +1,5 @@ package until.the.eternity.auctionhistory.application.service.fetcher; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; - -import java.time.Instant; -import java.util.List; -import java.util.OptionalInt; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -22,6 +14,15 @@ import until.the.eternity.auctionhistory.interfaces.external.dto.OpenApiAuctionHistoryResponse; import until.the.eternity.common.enums.ItemCategory; +import java.time.Instant; +import java.util.List; +import java.util.OptionalInt; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + @ExtendWith(MockitoExtension.class) class AuctionHistoryFetcherTest { diff --git a/src/test/java/until/the/eternity/auctionhistory/application/service/persister/AuctionHistoryPersisterTest.java b/src/test/java/until/the/eternity/auctionhistory/application/service/persister/AuctionHistoryPersisterTest.java index 0d9b8d2d..7764a911 100644 --- a/src/test/java/until/the/eternity/auctionhistory/application/service/persister/AuctionHistoryPersisterTest.java +++ b/src/test/java/until/the/eternity/auctionhistory/application/service/persister/AuctionHistoryPersisterTest.java @@ -1,11 +1,5 @@ package until.the.eternity.auctionhistory.application.service.persister; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - -import java.util.Arrays; -import java.util.Collections; -import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -19,6 +13,13 @@ import until.the.eternity.auctionhistory.interfaces.external.dto.OpenApiAuctionHistoryResponse; import until.the.eternity.common.enums.ItemCategory; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + @ExtendWith(MockitoExtension.class) class AuctionHistoryPersisterTest { diff --git a/src/test/java/until/the/eternity/auctionhistory/domain/service/AuctionHistoryDuplicateCheckerTest.java b/src/test/java/until/the/eternity/auctionhistory/domain/service/AuctionHistoryDuplicateCheckerTest.java index e0018834..2591d675 100644 --- a/src/test/java/until/the/eternity/auctionhistory/domain/service/AuctionHistoryDuplicateCheckerTest.java +++ b/src/test/java/until/the/eternity/auctionhistory/domain/service/AuctionHistoryDuplicateCheckerTest.java @@ -1,13 +1,5 @@ package until.the.eternity.auctionhistory.domain.service; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.when; - -import java.time.Instant; -import java.util.List; -import java.util.Optional; -import java.util.OptionalInt; -import java.util.Set; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -20,6 +12,15 @@ import until.the.eternity.auctionhistory.interfaces.external.dto.OpenApiAuctionHistoryResponse; import until.the.eternity.common.enums.ItemCategory; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + @ExtendWith(MockitoExtension.class) class AuctionHistoryDuplicateCheckerTest { diff --git a/src/test/java/until/the/eternity/auctionrealtime/application/service/AuctionRealtimeServiceTest.java b/src/test/java/until/the/eternity/auctionrealtime/application/service/AuctionRealtimeServiceTest.java index 692aa26b..c7e0ede3 100644 --- a/src/test/java/until/the/eternity/auctionrealtime/application/service/AuctionRealtimeServiceTest.java +++ b/src/test/java/until/the/eternity/auctionrealtime/application/service/AuctionRealtimeServiceTest.java @@ -1,11 +1,5 @@ package until.the.eternity.auctionrealtime.application.service; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.*; - -import java.util.List; -import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -26,6 +20,13 @@ import until.the.eternity.auctionrealtime.interfaces.rest.dto.response.RealtimeItemOptionResponse; import until.the.eternity.common.response.PageResponseDto; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + @ExtendWith(MockitoExtension.class) class AuctionRealtimeServiceTest { diff --git a/src/test/java/until/the/eternity/auctionrealtime/application/service/fetcher/AuctionRealtimeFetcherTest.java b/src/test/java/until/the/eternity/auctionrealtime/application/service/fetcher/AuctionRealtimeFetcherTest.java index cb22f7b3..e59f27ad 100644 --- a/src/test/java/until/the/eternity/auctionrealtime/application/service/fetcher/AuctionRealtimeFetcherTest.java +++ b/src/test/java/until/the/eternity/auctionrealtime/application/service/fetcher/AuctionRealtimeFetcherTest.java @@ -1,12 +1,5 @@ package until.the.eternity.auctionrealtime.application.service.fetcher; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; - -import java.time.Instant; -import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -21,6 +14,14 @@ import until.the.eternity.auctionrealtime.interfaces.external.dto.OpenApiAuctionRealtimeResponse; import until.the.eternity.common.enums.ItemCategory; +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + @ExtendWith(MockitoExtension.class) class AuctionRealtimeFetcherTest { diff --git a/src/test/java/until/the/eternity/auctionsearchoption/application/service/AuctionSearchOptionServiceTest.java b/src/test/java/until/the/eternity/auctionsearchoption/application/service/AuctionSearchOptionServiceTest.java index d92cf748..cbd42577 100644 --- a/src/test/java/until/the/eternity/auctionsearchoption/application/service/AuctionSearchOptionServiceTest.java +++ b/src/test/java/until/the/eternity/auctionsearchoption/application/service/AuctionSearchOptionServiceTest.java @@ -1,11 +1,6 @@ package until.the.eternity.auctionsearchoption.application.service; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.List; -import java.util.Map; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -17,6 +12,12 @@ import until.the.eternity.auctionsearchoption.interfaces.rest.dto.response.FieldMetadata; import until.the.eternity.auctionsearchoption.interfaces.rest.dto.response.SearchOptionMetadataResponse; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + @ExtendWith(MockitoExtension.class) class AuctionSearchOptionServiceTest { diff --git a/src/test/java/until/the/eternity/common/util/SegongOptionParserTest.java b/src/test/java/until/the/eternity/common/util/SegongOptionParserTest.java index be0a18f5..8f0e956d 100644 --- a/src/test/java/until/the/eternity/common/util/SegongOptionParserTest.java +++ b/src/test/java/until/the/eternity/common/util/SegongOptionParserTest.java @@ -1,13 +1,13 @@ package until.the.eternity.common.util; -import static org.assertj.core.api.Assertions.assertThat; - import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import static org.assertj.core.api.Assertions.assertThat; + class SegongOptionParserTest { private static final String SEGONG = "세공 옵션"; diff --git a/src/test/java/until/the/eternity/iteminfo/application/service/ItemInfoServiceTest.java b/src/test/java/until/the/eternity/iteminfo/application/service/ItemInfoServiceTest.java index bd64c643..0c472953 100644 --- a/src/test/java/until/the/eternity/iteminfo/application/service/ItemInfoServiceTest.java +++ b/src/test/java/until/the/eternity/iteminfo/application/service/ItemInfoServiceTest.java @@ -1,10 +1,5 @@ package until.the.eternity.iteminfo.application.service; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - -import java.util.ArrayList; -import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -22,6 +17,12 @@ import until.the.eternity.iteminfo.interfaces.rest.dto.response.ItemInfoSummaryResponse; import until.the.eternity.iteminfo.interfaces.rest.dto.response.ItemInfoSyncResponse; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + @ExtendWith(MockitoExtension.class) class ItemInfoServiceTest { diff --git a/src/test/java/until/the/eternity/metalwareinfo/application/service/MetalwareInfoServiceTest.java b/src/test/java/until/the/eternity/metalwareinfo/application/service/MetalwareInfoServiceTest.java index 4bd777ea..b357e1cf 100644 --- a/src/test/java/until/the/eternity/metalwareinfo/application/service/MetalwareInfoServiceTest.java +++ b/src/test/java/until/the/eternity/metalwareinfo/application/service/MetalwareInfoServiceTest.java @@ -1,9 +1,5 @@ package until.the.eternity.metalwareinfo.application.service; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - -import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -14,6 +10,11 @@ import until.the.eternity.metalwareinfo.interfaces.rest.dto.response.MetalwareInfoResponse; import until.the.eternity.metalwareinfo.interfaces.rest.dto.response.MetalwareInfoSyncResponse; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + @ExtendWith(MockitoExtension.class) class MetalwareInfoServiceTest {