계측 축이 "목록 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종 전건 측정.
187 lines
10 KiB
Markdown
187 lines
10 KiB
Markdown
# 성능 계측 시스템 (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) — 확장 선언 훅 일반
|