계측 축이 "목록 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종 전건 측정.
276 lines
8.9 KiB
PHP
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;
|
|
}
|
|
}
|