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:
HeuJung
2026-08-02 14:44:33 +09:00
parent be9befe023
commit 5f4a733b3b
40 changed files with 5266 additions and 567 deletions
+2
View File
@@ -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/
+8 -1
View File
@@ -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`
+4
View File
@@ -51,6 +51,10 @@
- 목록 화면이 뒤쪽 페이지로 갈수록 느려지는 문제를 구조적으로 해결할 수 있도록, 확장이 함께 쓸 수 있는 공통 조회 방식을 코어에 추가했습니다. 목록을 두 단계(먼저 이번 페이지에 해당하는 항목만 추려내고, 그 항목에 대해서만 본문·상세 정보를 읽기)로 나눠 읽으므로 게시글·주문·로그가 수십만 건으로 늘어나도 마지막 페이지 조회 비용이 첫 페이지와 비슷하게 유지됩니다. 목록 정렬 기준을 미리 정해 둔 항목으로만 해석하는 공통 처리도 함께 제공하므로, 확장이 목록 조회를 직접 만들 때 정렬 처리를 매번 새로 구현하지 않아도 됩니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
#### 성능 점검
- 확장이 자기 성능 측정 대상을 직접 등록할 수 있게 했습니다. 모듈·플러그인이 자신의 목록 화면, 관리자 화면, 저장 동작, 정기 작업 중 속도를 재고 싶은 것을 선언해 두면, 사이트 관리자가 성능 점검을 실행할 때 코어 항목과 함께 측정됩니다. 확장을 설치하면 그 확장의 측정 대상이 자동으로 목록에 나타나고, 제거하면 함께 사라집니다. 화면 측정은 응답 시간과 함께 그 화면이 실행한 데이터베이스 조회 횟수를 보여주므로, 목록 자체는 빠른데 화면이 느린 원인을 찾을 수 있습니다.
### Changed
- 활동 로그·알림 발송 이력·본인인증 기록·스케줄 실행 이력·회원 목록을 뒤쪽 페이지에서도 빠르게 열 수 있도록 조회 방식을 바꿨습니다. 예전에는 페이지가 뒤로 갈수록 건너뛰는 기록의 본문·변경 내역까지 함께 읽어 느려졌지만, 이제 현재 페이지에 해당하는 기록만 상세 정보를 읽습니다. 화면에 보이는 내용은 이전과 동일합니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
+141
View File
@@ -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';
}
}
+347
View File
@@ -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);
}
}
+261
View File
@@ -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];
}
}
+227
View File
@@ -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,
];
}
}
+144
View File
@@ -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();
}
}
+275
View File
@@ -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;
}
}
+226
View File
@@ -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;
}
+97
View File
@@ -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,
];
}
}
+68
View File
@@ -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,
];
}
}
+52
View File
@@ -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,
];
}
}
+102
View File
@@ -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,
];
}
}
+190
View File
@@ -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);
}
}
+395
View File
@@ -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);
}
}
+93
View File
@@ -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');
}
}
+32
View File
@@ -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 [];
}
/**
* 모듈 설치 시 실행할 시더 클래스 목록 반환
*
+31
View File
@@ -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 [];
}
/**
* 플러그인 설치 시 실행할 시더 클래스 목록 반환
*
+26
View File
@@ -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 오토로드를 등록합니다.
*
+116
View File
@@ -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
View File
@@ -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) | 컨트롤러 계층 구조 |
+1
View File
@@ -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... |
+186
View File
@@ -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) — 확장 선언 훅 일반
+3 -1
View File
@@ -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
View File
@@ -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 커맨드
+1
View File
@@ -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 |
#### 동적 권한/역할/메뉴 보존 규칙
+1
View File
@@ -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) 참조.
+44
View File
@@ -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',
@@ -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());
}
}
+31
View File
@@ -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'],
],
];
}
}
+725
View File
@@ -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());
}
}
}
+132
View File
@@ -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