Files
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

133 lines
8.6 KiB
YAML

# 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