From 5f4a733b3ba69405383cb15eea8da3900ed026ba Mon Sep 17 00:00:00 2001 From: HeuJung Date: Thu, 30 Jul 2026 23:20:42 +0900 Subject: [PATCH] =?UTF-8?q?feat(core,board,ecommerce,page):=20=EC=84=B1?= =?UTF-8?q?=EB=8A=A5=20=EA=B3=84=EC=B8=A1=20=EC=9D=B8=ED=94=84=EB=9D=BC=20?= =?UTF-8?q?4=EC=B6=95=20=ED=99=95=EC=9E=A5=20=EB=B0=8F=20=EA=B3=84?= =?UTF-8?q?=EC=B8=A1=20=EB=8C=80=EC=83=81=20=EC=84=A0=EC=96=B8=20=EC=A7=80?= =?UTF-8?q?=EC=A0=90=20=EC=8B=A0=EC=84=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 계측 축이 "목록 SELECT" 하나였고 프로파일 12종이 커맨드 파일에 하드코딩돼 있어 확장이 자기 대상을 추가할 수 없었다. 확장이 설치·제거되는 설치본마다 "실제로 존재하는 대상"이 달라지므로, 대상을 소유자가 선언하고 코어가 수집하도록 뒤집었다. - 선언 지점 두 곳, 스키마 하나: 코어 config/benchmark.php, 확장 getBenchmarkProfiles (모듈/플러그인). 수집은 method_exists 기반이라 인터페이스를 직접 구현한 서드파티 확장이 깨지지 않는다. - 축 4종(목록/화면/쓰기/배치)을 실행기로 분리하고 컨테이너 태그로 주입 — 축이 늘 때 커맨드를 고치지 않는다. - 화면 축은 HTTP 커널로 내부 요청을 처리한다. 라우트만 dispatch 하면 전역 미들웨어를 건너뛰어 화면과 다른 것을 재게 된다. 인증은 프로파일이 선언한 권한만 가진 임시 계정이 기본이며 --as 로 기존 계정을 지목할 수 있다. - 화면/쓰기 축은 계측 계정 생성부터 처리까지 롤백되는 트랜잭션 안에서 실행해 운영 DB 에도 잔여 데이터를 남기지 않는다. 배치 축은 내부 커밋·락 보유 때문에 감싸지 않고 --allow-write 로만 통제한다. - 잘못된 선언은 조용히 버리지 않고 사유를 경고로 남긴다. 버려진 선언이 곧 계측 사각이 되기 때문이다. 전부 건너뛴 실행은 종료 코드 1 로 끝난다. - 리포트는 환경 정보를 함께 적어 저장소 밖(storage/app/benchmarks)에 남긴다. 측정값이 실행 머신에 종속되므로 저장소에 축적하면 비교 불가능한 수치가 섞인다. 작업 중 발견해 함께 고친 것: - 이관 프로파일 3종의 soft_delete 선언이 실제 스키마와 어긋나 있었다 (users/product_inquiries/pages 에 deleted_at 없음). - 주문 목록이 상태 미지정 시 임시 주문 상태를 NOT IN 으로 제외하는데 선언에 빠져 있었다. filters 가 등가 비교만 지원한 것이 근본 원인이라 연산자 형태를 받도록 넓히고, 미허용 연산자는 측정 전에 거부한다. - 댓글 프로파일은 지배적 술어가 상관 서브쿼리라 선언형으로 재현할 수 없어 제거했다. 맨 테이블 스캔은 어느 화면도 내지 않는 수치가 된다. g7:bench:pagination → g7:bench 로 개명(4축을 재므로). 이전 이름은 별칭으로 남겼다. 테스트 61건 통과, 라이브 18종 전건 측정. --- .gitignore | 2 + AGENTS.md | 9 +- CHANGELOG.md | 4 + app/Benchmark/Axes/BatchAxisRunner.php | 141 ++++ app/Benchmark/Axes/ListAxisRunner.php | 347 +++++++++ app/Benchmark/Axes/ScreenAxisRunner.php | 261 +++++++ app/Benchmark/Axes/WriteAxisRunner.php | 227 ++++++ app/Benchmark/BenchmarkIdentity.php | 144 ++++ app/Benchmark/BenchmarkProfileRegistry.php | 275 +++++++ app/Benchmark/BenchmarkReporter.php | 226 ++++++ .../Contracts/BenchmarkAxisRunner.php | 34 + app/Benchmark/DTO/BenchmarkProfile.php | 97 +++ app/Benchmark/DTO/BenchmarkResult.php | 68 ++ app/Benchmark/DTO/BenchmarkRunOptions.php | 52 ++ app/Benchmark/QueryCollector.php | 102 +++ app/Benchmark/SyntheticSeeder.php | 190 +++++ app/Console/Commands/BenchCommand.php | 395 ++++++++++ .../Commands/BenchPaginationCommand.php | 556 ------------- app/Enums/BenchmarkAxis.php | 93 +++ app/Extension/AbstractModule.php | 32 + app/Extension/AbstractPlugin.php | 31 + app/Providers/CoreServiceProvider.php | 26 + config/benchmark.php | 116 +++ docs/README.md | 5 +- docs/backend/README.md | 1 + docs/backend/benchmark.md | 186 +++++ docs/backend/service-repository.md | 4 +- docs/cheatsheet.md | 20 +- docs/extension/module-basics.md | 1 + docs/extension/plugin-development.md | 1 + modules/_bundled/sirsoft-board/module.php | 44 ++ modules/_bundled/sirsoft-ecommerce/module.php | 86 ++ .../src/Benchmark/OrderCreationBenchmark.php | 116 +++ .../src/Repositories/OrderRepository.php | 2 +- .../Benchmark/OrderCreationBenchmarkTest.php | 161 ++++ modules/_bundled/sirsoft-page/module.php | 31 + tests/Feature/Console/BenchCommandTest.php | 725 +++++++++++++++++ .../Benchmark/BenchmarkAxisRunnerTest.php | 737 ++++++++++++++++++ tests/Unit/Benchmark/BenchmarkProfileTest.php | 153 ++++ tests/scenarios/benchmark-infra.yaml | 132 ++++ 40 files changed, 5266 insertions(+), 567 deletions(-) create mode 100644 app/Benchmark/Axes/BatchAxisRunner.php create mode 100644 app/Benchmark/Axes/ListAxisRunner.php create mode 100644 app/Benchmark/Axes/ScreenAxisRunner.php create mode 100644 app/Benchmark/Axes/WriteAxisRunner.php create mode 100644 app/Benchmark/BenchmarkIdentity.php create mode 100644 app/Benchmark/BenchmarkProfileRegistry.php create mode 100644 app/Benchmark/BenchmarkReporter.php create mode 100644 app/Benchmark/Contracts/BenchmarkAxisRunner.php create mode 100644 app/Benchmark/DTO/BenchmarkProfile.php create mode 100644 app/Benchmark/DTO/BenchmarkResult.php create mode 100644 app/Benchmark/DTO/BenchmarkRunOptions.php create mode 100644 app/Benchmark/QueryCollector.php create mode 100644 app/Benchmark/SyntheticSeeder.php create mode 100644 app/Console/Commands/BenchCommand.php delete mode 100644 app/Console/Commands/BenchPaginationCommand.php create mode 100644 app/Enums/BenchmarkAxis.php create mode 100644 config/benchmark.php create mode 100644 docs/backend/benchmark.md create mode 100644 modules/_bundled/sirsoft-ecommerce/src/Benchmark/OrderCreationBenchmark.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Unit/Benchmark/OrderCreationBenchmarkTest.php create mode 100644 tests/Feature/Console/BenchCommandTest.php create mode 100644 tests/Unit/Benchmark/BenchmarkAxisRunnerTest.php create mode 100644 tests/Unit/Benchmark/BenchmarkProfileTest.php create mode 100644 tests/scenarios/benchmark-infra.yaml diff --git a/.gitignore b/.gitignore index 009482e0..30b9125b 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,8 @@ node_modules/ # Settings JSON files (환경별 설정) /storage/app/settings/ +# 성능 계측 리포트 (g7:bench --report) — 측정값이 실행 머신 사양에 종속되므로 저장소에 축적하지 않는다 +/storage/app/benchmarks/ # 확장 프론트엔드 병합 번들 캐시 (version-in-path, 런타임 생성) /storage/app/ext-bundles/ diff --git a/AGENTS.md b/AGENTS.md index 5da0d614..8947f535 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ -### 백엔드 [backend/](docs/backend/) (32개) +### 백엔드 [backend/](docs/backend/) (33개) | 문서 | 설명 | TL;DR 핵심 | |------|------|-----------| @@ -16,6 +16,7 @@ | [api-documentation.md](docs/backend/api-documentation.md) | API 레퍼런스 문서 규정 (API Documentation) | 모든 API 엔드포인트는 레퍼런스 문서 필수 — 메서드/URI/파라미터/응답 필드 + 요청·응답 예시 ... | | [api-resources.md](docs/backend/api-resources.md) | API 리소스 | Resource: BaseApiResource 상속 필수 / Collection: BaseApiColl... | | [authentication.md](docs/backend/authentication.md) | 인증 및 세션 처리 | Laravel Sanctum 토큰 전용 인증 (Bearer 토큰만 사용) | +| [benchmark.md](docs/backend/benchmark.md) | 성능 계측 시스템 (Benchmark) | `g7:bench` 가 4축(list/screen/write/batch)을 잰다 — 계측 대상은 커맨드... | | [broadcasting.md](docs/backend/broadcasting.md) | Broadcasting (실시간 이벤트) | Laravel Reverb 사용 (WebSocket) | | [console-confirm.md](docs/backend/console-confirm.md) | 콘솔 yes/no 프롬프트 (ConsoleConfirm) | 콘솔 커맨드의 yes/no 프롬프트는 $this->unifiedConfirm() 사용 — Laravel... | | [controllers.md](docs/backend/controllers.md) | 컨트롤러 계층 구조 | AdminBaseController / AuthBaseController / PublicBaseCont... | @@ -1054,6 +1055,7 @@ php artisan migrate:rollback | `lang/**` | [database-guide.md](docs/database-guide.md) (다국어 섹션) | | `routes/**` | [routing.md](docs/backend/routing.md) | | `app/Seo/**` | [seo-system.md](docs/backend/seo-system.md) | +| `app/Benchmark/**`, `config/benchmark.php` | [benchmark.md](docs/backend/benchmark.md) | --- @@ -1086,3 +1088,8 @@ php artisan migrate:rollback - **ResolvesActivityLogType**: `app/ActivityLog/Traits/ResolvesActivityLogType.php` - **ChangeDetector**: `app/ActivityLog/ChangeDetector.php` - **CoreActivityLogListener**: `app/Listeners/CoreActivityLogListener.php` +- **BenchmarkProfileRegistry**: `app/Benchmark/BenchmarkProfileRegistry.php` +- **성능 계측 DTO**: `app/Benchmark/DTO/{BenchmarkProfile,BenchmarkRunOptions,BenchmarkResult}.php` +- **BenchmarkAxisRunner**: `app/Benchmark/Contracts/BenchmarkAxisRunner.php` +- **성능 계측 축 실행기**: `app/Benchmark/Axes/{List,Screen,Write,Batch}AxisRunner.php` +- **BenchmarkAxis**: `app/Enums/BenchmarkAxis.php` diff --git a/CHANGELOG.md b/CHANGELOG.md index 8284b4a1..ea9d5621 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,10 @@ - 목록 화면이 뒤쪽 페이지로 갈수록 느려지는 문제를 구조적으로 해결할 수 있도록, 확장이 함께 쓸 수 있는 공통 조회 방식을 코어에 추가했습니다. 목록을 두 단계(먼저 이번 페이지에 해당하는 항목만 추려내고, 그 항목에 대해서만 본문·상세 정보를 읽기)로 나눠 읽으므로 게시글·주문·로그가 수십만 건으로 늘어나도 마지막 페이지 조회 비용이 첫 페이지와 비슷하게 유지됩니다. 목록 정렬 기준을 미리 정해 둔 항목으로만 해석하는 공통 처리도 함께 제공하므로, 확장이 목록 조회를 직접 만들 때 정렬 처리를 매번 새로 구현하지 않아도 됩니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.) +#### 성능 점검 + +- 확장이 자기 성능 측정 대상을 직접 등록할 수 있게 했습니다. 모듈·플러그인이 자신의 목록 화면, 관리자 화면, 저장 동작, 정기 작업 중 속도를 재고 싶은 것을 선언해 두면, 사이트 관리자가 성능 점검을 실행할 때 코어 항목과 함께 측정됩니다. 확장을 설치하면 그 확장의 측정 대상이 자동으로 목록에 나타나고, 제거하면 함께 사라집니다. 화면 측정은 응답 시간과 함께 그 화면이 실행한 데이터베이스 조회 횟수를 보여주므로, 목록 자체는 빠른데 화면이 느린 원인을 찾을 수 있습니다. + ### Changed - 활동 로그·알림 발송 이력·본인인증 기록·스케줄 실행 이력·회원 목록을 뒤쪽 페이지에서도 빠르게 열 수 있도록 조회 방식을 바꿨습니다. 예전에는 페이지가 뒤로 갈수록 건너뛰는 기록의 본문·변경 내역까지 함께 읽어 느려졌지만, 이제 현재 페이지에 해당하는 기록만 상세 정보를 읽습니다. 화면에 보이는 내용은 이전과 동일합니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.) diff --git a/app/Benchmark/Axes/BatchAxisRunner.php b/app/Benchmark/Axes/BatchAxisRunner.php new file mode 100644 index 00000000..7d50b0fe --- /dev/null +++ b/app/Benchmark/Axes/BatchAxisRunner.php @@ -0,0 +1,141 @@ + null; + + if ($profile->mutates() && ! $options->allowWrite) { + return BenchmarkResult::skipped($profile, '데이터를 변경하는 배치입니다. --allow-write 를 붙여 실행하세요.'); + } + + $command = (string) $profile->option('command'); + $arguments = (array) $profile->option('arguments', []); + + if (! array_key_exists($command, Artisan::all())) { + return BenchmarkResult::skipped($profile, "등록되지 않은 커맨드: {$command} (해당 확장이 설치되어 있는지 확인)"); + } + + $notify("배치 실행: {$command}"); + + // 피크 메모리는 프로세스 단위 누적값이라 감소하지 않는다. 실행 전 값을 함께 + // 기록해야 "이 배치가 피크를 밀어올렸는지"를 판정할 수 있다. + $peakBefore = memory_get_peak_usage(true); + $usageBefore = memory_get_usage(true); + $buffer = new BufferedOutput; + + $start = microtime(true); + + try { + $collected = $this->collector->collect( + static fn () => Artisan::call($command, $arguments, $buffer) + ); + $exitCode = (int) $collected['value']; + $queries = $collected['queries']; + } catch (\Throwable $e) { + return BenchmarkResult::skipped($profile, '계측 실패: '.$e->getMessage()); + } + + $elapsedMs = (microtime(true) - $start) * 1000; + $peakAfter = memory_get_peak_usage(true); + $usageAfter = memory_get_usage(true); + $summary = $this->collector->summarize($queries); + + $notes = [ + sprintf('실행 전 피크 %s → 실행 후 피크 %s', $this->formatBytes($peakBefore), $this->formatBytes($peakAfter)), + ]; + + if ($peakAfter <= $peakBefore) { + $notes[] = '이 배치가 프로세스 피크를 밀어올리지 않았습니다 (실행 전 피크가 이미 더 높음).'; + } + + if ($exitCode !== 0) { + $notes[] = "커맨드가 실패 종료했습니다 (exit={$exitCode}) — 시간·메모리는 실패 지점까지의 값입니다."; + } + + $output = trim($buffer->fetch()); + + if ($output !== '') { + $notes[] = '커맨드 출력: '.$output; + } + + return new BenchmarkResult( + profile: $profile, + headers: ['커맨드', '종료코드', '소요(ms)', '피크 메모리', '메모리 증가', '쿼리(건)'], + rows: [[ + $command, + (string) $exitCode, + number_format($elapsedMs, 1), + $this->formatBytes($peakAfter), + $this->formatBytes(max(0, $usageAfter - $usageBefore)), + number_format($summary['count']), + ]], + metrics: [ + 'command' => $command, + 'arguments' => $arguments, + 'exit_code' => $exitCode, + 'elapsed_ms' => round($elapsedMs, 2), + 'peak_memory_before_bytes' => $peakBefore, + 'peak_memory_after_bytes' => $peakAfter, + 'memory_delta_bytes' => $usageAfter - $usageBefore, + 'query_count' => $summary['count'], + 'db_ms' => $summary['db_ms'], + ], + notes: $notes, + ); + } + + /** + * 바이트를 사람이 읽는 단위로 바꿉니다. + * + * @param int $bytes 바이트 + * @return string 포맷된 문자열 + */ + private function formatBytes(int $bytes): string + { + if ($bytes >= 1024 ** 3) { + return number_format($bytes / 1024 ** 3, 2).' GB'; + } + + if ($bytes >= 1024 ** 2) { + return number_format($bytes / 1024 ** 2, 1).' MB'; + } + + return number_format($bytes / 1024, 1).' KB'; + } +} diff --git a/app/Benchmark/Axes/ListAxisRunner.php b/app/Benchmark/Axes/ListAxisRunner.php new file mode 100644 index 00000000..44d2e27d --- /dev/null +++ b/app/Benchmark/Axes/ListAxisRunner.php @@ -0,0 +1,347 @@ + + */ + private const FILTER_OPERATORS = ['=', '!=', '<>', '<', '<=', '>', '>=', 'like', 'in', 'not in']; + + public function __construct(private readonly SyntheticSeeder $seeder) {} + + /** + * {@inheritDoc} + */ + public function axis(): BenchmarkAxis + { + return BenchmarkAxis::ListQuery; + } + + /** + * {@inheritDoc} + */ + public function run(BenchmarkProfile $profile, BenchmarkRunOptions $options, ?\Closure $onProgress = null): BenchmarkResult + { + $notify = $onProgress ?? static fn (string $message) => null; + $table = (string) $profile->option('table'); + + if (! Schema::hasTable($table)) { + return BenchmarkResult::skipped($profile, "테이블이 없습니다: {$table} (해당 확장이 설치되어 있는지 확인)"); + } + + if (($options->fresh || $options->seed > 0) && app()->environment('production')) { + return BenchmarkResult::skipped($profile, '운영 환경에서는 시딩/비움을 사용할 수 없습니다.'); + } + + $filterError = $this->validateFilters((array) $profile->option('filters', [])); + + if ($filterError !== null) { + return BenchmarkResult::skipped($profile, $filterError); + } + + if ($options->fresh) { + $this->seeder->truncate($table); + $notify("비움: {$table}"); + } + + if ($options->seed > 0) { + $notify("시딩: {$table} ".number_format($options->seed).' 건'); + $this->seeder->seed( + $table, + $options->seed, + (array) $profile->option('seed_overrides', []), + fn (int $inserted, int $total) => $notify(sprintf(' 시딩 %s / %s', number_format($inserted), number_format($total))) + ); + } + + $columns = $this->existingColumns($table, (array) $profile->option('columns', ['*'])); + $order = $this->existingOrder($table, (array) $profile->option('order', [['id', 'desc']])); + + $rows = []; + $notes = []; + + foreach ($options->offsets as $offset) { + $allMs = $this->measure($profile, ['*'], $order, $offset, $options->runs); + $listMs = $this->measure($profile, $columns, $order, $offset, $options->runs); + $idOnlyMs = $this->measure($profile, ['id'], $order, $offset, $options->runs); + + $rows[] = [ + 'offset' => $offset, + 'all_ms' => $allMs, + 'list_ms' => $listMs, + 'id_only_ms' => $idOnlyMs, + 'ratio' => $idOnlyMs > 0 ? round($allMs / $idOnlyMs, 1) : null, + ]; + + if ($options->explain) { + $notes[] = "EXPLAIN @ OFFSET {$offset} — 목록 컬럼"; + + foreach ($this->explain($profile, $columns, $order, $offset) as $line) { + $notes[] = ' '.$line; + } + + // 지연 조인의 inner 가 실제로 어떤 계획을 타는지가 인덱스 설계의 근거다 + $notes[] = "EXPLAIN @ OFFSET {$offset} — ID 만 (지연 조인 inner)"; + + foreach ($this->explain($profile, ['id'], $order, $offset) as $line) { + $notes[] = ' '.$line; + } + } + } + + return new BenchmarkResult( + profile: $profile, + headers: ['OFFSET', '전체 컬럼(ms)', '목록 컬럼(ms)', 'ID만(ms)', '전체÷ID'], + rows: array_map(fn (array $row) => [ + number_format($row['offset']), + number_format($row['all_ms'], 1), + number_format($row['list_ms'], 1), + number_format($row['id_only_ms'], 1), + $row['ratio'] !== null ? $row['ratio'].'×' : '-', + ], $rows), + metrics: [ + 'table' => $table, + 'columns' => $columns, + 'runs' => $options->runs, + 'offsets' => $rows, + ], + notes: $notes, + ); + } + + /** + * 스키마에 실제로 존재하는 컬럼만 남깁니다. + * + * `['*']` 는 "목록이 전 컬럼을 그대로 노출한다" 는 선언입니다. 응답 계약상 넓은 컬럼을 + * 뺄 수 없는 목록(활동 로그의 changes, 알림 발송 이력의 body 등)이 여기 해당하며, 이 + * 경우 계측의 비교 축은 select * vs select id 가 됩니다. 확장 버전에 따라 컬럼 구성이 + * 달라도 계측이 죽지 않도록 스키마에 없는 컬럼은 걸러냅니다. + * + * @param string $table 테이블명 + * @param array $columns 후보 컬럼 + * @return array 존재하는 컬럼 목록 + */ + private function existingColumns(string $table, array $columns): array + { + if ($columns === ['*']) { + return Schema::getColumnListing($table); + } + + $existing = array_values(array_filter( + $columns, + fn (mixed $column) => is_string($column) && Schema::hasColumn($table, $column) + )); + + return $existing === [] ? ['id'] : $existing; + } + + /** + * 스키마에 존재하는 정렬 컬럼만 남깁니다. + * + * @param string $table 테이블명 + * @param array $order 후보 정렬 + * @return array 적용 가능한 정렬 목록 + */ + private function existingOrder(string $table, array $order): array + { + $existing = array_values(array_filter( + $order, + fn (mixed $spec) => is_array($spec) && isset($spec[0]) && Schema::hasColumn($table, (string) $spec[0]) + )); + + return $existing === [] ? [['id', 'desc']] : $existing; + } + + /** + * 한 조합을 여러 번 실행해 중앙값(ms)을 돌려줍니다. + * + * `DB::enableQueryLog()` 는 오버헤드와 메모리 누적이 있어 쓰지 않고 직접 시간을 잽니다. + * + * @param BenchmarkProfile $profile 프로파일 + * @param array $columns select 컬럼 + * @param array $order 정렬 + * @param int $offset OFFSET + * @param int $runs 측정 횟수 + * @return float 중앙값 (밀리초) + */ + private function measure(BenchmarkProfile $profile, array $columns, array $order, int $offset, int $runs): float + { + // 첫 회는 캐시 워밍 성격이라 버린다 + $this->buildQuery($profile, $columns, $order, $offset)->get(); + + $samples = []; + + for ($i = 0; $i < max(1, $runs); $i++) { + $start = microtime(true); + $this->buildQuery($profile, $columns, $order, $offset)->get(); + $samples[] = (microtime(true) - $start) * 1000; + } + + sort($samples); + + return $samples[intdiv(count($samples), 2)]; + } + + /** + * 계측 대상 쿼리를 조립합니다. + * + * @param BenchmarkProfile $profile 프로파일 + * @param array $columns select 컬럼 + * @param array $order 정렬 + * @param int $offset OFFSET + * @return Builder 조립된 쿼리 + */ + private function buildQuery(BenchmarkProfile $profile, array $columns, array $order, int $offset): Builder + { + $table = (string) $profile->option('table'); + $query = DB::table($table)->select($columns); + + if ($profile->option('soft_delete', false) && Schema::hasColumn($table, 'deleted_at')) { + $query->whereNull('deleted_at'); + } + + $this->applyFilters($query, $table, (array) $profile->option('filters', [])); + + foreach ($order as $spec) { + $query->orderBy($spec[0], $spec[1] ?? 'asc'); + } + + return $query->offset($offset)->limit(self::PAGE_LIMIT); + } + + /** + * 선언된 필터를 계측 쿼리에 적용합니다. + * + * 값이 `[연산자, 값]` 형태면 그 연산자로, 그 밖에는 등가 비교로 해석합니다. 등가 비교만 + * 지원하면 화면이 실제로 거는 필터를 선언할 방법이 없는 목록이 생깁니다 — 주문 목록은 + * 상태 미지정 시 임시 주문 상태를 `NOT IN` 으로 제외하므로, 그것을 선언하지 못하면 + * 계측이 화면과 다른 인덱스를 타게 됩니다. + * + * 연산자는 닫힌 집합으로 해석합니다. 임의 문자열을 그대로 넘기면 선언 오타가 조용히 + * 통과하거나 의도치 않은 SQL 이 조립됩니다. + * + * @param Builder $query 계측 쿼리 + * @param string $table 대상 테이블 + * @param array $filters 선언된 필터 + */ + private function applyFilters(Builder $query, string $table, array $filters): void + { + foreach ($filters as $column => $declared) { + if (! Schema::hasColumn($table, (string) $column)) { + continue; + } + + // [연산자, 값] 형태가 아니면 등가 비교 (배열 값을 IN 으로 오해석하지 않도록 형태로 판정) + if (! is_array($declared) || count($declared) !== 2 || ! is_string($declared[0])) { + $query->where($column, $declared); + + continue; + } + + [$operator, $value] = $declared; + + match (strtolower($operator)) { + '=' => $query->where($column, '=', $value), + '!=', '<>' => $query->where($column, '!=', $value), + '<' => $query->where($column, '<', $value), + '<=' => $query->where($column, '<=', $value), + '>' => $query->where($column, '>', $value), + '>=' => $query->where($column, '>=', $value), + 'like' => $query->where($column, 'like', $value), + 'in' => $query->whereIn($column, (array) $value), + 'not in' => $query->whereNotIn($column, (array) $value), + // 도달 불가 — run() 이 실행 전에 연산자를 검증해 거부한다 + default => null, + }; + } + } + + /** + * 선언된 필터의 연산자를 실행 전에 검증합니다. + * + * 알 수 없는 연산자를 조용히 무시하면 그 필터가 빠진 채로 측정되어, 화면과 다른 것을 + * 재면서도 정상 측정으로 보고됩니다. 실행 전에 사유와 함께 거부합니다. + * + * @param array $filters 선언된 필터 + * @return string|null 실패 사유 (문제 없으면 null) + */ + private function validateFilters(array $filters): ?string + { + foreach ($filters as $column => $declared) { + if (! is_array($declared) || count($declared) !== 2 || ! is_string($declared[0])) { + continue; + } + + if (! in_array(strtolower($declared[0]), self::FILTER_OPERATORS, true)) { + return sprintf( + '필터 %s 의 연산자를 알 수 없습니다: %s (허용: %s)', + $column, + $declared[0], + implode(', ', self::FILTER_OPERATORS) + ); + } + } + + return null; + } + + /** + * 실행 계획을 수집합니다. + * + * @param BenchmarkProfile $profile 프로파일 + * @param array $columns select 컬럼 + * @param array $order 정렬 + * @param int $offset OFFSET + * @return array 실행 계획 요약 줄 목록 + */ + private function explain(BenchmarkProfile $profile, array $columns, array $order, int $offset): array + { + $query = $this->buildQuery($profile, $columns, $order, $offset); + + try { + $plan = DB::select('EXPLAIN '.$query->toSql(), $query->getBindings()); + } catch (\Throwable $e) { + return ['실행 계획 수집 실패: '.$e->getMessage()]; + } + + return array_map(function ($row) { + $row = (array) $row; + + return sprintf( + 'type=%s key=%s rows=%s filtered=%s extra=%s', + $row['type'] ?? '-', + $row['key'] ?? '-', + $row['rows'] ?? '-', + $row['filtered'] ?? '-', + $row['Extra'] ?? '-' + ); + }, $plan); + } +} diff --git a/app/Benchmark/Axes/ScreenAxisRunner.php b/app/Benchmark/Axes/ScreenAxisRunner.php new file mode 100644 index 00000000..787cadec --- /dev/null +++ b/app/Benchmark/Axes/ScreenAxisRunner.php @@ -0,0 +1,261 @@ +mutates() && ! $options->allowWrite) { + return BenchmarkResult::skipped($profile, '데이터를 변경하는 화면입니다. --allow-write 를 붙여 실행하세요.'); + } + + [$uri, $uriError] = $this->resolveUri($profile); + + if ($uriError !== null) { + return BenchmarkResult::skipped($profile, $uriError); + } + + // `--as` 로 지정한 계정은 트랜잭션 진입 전에 확인한다 — 없는 계정으로 계측을 시작하면 + // 롤백 구간 안에서 실패해 사유만 흐려진다. + if ($options->asUser !== null && $this->identity->findExistingUser($options->asUser) === null) { + return BenchmarkResult::skipped($profile, "계측에 사용할 계정을 찾을 수 없습니다: {$options->asUser}"); + } + + $method = strtoupper((string) $profile->option('method', 'GET')); + $query = (array) $profile->option('query', []); + $query['per_page'] ??= $options->perPage; + + // 데이터를 변경하는 화면은 반복 측정 시 2회차부터 조건이 달라진다(중복 키, 재고 소진). + // 롤백은 계측 종료 시 한 번이므로 회차 간에는 되돌려지지 않는다 → 1회만 잰다. + $runs = $profile->mutates() ? 1 : max(1, $options->runs); + $notes = []; + + if ($profile->mutates()) { + $notes[] = '데이터 변경 화면이라 1회만 측정했습니다 (회차 간 조건 변화 방지).'; + } + + try { + $measured = $this->identity->withRolledBackTransaction( + fn () => $this->measure($profile, (string) $uri, $method, $query, $runs, $options, $onProgress) + ); + } catch (\Throwable $e) { + return BenchmarkResult::skipped($profile, '계측 실패: '.$e->getMessage()); + } + + $summary = $measured['summary']; + + foreach ($summary['n_plus_one'] as $candidate) { + $notes[] = sprintf('N+1 후보 %d회: %s', $candidate['count'], $candidate['sql']); + } + + if ($summary['n_plus_one'] === []) { + $notes[] = 'N+1 후보 없음 (같은 SQL 5회 이상 반복 없음).'; + } + + return new BenchmarkResult( + profile: $profile, + headers: ['요청', '상태', '응답(ms)', '쿼리(건)', 'DB(ms)'], + rows: [[ + $method.' '.$uri, + (string) $measured['status'], + number_format($measured['total_ms'], 1), + number_format($summary['count']), + number_format($summary['db_ms'], 1), + ]], + metrics: [ + 'uri' => $uri, + 'method' => $method, + 'query' => $query, + 'status' => $measured['status'], + 'runs' => $runs, + 'total_ms' => $measured['total_ms'], + 'query_count' => $summary['count'], + 'db_ms' => $summary['db_ms'], + 'n_plus_one' => $summary['n_plus_one'], + 'acting_user' => $measured['acting_user'], + ], + notes: $notes, + ); + } + + /** + * 계측 대상 URI 를 해석합니다. + * + * 라우트명 선언을 권장하는 이유는 두 가지입니다 — 프리픽스를 문자열로 조립하지 않아도 + * 되고, 라우트가 사라지면 계측이 조용히 404 를 재는 대신 사유를 남기고 건너뜁니다. + * + * 예외를 던지지 않고 사유 문자열을 함께 돌려주는 이유는, 이 축의 실패 계약이 + * `BenchmarkResult::skipped` 이기 때문입니다 — 던지고 곧바로 잡는 우회로를 만들지 않습니다. + * + * @param BenchmarkProfile $profile 프로파일 + * @return array{0: string|null, 1: string|null} [경로, 실패 사유] + */ + private function resolveUri(BenchmarkProfile $profile): array + { + $routeName = $profile->option('route'); + + if (is_string($routeName) && $routeName !== '') { + if (Route::getRoutes()->getByName($routeName) === null) { + return [null, "등록되지 않은 라우트명: {$routeName} (해당 확장이 설치되어 있는지 확인)"]; + } + + return [route($routeName, (array) $profile->option('route_params', []), false), null]; + } + + return ['/'.ltrim((string) $profile->option('uri'), '/'), null]; + } + + /** + * 내부 요청을 반복 실행해 응답 시간 중앙값과 쿼리 요약을 냅니다. + * + * @param BenchmarkProfile $profile 프로파일 + * @param string $uri 경로 + * @param string $method HTTP 메서드 + * @param array $query 쿼리스트링 + * @param int $runs 측정 횟수 + * @param BenchmarkRunOptions $options 실행 옵션 + * @param \Closure|null $onProgress 진행 콜백 + * @return array{status: int, total_ms: float, summary: array, acting_user: array} 계측 결과 + */ + private function measure( + BenchmarkProfile $profile, + string $uri, + string $method, + array $query, + int $runs, + BenchmarkRunOptions $options, + ?\Closure $onProgress, + ): array { + $notify = $onProgress ?? static fn (string $message) => null; + + $issued = $this->identity->issueToken( + (array) $profile->option('permissions', []), + $options->asUser !== null ? $this->identity->findExistingUser($options->asUser) : null + ); + + $notify(sprintf( + '인증: %s (%s)', + $issued['user']->email, + $issued['ephemeral'] ? '계측용 임시 계정 — 종료 시 롤백' : '지정 계정' + )); + + $samples = []; + $status = 0; + $queries = []; + + // 첫 회는 라우트 매칭·컨테이너 해석 워밍이라 버린다 (측정 회차와 별도로 1회 더 실행) + for ($i = 0; $i <= $runs; $i++) { + $collected = $this->collector->collect( + fn () => $this->dispatch($uri, $method, $query, $issued['token']) + ); + + /** @var array{status: int, ms: float} $outcome */ + $outcome = $collected['value']; + $status = $outcome['status']; + + if ($i === 0) { + continue; + } + + $samples[] = $outcome['ms']; + $queries = $collected['queries']; + } + + sort($samples); + + return [ + 'status' => $status, + 'total_ms' => $samples[intdiv(count($samples), 2)], + 'summary' => $this->collector->summarize($queries), + 'acting_user' => [ + 'id' => $issued['user']->id, + 'email' => $issued['user']->email, + 'ephemeral' => $issued['ephemeral'], + ], + ]; + } + + /** + * 내부 요청 1회를 처리하고 소요 시간을 잽니다. + * + * 커널이 컨테이너의 `request` 인스턴스와 인증 가드 상태를 갈아치우므로, 처리 후 + * 원상 복구합니다 — 복구하지 않으면 이어지는 회차/프로파일이 앞 요청의 인증 상태를 + * 물려받아 계측이 서로 오염됩니다. + * + * @param string $uri 경로 + * @param string $method HTTP 메서드 + * @param array $query 쿼리스트링/바디 + * @param string $token Sanctum 토큰 + * @return array{status: int, ms: float} 상태 코드와 소요 시간 + */ + private function dispatch(string $uri, string $method, array $query, string $token): array + { + $previousRequest = app()->bound('request') ? app('request') : null; + + $request = Request::create($uri, $method, $query); + $request->headers->set('Accept', 'application/json'); + $request->headers->set('Authorization', 'Bearer '.$token); + + $start = microtime(true); + + try { + $response = app(HttpKernel::class)->handle($request); + $status = $response->getStatusCode(); + // 스트리밍 응답은 본문 생성까지가 사용자 체감 시간이라 여기서 소비한다 + $response->getContent(); + } finally { + $ms = (microtime(true) - $start) * 1000; + + Auth::forgetGuards(); + + if ($previousRequest !== null) { + app()->instance('request', $previousRequest); + } + } + + return ['status' => $status, 'ms' => $ms]; + } +} diff --git a/app/Benchmark/Axes/WriteAxisRunner.php b/app/Benchmark/Axes/WriteAxisRunner.php new file mode 100644 index 00000000..ca92dc20 --- /dev/null +++ b/app/Benchmark/Axes/WriteAxisRunner.php @@ -0,0 +1,227 @@ + null; + + if (! $options->allowWrite) { + return BenchmarkResult::skipped($profile, '쓰기 축입니다. --allow-write 를 붙여 실행하세요.'); + } + + $resolved = []; + + foreach (['callback', 'prepare', 'cleanup'] as $field) { + if ($field !== 'callback' && $profile->option($field) === null) { + $resolved[$field] = null; + + continue; + } + + [$callable, $error] = $this->resolveCallable($profile->option($field), $profile, $field); + + if ($error !== null) { + return BenchmarkResult::skipped($profile, $error); + } + + $resolved[$field] = $callable; + } + + $callback = $resolved['callback']; + $prepare = $resolved['prepare']; + $cleanup = $resolved['cleanup']; + + $runs = max(1, $options->runs); + + try { + $measured = $this->identity->withRolledBackTransaction( + fn () => $this->measure($callback, $prepare, $runs, $onProgress) + ); + } catch (\Throwable $e) { + return BenchmarkResult::skipped($profile, '계측 실패: '.$e->getMessage()); + } finally { + if ($cleanup !== null) { + // 트랜잭션 롤백으로 되돌지 않는 잔여물(파일·캐시)을 정리한다. + // 정리 실패가 계측 결과를 삼키면 안 되므로 사유만 알린다. + try { + $cleanup(); + } catch (\Throwable $e) { + $notify('cleanup 실패: '.$e->getMessage()); + } + } + } + + $summary = $this->collector->summarize($measured['queries']); + + return new BenchmarkResult( + profile: $profile, + headers: ['첫 회(ms)', '중앙값(ms)', '회차', '쿼리(건)', 'DB(ms)'], + rows: [[ + number_format($measured['first_ms'], 1), + number_format($measured['median_ms'], 1), + (string) $runs, + number_format($summary['count']), + number_format($summary['db_ms'], 1), + ]], + metrics: [ + 'first_ms' => $measured['first_ms'], + 'median_ms' => $measured['median_ms'], + 'runs' => $runs, + 'samples_ms' => $measured['samples'], + 'query_count' => $summary['count'], + 'db_ms' => $summary['db_ms'], + 'n_plus_one' => $summary['n_plus_one'], + ], + notes: array_merge( + ['계측으로 생긴 행은 롤백했습니다.'], + array_map( + fn (array $candidate) => sprintf('반복 쿼리 %d회: %s', $candidate['count'], $candidate['sql']), + $summary['n_plus_one'] + ) + ), + ); + } + + /** + * 선언된 콜백을 호출 가능한 형태로 해석합니다. + * + * 예외를 던지지 않고 사유 문자열을 함께 돌려주는 이유는, 이 축의 실패 계약이 + * `BenchmarkResult::skipped` 이기 때문입니다 — 던지고 곧바로 잡는 우회로를 만들지 않습니다. + * + * @param mixed $declared 선언값 + * @param BenchmarkProfile $profile 프로파일 (오류 메시지용) + * @param string $field 선언 필드명 (오류 메시지용) + * @return array{0: callable|null, 1: string|null} [해석된 콜백, 실패 사유] + */ + private function resolveCallable(mixed $declared, BenchmarkProfile $profile, string $field): array + { + $where = $profile->qualifiedKey().' 의 '.$field; + + if ($declared instanceof \Closure) { + return [null, "{$where}: 클로저는 쓸 수 없습니다 (config:cache 불가). 'Fqcn' 또는 ['Fqcn', 'method'] 형식으로 선언하세요."]; + } + + if (is_string($declared)) { + if (! class_exists($declared)) { + return [null, "{$where}: 클래스를 찾을 수 없습니다 — {$declared}"]; + } + + $instance = app($declared); + + if (! is_callable($instance)) { + return [null, "{$where}: {$declared} 에 __invoke() 가 없습니다."]; + } + + return [$instance, null]; + } + + if (is_array($declared) && count($declared) === 2 && is_string($declared[0]) && is_string($declared[1])) { + [$class, $method] = $declared; + + if (! class_exists($class)) { + return [null, "{$where}: 클래스를 찾을 수 없습니다 — {$class}"]; + } + + $instance = app($class); + + if (! method_exists($instance, $method)) { + return [null, "{$where}: {$class}::{$method}() 가 없습니다."]; + } + + return [[$instance, $method], null]; + } + + return [null, "{$where}: 'Fqcn' 또는 ['Fqcn', 'method'] 형식이어야 합니다."]; + } + + /** + * 콜백을 반복 실행해 첫 회 시간과 중앙값을 냅니다. + * + * 첫 회를 버리지 않고 따로 보고하는 이유는, 저장 경로에서는 첫 회가 포함하는 비용 + * (클래스 로딩, 설정 해석, 관계 초기 조회)이 실사용에서도 발생하기 때문입니다. + * 쿼리 요약은 마지막 회차 기준입니다 — 첫 회는 위 초기화 쿼리가 섞입니다. + * + * `prepare` 는 계측 구간 **밖**에서 회차마다 실행합니다. 저장 경로에는 선행 상태가 + * 필요한 경우가 많고(주문 생성에는 임시 주문이 필요하고 임시 주문은 1회만 전환됨), + * 그 준비 비용이 측정값에 섞이면 재려던 것을 재지 못하게 됩니다. + * + * @param callable $callback 저장 경로 콜백 (prepare 반환값을 인자로 받음) + * @param callable|null $prepare 회차별 선행 준비 콜백 (계측 제외, 회차 번호를 인자로 받음) + * @param int $runs 측정 횟수 + * @param \Closure|null $onProgress 진행 콜백 + * @return array{first_ms: float, median_ms: float, samples: array, queries: array} 계측 결과 + */ + private function measure(callable $callback, ?callable $prepare, int $runs, ?\Closure $onProgress): array + { + $notify = $onProgress ?? static fn (string $message) => null; + $samples = []; + $queries = []; + + for ($i = 1; $i <= $runs; $i++) { + $notify("쓰기 계측 {$i} / {$runs}"); + + $context = $prepare !== null ? $prepare($i) : null; + + $start = microtime(true); + $collected = $this->collector->collect(static function () use ($callback, $context) { + $callback($context); + }); + $samples[] = (microtime(true) - $start) * 1000; + $queries = $collected['queries']; + } + + $sorted = $samples; + sort($sorted); + + return [ + 'first_ms' => $samples[0], + 'median_ms' => $sorted[intdiv(count($sorted), 2)], + 'samples' => $samples, + 'queries' => $queries, + ]; + } +} diff --git a/app/Benchmark/BenchmarkIdentity.php b/app/Benchmark/BenchmarkIdentity.php new file mode 100644 index 00000000..cb2af9c1 --- /dev/null +++ b/app/Benchmark/BenchmarkIdentity.php @@ -0,0 +1,144 @@ + 0` 분기), 읽기/쓰기 분리 환경에서도 계측 요청이 이 계정을 인증할 수 + * 있습니다. 운영 DB 에서도 잔여 계정이 생기지 않는 이유가 이 롤백입니다. + */ +class BenchmarkIdentity +{ + /** + * 롤백되는 트랜잭션 안에서 콜백을 실행합니다. + * + * 계측이 만든 계정·토큰과, 계측 대상이 만든 행까지 함께 되돌립니다. 쓰기 축의 결과를 + * 되돌리는 것도 같은 장치를 씁니다. + * + * @param \Closure $callback 트랜잭션 안에서 실행할 작업 + * @return mixed 콜백 반환값 + */ + public function withRolledBackTransaction(\Closure $callback): mixed + { + DB::beginTransaction(); + + try { + return $callback(); + } finally { + // 계측 결과 산출 여부와 무관하게 되돌린다 — 예외로 빠져나가도 잔여 데이터 없음 + DB::rollBack(); + } + } + + /** + * 계측에 사용할 Sanctum 토큰을 발급합니다. + * + * 호출 전에 `--as` 계정 존재를 `findExistingUser()` 로 확인해야 합니다 — 계정 부재는 + * 계측 실패가 아니라 실행 전 판정 대상이라 이 메서드는 그 경우를 다루지 않습니다. + * + * @param array $permissions 임시 계정에 부여할 권한 식별자 목록 + * @param User|null $actor `--as` 로 확인된 기존 계정 (null 이면 임시 계정 생성) + * @return array{token: string, user: User, ephemeral: bool} 토큰과 인증 주체 + */ + public function issueToken(array $permissions = [], ?User $actor = null): array + { + $user = $actor ?? $this->makeEphemeralAdmin($permissions); + + return [ + 'token' => $user->createToken('g7-bench-'.Str::random(8))->plainTextToken, + 'user' => $user, + 'ephemeral' => $actor === null, + ]; + } + + /** + * 지정된 기존 계정을 찾습니다. + * + * @param string $identifier 계정 ID 또는 이메일 + * @return User|null 찾은 계정 (없으면 null) + */ + public function findExistingUser(string $identifier): ?User + { + return ctype_digit($identifier) + ? User::find((int) $identifier) + : User::where('email', $identifier)->first(); + } + + /** + * 선언된 권한만 가진 임시 관리자를 생성합니다. + * + * 권한을 프로파일 선언에서 받는 이유는, 계측 대상 화면이 요구하는 권한만 주어야 + * 미들웨어 통과 여부까지 실제와 같아지기 때문입니다(전권 계정으로 재면 권한 검사 + * 비용과 분기가 달라집니다). + * + * @param array $permissions 권한 식별자 목록 + * @return User 생성된 임시 관리자 + */ + private function makeEphemeralAdmin(array $permissions): User + { + $user = User::factory()->create(); + + $permissionIds = []; + + foreach ($permissions as $identifier) { + $identifier = (string) $identifier; + + $permission = Permission::firstOrCreate( + ['identifier' => $identifier], + [ + 'name' => json_encode(['ko' => $identifier, 'en' => $identifier]), + 'description' => json_encode(['ko' => $identifier, 'en' => $identifier]), + 'extension_type' => ExtensionOwnerType::Core, + 'extension_identifier' => 'core', + 'type' => 'admin', + ] + ); + + $permissionIds[] = $permission->id; + } + + $benchRole = Role::create([ + 'identifier' => 'g7_bench_'.Str::random(8), + 'name' => json_encode(['ko' => '성능 계측 전용', 'en' => 'Benchmark Only']), + 'description' => json_encode(['ko' => '성능 계측 임시 역할', 'en' => 'Temporary benchmark role']), + 'is_active' => true, + ]); + + $adminRole = Role::firstOrCreate( + ['identifier' => 'admin'], + [ + 'name' => json_encode(['ko' => '관리자', 'en' => 'Admin']), + 'description' => json_encode(['ko' => '시스템 관리자', 'en' => 'System Admin']), + 'extension_type' => ExtensionOwnerType::Core, + 'extension_identifier' => 'core', + 'type' => 'admin', + 'is_active' => true, + ] + ); + + if ($permissionIds !== []) { + $benchRole->permissions()->sync($permissionIds); + } + + $user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]); + $user->roles()->attach($benchRole->id, ['assigned_at' => now(), 'assigned_by' => null]); + + return $user->fresh(); + } +} diff --git a/app/Benchmark/BenchmarkProfileRegistry.php b/app/Benchmark/BenchmarkProfileRegistry.php new file mode 100644 index 00000000..93ac970c --- /dev/null +++ b/app/Benchmark/BenchmarkProfileRegistry.php @@ -0,0 +1,275 @@ +|null + */ + private ?array $profiles = null; + + /** + * 수집 중 발생한 선언 오류 메시지 + * + * @var array + */ + private array $warnings = []; + + public function __construct( + private readonly ModuleManagerInterface $moduleManager, + private readonly PluginManagerInterface $pluginManager, + ) {} + + /** + * 모든 프로파일을 정규화 키 기준으로 반환합니다. + * + * @return array 정규화 키 → 프로파일 + */ + public function all(): array + { + if ($this->profiles !== null) { + return $this->profiles; + } + + $profiles = []; + + foreach ($this->collectCore() as $profile) { + $profiles[$profile->qualifiedKey()] = $profile; + } + + foreach ($this->collectExtensions() as $profile) { + $profiles[$profile->qualifiedKey()] = $profile; + } + + return $this->profiles = $profiles; + } + + /** + * 특정 축의 프로파일만 반환합니다. + * + * @param BenchmarkAxis $axis 대상 축 + * @return array 정규화 키 → 프로파일 + */ + public function byAxis(BenchmarkAxis $axis): array + { + return array_filter($this->all(), fn (BenchmarkProfile $profile) => $profile->axis === $axis); + } + + /** + * 키로 프로파일을 해석합니다. + * + * 정규화 키(`sirsoft-ecommerce/orders`)를 우선 대조하고, 없으면 짧은 키로 찾습니다. + * 짧은 키가 둘 이상의 확장에서 선언돼 모호하면 후보 목록을 사유로 돌려줍니다 — + * 임의로 하나를 고르면 어느 확장의 목록을 잰 것인지 알 수 없게 됩니다. + * + * @param string $key 프로파일 키 (정규화 키 또는 짧은 키) + * @return array{0: BenchmarkProfile|null, 1: string|null} [찾은 프로파일, 실패 사유] + */ + public function resolve(string $key): array + { + $profiles = $this->all(); + + if (isset($profiles[$key])) { + return [$profiles[$key], null]; + } + + $matches = array_filter($profiles, fn (BenchmarkProfile $profile) => $profile->key === $key); + + if ($matches === []) { + return [null, "등록되지 않은 프로파일: {$key} (--list-profiles 로 목록 확인)"]; + } + + if (count($matches) > 1) { + $candidates = implode(', ', array_keys($matches)); + + return [null, "프로파일 키가 모호합니다: {$key} — 다음 중 하나로 지정하세요: {$candidates}"]; + } + + return [reset($matches), null]; + } + + /** + * 키로 프로파일을 찾습니다. (해석 실패 시 null) + * + * 사유가 필요하면 `resolve()` 를 씁니다. + * + * @param string $key 프로파일 키 (정규화 키 또는 짧은 키) + * @return BenchmarkProfile|null 찾은 프로파일 + */ + public function find(string $key): ?BenchmarkProfile + { + return $this->resolve($key)[0]; + } + + /** + * 수집 중 무시된 선언의 사유 목록을 반환합니다. + * + * @return array 경고 메시지 + */ + public function warnings(): array + { + $this->all(); + + return $this->warnings; + } + + /** + * 코어 프로파일을 수집합니다. + * + * @return array 코어 프로파일 목록 + */ + private function collectCore(): array + { + $declared = config('benchmark.profiles', []); + + return $this->normalize(is_array($declared) ? $declared : [], 'core', 'core'); + } + + /** + * 활성 모듈/플러그인 프로파일을 수집합니다. + * + * @return array 확장 프로파일 목록 + */ + private function collectExtensions(): array + { + $profiles = []; + + foreach ($this->moduleManager->getActiveModules() as $module) { + $profiles = array_merge( + $profiles, + $this->normalize($this->extract($module), 'module', $module->getIdentifier()) + ); + } + + foreach ($this->pluginManager->getActivePlugins() as $plugin) { + $profiles = array_merge( + $profiles, + $this->normalize($this->extract($plugin), 'plugin', $plugin->getIdentifier()) + ); + } + + return $profiles; + } + + /** + * 확장의 `getBenchmarkProfiles()` 선언을 격리 호출로 읽습니다. + * + * @param object $extension 확장 인스턴스 + * @return array 선언 배열 (실패 시 빈 배열) + */ + private function extract(object $extension): array + { + if (! method_exists($extension, 'getBenchmarkProfiles')) { + return []; + } + + try { + $declared = $extension->getBenchmarkProfiles(); + } catch (\Throwable $e) { + $this->warnings[] = sprintf( + '%s: getBenchmarkProfiles() 호출 실패 — %s', + method_exists($extension, 'getIdentifier') ? $extension->getIdentifier() : $extension::class, + $e->getMessage() + ); + + return []; + } + + return is_array($declared) ? $declared : []; + } + + /** + * 선언 배열을 검증해 프로파일 객체로 정규화합니다. + * + * @param array $declared 선언 배열 (키 → 정의) + * @param string $sourceKind 출처 종류 (core|module|plugin) + * @param string $sourceIdentifier 출처 식별자 + * @return array 정규화된 프로파일 목록 + */ + private function normalize(array $declared, string $sourceKind, string $sourceIdentifier): array + { + $profiles = []; + + foreach ($declared as $key => $definition) { + if (! is_string($key) || $key === '') { + $this->warnings[] = "{$sourceIdentifier}: 프로파일 키는 빈 문자열이 아닌 문자열이어야 합니다."; + + continue; + } + + if (! is_array($definition)) { + $this->warnings[] = "{$sourceIdentifier}/{$key}: 프로파일 정의는 배열이어야 합니다."; + + continue; + } + + $axis = BenchmarkAxis::tryFrom((string) ($definition['type'] ?? '')); + + if ($axis === null) { + $this->warnings[] = sprintf( + '%s/%s: 알 수 없는 type — %s (허용: %s)', + $sourceIdentifier, + $key, + var_export($definition['type'] ?? null, true), + implode('|', BenchmarkAxis::values()) + ); + + continue; + } + + $missing = array_values(array_filter( + $axis->requiredOptions(), + fn (array $group) => array_filter( + $group, + fn (string $option) => isset($definition[$option]) + ) === [] + )); + + if ($missing !== []) { + $this->warnings[] = sprintf( + '%s/%s: %s 축 필수 옵션 누락 — %s', + $sourceIdentifier, + $key, + $axis->value, + implode(', ', array_map(fn (array $group) => implode('|', $group), $missing)) + ); + + continue; + } + + $label = $definition['label'] ?? null; + unset($definition['type'], $definition['label']); + + $profiles[] = new BenchmarkProfile( + key: $key, + axis: $axis, + sourceKind: $sourceKind, + sourceIdentifier: $sourceIdentifier, + options: $definition, + label: is_string($label) ? $label : null, + ); + } + + return $profiles; + } +} diff --git a/app/Benchmark/BenchmarkReporter.php b/app/Benchmark/BenchmarkReporter.php new file mode 100644 index 00000000..08443a76 --- /dev/null +++ b/app/Benchmark/BenchmarkReporter.php @@ -0,0 +1,226 @@ + 항목 → 값 + */ + public function environment(): array + { + return [ + 'APP_ENV' => (string) config('app.env'), + 'G7 버전' => (string) config('app.version'), + 'DB 연결' => (string) config('database.default'), + 'DB 스키마' => $this->databaseName(), + 'DB 버전' => $this->databaseVersion(), + 'PHP 버전' => PHP_VERSION, + 'OPcache' => function_exists('opcache_get_status') && @opcache_get_status(false) !== false ? 'on' : 'off', + 'memory_limit' => (string) ini_get('memory_limit'), + 'config:cache' => app()->configurationIsCached() ? 'on' : 'off', + '실행 머신' => php_uname('s').' '.php_uname('r').' / '.php_uname('m'), + ]; + } + + /** + * JSON 으로 직렬화합니다. + * + * @param array $results 계측 결과 + * @param BenchmarkRunOptions $options 실행 옵션 + * @param array $warnings 프로파일 수집 경고 + * @return string JSON 문자열 + */ + public function toJson(array $results, BenchmarkRunOptions $options, array $warnings = []): string + { + return (string) json_encode([ + 'generated_at' => now()->toIso8601String(), + 'environment' => $this->environment(), + 'options' => $options->toArray(), + 'warnings' => $warnings, + 'results' => array_map(fn (BenchmarkResult $result) => $result->toArray(), $results), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + /** + * 마크다운 리포트를 만듭니다. + * + * @param array $results 계측 결과 + * @param BenchmarkRunOptions $options 실행 옵션 + * @param array $warnings 프로파일 수집 경고 + * @return string 마크다운 문서 + */ + public function toMarkdown(array $results, BenchmarkRunOptions $options, array $warnings = []): string + { + $lines = [ + '# G7 성능 점검 리포트', + '', + '- 생성 시각: '.now()->toDateTimeString(), + '', + '## 실행 환경', + '', + '| 항목 | 값 |', + '| ------ | ------ |', + ]; + + foreach ($this->environment() as $name => $value) { + $lines[] = "| {$name} | {$value} |"; + } + + $lines[] = ''; + $lines[] = '계측값은 위 환경에 종속됩니다. 다른 리포트와 비교할 때는 환경이 같은지 먼저 확인하세요.'; + $lines[] = ''; + $lines[] = '## 실행 조건'; + $lines[] = ''; + $lines[] = '| 항목 | 값 |'; + $lines[] = '| ------ | ------ |'; + + foreach ($options->toArray() as $name => $value) { + $lines[] = sprintf('| %s | %s |', $name, $this->stringify($value)); + } + + if ($warnings !== []) { + $lines[] = ''; + $lines[] = '## 무시된 프로파일 선언'; + $lines[] = ''; + + foreach ($warnings as $warning) { + $lines[] = '- '.$warning; + } + } + + foreach (BenchmarkAxis::cases() as $axis) { + $axisResults = array_values(array_filter( + $results, + fn (BenchmarkResult $result) => $result->profile->axis === $axis + )); + + if ($axisResults === []) { + continue; + } + + $lines[] = ''; + $lines[] = sprintf('## %s (%s)', $axis->label(), $axis->value); + + foreach ($axisResults as $result) { + $lines = array_merge($lines, $this->markdownSection($result)); + } + } + + return implode("\n", $lines)."\n"; + } + + /** + * 결과 1건의 마크다운 절을 만듭니다. + * + * @param BenchmarkResult $result 계측 결과 + * @return array 마크다운 줄 목록 + */ + private function markdownSection(BenchmarkResult $result): array + { + $title = $result->profile->qualifiedKey(); + + if ($result->profile->label !== null) { + $title .= ' — '.$result->profile->label; + } + + $lines = ['', '### '.$title, '']; + + if ($result->skipped) { + $lines[] = '측정하지 않음: '.$result->skipReason; + + return $lines; + } + + $lines[] = '| '.implode(' | ', $result->headers).' |'; + $lines[] = '| '.implode(' | ', array_fill(0, count($result->headers), '------')).' |'; + + foreach ($result->rows as $row) { + $lines[] = '| '.implode(' | ', $row).' |'; + } + + if ($result->notes !== []) { + $lines[] = ''; + + foreach ($result->notes as $note) { + // 실행 계획/SQL 은 표 안에서 깨지므로 코드 스팬으로 감싼다 + $lines[] = '- '.(str_contains($note, '|') ? '`'.$note.'`' : $note); + } + } + + return $lines; + } + + /** + * 옵션 값을 표에 넣을 문자열로 바꿉니다. + * + * @param mixed $value 옵션 값 + * @return string 표시 문자열 + */ + private function stringify(mixed $value): string + { + return match (true) { + is_bool($value) => $value ? 'true' : 'false', + is_array($value) => implode(', ', array_map(fn (mixed $item) => (string) $item, $value)), + $value === null => '-', + default => (string) $value, + }; + } + + /** + * 계측이 사용한 DB 스키마명을 반환합니다. + * + * 읽기/쓰기 분리 설정에서는 스키마명이 `write`/`read` 하위에만 있어 최상위 `database` + * 가 비어 있습니다. 리포트에 스키마가 비면 어느 DB 를 잰 것인지 알 수 없으므로 + * 세 위치를 순서대로 확인합니다. + * + * @return string 스키마명 (확인 불가 시 '-') + */ + private function databaseName(): string + { + $connection = (string) config('database.default'); + + foreach (['database', 'write.database', 'read.database'] as $key) { + $name = config("database.connections.{$connection}.{$key}"); + + if (is_string($name) && $name !== '') { + return $name; + } + } + + return '-'; + } + + /** + * DB 서버 버전을 조회합니다. + * + * @return string DB 버전 (조회 실패 시 '-') + */ + private function databaseVersion(): string + { + try { + $row = DB::selectOne('select version() as version'); + + return (string) ($row->version ?? '-'); + } catch (\Throwable) { + return '-'; + } + } +} diff --git a/app/Benchmark/Contracts/BenchmarkAxisRunner.php b/app/Benchmark/Contracts/BenchmarkAxisRunner.php new file mode 100644 index 00000000..1c264b26 --- /dev/null +++ b/app/Benchmark/Contracts/BenchmarkAxisRunner.php @@ -0,0 +1,34 @@ + $options 축 고유 옵션 + * @param string|null $label 표시용 설명 + */ + public function __construct( + public string $key, + public BenchmarkAxis $axis, + public string $sourceKind, + public string $sourceIdentifier, + public array $options = [], + public ?string $label = null, + ) {} + + /** + * 출처를 포함한 전역 고유 키를 반환합니다. + * + * 서로 다른 확장이 같은 키(`orders` 등)를 선언할 수 있으므로 충돌 시에는 이 키로 + * 지목합니다. 커맨드는 짧은 키가 유일할 때만 짧은 키를 허용합니다. + * + * @return string `{출처}/{키}` 형태의 정규화 키 + */ + public function qualifiedKey(): string + { + return $this->sourceIdentifier.'/'.$this->key; + } + + /** + * 축 고유 옵션 값을 읽습니다. + * + * @param string $name 옵션 키 + * @param mixed $default 미선언 시 기본값 + * @return mixed 옵션 값 + */ + public function option(string $name, mixed $default = null): mixed + { + return $this->options[$name] ?? $default; + } + + /** + * 이 프로파일 실행이 데이터를 변경하는지 판정합니다. + * + * 축 기본값을 쓰되, 프로파일이 `mutating` 을 명시하면 그 선언을 따릅니다. + * `screen` 축은 GET 이 아니면 변경으로 봅니다. + * + * @return bool 데이터 변경 여부 + */ + public function mutates(): bool + { + $declared = $this->options['mutating'] ?? null; + + if (is_bool($declared)) { + return $declared; + } + + if ($this->axis === BenchmarkAxis::Screen) { + return strtoupper((string) $this->option('method', 'GET')) !== 'GET'; + } + + return $this->axis->mutatesByDefault(); + } + + /** + * 배열로 직렬화합니다. (`--json` / 리포트 출력용) + * + * @return array 직렬화 결과 + */ + public function toArray(): array + { + return [ + 'key' => $this->key, + 'qualified_key' => $this->qualifiedKey(), + 'axis' => $this->axis->value, + 'source' => ['kind' => $this->sourceKind, 'identifier' => $this->sourceIdentifier], + 'label' => $this->label, + 'options' => $this->options, + ]; + } +} diff --git a/app/Benchmark/DTO/BenchmarkResult.php b/app/Benchmark/DTO/BenchmarkResult.php new file mode 100644 index 00000000..a39170ca --- /dev/null +++ b/app/Benchmark/DTO/BenchmarkResult.php @@ -0,0 +1,68 @@ + $headers 표 헤더 + * @param array> $rows 표 행 (표시용 문자열) + * @param array $metrics 기계 판독용 원시 수치 + * @param array $notes 부가 설명 줄 (실행 계획, N+1 후보 등) + * @param bool $skipped 실행하지 않았는지 여부 + * @param string|null $skipReason 실행하지 않은 사유 + */ + public function __construct( + public BenchmarkProfile $profile, + public array $headers = [], + public array $rows = [], + public array $metrics = [], + public array $notes = [], + public bool $skipped = false, + public ?string $skipReason = null, + ) {} + + /** + * 실행하지 않은 결과를 만듭니다. + * + * 실패/건너뜀을 결과 목록에서 빼면 리포트가 "전부 측정됨"으로 읽히므로, 사유를 담은 + * 결과로 남겨 표와 JSON 양쪽에 드러냅니다. + * + * @param BenchmarkProfile $profile 대상 프로파일 + * @param string $reason 건너뛴 사유 + * @return self 건너뜀 결과 + */ + public static function skipped(BenchmarkProfile $profile, string $reason): self + { + return new self(profile: $profile, skipped: true, skipReason: $reason); + } + + /** + * 배열로 직렬화합니다. + * + * @return array 직렬화 결과 + */ + public function toArray(): array + { + return [ + 'profile' => $this->profile->qualifiedKey(), + 'axis' => $this->profile->axis->value, + 'label' => $this->profile->label, + 'source' => ['kind' => $this->profile->sourceKind, 'identifier' => $this->profile->sourceIdentifier], + 'skipped' => $this->skipped, + 'skip_reason' => $this->skipReason, + 'headers' => $this->headers, + 'rows' => $this->rows, + 'metrics' => $this->metrics, + 'notes' => $this->notes, + ]; + } +} diff --git a/app/Benchmark/DTO/BenchmarkRunOptions.php b/app/Benchmark/DTO/BenchmarkRunOptions.php new file mode 100644 index 00000000..f93366b3 --- /dev/null +++ b/app/Benchmark/DTO/BenchmarkRunOptions.php @@ -0,0 +1,52 @@ + $offsets 측정할 OFFSET 목록 (list 축) + * @param int $runs 측정 횟수 (첫 회는 버림) + * @param int $seed 계측 전 합성 행 시딩 건수 (0 = 시딩 안 함, list 축) + * @param bool $fresh 시딩 전 대상 테이블 비움 (list 축) + * @param bool $explain 실행 계획 수집 (list 축) + * @param bool $allowWrite 데이터 변경 축 실행 허용 + * @param string|null $asUser 계측에 사용할 기존 계정 (ID 또는 이메일, screen 축) + * @param int $perPage 목록/화면 1페이지 건수 + */ + public function __construct( + public array $offsets = [0], + public int $runs = 3, + public int $seed = 0, + public bool $fresh = false, + public bool $explain = false, + public bool $allowWrite = false, + public ?string $asUser = null, + public int $perPage = 20, + ) {} + + /** + * 배열로 직렬화합니다. (리포트의 실행 조건 기재용) + * + * @return array 직렬화 결과 + */ + public function toArray(): array + { + return [ + 'offsets' => $this->offsets, + 'runs' => $this->runs, + 'seed' => $this->seed, + 'fresh' => $this->fresh, + 'explain' => $this->explain, + 'allow_write' => $this->allowWrite, + 'as_user' => $this->asUser, + 'per_page' => $this->perPage, + ]; + } +} diff --git a/app/Benchmark/QueryCollector.php b/app/Benchmark/QueryCollector.php new file mode 100644 index 00000000..9d51691e --- /dev/null +++ b/app/Benchmark/QueryCollector.php @@ -0,0 +1,102 @@ + + */ + private array $queries = []; + + public function __construct() + { + DB::listen(function (QueryExecuted $event) { + if (! $this->collecting) { + return; + } + + $this->queries[] = ['sql' => $event->sql, 'time' => (float) $event->time]; + }); + } + + /** + * 콜백 실행 구간의 쿼리를 수집합니다. + * + * @template TReturn + * + * @param \Closure(): TReturn $callback 계측 대상 작업 + * @return array{value: TReturn, queries: array} 반환값과 수집 결과 + */ + public function collect(\Closure $callback): array + { + $this->queries = []; + $this->collecting = true; + + try { + $value = $callback(); + } finally { + $this->collecting = false; + } + + return ['value' => $value, 'queries' => $this->queries]; + } + + /** + * 수집된 쿼리를 요약합니다. + * + * @param array $queries 수집 결과 + * @return array{count: int, db_ms: float, n_plus_one: array} 요약 + */ + public function summarize(array $queries): array + { + $grouped = []; + + foreach ($queries as $query) { + $grouped[$query['sql']] = ($grouped[$query['sql']] ?? 0) + 1; + } + + arsort($grouped); + + $candidates = []; + + foreach ($grouped as $sql => $count) { + if ($count < self::N_PLUS_ONE_THRESHOLD) { + continue; + } + + $candidates[] = ['count' => $count, 'sql' => $sql]; + } + + return [ + 'count' => count($queries), + 'db_ms' => round(array_sum(array_column($queries, 'time')), 2), + 'n_plus_one' => $candidates, + ]; + } +} diff --git a/app/Benchmark/SyntheticSeeder.php b/app/Benchmark/SyntheticSeeder.php new file mode 100644 index 00000000..71671297 --- /dev/null +++ b/app/Benchmark/SyntheticSeeder.php @@ -0,0 +1,190 @@ + $overrides 컬럼별 고정값 (실제 조회 조건과 맞추기 위한 지정) + * @param \Closure|null $onProgress 진행 콜백 (int $inserted, int $total) + */ + public function seed(string $table, int $count, array $overrides = [], ?\Closure $onProgress = null): void + { + $fillable = $this->fillableColumns($table); + + $chunk = []; + $inserted = 0; + + for ($i = 1; $i <= $count; $i++) { + $chunk[] = $this->synthesizeRow($table, $fillable, $overrides, $i); + + if (count($chunk) >= self::CHUNK_SIZE) { + $this->insert($table, $chunk); + $inserted += count($chunk); + $chunk = []; + + if ($onProgress !== null) { + $onProgress($inserted, $count); + } + } + } + + if ($chunk !== []) { + $this->insert($table, $chunk); + $inserted += count($chunk); + + if ($onProgress !== null) { + $onProgress($inserted, $count); + } + } + } + + /** + * 대상 테이블을 비웁니다. + * + * 다른 테이블이 FK 로 참조하면 TRUNCATE 가 거부되므로 제약을 잠시 내립니다. + * + * @param string $table 대상 테이블 + */ + public function truncate(string $table): void + { + Schema::withoutForeignKeyConstraints(function () use ($table) { + DB::table($table)->truncate(); + }); + } + + /** + * 값을 반드시 채워야 하는 컬럼 목록을 반환합니다. + * + * @param string $table 대상 테이블 + * @return array> Schema::getColumns() 항목 목록 + */ + private function fillableColumns(string $table): array + { + return array_values(array_filter( + Schema::getColumns($table), + fn (array $column) => ! ($column['auto_increment'] ?? false) + && ! $column['nullable'] + && ($column['default'] === null) + )); + } + + /** + * 합성 행 1건을 만듭니다. + * + * @param string $table 대상 테이블 + * @param array> $fillable 채워야 하는 컬럼 목록 + * @param array $overrides 컬럼별 고정값 + * @param int $index 행 인덱스 (고유값 생성용) + * @return array 합성 행 + */ + private function synthesizeRow(string $table, array $fillable, array $overrides, int $index): array + { + $row = []; + + foreach ($fillable as $column) { + $row[$column['name']] = array_key_exists($column['name'], $overrides) + ? $overrides[$column['name']] + : $this->synthesizeValue($column, $index); + } + + // NULL 허용 컬럼이라 위 루프에서 빠졌더라도, 실제 조회 조건에 쓰이는 컬럼은 + // 채워야 계측이 화면과 같은 인덱스를 타게 된다. + foreach ($overrides as $name => $value) { + if (! array_key_exists($name, $row) && Schema::hasColumn($table, $name)) { + $row[$name] = $value; + } + } + + return $row; + } + + /** + * 합성 행을 삽입합니다. (외래키 제약 일시 해제) + * + * @param string $table 대상 테이블 + * @param array> $chunk 삽입할 행 묶음 + */ + private function insert(string $table, array $chunk): void + { + Schema::withoutForeignKeyConstraints(function () use ($table, $chunk) { + DB::table($table)->insert($chunk); + }); + } + + /** + * 컬럼 타입에 맞는 합성 값을 만듭니다. + * + * @param array $column Schema::getColumns() 항목 + * @param int $index 행 인덱스 (고유값 생성용) + * @return mixed 합성 값 + */ + private function synthesizeValue(array $column, int $index): mixed + { + $type = strtolower((string) ($column['type_name'] ?? $column['type'] ?? 'varchar')); + + return match (true) { + str_contains($type, 'int') => $index, + str_contains($type, 'decimal'), str_contains($type, 'float'), str_contains($type, 'double') => 1000, + str_contains($type, 'bool'), str_contains($type, 'tinyint') => 0, + str_contains($type, 'json') => '{}', + // 시간 컬럼을 한 값으로 채우면 정렬이 전부 동률이 되어 filesort 가 지배해 버린다. + // 정렬 인덱스 효과를 재는 것이 목적이므로 행마다 값을 분산시킨다. + str_contains($type, 'date'), str_contains($type, 'time') => now()->subMinutes($index), + str_contains($type, 'text') => str_repeat('벤치마크 본문 ', 100), + default => $this->synthesizeString($column, $index), + }; + } + + /** + * 문자열 컬럼의 선언 길이에 맞는 합성 값을 만듭니다. + * + * 컬럼 길이를 무시하고 고정 길이 문자열을 넣으면 `varchar(10)` 류(신고 대상 타입, + * 우편번호, 로그 타입 등)에서 "Data too long" 으로 시딩 자체가 실패한다. + * + * @param array $column Schema::getColumns() 항목 + * @param int $index 행 인덱스 (고유값 생성용) + * @return string 합성 문자열 + */ + private function synthesizeString(array $column, int $index): string + { + $length = 40; + + // type 은 'varchar(10)' 형태로 선언 길이를 담고 있다 + if (preg_match('/\((\d+)\)/', (string) ($column['type'] ?? ''), $m) === 1) { + $length = max(1, (int) $m[1]); + } + + // 짧은 컬럼은 고유성 확보가 우선이라 인덱스 자체를 문자열로 쓴다 + if ($length <= 12) { + return substr((string) $index, -$length); + } + + return substr("bench-{$index}-".Str::random(8), 0, $length); + } +} diff --git a/app/Console/Commands/BenchCommand.php b/app/Console/Commands/BenchCommand.php new file mode 100644 index 00000000..45618db1 --- /dev/null +++ b/app/Console/Commands/BenchCommand.php @@ -0,0 +1,395 @@ + $runners 축 실행기 목록 (컨테이너 태그 주입) + */ + public function __construct( + private readonly BenchmarkProfileRegistry $registry, + private readonly BenchmarkReporter $reporter, + private readonly iterable $runners, + ) { + parent::__construct(); + } + + /** + * 커맨드를 실행합니다. + * + * @return int 종료 코드 + */ + public function handle(): int + { + $this->applyDatabaseOverride(); + + if ($this->option('list-profiles')) { + return $this->listProfiles(); + } + + [$profiles, $selectionError] = $this->selectProfiles(); + + if ($selectionError !== null) { + $this->error($selectionError); + + return self::FAILURE; + } + + if ($profiles === []) { + $this->error('계측할 프로파일이 없습니다. --profile / --axis / --all 중 하나를 지정하세요.'); + + return self::FAILURE; + } + + $options = $this->runOptions(); + + // 계측 결과가 환경에 좌우되므로 실행 환경을 먼저 드러낸다 + if (! $this->option('json')) { + foreach ($this->reporter->environment() as $name => $value) { + $this->line(sprintf(' %-14s %s', $name, $value)); + } + + $this->newLine(); + } + + $this->reportCollectionWarnings(); + + $results = []; + + foreach ($profiles as $profile) { + $results[] = $this->runProfile($profile, $options); + } + + return $this->output($results, $options); + } + + /** + * 계측용 데이터베이스 지정을 반영합니다. + * + * 계측은 대량 시딩을 동반하므로 개발 DB 대신 폐기 가능한 DB 를 지정할 수 있어야 합니다. + * config 가 캐시된 환경에서는 `.env` / `--env` 로 연결을 바꿀 수 없어 런타임 치환이 + * 유일한 수단입니다. + */ + private function applyDatabaseOverride(): void + { + $database = (string) $this->option('database'); + + if ($database === '') { + return; + } + + $connection = config('database.default'); + + config([ + "database.connections.{$connection}.database" => $database, + "database.connections.{$connection}.write.database" => $database, + "database.connections.{$connection}.read.database" => $database, + ]); + + DB::purge($connection); + } + + /** + * 등록된 프로파일 목록을 출력합니다. + * + * @return int 종료 코드 + */ + private function listProfiles(): int + { + $profiles = $this->registry->all(); + + if ($this->option('json')) { + $this->line((string) json_encode( + array_map(fn (BenchmarkProfile $profile) => $profile->toArray(), array_values($profiles)), + JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES + )); + + return self::SUCCESS; + } + + $this->table( + ['프로파일', '축', '출처', '설명'], + array_map(fn (BenchmarkProfile $profile) => [ + $profile->qualifiedKey(), + $profile->axis->value, + $profile->sourceKind, + $profile->label ?? '-', + ], array_values($profiles)) + ); + + $this->reportCollectionWarnings(); + + return self::SUCCESS; + } + + /** + * 실행할 프로파일을 고릅니다. + * + * @return array{0: array, 1: string|null} [실행 대상, 실패 사유] + */ + private function selectProfiles(): array + { + $keys = array_filter((array) $this->option('profile'), 'strlen'); + + if ($keys !== []) { + $selected = []; + + foreach ($keys as $key) { + [$profile, $error] = $this->registry->resolve((string) $key); + + if ($error !== null) { + return [[], $error]; + } + + $selected[] = $profile; + } + + return [$selected, null]; + } + + $axisOption = (string) $this->option('axis'); + + if ($axisOption !== '') { + $axis = BenchmarkAxis::tryFrom($axisOption); + + if ($axis === null) { + return [[], "알 수 없는 축: {$axisOption} (허용: ".implode('|', BenchmarkAxis::values()).')']; + } + + return [array_values($this->registry->byAxis($axis)), null]; + } + + return [$this->option('all') ? array_values($this->registry->all()) : [], null]; + } + + /** + * 커맨드 옵션을 실행 옵션으로 해석합니다. + * + * @return BenchmarkRunOptions 실행 옵션 + */ + private function runOptions(): BenchmarkRunOptions + { + $asUser = (string) $this->option('as'); + + return new BenchmarkRunOptions( + offsets: $this->parseOffsets(), + runs: max(1, (int) $this->option('runs')), + seed: max(0, (int) $this->option('seed')), + fresh: (bool) $this->option('fresh'), + explain: (bool) $this->option('explain'), + allowWrite: (bool) $this->option('allow-write'), + asUser: $asUser === '' ? null : $asUser, + perPage: max(1, (int) $this->option('per-page')), + ); + } + + /** + * OFFSET 목록을 파싱합니다. + * + * @return array 오름차순 정렬된 OFFSET 목록 + */ + private function parseOffsets(): array + { + $offsets = array_map('intval', array_filter(explode(',', (string) $this->option('offsets')), 'strlen')); + sort($offsets); + + return $offsets === [] ? [0] : $offsets; + } + + /** + * 프로파일 1건을 계측합니다. + * + * @param BenchmarkProfile $profile 대상 프로파일 + * @param BenchmarkRunOptions $options 실행 옵션 + * @return BenchmarkResult 계측 결과 + */ + private function runProfile(BenchmarkProfile $profile, BenchmarkRunOptions $options): BenchmarkResult + { + $runner = $this->runnerFor($profile->axis); + + if ($runner === null) { + return BenchmarkResult::skipped($profile, "축 실행기가 없습니다: {$profile->axis->value}"); + } + + if (! $this->option('json')) { + $this->line(sprintf('▶ %s (%s)', $profile->qualifiedKey(), $profile->axis->label())); + } + + $onProgress = $this->option('json') + ? null + : fn (string $message) => $this->line(' '.$message); + + return $runner->run($profile, $options, $onProgress); + } + + /** + * 축에 대응하는 실행기를 찾습니다. + * + * @param BenchmarkAxis $axis 대상 축 + * @return BenchmarkAxisRunner|null 실행기 (없으면 null) + */ + private function runnerFor(BenchmarkAxis $axis): ?BenchmarkAxisRunner + { + foreach ($this->runners as $runner) { + if ($runner->axis() === $axis) { + return $runner; + } + } + + return null; + } + + /** + * 무시된 프로파일 선언을 알립니다. + * + * 선언 오류를 조용히 버리면 그 대상이 계측 사각으로 남으므로 매 실행에 드러냅니다. + */ + private function reportCollectionWarnings(): void + { + if ($this->option('json')) { + return; + } + + foreach ($this->registry->warnings() as $warning) { + $this->warn('무시된 프로파일 선언 — '.$warning); + } + } + + /** + * 계측 결과를 출력합니다. + * + * @param array $results 계측 결과 + * @param BenchmarkRunOptions $options 실행 옵션 + * @return int 종료 코드 + */ + private function output(array $results, BenchmarkRunOptions $options): int + { + $warnings = $this->registry->warnings(); + + if ($this->option('json')) { + $this->line($this->reporter->toJson($results, $options, $warnings)); + } else { + foreach ($results as $result) { + $this->newLine(); + $this->line(sprintf('%s (%s)', $result->profile->qualifiedKey(), $result->profile->axis->label())); + + if ($result->skipped) { + $this->warn(' 측정하지 않음: '.$result->skipReason); + + continue; + } + + $this->table($result->headers, $result->rows); + + foreach ($result->notes as $note) { + $this->line(' '.$note); + } + } + } + + if ($this->wantsReport()) { + $path = $this->writeReport($results, $options, $warnings); + $this->newLine(); + $this->info('리포트 저장: '.$path); + } + + // 전부 건너뛴 실행을 성공으로 보고하면 "측정했다"로 읽히므로 실패로 돌려준다 + $measured = array_filter($results, fn (BenchmarkResult $result) => ! $result->skipped); + + return $measured === [] ? self::FAILURE : self::SUCCESS; + } + + /** + * 리포트 출력이 요청되었는지 판정합니다. + * + * `--report` 는 값이 선택이라 `option()` 만으로는 "미지정"과 "값 없이 지정"을 + * 구분할 수 없으므로 원시 입력을 확인합니다. + * + * @return bool 리포트 출력 여부 + */ + private function wantsReport(): bool + { + return $this->input->hasParameterOption('--report') + || $this->input->hasParameterOption('--report=') + || (string) $this->option('report') !== ''; + } + + /** + * 마크다운 리포트를 저장합니다. + * + * @param array $results 계측 결과 + * @param BenchmarkRunOptions $options 실행 옵션 + * @param array $warnings 프로파일 수집 경고 + * @return string 저장된 절대 경로 + */ + private function writeReport(array $results, BenchmarkRunOptions $options, array $warnings): string + { + $given = (string) $this->option('report'); + + $path = $given !== '' + ? $given + : storage_path(self::REPORT_DIR.'/bench-'.now()->format('Ymd-His').'.md'); + + $directory = dirname($path); + + if (! is_dir($directory)) { + mkdir($directory, 0755, true); + } + + file_put_contents($path, $this->reporter->toMarkdown($results, $options, $warnings)); + + return $path; + } +} diff --git a/app/Console/Commands/BenchPaginationCommand.php b/app/Console/Commands/BenchPaginationCommand.php deleted file mode 100644 index ad72b8cd..00000000 --- a/app/Console/Commands/BenchPaginationCommand.php +++ /dev/null @@ -1,556 +0,0 @@ -, order: array, soft_delete: bool}> - */ - private function profiles(): array - { - return [ - 'board_posts' => [ - 'table' => 'board_posts', - 'columns' => ['id', 'board_id', 'title', 'author_name', 'view_count', 'is_notice', 'created_at'], - 'order' => [['created_at', 'desc'], ['id', 'desc']], - 'soft_delete' => true, - // 실제 목록은 게시판 하나를 조회한다. 필터 없이 재면 인덱스 선택이 달라져 - // 화면에서 일어나는 일과 다른 것을 재게 된다. - 'filters' => ['board_id' => 1], - 'seed_overrides' => ['board_id' => 1, 'is_notice' => 0], - ], - 'orders' => [ - 'table' => 'ecommerce_orders', - 'columns' => ['id', 'user_id', 'order_number', 'order_status', 'order_device', 'is_first_order', 'currency', 'currency_snapshot', 'total_amount', 'total_shipping_amount', 'total_paid_amount', 'total_cancelled_amount', 'total_refunded_amount', 'total_points_used_amount', 'total_earned_points_amount', 'mc_total_amount', 'mc_total_shipping_amount', 'ordered_at', 'created_at', 'updated_at', 'deleted_at'], - 'order' => [['ordered_at', 'desc'], ['id', 'desc']], - 'soft_delete' => true, - ], - // 아래 두 목록은 응답 계약상 넓은 컬럼(changes/properties, body)을 그대로 노출하므로 - // 컬럼 프루닝이 불가하다. 지연 조인만 적용했고, 계측 축도 select * vs select id 다. - 'activity_logs' => [ - 'table' => 'activity_logs', - 'columns' => ['*'], - 'order' => [['created_at', 'desc'], ['id', 'desc']], - 'soft_delete' => false, - ], - 'notification_logs' => [ - 'table' => 'notification_logs', - 'columns' => ['*'], - 'order' => [['created_at', 'desc'], ['id', 'desc']], - 'soft_delete' => false, - ], - 'users' => [ - 'table' => 'users', - 'columns' => ['*'], - 'order' => [['created_at', 'desc'], ['id', 'desc']], - 'soft_delete' => true, - ], - 'reports' => [ - 'table' => 'boards_reports', - 'columns' => ['*'], - 'order' => [['created_at', 'desc'], ['id', 'desc']], - 'soft_delete' => true, - ], - 'coupon_issues' => [ - 'table' => 'ecommerce_promotion_coupon_issues', - 'columns' => ['*'], - 'order' => [['issued_at', 'desc'], ['id', 'desc']], - 'soft_delete' => false, - ], - 'products' => [ - 'table' => 'ecommerce_products', - 'columns' => ['*'], - 'order' => [['created_at', 'desc'], ['id', 'desc']], - 'soft_delete' => true, - ], - 'product_inquiries' => [ - 'table' => 'ecommerce_product_inquiries', - 'columns' => ['*'], - 'order' => [['created_at', 'desc'], ['id', 'desc']], - 'soft_delete' => true, - ], - 'product_reviews' => [ - 'table' => 'ecommerce_product_reviews', - 'columns' => ['*'], - 'order' => [['created_at', 'desc'], ['id', 'desc']], - 'soft_delete' => true, - ], - 'pages' => [ - 'table' => 'pages', - 'columns' => ['*'], - 'order' => [['created_at', 'desc'], ['id', 'desc']], - 'soft_delete' => true, - ], - 'extra_fee_templates' => [ - 'table' => 'ecommerce_shipping_policy_extra_fee_templates', - 'columns' => ['*'], - 'order' => [['zipcode', 'asc'], ['id', 'asc']], - 'soft_delete' => false, - ], - ]; - } - - /** - * 커맨드를 실행합니다. - * - * @return int 종료 코드 - */ - public function handle(): int - { - $profiles = $this->profiles(); - - if ($this->option('list-profiles')) { - foreach ($profiles as $key => $profile) { - $this->line(sprintf('%-20s %s', $key, $profile['table'])); - } - - return self::SUCCESS; - } - - $key = (string) $this->option('table'); - - if (! isset($profiles[$key])) { - $this->error("등록되지 않은 프로파일: {$key} (--list-profiles 로 목록 확인)"); - - return self::FAILURE; - } - - $profile = $profiles[$key]; - - // 계측은 대량 시딩을 동반하므로 개발 DB 대신 폐기 가능한 DB 를 지정할 수 있어야 한다. - // config 가 캐시된 환경에서는 .env / --env 로 연결을 바꿀 수 없어 런타임 치환이 유일한 수단이다. - $database = (string) $this->option('database'); - - if ($database !== '') { - $connection = config('database.default'); - - config([ - "database.connections.{$connection}.database" => $database, - "database.connections.{$connection}.write.database" => $database, - "database.connections.{$connection}.read.database" => $database, - ]); - - DB::purge($connection); - } - - if (! Schema::hasTable($profile['table'])) { - $this->error("테이블이 없습니다: {$profile['table']} (해당 확장이 설치되어 있는지 확인)"); - - return self::FAILURE; - } - - // 계측 결과가 환경에 좌우되므로 실행 환경을 먼저 드러낸다 - $this->line(sprintf( - '환경: APP_ENV=%s / DB=%s / config:cache=%s', - config('app.env'), - config('database.default'), - app()->configurationIsCached() ? 'on' : 'off' - )); - - if ($this->option('fresh')) { - if (app()->environment('production')) { - $this->error('운영 환경에서는 --fresh 를 사용할 수 없습니다.'); - - return self::FAILURE; - } - - // 다른 테이블이 FK 로 참조하면 TRUNCATE 가 거부되므로 제약을 잠시 내린다 - Schema::withoutForeignKeyConstraints(function () use ($profile) { - DB::table($profile['table'])->truncate(); - }); - - $this->warn("비움: {$profile['table']}"); - } - - $seed = (int) $this->option('seed'); - - if ($seed > 0) { - $this->seedRows($profile['table'], $seed, $profile['seed_overrides'] ?? []); - } - - $columns = $this->existingColumns($profile['table'], $profile['columns']); - $order = $this->existingOrder($profile['table'], $profile['order']); - $offsets = $this->parseOffsets(); - $runs = max(1, (int) $this->option('runs')); - - $rows = []; - - foreach ($offsets as $offset) { - // 세 축: 컬럼 인자를 생략했을 때(=select *) / 목록 컬럼만 / 키 컬럼만. - // 첫 축이 프루닝 이전 상태, 셋째 축이 지연 조인의 inner 에 해당한다. - $allMs = $this->measure($profile, ['*'], $order, $offset, $runs); - $listMs = $this->measure($profile, $columns, $order, $offset, $runs); - $idOnlyMs = $this->measure($profile, ['id'], $order, $offset, $runs); - - $rows[] = [ - 'offset' => $offset, - 'all_ms' => $allMs, - 'list_ms' => $listMs, - 'id_only_ms' => $idOnlyMs, - 'ratio' => $idOnlyMs > 0 ? round($allMs / $idOnlyMs, 1) : null, - 'explain' => $this->option('explain') ? $this->explain($profile, $columns, $order, $offset) : null, - // 지연 조인의 inner 가 실제로 어떤 계획을 타는지가 인덱스 설계의 근거다 - 'explain_id_only' => $this->option('explain') ? $this->explain($profile, ['id'], $order, $offset) : null, - ]; - } - - if ($this->option('json')) { - $this->line(json_encode([ - 'profile' => $key, - 'table' => $profile['table'], - 'columns' => $columns, - 'runs' => $runs, - 'results' => $rows, - ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); - - return self::SUCCESS; - } - - $this->table( - ['OFFSET', '전체 컬럼(ms)', '목록 컬럼(ms)', 'ID만(ms)', '전체÷ID'], - array_map(fn (array $row) => [ - number_format($row['offset']), - number_format($row['all_ms'], 1), - number_format($row['list_ms'], 1), - number_format($row['id_only_ms'], 1), - $row['ratio'] !== null ? $row['ratio'].'×' : '-', - ], $rows) - ); - - if ($this->option('explain')) { - foreach ($rows as $row) { - $this->newLine(); - $this->line("EXPLAIN @ OFFSET {$row['offset']} — 목록 컬럼"); - - foreach ($row['explain'] as $line) { - $this->line(' '.$line); - } - - $this->line("EXPLAIN @ OFFSET {$row['offset']} — ID 만 (지연 조인 inner)"); - - foreach ($row['explain_id_only'] as $line) { - $this->line(' '.$line); - } - } - } - - return self::SUCCESS; - } - - /** - * OFFSET 목록을 파싱합니다. - * - * @return array 오름차순 정렬된 OFFSET 목록 - */ - private function parseOffsets(): array - { - $offsets = array_map('intval', array_filter(explode(',', (string) $this->option('offsets')), 'strlen')); - sort($offsets); - - return $offsets === [] ? [0] : $offsets; - } - - /** - * 스키마에 실제로 존재하는 컬럼만 남깁니다. - * - * @param string $table 테이블명 - * @param array $columns 후보 컬럼 - * @return array 존재하는 컬럼 목록 - */ - private function existingColumns(string $table, array $columns): array - { - // ['*'] 는 "목록이 전 컬럼을 그대로 노출한다" 는 선언이다. 응답 계약상 넓은 컬럼을 - // 뺄 수 없는 목록(활동 로그의 changes, 알림 발송 이력의 body 등)이 여기 해당하며, - // 이 경우 계측의 비교 축은 "select * vs select id" 가 된다. - if ($columns === ['*']) { - return Schema::getColumnListing($table); - } - - $existing = array_values(array_filter($columns, fn (string $column) => Schema::hasColumn($table, $column))); - - return $existing === [] ? ['id'] : $existing; - } - - /** - * 스키마에 존재하는 정렬 컬럼만 남깁니다. - * - * @param string $table 테이블명 - * @param array $order 후보 정렬 - * @return array 적용 가능한 정렬 목록 - */ - private function existingOrder(string $table, array $order): array - { - $existing = array_values(array_filter($order, fn (array $spec) => Schema::hasColumn($table, $spec[0]))); - - return $existing === [] ? [['id', 'desc']] : $existing; - } - - /** - * 한 조합을 여러 번 실행해 중앙값(ms)을 돌려줍니다. - * - * DB::enableQueryLog() 는 오버헤드와 메모리 누적이 있어 쓰지 않고 직접 시간을 잰다. - * - * @param array $profile 프로파일 정의 - * @param array $columns select 컬럼 - * @param array $order 정렬 - * @param int $offset OFFSET - * @param int $runs 측정 횟수 - * @return float 중앙값 (밀리초) - */ - private function measure(array $profile, array $columns, array $order, int $offset, int $runs): float - { - // 첫 회는 캐시 워밍 성격이라 버린다 - $this->runQuery($profile, $columns, $order, $offset); - - $samples = []; - - for ($i = 0; $i < $runs; $i++) { - $start = microtime(true); - $this->runQuery($profile, $columns, $order, $offset); - $samples[] = (microtime(true) - $start) * 1000; - } - - sort($samples); - - return $samples[intdiv(count($samples), 2)]; - } - - /** - * 계측 대상 쿼리를 실행합니다. - * - * @param array $profile 프로파일 정의 - * @param array $columns select 컬럼 - * @param array $order 정렬 - * @param int $offset OFFSET - */ - private function runQuery(array $profile, array $columns, array $order, int $offset): void - { - $this->buildQuery($profile, $columns, $order, $offset)->get(); - } - - /** - * 계측 대상 쿼리를 조립합니다. - * - * @param array $profile 프로파일 정의 - * @param array $columns select 컬럼 - * @param array $order 정렬 - * @param int $offset OFFSET - * @return Builder 조립된 쿼리 - */ - private function buildQuery(array $profile, array $columns, array $order, int $offset) - { - $query = DB::table($profile['table'])->select($columns); - - if ($profile['soft_delete'] && Schema::hasColumn($profile['table'], 'deleted_at')) { - $query->whereNull('deleted_at'); - } - - foreach ($profile['filters'] ?? [] as $column => $value) { - if (Schema::hasColumn($profile['table'], $column)) { - $query->where($column, $value); - } - } - - foreach ($order as $spec) { - $query->orderBy($spec[0], $spec[1]); - } - - return $query->offset($offset)->limit(20); - } - - /** - * 실행 계획을 수집합니다. - * - * @param array $profile 프로파일 정의 - * @param array $columns select 컬럼 - * @param array $order 정렬 - * @param int $offset OFFSET - * @return array 실행 계획 요약 줄 목록 - */ - private function explain(array $profile, array $columns, array $order, int $offset): array - { - $query = $this->buildQuery($profile, $columns, $order, $offset); - - try { - $plan = DB::select('EXPLAIN '.$query->toSql(), $query->getBindings()); - } catch (\Throwable $e) { - return ['실행 계획 수집 실패: '.$e->getMessage()]; - } - - return array_map(function ($row) { - $row = (array) $row; - - return sprintf( - 'type=%s key=%s rows=%s filtered=%s extra=%s', - $row['type'] ?? '-', - $row['key'] ?? '-', - $row['rows'] ?? '-', - $row['filtered'] ?? '-', - $row['Extra'] ?? '-' - ); - }, $plan); - } - - /** - * 계측용 합성 행을 시딩합니다. - * - * 스키마를 introspect 해 NOT NULL + 기본값 없는 컬럼만 타입에 맞춰 채운다. 프로파일마다 - * 별도 시더를 두면 대상이 늘 때마다 시더가 따라 늘어나므로 일반화한다. - * - * @param string $table 대상 테이블 - * @param int $count 시딩 건수 - * @param array $overrides 컬럼별 고정값 (실제 조회 조건과 맞추기 위한 지정) - */ - private function seedRows(string $table, int $count, array $overrides = []): void - { - $columns = Schema::getColumns($table); - $fillable = array_values(array_filter( - $columns, - fn (array $column) => ! ($column['auto_increment'] ?? false) - && ! $column['nullable'] - && ($column['default'] === null) - )); - - $this->info("시딩: {$table} {$count} 건"); - $bar = $this->output->createProgressBar($count); - - $chunk = []; - $chunkSize = 500; - - for ($i = 1; $i <= $count; $i++) { - $row = []; - - foreach ($fillable as $column) { - $row[$column['name']] = array_key_exists($column['name'], $overrides) - ? $overrides[$column['name']] - : $this->synthesizeValue($column, $i); - } - - foreach ($overrides as $name => $value) { - if (! array_key_exists($name, $row) && Schema::hasColumn($table, $name)) { - $row[$name] = $value; - } - } - - $chunk[] = $row; - - if (count($chunk) >= $chunkSize) { - $this->insertSynthetic($table, $chunk); - $bar->advance(count($chunk)); - $chunk = []; - } - } - - if ($chunk !== []) { - $this->insertSynthetic($table, $chunk); - $bar->advance(count($chunk)); - } - - $bar->finish(); - $this->newLine(2); - } - - /** - * 합성 행을 삽입합니다. (외래키 제약 일시 해제) - * - * 합성 값은 실제 부모 행을 가리키지 않으므로 FK 를 그대로 두면 삽입이 거부된다. - * 본 커맨드가 재는 것은 대상 테이블의 OFFSET 스캔 비용이고 조인은 측정 대상이 아니므로, - * 부모 무결성 없이 행 수와 행 폭만 재현하면 목적에 충분하다. 폐기 가능한 계측용 DB - * (`--database`)에서만 쓰는 것을 전제로 한다. - * - * @param string $table 대상 테이블 - * @param array> $chunk 삽입할 행 묶음 - */ - private function insertSynthetic(string $table, array $chunk): void - { - Schema::withoutForeignKeyConstraints(function () use ($table, $chunk) { - DB::table($table)->insert($chunk); - }); - } - - /** - * 컬럼 타입에 맞는 합성 값을 만듭니다. - * - * @param array $column Schema::getColumns() 항목 - * @param int $index 행 인덱스 (고유값 생성용) - * @return mixed 합성 값 - */ - private function synthesizeValue(array $column, int $index): mixed - { - $type = strtolower((string) ($column['type_name'] ?? $column['type'] ?? 'varchar')); - - return match (true) { - str_contains($type, 'int') => $index, - str_contains($type, 'decimal'), str_contains($type, 'float'), str_contains($type, 'double') => 1000, - str_contains($type, 'bool'), str_contains($type, 'tinyint') => 0, - str_contains($type, 'json') => '{}', - // 시간 컬럼을 한 값으로 채우면 정렬이 전부 동률이 되어 filesort 가 지배해 버린다. - // 정렬 인덱스 효과를 재는 것이 목적이므로 행마다 값을 분산시킨다. - str_contains($type, 'date'), str_contains($type, 'time') => now()->subMinutes($index), - str_contains($type, 'text') => str_repeat('벤치마크 본문 ', 100), - default => $this->synthesizeString($column, $index), - }; - } - - /** - * 문자열 컬럼의 선언 길이에 맞는 합성 값을 만듭니다. - * - * 컬럼 길이를 무시하고 고정 길이 문자열을 넣으면 `varchar(10)` 류(신고 대상 타입, - * 우편번호, 로그 타입 등)에서 "Data too long" 으로 시딩 자체가 실패한다. - * - * @param array $column Schema::getColumns() 항목 - * @param int $index 행 인덱스 (고유값 생성용) - * @return string 합성 문자열 - */ - private function synthesizeString(array $column, int $index): string - { - $length = 40; - - // type 은 'varchar(10)' 형태로 선언 길이를 담고 있다 - if (preg_match('/\((\d+)\)/', (string) ($column['type'] ?? ''), $m) === 1) { - $length = max(1, (int) $m[1]); - } - - // 짧은 컬럼은 고유성 확보가 우선이라 인덱스 자체를 문자열로 쓴다 - if ($length <= 12) { - return substr((string) $index, -$length); - } - - return substr("bench-{$index}-".Str::random(8), 0, $length); - } -} diff --git a/app/Enums/BenchmarkAxis.php b/app/Enums/BenchmarkAxis.php new file mode 100644 index 00000000..9b6bc53d --- /dev/null +++ b/app/Enums/BenchmarkAxis.php @@ -0,0 +1,93 @@ + '목록 조회', + self::Screen => '화면 응답', + self::Write => '쓰기 작업', + self::Batch => '배치 작업', + }; + } + + /** + * 프로파일 선언에서 반드시 채워야 하는 옵션 키 목록 + * + * 각 원소는 "대안 그룹"이며, 그룹마다 최소 하나가 선언되어야 합니다 + * (`screen` 축은 라우트명 또는 URI 중 하나). 레지스트리가 이 목록으로 선언을 + * 검증하며, 누락된 선언은 사유와 함께 경고로 드러내고 목록에서 제외합니다 + * (조용히 버리면 계측 사각이 됩니다). + * + * @return array> 필수 옵션 대안 그룹 목록 + */ + public function requiredOptions(): array + { + return match ($this) { + self::ListQuery => [['table']], + self::Screen => [['route', 'uri']], + self::Write => [['callback']], + self::Batch => [['command']], + }; + } + + /** + * 기본적으로 데이터를 변경하는 축인지 여부 + * + * true 인 축은 `--allow-write` 없이는 실행을 거부합니다. `screen` 축은 선언한 + * HTTP 메서드에 따라 달라지므로 프로파일 단위로 다시 판정합니다. + * + * @return bool 데이터 변경 축 여부 + */ + public function mutatesByDefault(): bool + { + return match ($this) { + self::ListQuery, self::Screen => false, + self::Write, self::Batch => true, + }; + } + + /** + * 모든 값 배열 + * + * @return array 축 값 목록 + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/app/Extension/AbstractModule.php b/app/Extension/AbstractModule.php index c0347f53..66fea241 100644 --- a/app/Extension/AbstractModule.php +++ b/app/Extension/AbstractModule.php @@ -749,6 +749,38 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte return []; } + /** + * 성능 계측 프로파일 정의를 반환합니다. + * + * 이 모듈이 소유한 목록/화면/저장 경로/배치 중 성능을 재고 싶은 대상을 선언합니다. + * `g7:bench` 커맨드가 코어 `config/benchmark.php` 선언과 함께 수집합니다 + * (`App\Benchmark\BenchmarkProfileRegistry`). 계측 대상을 코어 커맨드에 하드코딩하지 + * 않는 이유는, 확장이 설치·제거되는 설치본마다 실제로 존재하는 대상이 다르기 + * 때문입니다. 키는 모듈 내부에서만 고유하면 되고, 다른 확장과 겹치면 커맨드가 + * `{식별자}/{키}` 로 지목합니다. + * + * `write` 축의 `callback` 은 클로저를 쓸 수 없습니다 — 코어 선언과 스키마를 공유하고 + * 코어 쪽은 `config:cache` 대상이므로, 형식을 `'Fqcn'`(invokable) 또는 + * `['Fqcn', 'method']` 로 통일합니다. + * + * @return array> 프로파일 키 → 정의 + * [ + * 'orders' => [ + * 'type' => 'list', // list | screen | write | batch + * 'label' => '주문 목록', + * 'table' => 'ecommerce_orders', + * 'columns' => ['id', 'order_number', ...], + * 'order' => [['ordered_at', 'desc']], + * 'filters' => ['order_status' => 'paid'], + * 'soft_delete' => true, + * ], + * ] + */ + public function getBenchmarkProfiles(): array + { + return []; + } + /** * 모듈 설치 시 실행할 시더 클래스 목록 반환 * diff --git a/app/Extension/AbstractPlugin.php b/app/Extension/AbstractPlugin.php index 3e811cd6..1d41aa54 100644 --- a/app/Extension/AbstractPlugin.php +++ b/app/Extension/AbstractPlugin.php @@ -658,6 +658,37 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte return []; } + /** + * 성능 계측 프로파일 정의를 반환합니다. + * + * 이 플러그인이 소유한 목록/화면/저장 경로/배치 중 성능을 재고 싶은 대상을 선언합니다. + * `g7:bench` 커맨드가 코어 `config/benchmark.php` 선언과 함께 수집합니다 + * (`App\Benchmark\BenchmarkProfileRegistry`). 계측 대상을 코어 커맨드에 하드코딩하지 + * 않는 이유는, 확장이 설치·제거되는 설치본마다 실제로 존재하는 대상이 다르기 + * 때문입니다. 키는 플러그인 내부에서만 고유하면 되고, 다른 확장과 겹치면 커맨드가 + * `{식별자}/{키}` 로 지목합니다. + * + * `write` 축의 `callback` 은 클로저를 쓸 수 없습니다 — 코어 선언과 스키마를 공유하고 + * 코어 쪽은 `config:cache` 대상이므로, 형식을 `'Fqcn'`(invokable) 또는 + * `['Fqcn', 'method']` 로 통일합니다. + * + * @return array> 프로파일 키 → 정의 + * [ + * 'consent_history' => [ + * 'type' => 'list', // list | screen | write | batch + * 'label' => '동의 이력 목록', + * 'table' => 'gdpr_user_consent_histories', + * 'columns' => ['*'], + * 'order' => [['created_at', 'desc']], + * 'soft_delete' => false, + * ], + * ] + */ + public function getBenchmarkProfiles(): array + { + return []; + } + /** * 플러그인 설치 시 실행할 시더 클래스 목록 반환 * diff --git a/app/Providers/CoreServiceProvider.php b/app/Providers/CoreServiceProvider.php index c63e4bb0..874976be 100644 --- a/app/Providers/CoreServiceProvider.php +++ b/app/Providers/CoreServiceProvider.php @@ -2,6 +2,11 @@ namespace App\Providers; +use App\Benchmark\Axes\BatchAxisRunner; +use App\Benchmark\Axes\ListAxisRunner; +use App\Benchmark\Axes\ScreenAxisRunner; +use App\Benchmark\Axes\WriteAxisRunner; +use App\Console\Commands\BenchCommand; use App\Contracts\Extension\CacheInterface; use App\Contracts\Extension\ExtensionMiddlewareRegistryInterface; use App\Contracts\Extension\HookListenerInterface; @@ -119,9 +124,30 @@ class CoreServiceProvider extends ServiceProvider $this->registerRepositoryBindings(); $this->registerExtensionManagers(); + $this->registerBenchmarkAxes(); // ActivityLogManager 제거됨 — Monolog 채널(config/logging.php 'activity')로 대체 } + /** + * 성능 계측 축 실행기를 등록합니다. + * + * 축이 늘어날 때 `g7:bench` 커맨드를 고치지 않고 여기에 실행기만 추가하면 되도록 + * 태그로 묶어 주입합니다. 실행기는 CLI 계측 시점에만 해석되므로 웹 요청 비용은 없습니다. + */ + private function registerBenchmarkAxes(): void + { + $this->app->tag([ + ListAxisRunner::class, + ScreenAxisRunner::class, + WriteAxisRunner::class, + BatchAxisRunner::class, + ], 'benchmark.axes'); + + $this->app->when(BenchCommand::class) + ->needs('$runners') + ->giveTagged('benchmark.axes'); + } + /** * Extension(모듈/플러그인) PSR-4 오토로드를 등록합니다. * diff --git a/config/benchmark.php b/config/benchmark.php new file mode 100644 index 00000000..6d7b16eb --- /dev/null +++ b/config/benchmark.php @@ -0,0 +1,116 @@ + 1] + | 연산자: ['order_status' => ['not in', [...]]] + | 연산자 닫힌 집합: = != <> < <= > >= like in "not in". + | 목록에 없는 연산자는 측정 전에 사유와 함께 거부한다. + | 선언형으로 재현 못 하는 술어(상관 서브쿼리·권한 스코프)가 지배적인 + | 목록은 프로파일을 두지 않는다 — 어느 화면도 내지 않는 수치가 된다. + | soft_delete : true 면 deleted_at IS NULL 부착 + | seed_overrides : 합성 시딩 시 고정할 컬럼값 (filters 와 맞출 용도) + | + | type=screen — 화면 1장 응답 시간 + 실행 쿼리 건수 + N+1 후보 + | route : 라우트명 (권장 — URI 프리픽스 조립 금지, 사라지면 즉시 실패) + | route_params: 라우트 파라미터 + | uri : 라우트명이 없을 때만 쓰는 원시 경로 + | method : HTTP 메서드 (기본 GET) + | query : 쿼리스트링 [키 => 값] + | permissions : 계측용 임시 계정에 부여할 권한 식별자 목록 + | + | type=write — 저장 경로 1회 소요 시간 + | callback : 'Fqcn' (invokable) 또는 ['Fqcn', 'method']. 클로저는 config:cache 를 + | 깨뜨리므로 금지하며 레지스트리가 거부한다 + | cleanup : 동일 형식. 측정으로 생긴 행을 되돌린다 + | + | type=batch — 배치 커맨드 소요 시간 + 피크 메모리 + | command : artisan 커맨드명 + | arguments : 커맨드 인자/옵션 [키 => 값] + | + */ + + 'profiles' => [ + + // --- 목록 조회 (list) ------------------------------------------------- + + // 활동 로그와 알림 발송 이력은 응답 계약상 넓은 컬럼(changes/properties, body)을 + // 그대로 노출하므로 컬럼 프루닝이 불가하다. 지연 조인만 적용했고 계측 비교축은 + // select * vs select id 다. + 'activity_logs' => [ + 'type' => 'list', + 'label' => '활동 로그 목록', + 'table' => 'activity_logs', + 'columns' => ['*'], + 'order' => [['created_at', 'desc'], ['id', 'desc']], + 'soft_delete' => false, + ], + + 'notification_logs' => [ + 'type' => 'list', + 'label' => '알림 발송 이력 목록', + 'table' => 'notification_logs', + 'columns' => ['*'], + 'order' => [['created_at', 'desc'], ['id', 'desc']], + 'soft_delete' => false, + ], + + // 회원 테이블은 소프트 삭제 컬럼이 없다 (탈퇴는 status 로 표현) — 실제 스키마 기준 선언 + 'users' => [ + 'type' => 'list', + 'label' => '회원 목록', + 'table' => 'users', + 'columns' => ['*'], + 'order' => [['created_at', 'desc'], ['id', 'desc']], + 'soft_delete' => false, + ], + + // --- 화면 응답 (screen) ----------------------------------------------- + + 'activity_logs_screen' => [ + 'type' => 'screen', + 'label' => '관리자 활동 로그 목록 화면', + 'route' => 'api.admin.activity-logs.index', + 'query' => ['per_page' => 20], + 'permissions' => ['core.activities.read'], + ], + + 'users_screen' => [ + 'type' => 'screen', + 'label' => '관리자 회원 목록 화면', + 'route' => 'api.admin.users.index', + 'query' => ['per_page' => 20], + 'permissions' => ['core.users.read'], + ], + + // --- 배치 작업 (batch) ------------------------------------------------ + + 'sitemap' => [ + 'type' => 'batch', + 'label' => '사이트맵 생성', + 'command' => 'seo:generate-sitemap', + 'arguments' => ['--sync' => true], + ], + + ], +]; diff --git a/docs/README.md b/docs/README.md index 28674e30..28b14c7a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,7 +9,7 @@ | 카테고리 | 문서 수 | 링크 상태 | |----------|---------|----------| -| [백엔드](backend/) | 33개 | 정상 | +| [백엔드](backend/) | 34개 | 정상 | | [프론트엔드](frontend/) | 51개 | 정상 | | [확장 시스템](extension/) | 31개 | 정상 | | 공통 | 20개 | 정상 | @@ -125,7 +125,7 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답 ## 카테고리별 전체 문서 목록 -### 백엔드 (33개) +### 백엔드 (34개) | 문서 | 제목 | |------|------| @@ -135,6 +135,7 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답 | [api-documentation.md](backend/api-documentation.md) | API 레퍼런스 문서 규정 (API Documentation) | | [api-resources.md](backend/api-resources.md) | API 리소스 | | [authentication.md](backend/authentication.md) | 인증 및 세션 처리 | +| [benchmark.md](backend/benchmark.md) | 성능 계측 시스템 (Benchmark) | | [broadcasting.md](backend/broadcasting.md) | Broadcasting (실시간 이벤트) | | [console-confirm.md](backend/console-confirm.md) | 콘솔 yes/no 프롬프트 (ConsoleConfirm) | | [controllers.md](backend/controllers.md) | 컨트롤러 계층 구조 | diff --git a/docs/backend/README.md b/docs/backend/README.md index f9b5a83a..b5d78a24 100644 --- a/docs/backend/README.md +++ b/docs/backend/README.md @@ -36,6 +36,7 @@ | [api-documentation.md](api-documentation.md) | API 레퍼런스 문서 규정 (API Documentation) | 모든 API 엔드포인트는 레퍼런스 문서 필수 — 메서드/URI/파라미터/응답 필드 +... | | [api-resources.md](api-resources.md) | API 리소스 | Resource: BaseApiResource 상속 필수 / Collection: B... | | [authentication.md](authentication.md) | 인증 및 세션 처리 | Laravel Sanctum 토큰 전용 인증 (Bearer 토큰만 사용) | +| [benchmark.md](benchmark.md) | 성능 계측 시스템 (Benchmark) | `g7:bench` 가 4축(list/screen/write/batch)을 잰다 — ... | | [broadcasting.md](broadcasting.md) | Broadcasting (실시간 이벤트) | Laravel Reverb 사용 (WebSocket) | | [console-confirm.md](console-confirm.md) | 콘솔 yes/no 프롬프트 (ConsoleConfirm) | 콘솔 커맨드의 yes/no 프롬프트는 $this->unifiedConfirm() 사용... | | [controllers.md](controllers.md) | 컨트롤러 계층 구조 | AdminBaseController / AuthBaseController / Publ... | diff --git a/docs/backend/benchmark.md b/docs/backend/benchmark.md new file mode 100644 index 00000000..df655bbe --- /dev/null +++ b/docs/backend/benchmark.md @@ -0,0 +1,186 @@ +# 성능 계측 시스템 (Benchmark) + +> **백엔드 가이드** | [목차로 돌아가기](README.md) + +--- + +## TL;DR (5초 요약) + +```text +1. `g7:bench` 가 4축(list/screen/write/batch)을 잰다 — 계측 대상은 커맨드에 하드코딩하지 않는다 +2. 선언 지점: 코어 `config/benchmark.php`, 확장 `getBenchmarkProfiles()` — 스키마 동일 +3. 화면(screen) 축은 프로파일이 선언한 권한만 가진 임시 계정으로 내부 요청 → 롤백. `--as=` 로 기존 계정 지정 가능 +4. 데이터를 변경하는 축(write/batch/비-GET screen)은 `--allow-write` 없이 실행 거부 +5. 문서에 옮길 수치는 `--report` 산출물에서만 옮긴다 (환경 정보 동반 — 눈대중 기재 금지) +``` + +--- + +## 목차 + +- [무엇을 재는가](#무엇을-재는가) +- [선언 지점](#선언-지점) +- [축별 스키마](#축별-스키마) +- [실행](#실행) +- [안전장치](#안전장치) +- [리포트](#리포트) +- [관련 문서](#관련-문서) + +--- + +## 무엇을 재는가 + +| 축 | 재는 것 | 실행 방식 | +| ------ | ------ | ------ | +| `list` | 목록 SELECT 비용 (전체 컬럼 / 목록 컬럼 / 키 컬럼 3축) | 선언된 필터·정렬로 쿼리를 조립해 OFFSET 별 반복 측정 | +| `screen` | 화면 1장 응답 시간 + 실행 쿼리 건수 + N+1 후보 | 라우트를 HTTP 커널로 내부 요청 처리 | +| `write` | 저장 경로 1회 소요 시간 + 쿼리 건수 | 확장이 선언한 콜백 실행 | +| `batch` | 배치 커맨드 소요 시간 + 피크 메모리 | artisan 커맨드 실행 감싸기 | + +`screen` 축이 값어치가 가장 크다 — 사용자가 체감하는 단위이고, 목록 SELECT 는 빨라도 N+1 로 느려지는 경우를 잡는다. `list` 축의 세 값 배수는 지연 조인(`PaginatesWithDeferredJoin`) 적용의 기대 효과에 대응한다 (셋째 축이 지연 조인의 inner 쿼리). + +## 선언 지점 + +계측 대상은 **소유자가 선언한다**. 커맨드에 대상을 하드코딩하지 않는 이유는, 확장이 설치·제거되는 설치본마다 실제로 존재하는 대상이 다르기 때문이다. + +| 소유자 | 선언 위치 | +| ------ | ------ | +| 코어 | `config/benchmark.php` 의 `profiles` 배열 | +| 모듈 | `module.php` 의 `getBenchmarkProfiles()` 오버라이드 | +| 플러그인 | `plugin.php` 의 `getBenchmarkProfiles()` 오버라이드 | + +프로파일 키는 확장 내부에서만 고유하면 된다. 서로 다른 확장이 같은 키를 쓰면 커맨드가 `{식별자}/{키}` 로 지목하며, 짧은 키가 모호할 때는 후보를 나열하고 실행을 거부한다 (임의로 하나를 고르면 어느 확장의 목록을 잰 것인지 알 수 없게 된다). + +```php +// modules/_bundled/sirsoft-ecommerce/module.php +public function getBenchmarkProfiles(): array +{ + return [ + 'orders' => [ + 'type' => 'list', + 'label' => '주문 목록', + 'table' => 'ecommerce_orders', + // 컬럼을 다시 적지 않고 Repository 상수를 참조한다 — 다시 적으면 계측이 조용히 낡는다 + 'columns' => OrderRepository::LIST_COLUMNS, + 'order' => [['ordered_at', 'desc'], ['id', 'desc']], + 'soft_delete' => true, + ], + ]; +} +``` + +선언이 잘못된 프로파일은 **조용히 버리지 않는다** — 사유와 함께 경고로 드러내고 목록에서 제외한다 (버려진 선언이 곧 계측 사각이 된다). + +## 축별 스키마 + +공통 필드는 `type`(필수)과 `label`(선택)이다. + +### type=list + +| 필드 | 설명 | +| ------ | ------ | +| `table` | 대상 테이블 (필수) | +| `columns` | 목록이 실제로 select 하는 컬럼. `['*']` 는 "응답 계약상 전 컬럼 노출이라 프루닝 불가" 선언이며 이때 비교축은 `select *` vs `select id` | +| `order` | `[[컬럼, 방향], ...]` | +| `filters` | 화면이 실제로 거는 필터. 필터 없이 재면 인덱스 선택이 달라져 화면에서 일어나는 일과 다른 것을 잰다 (아래 형식) | +| `soft_delete` | true 면 `deleted_at IS NULL` 부착 | +| `seed_overrides` | 합성 시딩 시 고정할 컬럼값 (`filters` 와 맞출 용도) | + +`filters` 는 등가 비교와 연산자 형태를 함께 받는다. 등가 비교만 지원하면 화면이 실제로 거는 필터를 선언할 수 없는 목록이 생긴다 — 주문 목록은 상태 미지정 시 임시 주문 상태를 `NOT IN` 으로 제외한다. + +```php +'filters' => [ + 'board_id' => 1, // 등가 비교 + 'order_status' => ['not in', OrderStatusEnum::listHiddenValues()], // 연산자 형태 + 'created_at' => ['>=', '2026-01-01'], +], +``` + +연산자는 닫힌 집합이다 — `=` `!=` `<>` `<` `<=` `>` `>=` `like` `in` `not in`. 목록에 없는 연산자를 선언하면 측정 전에 사유와 함께 거부한다(조용히 빠진 필터로 측정하면 화면과 다른 것을 재면서 정상 측정으로 보고된다). 값을 Enum·상수로 참조해 도메인 SSoT 와 어긋나지 않게 한다. + +선언형 필터로 재현할 수 없는 술어(상관 서브쿼리, 권한 스코프 등)가 목록의 지배적 조건이라면 **프로파일을 두지 않는다** — 어느 화면도 내지 않는 수치가 되기 때문이다. + +### type=screen + +| 필드 | 설명 | +| ------ | ------ | +| `route` | 라우트명 (권장). 라우트가 사라지면 조용히 404 를 재는 대신 즉시 실패한다 | +| `route_params` | 라우트 파라미터 | +| `uri` | 라우트명이 없을 때만 쓰는 원시 경로 | +| `method` | HTTP 메서드 (기본 `GET`) | +| `query` | 쿼리스트링 `[키 => 값]` | +| `permissions` | 계측용 임시 계정에 부여할 권한 식별자 목록 | + +권한을 프로파일에서 받는 이유는, 계측 대상 화면이 요구하는 권한만 주어야 미들웨어 통과 여부까지 실제와 같아지기 때문이다 (전권 계정으로 재면 권한 검사 비용과 분기가 달라진다). + +### type=write + +| 필드 | 설명 | +| ------ | ------ | +| `prepare` | 회차별 선행 준비 콜백 (**계측 구간 밖**에서 실행, 회차 번호를 인자로 받음) | +| `callback` | 계측 대상 콜백 (`prepare` 반환값을 인자로 받음) | +| `cleanup` | 트랜잭션 롤백으로 되돌지 않는 잔여물(파일·캐시) 정리 | + +콜백은 `'Fqcn'`(invokable) 또는 `['Fqcn', 'method']` 형식만 허용한다 — 코어 선언이 `config/benchmark.php` 에 있고 이 파일은 `config:cache` 대상이라 클로저를 담을 수 없으므로, 확장 선언도 같은 형식으로 통일한다. 클로저를 선언하면 레지스트리가 사유와 함께 거부한다. + +`prepare` 를 계측 구간 밖에 두는 이유는, 저장 경로에는 선행 상태가 필요한 경우가 많고(주문 생성에는 임시 주문이 필요하고 임시 주문은 1회만 전환됨) 그 준비 비용이 측정값에 섞이면 재려던 것을 재지 못하기 때문이다. + +### type=batch + +| 필드 | 설명 | +| ------ | ------ | +| `command` | artisan 커맨드명 | +| `arguments` | 커맨드 인자/옵션 `[키 => 값]` | + +## 실행 + +```bash +# 등록된 프로파일 목록 +php artisan g7:bench --list-profiles + +# 프로파일 단위 +php artisan g7:bench --profile=core/users_screen +php artisan g7:bench --profile=sirsoft-ecommerce/orders --offsets=0,20000,50000,199980 --runs=3 --explain + +# 축 단위 / 전체 +php artisan g7:bench --axis=screen +php artisan g7:bench --all --allow-write + +# 깊은 OFFSET 계측 (대량 합성 행 시딩 — 폐기 가능한 DB 에서만) +php artisan --env=testing g7:bench --profile=sirsoft-board/board_posts --fresh --seed=200000 +php artisan g7:bench --profile=sirsoft-board/board_posts --database=g7_bench --seed=200000 + +# 기계 판독 / 리포트 +php artisan g7:bench --axis=list --json +php artisan g7:bench --all --allow-write --report +php artisan g7:bench --all --allow-write --report=C:/tmp/before.md +``` + +`g7:bench:pagination` 은 이전 이름의 별칭으로 남아 있어 그대로 호출된다 (옵션은 `--profile=` 기준). + +## 안전장치 + +| 장치 | 동작 | +| ------ | ------ | +| `--allow-write` | 데이터를 변경하는 축(`write`/`batch`/비-GET `screen`)은 이 플래그 없이 실행을 거부 | +| 롤백 트랜잭션 | `screen`/`write` 축은 계측 계정 생성부터 요청·저장 처리까지 롤백되는 트랜잭션 안에서 실행 → 운영 DB 에서도 잔여 데이터가 남지 않음 | +| 운영 환경 시딩 거부 | `--seed`/`--fresh` 는 `production` 에서 거부 | +| `--database=` | 계측용 DB 지정. config 가 캐시된 환경에서 연결을 바꿀 유일한 수단 | + +트랜잭션이 열려 있으면 Laravel 이 읽기 쿼리도 write PDO 로 보내므로(`Connection::getReadPdo` 의 `transactions > 0` 분기), 읽기/쓰기 분리 환경에서도 계측 요청이 임시 계정을 인증할 수 있다. + +`batch` 축은 트랜잭션으로 감싸지 않는다 — 배치 커맨드는 내부에서 커밋하거나 DDL 을 실행할 수 있고, 대량 처리를 긴 트랜잭션에 담으면 락 보유 시간이 계측 자체보다 위험해진다. + +## 리포트 + +`--report` 는 마크다운 리포트를 `storage/app/benchmarks/` 에 저장한다 (`--report=<경로>` 로 임의 경로 지정). 이 디렉토리는 Git 미추적이다 — 계측값은 실행 머신·DB 버전·OPcache 여부에 종속되므로 저장소에 축적하면 서로 비교 불가능한 수치가 섞인다. + +리포트는 환경 정보(APP_ENV, DB 버전, PHP 버전, OPcache, `config:cache`, 실행 머신)와 실행 조건을 축별 표 앞에 함께 적는다. **문서에 옮길 수치는 이 산출물에서만 옮긴다** — 눈대중·기억으로 적은 수치는 어느 환경에서 나온 값인지 확인할 수 없다. + +측정하지 못한 프로파일은 목록에서 빠지지 않고 사유와 함께 남는다. 전부 건너뛴 실행은 종료 코드 1 로 끝난다 (성공으로 보고하면 "측정했다"로 읽힌다). + +## 관련 문서 + +- [service-repository.md](service-repository.md) — 목록 조회 컬럼 프루닝과 지연 조인 +- [cheatsheet.md](../cheatsheet.md) — 커맨드 요약 +- [hooks.md](../extension/hooks.md) — 확장 선언 훅 일반 diff --git a/docs/backend/service-repository.md b/docs/backend/service-repository.md index 571913a5..b09b3b49 100644 --- a/docs/backend/service-repository.md +++ b/docs/backend/service-repository.md @@ -847,7 +847,7 @@ public function getAll(): Collection 게시글 테이블 20만 행 기준 실측 (각 3회 중앙값, 첫 회 버림): ```bash -php artisan g7:bench:pagination --table=board_posts --seed=200000 --offsets=0,20000,50000,199980 --runs=3 --explain +php artisan g7:bench --profile=sirsoft-board/board_posts --seed=200000 --offsets=0,20000,50000,199980 --runs=3 --explain ``` | OFFSET | 목록 컬럼 | ID 만 조회 | 배수 | @@ -863,6 +863,8 @@ php artisan g7:bench:pagination --table=board_posts --seed=200000 --offsets=0,20 `SUBSTRING(content, 1, 200)` 을 목록 컬럼에 두는 것은 프루닝이 아니다. 앞 200바이트를 얻기 위해 오버플로 페이지를 그대로 읽기 때문이다. 잘라내기는 지연 조인의 outer 로 옮긴다. +계측 대상 프로파일 선언 방법과 나머지 3축(화면 응답·쓰기·배치)은 [benchmark.md](benchmark.md) 를 참조한다. + ### 사용 ```php diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md index fef00780..aa7ec63e 100644 --- a/docs/cheatsheet.md +++ b/docs/cheatsheet.md @@ -258,12 +258,20 @@ php artisan seo:generate-sitemap --mode=full|auto|incremental # 재생성 모 ### 성능 계측 Artisan 커맨드 ```bash -# 깊은 OFFSET 목록 조회 비용 계측 (목록 컬럼 vs 키 컬럼 두 축). 상세: docs/backend/service-repository.md -# 대량 합성 행을 시딩하므로 운영 데이터가 있는 환경에서 --seed/--fresh 를 쓰지 않는다. -php artisan --env=testing g7:bench:pagination --list-profiles -php artisan --env=testing g7:bench:pagination --table=board_posts --fresh --seed=200000 -php artisan --env=testing g7:bench:pagination --table=board_posts --offsets=0,20000,50000,199980 --runs=3 --explain -php artisan --env=testing g7:bench:pagination --table=orders --json # 기계 판독용 +# 4축(목록/화면/쓰기/배치) 성능 계측. 계측 대상은 코어 config/benchmark.php + 확장 getBenchmarkProfiles() 선언. +# 상세: docs/backend/benchmark.md +php artisan g7:bench --list-profiles # 등록된 프로파일 목록 +php artisan g7:bench --profile=core/users_screen # 화면 1장 응답 시간 + 쿼리 건수 + N+1 후보 +php artisan g7:bench --axis=list # 축 단위 +php artisan g7:bench --all --allow-write --report # 전체 + 마크다운 리포트 (storage/app/benchmarks/) + +# 깊은 OFFSET 계측 — 대량 합성 행을 시딩하므로 운영 데이터가 있는 환경에서 --seed/--fresh 를 쓰지 않는다. +php artisan --env=testing g7:bench --profile=sirsoft-board/board_posts --fresh --seed=200000 +php artisan --env=testing g7:bench --profile=sirsoft-board/board_posts --offsets=0,20000,50000,199980 --runs=3 --explain +php artisan g7:bench --profile=sirsoft-ecommerce/orders --json # 기계 판독용 + +# 데이터를 변경하는 축(write/batch/비-GET screen)은 --allow-write 없이 거부된다. +php artisan g7:bench --profile=sirsoft-ecommerce/order_create --allow-write ``` ### API 문서 Artisan 커맨드 diff --git a/docs/extension/module-basics.md b/docs/extension/module-basics.md index 35c56a63..4c9ab689 100644 --- a/docs/extension/module-basics.md +++ b/docs/extension/module-basics.md @@ -142,6 +142,7 @@ | `getDependencies()` | `[]` | 의존성 | | `getMetadata()` | `[]` | 메타데이터 | | `getMiddleware()` | `[]` | 확장 미들웨어 선언 (self-gate targets) — `{class, groups, timing?, targets}` ([middleware.md](../backend/middleware.md)) | +| `getBenchmarkProfiles()` | `[]` | 성능 계측 대상 선언 (`g7:bench` 가 수집) — 목록/화면/쓰기/배치 4축 ([benchmark.md](../backend/benchmark.md)) | | `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견). **`g7_version >= 7.0.0-beta.5` 인 모듈은 신규 step 이 `AbstractUpgradeStep` 상속 의무** ([upgrade-step-guide §13](upgrade-step-guide.md)) — 미상속 시 `ModuleManager::runUpgradeSteps` 가 `RuntimeException` throw | #### 동적 권한/역할/메뉴 보존 규칙 diff --git a/docs/extension/plugin-development.md b/docs/extension/plugin-development.md index 6f4c07bc..6217e6c5 100644 --- a/docs/extension/plugin-development.md +++ b/docs/extension/plugin-development.md @@ -241,6 +241,7 @@ class Plugin implements PluginInterface | `getDependencies()` | `[]` | 의존하는 모듈/플러그인 목록 | | `getHookListeners()` | `[]` | 훅 리스너 클래스 목록 | | `getMiddleware()` | `[]` | 확장 미들웨어 선언 (self-gate targets) — `{class, groups, timing?, targets}` ([middleware.md](../backend/middleware.md)) | +| `getBenchmarkProfiles()` | `[]` | 성능 계측 대상 선언 (`g7:bench` 가 수집) — 목록/화면/쓰기/배치 4축 ([benchmark.md](../backend/benchmark.md)) | | `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견). **`g7_version >= 7.0.0-beta.5` 인 플러그인은 신규 step 이 `AbstractUpgradeStep` 상속 의무** ([upgrade-step-guide §13](upgrade-step-guide.md)) — 미상속 시 `PluginManager::runUpgradeSteps` 가 `RuntimeException` throw | > **동적 식별자 보존 규칙**: `Permission::updateOrCreate()` / `Role::firstOrCreate()` 등으로 런타임에 생성한 엔티티는 업데이트 시 `cleanupStalePluginEntries` 에 의해 "정적 정의에 없는 고아 레코드" 로 판정되어 삭제될 위험이 있습니다. 이를 방지하려면 동적 식별자 목록을 위 3개 훅에서 반환하세요 — 정적 정의 + 동적 식별자가 병합된 expected 목록을 기준으로 판정되어 보존됩니다. 상세는 [extension-update-system.md](extension-update-system.md) 참조. diff --git a/modules/_bundled/sirsoft-board/module.php b/modules/_bundled/sirsoft-board/module.php index b9b167b1..794dc1b1 100644 --- a/modules/_bundled/sirsoft-board/module.php +++ b/modules/_bundled/sirsoft-board/module.php @@ -848,6 +848,50 @@ class Module extends AbstractModule ]; } + /** + * 성능 계측 프로파일 정의 (`g7:bench`). + * + * 게시글 목록의 `columns` 는 `PostRepository::paginate()` 가 select 하는 컬럼 집합입니다 + * (본문 미리보기용 `SUBSTRING` 표현식은 지연 조인의 outer 에서만 적용되고 계측 대상인 + * 건너뛰기 비용과 무관하므로 제외). 게시글 목록은 화면이 게시판 하나를 조회하므로 필터 + * 없이 재면 인덱스 선택이 달라져 화면에서 일어나는 일과 다른 것을 재게 됩니다. + * + * @return array> 프로파일 키 → 정의 + */ + public function getBenchmarkProfiles(): array + { + return [ + 'board_posts' => [ + 'type' => 'list', + 'label' => '게시글 목록', + 'table' => 'board_posts', + 'columns' => [ + 'id', 'board_id', 'user_id', 'parent_id', 'category', + 'title', 'author_name', 'content_mode', + 'is_notice', 'is_secret', 'status', 'depth', + 'view_count', 'comments_count', 'replies_count', 'attachments_count', + 'trigger_type', 'ip_address', 'created_at', 'updated_at', 'deleted_at', + ], + 'order' => [['created_at', 'desc'], ['id', 'desc']], + 'filters' => ['board_id' => 1], + 'seed_overrides' => ['board_id' => 1, 'is_notice' => 0], + 'soft_delete' => true, + ], + // 댓글은 계측 프로파일을 두지 않는다. 페이지네이션되는 댓글 목록은 회원 본인 댓글 + // 목록뿐이고(CommentRepository), 그 쿼리에는 회원 스코프 · 삭제 게시글 제외 + // (whereExists 서브쿼리) · 비활성 게시판 제외가 무조건 붙는다. 선언형 필터로 + // 재현할 수 없는 술어라, 맨 테이블 스캔을 재면 어느 화면도 내지 않는 수치가 된다. + 'reports' => [ + 'type' => 'list', + 'label' => '신고 목록', + 'table' => 'boards_reports', + 'columns' => ['*'], + 'order' => [['created_at', 'desc'], ['id', 'desc']], + 'soft_delete' => true, + ], + ]; + } + /** * 새 댓글 알림 정의. */ diff --git a/modules/_bundled/sirsoft-ecommerce/module.php b/modules/_bundled/sirsoft-ecommerce/module.php index 39b125ad..37a4f155 100644 --- a/modules/_bundled/sirsoft-ecommerce/module.php +++ b/modules/_bundled/sirsoft-ecommerce/module.php @@ -6,9 +6,11 @@ use App\Extension\AbstractModule; use App\Models\IdentityMessageDefinition; use App\Seo\Concerns\LocalizesSeoValues; use Illuminate\Database\Seeder; +use Modules\Sirsoft\Ecommerce\Benchmark\OrderCreationBenchmark; use Modules\Sirsoft\Ecommerce\Database\Seeders\ClaimReasonSeeder; use Modules\Sirsoft\Ecommerce\Database\Seeders\SequenceSeeder; use Modules\Sirsoft\Ecommerce\Database\Seeders\ShippingCarrierSeeder; +use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum; use Modules\Sirsoft\Ecommerce\Http\Middleware\DetectDevice; use Modules\Sirsoft\Ecommerce\Http\Middleware\ResolveShippingCountry; use Modules\Sirsoft\Ecommerce\Http\Middleware\VerifyGuestOrderToken; @@ -40,6 +42,7 @@ use Modules\Sirsoft\Ecommerce\Listeners\UserCurrencyInfoListener; use Modules\Sirsoft\Ecommerce\Listeners\UserMileageCleanupListener; use Modules\Sirsoft\Ecommerce\Listeners\UserMileageInfoListener; use Modules\Sirsoft\Ecommerce\Listeners\UserShippingCountryInfoListener; +use Modules\Sirsoft\Ecommerce\Repositories\OrderRepository; class Module extends AbstractModule { @@ -1242,6 +1245,89 @@ class Module extends AbstractModule ]; } + /** + * 성능 계측 프로파일 정의 (`g7:bench`). + * + * 목록 프로파일의 `columns` 는 해당 목록이 실제로 select 하는 컬럼이어야 합니다. 주문 + * 목록은 Repository 가 상수로 들고 있으므로 그 상수를 그대로 참조합니다 — 여기에 컬럼을 + * 다시 적으면 Repository 변경 시 계측이 조용히 낡습니다. 나머지 목록의 `['*']` 는 + * "응답 계약상 전 컬럼을 노출해 프루닝 불가" 선언이며, 이때 비교축은 select * vs select id + * 입니다. + * + * @return array> 프로파일 키 → 정의 + */ + public function getBenchmarkProfiles(): array + { + return [ + 'orders' => [ + 'type' => 'list', + 'label' => '주문 목록', + 'table' => 'ecommerce_orders', + 'columns' => OrderRepository::LIST_COLUMNS, + 'order' => [['ordered_at', 'desc'], ['id', 'desc']], + // 관리자 주문 목록은 상태 미지정 시 임시 주문 상태를 제외한다 + // (OrderRepository::getListWithFilters). 이 술어를 빼고 재면 옵티마이저가 다른 + // 인덱스를 골라 화면에서 일어나는 일과 다른 것을 잰다. 값은 Enum SSoT 를 참조한다. + 'filters' => ['order_status' => ['not in', OrderStatusEnum::listHiddenValues()]], + 'soft_delete' => true, + ], + 'products' => [ + 'type' => 'list', + 'label' => '상품 목록', + 'table' => 'ecommerce_products', + 'columns' => ['*'], + 'order' => [['created_at', 'desc'], ['id', 'desc']], + 'soft_delete' => true, + ], + // 상품 문의는 소프트 삭제 컬럼이 없다 — 실제 스키마 기준 선언 + 'product_inquiries' => [ + 'type' => 'list', + 'label' => '상품 문의 목록', + 'table' => 'ecommerce_product_inquiries', + 'columns' => ['*'], + 'order' => [['created_at', 'desc'], ['id', 'desc']], + 'soft_delete' => false, + ], + 'product_reviews' => [ + 'type' => 'list', + 'label' => '상품 후기 목록', + 'table' => 'ecommerce_product_reviews', + 'columns' => ['*'], + 'order' => [['created_at', 'desc'], ['id', 'desc']], + 'soft_delete' => true, + ], + 'coupon_issues' => [ + 'type' => 'list', + 'label' => '쿠폰 발급 이력 목록', + 'table' => 'ecommerce_promotion_coupon_issues', + 'columns' => ['*'], + 'order' => [['issued_at', 'desc'], ['id', 'desc']], + 'soft_delete' => false, + ], + 'extra_fee_templates' => [ + 'type' => 'list', + 'label' => '추가 배송비 템플릿 목록', + 'table' => 'ecommerce_shipping_policy_extra_fee_templates', + 'columns' => ['*'], + 'order' => [['zipcode', 'asc'], ['id', 'asc']], + 'soft_delete' => false, + ], + 'orders_screen' => [ + 'type' => 'screen', + 'label' => '관리자 주문 목록 화면', + 'route' => 'api.modules.sirsoft-ecommerce.admin.orders.index', + 'query' => ['per_page' => 20], + 'permissions' => ['sirsoft-ecommerce.orders.read'], + ], + 'order_create' => [ + 'type' => 'write', + 'label' => '주문 생성 (임시 주문 → 주문 전환)', + 'prepare' => [OrderCreationBenchmark::class, 'prepare'], + 'callback' => [OrderCreationBenchmark::class, 'create'], + ], + ]; + } + /** * 무통장입금 입금 안내 알림 정의. * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Benchmark/OrderCreationBenchmark.php b/modules/_bundled/sirsoft-ecommerce/src/Benchmark/OrderCreationBenchmark.php new file mode 100644 index 00000000..86a3042d --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Benchmark/OrderCreationBenchmark.php @@ -0,0 +1,116 @@ +create(); + + $product = Product::factory()->create([ + 'stock_quantity' => 10000, + 'has_options' => false, + 'option_groups' => null, + // 기본 배송정책 해석에 맡긴다 — 특정 정책을 지목하면 그 정책의 비용만 재게 된다 + 'shipping_policy_id' => null, + ]); + + $option = ProductOption::factory()->forProduct($product)->create([ + 'stock_quantity' => 10000, + 'price_adjustment' => 0, + 'is_default' => true, + 'is_active' => true, + ]); + + // 구매 대상 제한 검증이 Auth::user() 의 역할을 읽으므로 인증 주체를 세운다. + // 콘솔에는 세션이 없어 login() 대신 setUser() 를 쓴다. + Auth::setUser($user); + + $tempOrder = $this->tempOrderService->createTempOrderFromDirectItems( + items: [[ + 'product_id' => $product->id, + 'product_option_id' => $option->id, + 'quantity' => 1, + ]], + userId: $user->id, + cartKey: null, + ); + + return [ + 'temp_order' => $tempOrder, + 'user' => $user, + // 프론트엔드가 보내는 결제예정금액에 해당 — 임시 주문의 최종 금액을 그대로 쓴다 + 'expected_total_amount' => (float) $tempOrder->getFinalAmount(), + ]; + } + + /** + * 계측 대상 — 임시 주문을 실제 주문으로 생성합니다. + * + * @param array{temp_order: TempOrder, user: User, expected_total_amount: float} $context prepare 산출물 + * @return Order 생성된 주문 + */ + public function create(array $context): Order + { + return $this->orderProcessingService->createFromTempOrder( + tempOrder: $context['temp_order'], + ordererInfo: [ + 'name' => '성능계측', + 'phone' => '01000000000', + 'email' => $context['user']->email, + ], + shippingInfo: [ + 'recipient_name' => '성능계측', + 'recipient_phone' => '01000000000', + 'country_code' => 'KR', + 'zipcode' => '06236', + 'address' => '서울특별시 강남구 테헤란로 1', + 'address_detail' => '1층', + ], + paymentMethod: 'card', + expectedTotalAmount: $context['expected_total_amount'], + ); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderRepository.php index a2aee2dc..999f6a7e 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderRepository.php @@ -38,7 +38,7 @@ class OrderRepository implements OrderRepositoryInterface * * @var array */ - private const LIST_COLUMNS = [ + public const LIST_COLUMNS = [ 'id', 'user_id', 'order_number', diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Benchmark/OrderCreationBenchmarkTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Benchmark/OrderCreationBenchmarkTest.php new file mode 100644 index 00000000..6a416c9b --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Benchmark/OrderCreationBenchmarkTest.php @@ -0,0 +1,161 @@ +getBenchmarkProfiles(); + + // 목록 축 — 주문 목록은 Repository 상수를 참조해 컬럼 중복 선언을 없앤다 + $this->assertArrayHasKey('orders', $declared); + $this->assertSame('list', $declared['orders']['type']); + $this->assertSame('ecommerce_orders', $declared['orders']['table']); + $this->assertContains('order_number', $declared['orders']['columns']); + $this->assertNotContains('memo', $declared['orders']['columns'], '목록에 쓰지 않는 넓은 컬럼은 빠져야 한다.'); + + // 관리자 주문 목록은 상태 미지정 시 임시 주문 상태를 NOT IN 으로 제외한다 + // (OrderRepository::getListWithFilters). 이 술어가 빠지면 다른 인덱스를 재게 된다. + $this->assertSame( + ['order_status' => ['not in', OrderStatusEnum::listHiddenValues()]], + $declared['orders']['filters'], + '주문 목록 프로파일은 화면 기본 필터를 Enum SSoT 기준으로 선언해야 한다.' + ); + + // 화면 축 — 라우트명으로 선언 (URI 프리픽스 조립 금지) + $this->assertSame('screen', $declared['orders_screen']['type']); + $this->assertSame( + 'api.modules.sirsoft-ecommerce.admin.orders.index', + $declared['orders_screen']['route'] + ); + $this->assertSame(['sirsoft-ecommerce.orders.read'], $declared['orders_screen']['permissions']); + + // 쓰기 축 — prepare(계측 제외) / callback(계측) 분리, 클로저 아닌 형식 + $this->assertSame('write', $declared['order_create']['type']); + $this->assertSame([OrderCreationBenchmark::class, 'prepare'], $declared['order_create']['prepare']); + $this->assertSame([OrderCreationBenchmark::class, 'create'], $declared['order_create']['callback']); + } + + /** + * 선언한 프로파일이 코어 레지스트리에 수집됩니다. + */ + #[Test] + public function 선언한_프로파일이_코어_레지스트리에_수집된다(): void + { + $profile = app(BenchmarkProfileRegistry::class)->find('sirsoft-ecommerce/order_create'); + + $this->assertSame(BenchmarkAxis::Write, $profile->axis); + $this->assertSame('module', $profile->sourceKind); + $this->assertSame('sirsoft-ecommerce', $profile->sourceIdentifier); + $this->assertTrue($profile->mutates(), '쓰기 축은 데이터를 변경하는 축이다.'); + } + + /** + * `prepare` 가 구매 가능한 상품·옵션과 임시 주문을 세웁니다. + * + * 임시 주문을 팩토리로 조립하지 않고 실제 서비스로 만드는 이유는, 주문 생성이 저장된 + * 계산 파라미터로 금액을 재계산해 저장값과 대조하기 때문입니다 (손으로 만든 계산 결과는 + * 어긋나 차단되고, 어긋나지 않게 맞추면 실제 결제 경로가 아닌 다른 것을 재게 됩니다). + */ + #[Test] + public function prepare_가_임시_주문을_세운다(): void + { + $context = app(OrderCreationBenchmark::class)->prepare(1); + + $this->assertInstanceOf(TempOrder::class, $context['temp_order']); + $this->assertGreaterThan(0, $context['expected_total_amount'], '결제예정금액이 산출되어야 한다.'); + $this->assertSame( + $context['user']->id, + $context['temp_order']->user_id, + '임시 주문이 준비한 회원에 귀속되어야 한다.' + ); + $this->assertSame( + (float) $context['temp_order']->getFinalAmount(), + $context['expected_total_amount'], + '결제예정금액은 임시 주문의 최종 금액과 같아야 한다 — 다르면 금액 검증에 막힌다.' + ); + } + + /** + * 계측 대상 콜백이 실제 주문을 생성합니다. + */ + #[Test] + public function callback_이_실제_주문을_생성한다(): void + { + $benchmark = app(OrderCreationBenchmark::class); + + $ordersBefore = Order::count(); + $order = $benchmark->create($benchmark->prepare(1)); + + $this->assertInstanceOf(Order::class, $order); + $this->assertSame($ordersBefore + 1, Order::count()); + $this->assertNotEmpty($order->order_number); + } + + /** + * 쓰기 축 실행기가 이 대상을 실행하고 결과를 산출합니다. (수치 단언 없음) + * + * 계측이 만든 행은 실행기가 감싼 트랜잭션 롤백으로 되돌아가므로 주문 건수가 늘지 않습니다. + */ + #[Test] + public function 쓰기_축_실행이_결과를_산출하고_흔적을_남기지_않는다(): void + { + $ordersBefore = Order::count(); + + $result = app(WriteAxisRunner::class)->run( + app(BenchmarkProfileRegistry::class)->find('sirsoft-ecommerce/order_create'), + new BenchmarkRunOptions(runs: 1, allowWrite: true), + ); + + $this->assertFalse($result->skipped, '건너뜀 사유: '.(string) $result->skipReason); + $this->assertSame(['첫 회(ms)', '중앙값(ms)', '회차', '쿼리(건)', 'DB(ms)'], $result->headers); + $this->assertGreaterThan(0, $result->metrics['query_count'], '주문 생성은 쿼리를 실행한다.'); + + $this->assertSame($ordersBefore, Order::count(), '계측으로 생긴 주문은 롤백되어야 한다.'); + } + + /** + * `--allow-write` 없이는 이 대상이 실행되지 않습니다. + */ + #[Test] + public function allow_write_없이는_실행되지_않는다(): void + { + $ordersBefore = Order::count(); + + $result = app(WriteAxisRunner::class)->run( + app(BenchmarkProfileRegistry::class)->find('sirsoft-ecommerce/order_create'), + new BenchmarkRunOptions(runs: 1), + ); + + $this->assertTrue($result->skipped); + $this->assertStringContainsString('--allow-write', (string) $result->skipReason); + $this->assertSame($ordersBefore, Order::count()); + } +} diff --git a/modules/_bundled/sirsoft-page/module.php b/modules/_bundled/sirsoft-page/module.php index 311148d8..db9a2968 100644 --- a/modules/_bundled/sirsoft-page/module.php +++ b/modules/_bundled/sirsoft-page/module.php @@ -168,4 +168,35 @@ class Module extends AbstractModule ], ]; } + + /** + * 성능 계측 프로파일 정의 (`g7:bench`). + * + * 페이지 목록은 응답 계약상 본문까지 그대로 노출하므로 컬럼 프루닝이 불가합니다 + * (`['*']`). 이 경우 계측의 비교축은 select * vs select id 이며, 그 배수가 지연 조인 + * 적용의 기대 효과입니다. + * + * @return array> 프로파일 키 → 정의 + */ + public function getBenchmarkProfiles(): array + { + return [ + 'pages' => [ + 'type' => 'list', + 'label' => '페이지 목록', + 'table' => 'pages', + 'columns' => ['*'], + 'order' => [['created_at', 'desc'], ['id', 'desc']], + // 페이지는 소프트 삭제 컬럼이 없다 — 실제 스키마 기준 선언 + 'soft_delete' => false, + ], + 'pages_screen' => [ + 'type' => 'screen', + 'label' => '관리자 페이지 목록 화면', + 'route' => 'api.modules.sirsoft-page.admin.pages.index', + 'query' => ['per_page' => 20], + 'permissions' => ['sirsoft-page.pages.read'], + ], + ]; + } } diff --git a/tests/Feature/Console/BenchCommandTest.php b/tests/Feature/Console/BenchCommandTest.php new file mode 100644 index 00000000..d1b5d4af --- /dev/null +++ b/tests/Feature/Console/BenchCommandTest.php @@ -0,0 +1,725 @@ +> $profiles 치환할 코어 프로파일 선언 + */ + private function withCoreProfiles(array $profiles): void + { + Config::set('benchmark.profiles', $profiles); + + // 레지스트리는 수집 결과를 인스턴스에 캐시하므로 새 인스턴스를 강제한다 + $this->bindExtensionManagers([], []); + } + + /** + * 코어 config 선언이 프로파일 목록에 노출됩니다. + * + * @effects core_config_profiles_are_collected + */ + #[Test] + public function 코어_config_선언이_프로파일_목록에_노출된다(): void + { + $this->withCoreProfiles([ + 'bench_users' => [ + 'type' => 'list', + 'label' => '테스트 회원 목록', + 'table' => 'users', + 'columns' => ['id'], + 'order' => [['id', 'desc']], + ], + ]); + + $this->artisan('g7:bench', ['--list-profiles' => true]) + ->expectsOutputToContain('core/bench_users') + ->assertExitCode(0); + } + + /** + * 확장이 선언한 프로파일도 같은 목록에 함께 수집됩니다. + * + * 확장 선언 지점(`getBenchmarkProfiles()`)이 실제로 수집 경로에 연결돼 있는지를 + * 확인합니다. 특정 번들 확장에 의존하지 않도록 가짜 모듈을 매니저에 주입합니다. + * + * @effects module_declared_profiles_are_collected, profile_source_kind_and_identifier_are_recorded + */ + #[Test] + public function 확장_선언_프로파일이_수집된다(): void + { + $this->withCoreProfiles([]); + $this->fakeModuleWithProfiles([ + 'fake_list' => [ + 'type' => 'list', + 'label' => '가짜 확장 목록', + 'table' => 'users', + 'columns' => ['id'], + ], + ]); + + $profiles = app(BenchmarkProfileRegistry::class)->all(); + + $this->assertArrayHasKey('vendor-fake/fake_list', $profiles); + $this->assertSame('module', $profiles['vendor-fake/fake_list']->sourceKind); + $this->assertSame(BenchmarkAxis::ListQuery, $profiles['vendor-fake/fake_list']->axis); + } + + /** + * 플러그인이 선언한 프로파일도 함께 수집됩니다. + * + * @effects plugin_declared_profiles_are_collected + */ + #[Test] + public function 플러그인_선언_프로파일이_수집된다(): void + { + $this->withCoreProfiles([]); + $this->fakeExtensionsWithProfiles([], [ + 'fake_plugin_list' => ['type' => 'list', 'table' => 'users', 'columns' => ['id']], + ]); + + $profiles = app(BenchmarkProfileRegistry::class)->all(); + + $this->assertArrayHasKey('vendor-fake-plugin/fake_plugin_list', $profiles); + $this->assertSame('plugin', $profiles['vendor-fake-plugin/fake_plugin_list']->sourceKind); + } + + /** + * 확장 하나의 선언 실패가 전체 목록을 날리지 않습니다. + * + * 확장 하나가 던진 예외로 목록이 비면 계측 자체를 못 하게 되므로, 실패한 확장만 사유와 + * 함께 제외하고 나머지는 살립니다. + * + * @effects extension_declaration_failure_is_isolated_to_that_extension + */ + #[Test] + public function 확장_선언_실패는_그_확장에만_국한된다(): void + { + $this->withCoreProfiles([ + 'survivor' => ['type' => 'list', 'table' => 'users', 'columns' => ['id']], + ]); + + $broken = new class + { + /** + * @return string 모듈 식별자 + */ + public function getIdentifier(): string + { + return 'vendor-broken'; + } + + /** + * @return array> 선언 (항상 실패) + */ + public function getBenchmarkProfiles(): array + { + throw new \RuntimeException('declaration blew up'); + } + }; + + $this->bindExtensionManagers([$broken], []); + + $registry = app(BenchmarkProfileRegistry::class); + + // 코어 선언은 살아남는다 + $this->assertArrayHasKey('core/survivor', $registry->all()); + // 실패한 확장은 사유와 함께 드러난다 + $this->assertStringContainsString('vendor-broken', implode("\n", $registry->warnings())); + } + + /** + * 잘못된 선언은 조용히 버려지지 않고 사유와 함께 경고로 드러납니다. + * + * @effects missing_type_declaration_is_rejected_with_reason, unknown_axis_type_is_rejected_with_allowed_values, missing_required_option_is_rejected_naming_the_option, rejected_declarations_appear_in_warnings_not_silently_dropped + */ + #[Test] + public function 잘못된_선언은_사유와_함께_경고로_드러난다(): void + { + $this->withCoreProfiles([ + 'no_type' => ['table' => 'users'], + 'unknown_axis' => ['type' => 'nonsense', 'table' => 'users'], + 'missing_required' => ['type' => 'screen'], + 'valid' => ['type' => 'list', 'table' => 'users', 'columns' => ['id']], + ]); + + $registry = app(BenchmarkProfileRegistry::class); + + // 정상 선언만 남는다 + $this->assertSame(['core/valid'], array_keys($registry->all())); + + $warnings = implode("\n", $registry->warnings()); + $this->assertStringContainsString('core/no_type', $warnings); + $this->assertStringContainsString('core/unknown_axis', $warnings); + $this->assertStringContainsString('core/missing_required', $warnings); + // screen 축은 route 또는 uri 중 하나가 필요하다는 사유가 드러나야 한다 + $this->assertStringContainsString('route|uri', $warnings); + } + + /** + * 짧은 키가 둘 이상의 출처에서 선언되면 후보를 제시하고 실행을 거부합니다. + * + * 임의로 하나를 고르면 어느 확장의 대상을 잰 것인지 알 수 없게 됩니다. + * + * @effects ambiguous_short_key_lists_candidates_and_refuses, qualified_key_resolves_exactly, unique_short_key_resolves + */ + #[Test] + public function 모호한_짧은_키는_후보를_제시하고_거부한다(): void + { + $this->withCoreProfiles([ + 'orders' => ['type' => 'list', 'table' => 'users', 'columns' => ['id']], + ]); + $this->fakeModuleWithProfiles([ + 'orders' => ['type' => 'list', 'table' => 'users', 'columns' => ['id']], + ]); + + $this->artisan('g7:bench', ['--profile' => ['orders']]) + ->expectsOutputToContain('모호') + ->assertExitCode(1); + + // 정규화 키로 지목하면 정상 해석된다 + $registry = app(BenchmarkProfileRegistry::class); + $profile = $registry->find('vendor-fake/orders'); + $this->assertInstanceOf(BenchmarkProfile::class, $profile); + $this->assertSame('vendor-fake', $profile->sourceIdentifier); + + // 겹치지 않는 짧은 키는 그대로 해석된다 (모호할 때만 거부) + $this->withCoreProfiles([ + 'only_here' => ['type' => 'list', 'table' => 'users', 'columns' => ['id']], + ]); + + [$unique, $error] = app(BenchmarkProfileRegistry::class)->resolve('only_here'); + $this->assertNull($error); + $this->assertSame('core/only_here', $unique->qualifiedKey()); + } + + /** + * 미등록 프로파일 키는 실패로 끝납니다. + * + * @effects unknown_key_fails_with_list_profiles_hint + */ + #[Test] + public function 미등록_프로파일_키는_실패한다(): void + { + $this->withCoreProfiles([]); + + $this->artisan('g7:bench', ['--profile' => ['nope']]) + ->expectsOutputToContain('등록되지 않은 프로파일') + ->assertExitCode(1); + } + + /** + * 대상 지정이 없으면 무엇을 잴지 알 수 없으므로 실패합니다. + * + * @effects no_selection_fails_asking_for_profile_or_axis_or_all + */ + #[Test] + public function 대상_미지정_실행은_실패한다(): void + { + $this->withCoreProfiles([ + 'bench_users' => ['type' => 'list', 'table' => 'users', 'columns' => ['id']], + ]); + + $this->artisan('g7:bench') + ->expectsOutputToContain('--profile / --axis / --all') + ->assertExitCode(1); + } + + /** + * 목록 축은 컬럼 폭 3축 표를 산출합니다. (수치는 단언하지 않음) + * + * @effects list_axis_reports_all_list_and_id_only_columns + */ + #[Test] + public function 목록_축은_컬럼_폭_3축_표를_산출한다(): void + { + $this->withCoreProfiles([ + 'bench_users' => [ + 'type' => 'list', + 'table' => 'users', + 'columns' => ['id', 'email'], + 'order' => [['id', 'desc']], + 'soft_delete' => true, + ], + ]); + + $exitCode = Artisan::call('g7:bench', [ + '--profile' => ['core/bench_users'], + '--offsets' => '0', + '--runs' => 1, + ]); + + $this->assertSame(0, $exitCode); + + $output = Artisan::output(); + + // 세 축이 함께 나와야 배수(전체÷ID)의 근거가 성립한다 + $this->assertStringContainsString('전체 컬럼(ms)', $output); + $this->assertStringContainsString('목록 컬럼(ms)', $output); + $this->assertStringContainsString('ID만(ms)', $output); + $this->assertStringContainsString('전체÷ID', $output); + } + + /** + * 없는 테이블을 가리키는 목록 프로파일은 사유와 함께 건너뛰어집니다. + * + * 확장이 제거된 설치본에서 계측이 죽지 않아야 하고, 동시에 "측정했다"로 읽혀서도 + * 안 되므로 사유가 남고 종료 코드는 실패입니다. + * + * @effects skipped_profiles_remain_in_output_with_reason + */ + #[Test] + public function 없는_테이블_프로파일은_사유와_함께_건너뛴다(): void + { + $this->withCoreProfiles([ + 'ghost' => ['type' => 'list', 'table' => 'table_that_does_not_exist', 'columns' => ['id']], + ]); + + $this->artisan('g7:bench', ['--profile' => ['core/ghost'], '--offsets' => '0', '--runs' => 1]) + ->expectsOutputToContain('테이블이 없습니다') + ->assertExitCode(1); + } + + /** + * 쓰기 축은 `--allow-write` 없이 실행을 거부합니다. + * + * @effects mutating_axes_refuse_without_allow_write + */ + #[Test] + public function 쓰기_축은_allow_write_없이_거부된다(): void + { + $this->withCoreProfiles([ + 'bench_write' => [ + 'type' => 'write', + 'callback' => [BenchCommandWriteSubject::class, 'run'], + ], + ]); + + $this->artisan('g7:bench', ['--profile' => ['core/bench_write']]) + ->expectsOutputToContain('--allow-write') + ->assertExitCode(1); + } + + /** + * 데이터를 변경하는 배치도 `--allow-write` 없이 거부됩니다. + * + * @effects mutating_axes_refuse_without_allow_write + */ + #[Test] + public function 변경하는_배치는_allow_write_없이_거부된다(): void + { + $this->withCoreProfiles([ + 'bench_batch' => ['type' => 'batch', 'command' => 'seo:clear'], + ]); + + $this->artisan('g7:bench', ['--profile' => ['core/bench_batch']]) + ->expectsOutputToContain('--allow-write') + ->assertExitCode(1); + } + + /** + * 쓰기 축 콜백에 클로저를 쓸 수 없다는 사유가 드러납니다. + * + * 코어 선언은 `config:cache` 대상이라 클로저를 담을 수 없으므로 형식을 통일합니다. + * + * @effects closure_callback_is_rejected_with_config_cache_reason + */ + #[Test] + public function 쓰기_축_클로저_콜백은_사유와_함께_거부된다(): void + { + $this->withCoreProfiles([ + 'bench_closure' => [ + 'type' => 'write', + 'callback' => fn () => null, + ], + ]); + + $this->artisan('g7:bench', [ + '--profile' => ['core/bench_closure'], + '--allow-write' => true, + ]) + ->expectsOutputToContain('클로저는 쓸 수 없습니다') + ->assertExitCode(1); + } + + /** + * 알 수 없는 축 이름은 허용 목록과 함께 거부됩니다. + */ + #[Test] + public function 알_수_없는_축_이름은_거부된다(): void + { + $this->withCoreProfiles([]); + + $this->artisan('g7:bench', ['--axis' => 'nonsense']) + ->expectsOutputToContain('알 수 없는 축') + ->assertExitCode(1); + } + + /** + * `--json` 출력이 약속된 최상위 키와 결과 키를 담습니다. + * + * @effects json_output_carries_environment_options_warnings_results + */ + #[Test] + public function json_출력이_약속된_스키마를_담는다(): void + { + $this->withCoreProfiles([ + 'bench_users' => [ + 'type' => 'list', + 'label' => '테스트 회원 목록', + 'table' => 'users', + 'columns' => ['id'], + 'order' => [['id', 'desc']], + ], + ]); + + $exitCode = Artisan::call('g7:bench', [ + '--profile' => ['core/bench_users'], + '--offsets' => '0', + '--runs' => 1, + '--json' => true, + ]); + + $this->assertSame(0, $exitCode); + + $payload = json_decode(Artisan::output(), true); + + $this->assertIsArray($payload, 'JSON 출력이 파싱 가능해야 한다.'); + foreach (['generated_at', 'environment', 'options', 'warnings', 'results'] as $key) { + $this->assertArrayHasKey($key, $payload); + } + + $result = $payload['results'][0]; + foreach (['profile', 'axis', 'label', 'source', 'skipped', 'headers', 'rows', 'metrics', 'notes'] as $key) { + $this->assertArrayHasKey($key, $result); + } + $this->assertSame('core/bench_users', $result['profile']); + $this->assertSame('list', $result['axis']); + $this->assertFalse($result['skipped']); + } + + /** + * `--report` 는 환경 정보와 축 절을 담은 마크다운을 지정 경로에 씁니다. + * + * @effects report_includes_environment_and_run_conditions, report_writes_to_given_path + */ + #[Test] + public function report_는_환경_정보를_담은_마크다운을_쓴다(): void + { + $this->withCoreProfiles([ + 'bench_users' => [ + 'type' => 'list', + 'label' => '테스트 회원 목록', + 'table' => 'users', + 'columns' => ['id'], + 'order' => [['id', 'desc']], + ], + ]); + + $path = storage_path('app/benchmarks/test-'.uniqid().'.md'); + + $this->artisan('g7:bench', [ + '--profile' => ['core/bench_users'], + '--offsets' => '0', + '--runs' => 1, + '--report' => $path, + ])->assertExitCode(0); + + $this->assertFileExists($path); + + $markdown = (string) file_get_contents($path); + + // 환경 정보가 빠진 리포트는 다른 리포트와 비교할 수 없으므로 필수 항목이다 + $this->assertStringContainsString('## 실행 환경', $markdown); + $this->assertStringContainsString('DB 버전', $markdown); + $this->assertStringContainsString('config:cache', $markdown); + $this->assertStringContainsString('## 실행 조건', $markdown); + $this->assertStringContainsString('## 목록 조회 (list)', $markdown); + $this->assertStringContainsString('core/bench_users — 테스트 회원 목록', $markdown); + + @unlink($path); + } + + /** + * 운영 환경에서는 시딩/비움이 거부됩니다. + * + * @effects seeding_and_truncation_are_refused_in_production + */ + #[Test] + public function 운영_환경에서는_시딩이_거부된다(): void + { + $this->withCoreProfiles([ + 'bench_users' => ['type' => 'list', 'table' => 'users', 'columns' => ['id']], + ]); + + // 계측 대상 테이블을 실제로 건드리기 전에 거부되는지를 본다 + $this->app->detectEnvironment(fn () => 'production'); + + try { + $this->artisan('g7:bench', [ + '--profile' => ['core/bench_users'], + '--offsets' => '0', + '--runs' => 1, + '--seed' => 10, + ]) + ->expectsOutputToContain('운영 환경에서는 시딩/비움을 사용할 수 없습니다') + ->assertExitCode(1); + } finally { + $this->app->detectEnvironment(fn () => 'testing'); + } + } + + /** + * `--report` 를 값 없이 주면 저장소 미추적 기본 경로에 리포트를 씁니다. + * + * @effects report_defaults_to_untracked_storage_path + */ + #[Test] + public function report_는_값_없이_주면_기본_경로에_쓴다(): void + { + $this->withCoreProfiles([ + 'bench_users' => ['type' => 'list', 'table' => 'users', 'columns' => ['id']], + ]); + + $directory = storage_path('app/benchmarks'); + $before = is_dir($directory) ? glob($directory.'/bench-*.md') : []; + + $exitCode = Artisan::call('g7:bench', [ + '--profile' => ['core/bench_users'], + '--offsets' => '0', + '--runs' => 1, + '--report' => null, + ]); + + $this->assertSame(0, $exitCode); + + $after = glob($directory.'/bench-*.md'); + $created = array_values(array_diff($after ?: [], $before ?: [])); + + $this->assertCount(1, $created, '기본 경로(storage/app/benchmarks)에 리포트가 생성되어야 한다.'); + $this->assertStringContainsString('## 실행 환경', (string) file_get_contents($created[0])); + + @unlink($created[0]); + } + + /** + * `--database` 는 계측에 사용할 연결을 다시 지목합니다. + * + * config 가 캐시된 환경에서 연결을 바꿀 유일한 수단이라, 이 치환이 동작하지 않으면 + * 대량 시딩이 개발 DB 로 들어갑니다. + * + * @effects database_override_option_repoints_the_connection + */ + #[Test] + public function database_옵션이_연결을_다시_지목한다(): void + { + $this->withCoreProfiles([ + 'bench_users' => ['type' => 'list', 'table' => 'users', 'columns' => ['id']], + ]); + + $connection = (string) config('database.default'); + // 실제 접속이 끊기지 않도록 현재 스키마명으로 재지목한다 (치환 경로 자체를 검증) + $current = (string) (config("database.connections.{$connection}.database") + ?: config("database.connections.{$connection}.write.database")); + + Artisan::call('g7:bench', [ + '--profile' => ['core/bench_users'], + '--offsets' => '0', + '--runs' => 1, + '--database' => $current, + ]); + + // 최상위와 읽기/쓰기 하위 모두 지목되어야 한다 — 하나라도 남으면 그 경로만 다른 DB 를 본다 + $this->assertSame($current, config("database.connections.{$connection}.database")); + $this->assertSame($current, config("database.connections.{$connection}.write.database")); + $this->assertSame($current, config("database.connections.{$connection}.read.database")); + } + + /** + * 전부 건너뛴 실행은 실패로 끝납니다. (성공으로 보고하면 "측정했다"로 읽힘) + * + * @effects all_skipped_run_exits_nonzero, skipped_profiles_remain_in_output_with_reason + */ + #[Test] + public function 전부_건너뛴_실행은_실패로_끝난다(): void + { + $this->withCoreProfiles([ + 'ghost_a' => ['type' => 'list', 'table' => 'missing_table_a', 'columns' => ['id']], + 'ghost_b' => ['type' => 'list', 'table' => 'missing_table_b', 'columns' => ['id']], + ]); + + $exitCode = Artisan::call('g7:bench', ['--axis' => 'list', '--offsets' => '0', '--runs' => 1]); + + $this->assertSame(1, $exitCode); + + $output = Artisan::output(); + + // 건너뛴 프로파일이 목록에서 빠지지 않고 사유와 함께 남는다 + $this->assertStringContainsString('core/ghost_a', $output); + $this->assertStringContainsString('core/ghost_b', $output); + $this->assertStringContainsString('테이블이 없습니다', $output); + } + + /** + * 축 필터가 해당 축의 프로파일만 고릅니다. + * + * @effects axis_filter_selects_only_that_axis + */ + #[Test] + public function 축_필터는_해당_축만_고른다(): void + { + $this->withCoreProfiles([ + 'bench_users' => ['type' => 'list', 'table' => 'users', 'columns' => ['id']], + 'bench_screen' => ['type' => 'screen', 'route' => 'api.admin.users.index'], + 'bench_batch' => ['type' => 'batch', 'command' => 'seo:clear'], + ]); + + $registry = app(BenchmarkProfileRegistry::class); + + $this->assertSame(['core/bench_users'], array_keys($registry->byAxis(BenchmarkAxis::ListQuery))); + $this->assertSame(['core/bench_screen'], array_keys($registry->byAxis(BenchmarkAxis::Screen))); + $this->assertSame(['core/bench_batch'], array_keys($registry->byAxis(BenchmarkAxis::Batch))); + $this->assertSame([], array_keys($registry->byAxis(BenchmarkAxis::Write))); + } + + /** + * GET 화면은 변경으로 보지 않고, 비-GET 화면은 변경으로 봅니다. + */ + #[Test] + public function screen_축의_변경_판정은_http_메서드를_따른다(): void + { + $this->withCoreProfiles([ + 'get_screen' => ['type' => 'screen', 'route' => 'api.admin.users.index'], + 'post_screen' => ['type' => 'screen', 'route' => 'api.admin.users.store', 'method' => 'POST'], + 'declared_readonly' => ['type' => 'screen', 'route' => 'api.admin.users.store', 'method' => 'POST', 'mutating' => false], + ]); + + $registry = app(BenchmarkProfileRegistry::class); + + $this->assertFalse($registry->find('core/get_screen')->mutates()); + $this->assertTrue($registry->find('core/post_screen')->mutates()); + // 선언이 축 기본값을 덮어쓴다 + $this->assertFalse($registry->find('core/declared_readonly')->mutates()); + } + + /** + * 프로파일을 선언한 가짜 모듈을 모듈 매니저에 주입합니다. + * + * 특정 번들 확장 설치 여부에 의존하지 않고 "확장 선언이 수집된다"는 성질만 검증합니다. + * + * @param array> $profiles 가짜 모듈이 선언할 프로파일 + */ + private function fakeModuleWithProfiles(array $profiles): void + { + $this->fakeExtensionsWithProfiles($profiles, []); + } + + /** + * 프로파일을 선언한 가짜 모듈/플러그인을 각 매니저에 주입합니다. + * + * @param array> $modileProfiles 가짜 모듈이 선언할 프로파일 + * @param array> $pluginProfiles 가짜 플러그인이 선언할 프로파일 + */ + private function fakeExtensionsWithProfiles(array $modileProfiles, array $pluginProfiles): void + { + $this->bindExtensionManagers( + $modileProfiles === [] ? [] : [$this->fakeExtension('vendor-fake', $modileProfiles)], + $pluginProfiles === [] ? [] : [$this->fakeExtension('vendor-fake-plugin', $pluginProfiles)], + ); + } + + /** + * 프로파일만 선언하는 가짜 확장 인스턴스를 만듭니다. + * + * @param string $identifier 확장 식별자 + * @param array> $profiles 선언할 프로파일 + * @return object 가짜 확장 + */ + private function fakeExtension(string $identifier, array $profiles): object + { + return new class($identifier, $profiles) + { + /** + * @param string $identifier 확장 식별자 + * @param array> $profiles 선언할 프로파일 + */ + public function __construct( + private readonly string $identifier, + private readonly array $profiles, + ) {} + + /** + * @return string 확장 식별자 + */ + public function getIdentifier(): string + { + return $this->identifier; + } + + /** + * @return array> 선언한 프로파일 + */ + public function getBenchmarkProfiles(): array + { + return $this->profiles; + } + }; + } + + /** + * 확장 매니저를 가짜로 바인딩하고 레지스트리 캐시를 비웁니다. + * + * @param array $modules 활성 모듈 목록 + * @param array $plugins 활성 플러그인 목록 + */ + private function bindExtensionManagers(array $modules, array $plugins): void + { + $moduleManager = \Mockery::mock(ModuleManagerInterface::class); + $moduleManager->shouldReceive('getActiveModules')->andReturn($modules); + + $pluginManager = \Mockery::mock(PluginManagerInterface::class); + $pluginManager->shouldReceive('getActivePlugins')->andReturn($plugins); + + $this->app->instance(ModuleManagerInterface::class, $moduleManager); + $this->app->instance(PluginManagerInterface::class, $pluginManager); + $this->app->forgetInstance(BenchmarkProfileRegistry::class); + } +} + +/** + * 쓰기 축 콜백 형식 검증용 대상 (실행되지 않음 — 가드에서 먼저 막힘). + */ +class BenchCommandWriteSubject +{ + /** + * 계측 대상 자리표시자. + */ + public function run(mixed $context = null): void {} +} diff --git a/tests/Unit/Benchmark/BenchmarkAxisRunnerTest.php b/tests/Unit/Benchmark/BenchmarkAxisRunnerTest.php new file mode 100644 index 00000000..3ae4037d --- /dev/null +++ b/tests/Unit/Benchmark/BenchmarkAxisRunnerTest.php @@ -0,0 +1,737 @@ + $options 축 고유 옵션 + * @return BenchmarkProfile 프로파일 + */ + private function profile(BenchmarkAxis $axis, array $options): BenchmarkProfile + { + return new BenchmarkProfile( + key: 'sample', + axis: $axis, + sourceKind: 'core', + sourceIdentifier: 'core', + options: $options, + ); + } + + /** + * 목록 축은 컬럼 폭 3축을 재고 배수를 함께 산출합니다. + * + * @effects list_axis_reports_all_list_and_id_only_columns + */ + #[Test] + public function 목록_축은_3축_결과와_배수를_산출한다(): void + { + $result = app(ListAxisRunner::class)->run( + $this->profile(BenchmarkAxis::ListQuery, [ + 'table' => 'users', + 'columns' => ['id', 'email'], + 'order' => [['id', 'desc']], + 'soft_delete' => true, + ]), + new BenchmarkRunOptions(offsets: [0], runs: 1), + ); + + $this->assertFalse($result->skipped); + $this->assertSame(['OFFSET', '전체 컬럼(ms)', '목록 컬럼(ms)', 'ID만(ms)', '전체÷ID'], $result->headers); + $this->assertCount(1, $result->rows); + $this->assertSame('users', $result->metrics['table']); + $this->assertSame(['id', 'email'], $result->metrics['columns']); + + $offset = $result->metrics['offsets'][0]; + foreach (['offset', 'all_ms', 'list_ms', 'id_only_ms', 'ratio'] as $key) { + $this->assertArrayHasKey($key, $offset); + } + } + + /** + * 배수는 키 컬럼 조회를 기준선으로 산출됩니다. + * + * 이 기준선이 지연 조인의 inner 쿼리에 해당하므로, 배수가 곧 적용 기대 효과입니다. + * + * @effects list_axis_ratio_is_derived_from_id_only_baseline + */ + #[Test] + public function 배수는_키_컬럼_기준선으로_산출된다(): void + { + $result = app(ListAxisRunner::class)->run( + $this->profile(BenchmarkAxis::ListQuery, ['table' => 'users', 'columns' => ['*']]), + new BenchmarkRunOptions(offsets: [0], runs: 1), + ); + + $offset = $result->metrics['offsets'][0]; + + $this->assertSame( + round($offset['all_ms'] / $offset['id_only_ms'], 1), + $offset['ratio'], + '배수는 전체 컬럼 ÷ 키 컬럼이어야 한다.' + ); + } + + /** + * 선언된 필터와 소프트 삭제가 실제 계측 쿼리에 반영됩니다. + * + * 필터 없이 재면 인덱스 선택이 달라져 화면에서 일어나는 일과 다른 것을 잽니다. + * + * @effects declared_filters_are_applied_to_measured_query, soft_delete_declaration_adds_deleted_at_predicate + */ + #[Test] + public function 선언된_필터와_소프트_삭제가_계측_쿼리에_반영된다(): void + { + // 확장 설치 여부에 의존하지 않도록 소프트 삭제 컬럼이 있는 코어 테이블로 검증한다. + // 컬럼이 없는 테이블에서 술어가 붙지 않는 성질은 다음 테스트가 다룬다. + $collected = app(QueryCollector::class)->collect(function () { + app(ListAxisRunner::class)->run( + $this->profile(BenchmarkAxis::ListQuery, [ + 'table' => 'attachments', + 'columns' => ['id'], + 'filters' => ['disk' => 'public'], + 'soft_delete' => true, + ]), + new BenchmarkRunOptions(offsets: [0], runs: 1), + ); + }); + + $sql = str_replace('`', '"', implode("\n", array_column($collected['queries'], 'sql'))); + + $this->assertStringContainsString('"deleted_at" is null', $sql); + $this->assertStringContainsString('"disk" = ?', $sql); + } + + /** + * 연산자 형태 필터가 계측 쿼리에 반영됩니다. + * + * 등가 비교만 지원하면 화면이 실제로 거는 필터를 선언할 수 없는 목록이 생깁니다 — + * 주문 목록은 상태 미지정 시 임시 주문 상태를 `NOT IN` 으로 제외합니다. + * + * @effects operator_filters_are_applied_to_measured_query + */ + #[Test] + public function 연산자_형태_필터가_계측_쿼리에_반영된다(): void + { + $collected = app(QueryCollector::class)->collect(function () { + app(ListAxisRunner::class)->run( + $this->profile(BenchmarkAxis::ListQuery, [ + 'table' => 'attachments', + 'columns' => ['id'], + 'filters' => [ + 'disk' => ['not in', ['public', 'local']], + 'id' => ['>=', 10], + ], + ]), + new BenchmarkRunOptions(offsets: [0], runs: 1), + ); + }); + + $sql = str_replace('`', '"', implode("\n", array_column($collected['queries'], 'sql'))); + + $this->assertStringContainsString('"disk" not in (?, ?)', $sql); + $this->assertStringContainsString('"id" >= ?', $sql); + } + + /** + * 배열 값을 등가 비교로 선언하면 IN 으로 오해석하지 않습니다. + * + * 형태(2원소 + 첫 원소가 문자열)로만 연산자 선언을 판정하므로, 값 자체가 배열인 등가 + * 비교와 구분됩니다. + * + * @effects equality_filter_is_not_misread_as_operator_form + */ + #[Test] + public function 세_원소_배열은_연산자_선언으로_보지_않는다(): void + { + $result = app(ListAxisRunner::class)->run( + $this->profile(BenchmarkAxis::ListQuery, [ + 'table' => 'attachments', + 'columns' => ['id'], + // 2원소가 아니라 연산자 선언 형태가 아니다 → 등가 비교로 처리(연산자 검증 통과) + 'filters' => ['disk' => ['a', 'b', 'c']], + ]), + new BenchmarkRunOptions(offsets: [0], runs: 1), + ); + + // 연산자 검증에 걸리지 않고 실행된다 + $this->assertFalse($result->skipped, '건너뜀 사유: '.(string) $result->skipReason); + } + + /** + * 알 수 없는 연산자는 측정 전에 사유와 함께 거부됩니다. + * + * 조용히 무시하면 그 필터가 빠진 채로 측정되어, 화면과 다른 것을 재면서도 정상 측정으로 + * 보고됩니다. + * + * @effects unknown_filter_operator_is_refused_before_measuring + */ + #[Test] + public function 알_수_없는_필터_연산자는_측정_전에_거부된다(): void + { + $result = app(ListAxisRunner::class)->run( + $this->profile(BenchmarkAxis::ListQuery, [ + 'table' => 'attachments', + 'columns' => ['id'], + 'filters' => ['disk' => ['betwixt', ['a', 'b']]], + ]), + new BenchmarkRunOptions(offsets: [0], runs: 1), + ); + + $this->assertTrue($result->skipped); + $this->assertStringContainsString('연산자를 알 수 없습니다', (string) $result->skipReason); + $this->assertStringContainsString('betwixt', (string) $result->skipReason); + } + + /** + * 소프트 삭제 컬럼이 없는 테이블에는 술어를 붙이지 않습니다. + * + * 선언을 그대로 믿고 붙이면 컬럼 없는 테이블에서 SQL 오류로 계측이 죽습니다. + * + * @effects soft_delete_declaration_adds_deleted_at_predicate + */ + #[Test] + public function 소프트_삭제_컬럼이_없으면_술어를_붙이지_않는다(): void + { + $collected = app(QueryCollector::class)->collect(function () { + app(ListAxisRunner::class)->run( + // users 에는 deleted_at 이 없다 (탈퇴는 status 로 표현) + $this->profile(BenchmarkAxis::ListQuery, [ + 'table' => 'users', + 'columns' => ['id'], + 'soft_delete' => true, + ]), + new BenchmarkRunOptions(offsets: [0], runs: 1), + ); + }); + + $sql = str_replace('`', '"', implode("\n", array_column($collected['queries'], 'sql'))); + + $this->assertStringNotContainsString('"deleted_at" is null', $sql); + } + + /** + * `--explain` 은 목록 컬럼과 키 컬럼 두 폭의 실행 계획을 모두 수집합니다. + * + * 키 컬럼 계획이 지연 조인 inner 의 계획이라, 인덱스 설계 근거는 두 계획의 대조입니다. + * + * @effects explain_option_collects_plans_for_both_column_widths + */ + #[Test] + public function explain_은_두_폭의_실행_계획을_수집한다(): void + { + $result = app(ListAxisRunner::class)->run( + $this->profile(BenchmarkAxis::ListQuery, ['table' => 'users', 'columns' => ['id', 'email']]), + new BenchmarkRunOptions(offsets: [0], runs: 1, explain: true), + ); + + $notes = implode("\n", $result->notes); + + $this->assertStringContainsString('EXPLAIN @ OFFSET 0 — 목록 컬럼', $notes); + $this->assertStringContainsString('EXPLAIN @ OFFSET 0 — ID 만 (지연 조인 inner)', $notes); + } + + /** + * `['*']` 선언은 실제 스키마 컬럼으로 펼쳐집니다. + * + * 응답 계약상 전 컬럼을 노출하는 목록의 비교축이 `select *` vs `select id` 임을 고정합니다. + * + * @effects wildcard_columns_expand_to_actual_schema_columns + */ + #[Test] + public function 와일드카드_컬럼은_실제_스키마로_펼쳐진다(): void + { + $result = app(ListAxisRunner::class)->run( + $this->profile(BenchmarkAxis::ListQuery, ['table' => 'users', 'columns' => ['*']]), + new BenchmarkRunOptions(offsets: [0], runs: 1), + ); + + $this->assertContains('id', $result->metrics['columns']); + $this->assertContains('email', $result->metrics['columns']); + $this->assertGreaterThan(2, count($result->metrics['columns'])); + } + + /** + * 스키마에 없는 선언 컬럼은 걸러집니다. + * + * 확장 버전에 따라 컬럼 구성이 달라도 계측이 죽지 않아야 합니다. + * + * @effects nonexistent_declared_columns_are_filtered_out + */ + #[Test] + public function 없는_컬럼은_걸러진다(): void + { + $result = app(ListAxisRunner::class)->run( + $this->profile(BenchmarkAxis::ListQuery, [ + 'table' => 'users', + 'columns' => ['id', 'column_that_does_not_exist'], + ]), + new BenchmarkRunOptions(offsets: [0], runs: 1), + ); + + $this->assertSame(['id'], $result->metrics['columns']); + } + + /** + * 없는 테이블은 사유와 함께 건너뛰어집니다. + */ + #[Test] + public function 없는_테이블은_사유와_함께_건너뛴다(): void + { + $result = app(ListAxisRunner::class)->run( + $this->profile(BenchmarkAxis::ListQuery, ['table' => 'nope_missing_table']), + new BenchmarkRunOptions(offsets: [0], runs: 1), + ); + + $this->assertTrue($result->skipped); + $this->assertStringContainsString('테이블이 없습니다', (string) $result->skipReason); + } + + /** + * 화면 축은 상태 코드·응답 시간·쿼리 건수를 산출하고 계측 흔적을 남기지 않습니다. + * + * @effects screen_axis_dispatches_through_http_kernel_with_middleware, screen_axis_reports_status_response_time_and_query_count, absence_of_n_plus_one_is_stated_explicitly, measurement_leaves_no_residual_rows_after_rollback, container_request_and_auth_guards_are_restored_after_each_dispatch, route_name_declaration_resolves_uri_without_prefix_assembly + */ + #[Test] + public function 화면_축은_응답과_쿼리_요약을_산출하고_흔적을_남기지_않는다(): void + { + $usersBefore = User::count(); + + $result = app(ScreenAxisRunner::class)->run( + $this->profile(BenchmarkAxis::Screen, [ + 'route' => 'api.admin.users.index', + 'query' => ['per_page' => 5], + 'permissions' => ['core.users.read'], + ]), + new BenchmarkRunOptions(runs: 1), + ); + + $this->assertFalse($result->skipped, '건너뜀 사유: '.(string) $result->skipReason); + $this->assertSame(['요청', '상태', '응답(ms)', '쿼리(건)', 'DB(ms)'], $result->headers); + $this->assertSame(200, $result->metrics['status']); + $this->assertGreaterThan(0, $result->metrics['query_count'], '화면 1장은 최소 1건 이상 쿼리를 실행한다.'); + $this->assertTrue($result->metrics['acting_user']['ephemeral'], '기본은 계측용 임시 계정이다.'); + + // N+1 후보가 없으면 없다고 명시해야 한다 — 침묵은 "확인 안 함"과 구분되지 않는다 + $notes = implode("\n", $result->notes); + $this->assertTrue( + str_contains($notes, 'N+1 후보'), + 'N+1 후보 유무가 결과에 명시되어야 한다.' + ); + + // 계측 계정 생성부터 요청 처리까지 롤백되므로 잔여 계정/역할/토큰이 없어야 한다 + $this->assertSame($usersBefore, User::count()); + $this->assertSame(0, DB::table('roles')->where('identifier', 'like', 'g7_bench_%')->count()); + $this->assertSame(0, DB::table('personal_access_tokens')->where('name', 'like', 'g7-bench-%')->count()); + + // 인증 가드가 복구되지 않으면 이어지는 계측이 앞 요청의 인증을 물려받는다 + $this->assertFalse(Auth::check(), '계측 후 인증 가드가 복구되어야 한다.'); + } + + /** + * 선언한 권한만 임시 계정에 부여됩니다. + * + * 전권 계정으로 재면 권한 검사 비용과 분기가 실제와 달라집니다. + * + * @effects ephemeral_admin_receives_only_declared_permissions + */ + #[Test] + public function 임시_계정은_선언한_권한만_받는다(): void + { + // 선언한 권한이 없으면 관리자 목록 화면의 권한 미들웨어를 통과하지 못해야 한다 + $result = app(ScreenAxisRunner::class)->run( + $this->profile(BenchmarkAxis::Screen, [ + 'route' => 'api.admin.users.index', + 'permissions' => [], + ]), + new BenchmarkRunOptions(runs: 1), + ); + + $this->assertFalse($result->skipped, '건너뜀 사유: '.(string) $result->skipReason); + $this->assertSame( + 403, + $result->metrics['status'], + '권한을 선언하지 않으면 권한 미들웨어에서 막혀야 한다 — 전권 계정이 아니라는 실증.' + ); + } + + /** + * `--as` 로 지정한 기존 계정으로 계측할 수 있습니다. + * + * @effects as_option_measures_with_the_named_existing_account + */ + #[Test] + public function as_옵션은_지정_계정으로_계측한다(): void + { + $user = User::factory()->create(); + + $result = app(ScreenAxisRunner::class)->run( + $this->profile(BenchmarkAxis::Screen, ['route' => 'api.admin.users.index']), + new BenchmarkRunOptions(runs: 1, asUser: $user->email), + ); + + $this->assertFalse($result->skipped, '건너뜀 사유: '.(string) $result->skipReason); + $this->assertFalse($result->metrics['acting_user']['ephemeral']); + $this->assertSame($user->email, $result->metrics['acting_user']['email']); + } + + /** + * 없는 계정을 지정하면 사유와 함께 건너뛰어집니다. + */ + #[Test] + public function 없는_지정_계정은_사유와_함께_건너뛴다(): void + { + $result = app(ScreenAxisRunner::class)->run( + $this->profile(BenchmarkAxis::Screen, ['route' => 'api.admin.users.index']), + new BenchmarkRunOptions(runs: 1, asUser: 'no-such-account@example.test'), + ); + + $this->assertTrue($result->skipped); + $this->assertStringContainsString('계정을 찾을 수 없습니다', (string) $result->skipReason); + } + + /** + * 등록되지 않은 라우트명은 404 를 재는 대신 즉시 건너뛰어집니다. + * + * @effects missing_route_name_fails_instead_of_measuring_a_404 + */ + #[Test] + public function 없는_라우트명은_사유와_함께_건너뛴다(): void + { + $result = app(ScreenAxisRunner::class)->run( + $this->profile(BenchmarkAxis::Screen, ['route' => 'api.route.that.does.not.exist']), + new BenchmarkRunOptions(runs: 1), + ); + + $this->assertTrue($result->skipped); + $this->assertStringContainsString('등록되지 않은 라우트명', (string) $result->skipReason); + } + + /** + * 쓰기 축의 `prepare` 는 계측 구간 밖에서 회차마다 실행되고 결과가 콜백에 전달됩니다. + * + * @effects prepare_runs_once_per_iteration, callback_receives_prepare_result, write_axis_reports_first_run_and_median_separately + */ + #[Test] + public function 쓰기_축은_prepare_를_회차마다_계측_밖에서_실행한다(): void + { + BenchmarkWriteSpy::reset(); + + $result = app(WriteAxisRunner::class)->run( + $this->profile(BenchmarkAxis::Write, [ + 'prepare' => [BenchmarkWriteSpy::class, 'prepare'], + 'callback' => [BenchmarkWriteSpy::class, 'create'], + 'cleanup' => [BenchmarkWriteSpy::class, 'cleanup'], + ]), + new BenchmarkRunOptions(runs: 3, allowWrite: true), + ); + + $this->assertFalse($result->skipped, '건너뜀 사유: '.(string) $result->skipReason); + $this->assertSame(3, BenchmarkWriteSpy::$prepareCalls, 'prepare 는 회차마다 실행된다.'); + $this->assertSame(3, BenchmarkWriteSpy::$createCalls); + $this->assertSame([1, 2, 3], BenchmarkWriteSpy::$receivedContexts, 'prepare 반환값이 콜백에 전달된다.'); + $this->assertSame(1, BenchmarkWriteSpy::$cleanupCalls, 'cleanup 은 계측 종료 후 1회 실행된다.'); + + $this->assertSame(['첫 회(ms)', '중앙값(ms)', '회차', '쿼리(건)', 'DB(ms)'], $result->headers); + $this->assertCount(3, $result->metrics['samples_ms']); + } + + /** + * `prepare` 실행은 계측 창 밖이라 쿼리 집계에 섞이지 않습니다. + * + * 시간이 아니라 쿼리 건수로 확인합니다 — 시간 기반 확인은 실행 환경에 따라 흔들립니다. + * + * @effects prepare_runs_outside_the_measured_window + */ + #[Test] + public function prepare_쿼리는_계측_집계에_섞이지_않는다(): void + { + BenchmarkWriteSpy::reset(); + BenchmarkWriteSpy::$prepareQueries = 3; + BenchmarkWriteSpy::$createQueries = 1; + + $result = app(WriteAxisRunner::class)->run( + $this->profile(BenchmarkAxis::Write, [ + 'prepare' => [BenchmarkWriteSpy::class, 'prepare'], + 'callback' => [BenchmarkWriteSpy::class, 'create'], + ]), + new BenchmarkRunOptions(runs: 1, allowWrite: true), + ); + + $this->assertFalse($result->skipped, '건너뜀 사유: '.(string) $result->skipReason); + $this->assertSame( + 1, + $result->metrics['query_count'], + 'prepare 가 실행한 3건은 집계에서 빠지고 계측 대상 1건만 남아야 한다.' + ); + } + + /** + * 쓰기 축은 `--allow-write` 없이 실행을 거부합니다. + */ + #[Test] + public function 쓰기_축은_allow_write_없이_거부한다(): void + { + BenchmarkWriteSpy::reset(); + + $result = app(WriteAxisRunner::class)->run( + $this->profile(BenchmarkAxis::Write, ['callback' => [BenchmarkWriteSpy::class, 'create']]), + new BenchmarkRunOptions(runs: 1), + ); + + $this->assertTrue($result->skipped); + $this->assertStringContainsString('--allow-write', (string) $result->skipReason); + $this->assertSame(0, BenchmarkWriteSpy::$createCalls, '거부되면 콜백이 실행되지 않아야 한다.'); + } + + /** + * `cleanup` 실패가 계측 결과를 삼키지 않습니다. + * + * @effects cleanup_failure_does_not_swallow_the_measurement + */ + #[Test] + public function cleanup_실패가_계측_결과를_삼키지_않는다(): void + { + BenchmarkWriteSpy::reset(); + BenchmarkWriteSpy::$cleanupThrows = true; + + $messages = []; + + $result = app(WriteAxisRunner::class)->run( + $this->profile(BenchmarkAxis::Write, [ + 'callback' => [BenchmarkWriteSpy::class, 'create'], + 'cleanup' => [BenchmarkWriteSpy::class, 'cleanup'], + ]), + new BenchmarkRunOptions(runs: 1, allowWrite: true), + function (string $message) use (&$messages) { + $messages[] = $message; + }, + ); + + $this->assertFalse($result->skipped, '건너뜀 사유: '.(string) $result->skipReason); + $this->assertStringContainsString('cleanup 실패', implode("\n", $messages)); + } + + /** + * 배치 축은 소요 시간과 피크 메모리(실행 전/후)를 산출합니다. + * + * @effects batch_axis_reports_elapsed_time_and_peak_memory, peak_memory_before_and_after_are_both_reported + */ + #[Test] + public function 배치_축은_시간과_피크_메모리를_산출한다(): void + { + $result = app(BatchAxisRunner::class)->run( + $this->profile(BenchmarkAxis::Batch, ['command' => 'seo:clear']), + new BenchmarkRunOptions(allowWrite: true), + ); + + $this->assertFalse($result->skipped, '건너뜀 사유: '.(string) $result->skipReason); + $this->assertSame(['커맨드', '종료코드', '소요(ms)', '피크 메모리', '메모리 증가', '쿼리(건)'], $result->headers); + $this->assertSame('seo:clear', $result->metrics['command']); + // 피크는 프로세스 누적값이라 실행 전 값이 함께 있어야 이 배치의 기여를 판정할 수 있다 + $this->assertArrayHasKey('peak_memory_before_bytes', $result->metrics); + $this->assertArrayHasKey('peak_memory_after_bytes', $result->metrics); + $this->assertGreaterThanOrEqual( + $result->metrics['peak_memory_before_bytes'], + $result->metrics['peak_memory_after_bytes'], + '피크 메모리는 감소하지 않는다.' + ); + } + + /** + * 실패 종료한 배치는 부분 측정임을 사유로 남깁니다. + * + * 실패를 감추면 "이 시간이 전체 처리 비용"으로 읽힙니다. + * + * @effects batch_axis_reports_nonzero_exit_code_as_partial_measurement + */ + #[Test] + public function 실패_종료_배치는_부분_측정임을_남긴다(): void + { + Artisan::command('bench:always-fails', fn () => 3)->describe('계측 테스트용 실패 커맨드'); + + $result = app(BatchAxisRunner::class)->run( + $this->profile(BenchmarkAxis::Batch, ['command' => 'bench:always-fails']), + new BenchmarkRunOptions(allowWrite: true), + ); + + $this->assertFalse($result->skipped, '건너뜀 사유: '.(string) $result->skipReason); + $this->assertSame(3, $result->metrics['exit_code']); + $this->assertStringContainsString('실패 종료', implode("\n", $result->notes)); + } + + /** + * 등록되지 않은 커맨드는 사유와 함께 건너뛰어집니다. + * + * @effects unregistered_command_is_skipped_with_reason + */ + #[Test] + public function 없는_커맨드는_사유와_함께_건너뛴다(): void + { + $result = app(BatchAxisRunner::class)->run( + $this->profile(BenchmarkAxis::Batch, ['command' => 'no:such-command']), + new BenchmarkRunOptions(allowWrite: true), + ); + + $this->assertTrue($result->skipped); + $this->assertStringContainsString('등록되지 않은 커맨드', (string) $result->skipReason); + } + + /** + * 쿼리 수집기는 계측 구간의 쿼리만 모으고 반복 SQL 을 N+1 후보로 보고합니다. + * + * @effects repeated_identical_sql_is_reported_as_n_plus_one_candidate + */ + #[Test] + public function 쿼리_수집기는_구간_밖_쿼리를_섞지_않는다(): void + { + $collector = app(QueryCollector::class); + + // 구간 밖 쿼리 + DB::table('users')->limit(1)->get(); + + $collected = $collector->collect(function () { + for ($i = 0; $i < 6; $i++) { + DB::table('users')->where('id', $i)->limit(1)->get(); + } + }); + + // 구간 밖 쿼리를 제외한 6건만 모인다 + $this->assertCount(6, $collected['queries']); + + $summary = $collector->summarize($collected['queries']); + $this->assertSame(6, $summary['count']); + // 같은 SQL 6회(임계 5회 이상) → N+1 후보로 보고 + $this->assertNotEmpty($summary['n_plus_one']); + $this->assertSame(6, $summary['n_plus_one'][0]['count']); + } + + /** + * 반복이 임계 미만이면 N+1 후보로 보고하지 않습니다. + */ + #[Test] + public function 임계_미만_반복은_후보로_보고하지_않는다(): void + { + $collector = app(QueryCollector::class); + + $collected = $collector->collect(function () { + for ($i = 0; $i < 3; $i++) { + DB::table('users')->where('id', $i)->limit(1)->get(); + } + }); + + $this->assertSame([], $collector->summarize($collected['queries'])['n_plus_one']); + } +} + +/** + * 쓰기 축 계측 대상 스파이 — prepare/callback/cleanup 호출 순서와 인자 전달을 관찰한다. + */ +class BenchmarkWriteSpy +{ + public static int $prepareCalls = 0; + + public static int $createCalls = 0; + + public static int $cleanupCalls = 0; + + /** + * @var array + */ + public static array $receivedContexts = []; + + public static bool $cleanupThrows = false; + + public static int $prepareQueries = 0; + + public static int $createQueries = 0; + + /** + * 관찰 상태를 초기화합니다. + */ + public static function reset(): void + { + self::$prepareCalls = 0; + self::$createCalls = 0; + self::$cleanupCalls = 0; + self::$receivedContexts = []; + self::$cleanupThrows = false; + self::$prepareQueries = 0; + self::$createQueries = 0; + } + + /** + * 선행 준비 (계측 구간 밖). + * + * @param int $run 회차 번호 + * @return int 회차 번호를 그대로 컨텍스트로 넘긴다 + */ + public function prepare(int $run): int + { + self::$prepareCalls++; + $this->runQueries(self::$prepareQueries); + + return $run; + } + + /** + * 계측 대상. + * + * @param mixed $context prepare 반환값 + */ + public function create(mixed $context = null): void + { + self::$createCalls++; + self::$receivedContexts[] = $context; + $this->runQueries(self::$createQueries); + } + + /** + * 관찰용 쿼리를 지정 횟수만큼 실행합니다. + * + * @param int $count 실행할 쿼리 수 + */ + private function runQueries(int $count): void + { + for ($i = 0; $i < $count; $i++) { + DB::table('users')->where('id', -($i + 1))->limit(1)->get(); + } + } + + /** + * 잔여물 정리. + */ + public function cleanup(): void + { + self::$cleanupCalls++; + + if (self::$cleanupThrows) { + throw new \RuntimeException('정리 실패 재현'); + } + } +} diff --git a/tests/Unit/Benchmark/BenchmarkProfileTest.php b/tests/Unit/Benchmark/BenchmarkProfileTest.php new file mode 100644 index 00000000..4cf5c559 --- /dev/null +++ b/tests/Unit/Benchmark/BenchmarkProfileTest.php @@ -0,0 +1,153 @@ + $options 축 고유 옵션 + * @param string $sourceIdentifier 출처 식별자 + * @return BenchmarkProfile 프로파일 + */ + private function profile(BenchmarkAxis $axis, array $options = [], string $sourceIdentifier = 'core'): BenchmarkProfile + { + return new BenchmarkProfile( + key: 'sample', + axis: $axis, + sourceKind: $sourceIdentifier === 'core' ? 'core' : 'module', + sourceIdentifier: $sourceIdentifier, + options: $options, + label: '샘플', + ); + } + + /** + * 정규화 키는 출처와 키를 합쳐 전역 고유해집니다. + */ + #[Test] + public function 정규화_키는_출처를_포함한다(): void + { + $this->assertSame('core/sample', $this->profile(BenchmarkAxis::ListQuery)->qualifiedKey()); + $this->assertSame( + 'vendor-mod/sample', + $this->profile(BenchmarkAxis::ListQuery, [], 'vendor-mod')->qualifiedKey() + ); + } + + /** + * 축 고유 옵션은 기본값과 함께 읽힙니다. + */ + #[Test] + public function 옵션은_기본값과_함께_읽힌다(): void + { + $profile = $this->profile(BenchmarkAxis::ListQuery, ['table' => 'users']); + + $this->assertSame('users', $profile->option('table')); + $this->assertSame(['id'], $profile->option('columns', ['id'])); + $this->assertNull($profile->option('absent')); + } + + /** + * 목록/화면 축은 기본적으로 데이터를 변경하지 않고, 쓰기/배치 축은 변경합니다. + */ + #[Test] + public function 축_기본_변경_판정(): void + { + $this->assertFalse($this->profile(BenchmarkAxis::ListQuery)->mutates()); + $this->assertFalse($this->profile(BenchmarkAxis::Screen)->mutates()); + $this->assertTrue($this->profile(BenchmarkAxis::Write)->mutates()); + $this->assertTrue($this->profile(BenchmarkAxis::Batch)->mutates()); + } + + /** + * 화면 축의 변경 판정은 선언된 HTTP 메서드를 따릅니다. + * + * @effects screen_mutation_verdict_follows_http_method + */ + #[Test] + public function 화면_축은_http_메서드로_변경을_판정한다(): void + { + $this->assertFalse($this->profile(BenchmarkAxis::Screen, ['method' => 'GET'])->mutates()); + // 대소문자와 무관하게 판정한다 + $this->assertFalse($this->profile(BenchmarkAxis::Screen, ['method' => 'get'])->mutates()); + $this->assertTrue($this->profile(BenchmarkAxis::Screen, ['method' => 'POST'])->mutates()); + $this->assertTrue($this->profile(BenchmarkAxis::Screen, ['method' => 'DELETE'])->mutates()); + } + + /** + * 명시 선언이 축 기본값과 메서드 판정을 모두 덮어씁니다. + * + * 읽기 전용 배치(리포트 산출 등)를 `--allow-write` 없이 재려면 이 경로가 필요합니다. + * + * @effects explicit_mutating_declaration_overrides_axis_default + */ + #[Test] + public function 명시_mutating_선언이_기본값을_덮는다(): void + { + $this->assertFalse($this->profile(BenchmarkAxis::Batch, ['mutating' => false])->mutates()); + $this->assertTrue($this->profile(BenchmarkAxis::ListQuery, ['mutating' => true])->mutates()); + $this->assertFalse( + $this->profile(BenchmarkAxis::Screen, ['method' => 'POST', 'mutating' => false])->mutates() + ); + } + + /** + * 직렬화 결과가 리포트/JSON 이 기대하는 키를 담습니다. + */ + #[Test] + public function 직렬화가_약속된_키를_담는다(): void + { + $payload = $this->profile(BenchmarkAxis::ListQuery, ['table' => 'users'])->toArray(); + + foreach (['key', 'qualified_key', 'axis', 'source', 'label', 'options'] as $key) { + $this->assertArrayHasKey($key, $payload); + } + + $this->assertSame('list', $payload['axis']); + $this->assertSame(['kind' => 'core', 'identifier' => 'core'], $payload['source']); + $this->assertSame(['table' => 'users'], $payload['options']); + } + + /** + * 축마다 필수 옵션이 대안 그룹으로 선언됩니다. + * + * 화면 축이 라우트명 또는 URI 중 하나만 있으면 되는 성질이 이 구조에 담깁니다. + * + * @effects screen_axis_accepts_route_or_uri_alternative + */ + #[Test] + public function 축별_필수_옵션은_대안_그룹으로_선언된다(): void + { + $this->assertSame([['table']], BenchmarkAxis::ListQuery->requiredOptions()); + $this->assertSame([['route', 'uri']], BenchmarkAxis::Screen->requiredOptions()); + $this->assertSame([['callback']], BenchmarkAxis::Write->requiredOptions()); + $this->assertSame([['command']], BenchmarkAxis::Batch->requiredOptions()); + } + + /** + * 축 값 목록과 라벨이 정의됩니다. + */ + #[Test] + public function 축_값과_라벨이_정의된다(): void + { + $this->assertSame(['list', 'screen', 'write', 'batch'], BenchmarkAxis::values()); + + foreach (BenchmarkAxis::cases() as $axis) { + $this->assertNotSame('', $axis->label()); + } + } +} diff --git a/tests/scenarios/benchmark-infra.yaml b/tests/scenarios/benchmark-infra.yaml new file mode 100644 index 00000000..4e95643f --- /dev/null +++ b/tests/scenarios/benchmark-infra.yaml @@ -0,0 +1,132 @@ +# audit:allow test-scenario-coverage reason: cross product 면제 전용. 10축 전개 61,200 조합은 docblock @scenario 1:1 매핑이 비현실적이라 면제하고, 회귀 가드는 test_files 4종(커맨드 표면 + 축 실행기 단위 + 프로파일 VO/Enum 계약 + 확장 선언 통합)으로 커버한다. effects 54건은 전수 @effects 마킹돼 있어 이 면제 줄을 지워도 effects 검사는 통과한다 — 면제가 숨기는 항목 없음. + +feature: benchmark_infra + +description: | + 성능 계측 인프라를 4축(목록 조회 / 화면 응답 / 쓰기 작업 / 배치 작업)으로 확장하고, 계측 + 대상 선언을 커맨드 밖으로 빼낸다. 계측 대상을 커맨드에 하드코딩하면 확장이 설치·제거되는 + 설치본마다 "실제로 존재하는 대상"과 어긋나므로, 소유자가 선언하고 코어가 수집한다. + + 핵심 설계: + - 선언 지점 두 곳, 스키마 하나: 코어 `config/benchmark.php`, 확장 `getBenchmarkProfiles()`. + - 잘못된 선언은 조용히 버리지 않는다 — 사유를 경고로 남기고 그 선언만 제외한다. 버려진 + 선언은 곧 계측 사각이 된다. + - 짧은 키가 여러 확장에서 겹치면 후보를 제시하고 거부한다. 임의로 하나를 고르면 어느 + 확장의 대상을 잰 것인지 알 수 없다. + - 화면 축은 라우트를 HTTP 커널로 내부 요청 처리한다. 라우트만 dispatch 하면 전역 + 미들웨어(인증·권한·로케일)를 건너뛰어 화면과 다른 것을 잰다. + - 화면 축 인증은 프로파일이 선언한 권한만 가진 임시 계정이 기본이며 `--as` 로 기존 계정을 + 지목할 수 있다. 전권 계정으로 재면 권한 검사 비용과 분기가 실제와 달라진다. + - 화면/쓰기 축은 계측 계정 생성부터 처리까지 롤백되는 트랜잭션 안에서 실행한다. 트랜잭션이 + 열려 있으면 읽기 쿼리도 write PDO 로 가므로 읽기/쓰기 분리 환경에서도 인증이 성립한다. + - 쓰기 축은 `prepare`(계측 제외) / `callback`(계측) / `cleanup` 세 단계다. 선행 상태 준비 + 비용이 측정값에 섞이면 재려던 것을 재지 못한다. + - 콜백은 `'Fqcn'` 또는 `['Fqcn','method']` 만 허용한다 — 코어 선언이 config:cache 대상이라 + 클로저를 담을 수 없으므로 확장 선언도 형식을 통일한다. + - 배치 축은 트랜잭션으로 감싸지 않는다. 배치는 내부에서 커밋/DDL 을 실행할 수 있고, 대량 + 처리를 긴 트랜잭션에 담으면 락 보유가 계측보다 위험하다. + - 데이터를 변경하는 축은 `--allow-write` 없이 거부한다. 화면 축의 변경 판정은 선언된 HTTP + 메서드를 따르며 `mutating` 선언이 그것을 덮어쓴다. + - 측정하지 못한 프로파일은 목록에서 빠지지 않고 사유와 함께 남으며, 전부 건너뛴 실행은 + 실패로 끝난다. 성공으로 보고하면 "측정했다"로 읽힌다. + - 리포트에는 환경 정보(DB/PHP 버전·OPcache·config:cache·실행 머신)를 반드시 함께 적는다. + 환경이 빠진 수치는 다른 리포트와 비교할 수 없다. 문서로 옮길 수치는 이 경로로만 산출한다. + +axes: + source: [core_config, module_declaration, plugin_declaration] + axis_type: [list, screen, write, batch] + selection: [profile_key, qualified_key, axis_filter, all, none] + declaration_validity: [valid, missing_type, unknown_type, missing_required_option, closure_callback] + key_collision: [unique, ambiguous_short_key] + target_presence: [present, table_missing, route_missing, command_missing] + write_permission: [allow_write_given, allow_write_omitted] + mutation_declaration: [axis_default, declared_mutating, declared_readonly] + environment: [local, production] + output: [table, json, report_default_path, report_given_path] + +exclusions: + - { axis_type: list, write_permission: allow_write_omitted, reason: "목록 축은 읽기 전용이라 --allow-write 판정 대상이 아니다 (시딩은 environment 축이 담당)" } + - { axis_type: batch, target_presence: table_missing, reason: "배치 축은 테이블을 직접 지목하지 않는다 — 부재 판정은 command_missing" } + - { axis_type: list, target_presence: route_missing, reason: "목록 축은 라우트를 쓰지 않는다" } + - { declaration_validity: closure_callback, axis_type: list, reason: "콜백 선언은 write 축만 갖는다" } + - { environment: production, axis_type: screen, reason: "운영 거부 가드는 시딩/비움(목록 축)에만 걸린다 — 화면 축은 롤백 트랜잭션으로 운영에서도 안전" } + - { key_collision: ambiguous_short_key, source: core_config, reason: "충돌은 서로 다른 출처가 같은 키를 선언할 때만 발생 — 한 출처 안에서는 배열 키가 이미 유일" } + +effects: + # ── 선언 수집 (P0) ── + - core_config_profiles_are_collected + - module_declared_profiles_are_collected + - plugin_declared_profiles_are_collected + - profile_source_kind_and_identifier_are_recorded + - extension_declaration_failure_is_isolated_to_that_extension + # ── 선언 검증 (P0) ── + - missing_type_declaration_is_rejected_with_reason + - unknown_axis_type_is_rejected_with_allowed_values + - missing_required_option_is_rejected_naming_the_option + - screen_axis_accepts_route_or_uri_alternative + - closure_callback_is_rejected_with_config_cache_reason + - rejected_declarations_appear_in_warnings_not_silently_dropped + # ── 대상 지목 (P0) ── + - qualified_key_resolves_exactly + - unique_short_key_resolves + - ambiguous_short_key_lists_candidates_and_refuses + - unknown_key_fails_with_list_profiles_hint + - axis_filter_selects_only_that_axis + - no_selection_fails_asking_for_profile_or_axis_or_all + # ── 목록 축 (P0) ── + - list_axis_reports_all_list_and_id_only_columns + - list_axis_ratio_is_derived_from_id_only_baseline + - wildcard_columns_expand_to_actual_schema_columns + - nonexistent_declared_columns_are_filtered_out + - declared_filters_are_applied_to_measured_query + - operator_filters_are_applied_to_measured_query + - equality_filter_is_not_misread_as_operator_form + - unknown_filter_operator_is_refused_before_measuring + - list_profile_filters_match_the_screen_default_predicate + - soft_delete_declaration_adds_deleted_at_predicate + - explain_option_collects_plans_for_both_column_widths + # ── 화면 축 (P0) ── + - screen_axis_dispatches_through_http_kernel_with_middleware + - screen_axis_reports_status_response_time_and_query_count + - repeated_identical_sql_is_reported_as_n_plus_one_candidate + - absence_of_n_plus_one_is_stated_explicitly + - ephemeral_admin_receives_only_declared_permissions + - as_option_measures_with_the_named_existing_account + - container_request_and_auth_guards_are_restored_after_each_dispatch + - route_name_declaration_resolves_uri_without_prefix_assembly + - missing_route_name_fails_instead_of_measuring_a_404 + # ── 쓰기 축 (P0) ── + - prepare_runs_outside_the_measured_window + - prepare_runs_once_per_iteration + - callback_receives_prepare_result + - write_axis_reports_first_run_and_median_separately + - cleanup_failure_does_not_swallow_the_measurement + # ── 배치 축 (P1) ── + - batch_axis_reports_elapsed_time_and_peak_memory + - peak_memory_before_and_after_are_both_reported + - batch_axis_reports_nonzero_exit_code_as_partial_measurement + - unregistered_command_is_skipped_with_reason + # ── 안전장치 (P0) ── + - mutating_axes_refuse_without_allow_write + - screen_mutation_verdict_follows_http_method + - explicit_mutating_declaration_overrides_axis_default + - seeding_and_truncation_are_refused_in_production + - measurement_leaves_no_residual_rows_after_rollback + - database_override_option_repoints_the_connection + # ── 출력 (P0) ── + - skipped_profiles_remain_in_output_with_reason + - all_skipped_run_exits_nonzero + - json_output_carries_environment_options_warnings_results + - report_includes_environment_and_run_conditions + - report_defaults_to_untracked_storage_path + - report_writes_to_given_path + +test_files: + # 코어 — 선언 수집 / 검증 / 대상 지목 / 축 분기 / 가드 / 출력 스키마 + - tests/Feature/Console/BenchCommandTest.php + # 코어 — 축 실행기 단위 (화면 내부 요청·쿼리 수집·쓰기 prepare 분리·배치 메모리) + - tests/Unit/Benchmark/BenchmarkAxisRunnerTest.php + # 코어 — 프로파일 VO / 축 Enum 계약 + - tests/Unit/Benchmark/BenchmarkProfileTest.php + # 이커머스 — 확장 선언 + 주문 생성 쓰기 축 대상 + - modules/_bundled/sirsoft-ecommerce/tests/Unit/Benchmark/OrderCreationBenchmarkTest.php