feat(core,board,ecommerce,page): 성능 계측 인프라 4축 확장 및 계측 대상 선언 지점 신설
계측 축이 "목록 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종 전건 측정.
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<!-- AUTO-GENERATED-START: docs-quick-reference -->
|
||||
|
||||
### 백엔드 [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`
|
||||
|
||||
@@ -51,6 +51,10 @@
|
||||
|
||||
- 목록 화면이 뒤쪽 페이지로 갈수록 느려지는 문제를 구조적으로 해결할 수 있도록, 확장이 함께 쓸 수 있는 공통 조회 방식을 코어에 추가했습니다. 목록을 두 단계(먼저 이번 페이지에 해당하는 항목만 추려내고, 그 항목에 대해서만 본문·상세 정보를 읽기)로 나눠 읽으므로 게시글·주문·로그가 수십만 건으로 늘어나도 마지막 페이지 조회 비용이 첫 페이지와 비슷하게 유지됩니다. 목록 정렬 기준을 미리 정해 둔 항목으로만 해석하는 공통 처리도 함께 제공하므로, 확장이 목록 조회를 직접 만들 때 정렬 처리를 매번 새로 구현하지 않아도 됩니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
|
||||
#### 성능 점검
|
||||
|
||||
- 확장이 자기 성능 측정 대상을 직접 등록할 수 있게 했습니다. 모듈·플러그인이 자신의 목록 화면, 관리자 화면, 저장 동작, 정기 작업 중 속도를 재고 싶은 것을 선언해 두면, 사이트 관리자가 성능 점검을 실행할 때 코어 항목과 함께 측정됩니다. 확장을 설치하면 그 확장의 측정 대상이 자동으로 목록에 나타나고, 제거하면 함께 사라집니다. 화면 측정은 응답 시간과 함께 그 화면이 실행한 데이터베이스 조회 횟수를 보여주므로, 목록 자체는 빠른데 화면이 느린 원인을 찾을 수 있습니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 활동 로그·알림 발송 이력·본인인증 기록·스케줄 실행 이력·회원 목록을 뒤쪽 페이지에서도 빠르게 열 수 있도록 조회 방식을 바꿨습니다. 예전에는 페이지가 뒤로 갈수록 건너뛰는 기록의 본문·변경 내역까지 함께 읽어 느려졌지만, 이제 현재 페이지에 해당하는 기록만 상세 정보를 읽습니다. 화면에 보이는 내용은 이전과 동일합니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Benchmark\Axes;
|
||||
|
||||
use App\Benchmark\Contracts\BenchmarkAxisRunner;
|
||||
use App\Benchmark\DTO\BenchmarkProfile;
|
||||
use App\Benchmark\DTO\BenchmarkResult;
|
||||
use App\Benchmark\DTO\BenchmarkRunOptions;
|
||||
use App\Benchmark\QueryCollector;
|
||||
use App\Enums\BenchmarkAxis;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Symfony\Component\Console\Output\BufferedOutput;
|
||||
|
||||
/**
|
||||
* 배치 축 실행기 — 배치 커맨드 소요 시간 + 피크 메모리
|
||||
*
|
||||
* 배치는 한 번에 대량을 처리하므로 "느려짐"이 시간보다 메모리로 먼저 드러납니다(청크
|
||||
* 없이 전건 로딩 → OOM). 그래서 시간과 함께 피크 메모리를 함께 잽니다.
|
||||
*
|
||||
* 다른 축과 달리 트랜잭션으로 감싸지 않습니다 — 배치 커맨드는 내부에서 커밋하거나 DDL 을
|
||||
* 실행할 수 있고, 대량 처리를 긴 트랜잭션에 담으면 락 보유 시간이 계측 자체보다 위험해집니다.
|
||||
* 대신 데이터를 변경하는 배치는 `--allow-write` 없이는 실행하지 않습니다.
|
||||
*/
|
||||
class BatchAxisRunner implements BenchmarkAxisRunner
|
||||
{
|
||||
public function __construct(private readonly QueryCollector $collector) {}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function axis(): BenchmarkAxis
|
||||
{
|
||||
return BenchmarkAxis::Batch;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function run(BenchmarkProfile $profile, BenchmarkRunOptions $options, ?\Closure $onProgress = null): BenchmarkResult
|
||||
{
|
||||
$notify = $onProgress ?? static fn (string $message) => 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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
<?php
|
||||
|
||||
namespace App\Benchmark\Axes;
|
||||
|
||||
use App\Benchmark\Contracts\BenchmarkAxisRunner;
|
||||
use App\Benchmark\DTO\BenchmarkProfile;
|
||||
use App\Benchmark\DTO\BenchmarkResult;
|
||||
use App\Benchmark\DTO\BenchmarkRunOptions;
|
||||
use App\Benchmark\SyntheticSeeder;
|
||||
use App\Enums\BenchmarkAxis;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 목록 조회 축 실행기 — 깊은 OFFSET 비용을 컬럼 폭 3축으로 계측
|
||||
*
|
||||
* 목록 조회는 OFFSET 이 커질수록 느려지는데, 그 원인이 "건너뛸 행의 넓은 컬럼까지 읽기"
|
||||
* 인지 확인하려면 같은 필터·정렬로 (1) 전체 컬럼 (2) 실제 목록 컬럼 (3) 키 컬럼만 조회한
|
||||
* 비용을 나란히 재야 합니다. 셋째 축이 지연 조인(`PaginatesWithDeferredJoin`)의 inner
|
||||
* 쿼리에 해당하므로, 이 세 값의 배수가 곧 지연 조인 적용의 기대 효과입니다.
|
||||
*/
|
||||
class ListAxisRunner implements BenchmarkAxisRunner
|
||||
{
|
||||
/**
|
||||
* 목록 1페이지 상한 (OFFSET 스캔 비용 대비 무시할 수준이라 고정)
|
||||
*/
|
||||
private const PAGE_LIMIT = 20;
|
||||
|
||||
/**
|
||||
* 필터 선언에 쓸 수 있는 연산자 (닫힌 집합)
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
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<int, string> $columns 후보 컬럼
|
||||
* @return array<int, string> 존재하는 컬럼 목록
|
||||
*/
|
||||
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<int, array{0: string, 1: string}> $order 후보 정렬
|
||||
* @return array<int, array{0: string, 1: string}> 적용 가능한 정렬 목록
|
||||
*/
|
||||
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<int, string> $columns select 컬럼
|
||||
* @param array<int, array{0: string, 1: string}> $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<int, string> $columns select 컬럼
|
||||
* @param array<int, array{0: string, 1: string}> $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<string, mixed> $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<string, mixed> $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<int, string> $columns select 컬럼
|
||||
* @param array<int, array{0: string, 1: string}> $order 정렬
|
||||
* @param int $offset OFFSET
|
||||
* @return array<int, string> 실행 계획 요약 줄 목록
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
namespace App\Benchmark\Axes;
|
||||
|
||||
use App\Benchmark\BenchmarkIdentity;
|
||||
use App\Benchmark\Contracts\BenchmarkAxisRunner;
|
||||
use App\Benchmark\DTO\BenchmarkProfile;
|
||||
use App\Benchmark\DTO\BenchmarkResult;
|
||||
use App\Benchmark\DTO\BenchmarkRunOptions;
|
||||
use App\Benchmark\QueryCollector;
|
||||
use App\Enums\BenchmarkAxis;
|
||||
use Illuminate\Contracts\Http\Kernel as HttpKernel;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/**
|
||||
* 화면 응답 축 실행기 — 화면 1장의 응답 시간 + 실행 쿼리 건수 + N+1 후보
|
||||
*
|
||||
* 사용자가 체감하는 단위가 "화면 한 장"이므로 네 축 중 값어치가 가장 큽니다. 목록 SELECT
|
||||
* 는 빠른데 화면이 느린 경우(관계 지연 로딩으로 인한 N+1, 권한 조회 반복 등)를 잡는 것이
|
||||
* 이 축의 목적입니다.
|
||||
*
|
||||
* 실행 방식은 라우트를 **HTTP 커널로 내부 요청** 처리하는 것입니다. `Route::dispatch` 로
|
||||
* 라우트만 때리면 전역 미들웨어(인증·권한·로케일·타임존)를 건너뛰어 화면과 다른 것을 재게
|
||||
* 됩니다. 커널을 쓰면 실제 요청과 같은 경로를 지납니다.
|
||||
*
|
||||
* 계측 계정 생성과 요청 처리 전체를 롤백되는 트랜잭션으로 감싸므로, 계측이 데이터에 흔적을
|
||||
* 남기지 않습니다(`BenchmarkIdentity::withRolledBackTransaction`).
|
||||
*/
|
||||
class ScreenAxisRunner implements BenchmarkAxisRunner
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BenchmarkIdentity $identity,
|
||||
private readonly QueryCollector $collector,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function axis(): BenchmarkAxis
|
||||
{
|
||||
return BenchmarkAxis::Screen;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function run(BenchmarkProfile $profile, BenchmarkRunOptions $options, ?\Closure $onProgress = null): BenchmarkResult
|
||||
{
|
||||
if ($profile->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<string, mixed> $query 쿼리스트링
|
||||
* @param int $runs 측정 횟수
|
||||
* @param BenchmarkRunOptions $options 실행 옵션
|
||||
* @param \Closure|null $onProgress 진행 콜백
|
||||
* @return array{status: int, total_ms: float, summary: array<string, mixed>, acting_user: array<string, mixed>} 계측 결과
|
||||
*/
|
||||
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<string, mixed> $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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
namespace App\Benchmark\Axes;
|
||||
|
||||
use App\Benchmark\BenchmarkIdentity;
|
||||
use App\Benchmark\Contracts\BenchmarkAxisRunner;
|
||||
use App\Benchmark\DTO\BenchmarkProfile;
|
||||
use App\Benchmark\DTO\BenchmarkResult;
|
||||
use App\Benchmark\DTO\BenchmarkRunOptions;
|
||||
use App\Benchmark\QueryCollector;
|
||||
use App\Enums\BenchmarkAxis;
|
||||
|
||||
/**
|
||||
* 쓰기 축 실행기 — 저장 경로 1회 소요 시간
|
||||
*
|
||||
* 목록 조회와 달리 저장 경로는 커맨드가 스스로 조립할 수 없습니다(주문 생성 하나에 재고·
|
||||
* 쿠폰·마일리지·알림이 얽힘). 그래서 실행 자체는 소유 확장이 선언한 콜백에 맡기고, 이
|
||||
* 실행기는 시간·쿼리 건수 계측과 안전장치만 담당합니다.
|
||||
*
|
||||
* 콜백은 `'Fqcn'`(invokable) 또는 `['Fqcn', 'method']` 형식만 허용합니다 — 코어 선언은
|
||||
* `config/benchmark.php` 에 있고 이 파일은 `config:cache` 대상이라 클로저를 담을 수 없기
|
||||
* 때문입니다. 확장 선언도 같은 스키마를 쓰므로 형식을 통일합니다.
|
||||
*
|
||||
* 선언 필드는 세 개입니다 — `prepare`(계측 제외 선행 준비, 회차마다 실행), `callback`
|
||||
* (계측 대상, prepare 반환값을 인자로 받음), `cleanup`(트랜잭션이 되돌리지 못하는 잔여물 정리).
|
||||
*
|
||||
* 계측으로 생긴 행은 롤백되는 트랜잭션으로 되돌립니다. 트랜잭션이 되돌리지 못하는 것
|
||||
* (파일·캐시·외부 호출)은 프로파일이 선언한 `cleanup` 콜백이 정리합니다.
|
||||
*/
|
||||
class WriteAxisRunner implements BenchmarkAxisRunner
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BenchmarkIdentity $identity,
|
||||
private readonly QueryCollector $collector,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function axis(): BenchmarkAxis
|
||||
{
|
||||
return BenchmarkAxis::Write;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function run(BenchmarkProfile $profile, BenchmarkRunOptions $options, ?\Closure $onProgress = null): BenchmarkResult
|
||||
{
|
||||
$notify = $onProgress ?? static fn (string $message) => 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<int, float>, queries: array<int, array{sql: string, time: float}>} 계측 결과
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace App\Benchmark;
|
||||
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* 화면 계측용 인증 주체 해석기
|
||||
*
|
||||
* 관리자 화면의 응답 시간은 로그인 상태에서만 잴 수 있습니다. 두 방식을 지원합니다.
|
||||
*
|
||||
* 기본 — 프로파일이 선언한 권한만 가진 임시 관리자를 즉석 생성해 Sanctum 토큰 발급
|
||||
* `--as` — 기존 계정을 지정 (그 계정의 실제 역할/권한으로 재측정)
|
||||
*
|
||||
* 두 경로 모두 계정/역할/토큰 생성이라는 **쓰기**를 동반하므로, 호출자가 열어둔 트랜잭션
|
||||
* 안에서 수행하고 계측 후 롤백해 흔적을 남기지 않습니다(`withRolledBackTransaction`).
|
||||
* 트랜잭션이 열려 있으면 Laravel 이 읽기 쿼리도 write PDO 로 보내므로(`Connection::getReadPdo`
|
||||
* 의 `transactions > 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<int, string> $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<int, string> $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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
namespace App\Benchmark;
|
||||
|
||||
use App\Benchmark\DTO\BenchmarkProfile;
|
||||
use App\Contracts\Extension\ModuleManagerInterface;
|
||||
use App\Contracts\Extension\PluginManagerInterface;
|
||||
use App\Enums\BenchmarkAxis;
|
||||
|
||||
/**
|
||||
* 계측 프로파일 레지스트리 — 코어 config + 활성 확장 선언 수집
|
||||
*
|
||||
* 계측 대상은 "지금 이 설치본에 실제로 존재하는 것"이어야 하므로, 커맨드에 대상을
|
||||
* 하드코딩하지 않고 소유자가 선언한 것을 런타임에 모읍니다. 코어는
|
||||
* `config/benchmark.php`, 확장은 `getBenchmarkProfiles()` 오버라이드가 선언 지점입니다
|
||||
* (채널·알림 정의 등 기존 확장 선언 훅과 동일한 수집 모델).
|
||||
*
|
||||
* 확장 선언은 격리 호출합니다 — 확장 하나의 잘못된 선언이 전체 목록을 날리면 계측 자체를
|
||||
* 못 하게 되므로, 실패한 선언만 사유와 함께 `warnings()` 로 드러내고 나머지는 살립니다.
|
||||
* 조용히 버리지는 않습니다(버려진 선언이 곧 계측 사각이 됩니다).
|
||||
*/
|
||||
class BenchmarkProfileRegistry
|
||||
{
|
||||
/**
|
||||
* 수집된 프로파일 (정규화 키 → 프로파일)
|
||||
*
|
||||
* @var array<string, BenchmarkProfile>|null
|
||||
*/
|
||||
private ?array $profiles = null;
|
||||
|
||||
/**
|
||||
* 수집 중 발생한 선언 오류 메시지
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private array $warnings = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly ModuleManagerInterface $moduleManager,
|
||||
private readonly PluginManagerInterface $pluginManager,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 모든 프로파일을 정규화 키 기준으로 반환합니다.
|
||||
*
|
||||
* @return array<string, BenchmarkProfile> 정규화 키 → 프로파일
|
||||
*/
|
||||
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<string, BenchmarkProfile> 정규화 키 → 프로파일
|
||||
*/
|
||||
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<int, string> 경고 메시지
|
||||
*/
|
||||
public function warnings(): array
|
||||
{
|
||||
$this->all();
|
||||
|
||||
return $this->warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 프로파일을 수집합니다.
|
||||
*
|
||||
* @return array<int, BenchmarkProfile> 코어 프로파일 목록
|
||||
*/
|
||||
private function collectCore(): array
|
||||
{
|
||||
$declared = config('benchmark.profiles', []);
|
||||
|
||||
return $this->normalize(is_array($declared) ? $declared : [], 'core', 'core');
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 모듈/플러그인 프로파일을 수집합니다.
|
||||
*
|
||||
* @return array<int, BenchmarkProfile> 확장 프로파일 목록
|
||||
*/
|
||||
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<string, mixed> 선언 배열 (실패 시 빈 배열)
|
||||
*/
|
||||
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<string, mixed> $declared 선언 배열 (키 → 정의)
|
||||
* @param string $sourceKind 출처 종류 (core|module|plugin)
|
||||
* @param string $sourceIdentifier 출처 식별자
|
||||
* @return array<int, BenchmarkProfile> 정규화된 프로파일 목록
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
namespace App\Benchmark;
|
||||
|
||||
use App\Benchmark\DTO\BenchmarkResult;
|
||||
use App\Benchmark\DTO\BenchmarkRunOptions;
|
||||
use App\Enums\BenchmarkAxis;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 계측 결과 출력기 — 표준출력 표 / JSON / 마크다운 리포트
|
||||
*
|
||||
* 축을 모르고도 출력할 수 있는 이유는 `BenchmarkResult` 가 표시용 표와 기계 판독용 수치를
|
||||
* 함께 담기 때문입니다. 축이 늘어도 이 클래스는 고치지 않습니다.
|
||||
*
|
||||
* 마크다운 리포트에 환경 정보를 반드시 함께 적습니다 — 계측값은 실행 머신·DB 버전·
|
||||
* OPcache 여부에 종속되므로, 환경이 빠진 수치는 다른 리포트와 비교할 수 없는 숫자입니다.
|
||||
* 문서에 옮길 수치는 이 경로로만 산출해 눈대중 기재를 막는 것이 `--report` 의 목적입니다.
|
||||
*/
|
||||
class BenchmarkReporter
|
||||
{
|
||||
/**
|
||||
* 실행 환경 정보를 수집합니다.
|
||||
*
|
||||
* @return array<string, string> 항목 → 값
|
||||
*/
|
||||
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<int, BenchmarkResult> $results 계측 결과
|
||||
* @param BenchmarkRunOptions $options 실행 옵션
|
||||
* @param array<int, string> $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<int, BenchmarkResult> $results 계측 결과
|
||||
* @param BenchmarkRunOptions $options 실행 옵션
|
||||
* @param array<int, string> $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<int, string> 마크다운 줄 목록
|
||||
*/
|
||||
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 '-';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Benchmark\Contracts;
|
||||
|
||||
use App\Benchmark\DTO\BenchmarkProfile;
|
||||
use App\Benchmark\DTO\BenchmarkResult;
|
||||
use App\Benchmark\DTO\BenchmarkRunOptions;
|
||||
use App\Enums\BenchmarkAxis;
|
||||
|
||||
/**
|
||||
* 계측 축 실행기 계약
|
||||
*
|
||||
* 축이 늘어날 때 커맨드를 고치지 않고 실행기를 추가하도록 분리합니다. 커맨드는
|
||||
* 프로파일의 `type` 으로 실행기를 고르는 일만 합니다.
|
||||
*/
|
||||
interface BenchmarkAxisRunner
|
||||
{
|
||||
/**
|
||||
* 이 실행기가 담당하는 축을 반환합니다.
|
||||
*
|
||||
* @return BenchmarkAxis 담당 축
|
||||
*/
|
||||
public function axis(): BenchmarkAxis;
|
||||
|
||||
/**
|
||||
* 프로파일 1건을 계측합니다.
|
||||
*
|
||||
* @param BenchmarkProfile $profile 계측 대상
|
||||
* @param BenchmarkRunOptions $options 실행 옵션
|
||||
* @param \Closure|null $onProgress 진행 상황 콜백 (string $message, 시딩 등 장시간 작업 알림용)
|
||||
* @return BenchmarkResult 계측 결과
|
||||
*/
|
||||
public function run(BenchmarkProfile $profile, BenchmarkRunOptions $options, ?\Closure $onProgress = null): BenchmarkResult;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Benchmark\DTO;
|
||||
|
||||
use App\Enums\BenchmarkAxis;
|
||||
|
||||
/**
|
||||
* 계측 프로파일 — 코어 config / 확장 선언에서 수집된 계측 대상 1건 (Value Object)
|
||||
*
|
||||
* 레지스트리가 선언 배열을 검증해 이 객체로 정규화하고, 축 실행기가 그대로 받아 씁니다.
|
||||
* 축별 옵션 스키마가 서로 다르므로 공통 필드(키·축·출처·라벨)만 프로퍼티로 승격하고
|
||||
* 축 고유 옵션은 `options` 에 남깁니다 — 축이 늘 때 VO 를 고치지 않아도 되게 합니다.
|
||||
*/
|
||||
final readonly class BenchmarkProfile
|
||||
{
|
||||
/**
|
||||
* @param string $key 선언된 프로파일 키 (확장 내부에서만 고유)
|
||||
* @param BenchmarkAxis $axis 계측 축
|
||||
* @param string $sourceKind 출처 종류 (core|module|plugin)
|
||||
* @param string $sourceIdentifier 출처 식별자 (코어는 'core')
|
||||
* @param array<string, mixed> $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<string, mixed> 직렬화 결과
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Benchmark\DTO;
|
||||
|
||||
/**
|
||||
* 계측 결과 1건 — 축 실행기의 산출물 (Value Object)
|
||||
*
|
||||
* 출력기(`BenchmarkReporter`)가 축을 몰라도 표/JSON/마크다운을 만들 수 있도록,
|
||||
* 표시용 표(`headers`/`rows`)와 기계 판독용 수치(`metrics`)를 함께 담습니다.
|
||||
* 축이 늘어날 때 출력기를 고치지 않아도 되는 지점이 여기입니다.
|
||||
*/
|
||||
final readonly class BenchmarkResult
|
||||
{
|
||||
/**
|
||||
* @param BenchmarkProfile $profile 계측 대상 프로파일
|
||||
* @param array<int, string> $headers 표 헤더
|
||||
* @param array<int, array<int, string>> $rows 표 행 (표시용 문자열)
|
||||
* @param array<string, mixed> $metrics 기계 판독용 원시 수치
|
||||
* @param array<int, string> $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<string, mixed> 직렬화 결과
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Benchmark\DTO;
|
||||
|
||||
/**
|
||||
* 계측 실행 옵션 — 커맨드 옵션을 축 실행기로 한 번 전달하는 값 묶음 (Value Object)
|
||||
*
|
||||
* 축마다 쓰는 옵션이 다르지만(offsets 는 list 축만, allowWrite 는 write/batch 축만)
|
||||
* 실행기 시그니처를 하나로 두기 위해 한 객체로 모읍니다.
|
||||
*/
|
||||
final readonly class BenchmarkRunOptions
|
||||
{
|
||||
/**
|
||||
* @param array<int, int> $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<string, mixed> 직렬화 결과
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Benchmark;
|
||||
|
||||
use Illuminate\Database\Events\QueryExecuted;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 실행 쿼리 수집기 — 화면/쓰기/배치 축의 쿼리 건수·시간·N+1 후보 산출
|
||||
*
|
||||
* 목록 SELECT 는 빨라도 화면이 느린 대표적 원인이 N+1 이므로, 응답 시간만 재고 끝내면
|
||||
* 원인을 못 찾습니다. 이 수집기는 계측 구간에서 실행된 쿼리를 모아 건수·DB 시간과, 같은
|
||||
* SQL 이 반복 실행된 그룹(N+1 후보)을 함께 돌려줍니다.
|
||||
*
|
||||
* `DB::listen` 은 한 번 등록하면 해제할 수 없으므로, 리스너는 인스턴스당 한 번만 등록하고
|
||||
* 수집 여부는 플래그로 켰다 끕니다 — 계측 구간 밖의 쿼리가 섞이지 않게 합니다.
|
||||
*/
|
||||
class QueryCollector
|
||||
{
|
||||
/**
|
||||
* 같은 SQL 이 이 횟수 이상 반복되면 N+1 후보로 본다
|
||||
*/
|
||||
private const N_PLUS_ONE_THRESHOLD = 5;
|
||||
|
||||
/**
|
||||
* 수집 구간 여부
|
||||
*/
|
||||
private bool $collecting = false;
|
||||
|
||||
/**
|
||||
* 수집된 쿼리 (sql, time)
|
||||
*
|
||||
* @var array<int, array{sql: string, time: float}>
|
||||
*/
|
||||
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<int, array{sql: string, time: float}>} 반환값과 수집 결과
|
||||
*/
|
||||
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<int, array{sql: string, time: float}> $queries 수집 결과
|
||||
* @return array{count: int, db_ms: float, n_plus_one: array<int, array{count: int, sql: string}>} 요약
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
namespace App\Benchmark;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* 계측용 합성 행 시딩기
|
||||
*
|
||||
* 깊은 OFFSET 비용은 행 수와 행 폭이 있어야 재현되므로, 계측 전에 대상 테이블을 원하는
|
||||
* 규모까지 채웁니다. 스키마를 introspect 해 NOT NULL + 기본값 없는 컬럼만 타입에 맞춰
|
||||
* 채우므로 계측 대상이 늘 때마다 시더를 따라 만들지 않아도 됩니다.
|
||||
*
|
||||
* 합성 값은 실제 부모 행을 가리키지 않으므로 외래키 제약을 일시 해제합니다. 본 시딩이
|
||||
* 재현하려는 것은 대상 테이블의 OFFSET 스캔 비용(행 수·행 폭)이고 조인은 계측 대상이
|
||||
* 아니므로, 부모 무결성 없이 목적에 충분합니다 — 폐기 가능한 계측용 DB 에서만 쓰는 것을
|
||||
* 전제로 하며, 운영 환경 거부는 호출자(`ListAxisRunner`)가 담당합니다.
|
||||
*/
|
||||
class SyntheticSeeder
|
||||
{
|
||||
/**
|
||||
* 한 번에 삽입할 행 수
|
||||
*/
|
||||
private const CHUNK_SIZE = 500;
|
||||
|
||||
/**
|
||||
* 계측용 합성 행을 시딩합니다.
|
||||
*
|
||||
* @param string $table 대상 테이블
|
||||
* @param int $count 시딩 건수
|
||||
* @param array<string, mixed> $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<int, array<string, mixed>> 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<int, array<string, mixed>> $fillable 채워야 하는 컬럼 목록
|
||||
* @param array<string, mixed> $overrides 컬럼별 고정값
|
||||
* @param int $index 행 인덱스 (고유값 생성용)
|
||||
* @return array<string, mixed> 합성 행
|
||||
*/
|
||||
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<int, array<string, mixed>> $chunk 삽입할 행 묶음
|
||||
*/
|
||||
private function insert(string $table, array $chunk): void
|
||||
{
|
||||
Schema::withoutForeignKeyConstraints(function () use ($table, $chunk) {
|
||||
DB::table($table)->insert($chunk);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 타입에 맞는 합성 값을 만듭니다.
|
||||
*
|
||||
* @param array<string, mixed> $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<string, mixed> $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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Benchmark\BenchmarkProfileRegistry;
|
||||
use App\Benchmark\BenchmarkReporter;
|
||||
use App\Benchmark\Contracts\BenchmarkAxisRunner;
|
||||
use App\Benchmark\DTO\BenchmarkProfile;
|
||||
use App\Benchmark\DTO\BenchmarkResult;
|
||||
use App\Benchmark\DTO\BenchmarkRunOptions;
|
||||
use App\Enums\BenchmarkAxis;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 성능 계측 커맨드 — 목록 조회 / 화면 응답 / 쓰기 작업 / 배치 작업 4축
|
||||
*
|
||||
* 계측 대상은 소유자가 선언합니다 — 코어는 `config/benchmark.php`, 확장은
|
||||
* `getBenchmarkProfiles()` 오버라이드입니다. 커맨드에 대상을 하드코딩하지 않는 이유는,
|
||||
* 확장이 설치·제거되는 설치본마다 실제로 존재하는 대상이 다르기 때문입니다.
|
||||
*
|
||||
* 축별 실행은 `App\Benchmark\Axes\*` 실행기가 담당하고, 이 커맨드는 프로파일 선택·옵션
|
||||
* 해석·출력만 합니다.
|
||||
*
|
||||
* tinker 스크립트 대신 커맨드로 만든 이유: tinker 는 REPL 이라 스크립트 실행 후에도 STDIN 을
|
||||
* 기다려 출력이 유실된다.
|
||||
*/
|
||||
class BenchCommand extends Command
|
||||
{
|
||||
protected $signature = 'g7:bench
|
||||
{--profile=* : 계측할 프로파일 키 (짧은 키 또는 출처/키 형태). 다중 지정 가능}
|
||||
{--axis= : 축 단위 실행 (list|screen|write|batch)}
|
||||
{--all : 등록된 모든 프로파일 실행}
|
||||
{--offsets=0,20000,50000,99980 : 측정할 OFFSET 목록 (쉼표 구분, list 축)}
|
||||
{--runs=3 : 측정 횟수 (첫 회는 버림)}
|
||||
{--per-page=20 : 목록/화면 1페이지 건수}
|
||||
{--seed=0 : 계측 전 합성 행 시딩 건수 (0 = 시딩 안 함, list 축)}
|
||||
{--fresh : 시딩 전에 대상 테이블을 비움 (운영 환경에서는 거부)}
|
||||
{--explain : 각 OFFSET 의 실행 계획도 함께 수집 (list 축)}
|
||||
{--as= : 화면 계측에 사용할 기존 계정 (ID 또는 이메일). 미지정 시 계측용 임시 계정}
|
||||
{--allow-write : 데이터를 변경하는 축(write/batch/비-GET 화면) 실행 허용}
|
||||
{--database= : 계측에 사용할 데이터베이스명 (미지정 시 기본 연결. 개발 DB 오염 방지용)}
|
||||
{--json : 기계 판독용 JSON 출력}
|
||||
{--report= : 마크다운 리포트 저장 경로 (값 없이 --report 만 주면 storage/app/benchmarks/ 아래 자동 생성)}
|
||||
{--list-profiles : 등록된 프로파일 목록 출력}';
|
||||
|
||||
protected $description = '목록 조회·화면 응답·쓰기·배치 4축 성능을 계측합니다 (대상은 코어 config + 확장 선언에서 수집)';
|
||||
|
||||
protected $aliases = ['g7:bench:pagination'];
|
||||
|
||||
/**
|
||||
* 리포트 기본 저장 디렉토리 (저장소 미추적 — 계측값은 실행 머신 사양에 종속되므로 축적하지 않음)
|
||||
*/
|
||||
private const REPORT_DIR = 'app/benchmarks';
|
||||
|
||||
/**
|
||||
* @param BenchmarkProfileRegistry $registry 프로파일 레지스트리
|
||||
* @param BenchmarkReporter $reporter 결과 출력기
|
||||
* @param iterable<BenchmarkAxisRunner> $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<int, BenchmarkProfile>, 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<int, int> 오름차순 정렬된 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<int, BenchmarkResult> $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<int, BenchmarkResult> $results 계측 결과
|
||||
* @param BenchmarkRunOptions $options 실행 옵션
|
||||
* @param array<int, string> $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;
|
||||
}
|
||||
}
|
||||
@@ -1,556 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* 깊은 OFFSET 페이지네이션 비용 계측 커맨드
|
||||
*
|
||||
* 목록 조회는 OFFSET 이 커질수록 느려지는데, 그 원인이 "건너뛸 행의 넓은 컬럼까지 읽기"인지
|
||||
* 확인하려면 같은 필터·정렬로 (1) 실제 목록 컬럼과 (2) 키 컬럼만 조회한 비용을 나란히 재야 한다.
|
||||
* 본 커맨드는 그 두 축을 같은 조건에서 측정해 표로 출력한다.
|
||||
*
|
||||
* tinker 스크립트 대신 커맨드로 만든 이유: tinker 는 REPL 이라 스크립트 실행 후에도 STDIN 을
|
||||
* 기다려 출력이 유실된다.
|
||||
*/
|
||||
class BenchPaginationCommand extends Command
|
||||
{
|
||||
protected $signature = 'g7:bench:pagination
|
||||
{--table=board_posts : 계측 대상 프로파일 키}
|
||||
{--offsets=0,20000,50000,99980 : 측정할 OFFSET 목록 (쉼표 구분)}
|
||||
{--runs=3 : OFFSET 당 측정 횟수 (첫 회는 버림)}
|
||||
{--seed=0 : 계측 전 합성 행 시딩 건수 (0 = 시딩 안 함)}
|
||||
{--database= : 계측에 사용할 데이터베이스명 (미지정 시 기본 연결. 개발 DB 오염 방지용)}
|
||||
{--fresh : 시딩 전에 대상 테이블을 비움 (운영 환경에서는 거부)}
|
||||
{--explain : 각 OFFSET 의 실행 계획도 함께 수집}
|
||||
{--json : 기계 판독용 JSON 출력}
|
||||
{--list-profiles : 등록된 프로파일 목록 출력}';
|
||||
|
||||
protected $description = '깊은 OFFSET 목록 조회 비용을 목록 컬럼 / 키 컬럼 두 축으로 계측합니다';
|
||||
|
||||
/**
|
||||
* 계측 프로파일 정의
|
||||
*
|
||||
* columns 는 해당 목록이 실제로 select 하는 컬럼이다. 스키마에 없는 컬럼은 실행 시 걸러낸다
|
||||
* (확장 버전에 따라 컬럼 구성이 달라도 커맨드가 죽지 않도록).
|
||||
*
|
||||
* @return array<string, array{table: string, columns: array<int, string>, order: array<int, array{0: string, 1: string}>, 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<int, int> 오름차순 정렬된 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<int, string> $columns 후보 컬럼
|
||||
* @return array<int, string> 존재하는 컬럼 목록
|
||||
*/
|
||||
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<int, array{0: string, 1: string}> $order 후보 정렬
|
||||
* @return array<int, array{0: string, 1: string}> 적용 가능한 정렬 목록
|
||||
*/
|
||||
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<int, string> $columns select 컬럼
|
||||
* @param array<int, array{0: string, 1: string}> $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<int, string> $columns select 컬럼
|
||||
* @param array<int, array{0: string, 1: string}> $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<int, string> $columns select 컬럼
|
||||
* @param array<int, array{0: string, 1: string}> $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<int, string> $columns select 컬럼
|
||||
* @param array<int, array{0: string, 1: string}> $order 정렬
|
||||
* @param int $offset OFFSET
|
||||
* @return array<int, string> 실행 계획 요약 줄 목록
|
||||
*/
|
||||
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<string, mixed> $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<int, array<string, mixed>> $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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* 성능 계측 축 Enum
|
||||
*
|
||||
* `g7:bench` 커맨드가 재는 네 가지 대상을 구분합니다. 프로파일 선언의 `type` 필드
|
||||
* 값 도메인이며, 축마다 필수 옵션과 실행기(`App\Benchmark\Axes\*`)가 다릅니다.
|
||||
*/
|
||||
enum BenchmarkAxis: string
|
||||
{
|
||||
/**
|
||||
* 목록 SELECT 비용 (전체 컬럼 / 목록 컬럼 / 키 컬럼 3축 비교)
|
||||
*/
|
||||
case ListQuery = 'list';
|
||||
|
||||
/**
|
||||
* 화면 1장 응답 시간 + 실행 쿼리 건수 + N+1 후보
|
||||
*/
|
||||
case Screen = 'screen';
|
||||
|
||||
/**
|
||||
* 저장 경로 1회 소요 시간 (주문 생성, 게시글 등록 등)
|
||||
*/
|
||||
case Write = 'write';
|
||||
|
||||
/**
|
||||
* 배치 커맨드 소요 시간 + 피크 메모리
|
||||
*/
|
||||
case Batch = 'batch';
|
||||
|
||||
/**
|
||||
* 사람이 읽을 수 있는 라벨
|
||||
*
|
||||
* @return string 축 라벨
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::ListQuery => '목록 조회',
|
||||
self::Screen => '화면 응답',
|
||||
self::Write => '쓰기 작업',
|
||||
self::Batch => '배치 작업',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로파일 선언에서 반드시 채워야 하는 옵션 키 목록
|
||||
*
|
||||
* 각 원소는 "대안 그룹"이며, 그룹마다 최소 하나가 선언되어야 합니다
|
||||
* (`screen` 축은 라우트명 또는 URI 중 하나). 레지스트리가 이 목록으로 선언을
|
||||
* 검증하며, 누락된 선언은 사유와 함께 경고로 드러내고 목록에서 제외합니다
|
||||
* (조용히 버리면 계측 사각이 됩니다).
|
||||
*
|
||||
* @return array<int, array<int, string>> 필수 옵션 대안 그룹 목록
|
||||
*/
|
||||
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<int, string> 축 값 목록
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_column(self::cases(), 'value');
|
||||
}
|
||||
}
|
||||
@@ -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<string, array<string, mixed>> 프로파일 키 → 정의
|
||||
* [
|
||||
* '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 [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈 설치 시 실행할 시더 클래스 목록 반환
|
||||
*
|
||||
|
||||
@@ -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<string, array<string, mixed>> 프로파일 키 → 정의
|
||||
* [
|
||||
* '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 [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 설치 시 실행할 시더 클래스 목록 반환
|
||||
*
|
||||
|
||||
@@ -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 오토로드를 등록합니다.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 성능 계측 프로파일 (Benchmark Profiles)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| `g7:bench` 커맨드가 재는 코어 계측 대상 선언입니다. 확장은 자기 대상을
|
||||
| `getBenchmarkProfiles()` 오버라이드로 선언하며(모듈/플러그인 공통), 스키마는
|
||||
| 여기와 동일합니다. 계측 대상을 커맨드에 하드코딩하지 않는 이유는, 확장이
|
||||
| 설치·제거되는 설치본마다 "실제로 존재하는 대상"이 다르기 때문입니다.
|
||||
|
|
||||
| 공통 필드
|
||||
| type : list | screen | write | batch (필수)
|
||||
| label : 표시용 설명 (선택)
|
||||
|
|
||||
| type=list — 목록 SELECT 비용 (전체 컬럼 / 목록 컬럼 / 키 컬럼 3축)
|
||||
| table : 대상 테이블 (필수)
|
||||
| columns : 목록이 실제로 select 하는 컬럼. ['*'] 는 "응답 계약상 전 컬럼
|
||||
| 노출이라 프루닝 불가" 선언이며 이때 비교축은 select * vs select id
|
||||
| order : [[컬럼, 방향], ...]
|
||||
| filters : 화면이 실제로 거는 필터. 필터 없이 재면 인덱스 선택이 달라져 화면에서
|
||||
| 일어나는 일과 다른 것을 잰다. 두 형태를 받는다 —
|
||||
| 등가: ['board_id' => 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],
|
||||
],
|
||||
|
||||
],
|
||||
];
|
||||
+3
-2
@@ -9,7 +9,7 @@
|
||||
|
||||
| 카테고리 | 문서 수 | 링크 상태 |
|
||||
|----------|---------|----------|
|
||||
| [백엔드](backend/) | 33개 | 정상 |
|
||||
| [백엔드](backend/) | 34개 | 정상 |
|
||||
| [프론트엔드](frontend/) | 51개 | 정상 |
|
||||
| [확장 시스템](extension/) | 31개 | 정상 |
|
||||
| 공통 | 20개 | 정상 |
|
||||
@@ -125,7 +125,7 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답
|
||||
<!-- AUTO-GENERATED-START: docs-readme-full-list -->
|
||||
## 카테고리별 전체 문서 목록
|
||||
|
||||
### 백엔드 (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) | 컨트롤러 계층 구조 |
|
||||
|
||||
@@ -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... |
|
||||
|
||||
@@ -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) — 확장 선언 훅 일반
|
||||
@@ -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
|
||||
|
||||
+14
-6
@@ -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 커맨드
|
||||
|
||||
@@ -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 |
|
||||
|
||||
#### 동적 권한/역할/메뉴 보존 규칙
|
||||
|
||||
@@ -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) 참조.
|
||||
|
||||
@@ -848,6 +848,50 @@ class Module extends AbstractModule
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 성능 계측 프로파일 정의 (`g7:bench`).
|
||||
*
|
||||
* 게시글 목록의 `columns` 는 `PostRepository::paginate()` 가 select 하는 컬럼 집합입니다
|
||||
* (본문 미리보기용 `SUBSTRING` 표현식은 지연 조인의 outer 에서만 적용되고 계측 대상인
|
||||
* 건너뛰기 비용과 무관하므로 제외). 게시글 목록은 화면이 게시판 하나를 조회하므로 필터
|
||||
* 없이 재면 인덱스 선택이 달라져 화면에서 일어나는 일과 다른 것을 재게 됩니다.
|
||||
*
|
||||
* @return array<string, array<string, mixed>> 프로파일 키 → 정의
|
||||
*/
|
||||
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,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 댓글 알림 정의.
|
||||
*/
|
||||
|
||||
@@ -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<string, array<string, mixed>> 프로파일 키 → 정의
|
||||
*/
|
||||
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'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 무통장입금 입금 안내 알림 정의.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Benchmark;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||||
use Modules\Sirsoft\Ecommerce\Models\ProductOption;
|
||||
use Modules\Sirsoft\Ecommerce\Models\TempOrder;
|
||||
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\TempOrderService;
|
||||
|
||||
/**
|
||||
* 주문 생성 계측 대상 — `g7:bench` write 축이 실행하는 저장 경로
|
||||
*
|
||||
* 주문 생성은 재고 차감·쿠폰 소진·마일리지 적립·알림 발행이 한 트랜잭션에 얽힌 저장
|
||||
* 경로이므로, 계측 커맨드가 쿼리를 스스로 조립해 재는 방식으로는 실제 비용을 알 수 없습니다.
|
||||
* 그래서 이 클래스가 실제 서비스 경로(`OrderProcessingService::createFromTempOrder`)를
|
||||
* 호출하고, 커맨드는 시간·쿼리 건수만 잽니다.
|
||||
*
|
||||
* 임시 주문을 팩토리로 직접 조립하지 않고 실제 서비스(`TempOrderService`)로 만드는 이유는,
|
||||
* `createFromTempOrder` 가 저장된 계산 파라미터로 금액을 **재계산해 저장값과 대조**하기
|
||||
* 때문입니다. 손으로 만든 계산 결과는 재계산값과 어긋나 `OrderAmountChangedException` 으로
|
||||
* 막히며, 어긋나지 않게 맞추더라도 그때는 실제 결제 경로가 아닌 다른 것을 재게 됩니다.
|
||||
*
|
||||
* 준비(`prepare`)는 계측 구간 밖에서 회차마다 실행됩니다 — 임시 주문은 한 번만 주문으로
|
||||
* 전환되므로 회차마다 새로 필요하고, 그 준비 비용이 측정값에 섞이면 안 됩니다. 여기서 만든
|
||||
* 상품·회원·주문은 계측 커맨드가 감싼 트랜잭션 롤백으로 전부 되돌아갑니다.
|
||||
*/
|
||||
class OrderCreationBenchmark
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TempOrderService $tempOrderService,
|
||||
private readonly OrderProcessingService $orderProcessingService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 계측 회차 1건에 필요한 선행 상태를 준비합니다. (계측 구간 밖)
|
||||
*
|
||||
* 기존 상품을 골라 쓰지 않고 매 회차 새로 만드는 이유는, 설치본마다 상품 구성·재고·
|
||||
* 판매상태가 달라 같은 조건에서 재고 있는 단일 옵션 상품 1건을 스스로 세우는 편이
|
||||
* 회차 간·환경 간 비교 가능성을 보장하기 때문입니다.
|
||||
*
|
||||
* @param int $run 계측 회차 번호
|
||||
* @return array{temp_order: TempOrder, user: User, expected_total_amount: float} 계측 컨텍스트
|
||||
*/
|
||||
public function prepare(int $run): array
|
||||
{
|
||||
$user = User::factory()->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'],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ class OrderRepository implements OrderRepositoryInterface
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const LIST_COLUMNS = [
|
||||
public const LIST_COLUMNS = [
|
||||
'id',
|
||||
'user_id',
|
||||
'order_number',
|
||||
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Benchmark;
|
||||
|
||||
use App\Benchmark\Axes\WriteAxisRunner;
|
||||
use App\Benchmark\BenchmarkProfileRegistry;
|
||||
use App\Benchmark\DTO\BenchmarkRunOptions;
|
||||
use App\Enums\BenchmarkAxis;
|
||||
use Modules\Sirsoft\Ecommerce\Benchmark\OrderCreationBenchmark;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\TempOrder;
|
||||
use Modules\Sirsoft\Ecommerce\Module;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* 이커머스 계측 프로파일 선언 및 주문 생성 쓰기 축 대상 테스트.
|
||||
*
|
||||
* 계측 결과는 시간 값이라 **수치를 단언하지 않는다**. 단언 대상은 모듈이 자기 계측 대상을
|
||||
* 선언한다는 성질과, 쓰기 축 대상이 실제 서비스 경로로 주문을 만든다는 성질이다.
|
||||
*/
|
||||
class OrderCreationBenchmarkTest extends ModuleTestCase
|
||||
{
|
||||
/**
|
||||
* 모듈이 자기 계측 대상을 선언합니다.
|
||||
*
|
||||
* 계측 대상을 코어 커맨드에 하드코딩하지 않고 소유 확장이 선언한다는 설계의 실증입니다.
|
||||
*
|
||||
* @effects list_profile_filters_match_the_screen_default_predicate
|
||||
*/
|
||||
#[Test]
|
||||
public function 모듈이_계측_프로파일을_선언한다(): void
|
||||
{
|
||||
$declared = (new Module)->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());
|
||||
}
|
||||
}
|
||||
@@ -168,4 +168,35 @@ class Module extends AbstractModule
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 성능 계측 프로파일 정의 (`g7:bench`).
|
||||
*
|
||||
* 페이지 목록은 응답 계약상 본문까지 그대로 노출하므로 컬럼 프루닝이 불가합니다
|
||||
* (`['*']`). 이 경우 계측의 비교축은 select * vs select id 이며, 그 배수가 지연 조인
|
||||
* 적용의 기대 효과입니다.
|
||||
*
|
||||
* @return array<string, array<string, mixed>> 프로파일 키 → 정의
|
||||
*/
|
||||
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'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,725 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Console;
|
||||
|
||||
use App\Benchmark\BenchmarkProfileRegistry;
|
||||
use App\Benchmark\DTO\BenchmarkProfile;
|
||||
use App\Contracts\Extension\ModuleManagerInterface;
|
||||
use App\Contracts\Extension\PluginManagerInterface;
|
||||
use App\Enums\BenchmarkAxis;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* `g7:bench` 커맨드 및 프로파일 레지스트리 Feature 테스트.
|
||||
*
|
||||
* 계측 결과는 시간 값이라 실행마다 달라지므로 **수치는 단언하지 않는다**. 단언 대상은
|
||||
* 산출 구조(표 헤더 / JSON 키), 프로파일 수집, 축별 실행 분기, 안전장치 동작이다.
|
||||
*
|
||||
* 프로파일은 코어 `config/benchmark.php` 와 확장 `getBenchmarkProfiles()` 두 지점에서
|
||||
* 수집되므로, 테스트는 config 를 런타임 치환해 "커맨드가 선언을 읽는다"는 성질 자체를
|
||||
* 검증한다 — 특정 확장 설치 여부에 의존하면 설치본마다 결과가 갈린다.
|
||||
*/
|
||||
class BenchCommandTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* 각 테스트가 자기 프로파일만 보도록 코어 선언을 치환하고 레지스트리 캐시를 비운다.
|
||||
*
|
||||
* 활성 확장의 선언도 함께 차단한다 — 차단하지 않으면 설치된 번들 확장 구성에 따라
|
||||
* 정확 집합 단언이 갈리고, 테스트가 검증하려는 성질(커맨드가 선언을 읽는다)과 무관한
|
||||
* 이유로 실패한다. 확장 선언 수집 자체는 가짜 확장을 주입하는 테스트가 담당한다.
|
||||
*
|
||||
* @param array<string, array<string, mixed>> $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<string, array<string, mixed>> 선언 (항상 실패)
|
||||
*/
|
||||
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<string, array<string, mixed>> $profiles 가짜 모듈이 선언할 프로파일
|
||||
*/
|
||||
private function fakeModuleWithProfiles(array $profiles): void
|
||||
{
|
||||
$this->fakeExtensionsWithProfiles($profiles, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로파일을 선언한 가짜 모듈/플러그인을 각 매니저에 주입합니다.
|
||||
*
|
||||
* @param array<string, array<string, mixed>> $modileProfiles 가짜 모듈이 선언할 프로파일
|
||||
* @param array<string, array<string, mixed>> $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<string, array<string, mixed>> $profiles 선언할 프로파일
|
||||
* @return object 가짜 확장
|
||||
*/
|
||||
private function fakeExtension(string $identifier, array $profiles): object
|
||||
{
|
||||
return new class($identifier, $profiles)
|
||||
{
|
||||
/**
|
||||
* @param string $identifier 확장 식별자
|
||||
* @param array<string, array<string, mixed>> $profiles 선언할 프로파일
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $identifier,
|
||||
private readonly array $profiles,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return string 확장 식별자
|
||||
*/
|
||||
public function getIdentifier(): string
|
||||
{
|
||||
return $this->identifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, mixed>> 선언한 프로파일
|
||||
*/
|
||||
public function getBenchmarkProfiles(): array
|
||||
{
|
||||
return $this->profiles;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 매니저를 가짜로 바인딩하고 레지스트리 캐시를 비웁니다.
|
||||
*
|
||||
* @param array<int, object> $modules 활성 모듈 목록
|
||||
* @param array<int, object> $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 {}
|
||||
}
|
||||
@@ -0,0 +1,737 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Benchmark;
|
||||
|
||||
use App\Benchmark\Axes\BatchAxisRunner;
|
||||
use App\Benchmark\Axes\ListAxisRunner;
|
||||
use App\Benchmark\Axes\ScreenAxisRunner;
|
||||
use App\Benchmark\Axes\WriteAxisRunner;
|
||||
use App\Benchmark\DTO\BenchmarkProfile;
|
||||
use App\Benchmark\DTO\BenchmarkRunOptions;
|
||||
use App\Benchmark\QueryCollector;
|
||||
use App\Enums\BenchmarkAxis;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 축 실행기 단위 테스트.
|
||||
*
|
||||
* 계측 결과는 시간 값이라 **수치를 단언하지 않는다**. 단언 대상은 각 축이 무엇을 실행하고
|
||||
* 무엇을 산출하는지, 그리고 계측이 데이터에 흔적을 남기지 않는지다.
|
||||
*/
|
||||
class BenchmarkAxisRunnerTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* 프로파일을 만듭니다.
|
||||
*
|
||||
* @param BenchmarkAxis $axis 계측 축
|
||||
* @param array<string, mixed> $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<int, mixed>
|
||||
*/
|
||||
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('정리 실패 재현');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Benchmark;
|
||||
|
||||
use App\Benchmark\DTO\BenchmarkProfile;
|
||||
use App\Enums\BenchmarkAxis;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 계측 프로파일 VO 및 축 Enum 계약 단위 테스트.
|
||||
*
|
||||
* 축이 늘어나도 VO 를 고치지 않는다는 설계(축 고유 옵션은 `options` 에 남김)와, 변경 판정이
|
||||
* 어디서 결정되는지(축 기본값 → HTTP 메서드 → 선언 순)를 고정한다.
|
||||
*/
|
||||
class BenchmarkProfileTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* 프로파일을 만듭니다.
|
||||
*
|
||||
* @param BenchmarkAxis $axis 계측 축
|
||||
* @param array<string, mixed> $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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user