Files
Gnuboard7/app/Benchmark/BenchmarkProfileRegistry.php
HeuJung 5f4a733b3b 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종 전건 측정.
2026-08-02 14:44:33 +09:00

276 lines
8.9 KiB
PHP

<?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;
}
}