diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 4725553..4a9e12c 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -6,7 +6,7 @@ - Java 17 / Spring Boot 4.0.1 / Gradle - JPA + MySQL + Flyway (스키마 마이그레이션 `database/migration/` + 시드 데이터 `database/seed/`) -- 커스텀 JWT 인증 (`@Auth` 파라미터 주입 + `JwtAuthenticationFilter`) + 이메일 인증(회원가입) +- 커스텀 JWT 인증 (`@Auth` 파라미터 주입 + `JwtAuthenticationFilter`) - FULLTEXT(ngram) 기반 강의 검색 - springdoc-openapi (Swagger UI) - H2 (통합 테스트) / p6spy (SQL 로깅) diff --git a/.claude/resources/perf/100/search/k6-probe-vu1.json b/.claude/resources/perf/100/search/k6-probe-vu1.json new file mode 100644 index 0000000..1927af6 --- /dev/null +++ b/.claude/resources/perf/100/search/k6-probe-vu1.json @@ -0,0 +1,63 @@ +{ + "phase": "measure", + "target": "search", + "endpoint": "GET /api/v1/courses/search", + "condition": { + "vus": 30, + "steady_state_duration": "1m", + "ramp_up": "30s", + "ramp_down": "30s", + "total_duration": "2m", + "db_cache": "warm (InnoDB 버퍼 풀은 재기동 없이 비울 수 없다)", + "app_cache": "없음", + "user_count": 50, + "course_rows": 26439, + "keyword_match_rows": "조합당 24건 (시드 24,000 / 1,000 조합)" + }, + "keywords": [ + "컴퓨터공학", + "기계설계", + "전자시스템", + "화학실험", + "경영분석", + "미디어데이터", + "바이오실험", + "로봇제어", + "금융최적화", + "스포츠계측" + ], + "requests": 3324, + "rps": 110.79265813985393, + "failed_rate": 0, + "checks_rate": 1, + "checks": [ + { + "name": "status is 200", + "passes": 3324, + "fails": 0 + }, + { + "name": "body is not empty", + "passes": 3324, + "fails": 0 + }, + { + "name": "searchedCourseResponses가 실려 있다", + "passes": 3324, + "fails": 0 + } + ], + "duration_ms": { + "med": 7.282, + "p95": 16.721399999999996, + "p99": 22.570529999999998, + "max": 135.51 + }, + "waiting_ms": { + "med": 7.218, + "p95": 16.49685, + "p99": 22.362709999999993, + "max": 135.244 + }, + "bytes_received": 41523337 +} \ No newline at end of file diff --git a/.claude/resources/perf/100/search/k6-test-summary-0.json b/.claude/resources/perf/100/search/k6-test-summary-0.json new file mode 100644 index 0000000..a2b60cb --- /dev/null +++ b/.claude/resources/perf/100/search/k6-test-summary-0.json @@ -0,0 +1,63 @@ +{ + "phase": "measure", + "target": "search", + "endpoint": "GET /api/v1/courses/search", + "condition": { + "vus": 30, + "steady_state_duration": "1m", + "ramp_up": "30s", + "ramp_down": "30s", + "total_duration": "2m", + "db_cache": "warm (InnoDB 버퍼 풀은 재기동 없이 비울 수 없다)", + "app_cache": "없음", + "user_count": 50, + "course_rows": 26439, + "keyword_match_rows": "조합당 24건 (시드 24,000 / 1,000 조합)" + }, + "keywords": [ + "컴퓨터공학", + "기계설계", + "전자시스템", + "화학실험", + "경영분석", + "미디어데이터", + "바이오실험", + "로봇제어", + "금융최적화", + "스포츠계측" + ], + "requests": 43545, + "rps": 362.8612868672005, + "failed_rate": 0, + "checks_rate": 1, + "checks": [ + { + "name": "status is 200", + "passes": 43545, + "fails": 0 + }, + { + "name": "body is not empty", + "passes": 43545, + "fails": 0 + }, + { + "name": "searchedCourseResponses가 실려 있다", + "passes": 43545, + "fails": 0 + } + ], + "duration_ms": { + "med": 63.861, + "p95": 114.4194, + "p99": 172.74107999999995, + "max": 933.574 + }, + "waiting_ms": { + "med": 63.739, + "p95": 114.21539999999999, + "p99": 172.54923999999988, + "max": 933.434 + }, + "bytes_received": 543902071 +} \ No newline at end of file diff --git a/.claude/resources/perf/100/search/query-stats-summary-0.md b/.claude/resources/perf/100/search/query-stats-summary-0.md new file mode 100644 index 0000000..63fae1e --- /dev/null +++ b/.claude/resources/perf/100/search/query-stats-summary-0.md @@ -0,0 +1,30 @@ +# query-stats-summary-0 — search + +상태: 0 = 원본 +측정: VU 30 / 2m / 캐시 warm / 요청 43545건 + +| # | 요청당 | calls | mean_ms | total_ms | 비중 | 행/호출 | 읽은행/반환행 | 출처 | 하는 일 | +|---|---|---|---|---|---|---|---|---|---| +| 1 | 0.9999 | 43542 | 2.439837 | 106235.424569 | 53.1024% | 26.2010 | 2.0000 | CourseRepository.findByKeyword | courses 테이블에서 FULLTEXT 인덱스(course_code, haksu_code, title_kr, title_en)로 MATCH ... AGAINST 검색, status = 'ACTIVE' 조건을 추가하고 같은 MATCH 식으로 정렬해 반환 | +| 2 | 0.9999 | 43539 | 1.645839 | 71658.193432 | 35.8188% | 77.2111 | 1.0000 | Course.schedules 지연 로딩 (SearchedCourseResponse.from에서 course.getSchedules() 호출로 트리거, @BatchSize(1000)) | course_schedules 테이블에서 course_id IN (...) 배치로 강의 시간표를 조회 | +| 3 | 1.9972 | 86966 | 0.103698 | 9018.201303 | 4.5078% | 0.0000 | - | - | 트랜잭션 제어 | +| 4 | 0.9996 | 43527 | 0.103635 | 4510.92689 | 2.2548% | 0.0000 | - | - | 트랜잭션 제어 | +| 5 | 0.9994 | 43521 | 0.10111 | 4400.438936 | 2.1996% | 0.0000 | - | - | 트랜잭션 제어 | +| 6 | 0.9993 | 43513 | 0.097309 | 4234.245109 | 2.1165% | 0.0000 | - | - | 트랜잭션 제어 | +| 7 | 0.0000 | 1 | 0.283292 | 0.283292 | 0.0001% | 1.0000 | 1.0000 | 미상 | 서버 버전 코멘트(@@version_comment) 조회 | + +## 쿼리 원문 + +[1] SELECT DISTINCTROW `c` . * FROM `courses` `c` WHERE MATCH ( `c` . `course_code` , `c` . `haksu_code` , `c` . `title_kr` , `c` . `title_en` ) AGAINST ( ? IN BOOLEAN MODE ) AND `c` . `status` = ? ORDER BY MATCH ( `c` . `course_code` , `c` . `haksu_code` , `c` . `title_kr` , `c` . `title_en` ) AGAINST ( ? IN BOOLEAN MODE ) + +[2] SELECT `s1_0` . `course_id` , `s1_0` . `id` , `s1_0` . `classroom` , `s1_0` . `day_of_week` , `s1_0` . `end_time` , `s1_0` . `period_code` , `s1_0` . `period_name` , `s1_0` . `start_time` FROM `course_schedules` `s1_0` WHERE `s1_0` . `course_id` IN (...) + +[3] SET `autocommit` = ? + +[4] COMMIT + +[5] SET SESSION TRANSACTION READ ONLY + +[6] SET SESSION TRANSACTION READ WRITE + +[7] SELECT @@`version_comment` LIMIT ? diff --git a/.claude/resources/perf/100/search/record.md b/.claude/resources/perf/100/search/record.md new file mode 100644 index 0000000..0c47fc5 --- /dev/null +++ b/.claude/resources/perf/100/search/record.md @@ -0,0 +1,122 @@ +# [PERF-100] GET /api/v1/courses/search + +> 이슈: #100 +> 브랜치: refactor/100-search-performance +> 대상 디렉토리: `.claude/resources/perf/100/search/` + +이 파일은 대상 엔드포인트 하나만 다룬다. 같은 이슈의 다른 엔드포인트는 각자의 디렉토리에 각자의 `record.md`를 가진다. + +## 진행 상태 + +> ⏳ 미완 / ✅ 완료 / ⏭️ 건너뜀. 재진입 시 ⏳로 표기된 가장 이른 Phase부터 재개한다. + +**준비 (대상당 1회)** + +| 1. 대상 | 2. 환경 | 3. 조건 | 4. 기준선 | +|---|---|---|---| +| ✅ | ✅ | ✅ | ✅ | + +**사이클 (반복)** + +| # | 기법 | 5. 설계 | 6. 스냅샷 | 7. 적용 | 8. 검증 | +|---|---|---|---|---|---| +| 1 | 없음 (병목 없음 판정으로 사이클 미진행) | ⏭️ | ⏭️ | ⏭️ | ⏭️ | + +**재개 메모**: 2026-08-28 Phase 9까지 완료. 기준선에서 병목 없음으로 판정해 사이클 없이 종료했다. 코드 변경 없음. +측정 중 발견한 정렬 문제는 #101로 분리했다(검색 결과 학년 정렬 누락, 관련도 정렬 방향 미지정). +측정 후 시드 24,000행(id 1,000,001~1,024,000)과 `../tokens.json`은 지웠다. 다시 재려면 Phase 3-A 적재와 Phase 4 토큰 발급부터 한다. + +## 대상 + +- 엔드포인트: `GET /api/v1/courses/search?keyword={keyword}` +- 실행 경로: `CourseController.searchCourses` (`CourseController.java:59`) → `CourseService.searchCourses` (`CourseService.java:108`) → `CourseRepository.findByKeyword` (`CourseRepository.java:46`) +- 인증: 화이트리스트 밖이라 `JwtAuthenticationFilter`를 탄다. 컨트롤러에 `@Auth`가 없고 필터는 DB를 보지 않으므로 회원 시드 없이 토큰만 있으면 된다. +- 예상 쿼리 목록 (요청 1회 기준, 매칭 강의 수를 n이라 할 때) + 1. `CourseRepository.findByKeyword` - 1회. 네이티브 쿼리. `MATCH(course_code, haksu_code, title_kr, title_en) AGAINST(? IN BOOLEAN MODE)`로 걸러 `status = 'ACTIVE'`를 추가 조건으로 두고, 같은 `MATCH` 식으로 `ORDER BY`한다. `SELECT DISTINCT c.*`. + 2. `Course.schedules` 지연 로딩 - `ceil(n / 1000)`회. `SearchedCourseResponse.from`이 `CourseScheduleFormatter.format(course.getSchedules())`로 컬렉션을 건드린다(`SearchedCourseResponse.java:41`). `@BatchSize(size = 1000)`(`Course.java:55`)이 걸려 있어 N회가 아니다. +- 쿼리가 붙지 않는 지점 (확인 완료) + - `course.is75MinLesson()` (`Course.java:232`) - 2에서 초기화된 컬렉션을 재사용한다 + - `course.getDepartment().getName()` - `CourseDepartment`는 enum이다 + - `ApiPerformanceInterceptor` - `System.nanoTime()` 기반 로깅만 한다 + - `SearchedCoursesResponse.of` - 리스트 래핑만 한다 + +## 측정 환경 + +> 값이 바뀌면 그 사실을 사이클 기록에 남긴다. 2026-08-28 Phase 2에서 재확인한 값이다. 데이터 규모, 카디널리티, 부하 조건은 Phase 3에서 확정한다. + +| 항목 | 값 | +|---|---| +| 프로파일 | perf (`application-perf.yml`). `application="uss-server-perf"` 확인, SQL 로깅 OFF, p6spy 미사용, `open-in-view: false` | +| DB | MySQL 8.0 / InnoDB (`uss-mysql`, `127.0.0.1:3307`, `uss_db`). `ngram_token_size=2`, utf8mb4_unicode_ci | +| 커넥션 풀 크기 | 10 (`minimum-idle`도 10) | +| InnoDB 버퍼 풀 크기 | 128MiB (2026-08-28). 철거 전에는 시드 약 3GB를 담기 위해 2GiB로 온라인 리사이즈했었다. Phase 3-A의 시드 규모가 이를 넘으면 리사이즈하고 여기에 시점을 남긴다 | +| 응답시간 히스토그램 | `http_server_requests_seconds_bucket` 75개 (SLO 100ms~5s) | +| performance_schema | ON. `statements_digest`, `events_statements_current` YES. digest 44건 적재 확인 | +| digest 길이 상한 | `max_digest_length` 1024. 대상 쿼리(`findByKeyword` 네이티브, `schedules` 배치 로딩)는 각 300자 안팎이라 잘리지 않는다. 1024를 넘는 건 Flyway 시드의 다중행 `INSERT IGNORE INTO courses`(1053자)뿐이며 Phase 4의 digest 리셋으로 측정 구간에서 사라진다. 올리지 않는다 | +| 데이터 규모 | `courses` 26,439 (앱 시드 2,439 + 성능 시드 24,000, id 1,000,001~1,024,000), `course_schedules` 79,819 (7,819 + 72,000, 시드 강의당 3.0), `members` 0. 2026-08-28 적재, 검증값 일치 | +| 규모 근거 | 운영(앱 시드 2,439행) 대비 10x. `findByKeyword`가 학년도·학기로 거르지 않으므로 학기가 ~10개 쌓인 운영 상태를 재현한다. 행당 실측(`courses` 1.19KB, `course_schedules` 0.24KB, 데이터+인덱스) 기준 시드 약 46MB로 버퍼 풀 128MiB 안에 든다. 철거 전 2,000,000행(약 820x)은 근거가 없었고 적재에 실패했다 | +| 카디널리티 | `status` 1종(`ACTIVE`) - `CourseStatus` 상수가 하나뿐이라 `AND c.status = 'ACTIVE'`가 거르는 행이 없다. `department` 20종, `area` 8종 균등(검증값 20 / 8). 검색 제목은 접두 40 x 접미 25 = 1,000조합, 조합당 매칭 = 24,000 / 1,000 = 24건(검증: `컴퓨터공학` 시드 24 / 실데이터 0). 실데이터 `컴퓨터` 21/2,440 = 0.86%와 같은 수준이다. ngram 구 검색의 DB 비용은 최종 매칭 수보다 가장 흔한 바이그램(접미 `공학` 등, 전체의 1/25 ≈ 960행)의 포스팅 리스트 크기에 먼저 비례한다 | +| 부하 조건 | VU 30, 유지 1m (ramp-up 30s + 유지 1m + ramp-down 30s = 총 2m), USER_COUNT 50, 키워드 조합어 10개 순환. 풀 10의 3배라 커넥션 대기가 섞이는 조건이며 호출자가 이를 알고 확정했다(2026-08-28). 워밍업은 VU 5, 30s | +| 캐시 상태 | warm 고정. InnoDB 버퍼 풀은 재기동 없이 비울 수 없고 애플리케이션 캐시는 없다. 매 측정 전 같은 워밍업으로 맞춘다 | +| 되돌리기 절차 | 불필요 (읽기 전용) | +| 시드 | `../seeds.sql`(변수 블록) + `course.sql`(`@course_title_mode = 'search'`). `member.sql` 미사용. 적재 직후 `fts_indexed` 24,000으로 일치했으나 호출자가 FULLTEXT 인덱스를 떼고 다시 만들어 색인은 재구축본이다. 되돌리기: `DELETE FROM courses WHERE id BETWEEN 1000001 AND 1024000` | +| 토큰 | `../tokens.json` (`mint-tokens.sh`, 회원 id 900001~900050). 만료됨, Phase 4에서 재발급 | + +## 기준선 (Baseline) + +| 지표 | 값 | +|---|---| +| p95 | 114.4194 ms | +| p99 | 172.74108 ms | +| med | 63.861 ms | +| RPS | 362.86 | +| 에러율 | 0% (43,545건 중 0) | +| check 통과율 | 100% (세 항목 모두 43,545 통과) | +| 요청당 쿼리 수 | 2.00 (`findByKeyword` 1.00 + `schedules` 배치 1.00) + 트랜잭션 제어문 5.0 | +| 요청당 DB 시간 | 4.6 ms (digest 총합 200,057 ms / 43,545건, 트랜잭션 제어문 포함) | +| 응답 크기 | 약 12.5 KB (543.9 MB / 43,545건, 평균 26.2건) | + +보조 관측 - 경합 없는 단건 (VU 1, 30s, `k6-probe-vu1.json`): med 7.282 / p95 16.7214 / p99 22.5705 ms, 110.79 rps (평균 왕복 9.0 ms). + +### 쿼리 통계 (total_ms 상위) + +> 전체: `query-stats-summary-0.md` / k6 요약: `k6-test-summary-0.json`. 진단 근거로 쓴 행만 옮긴다. + +| 요청당 | mean_ms | total_ms | 비중 | 읽은행/반환행 | 출처 | +|---|---|---|---|---|---| +| 0.9999 | 2.439837 | 106235.42 | 53.10% | 2.0 (26.2행/호출) | `CourseRepository.findByKeyword` | +| 0.9999 | 1.645839 | 71658.19 | 35.82% | 1.0 (77.2행/호출) | `Course.schedules` 배치 로딩 (`@BatchSize(1000)`) | +| 4.9955 (4건 합) | 0.10 | 22163.81 | 11.08% | - | 트랜잭션 제어 (`SET autocommit` ×2, `COMMIT`, `SET SESSION TRANSACTION READ ONLY` / `READ WRITE`) | + +### 진단 + +- 병목 성격: 없음. DB 쪽은 요청당 쿼리 2건(N+1 없음), `examined_per_sent` 2.0 / 1.0(읽고 버리는 행 없음), 합계 4.6 ms. 앱 쪽은 경합 없는 단건 7.3 ms로 DB 4.6 ms와의 차이가 3~4 ms에 그친다. +- 근거: VU 30에서 평균 왕복 82.7 ms는 단건 9.0 ms의 9배인데 처리량은 3.3배(110.8 → 362.9 rps)만 늘었다. 늘어난 74 ms는 요청 하나의 처리가 아니라 포화 대기다. 커넥션 대기는 아니다(풀 점유율 362.9 rps × 4.6 ms ≈ 17%). k6, JVM, MySQL(Docker VM)이 같은 8코어를 나눠 쓴 환경이라 포화 지점이 어느 프로세스 때문인지는 이 자료에 없다. +- 예상 쿼리 목록과 어긋난 지점: 대상 쿼리 두 건은 예상(1회 / ⌈n/1000⌉ = 1회)과 일치. 예상 목록에 없던 것은 요청당 5건의 트랜잭션 제어문(Spring이 `@Transactional(readOnly = true)` 경계에서 보내는 것)으로 합계 11.1%, 0.5 ms. +- 행/호출 26.2는 시드 24건 + 실데이터 매칭. 키워드 10개 중 일부(`화학실험`, `기계설계` 등)가 실제 강의 제목에도 걸린다. + +### 측정 중 발견한 사항 (성능 외) + +- `findByKeyword`의 `ORDER BY MATCH ... AGAINST`에 방향이 없어 관련도 오름차순(낮은 순)으로 나간다. 정책(`service-policy/course.md` 강의 검색 절 "관련도가 높은 순")과 반대다. 검색 결과에는 다른 조회와 달리 학년 정렬도 없다. → #101로 분리. +- 시드 모듈 `course.sql`의 `grade_code`는 `'01'`~`'05'`(전학년 = `'05'`)인데 실데이터는 `'0'`(전학년)~`'4'`다. 검색은 이 컬럼을 보지 않아 이번 측정에는 영향이 없다. 학년 정렬을 재는 대상이 생기면 모듈을 실데이터 형식에 맞춘다. + +--- + +## 최종 요약 + +| 구분 | 지표 | 최초 | 최종 | 변화 | +|---|---|---|---|---| +| 하드웨어 의존 | p95 | 114.4194 ms | 114.4194 ms | 없음 (동일 상태 0) | +| | p99 | 172.74108 ms | 172.74108 ms | 없음 | +| | RPS | 362.86 | 362.86 | 없음 | +| 하드웨어 독립 | 요청당 쿼리 수 | 2.00 (+ 트랜잭션 제어 5.0) | 2.00 (+ 5.0) | 없음 | +| | 읽은 행 / 반환 행 | 2.0 / 1.0 | 2.0 / 1.0 | 없음 | +| | 접근 방식과 인덱스 | FULLTEXT `ft_idx_course_search` (ngram) + `idx_course_id` (실행계획 미캡처, Phase 6 미진행) | 동일 | 없음 | +| | `Handler_read_rnd_next` | 미측정 (Phase 6 미진행) | - | - | +| | 캐시 hit / miss, 적중률 | 해당 없음 (애플리케이션 캐시 없음) | - | - | + +적용한 기법: 없음. 기준선에서 병목 없음으로 판정하고 종료했다. + +운영 반영 시 유의점: 스키마 변경 없음. 코드 변경 없음. + +측정 조건 요약: 운영 대비 10x(`courses` 26,439), VU 30 / 유지 1m / 캐시 warm, 키워드당 매칭 약 26건. 이 규모에서 요청당 DB 4.6 ms, 경합 없는 단건 7.3 ms. diff --git a/.claude/resources/perf/100/search/test-script.js b/.claude/resources/perf/100/search/test-script.js new file mode 100644 index 0000000..c8a3642 --- /dev/null +++ b/.claude/resources/perf/100/search/test-script.js @@ -0,0 +1,171 @@ +// PERF-100 / GET /api/v1/courses/search 부하 스크립트. +// 실행 명령은 .claude/skills/optimize-performance/template/commands.md에 있다. +// +// 템플릿과 다른 곳: KEYWORDS 풀. 시드의 검색 제목 설계(접두 40 x 접미 25)에서 고른 조합어 10개를 __ITER로 돌린다. +// 이 엔드포인트는 컨트롤러에 @Auth가 없고 필터가 DB를 보지 않으므로 회원 시드 없이 토큰만 있으면 된다. + +import http from 'k6/http'; +import exec from 'k6/execution'; +import { check } from 'k6'; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; +const USER_COUNT = Number(__ENV.USER_COUNT || 50); +const PHASE = __ENV.PHASE || 'measure'; + +const TARGET = 'search'; +const ENDPOINT = 'GET /api/v1/courses/search'; +const CONDITION = { + vus: 30, + steady_state_duration: '1m', + ramp_up: '30s', + ramp_down: '30s', + total_duration: '2m', + db_cache: 'warm (InnoDB 버퍼 풀은 재기동 없이 비울 수 없다)', + app_cache: '없음', + user_count: USER_COUNT, + course_rows: 26439, + keyword_match_rows: '조합당 24건 (시드 24,000 / 1,000 조합)', +}; + +// 시드의 접두 40개 x 접미 25개 조합에서 고르게 흩어 뽑았다. +// ngram BOOLEAN MODE는 구(phrase) 검색이라 조합어 하나가 그 조합 행만 매칭한다. +// 한 키워드만 반복하면 같은 행만 조회해 버퍼 풀에 완전히 올라간 상태를 재게 되므로 순회한다. +const KEYWORDS = [ + '컴퓨터공학', + '기계설계', + '전자시스템', + '화학실험', + '경영분석', + '미디어데이터', + '바이오실험', + '로봇제어', + '금융최적화', + '스포츠계측', +]; + +// tokens.json은 이슈 디렉토리에 있다(대상 간 공유). +// 형태: [{ "memberId": 900001, "accessToken": "..." }, ...] +const tokens = JSON.parse(open('../tokens.json')); + +if (tokens.length !== USER_COUNT) { + throw new Error( + `토큰 ${tokens.length}건 / 필요 ${USER_COUNT}건. mint-tokens.sh의 --count와 USER_COUNT를 맞춰라.` + ); +} + +const scenarios = { + warmup: { + executor: 'constant-vus', + vus: 5, + duration: '30s', + }, + measure: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '30s', target: 30 }, + { duration: '1m', target: 30 }, + { duration: '30s', target: 0 }, + ], + }, +}; + +export const options = { + scenarios: { [PHASE]: scenarios[PHASE] }, + summaryTrendStats: ['avg', 'min', 'med', 'p(90)', 'p(95)', 'p(99)', 'max'], + thresholds: { + http_req_failed: ['rate<0.01'], + checks: ['rate>0.99'], + }, +}; + +// 비JSON 응답에서 예외를 던지지 않는다. 파싱 실패는 null로 떨어뜨려 check 실패로 드러낸다. +function parseBody(res) { + if (res.status !== 200 || res.body === null || res.body.length <= 2) { + return null; + } + try { + return res.json(); + } catch (e) { + return null; + } +} + +export default function () { + const iter = exec.scenario.iterationInTest; + const token = tokens[iter % tokens.length]; + const keyword = KEYWORDS[iter % KEYWORDS.length]; + + const params = { + headers: { + 'access-token': token.accessToken, + }, + }; + + const res = http.get( + `${BASE_URL}/api/v1/courses/search?keyword=${encodeURIComponent(keyword)}`, + params + ); + + check(res, { + 'status is 200': (r) => r.status === 200, + 'body is not empty': (r) => r.body !== null && r.body.length > 2, + 'searchedCourseResponses가 실려 있다': (r) => { + const body = parseBody(r); + return ( + body !== null && + Array.isArray(body.searchedCourseResponses) && + body.searchedCourseResponses.length > 0 + ); + }, + }); +} + +export function handleSummary(data) { + const m = data.metrics || {}; + const val = (name, key) => (m[name] && m[name].values[key] !== undefined ? m[name].values[key] : null); + const trend = (name) => ({ + med: val(name, 'med'), + p95: val(name, 'p(95)'), + p99: val(name, 'p(99)'), + max: val(name, 'max'), + }); + + const summary = { + phase: PHASE, + target: TARGET, + endpoint: ENDPOINT, + condition: CONDITION, + keywords: KEYWORDS, + requests: val('http_reqs', 'count'), + rps: val('http_reqs', 'rate'), + failed_rate: val('http_req_failed', 'rate'), + checks_rate: val('checks', 'rate'), + checks: ((data.root_group || {}).checks || []).map((c) => ({ + name: c.name, + passes: c.passes, + fails: c.fails, + })), + duration_ms: trend('http_req_duration'), + waiting_ms: trend('http_req_waiting'), + bytes_received: val('data_received', 'count'), + }; + + const num = (x, d) => (typeof x === 'number' ? x.toFixed(d) : '-'); + const line = [ + `[${PHASE}] ${TARGET}`, + `요청 ${summary.requests}건`, + `p95 ${num(summary.duration_ms.p95, 1)}ms`, + `p99 ${num(summary.duration_ms.p99, 1)}ms`, + `실패율 ${num(summary.failed_rate * 100, 2)}%`, + `check ${num(summary.checks_rate * 100, 2)}%`, + ].join(' / '); + + const out = { stdout: `\n${line}\n\n` }; + + if (__ENV.SUMMARY_OUT) { + out[__ENV.SUMMARY_OUT] = JSON.stringify(summary, null, 2); + } + + return out; +} diff --git a/.claude/resources/perf/100/seeds.sql b/.claude/resources/perf/100/seeds.sql new file mode 100644 index 0000000..00931d9 --- /dev/null +++ b/.claude/resources/perf/100/seeds.sql @@ -0,0 +1,26 @@ +-- PERF-100 시드 (이슈 공용) +-- 대상: GET /api/v1/courses/search +-- +-- 변수 블록만 둔다. 모듈 본문은 template/seeds/ 에 있고 실행 시 cat으로 이어 붙인다. +-- cat $PERF_DIR/seeds.sql $SEEDS/course.sql | mysqlp +-- +-- 규모: 운영(앱 시드 courses 2,439) 대비 10x. 검색이 학년도·학기로 거르지 않아 학기가 ~10개 쌓인 운영 상태를 재현한다. +-- search 모드 조합당 매칭 = 24,000 / 1,000 = 24건. 실데이터 선택도(컴퓨터 21/2,440)와 같은 수준이다. +-- 크기: 행당 실측(courses 1.19KB, course_schedules 0.24KB) 기준 약 46MB. 버퍼 풀 128MiB 안에 든다. +-- 회원: 대상 컨트롤러에 @Auth가 없어 회원 시드가 필요 없다. 토큰은 mint-tokens.sh로 서명한다. member.sql은 붙이지 않는다. +-- +-- 되돌리기 (FK CASCADE로 course_schedules가 따라 지워진다): +-- DELETE FROM courses WHERE id BETWEEN 1000001 AND 1024000; + +-- 재귀 CTE 기본 깊이 상한은 1000이다. 안 올리면 1000행에서 끊긴다. +SET SESSION cte_max_recursion_depth = 10000000; + +-- 강의. @course_start는 SELECT MAX(id) FROM courses; (2026-08-28 기준 2,439) 보다 커야 한다. +SET @course_start = 1000001; +SET @course_count = 24000; +SET @course_dept_count = 20; +SET @course_area_count = 8; +SET @schedules_per_course = 3; +SET @course_term_year = 2026; +SET @course_term = 'FIRST'; +SET @course_title_mode = 'search'; diff --git a/.claude/skills/fix-concurrency/template/mint-tokens.sh b/.claude/skills/_shared/mint-tokens.sh similarity index 97% rename from .claude/skills/fix-concurrency/template/mint-tokens.sh rename to .claude/skills/_shared/mint-tokens.sh index 2031f04..99e6af9 100644 --- a/.claude/skills/fix-concurrency/template/mint-tokens.sh +++ b/.claude/skills/_shared/mint-tokens.sh @@ -15,7 +15,7 @@ # bash mint-tokens.sh --secret {서명키} --start {회원 id 시작값} --count {개수} --out {경로} # # 예: -# bash .claude/skills/fix-concurrency/template/mint-tokens.sh \ +# bash .claude/skills/_shared/mint-tokens.sh \ # --secret conc-only-local-secret-key-not-for-any-real-environment \ # --start 900001 --count 500 \ # --out .claude/resources/concurrency/90/tokens.json diff --git a/.claude/skills/fix-concurrency/SKILL.md b/.claude/skills/fix-concurrency/SKILL.md index 6b19b95..b1b338c 100644 --- a/.claude/skills/fix-concurrency/SKILL.md +++ b/.claude/skills/fix-concurrency/SKILL.md @@ -76,7 +76,7 @@ effort: xhigh 이 프로젝트는 MySQL 8.0 / InnoDB다. 접속은 아래 함수를 쓴다. ```bash -mysqlc() { docker exec -i -e MYSQL_PWD=root uss-mysql mysql -uroot uss_db "$@"; } +mysqlc() { docker exec -i -e MYSQL_PWD=root uss-mysql mysql -uroot --default-character-set=utf8mb4 --init-command="SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci" uss_db "$@"; } ``` **문자열 변수로 만들지 마라.** `MYSQL_CONC="mysql -h 127.0.0.1 -P 3307 -u root uss_db"` 형태는 @@ -88,6 +88,11 @@ mysqlc() { docker exec -i -e MYSQL_PWD=root uss-mysql mysql -uroot uss_db "$@"; 함수는 두 문제를 한 번에 피한다. `docker exec`의 `-i`는 `mysqlc < 파일.sql` 형태를 위해 필요하다. +charset과 collation 옵션도 빼지 마라. 컨테이너 안의 클라이언트는 로케일이 없어 `latin1`로 붙는다. +`--default-character-set=utf8mb4`가 없으면 쿼리 안의 한글 리터럴이 `?`가 되어 결과가 조용히 틀리고, +`--init-command="SET NAMES ... COLLATE utf8mb4_unicode_ci"`가 없으면 collation이 서버 기본(`utf8mb4_0900_ai_ci`)으로 남아 +`utf8mb4_unicode_ci`인 컬럼과 사용자 변수의 비교가 `ERROR 1267`로 죽는다. + **동시성 측정에서만 걸리는 것** - **격리 수준이 결과를 바꾼다.** MySQL 기본은 `REPEATABLE READ`다. 낙관적 락의 재시도 빈도와 @@ -155,7 +160,7 @@ mysqlc() { docker exec -i -e MYSQL_PWD=root uss-mysql mysql -uroot uss_db "$@"; | 파일 | 위치 | 만드는 Phase | 템플릿 | |---|---|---|---| | `seeds.sql` | 이슈 | 3 | `template/seeds/` 모듈 조합 | -| `tokens.json` | 이슈 | 3 | `template/mint-tokens.sh` | +| `tokens.json` | 이슈 | 3 | `.claude/skills/_shared/mint-tokens.sh` | | `record.md` | 대상 | 1 | `template/CONCURRENCY-template.md` | | `invariant-check.sql` | 대상 | 1 | `template/invariant-check.sql` | | `burst-script.js` | 대상 | 3 | `template/k6-burst-template.js` | diff --git a/.claude/skills/fix-concurrency/phases/phase-1-invariant.md b/.claude/skills/fix-concurrency/phases/phase-1-invariant.md index e7969d7..d82941d 100644 --- a/.claude/skills/fix-concurrency/phases/phase-1-invariant.md +++ b/.claude/skills/fix-concurrency/phases/phase-1-invariant.md @@ -92,7 +92,7 @@ export CONC_DIR=.claude/resources/concurrency/{이슈번호} export TARGET_DIR=$CONC_DIR/{슬러그} - mysqlc() { docker exec -i -e MYSQL_PWD=root uss-mysql mysql -uroot uss_db "$@"; } + mysqlc() { docker exec -i -e MYSQL_PWD=root uss-mysql mysql -uroot --default-character-set=utf8mb4 --init-command="SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci" uss_db "$@"; } ``` - `mysqlc`는 문자열 변수가 아니라 함수다. 이유는 `SKILL.md`의 **측정 스택**을 보라. diff --git a/.claude/skills/fix-concurrency/phases/phase-3-contention.md b/.claude/skills/fix-concurrency/phases/phase-3-contention.md index 582501a..df972d9 100644 --- a/.claude/skills/fix-concurrency/phases/phase-3-contention.md +++ b/.claude/skills/fix-concurrency/phases/phase-3-contention.md @@ -10,7 +10,7 @@ ### 참조 파일 - `.claude/skills/fix-concurrency/template/seeds/README.md` -- `.claude/skills/fix-concurrency/template/mint-tokens.sh` +- `.claude/skills/_shared/mint-tokens.sh` - `.claude/skills/fix-concurrency/template/k6-burst-template.js` ### 절차 @@ -65,7 +65,7 @@ 시드가 넣는 `password`는 BCrypt 해시가 아닌 더미 문자열이라 로그인 대조를 통과하지 못한다. ```bash - bash .claude/skills/fix-concurrency/template/mint-tokens.sh \ + bash .claude/skills/_shared/mint-tokens.sh \ --secret "$(grep 'secret-key:' src/main/resources/application-conc.yml | head -1 | sed 's/.*secret-key: *//')" \ --start {시드 회원 id 시작값} \ --count {VU 수} \ diff --git a/.claude/skills/fix-concurrency/phases/phase-4-reproduce.md b/.claude/skills/fix-concurrency/phases/phase-4-reproduce.md index d91ab33..8032f0e 100644 --- a/.claude/skills/fix-concurrency/phases/phase-4-reproduce.md +++ b/.claude/skills/fix-concurrency/phases/phase-4-reproduce.md @@ -25,7 +25,7 @@ ```bash CONC_DIR=.claude/resources/concurrency/{이슈번호} TARGET_DIR=$CONC_DIR/{슬러그} - mysqlc() { docker exec -i -e MYSQL_PWD=root uss-mysql mysql -uroot uss_db "$@"; } + mysqlc() { docker exec -i -e MYSQL_PWD=root uss-mysql mysql -uroot --default-character-set=utf8mb4 --init-command="SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci" uss_db "$@"; } N=0 # 후보 번호. 원본은 0이다 diff --git a/.claude/skills/fix-concurrency/template/restart-app.sh b/.claude/skills/fix-concurrency/template/restart-app.sh index 27e63f2..162e661 100755 --- a/.claude/skills/fix-concurrency/template/restart-app.sh +++ b/.claude/skills/fix-concurrency/template/restart-app.sh @@ -23,7 +23,7 @@ MGMT_PORT=${MGMT_PORT:-8081} POOL_MIN=${POOL_MIN:-500} # Hikari minimum-idle. 0이면 풀 충전 대기를 건너뛴다 BOOT_LOG=${BOOT_LOG:-build/conc-boot.log} -mysqlc() { docker exec -i -e MYSQL_PWD=root uss-mysql mysql -uroot uss_db "$@"; } +mysqlc() { docker exec -i -e MYSQL_PWD=root uss-mysql mysql -uroot --default-character-set=utf8mb4 --init-command="SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci" uss_db "$@"; } echo "레포 루트: $REPO_ROOT" echo diff --git a/.claude/skills/optimize-performance/SKILL.md b/.claude/skills/optimize-performance/SKILL.md index 5178f4e..6a2d5f8 100644 --- a/.claude/skills/optimize-performance/SKILL.md +++ b/.claude/skills/optimize-performance/SKILL.md @@ -11,107 +11,100 @@ effort: xhigh --- # 성능 최적화 + 각 단계에서 확인할 항목을 호출자에게 안내하고, 관측된 사실을 정리해 제시한다. 무엇이 병목인지, 어떤 기법을 채택할지, 어떤 트레이드오프를 감수할지는 호출자가 판단한다. +확정된 설계를 코드에 반영하는 것은 스킬이 한다. -애플리케이션 기동, k6 부하 테스트, 데이터베이스 쿼리는 호출자가 직접 실행하며, 명령어와 입력 형식을 제시하고 결과를 받는다. +애플리케이션 기동, k6 부하 테스트, 데이터베이스 쿼리는 호출자가 직접 실행한다. 스킬은 명령어와 입력 형식을 제시하고 결과 파일을 읽는다. 대상 API: $ARGUMENTS +## 역할 경계 + +경계는 **사실이냐 판단이냐**다. + +| 스킬이 한다 (사실) | 호출자가 한다 (판단) | +|---|---| +| 수치 재구성 (요청당 호출 수, 총 시간 비중, 전후 델타) | 병목이 어디인지 | +| 쿼리 원문을 리포지토리 메서드에 매핑 | 그 쿼리가 왜 느린지 | +| 실행계획 노드별 수치를 표로 정리 | 어느 노드가 문제인지 | +| 기법 선택지와 각각의 비용을 나열 | 어떤 기법을 쓸지 | +| 확정된 설계를 코드에 반영 | 설계의 각 항목을 무엇으로 정할지 | + +관측 자료를 해석하는 Phase(1, 4, 6, 8)와 설계를 정하는 Phase(5)는 아래 순서를 지킨다. + +1. 관측 자료를 먼저 전부 펼친다. 자료에 해석을 섞지 마라. +2. 호출자에게 해석(또는 설계)을 묻고 **답을 기다린다.** 결론을 먼저 말하지 마라. +3. 답이 오면 타당성을 판정한다. 타당하면 왜 타당한지 한 줄로 확인하고, 아니면 관측값으로 반례를 든다. +4. 호출자가 모르겠다고 하거나 답이 막히면, 그때 답과 근거를 제시한다. + ## 측정 스택 -이 프로젝트는 MySQL 8.0 / InnoDB다. PostgreSQL 기준의 관측 방법을 그대로 옮겨 쓰지 마라. +MySQL 8.0 / InnoDB, 컨테이너 `uss-mysql`, 호스트 포트 3307. PostgreSQL 기준의 관측 방법을 옮겨 쓰지 마라. | 목적 | 수단 | |---|---| | 쿼리별 통계 | `performance_schema.events_statements_summary_by_digest` | | 통계 리셋 | `TRUNCATE TABLE performance_schema.events_statements_summary_by_digest` | -| 실행계획 (실측) | `EXPLAIN ANALYZE` (MySQL 8.0.18+, SELECT만) | +| 실행계획 (실측) | `EXPLAIN ANALYZE` (SELECT만) | | 실행계획 (추정) | `EXPLAIN FORMAT=JSON` | -| 접근 방식별 실제 작업량 | `FLUSH STATUS` → 쿼리 → `SHOW SESSION STATUS LIKE 'Handler_%'` | -| 옵티마이저 통계 갱신 | `ANALYZE TABLE {테이블}` | -| 인덱스 카디널리티 | `SHOW INDEX FROM {테이블}` | +| 접근 방식별 실제 작업량 | `FLUSH STATUS` → 쿼리 → `SHOW SESSION STATUS` (`Handler_%`, `Sort_%`) | +| 옵티마이저 통계 갱신 | `ANALYZE TABLE` | +| 인덱스 카디널리티 | `SHOW INDEX FROM` | -접속 명령은 아래를 쓴다. 이후 모든 DB 명령이 `$MYSQL_PERF`를 쓴다. +PostgreSQL과 달라서 측정에 영향을 주는 것: + +- `TIMER_WAIT` 계열은 **피코초**다. ms는 `/1e9`. +- `EXPLAIN ANALYZE`에 `BUFFERS`가 없다. 읽은 페이지 대신 `Handler_%` 카운터로 **읽은 행 수**를 본다. +- `VACUUM`이 없다. 갱신할 것은 옵티마이저 통계(`ANALYZE TABLE`)뿐이다. +- InnoDB 버퍼 풀은 재기동 없이 비울 수 없고 애플리케이션 캐시도 없다. 측정은 **warm으로 통일**하고 매번 같은 워밍업으로 상태를 맞춘다. + +**셸 환경.** 호출자가 새 터미널마다 한 번 source한다. 경로 변수, 서명키, `mysqlp` 접속 함수가 여기서 정의된다. +접속 옵션의 이유는 스크립트 주석에 있다. 명령 블록에 이 정의를 다시 적지 마라. ```bash -export MYSQL_PWD=root -export MYSQL_PERF="mysql -h 127.0.0.1 -P 3307 -u root uss_db" +source .claude/skills/optimize-performance/template/perf-env.sh {이슈번호} {슬러그} ``` -**PostgreSQL과 다른 점 중 측정에 영향을 주는 것** - -- `SUM_TIMER_WAIT`, `AVG_TIMER_WAIT`는 **피코초**다. ms로 보려면 `/1e9`로 나눈다 -- `EXPLAIN ANALYZE`에 `BUFFERS`가 없다. 페이지 단위 I/O 대신 `Handler_%` 카운터로 **읽은 행 수**를 본다 -- `VACUUM`이 없다. InnoDB는 purge가 자동이라 별도 회수 명령이 없고, 갱신할 것은 옵티마이저 통계(`ANALYZE TABLE`)뿐이다 -- InnoDB 버퍼 풀은 **재기동 없이 비울 수 없다.** 캐시를 cold로 만드는 수단이 없으므로 측정은 warm으로 통일한다 -- 애플리케이션 캐시(Redis 등)를 쓰지 않는다. `FLUSHDB` 같은 캐시 초기화 단계는 없다 +Phase 4, 6, 8이 제시하는 명령은 `template/commands.md`에 있다. phase 파일에 옮겨 적지 않는다. ## 진입 지시 -1. 현재 브랜치에서 이슈 번호를 파싱한다(`git branch --show-current`). -2. 이슈 번호를 뽑았으면 `Glob(.claude/resources/perf/{이슈번호}/*/record.md)`으로 진행 중인 대상을 찾는다. - - 대상이 여러 개면 목록을 보고하고 어느 대상을 이어서 할지 호출자에게 묻는다. - `$ARGUMENTS`가 그중 하나를 가리키면 그 대상으로 간다. - - 대상을 정했으면 그 `record.md`를 `Read(limit: 25)`로 최상단 **진행 상태** 표만 읽고, +1. 현재 브랜치에서 이슈 번호를 파싱한다 (`git branch --show-current`). +2. `Glob(.claude/resources/perf/{이슈번호}/*/record.md)`으로 진행 중인 대상을 찾는다. + - 여러 개면 목록을 보고하고 어느 대상을 이어서 할지 묻는다. `$ARGUMENTS`가 그중 하나를 가리키면 그 대상이다. + - 대상을 정했으면 그 `record.md`를 `Read(limit: 30)`로 **진행 상태**와 **재개 메모**만 읽고, ⏳로 표기된 가장 이른 Phase 파일을 Read해 그 지점부터 재개한다. 전체를 읽지 마라. - - 해당하는 `record.md`가 없거나 `$ARGUMENTS`가 새 대상이면 `phases/phase-1-target.md`부터 시작한다. -3. Phase 간 이동은 항상 현재 phase 파일의 **다음 Phase 조건**을 따른다. -4. 각 Phase를 마치면 그 대상의 `record.md`에 있는 **진행 상태**를 갱신한다. - 이 표가 유일한 상태 저장소다. 별도 state 파일이나 인덱스 파일을 두지 마라. - -## 분석 주도 규칙 - -관측 자료의 해석은 호출자가 한다. 이 규칙은 Phase 1, 4, 6, 8에 적용된다. - -1. 관측 자료를 먼저 전부 펼친다. 자료에 해석을 섞지 마라. -2. 호출자에게 해석을 묻고 **답을 기다린다.** 결론을 먼저 말하지 마라. -3. 답이 오면 타당성을 판정한다. 타당하면 왜 타당한지 한 줄로 확인하고, 아니면 관측값으로 반례를 든다. -4. 호출자가 모르겠다고 하거나 답이 막히면, 그때 답과 근거를 제시한다. - -**스킬이 하는 일과 하지 않는 일의 경계는 "사실이냐 판단이냐"다.** - -| 스킬이 한다 (사실) | 호출자가 한다 (판단) | -|---|---| -| 수치 재구성 (요청당 호출 수, 총 시간 비중, 전후 델타) | 병목이 어디인지 | -| 쿼리 원문을 리포지토리 메서드에 매핑 | 그 쿼리가 왜 느린지 | -| 실행계획 노드별 수치를 표로 정리 | 어느 노드가 문제인지 | -| 기법 선택지와 각각의 비용을 나열 | 어떤 기법을 쓸지 | + - `record.md`가 없거나 `$ARGUMENTS`가 새 대상이면 `phases/phase-1-target.md`부터 시작한다. +3. Phase 간 이동은 현재 phase 파일의 **다음 Phase 조건**을 따른다. +4. 각 Phase를 마치면 `record.md`의 **진행 상태**를 갱신한다. 이 표가 유일한 상태 저장소다. + 재개할 때 먼저 알아야 할 사실(환경 변화, 미결 사항)은 **재개 메모**에 적는다. 별도 state 파일을 두지 마라. ## 대상 진행 규칙 -**엔드포인트가 여러 개 주어져도 한 번에 한 API씩 진행한다.** -한 대상이 Phase 9까지 끝난 뒤에 다음 대상의 Phase 1로 간다. - -아래는 하지 마라. - -- 여러 대상의 슬러그 디렉토리, `record.md`, `test-script.js`를 한꺼번에 만드는 것 -- 한 측정 구간에서 여러 대상의 부하를 연달아 돌리는 것 -- 한 대상의 Phase 7을 적용한 뒤, 아직 기준선을 잡지 않은 다른 대상을 재는 것 -- 여러 대상의 결과를 모아 한 번에 보고하는 것 - +엔드포인트가 여러 개 주어져도 **한 번에 한 API씩** 진행한다. 한 대상이 Phase 9까지 끝난 뒤 다음 대상의 Phase 1로 간다. 측정 자원이 전부 하나뿐이라서다. | 자원 | 동시에 진행하면 생기는 일 | |---|---| -| `events_statements_summary_by_digest` (인스턴스 전역) | 리셋 없이 다음 대상을 재면 통계가 섞인다. `per_req`의 분모가 그 대상의 `requests`이므로 요청당 쿼리 수가 틀린 값이 된다 | -| 애플리케이션 인스턴스 | 한 대상에 기법을 적용하고 재기동하면, 아직 재지 않은 대상의 `-0`이 "아무것도 적용하지 않은 원본"이 아니게 된다 | -| 커넥션 풀, DB | 부하가 겹치면 서로의 응답시간에 경합이 섞인다 | -| InnoDB 버퍼 풀 | 앞 대상의 부하가 채워둔 페이지가 다음 대상의 워밍업 상태를 바꾼다 | +| digest 통계 (인스턴스 전역) | 리셋 없이 다음 대상을 재면 섞인다. `per_req` 분모가 그 대상의 요청 수이므로 요청당 쿼리 수가 틀린다 | +| 애플리케이션 인스턴스 | 한 대상에 기법을 적용해 재기동하면, 아직 재지 않은 대상의 `-0`이 원본이 아니게 된다 | +| 커넥션 풀, DB, 버퍼 풀 | 부하가 겹치면 경합과 워밍업 상태가 섞인다 | -**예외는 이슈 공용 산출물뿐이다.** `seeds.sql`과 `tokens.json`은 대상별로 만들지 않고 이슈에서 한 번만 만들어 공유한다. +하지 않는 것: 여러 대상의 디렉토리와 `record.md`를 한꺼번에 만드는 것, 한 측정 구간에 여러 대상의 부하를 잇는 것, +한 대상의 Phase 7을 적용한 뒤 기준선 없는 다른 대상을 재는 것, 결과를 모아 한 번에 보고하는 것. -진행하지 않는 대상은 어디에도 기록하지 않는다. 남은 대상 목록은 호출자가 쥔다. -Phase 1에서 "나머지 {n}개는 이 대상이 끝난 뒤에 진행한다"고 알리고, Phase 9에서 다시 확인한다. +이슈 공용 산출물(`seeds.sql`, `tokens.json`)만 예외로 공유한다. +남은 대상 목록은 호출자가 쥔다. Phase 1에서 "나머지는 이 대상이 끝난 뒤"라고 알리고 Phase 9에서 다시 확인한다. ## 산출물 규약 -이슈 하나당 디렉토리 하나, 대상 엔드포인트 하나당 그 아래 하위 디렉토리 하나를 쓴다. -시드와 토큰은 이슈 전체가 공유하고, 나머지는 전부 대상별로 독립이다. +이슈 하나당 디렉토리 하나, 대상 엔드포인트 하나당 그 아래 하위 디렉토리 하나. ``` .claude/resources/perf/{이슈번호}/ -├── seeds.sql # 이슈 공용 +├── seeds.sql # 이슈 공용 (변수 블록만) ├── tokens.json # 이슈 공용 (gitignore 대상) └── {엔드포인트-슬러그}/ ├── record.md @@ -121,67 +114,51 @@ Phase 1에서 "나머지 {n}개는 이 대상이 끝난 뒤에 진행한다"고 └── query-plan-{n}.txt ``` -| 파일 | 위치 | 만드는 Phase | 템플릿 | -|---|---|---|---| -| `seeds.sql` | 이슈 | 3-A (시드가 필요한 경우만) | `template/seeds/` 모듈 조합 | -| `tokens.json` | 이슈 | 4, 8 | - | -| `record.md` | 대상 | 1 | `template/PERF-template.md` | -| `test-script.js` | 대상 | 3-B | `template/k6-script-template.js` | -| `k6-test-summary-{n}.json` | 대상 | 4, 8 | - | -| `query-stats-summary-{n}.md` | 대상 | 4, 8 | `template/query-stats-template.md` | -| `query-plan-{n}.txt` | 대상 | 6, 8 | - | - -템플릿 상단의 **작성 규칙**이 해당 산출물의 작성 기준이다. phase 파일에 규칙을 중복해 적지 마라. - -호출자에게 제시하는 셸 명령에서는 Phase 1에서 잡은 `$PERF_DIR`(이슈)와 `$TARGET_DIR`(대상)를 쓴다. +| 파일 | 만드는 Phase | 템플릿 | +|---|---|---| +| `seeds.sql` | 3-A (시드가 필요한 경우만) | `template/seeds/` 모듈 조합 | +| `tokens.json` | 4 | `mint-tokens.sh` 출력 | +| `record.md` | 1 | `template/PERF-template.md` | +| `test-script.js` | 3-B | `template/k6-script-template.js` | +| `k6-test-summary-{n}.json` | 4, 8 | k6 `handleSummary` 출력 | +| `query-stats-summary-{n}.md` | 4, 8 | `template/query-stats-template.md` | +| `query-plan-{n}.txt` | 6, 8 | 원본 그대로 | + +템플릿 상단의 **작성 규칙**이 그 산출물의 작성 기준이다. phase 파일에 규칙을 중복해 적지 마라. Read와 Write의 대상 경로에는 셸 변수가 통하지 않는다. 전체 경로를 쓴다. -### 파일명의 `{n}` +**`{n}`은 사이클 번호가 아니라 코드의 상태 번호다.** 0은 아무것도 적용하지 않은 원본, n은 사이클 n까지 적용한 상태. +사이클 n의 개선 전 자료는 `-{n-1}`, 개선 후 자료는 `-{n}`이다. -`{n}`은 사이클 번호가 아니라 **코드의 상태 번호**다. +측정 산출물의 형태: -| n | 상태 | 만드는 Phase | -|---|---|---| -| 0 | 아무것도 적용하지 않은 원본 | 4 (요약, 통계), 6 (실행계획) | -| 1 | 사이클 1의 기법을 적용한 상태 | 8 | -| 2 | 사이클 2까지 적용한 상태 | 8 | - -사이클 n의 **개선 전** 자료는 `-{n-1}`, **개선 후** 자료는 `-{n}`이다. - -### 측정 산출물의 형태 - -- 비교 대상이 되는 측정 출력은 전부 파일로 남기고, 스킬은 그 파일을 Read로 읽는다. - 터미널 출력을 붙여넣게 하지 마라. -- **`k6-test-summary-{n}.json`과 `query-stats-summary-{n}.md`는 가공본이다.** - 호출자가 명령으로 뽑은 1차 출력을 스킬이 읽고, 같은 경로에 소비 가능한 형태로 다시 쓴다. - 1차 출력을 따로 보존하지 않는다. 대신 원문 없이는 재현할 수 없는 것(쿼리 원문)은 가공본 안에 포함시킨다. -- **`query-plan-{n}.txt`는 원본 그대로 둔다.** 실행계획은 노드 트리 전체가 근거이므로 요약이 원본을 대신할 수 없다. - 가공이 필요하면 대화에서 표로 정리해 제시하고, 파일은 손대지 않는다. -- 수치를 임의로 반올림하지 마라. 명령이 뽑아준 자릿수를 그대로 옮긴다. -- 앞선 상태(`{n-1}` 이하)의 파일을 덮어쓰지 마라. -- `record.md`에는 원본을 옮겨 적지 않고 해석과 판정만 적는다. -- 비교 대상이 아닌 일회성 조회(Phase 3-A의 행 수 확인, Phase 5-B의 `SHOW CREATE TABLE`과 `SHOW INDEX`)는 파일로 남기지 않는다. +- 비교 대상이 되는 출력은 전부 파일로 남기고 스킬은 그 파일을 Read한다. 터미널 출력을 붙여넣게 하지 마라. +- `k6-test-summary-{n}.json`과 `query-stats-summary-{n}.md`는 **가공본**이다. 1차 출력을 읽고 같은 경로에 소비 가능한 형태로 다시 쓴다. + 1차 출력은 따로 보존하지 않되, 원문 없이는 재현할 수 없는 것(쿼리 원문)은 가공본에 포함한다. +- `query-plan-{n}.txt`는 **원본 그대로** 둔다. 노드 트리 전체가 근거다. 가공은 대화의 표로만 한다. +- 수치를 임의로 반올림하지 마라. 앞선 상태의 파일을 덮어쓰지 마라. +- `record.md`에는 원본을 옮기지 않고 해석과 판정만 적는다. +- 일회성 조회(행 수 확인, `SHOW CREATE TABLE`, `SHOW INDEX`)는 파일로 남기지 않는다. ## Phase 인덱스 -| Phase | 파일 | 한 줄 요약 | +| Phase | 파일 | 요약 | |---|---|---| -| 1 | `phases/phase-1-target.md` | 이슈와 브랜치 확보, 엔드포인트 확정, 실행 경로 파악, `record.md` 생성 | -| 2 | `phases/phase-2-environment.md` | perf 프로파일, 히스토그램, `performance_schema` digest 점검 **(게이트)** | -| 3 | `phases/phase-3-dataset.md` | 3-A 데이터 규모, 카디널리티, 시드 SQL (이슈 공용) / 3-B 부하 조건, k6 스크립트 (대상별) | +| 1 | `phases/phase-1-target.md` | 이슈와 브랜치 확보, 엔드포인트 확정, 실행 경로와 예상 쿼리, `record.md` 생성 | +| 2 | `phases/phase-2-environment.md` | perf 프로파일, 셸 환경, 히스토그램, `performance_schema` 점검 **(게이트)** | +| 3 | `phases/phase-3-dataset.md` | 3-A 데이터 규모와 카디널리티, 시드 (이슈 공용) / 3-B 부하 조건, k6 스크립트 (대상별) | | 4 | `phases/phase-4-baseline.md` | 기준선 측정 결과를 가공해 제시, 호출자가 병목을 판정 | -| 5 | `phases/phase-5-design.md` | 근거와 함께 기법 제시 → 호출자가 선택 → 설계를 함께 확정 **(게이트)** | -| 6 | `phases/phase-6-snapshot.md` | 개선 전 지표와 실행계획 캡처 **(비가역)** | -| 7 | `phases/phase-7-apply.md` | Phase 5에서 확정한 설계 그대로 적용 (기법 하나만) | +| 5 | `phases/phase-5-design.md` | 기법 제시 → 호출자 선택 → 설계 협의 **(게이트)** | +| 6 | `phases/phase-6-snapshot.md` | 개선 전 지표와 실행계획 캡처 | +| 7 | `phases/phase-7-apply.md` | 확정한 설계 그대로 적용 (기법 하나), 테스트, 재기동 | | 8 | `phases/phase-8-verify.md` | 동일 조건 재측정, 종료 판정 | | 9 | `phases/phase-9-report.md` | 결과 보고, 산출물 정리 | -**분기 요약** +분기: -- Phase 1~4는 대상당 1회 수행한다. Phase 5~8은 사이클마다 반복한다. -- Phase 3-A: 이번 대상의 쿼리가 읽는 **모든 테이블**이 목표 규모와 카디널리티를 충족하면 → **3-B** (건너뜀) - 다른 대상이 시드를 돌렸다는 사실만으로는 건너뛰지 마라. 앞선 대상이 다루지 않은 테이블이 있으면 그 테이블만 3-A에서 추가로 채운다 -- Phase 3-B: 2회차 이상이고 스크립트가 준비되어 있으면 → **Phase 4** (건너뜀) -- Phase 8: 개선이 멈췄거나 호출자가 종료를 선택 → **Phase 9** -- Phase 8: 호출자가 계속을 선택 → **Phase 5** (사이클 번호 +1) -- Phase 9 종료 후 같은 이슈에 다른 대상이 남아 있으면 → **Phase 1** (새 슬러그 디렉토리) +- Phase 1~4는 대상당 1회, Phase 5~8은 사이클마다 반복한다. +- 3-A: 이번 대상의 쿼리가 읽는 **모든 테이블**이 목표 규모와 카디널리티를 충족하면 건너뛴다. + 앞선 대상이 다루지 않은 테이블이 있으면 그 테이블만 추가로 채운다. +- 3-B: 2회차 이상이고 스크립트가 있으면 건너뛴다. +- Phase 8: 종료 판정이거나 호출자가 종료를 선택 → Phase 9. 계속 → Phase 5 (사이클 번호 +1). +- Phase 9 뒤 같은 이슈에 다른 대상이 남아 있으면 → Phase 1 (새 슬러그 디렉토리). diff --git a/.claude/skills/optimize-performance/phases/phase-1-target.md b/.claude/skills/optimize-performance/phases/phase-1-target.md index ba16b15..2da41a5 100644 --- a/.claude/skills/optimize-performance/phases/phase-1-target.md +++ b/.claude/skills/optimize-performance/phases/phase-1-target.md @@ -1,11 +1,11 @@ ## Phase 1. 개선 대상 확정 ### 목적 -이슈와 작업 브랜치를 확보하고, 개선 대상 API가 어떤 쿼리를 날리는지 호출자와 함께 확정한 뒤 `record.md`를 생성한다. +이슈와 작업 브랜치를 확보하고, 대상 API가 요청 1회에 어떤 쿼리를 날리는지 호출자와 확정한 뒤 `record.md`를 만든다. ### 선행 조건 -- SKILL.md에서 전달받은 대상 API가 있다. 비어있다면 중단하고 호출자에게 질의한다. -- 진행 중인 다른 대상이 없다. 있다면 그 대상이 Phase 9까지 끝난 뒤에 이 Phase를 시작한다. +- 대상 API가 주어졌다. 비어 있으면 중단하고 호출자에게 묻는다. +- 진행 중인 다른 대상이 없다. 있으면 그 대상이 Phase 9까지 끝난 뒤 시작한다. ### 참조 파일 - `.claude/skills/optimize-performance/template/PERF-template.md` @@ -13,73 +13,48 @@ ### 절차 -1. 현재 브랜치에서 이슈 번호를 확보한다. +1. 이슈 번호를 확보한다. ```bash git branch --show-current ``` - - 브랜치명이 `{종류}/{이슈번호}-{slug}` 형식이면 이슈 번호를 뽑아 이슈를 조회한다. + - 브랜치명이 `{종류}/{이슈번호}-{slug}`면 이슈를 조회해 번호, 제목, 상태를 보고하고 이 이슈로 진행할지 확답을 받는다. ```bash gh issue view {이슈번호} --json number,title,state,url ``` - - 조회 결과(번호, 제목, 상태)를 호출자에게 보고하고, 이 이슈로 진행할지 확답을 받는다. - - 이슈가 `CLOSED`면 그 사실을 함께 알리고, 확답 전까지 다음 단계로 넘어가지 마라. - - 브랜치에서 번호를 못 뽑거나, 호출자가 다른 이슈를 원하면 `open-issue` 스킬을 호출해 이슈와 브랜치를 확보한다. - -2. 대상이 여러 개면 **하나로 좁힌다.** `SKILL.md`의 **대상 진행 규칙**을 따른다. - - `$ARGUMENTS`에 엔드포인트가 둘 이상이면 목록을 보고하고 어느 것부터 할지 호출자에게 묻는다. - - 나머지는 "이 대상이 Phase 9까지 끝난 뒤에 진행한다"고 알린다. - 대기 목록을 파일로 만들지 마라. 디렉토리도 `record.md`도 이번 대상 것만 만든다. - - 순서를 스킬이 임의로 정하지 마라. - -3. 대상 엔드포인트의 **슬러그**를 정한다. 이 슬러그가 대상 디렉토리 이름이 된다. - - 경로의 마지막 세그먼트를 케밥 케이스로 쓴다. (`/api/v1/courses/general-education` → `general-education`) - - 마지막 세그먼트가 경로 변수(`{courseId}`)면 그 앞 세그먼트를 쓴다. - - 같은 이슈의 기존 대상과 겹치면 앞 세그먼트를 하나 더 붙인다. (`courses-search`) - - HTTP 메서드가 달라 경로가 같은 대상이 생기면 메서드를 접두로 붙인다. (`post-registration`) - - 정한 슬러그를 호출자에게 알린다. - -4. 실행 경로를 Controller → Service → Repository 순으로 읽고, **호출자에게 예상 쿼리를 먼저 묻는다.** - `SKILL.md`의 **분석 주도 규칙**을 따른다. 이 프로젝트에는 Facade 레이어가 없다. - - - 먼저 재료만 펼친다. 각 레이어의 파일 경로와 호출되는 Repository 메서드 이름까지만 제시한다. - - 그다음 묻는다: "이 API가 요청 1회에 날리는 쿼리를 나열해보시겠습니까?" - 대부분 JPQL이나 메서드명으로 드러나 있으므로 호출자가 직접 읽을 수 있다. - - 답을 받으면 실제 코드와 대조해 **누락분과 오차만** 짚는다. 특히 아래를 확인한다. - - 호출자가 놓친 지연 로딩 지점. `Course.courseSchedules`처럼 `LEFT JOIN FETCH` 없이 접근하면 컬렉션마다 쿼리가 붙는다 - - `@BatchSize`가 걸린 컬렉션은 N번이 아니라 `ceil(N / size)`번으로 나간다. 1도 N도 아니다 - - 인증 필터(`JwtAuthenticationFilter`)와 인터셉터가 붙이는 쿼리. - 현재 JWT 검증은 DB를 보지 않으므로 쿼리가 없어야 한다. 있으면 그 자체가 관측 대상이다 - - 메서드 하나가 실제로는 여러 쿼리로 쪼개지는 경우 - - 호출자가 답하기 어려워하면 그때 목록을 제시하고 근거가 되는 코드 위치를 함께 보여준다. - -5. 작업 디렉토리 `.claude/resources/perf/{이슈번호}/{슬러그}/`를 만들고 그 안에 `record.md`를 생성한다. - **이번 대상 것 하나만 만든다.** - - `template/PERF-template.md`를 Read해 그 구조 그대로 만든다. - - **대상**과 **진행 상태**의 Phase 1을 채운다. 예상 쿼리 목록에는 4번에서 확정한 목록을 적는다. - - 셸 변수를 잡도록 호출자에게 제시한다. 이후 모든 셸 명령이 이 변수들을 쓴다. - - ```bash - export PERF_DIR=.claude/resources/perf/{이슈번호} - export TARGET_DIR=$PERF_DIR/{슬러그} - - export MYSQL_PWD=root - export MYSQL_PERF="mysql -h 127.0.0.1 -P 3307 -u root uss_db" - ``` - - - 같은 이슈의 다른 대상에서 이미 `seeds.sql`이나 `tokens.json`을 만들었으면 그대로 공유한다. 대상 디렉토리에 복사하지 마라. + - `CLOSED`면 그 사실을 함께 알리고 확답 전까지 넘어가지 마라. + - 번호를 못 뽑거나 호출자가 다른 이슈를 원하면 `open-issue` 스킬로 이슈와 브랜치를 확보한다. + +2. 대상이 여러 개면 하나로 좁힌다 (`SKILL.md`의 **대상 진행 규칙**). + - 목록을 보고하고 어느 것부터 할지 묻는다. 순서를 스킬이 정하지 마라. + - 나머지는 "이 대상이 Phase 9까지 끝난 뒤에 진행한다"고 알린다. 대기 목록을 파일로 만들지 마라. + +3. 슬러그를 정하고 호출자에게 알린다. 이 슬러그가 대상 디렉토리 이름이다. + - 경로의 마지막 세그먼트를 케밥 케이스로 (`/api/v1/courses/general-education` → `general-education`). + - 마지막이 경로 변수(`{courseId}`)면 그 앞 세그먼트. + - 같은 이슈의 기존 대상과 겹치면 앞 세그먼트를 하나 더 붙인다 (`courses-search`). + - 경로가 같고 메서드만 다르면 메서드를 접두로 (`post-registration`). + +4. 실행 경로를 Controller → Service → Repository 순으로 읽고, **예상 쿼리를 호출자에게 먼저 묻는다** (`SKILL.md`의 **역할 경계**). + - 재료만 펼친다: 각 레이어의 파일 경로와 호출되는 Repository 메서드 이름까지. + - 묻는다: "이 API가 요청 1회에 날리는 쿼리를 나열해보시겠습니까?" + - 답을 실제 코드와 대조해 **누락분과 오차만** 짚는다. 특히: + - 지연 로딩 지점. `LEFT JOIN FETCH` 없이 컬렉션에 접근하면 컬렉션마다 쿼리가 붙는다. + - `@BatchSize`가 걸린 컬렉션은 N번이 아니라 `ceil(N / size)`번이다. + - `JwtAuthenticationFilter`는 서명만 검증하고 DB를 보지 않는다. 인증 경로에서 쿼리가 보이면 그 자체가 관측 대상이다. + - 메서드 하나가 여러 쿼리로 쪼개지는 경우. + - 호출자가 막히면 그때 목록과 근거 코드 위치를 제시한다. + +5. `.claude/resources/perf/{이슈번호}/{슬러그}/record.md`를 `template/PERF-template.md` 구조 그대로 만든다. **이번 대상 것 하나만.** + - **대상**과 **진행 상태**의 Phase 1을 채운다. 예상 쿼리 목록은 4에서 확정한 것. + - 같은 이슈의 다른 대상이 만든 `seeds.sql`, `tokens.json`이 있으면 그대로 공유한다. 대상 디렉토리에 복사하지 마라. ### 출력 -- `.claude/resources/perf/{이슈번호}/{슬러그}/record.md` 생성 -- `record.md`의 **대상**에 실행 경로와 예상 쿼리 목록이 기록 -- `record.md`의 진행 상태의 Phase 1이 ✅로 기록 - -### 실패 처리 -- 없음 - -> 다음 Phase 조건: 이슈 번호와 슬러그를 확보했고, 예상 쿼리 목록이 `record.md`에 적혔을 때 → Phase 2 +- `record.md` 생성, **대상**에 실행 경로와 예상 쿼리 목록, 진행 상태 Phase 1 ✅ -> Skip 조건: 없음 (필수 Phase) +> 다음 Phase 조건: 이슈 번호와 슬러그를 확보했고 예상 쿼리 목록이 `record.md`에 적혔을 때 → Phase 2 +> +> Skip 조건: 없음 diff --git a/.claude/skills/optimize-performance/phases/phase-2-environment.md b/.claude/skills/optimize-performance/phases/phase-2-environment.md index 658f7ba..9d695cf 100644 --- a/.claude/skills/optimize-performance/phases/phase-2-environment.md +++ b/.claude/skills/optimize-performance/phases/phase-2-environment.md @@ -1,97 +1,96 @@ ## Phase 2. 측정 환경 검증 ### 목적 -측정값을 왜곡하는 설정은 없는지, 쿼리 단위 관측 도구가 유효한지 확인한다. +측정값을 왜곡하는 설정이 없는지, 쿼리 단위 관측 도구가 유효한지 확인한다. -**게이트.** 하나라도 충족하지 못하면 다음 Phase로 넘어가지 마라. 호출자에게 조치를 요청한다. +**게이트.** 하나라도 통과하지 못하면 넘어가지 마라. 호출자에게 조치를 요청한다. ### 선행 조건 - Phase 1 완료 ### 참조 파일 -- `src/main/resources/application-perf.yml` - `.claude/skills/optimize-performance/template/application-perf.yml` +- `.claude/skills/optimize-performance/template/perf-env.sh` ### 절차 -1. `src/main/resources/application-perf.yml`이 있는지 Glob으로 확인한다. - - 없으면 `template/application-perf.yml`을 Read해 그 내용으로 생성한다. - 이 파일은 측정 전용이다. 프로파일을 새로 만드는 것이므로 만들었다는 사실을 호출자에게 알린다. - - 있으면 Read해 `maximum-pool-size`, `show_sql`, p6spy 설정을 확인한다. - **SQL 로깅이 켜져 있으면 끄도록 요청한다.** 요청당 수십 줄을 찍는 로깅은 측정값을 통째로 바꾼다. +1. `src/main/resources/application-perf.yml`을 Glob으로 확인한다. + - 없으면 `template/application-perf.yml` 내용 그대로 생성하고, 만들었다는 사실을 알린다. + - 있으면 Read해 `maximum-pool-size`, `show_sql`, 드라이버를 확인한다. SQL 로깅이나 p6spy가 켜져 있으면 끄도록 요청한다. + 요청당 수십 줄을 찍는 로깅은 측정값을 통째로 바꾼다. -2. MySQL과 애플리케이션을 기동하도록 호출자에게 제시한다. 별도 터미널에서 실행하게 하고, 기동 완료를 확인받은 뒤 3번으로 간다. +2. 셸 환경과 서버 기동을 제시한다. 애플리케이션은 별도 터미널에서 띄우게 하고, 기동 완료를 확인받은 뒤 3으로 간다. ```bash + # 측정 터미널 (레포 루트) + source .claude/skills/optimize-performance/template/perf-env.sh {이슈번호} {슬러그} docker-compose -f docker/docker-compose-local.yml up -d mysql + + # 애플리케이션 터미널 ./gradlew bootRun --args='--spring.profiles.active=perf' ``` -3. 아래를 **적힌 순서대로** 실행하도록 제시하고 결과를 받는다. - 3)은 요청이 한 번 들어온 뒤에만 값이 잡히므로 2)를 건너뛰지 마라. +3. 아래를 **순서대로** 실행하게 하고 결과를 받는다. 3)은 2)가 만든 미터를 세므로 2)를 건너뛰지 마라. ```bash - # 1) perf 프로파일로 떠 있는가 (application 태그가 uss-server-perf여야 한다) + # 0) DB 접속과 charset. 한글이 ?로 보이면 perf-env.sh가 source되지 않은 것이다 + mysqlp -e "SELECT title_kr FROM courses LIMIT 1;" + + # 1) perf 프로파일로 떠 있는가 (application="uss-server-perf") curl -s localhost:8081/actuator/prometheus | grep -m1 'application=' - # 2) 미터 등록용 1회 요청 - curl -s -o /dev/null -w '%{http_code}\n' localhost:8081/actuator/health + # 2) 미터 등록용 1회 요청. 메인 포트(8080)여야 한다. 관리 포트(8081) 요청은 메인 서버 미터를 만들지 않는다. 401이어도 기록된다 + curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/api/v1/courses/terms - # 3) 응답시간 히스토그램이 노출되는가 (0이면 SLO 버킷 설정이 안 붙은 것) + # 3) 응답시간 히스토그램 (0이면 SLO 버킷 설정이 안 붙은 것) curl -s localhost:8081/actuator/prometheus | grep -c http_server_requests_seconds_bucket - # 4) performance_schema와 statements_digest 소비자가 켜져 있는가 (셋 다 ON이어야 한다) - $MYSQL_PERF -e " + # 4) performance_schema와 digest 소비자 (셋 다 ON / YES) + mysqlp -e " SELECT @@performance_schema AS ps; SELECT NAME, ENABLED FROM performance_schema.setup_consumers - WHERE NAME IN ('statements_digest','events_statements_current');" + WHERE NAME IN ('statements_digest', 'events_statements_current');" - # 5) digest 테이블이 실제로 채워지는가 (0보다 커야 한다) - $MYSQL_PERF -e " + # 5) digest 테이블이 채워지는가 (0보다 커야 한다) + mysqlp -e " SELECT count(*) FROM performance_schema.events_statements_summary_by_digest WHERE SCHEMA_NAME = 'uss_db';" - # 6) digest 원문이 잘리지 않는가 (기본 1024. 대상 쿼리가 이보다 길면 원문이 잘린다) - $MYSQL_PERF -e "SELECT @@performance_schema_max_digest_length;" - ``` + # 6) digest 원문이 잘리는가. 가장 긴 digest가 max_digest_length에 닿으면 잘리는 것이다 + mysqlp -e " + SELECT @@performance_schema_max_digest_length AS max_len; + SELECT LENGTH(DIGEST_TEXT) AS len, LEFT(DIGEST_TEXT, 80) AS head + FROM performance_schema.events_statements_summary_by_digest + WHERE SCHEMA_NAME = 'uss_db' ORDER BY len DESC LIMIT 3;" -4. 4)의 `ps`가 0이거나 소비자가 `NO`면 아래로 조치하도록 안내한다. + # 7) 버퍼 풀 크기. 데이터가 이보다 크면 디스크 I/O가 측정에 섞인다 + mysqlp -e "SELECT @@innodb_buffer_pool_size / 1024 / 1024 AS buffer_pool_mib;" + ``` - - `@@performance_schema`가 0이면 재기동이 필요하다. `docker/docker-compose-local.yml`의 mysql `command`에 - `--performance-schema=ON`을 추가한 뒤 컨테이너를 재기동한다. - - 소비자가 꺼져 있으면 재기동 없이 켤 수 있다. +4. 실패 항목의 조치: + - 3)이 0 → 설정을 의심하기 전에 2)를 메인 포트로 보냈는지 확인한다. + - 4)의 `ps`가 0 → `docker/docker-compose-local.yml`의 mysql `command`에 `--performance-schema=ON`을 추가하고 컨테이너를 재기동한다. + - 소비자가 `NO` → 재기동 없이 켠다. ```bash - $MYSQL_PERF -e " + mysqlp -e " UPDATE performance_schema.setup_consumers SET ENABLED = 'YES' - WHERE NAME IN ('statements_digest','events_statements_current');" + WHERE NAME IN ('statements_digest', 'events_statements_current');" ``` -5. 5)가 0이면 아직 아무 쿼리도 안 지나간 것이다. 대상 API를 한 번 호출하게 한 뒤 다시 센다. - 그래도 0이면 4)로 돌아가 소비자 상태를 다시 확인한다. + - 5)가 0 → 대상 API를 한 번 호출하게 한 뒤 다시 센다. 그래도 0이면 4)로 돌아간다. + - 6)에서 잘림 → `--performance-schema-max-digest-length=4096`과 `--performance-schema-max-sql-text-length=4096`을 mysql `command`에 넣고 재기동한다. + MySQL 8.0.28+는 digest에서 `IN` 목록을 `IN (...)`로 접으므로 `@BatchSize`의 자리표시자 1000개도 100자 미만이다. + 실측 길이가 닿지 않으면 올리지 마라. -6. 6)의 값이 대상 쿼리 길이보다 짧으면 그 사실을 호출자에게 알린다. - digest 원문이 잘리면 Phase 4에서 쿼리를 리포지토리 메서드에 매핑할 수 없다. - 늘리려면 `--performance-schema-max-digest-length=4096`을 mysql `command`에 추가하고 재기동한다. - **`performance-schema-max-sql-text-length`도 함께 올려야** 원문 전체가 남는다. - -7. 확인 결과를 `.claude/resources/perf/{이슈번호}/{슬러그}/record.md`의 **측정 환경**에 기록한다. - - 커넥션 풀 크기는 `application-perf.yml`의 `maximum-pool-size`를 Read로 읽어 적는다. - - **캐시 상태는 warm으로 고정해 적는다.** InnoDB 버퍼 풀은 재기동 없이 비울 수 없고, - 이 프로젝트는 애플리케이션 캐시를 쓰지 않는다. cold 측정을 설계하지 마라. - 대신 Phase 4와 8에서 워밍업을 같은 조건으로 돌려 버퍼 풀 상태를 맞춘다. - - 버퍼 풀 크기(`SELECT @@innodb_buffer_pool_size;`)를 함께 적는다. - 데이터가 버퍼 풀보다 크면 디스크 I/O가 측정에 섞이므로, 그 사실이 해석의 전제가 된다. +5. 결과를 `record.md`의 **측정 환경**에 적는다. 프로파일, 커넥션 풀 크기(`maximum-pool-size`), 버퍼 풀 크기, 캐시 상태(warm 고정). + cold 측정을 설계하지 마라. Phase 4와 8이 같은 워밍업으로 버퍼 풀 상태를 맞춘다. ### 출력 -- `src/main/resources/application-perf.yml` 존재 확인 또는 생성 -- `.claude/resources/perf/{이슈번호}/{슬러그}/record.md`의 측정 환경에 프로파일, 커넥션 풀 크기, 버퍼 풀 크기, 캐시 상태가 기록 -- `.claude/resources/perf/{이슈번호}/{슬러그}/record.md`의 진행 상태의 Phase 2가 ✅로 기록 - -### 실패 처리 -- 없음 - -> 다음 Phase 조건: 3번의 여섯 항목이 모두 통과했을 때 → Phase 3 +- `src/main/resources/application-perf.yml` 존재 +- `record.md`의 **측정 환경**에 프로파일, 풀 크기, 버퍼 풀 크기, 캐시 상태, 진행 상태 Phase 2 ✅ -> Skip 조건: 같은 이슈의 다른 대상에서 이미 통과했고 그 사이에 애플리케이션과 컨테이너를 재기동하지 않았으면, -> 앞선 대상의 `record.md` **측정 환경**을 그대로 옮겨 적고 건너뛴다. 진행 상태에는 ⏭️로 표기한다. +> 다음 Phase 조건: 3의 여덟 항목이 모두 통과했을 때 → Phase 3 +> +> Skip 조건: 같은 이슈의 다른 대상에서 통과했고 그 사이에 애플리케이션과 컨테이너를 재기동하지 않았으면, +> 앞선 대상의 **측정 환경**을 옮겨 적고 ⏭️로 표기한다. diff --git a/.claude/skills/optimize-performance/phases/phase-3-dataset.md b/.claude/skills/optimize-performance/phases/phase-3-dataset.md index f67f12f..f03bec7 100644 --- a/.claude/skills/optimize-performance/phases/phase-3-dataset.md +++ b/.claude/skills/optimize-performance/phases/phase-3-dataset.md @@ -1,8 +1,7 @@ ## Phase 3. 측정 조건 구성 ### 목적 -데이터 규모와 카디널리티를 확정해 시드를 채우고(3-A, 이슈 공용), -부하 조건을 확정해 k6 스크립트를 만든다(3-B, 대상별). +데이터 규모와 카디널리티를 확정해 시드를 채우고(3-A, 이슈 공용), 부하 조건을 확정해 k6 스크립트를 만든다(3-B, 대상별). ### 선행 조건 - Phase 2 완료 @@ -15,22 +14,12 @@ ### 3-A. 데이터셋 (이슈 공용) -> 같은 이슈의 다른 대상에서 이미 채웠으면 건너뛰고 3-B로 간다. -> 단, 이번 대상의 쿼리가 앞선 대상이 다루지 않은 테이블을 읽으면 그 테이블만 추가로 채운다. +> 같은 이슈의 다른 대상이 채웠으면 3-B로 간다. 단, 이번 대상의 쿼리가 앞선 대상이 다루지 않은 테이블을 읽으면 그 테이블만 추가로 채운다. -1. 현재 행 수를 확인하도록 제시하고 결과를 받는다. +1. 현재 행 수를 확인하게 한다. `information_schema.TABLES.TABLE_ROWS`는 샘플링 추정치라 배 단위로 어긋난다. 실제 카운트를 쓴다. ```bash - $MYSQL_PERF -e " - SELECT TABLE_NAME, TABLE_ROWS FROM information_schema.TABLES - WHERE TABLE_SCHEMA = 'uss_db' ORDER BY TABLE_ROWS DESC;" - ``` - - **`TABLE_ROWS`를 행 수로 믿지 마라.** InnoDB에서 이 값은 옵티마이저가 샘플링한 **추정치**이고, - 실제와 배 단위로 어긋난다. 목표 규모를 판정할 때는 반드시 실제 카운트로 확인한다. - - ```bash - $MYSQL_PERF -e " + mysqlp -e " SELECT 'courses' AS t, count(*) AS n FROM courses UNION ALL SELECT 'course_schedules', count(*) FROM course_schedules UNION ALL SELECT 'members', count(*) FROM members @@ -38,108 +27,82 @@ UNION ALL SELECT 'registrations', count(*) FROM registrations;" ``` -2. 목표 규모를 호출자와 확정한다. - - Phase 1에서 확정한 쿼리가 읽는 테이블만을 대상으로 삼는다. 그 외 테이블은 다루지 않는다. - - 정렬, 집계가 걸린 쿼리가 있다면 해당 테이블의 목표 규모를 개별 수치로 확정한다. - - 목표 규모는 스킬이 임의로 확정하지 않는다. 호출자와의 인터렉션을 통해 확정한다. +2. 목표 규모를 호출자와 확정한다. Phase 1에서 확정한 쿼리가 읽는 테이블만 다룬다. + - 앱 시드(`database/seed/`)가 곧 운영 규모의 근사치다. 목표는 **운영 대비 배수**로 정하고, + 그 배수를 고른 이유(무엇을 관측하려는지)를 함께 확정한다. 배수가 클수록 적재, 워밍업, 버퍼 풀 비용이 커지고 + 운영에서 볼 일 없는 구간을 재게 된다. 근거 없는 큰 수를 받아들이지 마라. + - 정렬, 집계가 걸린 쿼리가 있으면 그 테이블의 목표를 개별 수치로 확정한다. + - 규모는 스킬이 정하지 않는다. 3. 목표 카디널리티를 호출자와 확정한다. - - 대상 쿼리의 `WHERE`, `ORDER BY`, `GROUP BY`에 쓰이는 컬럼마다 서로 다른 값의 개수를 정한다. - - `course_department`, `course_area`처럼 enum이 들어가는 컬럼은 **분포의 치우침**까지 정한다. - 한 학과에 강의가 몰린 분포와 균등 분포는 인덱스 선택도가 달라진다. - - Phase 5-B의 인덱스 설계가 이 값을 근거로 쓴다. 확정하지 않은 채 넘어가지 마라. + - 대상 쿼리의 `WHERE`, `ORDER BY`, `GROUP BY` 컬럼마다 서로 다른 값의 개수를 정한다. + - enum 컬럼(`department`, `area`)은 **분포의 치우침**까지 정한다. 한 값에 몰린 분포와 균등 분포는 인덱스 선택도가 다르다. + - FULLTEXT 검색이 대상이면 키워드당 매칭 건수를 정한다. `seeds/README.md`의 **검색 제목 설계** 참고. + - Phase 5-B가 이 값을 근거로 쓴다. 확정하지 않고 넘어가지 마라. + +4. 인증이 필요한 대상이면 토큰 방식을 정한다. + - 기본은 **서명키로 직접 발급**(`mint-tokens.sh`, Phase 4에서 실행)이다. 로그인 API를 태우지 마라. + 로그인 SQL이 측정에 섞이고 수백 계정을 가입시키는 것보다 느리다. + - **대상 컨트롤러에 `@Auth` 파라미터가 없으면 회원 시드가 필요 없다.** 필터가 DB를 보지 않으므로 + 토큰의 `memberId`가 `members`에 없어도 통과한다. 컨트롤러 시그니처를 먼저 확인하라. + - **로그인 경로 자체를 측정할 때만** 실제 해시가 필요하다. 지어내지 말고 가입 API가 만든 값을 쓴다. -4. **인증이 필요한 대상이면 로그인용 비밀번호 해시를 먼저 확보한다.** - 시드 회원 전원이 같은 해시를 쓰고, Phase 4가 그 비밀번호로 로그인해 토큰을 받는다. - 해시를 지어내지 마라. 애플리케이션이 실제로 만든 값을 그대로 재사용한다. + ```bash + curl -s -X POST localhost:8080/api/v1/auth/sign-up -H 'Content-Type: application/json' \ + -d '{"email":"perfseed@inu.ac.kr","password":"perfPassw0rd","studentId":"999999999","name":"perf", + "college":"INFORMATION_TECHNOLOGY","department":"COMPUTER_ENGINEERING","grade":"SENIOR", + "academicStatus":"ENROLLED","lastSemesterGpa":4.0}' - ```bash - # 1) 인증 완료 상태의 이메일 인증 기록을 직접 넣는다 (메일 발송을 우회한다) - $MYSQL_PERF -e " - INSERT INTO email_verification_codes (email, code, verified, failed_count, resend_count, expires_at) - VALUES ('perfseed@inu.ac.kr', '000000', TRUE, 0, 0, NOW() + INTERVAL 1 DAY) - ON DUPLICATE KEY UPDATE verified = TRUE, expires_at = NOW() + INTERVAL 1 DAY;" - - # 2) 실제 회원가입 API로 회원을 만든다 (비밀번호가 애플리케이션 인코더로 해시된다) - curl -s -X POST localhost:8080/api/v1/auth/sign-up \ - -H 'Content-Type: application/json' \ - -d '{"studentId":"999999999","password":"perfPassw0rd","name":"perf", - "email":"perfseed@inu.ac.kr","memberCollege":"INFORMATION_TECHNOLOGY", - "memberDepartment":"COMPUTER_ENGINEERING","memberGrade":"SENIOR", - "academicStatus":"ENROLLED","lastSemesterGPA":4.0}' - - # 3) 저장된 해시를 꺼낸다. 이 값이 seeds.sql의 @pw_hash가 된다 - $MYSQL_PERF -N -e "SELECT password FROM members WHERE email = 'perfseed@inu.ac.kr';" - ``` + mysqlp -N -e "SELECT password FROM members WHERE email = 'perfseed@inu.ac.kr';" + ``` - - 학번은 9자리 숫자, 이메일은 `@inu.ac.kr`만 통과한다. 이 제약을 어기면 400이 떨어진다. - - 받은 해시를 그대로 `seeds.sql`의 `@pw_hash`에 넣는다. 해시 문자열에 `$`가 들어가므로 - 셸에서 다시 가공하지 말고 SQL 파일에 작은따옴표로 감싸 붙여넣게 한다. - - 이 회원은 시드 범위 밖의 학번을 쓰므로 측정 대상에 섞이지 않는다. + 로그인 식별자는 이메일이다 (`LoginRequest{email, password}`). 비밀번호는 8~20자, 학번은 영숫자 1~20자. + 받은 해시는 `$`를 포함하므로 셸에서 가공하지 말고 `seeds.sql`의 `@pw_hash`에 작은따옴표로 감싸 넣게 한다. -5. 현재 행 수가 목표 규모에 미달하는 테이블이 있다면 `template/seeds/README.md`를 Read하고, - 필요한 모듈을 골라 `.claude/resources/perf/{이슈번호}/seeds.sql`을 만든다. - - **모듈 본문을 복사하지 마라.** 변수 블록을 쓰고 `SOURCE`로 모듈을 불러오는 형태로만 작성한다. - - 필요한 테이블이 기존 모듈에 없을 때만 그 테이블 블록을 `seeds.sql`에 직접 쓴다. - 같은 이슈에서 두 번 이상 쓸 것 같으면 새 모듈로 만들자고 호출자에게 제안한다. - - 실행 명령을 호출자에게 제시하고, 모듈 말미의 검증 쿼리 결과를 받는다. +5. 목표에 미달하는 테이블이 있으면 `seeds/README.md`를 Read하고 `.claude/resources/perf/{이슈번호}/seeds.sql`을 만든다. + - `seeds.sql`에는 **변수 블록만** 쓴다. 모듈 본문을 복사하지 마라. 실행할 때 `cat`으로 이어 붙인다. + - 필요한 테이블이 모듈에 없을 때만 그 블록을 `seeds.sql`에 직접 쓴다. 두 번 이상 쓸 것 같으면 새 모듈을 제안한다. + - 모듈의 규모 상한(README)을 넘는 규모면 README의 **대량 적재** 절차를 함께 제시한다. ```bash - $MYSQL_PERF < $PERF_DIR/seeds.sql + cat $PERF_DIR/seeds.sql $SEEDS/member.sql $SEEDS/course.sql $SEEDS/enrollment.sql | mysqlp ``` - - 검증 결과가 2번, 3번에서 확정한 값과 어긋나면 변수를 고쳐 다시 실행하게 한다. - 어긋난 채로 5번을 통과시키지 마라. + - 모듈 말미의 검증 결과가 2, 3에서 확정한 값과 어긋나면 변수를 고쳐 다시 실행하게 한다. 어긋난 채로 넘어가지 마라. -6. 시드 적재 후 옵티마이저 통계를 갱신하게 한다. 이 단계를 빼면 첫 측정의 실행계획이 적재 전 통계로 잡힌다. +6. 옵티마이저 통계를 갱신하게 한다. 빼면 첫 측정의 실행계획이 적재 전 통계로 잡힌다. ```bash - $MYSQL_PERF -e "ANALYZE TABLE members, courses, course_schedules, carts, registrations;" + mysqlp -e "ANALYZE TABLE members, courses, course_schedules, carts, registrations;" ``` -7. 확정한 목표 규모, 카디널리티, 실제 검증값을 `.claude/resources/perf/{이슈번호}/{슬러그}/record.md`의 **측정 환경**에 적는다. +7. 확정한 규모(운영 대비 배수와 근거 포함), 카디널리티, 검증값을 `record.md`의 **측정 환경**에 적는다. --- ### 3-B. 부하 스크립트 (대상별) -1. `template/k6-script-template.js`를 Read하고, 작성 규칙에 따라 스크립트를 작성한다. - - `.claude/resources/perf/{이슈번호}/{슬러그}/test-script.js`로 파일을 생성한다. - - `TARGET`에는 Phase 1에서 정한 슬러그를, `ENDPOINT`에는 경로를 그대로 넣는다. - - `tokens.json`은 이슈 디렉토리에 있다. `open('../tokens.json')` 경로를 바꾸지 마라. - - 실행은 Phase 4에서 호출자가 직접 한다. +1. `template/k6-script-template.js`를 Read하고 작성 규칙대로 `.claude/resources/perf/{이슈번호}/{슬러그}/test-script.js`를 만든다. + `TARGET`은 슬러그, `ENDPOINT`는 경로. 실행은 Phase 4에서 호출자가 한다. -2. 부하 조건(VU, duration)을 호출자와 확정해 - 스크립트의 `CONDITION` 블록과 `record.md`의 **측정 환경**에 적는다. - - 같은 이슈의 다른 대상과 조건을 맞출지 호출자에게 확인한다. 조건이 다르면 대상 간 비교가 불가능해진다. - - VU 상한은 커넥션 풀 크기와 함께 본다. 풀보다 훨씬 큰 VU는 쿼리가 아니라 커넥션 대기를 재게 된다. - - 이후 사이클에서 이 값이 바뀌면 변경된 값과 변경 시점을 같은 항목에 덧붙인다. 기존 값을 덮어쓰지 마라. +2. 부하 조건(VU, duration, USER_COUNT)을 호출자와 확정해 스크립트의 `CONDITION`과 `record.md`의 **측정 환경**에 적는다. + - 같은 이슈의 다른 대상과 조건을 맞출지 확인한다. 다르면 대상 간 비교가 불가능하다. + - VU 상한은 커넥션 풀 크기와 함께 본다. 풀보다 훨씬 큰 VU는 쿼리가 아니라 커넥션 대기를 잰다. + - 이후 값이 바뀌면 변경값과 시점을 덧붙인다. 기존 값을 덮어쓰지 마라. -3. **쓰기 엔드포인트면 되돌리기 절차를 여기서 확정한다.** - 수강신청과 장바구니 담기는 행을 남기고 `courses.current_enrollment`를 바꾼다. - 측정마다 시작 상태가 같아야 하므로, 되돌리는 SQL을 확정해 `record.md`에 적어둔다. - Phase 4와 8이 같은 자리에서 이 SQL을 실행한다. +3. **쓰기 엔드포인트면 되돌리기 SQL을 여기서 확정해 `record.md`에 적는다.** Phase 4와 8이 같은 자리에서 실행한다. + 수강신청과 장바구니는 행을 남기고 `courses.current_enrollment`를 바꾼다. `uk_member_course` 때문에 되돌리지 않으면 2회차부터 전부 중복 실패다. ```sql - -- 예: 수강신청 측정의 되돌리기 - DELETE FROM registrations WHERE member_id BETWEEN @member_start AND @member_end; - UPDATE courses SET current_enrollment = 0 WHERE id BETWEEN @course_start AND @course_end; + DELETE FROM registrations WHERE member_id BETWEEN {회원 id 시작} AND {회원 id 끝}; + UPDATE courses SET current_enrollment = 0 WHERE id BETWEEN {강의 id 시작} AND {강의 id 끝}; ``` - - `UNIQUE KEY uk_member_course` 때문에 되돌리지 않으면 2회차부터 전부 중복 실패가 된다. - 되돌리기를 빼면 측정이 아니라 에러율을 재게 된다. - ### 출력 -- `.claude/resources/perf/{이슈번호}/seeds.sql` 생성 (시드가 필요한 경우) -- `.claude/resources/perf/{이슈번호}/{슬러그}/test-script.js` 생성 -- `record.md`의 측정 환경에 목표 데이터 규모, 카디널리티, 부하 조건, 되돌리기 절차가 기록 -- `record.md`의 진행 상태의 Phase 3이 ✅로 기록 - -### 실패 처리 -- 없음 - -> 다음 Phase 조건: k6 스크립트가 작성되었고 목표 규모에 도달했을 때 → Phase 4 +- `seeds.sql` (필요한 경우), `test-script.js` +- `record.md`의 **측정 환경**에 규모와 근거, 카디널리티, 부하 조건, 되돌리기 절차, 진행 상태 Phase 3 ✅ -> Skip 조건: 3-A는 **이번 대상의 쿼리가 읽는 모든 테이블**이 목표 규모와 카디널리티를 충족했을 때만 건너뛴다. -> 다른 대상이 시드를 돌렸다는 사실만으로는 건너뛰지 마라. 누락된 테이블이나 목표에 미달하는 테이블이 있으면 그것만 3-A에서 추가로 채운다. -> 3-B는 2회차 이상이고 스크립트가 이미 있으면 건너뛴다. 둘 다 건너뛰면 진행 상태에 ⏭️로 표기한다. +> 다음 Phase 조건: k6 스크립트가 있고 목표 규모에 도달했을 때 → Phase 4 +> +> Skip 조건: 3-A는 이번 대상의 쿼리가 읽는 모든 테이블이 목표를 충족할 때만. 3-B는 2회차 이상이고 스크립트가 있을 때. +> 둘 다 건너뛰면 ⏭️로 표기한다. diff --git a/.claude/skills/optimize-performance/phases/phase-4-baseline.md b/.claude/skills/optimize-performance/phases/phase-4-baseline.md index a3c2a9c..1bb4ac6 100644 --- a/.claude/skills/optimize-performance/phases/phase-4-baseline.md +++ b/.claude/skills/optimize-performance/phases/phase-4-baseline.md @@ -1,159 +1,57 @@ ## Phase 4. 기준선 측정 ### 목적 -기준선을 측정하고, 그 결과를 소비 가능한 형태로 가공해 호출자에게 제시한다. -병목이 어디인지는 호출자가 판정한다. +기준선을 측정하고 결과를 소비 가능한 형태로 가공해 제시한다. 병목이 어디인지는 호출자가 판정한다. ### 선행 조건 -- Phase 3 완료 -- `.claude/resources/perf/{이슈번호}/{슬러그}/test-script.js` 존재 +- Phase 3 완료, `test-script.js` 존재 ### 참조 파일 +- `.claude/skills/optimize-performance/template/commands.md` - `.claude/skills/optimize-performance/template/query-stats-template.md` ### 절차 -1. 아래 순서로 실행하도록 제시한다. **측정 실행은 호출자가 한다.** 순서를 바꾸지 마라. +1. `commands.md`의 **A. 부하 측정** 블록을 `{n}` = 0으로 채워 제시한다. 실행은 호출자가 한다. + 이 블록은 대상 하나만 잰다. 다른 대상의 스크립트를 이어서 돌리게 하지 마라 (`SKILL.md`의 **대상 진행 규칙**). - 이 블록은 대상 하나만 잰다. 다른 대상의 스크립트를 이어서 돌리게 하지 마라. - digest 통계는 인스턴스 전역이라 리셋 없이 다음 대상을 재면 통계가 섞이고, - `per_req`의 분모(`requests`)가 이 대상의 것이므로 요청당 쿼리 수가 틀린 값이 된다. - (`SKILL.md`의 **대상 진행 규칙**) +2. 끝나면 `k6-test-summary-0.json`을 Read한다. 쿼리 통계 1차 출력은 메인에서 Read하지 않는다. + - 파일이 없으면 원인을 확인하고 재실행을 요청한다. 추정으로 채우지 마라. + - `checks_rate`가 1이 아니면 `checks[]`에서 어떤 항목이 깨졌는지 먼저 본다. 데이터 검증 check가 깨진 측정은 진단에 쓰지 않는다. - ```bash - # 새 터미널이면 먼저: - # export PERF_DIR=.claude/resources/perf/{이슈번호} - # export TARGET_DIR=$PERF_DIR/{슬러그} - # export MYSQL_PWD=root - # export MYSQL_PERF="mysql -h 127.0.0.1 -P 3307 -u root uss_db" - - # 1) 토큰 발급 (digest 리셋 전에 끝낸다. 이슈 공용이므로 이미 있으면 건너뛴다) - # 로그인 쿼리가 측정 통계에 섞이지 않도록 반드시 6)보다 먼저 한다. - seq {STUDENT_ID_START} {STUDENT_ID_END} \ - | while read -r sid; do - curl -s -X POST localhost:8080/api/v1/auth/login \ - -H 'Content-Type: application/json' \ - -d "{\"studentId\":\"$sid\",\"password\":\"perfPassw0rd\"}" \ - | jq -c 'select(.accessToken != null) | {accessToken, refreshToken}' - done \ - | jq -s '.' > $PERF_DIR/tokens.json - - jq 'length' $PERF_DIR/tokens.json # 시드 회원 수와 같아야 한다 - - # 2) 워밍업 (JIT, 커넥션 풀, InnoDB 버퍼 풀). 이 실행의 결과는 쓰지 않는다. - k6 run -e PHASE=warmup $TARGET_DIR/test-script.js - - # 3) 쓰기 엔드포인트면 워밍업이 남긴 행을 되돌린다 - # Phase 3-B에서 확정해 record.md에 적어둔 SQL을 그대로 실행한다 - - # 4) 옵티마이저 통계 갱신. 3)의 DELETE 이후 분포가 달라졌을 수 있다 - $MYSQL_PERF -e "ANALYZE TABLE members, courses, course_schedules, carts, registrations;" - - # 5) 데이터 규모가 시작 상태와 같은지 확인 (되돌리기가 제대로 됐는지) - $MYSQL_PERF -e " - SELECT 'registrations' AS t, count(*) AS n FROM registrations - UNION ALL SELECT 'carts', count(*) FROM carts - UNION ALL SELECT 'enrolled', COALESCE(sum(current_enrollment),0) FROM courses;" - - # 6) 쿼리 통계 리셋 - $MYSQL_PERF -e " - TRUNCATE TABLE performance_schema.events_statements_summary_by_digest;" - - # 7) 측정 부하 - k6 run -e PHASE=measure \ - -e SUMMARY_OUT=$TARGET_DIR/k6-test-summary-0.json \ - $TARGET_DIR/test-script.js - - # 8) 쿼리 통계 수집 (요청 수를 분모로 넘겨 요청당 호출 수까지 뽑는다) - REQS=$(jq -r '.requests // empty' $TARGET_DIR/k6-test-summary-0.json) - - if ! [ "$REQS" -gt 0 ] 2>/dev/null; then - echo "요청 수가 '$REQS'다. 측정이 실패했으므로 통계를 수집하지 않는다. 원인을 확인하고 재측정하라." - else - $MYSQL_PERF -B -e " - SELECT COUNT_STAR AS calls, - COUNT_STAR / $REQS AS per_req, - AVG_TIMER_WAIT / 1e9 AS mean_ms, - SUM_TIMER_WAIT / 1e9 AS total_ms, - 100 * SUM_TIMER_WAIT / SUM(SUM_TIMER_WAIT) OVER () AS pct, - SUM_ROWS_SENT / NULLIF(COUNT_STAR, 0) AS rows_per_call, - SUM_ROWS_EXAMINED / NULLIF(SUM_ROWS_SENT, 0) AS examined_per_sent, - DIGEST_TEXT - FROM performance_schema.events_statements_summary_by_digest - WHERE SCHEMA_NAME = 'uss_db' - AND DIGEST_TEXT NOT LIKE '%performance_schema%' - ORDER BY SUM_TIMER_WAIT DESC LIMIT 20;" \ - | tee $TARGET_DIR/query-stats-summary-0.md - fi - ``` - - - **`ANALYZE TABLE`을 빼지 마라.** 되돌리기 `DELETE` 이후 인덱스 통계가 실제 분포와 어긋난 채로 남으면 - 옵티마이저가 다른 계획을 고를 수 있고, Phase 8의 전후 비교가 계획 차이로 오염된다. - - `-B`를 빼지 마라. 기본 박스 출력은 `DIGEST_TEXT`를 잘라 리포지토리 메서드를 식별할 수 없게 만든다. - `-B`는 탭 구분으로 한 줄에 내보낸다. - - **`SUM_TIMER_WAIT`는 피코초다.** `/1e9`를 빼면 단위가 ms가 아니게 된다. - - **수집 단계에서 반올림하지 마라.** `per_req`를 소수 둘째 자리로 자르면 0.005 미만인 쿼리가 `0.00`으로 사라진다. - 반올림은 대화에서 표로 제시할 때만 한다. 파일에는 MySQL이 뽑아준 값을 그대로 둔다. - - `REQS` 가드를 빼지 마라. 요청 0건이면 `per_req`의 분모가 0이 되어 수집이 무의미해진다. - - `examined_per_sent`는 **읽은 행 대 돌려준 행의 비율**이다. PostgreSQL의 `Rows Removed by Filter`에 해당하는 - 하드웨어 독립 지표이고, 인덱스 필요성을 가장 직접적으로 보여준다. 이 칼럼을 빼지 마라. - -2. 실행이 끝나면 k6 요약(`.claude/resources/perf/{이슈번호}/{슬러그}/k6-test-summary-0.json`)을 Read로 읽는다. - 쿼리 통계 1차 출력(`query-stats-summary-0.md`)은 메인에서 Read하지 않는다. 절차 3의 위임이 끝난 뒤 가공본을 읽는다. - - - 터미널 출력을 붙여넣게 하지 마라. 파일이 없으면 원인을 확인하고 재실행을 요청한다. 추정으로 채우지 마라. - - k6 요약에는 스크립트가 선별해 내보낸 값만 있다. 담기지 않은 지표가 필요해지면 재측정해야 한다. - - `checks_rate`가 1이 아니면 `checks[]`에서 어떤 항목이 깨졌는지 먼저 확인한다. - 데이터 검증 check가 깨진 측정은 진단에 쓰지 마라. - -3. 가공본 작성을 Agent 도구로 `query-source-mapper`에 위임한다. - 1차 출력을 메인에서 Read하거나 직접 가공하지 마라. Grep 탐색 흔적이 메인 컨텍스트에 남지 않게 하는 위임이다. - - 프롬프트에 넘길 것 (전체 경로로): - - 1차 출력: `.claude/resources/perf/{이슈번호}/{슬러그}/query-stats-summary-0.md` - - 템플릿: `.claude/skills/optimize-performance/template/query-stats-template.md` - - `record.md` (예상 쿼리 목록이 출처 매핑의 1차 후보) - - 상태 번호 `n=0` - - k6 요약: `.claude/resources/perf/{이슈번호}/{슬러그}/k6-test-summary-0.json` - - 반환된 출처 미상 목록과 `DIGEST_TEXT` 잘림 여부를 확인한다. 미상이 남는 것은 정상이다. 채우라고 재호출하지 마라. - -4. 가공본(`query-stats-summary-0.md`)을 Read해 k6 요약과 함께 호출자에게 제시하고 **병목 판정을 묻는다.** - `SKILL.md`의 **분석 주도 규칙**을 따른다. +3. 가공본 작성을 `query-source-mapper`에 위임한다. Grep 흔적이 메인 컨텍스트에 남지 않게 하는 위임이다. + 프롬프트에 넘길 것(전체 경로): 1차 출력 `query-stats-summary-0.md`, 템플릿 `query-stats-template.md`, `record.md`, 상태 번호 `n=0`, `k6-test-summary-0.json`. + 반환된 출처 미상 목록과 잘림 여부만 확인한다. 미상이 남는 것은 정상이다. 채우라고 재호출하지 마라. +4. 가공본을 Read해 k6 요약과 함께 제시하고 **병목 판정을 묻는다** (`SKILL.md`의 **역할 경계**). - 제시할 것: 응답시간 분포, 처리량, check 결과, 쿼리별 요청당 호출 수, 총 시간 비중, `examined_per_sent`. - 물을 것: "요청당 쿼리 수와 시간이 쏠린 지점을 보고, 병목의 성격을 어떻게 판단하십니까?" - - 결론을 먼저 말하지 마라. 아래 표는 호출자가 막혔을 때 꺼내는 재료다. + - 아래 표는 호출자가 막혔을 때 꺼내는 재료다. 먼저 보여주지 마라. | 관측 | 진단 | 유력한 기법 | |---|---|---| | 특정 쿼리 1건이 느리고 호출 수는 예상대로 | 쿼리 자체 비효율 | 인덱스, 쿼리 재작성 | - | `examined_per_sent`가 크다 | 읽고 버리는 행이 많음 | 인덱스, WHERE 조건 선택도 개선 | + | `examined_per_sent`가 크다 | 읽고 버리는 행이 많음 | 인덱스, WHERE 선택도 개선 | | 쿼리는 빠른데 호출 수가 요청당 N배 | N+1 | fetch join, DTO projection, `@BatchSize` | | 쿼리 효율적이고 호출도 적은데 API가 느림 | DB 밖 문제 | 직렬화, 응답 크기, 컬렉션 가공 | | 매 요청이 같은 결과를 다시 계산 | 불필요한 재조회 | 캐싱 | | 단건은 빠른데 VU를 올리면 급락 | 자원 경합 | 커넥션 풀, 트랜잭션 범위 축소, 락 경합 | - | 쓰기 엔드포인트에서 VU에 비례해 대기가 늘어남 | 같은 행에 쓰기가 몰림 | 락 범위 축소, 원자적 UPDATE | + | 쓰기에서 VU에 비례해 대기가 늘어남 | 같은 행에 쓰기가 몰림 | 락 범위 축소, 원자적 UPDATE | -5. 호출자의 판정에 대해 타당성을 확인한다. - - Phase 1의 예상 쿼리 목록과 실제 `per_req`가 어긋난 지점이 있으면 반드시 짚는다. - - 시간 비중이 낮은 쿼리를 병목으로 지목했으면 `pct` 수치로 반례를 든다. +5. 판정의 타당성을 확인한다. + - Phase 1의 예상 쿼리 목록과 실제 `per_req`가 어긋난 지점은 반드시 짚는다. + - 시간 비중이 낮은 쿼리를 병목으로 지목했으면 `pct`로 반례를 든다. -6. 확정된 판정과 근거를 `.claude/resources/perf/{이슈번호}/{슬러그}/record.md`의 **기준선**에 남긴다. - 근거에는 관측된 수치를 쓴다. 판정의 주체가 호출자였다는 사실은 따로 적지 않는다. +6. 확정된 판정과 근거 수치를 `record.md`의 **기준선**에 적는다. 판정의 주체가 호출자였다는 사실은 적지 않는다. ### 출력 -- `.claude/resources/perf/{이슈번호}/tokens.json` 생성 -- `.claude/resources/perf/{이슈번호}/{슬러그}/k6-test-summary-0.json` 생성 -- `.claude/resources/perf/{이슈번호}/{슬러그}/query-stats-summary-0.md` 생성 (가공본) -- `record.md`의 **기준선** 표와 쿼리 통계, 진단이 채워짐 -- `record.md`의 진행 상태의 Phase 4가 ✅로 기록 +- `tokens.json`, `k6-test-summary-0.json`, `query-stats-summary-0.md` (가공본) +- `record.md`의 **기준선**과 진단, 진행 상태 Phase 4 ✅ ### 실패 처리 -- 에러율이 높거나 데이터 검증 check가 깨져 측정이 무의미하면, 원인을 짚어 스크립트나 시드를 수정한 후 재측정하도록 안내한다. 실패한 측정치로 진단하지 않는다. -- 토큰 수가 시드 회원 수와 다르면 로그인에 실패한 학번이 있는 것이다. 비밀번호 해시(`@pw_hash`)가 - Phase 3-A에서 뽑은 값과 같은지 먼저 확인한다. - -> 다음 Phase 조건: 병목의 성격이 판정되었고 근거 수치가 기록되었을 때 → Phase 5 +- 에러율이 높거나 데이터 검증 check가 깨졌으면 원인을 짚어 스크립트나 시드를 고치고 재측정하게 한다. 실패한 측정치로 진단하지 않는다. +- 토큰 수가 `USER_COUNT`와 다르면 `mint-tokens.sh`의 `--count`가 어긋난 것이다. 첫 토큰으로 401이 나면 `--secret`이 `application-perf.yml`과 다른 것이다. -> Skip 조건: 없음 (필수 Phase) +> 다음 Phase 조건: 병목의 성격이 판정되고 근거 수치가 기록되었을 때 → Phase 5 +> +> Skip 조건: 없음 diff --git a/.claude/skills/optimize-performance/phases/phase-5-design.md b/.claude/skills/optimize-performance/phases/phase-5-design.md index 5f6b6ce..fc3ead5 100644 --- a/.claude/skills/optimize-performance/phases/phase-5-design.md +++ b/.claude/skills/optimize-performance/phases/phase-5-design.md @@ -1,101 +1,73 @@ ## Phase 5. 기법 선택과 설계 ### 목적 -Phase 4의 진단에 근거하여 성능 최적화 기법 선택지를 제시하고 호출자와 어떻게 구현할지를 설계한다. +Phase 4의 진단에 근거해 기법 선택지를 제시하고, 호출자가 고른 기법의 설계를 함께 확정한다. **게이트.** 기법과 설계를 스킬이 단독으로 확정하지 마라. ### 선행 조건 -- Phase 4 완료 - -### 참조 파일 -- 없음 +- Phase 4 완료 (사이클 1) 또는 Phase 8에서 계속 판정 (사이클 2+) ### 절차 #### 5-A. 기법 선택 -1. 판정에 근거해 기법을 표로 제시한다. + +1. 판정에 근거해 기법을 표로 제시한다. 근거에는 Phase 4(또는 직전 Phase 8)의 관측 수치를 쓴다. + 비용에는 마이그레이션 필요 여부, 쓰기 성능 영향, 무효화 설계 필요 여부를 적는다. + 이 표에는 기법 이름까지만 쓴다. 설계는 5-B에서 정한다. | # | 기법 | 근거 (관측된 사실) | 예상 효과 | 비용, 리스크 | |---|---|---|---|---| - - 근거에는 Phase 4에서 관측된 수치를 사용한다. - - 비용에는 마이그레이션 필요 여부, 쓰기 성능 영향, 무효화 설계 필요 여부를 적는다. - - 이 표에는 기법의 이름까지만 작성한다. 구체적인 설계는 5-B에서 호출자와 함께 확정한다. - 2. 쿼리 튜닝, 로직 개선, 캐싱 순으로 배치한다. - -3. 표를 보고하고 호출자의 선택을 기다린다. - - 한 사이클에 한 기법만 적용한다. +3. 표를 보고하고 호출자의 선택을 기다린다. 한 사이클에 한 기법만 적용한다. #### 5-B. 설계 협의 -호출자가 설계하고 스킬이 검증한다. `SKILL.md`의 **분석 주도 규칙**을 따르되, 여기서는 해석이 아니라 설계가 대상이다. +호출자가 설계하고 스킬이 검증한다 (`SKILL.md`의 **역할 경계**). -1. 판단 재료를 먼저 전부 펼친다. +1. 판단 재료를 먼저 펼친다. - 대상 쿼리 원문과 `WHERE` / `ORDER BY` / `GROUP BY` 절 - - 해당 테이블의 기존 인덱스 목록, 행 수, 컬럼 카디널리티 - (Phase 3에서 확정해 측정 환경에 적어둔 값과 실제 값을 함께 본다) - - 재료가 부족하면 아래를 호출자에게 실행하도록 제시하고 결과를 받는다. + - 테이블의 기존 인덱스, 행 수, 컬럼 카디널리티 (Phase 3에서 확정한 값과 실제 값을 함께) + - 재료가 부족하면 실행하게 하고 결과를 받는다. 파일로 남기지 않는다. ```bash - # 테이블 정의와 기존 인덱스 - $MYSQL_PERF -e "SHOW CREATE TABLE {테이블}\G" - - # 인덱스별 카디널리티 (옵티마이저가 실제로 보는 값) - $MYSQL_PERF -e "SHOW INDEX FROM {테이블};" - - # 컬럼별 서로 다른 값의 개수와 최빈값 쏠림 - $MYSQL_PERF -e " - SELECT count(*) AS total, - count(DISTINCT {컬럼}) AS distinct_vals, - max(c) AS max_group_size - FROM (SELECT {컬럼}, count(*) AS c FROM {테이블} GROUP BY {컬럼}) g, - (SELECT 1) x;" + mysqlp -e "SHOW CREATE TABLE {테이블}\G" + mysqlp -e "SHOW INDEX FROM {테이블};" + mysqlp -e " + SELECT count(*) AS total, count(DISTINCT {컬럼}) AS distinct_vals, max(c) AS max_group_size + FROM (SELECT {컬럼}, count(*) AS c FROM {테이블} GROUP BY {컬럼}) g;" ``` - - `SHOW INDEX`의 `Cardinality`는 샘플링 추정치다. 실제 `count(DISTINCT)`와 크게 어긋나면 - `ANALYZE TABLE`이 필요한 상태이므로, 그 사실을 먼저 짚는다. + - `SHOW INDEX`의 `Cardinality`는 샘플링 추정치다. `count(DISTINCT)`와 크게 어긋나면 `ANALYZE TABLE`이 필요한 상태이므로 먼저 짚는다. -2. 결정할 항목을 나열하고, **한 번에 하나씩** 호출자에게 묻는다. +2. 결정할 항목을 나열하고 **한 번에 하나씩** 묻는다. 정답을 먼저 말하지 마라. 질문을 몰아서 하지 마라. | 기법 | 호출자가 결정할 항목 | |---|---| | 인덱스 | 대상 컬럼, 컬럼 순서, 프리픽스 길이, 커버링 여부, 감수할 쓰기 비용 | | fetch join | 컬렉션 조인 여부와 페이징 충돌 처리, 중복 제거 방식 | | `@BatchSize`, projection | batch size 값, DTO projection으로 대체할지 | - | 캐싱 | 캐시 키 구성, TTL, 무효화 시점과 대상 쓰기 경로, 허용할 정합성 지연 | - | 커넥션 풀, 트랜잭션 | 풀 크기와 그 산정 근거, 트랜잭션 경계를 어디까지 좁힐지 | - | 락, 원자적 갱신 | 비관적 락 / 조건부 UPDATE 중 무엇을 쓸지, 실패 시 사용자에게 무엇을 돌려줄지 | + | 캐싱 | 캐시 키, TTL, 무효화 시점과 대상 쓰기 경로, 허용할 정합성 지연 | + | 커넥션 풀, 트랜잭션 | 풀 크기와 산정 근거, 트랜잭션 경계를 어디까지 좁힐지 | + | 락, 원자적 갱신 | 비관적 락 / 조건부 UPDATE 중 무엇을, 실패 시 사용자에게 무엇을 돌려줄지 | - - 정답을 먼저 말하지 마라. 결정에 필요한 사실만 주고 호출자의 답을 기다린다. - - 질문을 몰아서 하지 마라. +3. 답마다 판정한다. 타당하지 않으면 어떤 조회 패턴, 데이터 분포에서 깨지는지 반례를 든다. MySQL 제약을 놓치면 반드시 짚는다. + - InnoDB 보조 인덱스는 PK를 항상 포함한다. 커버링 인덱스에 PK를 다시 넣지 않는다. + - `ORDER BY`가 인덱스 순서와 어긋나면 `filesort`가 붙는다. 등호 조건 뒤에 정렬 컬럼을 둔다. + - `FULLTEXT`는 `MATCH ... AGAINST`에서만 쓰인다. `LIKE`로 바꾸면 죽는다. + - 인덱스 컬럼에 함수를 씌우면 인덱스를 못 쓴다. + - 서비스 정책이 걸린 설계면 `.claude/spec/service-policy/`의 해당 파일을 읽고 어긋나는지 확인한다. -3. 호출자의 답마다 판정한다. - - 타당하지 않으면 반례를 제시한다. 어떤 조회 패턴, 데이터 분포에서 그 설계가 깨지는지 설명한다. - - MySQL에서 걸리는 제약을 놓치면 반드시 짚는다. - - InnoDB의 보조 인덱스는 PK를 항상 포함한다. 커버링 인덱스를 설계할 때 PK를 다시 넣지 않는다 - - `ORDER BY`가 인덱스 순서와 어긋나면 `filesort`가 붙는다. 등호 조건 뒤에 정렬 컬럼을 둔다 - - `FULLTEXT` 인덱스는 `MATCH ... AGAINST`에서만 쓰인다. `LIKE`로 바꾸면 인덱스가 죽는다 - - 인덱스 컬럼에 함수를 씌우면(`DATE(created_at)`) 인덱스를 못 쓴다 - - 서비스 정책이 걸린 설계면 `.claude/spec/service-policy/`의 해당 도메인 파일을 읽고 어긋나는지 확인한다. +4. 호출자의 설계가 최선이 아니어도 동작하고 위험하지 않으면 그대로 진행한다. 더 나은 대안은 근거와 함께 제시하되, 호출자가 고수하면 따른다. + 데이터 정합성이 깨지거나 되돌리기 어려운 설계일 때만 중단하고 근거를 설명한다. -4. 호출자의 설계가 최선이 아니어도, 동작하고 위험하지 않으면 그대로 진행한다. - - 더 나은 대안이 있다면, 근거와 함께 제시한다. - - 호출자가 자기 안을 고수하면 그대로 진행한다. - - 데이터 정합성이 깨지거나, 되돌리기 어려운 설계일 경우에만 진행을 중단하고 근거를 설명한다. - -5. 확정된 설계와 예상 효과를 `.claude/resources/perf/{이슈번호}/{슬러그}/record.md`의 사이클 {n} 설계 결정에 기록한다. - -6. `.claude/resources/perf/{이슈번호}/{슬러그}/record.md`의 진행 상태에 이번 사이클 행을 추가하고, **기법** 칸에 선택된 기법을 적는다. +5. 확정된 설계, 배제한 안, 호출자가 예상한 효과를 `record.md`의 사이클 {n} **설계 결정**에 적는다. + 진행 상태에 사이클 행을 추가하고 **기법** 칸을 채운다. ### 출력 -- `.claude/resources/perf/{이슈번호}/{슬러그}/record.md`에 사이클 {n} 섹션이 생성되고, 적용할 기법과 **설계 결정**이 확정됨 -- `.claude/resources/perf/{이슈번호}/{슬러그}/record.md`의 진행 상태의 사이클 {n} Phase 5가 ✅으로 기록 - -### 실패 처리 -- 없음 - -> 다음 Phase 조건: 적용할 기법 하나와 설계 결정이 확정되어 기록되었을 때 → Phase 6 +- `record.md`에 사이클 {n} 섹션과 **설계 결정**, 진행 상태 사이클 {n} Phase 5 ✅ -> Skip 조건: 없음 (필수 Phase) +> 다음 Phase 조건: 기법 하나와 설계 결정이 확정되어 기록되었을 때 → Phase 6 +> +> Skip 조건: 없음 diff --git a/.claude/skills/optimize-performance/phases/phase-6-snapshot.md b/.claude/skills/optimize-performance/phases/phase-6-snapshot.md index dffee5e..055a5d3 100644 --- a/.claude/skills/optimize-performance/phases/phase-6-snapshot.md +++ b/.claude/skills/optimize-performance/phases/phase-6-snapshot.md @@ -7,138 +7,75 @@ - Phase 5 완료 ### 참조 파일 -- 없음 +- `.claude/skills/optimize-performance/template/commands.md` ### 절차 -1. 기법과 무관하게 공통으로 캡처한다. - - k6 요약: p95, p99, RPS, 에러율 - - 요청당 쿼리 수, 대상 쿼리의 `calls` / `mean_ms` / `total_ms` / `examined_per_sent` - - 부하 조건: VU, duration, 데이터 규모, 커넥션 풀 크기 - -2. EXPLAIN에 넣을 쿼리를 만든다. - - - digest의 쿼리 원문은 리터럴이 `?`로 정규화되어 있다. 그대로 EXPLAIN하면 실패한다. - - 파라미터마다 리터럴을 대입한다. 대입값은 **부하 스크립트가 실제로 보내는 값의 범위**에서 고른다. - - memberId 계열: 시드 회원 id 범위 안의 값 - - courseId, 학과, 영역 계열: Phase 3에서 확정한 카디널리티 분포의 중앙에 있는 값 - - 값을 한쪽 끝(최솟값, 최댓값, 행이 0건인 값)으로 잡지 마라. - 특히 **분포가 치우친 컬럼은 값 하나로 계획이 뒤집힌다.** 흔한 값과 드문 값 중 어느 쪽을 기준으로 - 삼을지 호출자와 정하고, 정한 이유를 함께 적는다. - - 대입한 파라미터 값을 `record.md`의 사이클 {n} **실행계획**에 적는다. 이후 모든 사이클에서 같은 값을 쓴다. - -3. 대상 쿼리의 실행계획을 `query-plan-{n-1}.txt`로 캡처한다. (사이클 1이면 `query-plan-0.txt`) - - - **`query-plan-{n-1}.txt`가 이미 있고 대상 쿼리가 같으면 다시 뜨지 마라.** Read로 읽고 4번으로 간다. - - 없거나 대상 쿼리가 직전 사이클과 다르면 아래를 제시하고 결과를 받는다. - `tee -a`로 덧붙인다. 같은 상태의 다른 쿼리 계획을 덮어쓰지 마라. - - ```bash - # 새 터미널이면 먼저: - # export PERF_DIR=.claude/resources/perf/{이슈번호} - # export TARGET_DIR=$PERF_DIR/{슬러그} - # export MYSQL_PWD=root - # export MYSQL_PERF="mysql -h 127.0.0.1 -P 3307 -u root uss_db" - - # 버퍼 풀을 채우는 1회. 이 출력은 쓰지 않는다. - $MYSQL_PERF -e "EXPLAIN ANALYZE {대상 쿼리}" > /dev/null - - # 기록용 2회차 - 실측 계획, 추정 계획, 접근 방식별 실제 작업량을 한 파일에 이어 붙인다 - { - echo "=== EXPLAIN ANALYZE ===" - $MYSQL_PERF -e "EXPLAIN ANALYZE {대상 쿼리}\G" - - echo "=== EXPLAIN FORMAT=JSON ===" - $MYSQL_PERF -e "EXPLAIN FORMAT=JSON {대상 쿼리}\G" - - echo "=== HANDLER COUNTERS ===" - $MYSQL_PERF -e " - FLUSH STATUS; - {대상 쿼리}; - SHOW SESSION STATUS WHERE Variable_name LIKE 'Handler_%' AND Value > 0;" \ - | grep -A100 'Handler_' - } | tee -a $TARGET_DIR/query-plan-{n-1}.txt - ``` - - - `EXPLAIN ANALYZE`는 대상 쿼리를 **실제로 실행한다.** 읽기 쿼리라 되돌릴 것은 없지만, - 대상이 쓰기 쿼리면 상황이 다르다. 아래를 따른다. - - MySQL 8.0의 `EXPLAIN ANALYZE`는 `SELECT`만 받는다. 쓰기 쿼리면 `EXPLAIN FORMAT=JSON`(실행하지 않음)만 쓴다 - - 쓰기 쿼리의 실제 작업량이 필요하면 `BEGIN; {쿼리}; ROLLBACK;` 안에서 Handler 카운터를 잰다. - AUTO_INCREMENT 증가는 롤백되지 않는다는 사실을 호출자에게 알린다 - - **`FLUSH STATUS`와 쿼리를 같은 세션에서 실행해야 한다.** 명령을 나눠 실행하면 세션이 갈려 카운터가 0으로 나온다. - 위 블록처럼 하나의 `-e` 안에 세미콜론으로 이어 붙인다. - - 파일을 Read로 읽는다. 터미널 출력을 붙여넣게 하지 마라. - **이 파일은 원본 그대로 둔다.** 가공본으로 덮어쓰지 마라. - -4. 계획에서 아래 수치를 뽑아 노드별 표로 정리해 대화에서 제시한다. 파일에는 쓰지 않는다. +1. 공통 지표를 `record.md`의 사이클 {n} **개선 전 지표**에 옮긴다. `-{n-1}` 파일에서 가져온다. + k6 p95 / p99 / RPS / 에러율, 요청당 쿼리 수, 대상 쿼리의 `calls` / `mean_ms` / `total_ms` / `examined_per_sent`. + +2. EXPLAIN에 넣을 쿼리를 만든다. digest 원문은 리터럴이 `?`라 그대로 EXPLAIN하면 실패한다. + - 대입값은 **부하 스크립트가 실제로 보내는 값의 범위**에서 고른다. 회원 id는 시드 범위 안, 학과나 영역은 카디널리티 분포의 중앙값. + - 한쪽 끝(최솟값, 최댓값, 0건인 값)으로 잡지 마라. **분포가 치우친 컬럼은 값 하나로 계획이 뒤집힌다.** + 흔한 값과 드문 값 중 어느 쪽을 기준으로 삼을지 호출자와 정하고 이유를 함께 적는다. + - 대입한 값을 **실행계획**에 적는다. 이후 모든 사이클에서 같은 값을 쓴다. + +3. `query-plan-{n-1}.txt`를 캡처한다. 이미 있고 대상 쿼리가 같으면 다시 뜨지 않고 Read한다. + 없거나 쿼리가 바뀌었으면 `commands.md`의 **B. 실행계획 캡처** 블록을 `{n-1}`로 제시하고 파일을 Read한다. + 이 파일은 원본 그대로 둔다. + +4. 계획을 노드별 표와 카운터 표로 정리해 대화에 제시한다. 파일에는 쓰지 않는다. + **표 아래에 칼럼 설명을 그 계획의 실제 수치로 예를 들어 붙인다.** 표만 던지지 마라. | 노드 | 접근 방식 / 인덱스 | actual time | 추정 rows | 실측 rows | loops | 비고 | |---|---|---|---|---|---|---| - 그리고 Handler 카운터를 별도 표로 함께 낸다. - | 카운터 | 값 | 뜻 | |---|---|---| - **두 표 바로 아래에 칼럼 설명을 함께 붙인다.** 표만 던지지 마라. 아래 내용을 그 계획의 실제 수치로 예를 들어 적는다. - | 칼럼 | 설명 | |---|---| - | actual time=A..B | A는 첫 행까지, B는 마지막 행까지 걸린 시간(ms). **loops당 평균**이고 자식 노드의 시간을 포함한다 | - | 추정 rows (`cost=... rows=N`) | 옵티마이저가 실행 전에 인덱스 통계를 보고 계산한 예상 행 수. 옵티마이저는 이 값만 보고 접근 방식과 조인 순서를 고른다 | - | 실측 rows | 그 노드가 실제로 내보낸 행 수. **loops당 평균값**이므로 총량을 보려면 loops를 곱한다. 추정과 10배 이상 벌어지면 통계가 낡았거나 계획 자체가 잘못 골라졌다 | - | loops | 그 노드가 실행된 횟수. Nested Loop 안쪽이면 바깥 행 수만큼 커진다 | - - | Handler 카운터 | 뜻 | - |---|---| - | `Handler_read_rnd_next` | 테이블을 순차로 다음 행 읽기. **풀스캔의 직접 증거**다. 반환 행 수보다 훨씬 크면 읽고 버린 행이 그만큼이다 | - | `Handler_read_key` | 인덱스로 행을 찾은 횟수. 이 값이 크고 `rnd_next`가 작으면 인덱스가 일하고 있다 | - | `Handler_read_next` | 인덱스 순서로 다음 행 읽기. 범위 스캔의 폭이다 | + | actual time=A..B | A는 첫 행까지, B는 마지막 행까지(ms). **loops당 평균**이고 자식 노드 시간을 포함한다 | + | 추정 rows | 옵티마이저가 실행 전에 인덱스 통계로 계산한 예상 행 수. 접근 방식과 조인 순서는 이 값으로 정해진다 | + | 실측 rows | 그 노드가 실제로 내보낸 행 수. loops당 평균이므로 총량은 loops를 곱한다. 추정과 10배 이상 벌어지면 통계가 낡았거나 계획이 잘못 골라진 것이다 | + | loops | 노드가 실행된 횟수. Nested Loop 안쪽이면 바깥 행 수만큼 커진다 | + | `Handler_read_rnd_next` | 테이블을 순차로 읽은 횟수. **풀스캔의 직접 증거.** 반환 행보다 훨씬 크면 그만큼 읽고 버린 것이다 | + | `Handler_read_key` | 인덱스로 행을 찾은 횟수. 크고 `rnd_next`가 작으면 인덱스가 일하고 있다 | + | `Handler_read_next` | 인덱스 순서로 다음 행을 읽은 횟수. 범위 스캔의 폭 | | `Handler_read_rnd` | 인덱스로 찾은 위치에서 행을 다시 읽은 횟수. 커버링이 안 될 때 늘어난다 | - | `Sort_scan`, `Sort_rows` | `filesort` 발생 여부와 정렬한 행 수. 인덱스 순서로 정렬이 해결되면 0이 된다 | + | `Sort_scan`, `Sort_rows` | filesort 발생 여부와 정렬한 행 수. 인덱스 순서로 정렬이 풀리면 0 | - `EXPLAIN ANALYZE`에는 PostgreSQL의 `BUFFERS`에 해당하는 항목이 없다. - **읽은 페이지 대신 읽은 행으로 본다.** 그 근거가 Handler 카운터다. 없는 지표를 있는 것처럼 적지 마라. + PostgreSQL의 `BUFFERS`에 해당하는 항목은 없다. 읽은 페이지 대신 읽은 행으로 본다. 없는 지표를 있는 것처럼 적지 마라. - 그다음 **어느 노드가 비용을 먹고 있는지 호출자에게 묻는다.** `SKILL.md`의 **분석 주도 규칙**을 따른다. - - 표는 사실만 옮긴다. 특정 행을 강조하거나 순서를 바꿔 답을 유도하지 마라. - - 호출자의 답에 대해 타당성을 확인하고, 어긋나면 표의 수치로 반례를 든다. - - 호출자가 막히면 그때 6번의 위험 신호 표를 꺼내 함께 본다. - - 확정된 해석을 `record.md`의 사이클 {n} **실행계획**에 적는다. + 그다음 **어느 노드가 비용을 먹고 있는지 묻는다** (`SKILL.md`의 **역할 경계**). 특정 행을 강조하거나 순서를 바꿔 답을 유도하지 마라. + 호출자가 막히면 6의 위험 신호 표를 함께 본다. 확정된 해석을 **실행계획**에 적는다. 5. 기법별로 추가 캡처한다. - - **캐싱**: 동일 입력의 반복 호출 비율, 무효화가 필요한 쓰기 경로 목록 - - **로직 개선**: 변경 전 요청당 쿼리 수와 그 호출 스택 - - **풀, 트랜잭션**: 커넥션 획득 대기 시간, 트랜잭션 유지 구간 - - **락, 원자적 갱신**: 락 대기 현황 + - 캐싱: 동일 입력의 반복 호출 비율, 무효화가 필요한 쓰기 경로 목록 + - 로직 개선: 변경 전 요청당 쿼리 수와 호출 스택 + - 풀, 트랜잭션: 커넥션 획득 대기 시간, 트랜잭션 유지 구간 + - 락, 원자적 갱신: 락 대기 현황 ```bash - $MYSQL_PERF -e " - SELECT * FROM performance_schema.data_lock_waits\G - SHOW ENGINE INNODB STATUS\G" | grep -A20 'LATEST DETECTED DEADLOCK\|TRANSACTIONS' + mysqlp -e "SELECT * FROM performance_schema.data_lock_waits\G SHOW ENGINE INNODB STATUS\G" \ + | grep -A20 'LATEST DETECTED DEADLOCK\|TRANSACTIONS' ``` 6. 판정은 절대 시간이 아니라 비율로 한다. 이 표는 호출자와 함께 본다. | 지표 | 위험 신호 | |---|---| - | `examined_per_sent` (읽은 행 / 반환 행) | 100:1 초과 | - | 옵티마이저 추정 대 실측 행 수 | 10배 이상 괴리 | + | `examined_per_sent` | 100:1 초과 | + | 추정 대 실측 행 수 | 10배 이상 괴리 | | 단일 쿼리의 `total_ms` 점유율 | 30% 이상 | - | OLTP 경로의 풀스캔(`Handler_read_rnd_next` 급증) | 1만 행 이상 테이블이면 신호 | + | OLTP 경로의 풀스캔 (`Handler_read_rnd_next` 급증) | 1만 행 이상 테이블이면 신호 | | 동일 쿼리의 요청당 호출 횟수 | 1회 초과면 N+1 의심 | | `Sort_rows`가 반환 행 수보다 큼 | 정렬을 인덱스로 못 풀고 있음 | ### 출력 -- `.claude/resources/perf/{이슈번호}/{슬러그}/query-plan-{n-1}.txt` 생성 (원본 유지) -- `.claude/resources/perf/{이슈번호}/{슬러그}/record.md`의 사이클 {n} **개선 전 지표**와 **실행계획**이 채워짐 - (EXPLAIN에 대입한 파라미터 값 포함) -- `.claude/resources/perf/{이슈번호}/{슬러그}/record.md`의 진행 상태의 사이클 {n} Phase 6이 ✅으로 기록 - -### 실패 처리 -- 없음 +- `query-plan-{n-1}.txt` (원본) +- `record.md`의 사이클 {n} **개선 전 지표**, **실행계획**(파라미터 값 포함), 진행 상태 Phase 6 ✅ > 다음 Phase 조건: 개선 전 지표와 실행계획이 기록되었을 때 → Phase 7 - -> Skip 조건: 없음 (필수 Phase) +> +> Skip 조건: 없음 diff --git a/.claude/skills/optimize-performance/phases/phase-7-apply.md b/.claude/skills/optimize-performance/phases/phase-7-apply.md index e688ecb..714af8e 100644 --- a/.claude/skills/optimize-performance/phases/phase-7-apply.md +++ b/.claude/skills/optimize-performance/phases/phase-7-apply.md @@ -1,7 +1,7 @@ ## Phase 7. 개선 적용 ### 목적 -호출자가 고른 기법 하나를 코드에 반영하고, 동작이 그대로인지 확인한다. +호출자가 고른 기법 하나를 Phase 5-B의 설계 그대로 코드에 반영하고, 동작이 그대로인지 확인한 뒤 재기동한다. ### 선행 조건 - Phase 6 완료 @@ -13,61 +13,58 @@ ### 절차 -1. 호출자가 고른 기법을 적용한다. - - Phase 5-B에서 확정한 설계 결정 그대로 구현한다. - - **이번 대상의 개선만 적용한다.** 같은 이슈의 다른 대상에도 통할 것 같은 변경이라도 여기서 함께 넣지 마라. - 아직 기준선을 잡지 않은 대상에 코드가 먼저 들어가면 그 대상의 `-0`이 원본이 아니게 된다. - 그런 변경이 보이면 다음 대상의 Phase 5에서 후보로 꺼낸다. (`SKILL.md`의 **대상 진행 규칙**) +1. 설계 결정 그대로 구현한다. 수정하는 레이어의 컨벤션 파일을 Read하고 그에 맞춘다. + **이번 대상의 개선만 넣는다.** 다른 대상에도 통할 변경이라도 여기서 함께 넣지 마라. + 기준선을 잡지 않은 대상에 코드가 먼저 들어가면 그 대상의 `-0`이 원본이 아니게 된다. 다음 대상의 Phase 5에서 후보로 꺼낸다. -2. 수정해야 할 레이어에 맞는 코드 컨벤션 파일을 Read로 읽고 그에 맞춰 작성한다. +2. 스키마 변경은 `.claude/rules/migration.md`를 따라 새 버전의 Flyway 파일로 추가한다. 적용된 파일을 고치지 마라. + 인덱스는 `ALTER TABLE`로 추가하고, 적용 후 통계를 갱신하게 한다. 만든 직후의 통계는 실제 분포와 어긋나 있을 수 있다. -3. 인덱스 추가를 비롯한 스키마 변경은 `.claude/rules/migration.md`를 따라 Flyway 마이그레이션 파일로 작성한다. - - 이미 적용된 마이그레이션 파일을 고치지 마라. 새 버전 파일로 추가한다. - - 인덱스는 기존 테이블 정의 안이 아니라 `ALTER TABLE`로 추가한다. - - ```sql - -- V0_7__add_index_to_courses.sql - ALTER TABLE courses ADD INDEX idx_department_grade (course_department, course_grade); - ``` - - - 마이그레이션 적용 후 대상 테이블의 통계를 갱신하도록 호출자에게 제시한다. - 인덱스를 만든 직후의 통계는 실제 분포와 어긋나 있을 수 있다. + ```sql + -- V{major}_{minor}__add_index_to_{테이블}.sql + ALTER TABLE {테이블} ADD INDEX idx_{이름} ({컬럼}, {컬럼}); + ``` - ```bash - $MYSQL_PERF -e "ANALYZE TABLE {테이블};" - ``` + ```bash + mysqlp -e "ANALYZE TABLE {테이블};" + ``` -4. 개선이 실제로 그 쿼리에 붙었는지 **적용 직후에 한 번 확인한다.** 재측정 전에 확인해야 - Phase 8에서 "효과 없음"과 "적용 안 됨"을 구분할 수 있다. +3. 개선이 실제로 그 쿼리에 붙었는지 **적용 직후 확인한다.** 여기서 확인해야 Phase 8에서 "효과 없음"과 "적용 안 됨"을 구분할 수 있다. ```bash - $MYSQL_PERF -e "EXPLAIN {대상 쿼리}\G" | grep -E 'key|type|rows|Extra' + mysqlp -e "EXPLAIN {대상 쿼리}\G" | grep -E 'key|type|rows|Extra' ``` - - 새로 만든 인덱스가 `key`에 잡히지 않으면 그 사실을 먼저 보고하고, Phase 8로 넘어가지 마라. - 컬럼 순서, 함수 감싸기, 타입 불일치를 순서대로 확인한다. + 새 인덱스가 `key`에 잡히지 않으면 그 사실을 먼저 보고하고 넘어가지 마라. 컬럼 순서, 함수 감싸기, 타입 불일치 순으로 확인한다. -5. 테스트를 실행하도록 호출자에게 제시하고 결과를 받는다. **실행은 호출자가 한다.** +4. 테스트를 실행하게 하고 결과를 받는다. ```bash ./gradlew test ``` - - 실패한 테스트가 있으면 원인을 짚어 보고하고, 해소 전까지 Phase 8로 넘어가지 마라. - - 실패 원인이 이번 변경과 무관하다는 판단이 서면 근거를 밝히고 호출자의 확답을 받는다. + 실패가 있으면 원인을 짚고 해소 전까지 넘어가지 마라. 이번 변경과 무관하다는 판단이 서면 근거를 밝히고 호출자의 확답을 받는다. -6. 무엇을 어떻게 바꿨는지 `.claude/resources/perf/{이슈번호}/{슬러그}/record.md`의 사이클 {n} **적용 내용**에 - 수정한 파일 경로 + 변경 요지를 작성한다. 4번의 확인 결과와 테스트 결과도 같은 항목에 적는다. +5. 변경된 코드로 재기동하게 하고 기동을 확인받는다. + **기존 인스턴스를 먼저 내려야 한다.** 내리지 않고 `bootRun`을 다시 돌리면 포트 점유로 새 프로세스만 죽고 옛 코드가 계속 응답한다. + 그 상태로 Phase 8을 재면 적용하지 않은 코드를 "효과 없음"으로 오판한다. -### 출력 -- 코드 변경 -- `.claude/resources/perf/{이슈번호}/{슬러그}/record.md`의 사이클 {n} **적용 내용**이 채워짐 -- `.claude/resources/perf/{이슈번호}/{슬러그}/record.md`의 진행 상태의 사이클 {n} Phase 7이 ✅으로 기록 + ```bash + # 애플리케이션 터미널 + pid=$(lsof -ti :8080); [ -n "$pid" ] && kill $pid + ./gradlew bootRun --args='--spring.profiles.active=perf' -### 실패 처리 -- 없음 + # 측정 터미널: 기동 확인 + curl -s localhost:8081/actuator/health + ``` -> 다음 Phase 조건: 설계대로 적용되었고 테스트가 통과했을 때 → Phase 8. -> 이번 변경과 무관한 실패를 호출자가 승인한 경우, 그 근거를 **적용 내용**에 적은 뒤 → Phase 8 +6. 수정한 파일과 변경 요지, 3의 확인 결과, 테스트 결과를 `record.md`의 사이클 {n} **적용 내용**에 적는다. + +### 출력 +- 코드 변경, 변경된 코드로 기동된 애플리케이션 +- `record.md`의 사이클 {n} **적용 내용**, 진행 상태 Phase 7 ✅ -> Skip 조건: 없음 (필수 Phase) +> 다음 Phase 조건: 설계대로 적용되었고 테스트가 통과했으며 재기동이 확인되었을 때 → Phase 8. +> 이번 변경과 무관한 실패를 호출자가 승인한 경우 그 근거를 **적용 내용**에 적은 뒤 → Phase 8 +> +> Skip 조건: 없음 diff --git a/.claude/skills/optimize-performance/phases/phase-8-verify.md b/.claude/skills/optimize-performance/phases/phase-8-verify.md index 6a92139..11b5a5a 100644 --- a/.claude/skills/optimize-performance/phases/phase-8-verify.md +++ b/.claude/skills/optimize-performance/phases/phase-8-verify.md @@ -4,133 +4,29 @@ 동일 조건으로 재측정해 개선 효과를 수치로 확정하고, 사이클을 계속할지 판정한다. ### 선행 조건 -- Phase 7 완료 -- 애플리케이션이 변경된 코드로 재기동 +- Phase 7 완료 (변경된 코드로 재기동 확인됨) ### 참조 파일 +- `.claude/skills/optimize-performance/template/commands.md` - `.claude/skills/optimize-performance/template/query-stats-template.md` ### 절차 -1. **Phase 4와 완전히 동일한 조건**으로 재측정하도록 아래를 제시한다. - 스크립트, VU, duration, 데이터 규모, 풀 크기를 하나도 바꾸지 마라. - - ```bash - # 새 터미널이면 먼저: - # export PERF_DIR=.claude/resources/perf/{이슈번호} - # export TARGET_DIR=$PERF_DIR/{슬러그} - # export MYSQL_PWD=root - # export MYSQL_PERF="mysql -h 127.0.0.1 -P 3307 -u root uss_db" - - # 1) 토큰 재발급 (digest 리셋 전에 끝낸다) - seq {STUDENT_ID_START} {STUDENT_ID_END} \ - | while read -r sid; do - curl -s -X POST localhost:8080/api/v1/auth/login \ - -H 'Content-Type: application/json' \ - -d "{\"studentId\":\"$sid\",\"password\":\"perfPassw0rd\"}" \ - | jq -c 'select(.accessToken != null) | {accessToken, refreshToken}' - done \ - | jq -s '.' > $PERF_DIR/tokens.json - - jq 'length' $PERF_DIR/tokens.json - - # 2) 워밍업. 이 실행의 결과는 쓰지 않는다. - k6 run -e PHASE=warmup $TARGET_DIR/test-script.js - - # 3) 되돌리기 - Phase 4와 같은 절차를 같은 자리에서 실행한다 - # record.md에 적어둔 SQL을 그대로 쓴다 - - # 4) 옵티마이저 통계 갱신 - Phase 4와 같아야 한다 - $MYSQL_PERF -e "ANALYZE TABLE members, courses, course_schedules, carts, registrations;" - - # 5) 데이터 규모가 Phase 4의 시작 상태와 같은지 확인 - $MYSQL_PERF -e " - SELECT 'registrations' AS t, count(*) AS n FROM registrations - UNION ALL SELECT 'carts', count(*) FROM carts - UNION ALL SELECT 'enrolled', COALESCE(sum(current_enrollment),0) FROM courses;" - - # 6) 쿼리 통계 리셋 - $MYSQL_PERF -e " - TRUNCATE TABLE performance_schema.events_statements_summary_by_digest;" - - # 7) 측정 부하 (Phase 3의 스크립트를 그대로 쓴다) - k6 run -e PHASE=measure \ - -e SUMMARY_OUT=$TARGET_DIR/k6-test-summary-{n}.json \ - $TARGET_DIR/test-script.js - - # 8) 쿼리 통계 수집 - # 수집 단계에서 반올림하지 않는다. 반올림은 대화에서 표로 제시할 때만 한다. - REQS=$(jq -r '.requests // empty' $TARGET_DIR/k6-test-summary-{n}.json) - - if ! [ "$REQS" -gt 0 ] 2>/dev/null; then - echo "요청 수가 '$REQS'다. 측정이 실패했으므로 통계를 수집하지 않는다. 원인을 확인하고 재측정하라." - else - $MYSQL_PERF -B -e " - SELECT COUNT_STAR AS calls, - COUNT_STAR / $REQS AS per_req, - AVG_TIMER_WAIT / 1e9 AS mean_ms, - SUM_TIMER_WAIT / 1e9 AS total_ms, - 100 * SUM_TIMER_WAIT / SUM(SUM_TIMER_WAIT) OVER () AS pct, - SUM_ROWS_SENT / NULLIF(COUNT_STAR, 0) AS rows_per_call, - SUM_ROWS_EXAMINED / NULLIF(SUM_ROWS_SENT, 0) AS examined_per_sent, - DIGEST_TEXT - FROM performance_schema.events_statements_summary_by_digest - WHERE SCHEMA_NAME = 'uss_db' - AND DIGEST_TEXT NOT LIKE '%performance_schema%' - ORDER BY SUM_TIMER_WAIT DESC LIMIT 20;" \ - | tee $TARGET_DIR/query-stats-summary-{n}.md - fi - - # 9) 개선 후 실행계획 (Phase 6과 같은 쿼리, 같은 파라미터 값) - $MYSQL_PERF -e "EXPLAIN ANALYZE {대상 쿼리}" > /dev/null - - { - echo "=== EXPLAIN ANALYZE ===" - $MYSQL_PERF -e "EXPLAIN ANALYZE {대상 쿼리}\G" - - echo "=== EXPLAIN FORMAT=JSON ===" - $MYSQL_PERF -e "EXPLAIN FORMAT=JSON {대상 쿼리}\G" - - echo "=== HANDLER COUNTERS ===" - $MYSQL_PERF -e " - FLUSH STATUS; - {대상 쿼리}; - SHOW SESSION STATUS WHERE Variable_name LIKE 'Handler_%' AND Value > 0;" \ - | grep -A100 'Handler_' - } | tee -a $TARGET_DIR/query-plan-{n}.txt - ``` - - - 이 블록도 대상 하나만 잰다. 다른 대상의 스크립트를 이어서 돌리게 하지 마라. (`SKILL.md`의 **대상 진행 규칙**) - - `{n}`에는 이번 사이클 적용 후의 상태 번호를 넣는다. 앞선 상태의 파일을 덮어쓰지 마라. - - EXPLAIN에는 Phase 6 **실행계획**에 적어둔 파라미터 값을 그대로 쓴다. 값을 바꾸면 계획이 비교 불가가 된다. - - `FLUSH STATUS`와 쿼리는 Phase 6과 마찬가지로 같은 세션에서 실행해야 한다. - - **되돌리기와 `ANALYZE TABLE`을 Phase 4와 같은 자리에서 같은 방식으로 실행한다.** 하나라도 어긋나면 - 전후 비교가 아니라 서로 다른 조건의 두 측정을 비교하게 된다. - 특히 되돌리기를 건너뛰면 `uk_member_course` 중복으로 요청이 전부 실패해 측정 자체가 무의미해진다. - - 쓰기 부하가 포함된 시나리오면 1차 측정이 데이터를 불려놓았을 수 있다. 5)의 결과로 확인한다. - - 조건이 달라졌으면 그 사실을 기록에 명시하고, 비교 가능한 범위를 좁혀서 해석한다. - -2. 실행이 끝나면 아래 파일을 Read로 읽는다. 터미널 출력을 붙여넣게 하지 마라. - - | 산출물 | 개선 후 (이번 사이클) | 개선 전 (비교 대상) | - |---|---|---| - | k6 요약 | `k6-test-summary-{n}.json` | `k6-test-summary-{n-1}.json` | - | 실행계획 | `query-plan-{n}.txt` | `query-plan-{n-1}.txt` | - - 쿼리 통계는 여기서 읽지 않는다. `{n}` 1차 출력은 절차 3에서 위임으로 가공하고, - 가공이 끝난 뒤 `{n}`과 `{n-1}` 가공본을 읽는다. - 최초 상태와의 누적 변화가 필요하면 `-0` 파일을 함께 읽는다. - - - `checks_rate`가 Phase 4보다 떨어졌으면 응답 내용이 달라진 것이다. 수치 비교보다 이 사실을 먼저 보고한다. - -3. 두 산출물을 가공본으로 다시 쓴다. - - - `query-stats-summary-{n}.md`: Agent 도구로 `query-source-mapper`에 위임한다. 1차 출력을 메인에서 Read하지 마라. - 프롬프트에 넘길 것 (전체 경로로): 1차 출력(`query-stats-summary-{n}.md`), 템플릿(`template/query-stats-template.md`), - `record.md`, 상태 번호 `n`, k6 요약(`k6-test-summary-{n}.json`), 직전 가공본(`query-stats-summary-{n-1}.md`). - 헤더의 **직전 상태 대비**는 에이전트가 `{n-1}` 가공본과 대조해 적는다. - 반환된 출처 미상 목록과 잘림 여부만 확인하고, 위임이 끝난 뒤 가공본을 Read해 절차 4의 제시에 쓴다. - - `k6-test-summary-{n}.json`: 최상위에 `delta_vs_prev` 객체를 덧붙인다. 다른 필드는 손대지 마라. +1. `commands.md`의 **A. 부하 측정**과 **B. 실행계획 캡처** 블록을 `{n}` = 이번 사이클 번호로 채워 순서대로 제시한다. + - **Phase 4와 완전히 같은 조건이다.** 스크립트, VU, duration, 데이터 규모, 풀 크기, 되돌리기, `ANALYZE` 자리를 하나도 바꾸지 마라. + 하나라도 어긋나면 전후 비교가 아니라 서로 다른 조건의 두 측정이 된다. + - EXPLAIN에는 Phase 6 **실행계획**에 적어둔 파라미터 값을 그대로 쓴다. + - 앞선 상태의 파일을 덮어쓰지 마라. + - 조건이 달라졌으면 기록에 명시하고 비교 가능한 범위를 좁혀 해석한다. + +2. 끝나면 `k6-test-summary-{n}.json`과 `query-plan-{n}.txt`를 개선 전 파일(`-{n-1}`)과 함께 Read한다. + 최초 상태와의 누적 변화가 필요하면 `-0`도 읽는다. 쿼리 통계는 3의 위임이 끝난 뒤 가공본을 읽는다. + - `checks_rate`가 떨어졌으면 응답 내용이 달라진 것이다. 수치 비교보다 이 사실을 먼저 보고한다. + +3. 가공본을 만든다. + - `query-stats-summary-{n}.md`: `query-source-mapper`에 위임한다. 프롬프트에 넘길 것(전체 경로): 1차 출력, 템플릿, `record.md`, + 상태 번호 `n`, `k6-test-summary-{n}.json`, 직전 가공본 `query-stats-summary-{n-1}.md`. 헤더의 **직전 상태 대비**는 에이전트가 적는다. + - `k6-test-summary-{n}.json`: 최상위에 `delta_vs_prev`를 덧붙인다. 다른 필드는 손대지 마라. 값은 각 파일의 것을 자릿수 그대로 옮긴다. ```json "delta_vs_prev": { @@ -141,47 +37,35 @@ } ``` - `before`와 `after`에는 각 파일에 적힌 값을 **그대로** 옮긴다. 자릿수를 줄이지 마라. - - `query-plan-{n}.txt`는 원본 그대로 둔다. - -4. 전후를 비교해 제시하고 **개선 여부 판정을 호출자에게 묻는다.** `SKILL.md`의 **분석 주도 규칙**을 따른다. - 제시할 때 **두 축을 모두 남긴다.** +4. 전후를 두 축으로 제시하고 **개선 여부 판정을 묻는다** (`SKILL.md`의 **역할 경계**). + - 하드웨어 의존 증거: p95, p99, RPS. 로컬 절대값은 믿지 말고 상대 변화만 쓴다. + - 하드웨어 독립 증거: 요청당 쿼리 수, `examined_per_sent`, Handler / Sort 카운터, 접근 방식과 사용 인덱스. + - 실행계획은 Phase 6과 같은 노드별 표로, 칼럼 설명을 함께 붙인다. + - 물을 것: "이 변화가 기법의 효과라고 보십니까, 측정 편차라고 보십니까?" + - 개선이 없거나 나빠졌으면 그대로 제시한다. 유리하게 해석하지 마라. - - **하드웨어 의존 증거**: p95, p99, RPS. 로컬 절대값은 신뢰하지 말고 상대 변화만 쓴다. - - **하드웨어 독립 증거**: 요청당 쿼리 수, `examined_per_sent`, Handler 카운터, 접근 방식과 사용 인덱스 변화. - - 실행계획을 노드별 표로 제시할 때는 **Phase 6의 칼럼 설명 표를 함께 붙인다.** 표만 던지지 마라. - - 물을 것: "이 변화가 기법의 효과라고 보십니까, 아니면 측정 편차라고 보십니까?" - - 개선이 없거나 오히려 나빠졌으면 그대로 제시한다. 수치를 유리하게 해석하지 마라. +5. 5-B의 **호출자가 예상한 효과**와 실측을 대조한다. 맞았으면 어떤 근거가 맞았는지, 어긋났으면 어느 가정이 틀렸는지 관측값으로 짚는다. + 실측 없이 "예상대로 개선되었다"고 쓰지 마라. -5. Phase 5-B에 적힌 **호출자가 예상한 효과**와 실측을 대조해 보고한다. - - 예상대로면 어떤 근거가 맞았는지 짚는다. - - 어긋났으면 어느 가정이 틀렸는지 관측값으로 짚는다. - - 실측 없이 "예상대로 개선되었다"고 쓰지 마라. - -6. 종료를 판정한다. **개선 여부는 하드웨어 독립 증거로 판정한다.** p95나 RPS의 변화만으로 개선을 주장하거나 종료를 판정하지 마라. +6. 종료를 판정한다. **개선 여부는 하드웨어 독립 증거로 판정한다.** p95나 RPS만으로 개선을 주장하거나 종료를 판정하지 마라. | 조건 | 판정 | |---|---| | 하드웨어 독립 증거에 변화가 없음 | 종료 | - | Phase 6 위험 신호 표의 항목이 모두 해소됨 | 종료 | + | Phase 6 위험 신호가 모두 해소됨 | 종료 | | 호출자가 종료를 선택 | 종료 | | 그 외 | 계속 | - **판정만 하고, 계속할지는 호출자에게 확인한다.** + 판정만 하고, 계속할지는 호출자에게 확인한다. ### 출력 -- `.claude/resources/perf/{이슈번호}/{슬러그}/k6-test-summary-{n}.json` 생성 (`delta_vs_prev` 포함) -- `.claude/resources/perf/{이슈번호}/{슬러그}/query-stats-summary-{n}.md` 생성 (가공본) -- `.claude/resources/perf/{이슈번호}/{슬러그}/query-plan-{n}.txt` 생성 (원본 유지) -- `record.md`의 사이클 {n} **개선 후 지표**와 **판정**이 채워짐 -- `record.md`의 진행 상태의 사이클 {n} Phase 8이 ✅으로 기록 +- `k6-test-summary-{n}.json` (`delta_vs_prev` 포함), `query-stats-summary-{n}.md` (가공본), `query-plan-{n}.txt` (원본) +- `record.md`의 사이클 {n} **개선 후 지표**와 **판정**, 진행 상태 Phase 8 ✅ ### 실패 처리 -- 재측정 조건이 1차와 달라졌는데 되돌릴 수 없으면, 비교 가능한 지표만 골라 해석하고 나머지는 "조건 변경으로 비교 불가"로 명시한다. -- 개선 후 에러율이 올랐으면 수치 비교보다 원인을 먼저 보고한다. - -> 다음 Phase 조건: 종료 판정이거나 호출자가 종료를 선택한 경우 → Phase 9 - -> 계속하는 경우 → Phase 5 (`record.md`의 진행 상태에 사이클 행을 추가하고 번호를 +1) +- 조건이 1차와 달라졌는데 되돌릴 수 없으면 비교 가능한 지표만 해석하고 나머지는 "조건 변경으로 비교 불가"로 명시한다. +- 에러율이 올랐으면 수치 비교보다 원인을 먼저 보고한다. -> Skip 조건: 없음 (필수 Phase) +> 다음 Phase 조건: 종료 판정이거나 호출자가 종료를 선택 → Phase 9. 계속 → Phase 5 (진행 상태에 사이클 행 추가, 번호 +1) +> +> Skip 조건: 없음 diff --git a/.claude/skills/optimize-performance/phases/phase-9-report.md b/.claude/skills/optimize-performance/phases/phase-9-report.md index 1c0fba7..db6f42d 100644 --- a/.claude/skills/optimize-performance/phases/phase-9-report.md +++ b/.claude/skills/optimize-performance/phases/phase-9-report.md @@ -4,57 +4,47 @@ 사이클 전체를 요약해 보고하고, `record.md`를 PR 본문의 근거로 쓸 수 있는 상태로 마무리한다. ### 선행 조건 -- Phase 8에서 종료가 판정되었거나, 호출자가 종료를 선택했다. +- Phase 8에서 종료가 판정되었거나 호출자가 종료를 선택했다. ### 참조 파일 - `.claude/skills/optimize-performance/template/output.md` ### 절차 -1. `.claude/resources/perf/{이슈번호}/{슬러그}/record.md`의 **최종 요약** 표를 채운다. - - 최초 baseline(`-0` 파일)과 최종 상태(마지막 사이클 번호의 파일)를 하드웨어 의존, 독립 두 축으로 나눠 적고, - 적용한 기법을 사이클 순서대로 나열한다. +1. `record.md`의 **최종 요약**을 채운다. 최초(`-0`)와 최종(마지막 상태 번호)을 하드웨어 의존, 독립 두 축으로 적고, 적용한 기법을 사이클 순서대로 나열한다. + 개선 없이 끝난 사이클도 뺀다. -2. 진행 상태 표를 최종 상태로 갱신한다. +2. 진행 상태를 최종 상태로 갱신한다. -3. 산출물을 정리하도록 호출자에게 제시한다. +3. Phase 7에서 추가한 마이그레이션이 운영 DB에 어떤 영향을 주는지 한 줄로 적는다. + 로컬에서 즉시 끝난 인덱스 추가가 운영 규모에서는 다를 수 있다. 운영 행 수를 모르면 모른다고 적는다. 추정치를 지어내지 마라. + +4. 산출물 정리를 제시한다. ```bash - # 토큰은 실 JWT다. 최적화가 끝나면 남기지 않는다. + # 토큰은 실 JWT다. 남은 대상이 없을 때만 지운다 rm -f $PERF_DIR/tokens.json - - # 시드로 만든 측정용 회원을 남길지 호출자에게 확인한다. - # 남기면 다음 이슈에서 재사용할 수 있고, 지우면 로컬 DB가 원래 규모로 돌아간다. ``` - - 같은 이슈에 아직 측정하지 않은 대상이 남아 있으면 토큰을 지우지 말고, 그 사실을 알린 뒤 다음 대상으로 넘어간다. - - `tokens.json`은 gitignore 대상이라 커밋되지 않는다. 그래도 로컬에 남기지 않는다. + - 같은 이슈에 측정하지 않은 대상이 남아 있으면 토큰을 지우지 말고 그 사실을 알린다. + - 시드 데이터를 남길지 호출자에게 확인한다. 지우는 방법은 `seeds/README.md`의 **시드를 지울 때**. - `seeds.sql`, `record.md`, 측정 산출물은 근거이므로 남긴다. 커밋 여부는 호출자에게 확인한다. -4. **Phase 7에서 추가한 마이그레이션이 운영 DB에 어떤 영향을 주는지 한 줄로 보고한다.** - 로컬에서는 즉시 끝난 인덱스 추가가 운영 규모에서는 다를 수 있다. - 대상 테이블의 운영 행 수를 모르면 모른다고 적는다. 추정치를 지어내지 마라. - -5. 보고 템플릿을 Read로 읽고, 상단 작성 가이드에 따라 항목을 채워 보고한다. - - 가이드 주석은 출력에 포함하지 않는다. - - 1번에서 채운 **최종 요약**의 값을 그대로 옮긴다. 마지막 사이클만 보고하지 마라. +5. `output.md`를 Read하고 상단 가이드에 따라 보고한다. 수치는 **최종 요약**의 값만 옮긴다. 받지 못한 값을 추정해 채우지 마라. -6. 수치는 `record.md`에 저장된 값만 쓴다. 받지 못한 값을 추정해서 채우지 마라. - -7. 이 대상의 보고가 끝난 뒤에 남은 대상을 확인한다. **보고를 몰아서 하지 마라.** - - Phase 1에서 알린 대기 목록이 있으면 다음 대상을 진행할지 호출자에게 묻는다. - - 진행한다면 그 대상의 Phase 1부터 새로 시작한다. 앞 대상의 측정값을 물려쓰지 마라. - `SKILL.md`의 **대상 진행 규칙**에 따라 이슈 공용 산출물(`seeds.sql`, `tokens.json`)만 공유한다. +6. 남은 대상을 확인한다. 보고를 몰아서 하지 마라. + Phase 1에서 알린 대기 목록이 있으면 다음 대상을 진행할지 묻고, 진행하면 그 대상의 Phase 1부터 새로 시작한다. + 앞 대상의 측정값을 물려쓰지 않는다. 이슈 공용 산출물만 공유한다. ### 출력 -- `.claude/resources/perf/{이슈번호}/{슬러그}/record.md`의 **최종 요약** 완성 -- `.claude/resources/perf/{이슈번호}/tokens.json` 삭제 (남은 대상이 없는 경우) +- `record.md`의 **최종 요약** 완성 +- `tokens.json` 삭제 (남은 대상이 없는 경우) - 호출자에게 보고 ### 실패 처리 - 개선 없이 종료된 경우에도 보고한다. 무엇을 시도했고 왜 효과가 없었는지 적는다. -> 다음 Phase 조건: 같은 이슈에 측정할 대상이 남아 있으면 → Phase 1 (새 슬러그 디렉토리). -> 남은 대상이 없으면 스킬 종료. 커밋은 `commit-push`, PR은 `open-pr`로 이어간다. 이 스킬은 커밋하지 않는다. - -> Skip 조건: 없음 (필수 Phase) +> 다음 Phase 조건: 같은 이슈에 대상이 남아 있으면 → Phase 1 (새 슬러그 디렉토리). +> 없으면 스킬 종료. 커밋은 `commit-push`, PR은 `open-pr`로 이어간다. 이 스킬은 커밋하지 않는다. +> +> Skip 조건: 없음 diff --git a/.claude/skills/optimize-performance/template/PERF-template.md b/.claude/skills/optimize-performance/template/PERF-template.md index 7d730cd..16c63a4 100644 --- a/.claude/skills/optimize-performance/template/PERF-template.md +++ b/.claude/skills/optimize-performance/template/PERF-template.md @@ -8,8 +8,7 @@ ## 진행 상태 -> ⏳ 미완 / ✅ 완료 / ⏭️ 건너뜀 -> 재진입 시 ⏳로 표기된 가장 이른 Phase부터 재개한다. +> ⏳ 미완 / ✅ 완료 / ⏭️ 건너뜀. 재진입 시 ⏳로 표기된 가장 이른 Phase부터 재개한다. **준비 (대상당 1회)** @@ -23,10 +22,13 @@ |---|---|---|---|---|---| | 1 | | ⏳ | ⏳ | ⏳ | ⏳ | +**재개 메모**: {없음 / 재개할 때 먼저 알아야 할 것 - 환경 변화, 철거된 시드, 미결 사항} + ## 대상 - 엔드포인트: `{HTTP} {경로}` - 실행 경로: `{Controller}` → `{Service}` → `{Repository}` +- 인증: {`@Auth` 있음 → 회원 시드 필요 / 없음 → 토큰만 / 화이트리스트 → 불필요} - 예상 쿼리 목록 (요청 1회 기준) 1. `{Repository.메서드}` - {쿼리 요지} 2. {지연 로딩 참조 지점이 있으면 추가 쿼리 후보로 표시} @@ -38,17 +40,17 @@ | 항목 | 값 | |---|---| | 프로파일 | perf (`application-perf.yml`) | -| DB | MySQL 8.0 / InnoDB (`127.0.0.1:3307`, `uss_db`) | +| DB | MySQL {버전} / InnoDB (`uss-mysql`, `127.0.0.1:3307`, `uss_db`) | | 커넥션 풀 크기 | {maximum-pool-size} | -| InnoDB 버퍼 풀 크기 | {@@innodb_buffer_pool_size} | -| 데이터 규모 | {대상 테이블별 실제 행 수 (`count(*)` 기준)} | +| InnoDB 버퍼 풀 크기 | {MiB} | +| 데이터 규모 | {테이블별 실제 행 수 (`count(*)`)} | +| 규모 근거 | 운영(앱 시드) 대비 {배수}. {왜 이 배수인지, 무엇을 관측하려는지} | | 카디널리티 | {컬럼별 서로 다른 값의 개수와 분포 쏠림} | -| 부하 조건 | VU {n}, 유지 {t} (ramp-up {t1} + 유지 {t} + ramp-down {t2} = 총 {T}) | -| 캐시 상태 | warm 고정. InnoDB 버퍼 풀은 재기동 없이 비울 수 없고 애플리케이션 캐시는 쓰지 않는다. 매 측정 전 동일한 워밍업으로 상태를 맞춘다 | +| 부하 조건 | VU {n}, 유지 {t} (ramp-up {t1} + 유지 {t} + ramp-down {t2} = 총 {T}), USER_COUNT {n} | +| 캐시 상태 | warm 고정. InnoDB 버퍼 풀은 재기동 없이 비울 수 없고 애플리케이션 캐시는 없다. 매 측정 전 같은 워밍업으로 맞춘다 | | 되돌리기 절차 | {쓰기 엔드포인트면 Phase 3-B에서 확정한 SQL / 읽기면 `불필요`} | -| 시드 SQL | `../seeds.sql` (이슈 공용) / 미사용 | -| 시드 모듈과 변수 | {`SOURCE`로 부른 모듈과 확정한 변수값} | -| 측정용 자격증명 | 학번 {STUDENT_ID_START}~{STUDENT_ID_END}, 비밀번호 `perfPassw0rd` | +| 시드 | `../seeds.sql` + {이어 붙인 모듈} / 미사용. 변수: {확정한 변수값} | +| 토큰 | `../tokens.json` (`mint-tokens.sh`, 회원 id {시작}~{끝}) / 불필요 | ## 기준선 (Baseline) @@ -63,8 +65,7 @@ ### 쿼리 통계 (total_ms 상위) -> 전체: `query-stats-summary-0.md` / k6 요약: `k6-test-summary-0.json` -> 여기에는 진단 근거로 쓴 행만 옮긴다. 전체를 복사하지 않는다. +> 전체: `query-stats-summary-0.md` / k6 요약: `k6-test-summary-0.json`. 진단 근거로 쓴 행만 옮긴다. | 요청당 | mean_ms | total_ms | 비중 | 읽은행/반환행 | 출처 | |---|---|---|---|---|---| @@ -73,7 +74,7 @@ - 병목 성격: {Phase 4에서 확정한 판정} - 근거: {관측된 수치} -- 예상 쿼리 목록과 어긋난 지점: {있으면 무엇이 어떻게 어긋났는지, 없으면 `없음`} +- 예상 쿼리 목록과 어긋난 지점: {무엇이 어떻게 / `없음`} --- @@ -87,7 +88,7 @@ |---|---|---| - 검토했지만 택하지 않은 안: {안} - {배제 근거} -- 호출자가 예상한 효과: {지표와 방향, 예: p95 30% 감소 / 풀스캔 → 인덱스 스캔} +- 호출자가 예상한 효과: {지표와 방향, 예: 풀스캔 → 인덱스 스캔, 요청당 쿼리 3 → 1} ### 개선 전 지표 @@ -103,21 +104,19 @@ > 원본: `query-plan-{n-1}.txt` (개선 후는 `query-plan-{n}.txt`) -- EXPLAIN 파라미터: {? = 값, ? = 값} (이후 모든 사이클에서 동일하게 사용) -- 값 선정 근거: {흔한 값 / 드문 값 중 무엇을 골랐고 왜인지} -- 접근 방식: {풀스캔 / ref / range / ...}, 사용 인덱스: {인덱스명 또는 `없음`} +- EXPLAIN 파라미터: {? = 값, ? = 값} (이후 모든 사이클에서 동일) +- 값 선정 근거: {흔한 값 / 드문 값 중 무엇을 골랐고 왜} +- 접근 방식: {풀스캔 / ref / range / ...}, 사용 인덱스: {인덱스명 / `없음`} - 실측 rows 대 반환 행 수: {n} / {m} - 옵티마이저 추정 대 실측: {rows=n} / {actual rows=m} -- Handler 카운터: `read_rnd_next` {n} / `read_key` {n} / `read_next` {n} / `Sort_rows` {n} +- 카운터: `Handler_read_rnd_next` {n} / `Handler_read_key` {n} / `Handler_read_next` {n} / `Sort_rows` {n} {기법별 추가 캡처 - 캐싱이면 반복 호출 비율과 무효화 경로, 로직이면 호출 스택, 풀이면 획득 대기 시간, 락이면 대기 현황} -> 캐싱 사이클이 아니면 개선 후 지표의 캐시 행은 `-`로 둔다. - ### 적용 내용 - {수정한 파일과 변경 요지} -- 인덱스 적용 확인: {`EXPLAIN`의 `key`에 새 인덱스가 잡혔는지 / 해당 없음} +- 적용 확인: {`EXPLAIN`의 `key`에 새 인덱스가 잡혔는지 / 해당 없음} - 테스트: {`./gradlew test` 결과} ### 개선 후 지표 @@ -133,7 +132,7 @@ | | 접근 방식과 인덱스 | | | | | | `Handler_read_rnd_next` | | | | | | `Sort_rows` | | | | -| | 캐시 hit / miss, 적중률 | | | | +| | 캐시 hit / miss, 적중률 (캐싱 사이클만, 아니면 `-`) | | | | ### 판정 @@ -145,8 +144,6 @@ ## 최종 요약 -> 하드웨어 의존 증거와 독립 증거를 모두 남긴다. - | 구분 | 지표 | 최초 | 최종 | 변화 | |---|---|---|---|---| | 하드웨어 의존 | p95 | | | | @@ -160,4 +157,4 @@ 적용한 기법: {사이클 순서대로} -운영 반영 시 유의점: {Phase 9에서 확인한 마이그레이션 영향 / 확인 못 했으면 `미확인`} +운영 반영 시 유의점: {Phase 9에서 확인한 마이그레이션 영향 / `스키마 변경 없음` / `미확인`} diff --git a/.claude/skills/optimize-performance/template/application-perf.yml b/.claude/skills/optimize-performance/template/application-perf.yml index dceef5a..45b3e52 100644 --- a/.claude/skills/optimize-performance/template/application-perf.yml +++ b/.claude/skills/optimize-performance/template/application-perf.yml @@ -1,8 +1,6 @@ -# 성능 측정 전용 프로파일. -# Phase 2에서 src/main/resources/application-perf.yml 이 없으면 이 내용으로 생성한다. +# 성능 측정 전용 프로파일. Phase 2에서 src/main/resources/application-perf.yml 이 없으면 이 내용으로 생성한다. # -# 이 파일의 목적은 하나다. **측정값을 왜곡하는 요소를 전부 끄는 것.** -# 값을 바꿀 때는 아래 주석의 이유를 먼저 읽어라. 이유 없이 prod 설정을 베껴오지 마라. +# 목적은 하나다. 측정값을 왜곡하는 요소를 전부 끄는 것. 값을 바꿀 때는 주석의 이유를 먼저 읽어라. # # 기동: ./gradlew bootRun --args='--spring.profiles.active=perf' # 선행: docker-compose -f docker/docker-compose-local.yml up -d mysql @@ -12,18 +10,16 @@ spring: name: uss-server-perf datasource: - # docker-compose-local.yml의 mysql 서비스. 호스트 포트가 3307이다(3306 아님). + # docker-compose-local.yml의 mysql. 호스트 포트는 3307이다. url: jdbc:mysql://127.0.0.1:3307/uss_db?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul&rewriteBatchedStatements=true username: root password: root - # p6spy 드라이버(com.p6spy.engine.spy.P6SpyDriver)를 쓰지 마라. - # 쿼리마다 프록시를 한 겹 태우면 그 오버헤드가 측정값에 그대로 들어간다. + # p6spy 드라이버를 쓰지 마라. 쿼리마다 프록시 한 겹의 오버헤드가 측정값에 들어간다. driver-class-name: com.mysql.cj.jdbc.Driver hikari: - # Phase 3-B에서 VU를 정할 때 이 값을 함께 본다. - # 풀보다 훨씬 큰 VU로 재면 쿼리가 아니라 커넥션 대기를 재게 된다. - # 값을 바꾸면 record.md의 측정 환경에 변경 시점과 함께 남긴다. + # Phase 3-B에서 VU를 정할 때 이 값을 함께 본다. 풀보다 훨씬 큰 VU는 커넥션 대기를 잰다. + # 바꾸면 record.md의 측정 환경에 시점과 함께 남긴다. maximum-pool-size: 10 minimum-idle: 10 connection-timeout: 10000 @@ -35,13 +31,13 @@ spring: ddl-auto: none properties: hibernate: - # SQL 로깅은 요청당 수십 줄을 찍는다. 측정값을 통째로 바꾸므로 반드시 꺼둔다. + # SQL 로깅은 요청당 수십 줄을 찍는다. 측정값을 통째로 바꾼다. format_sql: false show_sql: false # 통계 수집도 오버헤드다. 쿼리 관측은 performance_schema로 한다. generate_statistics: false show-sql: false - # 뷰 렌더링이 없으므로 열어둘 이유가 없다. 열려 있으면 커넥션 점유 구간이 실제보다 길어진다. + # 뷰 렌더링이 없다. 열려 있으면 커넥션 점유 구간이 실제보다 길어진다. open-in-view: false flyway: @@ -51,31 +47,24 @@ spring: - classpath:database/migration - classpath:database/seed - mail: - # 측정 중 실제 메일이 나가지 않게 한다. 이메일 인증 경로를 재는 게 아니라면 건드릴 일이 없다. - host: localhost - port: 1025 - username: perf - password: perf - properties: - mail: - smtp: - auth: false - starttls: - enable: false +inu: + course-api: + # InuCourseApiProperties가 바인딩한다. 연계 API는 IP 화이트리스트라 로컬에서 호출되지 않는다. 기동에 필요한 자리만 채운다. + base-url: http://localhost/unused-in-perf-profile + auth-key: unused + mod-date: "20260101" security: jwt: - # 로컬 측정 전용 키다. 이 값을 다른 환경에 쓰지 마라. - # 32바이트 이상이어야 HS256 서명이 된다. + # 로컬 측정 전용 키. perf-env.sh가 이 값을 읽어 토큰을 만든다. 바꾸면 토큰도 다시 만든다. 32바이트 이상. secret-key: perf-only-local-secret-key-not-for-any-real-environment - # 측정 중 토큰이 만료되면 401이 섞여 에러율이 오염된다. 넉넉히 잡는다. + # JwtProvider 생성자가 요구하는 두 키. 측정 중 만료되면 401이 섞이므로 넉넉히 잡는다. access-token-expiration-time: 86400000 - refresh-token-expiration-time: 604800000 + admin-access-token-expiration-time: 86400000 management: - # Phase 2의 actuator 확인 명령이 8081을 본다. 포트를 바꾸면 그 명령도 함께 고쳐야 한다. server: + # Phase 2의 actuator 확인 명령이 8081을 본다. port: 8081 endpoints: web: @@ -85,7 +74,7 @@ management: tags: application: uss-server-perf distribution: - # 이 두 설정이 없으면 Phase 2의 3)번 확인(히스토그램 버킷 개수)이 0으로 나온다. + # 없으면 Phase 2의 히스토그램 버킷 확인이 0으로 나온다. percentiles-histogram: http.server.requests: true slo: @@ -97,5 +86,4 @@ logging: uss.code: WARN org.hibernate.SQL: OFF org.hibernate.orm.jdbc.bind: OFF - # p6spy는 위 driver-class-name 설정상 끼어들지 않지만, 설정이 바뀌어도 로그가 새지 않게 막아둔다. - p6spy: OFF \ No newline at end of file + p6spy: OFF diff --git a/.claude/skills/optimize-performance/template/commands.md b/.claude/skills/optimize-performance/template/commands.md new file mode 100644 index 0000000..f01ccd2 --- /dev/null +++ b/.claude/skills/optimize-performance/template/commands.md @@ -0,0 +1,102 @@ +# 측정 명령 블록 + +Phase 4, 6, 8이 호출자에게 제시하는 명령이다. `{n}`과 `{…}` 자리를 채워 **블록 그대로** 제시한다. +전제: `perf-env.sh`를 source한 터미널 (`$PERF_DIR`, `$TARGET_DIR`, `$PERF_JWT_SECRET`, `mysqlp`). + +## A. 부하 측정 + +Phase 4는 `{n}` = 0, Phase 8은 `{n}` = 이번 사이클 번호. + +```bash +# 1) 토큰. 이슈 공용이라 이미 있고 만료 전이면 건너뛴다. 로그인 API를 태우지 않는다 +bash .claude/skills/_shared/mint-tokens.sh \ + --secret "$PERF_JWT_SECRET" --start {회원 id 시작값} --count {USER_COUNT} \ + --out $PERF_DIR/tokens.json +python3 -c "import json;print(len(json.load(open('$PERF_DIR/tokens.json'))))" # USER_COUNT와 같아야 한다 + +# 2) 워밍업 (JIT, 커넥션 풀, InnoDB 버퍼 풀). 이 실행의 결과는 쓰지 않는다 +k6 run -e PHASE=warmup $TARGET_DIR/test-script.js + +# 3) 되돌리기. 쓰기 엔드포인트면 record.md의 되돌리기 SQL을 여기서 실행한다. 읽기면 없음 + +# 4) 옵티마이저 통계 갱신, 시작 상태 확인 +mysqlp -e " +ANALYZE TABLE members, courses, course_schedules, carts, registrations; +SELECT 'registrations' AS t, count(*) AS n FROM registrations +UNION ALL SELECT 'carts', count(*) FROM carts +UNION ALL SELECT 'enrolled', COALESCE(sum(current_enrollment), 0) FROM courses;" + +# 5) 쿼리 통계 리셋 +mysqlp -e "TRUNCATE TABLE performance_schema.events_statements_summary_by_digest;" + +# 6) 측정 +k6 run -e PHASE=measure -e SUMMARY_OUT=$TARGET_DIR/k6-test-summary-{n}.json $TARGET_DIR/test-script.js + +# 7) 쿼리 통계 수집. 요청 수를 분모로 넘겨 요청당 호출 수까지 뽑는다 +REQS=$(jq -r '.requests // empty' $TARGET_DIR/k6-test-summary-{n}.json) +if ! [ "$REQS" -gt 0 ] 2>/dev/null; then + echo "요청 수가 '$REQS'다. 측정이 실패했으므로 통계를 수집하지 않는다. 원인을 확인하고 재측정하라." +else +mysqlp -B -e " +SELECT COUNT_STAR AS calls, + COUNT_STAR / $REQS AS per_req, + AVG_TIMER_WAIT / 1e9 AS mean_ms, + SUM_TIMER_WAIT / 1e9 AS total_ms, + 100 * SUM_TIMER_WAIT / SUM(SUM_TIMER_WAIT) OVER () AS pct, + SUM_ROWS_SENT / NULLIF(COUNT_STAR, 0) AS rows_per_call, + SUM_ROWS_EXAMINED / NULLIF(SUM_ROWS_SENT, 0) AS examined_per_sent, + DIGEST_TEXT +FROM performance_schema.events_statements_summary_by_digest +WHERE SCHEMA_NAME = 'uss_db' + AND DIGEST_TEXT NOT LIKE '%performance_schema%' + AND DIGEST_TEXT NOT LIKE 'SET NAMES%' +ORDER BY SUM_TIMER_WAIT DESC LIMIT 20;" \ +| tee $TARGET_DIR/query-stats-summary-{n}.md +fi +``` + +블록의 순서와 각 요소는 아래 이유로 고정이다. 바꾸거나 빼지 마라. + +| 요소 | 이유 | +|---|---| +| 워밍업 → 되돌리기 → `ANALYZE` 순서 | 되돌리기 `DELETE` 뒤에 통계를 갱신해야 옵티마이저가 같은 계획을 고른다. Phase 4와 8의 계획이 달라지면 전후 비교가 아니다 | +| 리셋 뒤에 곧바로 측정 | digest는 인스턴스 전역이다. 사이에 다른 부하가 끼면 통계가 섞이고 `per_req`가 틀린다 | +| `-B` | 기본 박스 출력은 `DIGEST_TEXT`를 잘라 출처를 매핑할 수 없게 한다 | +| `/1e9` | `TIMER_WAIT`는 피코초다 | +| 반올림 없음 | `per_req`를 둘째 자리에서 자르면 0.005 미만 쿼리가 `0.00`으로 사라진다. 반올림은 대화의 표에서만 한다 | +| `REQS` 가드 | 요청 0건이면 `per_req` 분모가 0이다 | +| `examined_per_sent` | 읽은 행 대 돌려준 행. 하드웨어 독립 지표이고 인덱스 필요성을 가장 직접 보여준다 | +| `SET NAMES` 제외 | `mysqlp`의 init-command가 남기는 행이다. 측정 대상이 아니다 | + +## B. 실행계획 캡처 + +Phase 6은 `{n}` = 사이클 번호 - 1, Phase 8은 `{n}` = 사이클 번호. +`{대상 쿼리}`는 digest의 `?`에 record.md **실행계획**에 적어둔 파라미터 값을 대입한 원문이다. + +```bash +# 버퍼 풀을 채우는 1회. 이 출력은 쓰지 않는다 +mysqlp -e "EXPLAIN ANALYZE {대상 쿼리}" > /dev/null + +{ + echo "=== EXPLAIN ANALYZE ===" + mysqlp -e "EXPLAIN ANALYZE {대상 쿼리}\G" + + echo "=== EXPLAIN FORMAT=JSON ===" + mysqlp -e "EXPLAIN FORMAT=JSON {대상 쿼리}\G" + + echo "=== STATUS COUNTERS ===" + mysqlp -e " + FLUSH STATUS; + {대상 쿼리}; + SHOW SESSION STATUS + WHERE (Variable_name LIKE 'Handler_%' OR Variable_name LIKE 'Sort_%') AND Value > 0;" \ + | grep -E '^(Handler_|Sort_)' +} | tee -a $TARGET_DIR/query-plan-{n}.txt +``` + +| 요소 | 이유 | +|---|---| +| `FLUSH STATUS`와 쿼리를 한 `-e` 안에 | 세션 카운터다. 명령을 나누면 세션이 갈려 0이 나온다 | +| `Sort_%` 포함 | `Sort_rows`, `Sort_scan`이 filesort 판정 근거다. `Handler_%`만 뽑으면 기록 항목이 빈다 | +| `tee -a` | 같은 상태의 다른 쿼리 계획을 덮어쓰지 않는다 | +| `EXPLAIN ANALYZE`는 SELECT만 | MySQL 8.0은 쓰기 문을 받지 않는다. 쓰기 쿼리면 `EXPLAIN FORMAT=JSON`만 뜨고, 작업량은 `BEGIN; {쿼리}; ROLLBACK;` 안에서 카운터를 잰다. AUTO_INCREMENT 증가는 롤백되지 않는다 | diff --git a/.claude/skills/optimize-performance/template/k6-script-template.js b/.claude/skills/optimize-performance/template/k6-script-template.js index 4a5de04..5adeb7a 100644 --- a/.claude/skills/optimize-performance/template/k6-script-template.js +++ b/.claude/skills/optimize-performance/template/k6-script-template.js @@ -1,60 +1,33 @@ // 부하 테스트 스크립트 템플릿. // {…} 자리를 채워 .claude/resources/perf/{이슈번호}/{슬러그}/test-script.js 로 저장한다. -// -// 실행: 토큰 발급, warmup, measure를 각각 따로 돌린다. 통계 리셋은 토큰 발급이 끝난 뒤에 한다. -// -// (토큰 발급 - Phase 4, 8의 명령 블록 참조. $PERF_DIR/tokens.json 생성) -// k6 run -e PHASE=warmup $TARGET_DIR/test-script.js -// $MYSQL_PERF -e "TRUNCATE TABLE performance_schema.events_statements_summary_by_digest;" -// k6 run -e PHASE=measure -e SUMMARY_OUT=$TARGET_DIR/k6-test-summary-{n}.json \ -// $TARGET_DIR/test-script.js -// -// {n}은 상태 번호다. 0 = 아무것도 적용하지 않은 원본(Phase 4), n = 사이클 n 적용 후(Phase 8). -// 실행은 호출자가 한다. 스킬은 명령어만 제시한다. +// 실행 명령은 template/commands.md에 있다. 실행은 호출자가 한다. // // ── 작성 규칙 ────────────────────────────────────────────── -// 1. 이 파일을 복사해 고친다. 빈 파일에서 새로 쓰지 않는다. -// 2. 고치는 자리는 상단 상수 블록(TARGET, ENDPOINT, CONDITION, STUDENT_ID_START, USER_COUNT), -// default 함수의 요청 한 줄, check의 세 번째 항목, VU와 duration뿐이다. -// 그 외 구조는 아래 3~12 규칙 안에서만 손댄다. -// 3. PHASE 분기를 유지한다. 두 시나리오를 한 프로세스에서 같이 돌리지 않는다. -// 4. 토큰은 init 컨텍스트에서 이슈 디렉토리의 tokens.json으로 읽는다. setup()에서 로그인을 호출하지 마라. -// 측정 프로세스가 로그인 요청을 보내면 그 SQL(회원 조회 + BCrypt 검증)과 응답시간이 측정값에 섞인다. -// tokens.json은 이슈 전체가 공유한다. `../tokens.json` 경로를 대상 디렉토리 안쪽으로 바꾸지 마라. -// 5. **이 서버는 access-token과 refresh-token 헤더를 둘 다 요구한다.** 하나만 보내면 401이 떨어진다. -// Authorization: Bearer 형식이 아니다. 헤더 이름을 바꾸지 마라. -// (근거: JwtAuthenticationFilter가 두 헤더를 각각 읽어 JwtProvider.validateTokens로 넘긴다) -// 6. tokens.json은 `[{accessToken, refreshToken}, ...]` 형태다. 문자열 배열이 아니다. -// 7. 토큰 수가 USER_COUNT와 다르면 중단한다. 일부만 발급된 채로 측정하지 마라. -// 8. 토큰은 `exec.scenario.iterationInTest`로 고른다. 이 값은 시나리오 전체에서 반복마다 1씩 늘어나므로 -// VU 수와 무관하게 USER_COUNT 전체를 균등하게 돈다. -// `__VU`로 고르지 마라. VU가 50개면 토큰도 50개만 쓰여 요약의 user_count와 실제 사용자 수가 어긋난다. -// 9. 응답시간 임계를 thresholds에 넣지 않는다. 판정은 스킬이 전후 비교로 한다. -// summaryTrendStats는 지우지 않는다. 지우면 p(99)가 요약에서 사라진다(k6 기본값에 없다). -// 10. check에는 실제 데이터가 실렸는지 확인하는 항목을 반드시 하나 넣는다. -// 리스트 응답이면 `body.{필드}.length > 0`, 객체 응답이면 `body.{필드} !== undefined`. -// **`r.json()`은 반드시 try/catch로 감싼다.** 비JSON 응답(502 HTML, 빈 본문)에서 예외가 나면 -// 무엇이 깨졌는지 알 수 없게 된다. 파싱 실패는 명시적인 check 실패로 떨어뜨린다. -// 쓰기 엔드포인트는 본문이 없다(204/201 + 빈 body). 그 경우 status만 검증하고 -// `body is not empty` check는 지운다. 지웠다는 사실을 record.md에 남긴다. -// 11. VU와 duration은 Phase 3에서 호출자와 확정한 값으로, STUDENT_ID_START와 USER_COUNT는 -// seeds.sql로 실제 만든 학번 범위와 일치시킨다. 기본값을 그대로 두지 않는다. -// 12. TARGET에는 대상 디렉토리 슬러그를, ENDPOINT에는 경로를, CONDITION에는 Phase 3-B에서 -// 확정한 부하 조건을 적는다. 요약 파일만 보고 어떤 측정인지 알 수 있어야 한다. -// duration은 유지 구간만 적지 말고 ramp 구간과 총 실행시간을 분리해 적는다. -// **캐시 상태는 항상 warm이다.** InnoDB 버퍼 풀은 재기동 없이 비울 수 없고 애플리케이션 캐시는 없다. -// cold라고 적지 마라. +// 1. 이 파일을 복사해 고친다. 고치는 자리는 상수 블록(TARGET, ENDPOINT, CONDITION, USER_COUNT 기본값), +// measure 시나리오의 VU와 duration, default 함수의 요청 한 줄, check의 세 번째 항목뿐이다. +// 2. PHASE 분기를 유지한다. warmup과 measure를 한 프로세스에서 같이 돌리지 않는다. +// 3. 토큰은 init 컨텍스트에서 이슈 디렉토리의 tokens.json을 읽는다. 형태는 [{memberId, accessToken}] (mint-tokens.sh 출력). +// setup()에서 로그인을 호출하지 마라. 로그인 SQL과 응답시간이 측정에 섞인다. '../tokens.json' 경로를 바꾸지 마라. +// 4. 인증 헤더는 access-token 하나다. JwtAuthenticationFilter가 읽는 유일한 헤더이며 Bearer 형식이 아니다. +// 5. 토큰 수가 USER_COUNT와 다르면 중단한다. 일부만 발급된 채로 측정하지 않는다. +// 6. 토큰은 exec.scenario.iterationInTest로 고른다. __VU로 고르면 VU 수만큼만 쓰여 user_count와 실제가 어긋난다. +// 7. thresholds에 응답시간 임계를 넣지 않는다. 판정은 스킬이 전후 비교로 한다. +// summaryTrendStats는 지우지 않는다. 지우면 p(99)가 요약에서 사라진다. +// 8. check에 실제 데이터가 실렸는지 확인하는 항목을 하나 넣는다. r.json()은 parseBody로 감싼다. +// 비JSON 응답(502 HTML, 빈 본문)의 예외를 check 실패로 떨어뜨리기 위해서다. +// 본문 없는 쓰기 응답(201/204)이면 status만 검증하고 나머지 check를 지운 사실을 record.md에 남긴다. +// 9. 경로 변수와 쿼리 값은 iterationInTest나 __ITER로 흩는다. 고정값은 한 행만 반복 조회해 버퍼 풀에 완전히 올라간 상태를 잰다. +// 10. CONDITION에는 Phase 3-B에서 확정한 값을 적는다. 요약 파일만 보고 어떤 측정인지 알 수 있어야 한다. +// duration은 ramp 구간과 유지 구간을 분리한다. 캐시는 항상 warm이다. // -// measure 실행의 요청은 전부 대상 API다. 요청당 쿼리 수의 분모는 요약의 `requests`를 그대로 쓴다. +// measure 실행의 요청은 전부 대상 API다. 요청당 쿼리 수의 분모는 요약의 requests를 그대로 쓴다. // -// 대상 엔드포인트 형태별 요청 한 줄: +// 요청 한 줄의 형태: // GET http.get(`${BASE_URL}/api/v1/courses/major`, params) -// GET + 쿼리 http.get(`${BASE_URL}/api/v1/courses/general-education?courseArea=${AREAS[__ITER % AREAS.length]}`, params) -// POST 경로변수만 http.post(`${BASE_URL}/api/v1/carts/${1 + (exec.scenario.iterationInTest % COURSE_COUNT)}`, null, params) +// GET + 쿼리 http.get(`${BASE_URL}/api/v1/courses/search?keyword=${KEYWORDS[__ITER % KEYWORDS.length]}`, params) +// POST 경로변수 http.post(`${BASE_URL}/api/v1/carts/${1 + (exec.scenario.iterationInTest % COURSE_COUNT)}`, null, params) // POST + 바디 http.post(`${BASE_URL}/api/v1/...`, JSON.stringify({…}), // { headers: { ...params.headers, 'Content-Type': 'application/json' } }) -// 경로 변수나 쿼리에 들어갈 식별자는 `exec.scenario.iterationInTest`나 `__ITER`로 흩는다. -// 고정값을 박으면 한 행만 반복 조회해 버퍼 풀에 완전히 올라간 상태를 재게 된다. // ────────────────────────────────────────────────────────── import http from 'k6/http'; @@ -62,11 +35,9 @@ import exec from 'k6/execution'; import { check } from 'k6'; const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; -const STUDENT_ID_START = Number(__ENV.STUDENT_ID_START || 200000001); -const USER_COUNT = Number(__ENV.USER_COUNT || 50); +const USER_COUNT = Number(__ENV.USER_COUNT || {USER_COUNT}); const PHASE = __ENV.PHASE || 'measure'; -// 이 측정이 무엇이었는지 요약 파일만 보고 알 수 있게 한다. const TARGET = '{슬러그}'; const ENDPOINT = '{HTTP} {경로}'; const CONDITION = { @@ -75,21 +46,16 @@ const CONDITION = { ramp_up: '30s', ramp_down: '30s', total_duration: '{ramp_up + duration + ramp_down}', - db_cache: 'warm (InnoDB 버퍼 풀은 재기동 없이 비울 수 없다)', + db_cache: 'warm', app_cache: '없음', - student_id_start: STUDENT_ID_START, user_count: USER_COUNT, }; -// tokens.json은 이슈 디렉토리에 있다(대상 간 공유). -// 형태: [{ "accessToken": "...", "refreshToken": "..." }, ...] +// 형태: [{ "memberId": 900001, "accessToken": "..." }, ...] const tokens = JSON.parse(open('../tokens.json')); if (tokens.length !== USER_COUNT) { - throw new Error( - `토큰 ${tokens.length}건 / 필요 ${USER_COUNT}건. 로그인에 실패한 학번이 있다. ` + - 'tokens.json을 다시 만들고, 학번 범위와 시드의 비밀번호 해시(@pw_hash)가 맞는지 확인하라.' - ); + throw new Error(`토큰 ${tokens.length}건 / 필요 ${USER_COUNT}건. mint-tokens.sh의 --count와 USER_COUNT를 맞춰라.`); } const scenarios = { @@ -131,16 +97,8 @@ function parseBody(res) { } export default function () { - // 시나리오 전체의 반복 번호로 고른다. VU 수와 무관하게 토큰 USER_COUNT개를 균등하게 돈다. const token = tokens[exec.scenario.iterationInTest % tokens.length]; - - // 이 서버는 두 헤더를 모두 요구한다. 하나라도 빠지면 401이다. - const params = { - headers: { - 'access-token': token.accessToken, - 'refresh-token': token.refreshToken, - }, - }; + const params = { headers: { 'access-token': token.accessToken } }; const res = http.get(`${BASE_URL}{대상 엔드포인트}`, params); @@ -183,7 +141,7 @@ export function handleSummary(data) { bytes_received: val('data_received', 'count'), }; - // 아래 반올림은 터미널 한 줄 출력에만 쓴다. summary 객체의 값은 손대지 않는다. + // 반올림은 터미널 한 줄에만 쓴다. summary의 값은 손대지 않는다. const num = (x, d) => (typeof x === 'number' ? x.toFixed(d) : '-'); const line = [ `[${PHASE}] ${TARGET}`, @@ -195,10 +153,8 @@ export function handleSummary(data) { ].join(' / '); const out = { stdout: `\n${line}\n\n` }; - if (__ENV.SUMMARY_OUT) { out[__ENV.SUMMARY_OUT] = JSON.stringify(summary, null, 2); } - return out; } diff --git a/.claude/skills/optimize-performance/template/perf-env.sh b/.claude/skills/optimize-performance/template/perf-env.sh new file mode 100644 index 0000000..b70d448 --- /dev/null +++ b/.claude/skills/optimize-performance/template/perf-env.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# 측정 셸 환경. 레포 루트에서, 새 터미널마다 한 번 source한다. +# 실행(bash perf-env.sh)이 아니라 source여야 함수와 변수가 현재 셸에 남는다. bash, zsh 모두 된다. +# +# source .claude/skills/optimize-performance/template/perf-env.sh {이슈번호} {슬러그} +# +# 정의하는 것 +# PERF_DIR 이슈 디렉토리 .claude/resources/perf/{이슈번호} +# TARGET_DIR 대상 디렉토리 $PERF_DIR/{슬러그} +# SEEDS 시드 모듈 디렉토리 +# PERF_JWT_SECRET application-perf.yml의 security.jwt.secret-key (토큰 발급에 쓴다) +# mysqlp uss_db 접속 함수. mysqlp -e "SELECT 1;" / mysqlp < file.sql + +if [ $# -lt 2 ]; then + echo "사용법: source .claude/skills/optimize-performance/template/perf-env.sh {이슈번호} {슬러그}" >&2 + return 1 2>/dev/null || exit 1 +fi + +export PERF_DIR=.claude/resources/perf/$1 +export TARGET_DIR=$PERF_DIR/$2 +export SEEDS=.claude/skills/optimize-performance/template/seeds + +# 주석 줄을 건너뛰고 키 값만 집는다. 파일은 Phase 2가 만든다. 그 전에는 비어 있어도 된다. +PERF_JWT_SECRET=$(grep -E '^[[:space:]]*secret-key:' src/main/resources/application-perf.yml 2>/dev/null | awk '{print $2}') +export PERF_JWT_SECRET +if [ -z "$PERF_JWT_SECRET" ]; then + echo "경고: application-perf.yml의 secret-key를 읽지 못했다. Phase 2에서 파일을 만든 뒤 다시 source하라." >&2 +fi + +# uss_db 접속. 네 요소 모두 필요하다. +# docker exec 호스트에 mysql 클라이언트가 없다 +# 함수 zsh는 따옴표 없는 변수 확장에 단어 분리를 하지 않아 "$MYSQL -e" 형식이 깨진다 +# --default-character-set=utf8mb4 없으면 latin1로 붙어 쿼리 안의 한글 리터럴이 ?가 되고 결과가 조용히 틀린다 +# --init-command=SET NAMES ... charset만 맞추면 collation이 서버 기본(utf8mb4_0900_ai_ci)으로 남아 +# utf8mb4_unicode_ci인 컬럼과 사용자 변수 비교가 ERROR 1267로 죽는다 +mysqlp() { + docker exec -i -e MYSQL_PWD=root uss-mysql \ + mysql -u root \ + --default-character-set=utf8mb4 \ + --init-command="SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci" \ + uss_db "$@" +} + +echo "PERF_DIR=$PERF_DIR TARGET_DIR=$TARGET_DIR mysqlp 정의됨" diff --git a/.claude/skills/optimize-performance/template/seeds/README.md b/.claude/skills/optimize-performance/template/seeds/README.md index 13f5c3f..e9a37ae 100644 --- a/.claude/skills/optimize-performance/template/seeds/README.md +++ b/.claude/skills/optimize-performance/template/seeds/README.md @@ -1,52 +1,48 @@ # 시드 모듈 -성능 측정용 더미 데이터를 도메인 단위로 모듈화해 둔 것이다. -**규모만 정하면 바로 쓸 수 있어야 한다.** 매번 INSERT 문을 새로 쓰지 마라. +성능 측정용 더미 데이터를 도메인 단위로 모듈화해 둔 것이다. **규모와 카디널리티는 변수로만 정한다.** 모듈 본문은 고치지 않는다. ## 쓰는 법 -`.claude/resources/perf/{이슈번호}/seeds.sql`을 아래 형태로 만든다. -변수 블록과 `SOURCE` 줄만 있으면 된다. **모듈 본문을 복사하지 마라.** +`.claude/resources/perf/{이슈번호}/seeds.sql`에 **변수 블록만** 쓴다. ```sql -- PERF-{이슈번호} 시드 -- 대상: {측정할 엔드포인트들} --- MySQL의 재귀 CTE 기본 깊이 상한은 1000이다. 이걸 안 올리면 1000행에서 끊긴다. +-- 재귀 CTE 기본 깊이 상한은 1000이다. 안 올리면 1000행에서 끊긴다. SET SESSION cte_max_recursion_depth = 10000000; -- 회원 SET @member_start = 900001; SET @member_count = 1000; SET @student_id_start = 200000001; -SET @pw_hash = '{Phase 3-A에서 뽑은 BCrypt 해시를 그대로 붙여넣는다}'; +SET @pw_hash = 'perf-not-a-real-hash'; SET @member_dept_count = 5; --- 강의 -SET @course_start = 900001; -SET @course_count = 20000; -SET @course_dept_count = 20; -SET @course_area_count = 10; +-- 강의. @course_start는 SELECT MAX(id) FROM courses; 보다 커야 한다. 겹치면 시드 정리가 실제 강의를 지운다. +SET @course_start = 1000001; +SET @course_count = 20000; +SET @course_dept_count = 20; +SET @course_area_count = 8; SET @schedules_per_course = 2; +SET @course_term_year = 2026; +SET @course_term = 'FIRST'; +SET @course_title_mode = 'plain'; -- 장바구니, 수강신청 SET @cart_per_member = 8; SET @registration_per_member = 6; - -SOURCE .claude/skills/optimize-performance/template/seeds/member.sql -SOURCE .claude/skills/optimize-performance/template/seeds/course.sql -SOURCE .claude/skills/optimize-performance/template/seeds/enrollment.sql ``` -실행은 호출자가 프로젝트 루트에서 한다. +실행은 호출자가 레포 루트에서 한다. 변수 블록과 모듈을 호스트에서 이어 붙여 표준입력으로 넘긴다. +`cat` 순서가 곧 FK 의존 순서다. 필요 없는 모듈은 뺀다. ```bash -$MYSQL_PERF < $PERF_DIR/seeds.sql +cat $PERF_DIR/seeds.sql $SEEDS/member.sql $SEEDS/course.sql $SEEDS/enrollment.sql | mysqlp ``` -> `SOURCE`는 mysql 클라이언트의 명령이라 **경로가 실행 위치 기준**이다. -> 반드시 프로젝트 루트에서 실행해야 한다. `-e "SOURCE ..."` 형태로는 동작하지 않으므로 -> 위처럼 파일을 표준입력으로 넘긴다. +`SOURCE`는 쓰지 마라. mysql 클라이언트가 컨테이너 안에서 돌아 호스트 경로를 못 본다. ## 모듈 @@ -54,52 +50,100 @@ $MYSQL_PERF < $PERF_DIR/seeds.sql |---|---|---| | `member.sql` | `members` | 없음 | | `course.sql` | `courses`, `course_schedules` | 없음 | -| `enrollment.sql` | `carts`, `registrations` | `member`, `course` | - -`SOURCE` 순서가 곧 FK 의존 순서다. 표의 위에서 아래로 부른다. +| `enrollment.sql` | `carts`, `registrations` (+ `courses.current_enrollment` 동기화) | `member`, `course` | ## 변수 -모듈은 MySQL 사용자 변수만 읽는다. 정의하지 않은 변수는 `NULL`이 되고, -`NOT NULL` 컬럼에 들어가면서 에러로 죽는다. 조용히 0건이 들어가지는 않는다. +모듈은 MySQL 사용자 변수만 읽는다. 정의하지 않은 변수는 `NULL`이 되고 `NOT NULL` 컬럼에서 에러로 죽는다. 조용히 0건이 되지는 않는다. +`ELT` 목록으로 카디널리티를 만드는 변수는 **목록 길이가 상한**이다. 넘기면 `NULL`이 들어가 죽는다. -| 변수 | 쓰는 모듈 | 뜻 | -|---|---|---| -| `@member_start` | member, enrollment | 시드 회원 id 시작값 | -| `@member_count` | member, enrollment | 시드 회원 수. k6 스크립트의 `USER_COUNT`와 일치시킨다 | -| `@student_id_start` | member | 학번 시작값. **9자리 숫자여야 로그인이 된다.** k6의 `STUDENT_ID_START`와 일치시킨다 | -| `@pw_hash` | member | 회원 전원이 공유할 BCrypt 해시. Phase 3-A에서 실제 가입 API로 뽑은 값을 쓴다 | -| `@member_dept_count` | member | 회원이 퍼질 학과 수 (전공 조회의 카디널리티) | -| `@course_start` | course, enrollment | 시드 강의 id 시작값 | -| `@course_count` | course, enrollment | 시드 강의 수 | -| `@course_dept_count` | course | 강의가 퍼질 학과 수 | -| `@course_area_count` | course | 강의가 퍼질 교양 영역 수 | -| `@schedules_per_course` | course | 강의당 시간표 행 수 | -| `@cart_per_member` | enrollment | 회원당 장바구니 강의 수. **10을 넘기지 마라** (담기 상한이 10이라 실제로 도달 불가능한 상태가 된다) | -| `@registration_per_member` | enrollment | 회원당 수강신청 강의 수 | +| 변수 | 모듈 | 뜻 | 상한 | +|---|---|---|---| +| `@member_start` | member, enrollment | 시드 회원 id 시작값 | | +| `@member_count` | member, enrollment | 시드 회원 수. k6의 `USER_COUNT`, `mint-tokens.sh`의 `--count`와 맞춘다 | | +| `@student_id_start` | member | 학번 시작값 (영숫자 1~20자) | | +| `@pw_hash` | member | 회원 전원이 공유할 `password` 값. 토큰을 `mint-tokens.sh`로 만들면 로그인을 안 타므로 아무 문자열이나 된다. **로그인 경로를 측정할 때만** Phase 3-A에서 가입 API로 뽑은 BCrypt 해시를 넣는다 | | +| `@member_dept_count` | member | 회원이 퍼질 학과 수 | 5 | +| `@course_start` | course, enrollment | 시드 강의 id 시작값 | | +| `@course_count` | course, enrollment | 시드 강의 수 | | +| `@course_dept_count` | course | 강의가 퍼질 학과 수. 앞 5개가 member의 학과와 같아 전공 조회가 0건이 되지 않는다 | 20 | +| `@course_area_count` | course | 강의가 퍼질 영역 수. 앞 3개는 전공 영역, 4번째부터 교양 영역이다. 교양 조회를 재면 4 이상 | 8 | +| `@schedules_per_course` | course | 강의당 시간표 행 수 | | +| `@course_term_year` | course | 학년도. `uk_year_term_haksu`의 첫 컬럼 | | +| `@course_term` | course | 학기. `CourseTerm` 상수 (`FIRST`, `SECOND`, `SUMMER`, `WINTER`) | | +| `@course_title_mode` | course | `plain`이면 `성능측정강의{n}`, `search`면 아래 **검색 제목 설계**의 조합 제목 | | +| `@cart_per_member` | enrollment | 회원당 장바구니 강의 수. 담기 상한이 10이라 넘기면 담기 API가 항상 실패한다 | 10 | +| `@registration_per_member` | enrollment | 회원당 수강신청 강의 수 | | + +**쓰기 엔드포인트를 측정할 때.** `POST /carts/{courseId}`나 `POST /registration/{courseId}`가 대상이면 부하가 담으려는 강의를 +미리 채워두면 전부 중복 실패다. `@cart_per_member` 또는 `@registration_per_member`를 0으로 둔다. + +## 검색 제목 설계 (`@course_title_mode = 'search'`) + +ngram 파서 + BOOLEAN MODE는 검색어를 OR 합집합이 아니라 **구(phrase) 검색**으로 다룬다. +`plain` 제목은 전 행이 같은 바이그램을 공유해 키워드 하나가 전 행에 걸리거나 0건이 된다. + +`search` 모드는 제목을 `{접두 40종}{접미 25종}({n})`으로 만든다. 접두는 `n % 40`, 접미는 `FLOOR(n / 40) % 25`라 두 축이 독립이고, +연속한 1000개 n마다 1000개 조합이 한 번씩 나온다. 조합어 하나(`컴퓨터공학`)는 그 조합 행만 매칭하므로 + + 조합당 매칭 건수 = @course_count / 1000 + +k6 키워드 풀에는 조합어를 넣는다. 접두나 접미 하나만 넣으면 부분 일치가 되어 선택도가 통제되지 않는다. +매칭 건수를 바꾸려면 `@course_count`를 바꾼다. + +## FULLTEXT 커버리지 + +`course.sql`의 검증 쿼리 `fts_indexed`가 `rows_seeded`와 같아야 한다. 적으면 인덱스에서 빠진 행이 있는 것이다. +2026-08-28 실측에서 2,000행 대량 INSERT 직후 앞쪽 19행이 색인되지 않은 채 남은 적이 있다(`OPTIMIZE TABLE`로도 안 돌아옴, 재현은 간헐적). +빠진 행은 검색에 안 잡히므로 선택도가 조용히 틀린다. 어긋나면 아래 **대량 적재**의 인덱스 재생성을 실행한다. + +## 규모 상한과 대량 적재 + +모듈은 재귀 CTE로 행을 만든다. **수십만 행까지**가 편한 범위다. 그 이상이면 아래를 함께 한다. + +- `courses`의 FULLTEXT(ngram) 인덱스는 행마다 증분 유지되어 적재를 몇 배 느리게 한다. 떼고 넣고 다시 만든다. + 인덱스가 없는 동안 `MATCH ... AGAINST`는 에러(애플리케이션은 500)이므로 재생성 전에 대상 API를 호출하지 마라. + + ```bash + mysqlp -e "ALTER TABLE courses DROP INDEX ft_idx_course_search;" + cat $PERF_DIR/seeds.sql $SEEDS/course.sql | mysqlp + mysqlp -e "ALTER TABLE courses ADD FULLTEXT INDEX ft_idx_course_search (course_code, haksu_code, title_kr, title_en) WITH PARSER ngram;" + ``` + +- 인덱스 재생성은 규모와 무관하게 **FULLTEXT 커버리지**가 어긋났을 때의 복구 수단이기도 하다. `ALTER ... DROP INDEX`와 `ADD FULLTEXT INDEX`만 실행하면 된다. +- 적재 세션에서 `SET SESSION unique_checks = 0; SET SESSION foreign_key_checks = 0;`를 변수 블록에 넣으면 빨라진다. + 전역 설정(`innodb_flush_log_at_trx_commit`)은 건드리지 마라. 되돌리지 않으면 측정이 운영과 다른 내구성에서 돈다. +- 데이터가 InnoDB 버퍼 풀(기본 128MiB)보다 커지면 측정이 디스크 I/O를 잰다. 규모를 키우기 전에 그 사실을 Phase 3에서 확정한다. ## 모듈을 고칠 때 -- **규모는 변수로만 조절한다.** 특정 이슈의 숫자를 모듈 본문에 박지 마라. -- 값이 실제 강의처럼 보일 필요는 없다. FK 관계와 개수, 카디널리티만 맞으면 된다. -- **enum 컬럼에는 반드시 실재하는 enum 상수명을 넣어라.** 애플리케이션이 `valueOf`로 파싱하므로 - 없는 값이 들어가면 조회 시점에 500이 난다. DB에는 `VARCHAR(50)`이라 들어갈 때는 통과한다. - 값 목록은 `src/main/java/uss/code/course/domain/`과 `member/domain/`의 enum 파일이 기준이다. -- 모든 모듈은 **재실행해도 중복이 쌓이지 않아야 한다.** `INSERT IGNORE`와 UNIQUE 제약이 그 장치다. 지우지 마라. -- 카디널리티가 걸린 컬럼은 `ELT(1 + (n % @변수), ...)` 형태로 서로 다른 값의 개수를 명시적으로 통제한다. - 한 값에 전 행이 몰리거나 전 행이 서로 다른 값을 갖게 두지 마라. -- 새 도메인이 필요하면 새 모듈 파일을 만들고 이 표에 추가한다. 기존 모듈에 덧붙이지 마라. -- 검증 쿼리는 모듈 말미에 둔다. 행 수와 함께 카디널리티를 반드시 뽑는다. +- **실제 스키마와 먼저 대조하라.** 마이그레이션이 추가돼도 모듈은 따라오지 않는다. 아래로 NOT NULL·기본값 없음 컬럼을 뽑아 + 모듈의 INSERT 컬럼 목록과 맞춘다. + + ```bash + mysqlp -e " + SELECT TABLE_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY ORDINAL_POSITION) AS required_columns + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = 'uss_db' + AND TABLE_NAME IN ('members', 'courses', 'course_schedules', 'carts', 'registrations') + AND IS_NULLABLE = 'NO' AND COLUMN_DEFAULT IS NULL AND EXTRA NOT LIKE '%auto_increment%' + GROUP BY TABLE_NAME;" + ``` + +- **enum 컬럼에는 실재하는 상수명만.** 애플리케이션이 `valueOf`로 파싱하므로 없는 값은 조회 시점에 500이다. DB는 `VARCHAR`라 들어갈 때는 통과한다. + 기준은 `src/main/java/uss/code/course/domain/`과 `member/domain/`의 enum 파일이다. + `courses`는 enum 컬럼(`college`, `department`, `area`, `term`, `status`)과 `{*_code, *_name}` String 쌍이 섞여 있다. +- 값이 실제 강의처럼 보일 필요는 없다. FK, 개수, 카디널리티만 맞으면 된다. +- 재실행해도 중복이 쌓이지 않아야 한다. `INSERT IGNORE`와 UNIQUE 제약이 그 장치다. +- 카디널리티가 걸린 컬럼은 `ELT(1 + (n % @변수), ...)`로 서로 다른 값의 개수를 명시적으로 통제한다. +- 새 도메인은 새 모듈 파일로 만들고 위 표에 추가한다. 검증 쿼리는 모듈 말미에 두고 행 수와 카디널리티를 뽑는다. +- 고친 모듈은 커밋 전에 한 번 실행한다. ## 시드를 지울 때 -시드 회원과 강의는 id를 명시 삽입하므로 범위로 지울 수 있다. FK가 `ON DELETE CASCADE`라 -`members`와 `courses`만 지우면 `carts`, `registrations`, `course_schedules`가 함께 사라진다. +id를 명시 삽입하므로 범위로 지운다. FK가 `ON DELETE CASCADE`라 `members`와 `courses`만 지우면 나머지가 따라 지워진다. ```sql DELETE FROM members WHERE id BETWEEN @member_start AND @member_start + @member_count - 1; DELETE FROM courses WHERE id BETWEEN @course_start AND @course_start + @course_count - 1; ``` - -앱 시드(`database/seed/`의 실제 강의 데이터)와 id가 겹치지 않도록 `@course_start`를 충분히 크게 잡는다. -겹치면 위 DELETE가 실제 강의 데이터를 지운다. diff --git a/.claude/skills/optimize-performance/template/seeds/course.sql b/.claude/skills/optimize-performance/template/seeds/course.sql index a66772e..e973882 100644 --- a/.claude/skills/optimize-performance/template/seeds/course.sql +++ b/.claude/skills/optimize-performance/template/seeds/course.sql @@ -1,73 +1,92 @@ -- 강의 시드: courses, course_schedules -- --- 필요한 변수: @course_start, @course_count, @course_dept_count, @course_area_count, @schedules_per_course +-- 필요한 변수: @course_start, @course_count, @course_dept_count (상한 20), @course_area_count (상한 8), +-- @schedules_per_course, @course_term_year, @course_term, @course_title_mode ('plain' | 'search') -- --- id를 명시 삽입한다. @course_start를 앱 시드(database/seed/)의 강의 id보다 충분히 크게 잡아야 --- 시드 정리 시 실제 강의 데이터를 지우지 않는다. --- --- course_area의 값 선택이 중요하다. 교양 조회(/api/v1/courses/general-education)는 --- 교양 영역만 통과시키므로(CourseArea.isGeneralEducationArea), 교양 조회를 잴 거면 --- 아래 목록에 교양 영역이 반드시 들어 있어야 한다. +-- id를 명시 삽입한다. @course_start는 SELECT MAX(id) FROM courses; 보다 커야 한다. +-- haksu_code는 VARCHAR(15)다. 'P' + 10자리라 @course_count가 커져도 넘지 않는다. +-- college, department, area, term, status는 enum 컬럼이고 classification, type, grade, concentration, english는 +-- {*_code, *_name} String 쌍이다. enum 컬럼에는 실재하는 상수명만 넣는다. SELECT '[course.sql] courses 적재' AS ''; INSERT IGNORE INTO courses - (id, title_kr, title_en, course_code, - course_college, course_department, course_classification, - course_area, course_type, course_grade, - professor_name, classroom, credits, is_english_course, - max_capacity, current_enrollment) + (id, academic_year, term, title_kr, title_en, course_code, haksu_code, + college, department, classification_code, classification_name, + area, area_code, area_name, type_code, type_name, + grade_code, grade_name, concentration_code, concentration_name, + credits, is_english_course, english_code, english_name, + is_huss_course, max_capacity, current_enrollment, status) WITH RECURSIVE seq AS ( SELECT 0 AS n UNION ALL SELECT n + 1 FROM seq WHERE n < @course_count - 1 ) SELECT @course_start + n, - CONCAT('성능측정강의', n), + @course_term_year, + @course_term, + -- search 모드: {접두 40}{접미 25}(n). 조합어 하나가 그 조합 행만 매칭한다 (README 검색 제목 설계). + IF(@course_title_mode = 'search', + CONCAT( + ELT(1 + (n % 40), + '컴퓨터','기계','전자','전기','화학','물리','수학','통계','경영','경제', + '무역','행정','정치','사회','심리','교육','역사','철학','문학','언어', + '미디어','디자인','건축','도시','환경','에너지','신소재','반도체','바이오','의료', + '해양','항공','로봇','자동차','금융','회계','마케팅','물류','관광','스포츠'), + ELT(1 + (FLOOR(n / 40) % 25), + '공학','과학','개론','실습','설계','시스템','이론','분석','응용','실험', + '연습','특강','세미나','연구','방법론','프로그래밍','알고리즘','데이터','네트워크','보안', + '최적화','시뮬레이션','모델링','제어','계측'), + '(', n, ')'), + CONCAT('성능측정강의', n)), CONCAT('perf course ', n), - CONCAT('PERF', LPAD(n, 6, '0')), - ELT(1 + (n % 5), - 'INFORMATION_TECHNOLOGY', 'ENGINEERING', 'NATURAL_SCIENCES', - 'BUSINESS', 'COMMERCE_PUBLIC_AFFAIRS'), - -- member.sql의 member_department 앞 5개와 겹치게 둔다. 겹치지 않으면 전공 조회가 0건이 된다. + CONCAT('PERF', LPAD(n, 7, '0')), + CONCAT('P', LPAD(n, 10, '0')), + ELT(1 + (n % 8), + 'HUMANITIES', 'NATURAL_SCIENCES', 'SOCIAL_SCIENCES', 'COMMERCE_PUBLIC_AFFAIRS', + 'ENGINEERING', 'INFORMATION_TECHNOLOGY', 'BUSINESS', 'ARTS_PHYSICAL_EDUCATION'), + -- 앞 5개는 member.sql의 department와 같다 (CourseDepartment, MemberDepartment 양쪽에 있는 상수). ELT(1 + (n % @course_dept_count), 'COMPUTER_ENGINEERING', 'MECHANICAL_ENGINEERING', 'MATHEMATICS', 'BUSINESS_ADMINISTRATION', 'ECONOMICS', - 'INFORMATION_COMMUNICATION_ENGINEERING', 'EMBEDDED_SYSTEM', - 'ELECTRICAL_ENGINEERING', 'ELECTRONICS_ENGINEERING', 'PHYSICS', - 'CHEMISTRY', 'DATA_SCIENCE', 'TAX_ACCOUNTING', 'TRADE', - 'CONSUMER_SCIENCE', 'SOCIAL_WELFARE', 'PUBLIC_ADMINISTRATION', - 'POLITICS_DIPLOMACY', 'URBAN_ENGINEERING', 'SAFETY_ENGINEERING'), - ELT(1 + (n % 6), - 'MAJOR_ADVANCED', 'MAJOR_BASIC', 'MAJOR_CORE', - 'BASIC_LIBERAL_ARTS', 'CORE_LIBERAL_ARTS', 'ADVANCED_LIBERAL_ARTS'), - -- 앞 3개는 전공 영역, 나머지는 교양 영역이다. 교양 조회는 뒤쪽만 통과한다. + 'KOREAN_LITERATURE', 'ENGLISH_LITERATURE', 'GERMAN_STUDIES', 'FRENCH_STUDIES', + 'JAPANESE_LITERATURE', 'CHINESE_STUDIES', 'PHYSICS', 'CHEMISTRY', + 'FASHION_INDUSTRY', 'MARINE_SCIENCE', 'SOCIAL_WELFARE', 'MEDIA_COMMUNICATION', + 'LIBRARY_INFO', 'CREATIVE_HRD', 'PUBLIC_ADMINISTRATION'), + LPAD(1 + (n % 6), 2, '0'), + ELT(1 + (n % 6), '전공심화', '전공기초', '전공핵심', '기초교양', '핵심교양', '심화교양'), + -- 앞 3개는 전공 영역, 4번째부터 교양 영역 (CourseArea.isGeneralEducationArea). ELT(1 + (n % @course_area_count), 'MAJOR_ADVANCED', 'MAJOR_BASIC', 'MAJOR_CORE', - 'ACADEMIC_FOUNDATION', 'BASIC_SCIENCE_ENGINEERING', - 'CORE_HUMANITIES', 'CORE_SOCIAL', 'CORE_SCIENCE_TECHNOLOGY', - 'HUMANITIES', 'FOREIGN_LANGUAGE'), - -- OCU 2개, K-MOOC 1개 상한이 있는 유형을 소수 섞어 둔다. - -- 전부 LECTURE로 채우면 과목 유형 제한 분기를 한 번도 타지 않는다. - ELT(1 + (n % 10), - 'LECTURE', 'LECTURE', 'LECTURE', 'LECTURE', 'LECTURE', - 'THEORY_LAB', 'LAB', 'E_LEARNING', 'OCU', 'K_MOOC'), - ELT(1 + (n % 5), 'FRESHMAN', 'SOPHOMORE', 'JUNIOR', 'SENIOR', 'ALL'), - CONCAT('교수', n % 200), - CONCAT('호관 ', 100 + (n % 300)), - ELT(1 + (n % 3), 1, 2, 3), - IF(n % 20 = 0, TRUE, FALSE), - -- 정원 마감 분기를 재려면 current_enrollment를 따로 올린다. 시드는 0에서 시작한다. + 'BASIC_SCIENCE_ENGINEERING', 'ACADEMIC_FOUNDATION', 'CORE_INU_SEMINAR', + 'CORE_HUMANITIES', 'CORE_SOCIAL'), + LPAD(1 + (n % @course_area_count), 2, '0'), + ELT(1 + (n % @course_area_count), + '전공심화', '전공기초', '전공핵심', '기초과학공학', + '학문기초', 'INU세미나', '핵심인문', '핵심사회'), + LPAD(1 + (n % 5), 2, '0'), + ELT(1 + (n % 5), '강의', '이론실습', '실습', '이러닝', '원격'), + LPAD(1 + (n % 5), 2, '0'), + ELT(1 + (n % 5), '1학년', '2학년', '3학년', '4학년', '전학년'), + LPAD(1 + (n % 3), 2, '0'), + ELT(1 + (n % 3), '해당없음', '연계전공', '융합전공'), + 1 + (n % 3), + IF(n % 20 = 0, 1, 0), + LPAD(1 + (n % 2), 2, '0'), + ELT(1 + (n % 2), '해당없음', '영어강의'), + IF(n % 25 = 0, 1, 0), 30 + (n % 70), - 0 + -- 0에서 시작한다. enrollment.sql이 실제 신청 건수로 맞춘다. + 0, + 'ACTIVE' FROM seq; SELECT '[course.sql] course_schedules 적재' AS ''; -- 강의당 @schedules_per_course개. 요일과 시간대를 흩어 시간표 충돌 판정이 실제로 갈리게 한다. --- 전부 같은 요일, 같은 시간에 몰아넣으면 담기와 신청이 첫 건 이후 전부 충돌로 실패한다. +-- classroom은 CourseScheduleFormatter가 묶음 단위로 쓰므로 강의 안에서는 같은 값이다. INSERT IGNORE INTO course_schedules - (course_id, schedule_text, course_day, start_time, end_time) + (course_id, day_of_week, period_code, period_name, classroom, start_time, end_time) WITH RECURSIVE seq AS ( SELECT 0 AS n UNION ALL @@ -79,13 +98,10 @@ slot AS ( SELECT s + 1 FROM slot WHERE s < @schedules_per_course - 1 ) SELECT @course_start + seq.n, - CONCAT( - ELT(1 + ((seq.n + slot.s) % 5), '월', '화', '수', '목', '금'), - ' ', - LPAD(9 + ((seq.n * 2 + slot.s) % 9), 2, '0'), ':00-', - LPAD(10 + ((seq.n * 2 + slot.s) % 9), 2, '0'), ':00' - ), ELT(1 + ((seq.n + slot.s) % 5), 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY'), + LPAD(1 + ((seq.n * 2 + slot.s) % 9), 2, '0'), + CONCAT(1 + ((seq.n * 2 + slot.s) % 9), '교시'), + CONCAT(ELT(1 + (seq.n % 5), '1', '2', '3', '4', '5'), '호관 ', 100 + (seq.n % 300)), MAKETIME(9 + ((seq.n * 2 + slot.s) % 9), 0, 0), MAKETIME(10 + ((seq.n * 2 + slot.s) % 9), 0, 0) FROM seq CROSS JOIN slot; @@ -93,25 +109,38 @@ FROM seq CROSS JOIN slot; ANALYZE TABLE courses, course_schedules; -- 검증 -SELECT 'courses' AS table_name, - count(*) AS rows_seeded, - count(DISTINCT course_department) AS dept_cardinality, - count(DISTINCT course_area) AS area_cardinality, - count(DISTINCT course_type) AS type_cardinality, - min(id) AS min_id, - max(id) AS max_id +SELECT 'courses' AS table_name, + count(*) AS rows_seeded, + count(DISTINCT department) AS dept_cardinality, + count(DISTINCT area) AS area_cardinality, + min(id) AS min_id, + max(id) AS max_id FROM courses WHERE id BETWEEN @course_start AND @course_start + @course_count - 1; -SELECT 'course_schedules' AS table_name, - count(*) AS rows_seeded, +SELECT 'course_schedules' AS table_name, + count(*) AS rows_seeded, count(*) / NULLIF(count(DISTINCT course_id), 0) AS schedules_per_course, - count(DISTINCT course_day) AS day_cardinality + count(DISTINCT day_of_week) AS day_cardinality FROM course_schedules WHERE course_id BETWEEN @course_start AND @course_start + @course_count - 1; --- 교양 조회를 잴 거면 교양 영역 강의가 실제로 있어야 한다. 0이면 @course_area_count를 늘려라. +-- 교양 조회를 재면 0이어서는 안 된다. 0이면 @course_area_count를 4 이상으로. SELECT count(*) AS general_education_courses FROM courses WHERE id BETWEEN @course_start AND @course_start + @course_count - 1 - AND course_area NOT IN ('MAJOR_ADVANCED', 'MAJOR_BASIC', 'MAJOR_CORE'); + AND area NOT IN ('MAJOR_ADVANCED', 'MAJOR_BASIC', 'MAJOR_CORE'); + +-- FULLTEXT 커버리지. 시드 전 행의 course_code가 'PERF'로 시작하므로 fts_indexed = rows_seeded여야 한다. +-- 적으면 인덱스에서 빠진 행이 있는 것이다(대량 INSERT에서 앞쪽 행이 빠지는 현상이 관측된 적 있다). +-- README의 대량 적재 절차대로 FULLTEXT 인덱스를 떼고 다시 만들면 전 행이 다시 색인된다. +SELECT count(*) AS fts_indexed +FROM courses +WHERE id BETWEEN @course_start AND @course_start + @course_count - 1 + AND MATCH(course_code, haksu_code, title_kr, title_en) AGAINST('PERF' IN BOOLEAN MODE); + +-- search 모드의 선택도. 목표는 @course_count / 1000. plain 모드면 0이 정상이다. +SELECT '컴퓨터공학' AS keyword, count(*) AS matched +FROM courses +WHERE MATCH(course_code, haksu_code, title_kr, title_en) AGAINST('컴퓨터공학' IN BOOLEAN MODE) + AND status = 'ACTIVE'; diff --git a/.claude/skills/optimize-performance/template/seeds/enrollment.sql b/.claude/skills/optimize-performance/template/seeds/enrollment.sql index ce45ab8..52efb1a 100644 --- a/.claude/skills/optimize-performance/template/seeds/enrollment.sql +++ b/.claude/skills/optimize-performance/template/seeds/enrollment.sql @@ -1,17 +1,12 @@ -- 장바구니, 수강신청 시드: carts, registrations -- -- 필요한 변수: @member_start, @member_count, @course_start, @course_count, --- @cart_per_member, @registration_per_member +-- @cart_per_member (상한 10), @registration_per_member -- 선행 모듈: member.sql, course.sql -- -- 두 테이블 모두 UNIQUE KEY uk_member_course (member_id, course_id)를 가진다. --- 회원마다 서로 다른 강의를 배정해야 하므로 (m * 37 + k) % @course_count로 흩는다. --- --- **쓰기 엔드포인트를 측정할 때 주의한다.** --- POST /api/v1/carts/{courseId}나 POST /api/v1/registration/{courseId}를 재는 경우, --- 부하가 담으려는 강의를 여기서 미리 채워두면 전부 중복으로 실패한다. --- 그 대상이면 @cart_per_member 또는 @registration_per_member를 0으로 두고, --- 조회 대상일 때만 채운다. +-- 회원마다 서로 다른 강의를 배정하도록 (m * 소수 + k) % @course_count로 흩는다. +-- 담기·신청 API 자체를 측정할 때는 해당 변수를 0으로 둔다. 미리 채워두면 부하가 전부 중복 실패다. SELECT '[enrollment.sql] carts 적재' AS ''; @@ -51,32 +46,29 @@ SELECT @member_start + m.i, FROM m CROSS JOIN r WHERE @registration_per_member > 0; --- current_enrollment를 실제 신청 건수와 맞춘다. --- 이 값이 어긋나 있으면 정원 판정이 실제 데이터와 다르게 동작해, 측정에서 재는 분기가 달라진다. +-- current_enrollment를 실제 신청 건수와 맞춘다. 어긋나면 정원 판정이 실제 데이터와 다르게 동작한다. UPDATE courses c -SET c.current_enrollment = ( - SELECT count(*) FROM registrations r WHERE r.course_id = c.id -) +SET c.current_enrollment = (SELECT count(*) FROM registrations r WHERE r.course_id = c.id) WHERE c.id BETWEEN @course_start AND @course_start + @course_count - 1; ANALYZE TABLE carts, registrations, courses; -- 검증 -SELECT 'carts' AS table_name, - count(*) AS rows_seeded, - count(DISTINCT member_id) AS members, - count(*) / NULLIF(count(DISTINCT member_id), 0) AS per_member +SELECT 'carts' AS table_name, + count(*) AS rows_seeded, + count(DISTINCT member_id) AS members, + count(*) / NULLIF(count(DISTINCT member_id), 0) AS per_member FROM carts WHERE member_id BETWEEN @member_start AND @member_start + @member_count - 1; -SELECT 'registrations' AS table_name, - count(*) AS rows_seeded, - count(DISTINCT member_id) AS members, - count(*) / NULLIF(count(DISTINCT member_id), 0) AS per_member +SELECT 'registrations' AS table_name, + count(*) AS rows_seeded, + count(DISTINCT member_id) AS members, + count(*) / NULLIF(count(DISTINCT member_id), 0) AS per_member FROM registrations WHERE member_id BETWEEN @member_start AND @member_start + @member_count - 1; --- 장바구니 상한(10)을 넘긴 회원이 있으면 안 된다. 넘겼다면 그 회원은 담기 API가 항상 실패한다. +-- 장바구니 상한(10)을 넘긴 회원이 있으면 담기 API가 항상 실패한다. 0이어야 한다. SELECT count(*) AS members_over_cart_limit FROM ( SELECT member_id FROM carts @@ -84,7 +76,7 @@ FROM ( GROUP BY member_id HAVING count(*) > 10 ) g; --- current_enrollment가 정원을 넘긴 강의가 있으면 안 된다. 넘겼다면 신청 API가 항상 마감으로 실패한다. +-- 정원을 넘긴 강의가 있으면 신청 API가 항상 마감으로 실패한다. 0이어야 한다. SELECT count(*) AS courses_over_capacity FROM courses WHERE id BETWEEN @course_start AND @course_start + @course_count - 1 diff --git a/.claude/skills/optimize-performance/template/seeds/member.sql b/.claude/skills/optimize-performance/template/seeds/member.sql index e3733f6..cda1aff 100644 --- a/.claude/skills/optimize-performance/template/seeds/member.sql +++ b/.claude/skills/optimize-performance/template/seeds/member.sql @@ -1,16 +1,15 @@ -- 회원 시드: members -- --- 필요한 변수: @member_start, @member_count, @student_id_start, @pw_hash, @member_dept_count +-- 필요한 변수: @member_start, @member_count, @student_id_start, @pw_hash, @member_dept_count (상한 5) -- --- id와 학번을 명시 삽입하므로 k6 스크립트의 STUDENT_ID_START / USER_COUNT와 그대로 맞아떨어진다. --- 학번은 9자리여야 로그인 요청이 검증을 통과한다(LoginRequest의 ^\d{9}$). --- @pw_hash는 Phase 3-A에서 실제 가입 API로 만든 BCrypt 해시다. 직접 만들어 넣지 마라. +-- id를 명시 삽입한다. mint-tokens.sh의 --start / --count가 이 범위와 맞아야 한다. +-- 이메일은 perf{id}@inu.ac.kr, 학번은 @student_id_start부터 1씩 증가한다. SELECT '[member.sql] members 적재' AS ''; INSERT IGNORE INTO members (id, email, student_id, password, name, - member_college, member_department, member_grade, academic_status, + college, department, grade, academic_status, last_semester_gpa, created_at, updated_at) WITH RECURSIVE seq AS ( SELECT 0 AS n @@ -22,20 +21,19 @@ SELECT @member_start + n, CAST(@student_id_start + n AS CHAR), @pw_hash, CONCAT('perf', n), - -- member_college는 아래 member_department가 속한 단과대학과 맞춰야 의미가 통한다. + -- college와 department는 같은 인덱스로 골라 단과대학-학과 쌍이 맞는다. ELT(1 + (n % @member_dept_count), 'INFORMATION_TECHNOLOGY', 'ENGINEERING', 'NATURAL_SCIENCES', 'BUSINESS', 'COMMERCE_PUBLIC_AFFAIRS'), -- 전공 조회(/api/v1/courses/major)가 이 값을 CourseDepartment로 그대로 변환한다. - -- course.sql이 채우는 course_department 목록과 앞쪽이 겹쳐야 조회 결과가 0건이 아니게 된다. + -- course.sql의 department 목록 앞 5개와 같다. ELT(1 + (n % @member_dept_count), 'COMPUTER_ENGINEERING', 'MECHANICAL_ENGINEERING', 'MATHEMATICS', 'BUSINESS_ADMINISTRATION', 'ECONOMICS'), ELT(1 + (n % 4), 'FRESHMAN', 'SOPHOMORE', 'JUNIOR', 'SENIOR'), - -- 휴학생을 섞으면 학적 상태 분기가 생겼을 때 그대로 쓸 수 있다. + -- 휴학생을 섞어 학적 상태 분기가 생겼을 때 그대로 쓸 수 있게 한다. IF(n % 10 = 0, 'LEAVE_OF_ABSENCE', 'ENROLLED'), - -- 최대 이수 학점이 성적으로 갈린다(4.0↑ 24 / 3.5↑ 21 / 그 외 19). - -- 수강신청 측정에서 학점 상한에 걸리는 회원과 아닌 회원이 섞이게 3구간을 모두 만든다. + -- 최대 이수 학점이 성적으로 갈린다(4.0↑ 24 / 3.5↑ 21 / 그 외 19). 세 구간을 모두 만든다. ELT(1 + (n % 3), 4.2, 3.7, 3.0), NOW() - INTERVAL 200 DAY, NOW() @@ -44,19 +42,13 @@ FROM seq; ANALYZE TABLE members; -- 검증 -SELECT 'members' AS table_name, - count(*) AS rows_seeded, - count(DISTINCT member_department) AS dept_cardinality, - count(DISTINCT last_semester_gpa) AS gpa_cardinality, - min(id) AS min_id, - max(id) AS max_id, - min(student_id) AS min_student_id, - max(student_id) AS max_student_id +SELECT 'members' AS table_name, + count(*) AS rows_seeded, + count(DISTINCT department) AS dept_cardinality, + count(DISTINCT last_semester_gpa) AS gpa_cardinality, + min(id) AS min_id, + max(id) AS max_id, + min(student_id) AS min_student_id, + max(student_id) AS max_student_id FROM members WHERE id BETWEEN @member_start AND @member_start + @member_count - 1; - --- 해시가 제대로 들어갔는지 확인한다. 여기서 어긋나면 Phase 4의 토큰 발급이 통째로 실패한다. -SELECT IF(count(*) = @member_count, 'OK', CONCAT('불일치: ', count(*), '/', @member_count)) AS pw_hash_check -FROM members -WHERE id BETWEEN @member_start AND @member_start + @member_count - 1 - AND password = @pw_hash; diff --git a/src/main/resources/application-perf.yml b/src/main/resources/application-perf.yml new file mode 100644 index 0000000..85939fc --- /dev/null +++ b/src/main/resources/application-perf.yml @@ -0,0 +1,89 @@ +# 성능 측정 전용 프로파일. 원본은 .claude/skills/optimize-performance/template/application-perf.yml 이다. +# +# 목적은 하나다. 측정값을 왜곡하는 요소를 전부 끄는 것. 값을 바꿀 때는 주석의 이유를 먼저 읽어라. +# +# 기동: ./gradlew bootRun --args='--spring.profiles.active=perf' +# 선행: docker-compose -f docker/docker-compose-local.yml up -d mysql + +spring: + application: + name: uss-server-perf + + datasource: + # docker-compose-local.yml의 mysql. 호스트 포트는 3307이다. + url: jdbc:mysql://127.0.0.1:3307/uss_db?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul&rewriteBatchedStatements=true + username: root + password: root + # p6spy 드라이버를 쓰지 마라. 쿼리마다 프록시 한 겹의 오버헤드가 측정값에 들어간다. + driver-class-name: com.mysql.cj.jdbc.Driver + + hikari: + # Phase 3-B에서 VU를 정할 때 이 값을 함께 본다. 풀보다 훨씬 큰 VU는 커넥션 대기를 잰다. + # 바꾸면 record.md의 측정 환경에 시점과 함께 남긴다. + maximum-pool-size: 10 + minimum-idle: 10 + connection-timeout: 10000 + # 측정 중 커넥션이 재생성되면 그 지연이 응답시간에 섞인다. + max-lifetime: 1800000 + + jpa: + hibernate: + ddl-auto: none + properties: + hibernate: + # SQL 로깅은 요청당 수십 줄을 찍는다. 측정값을 통째로 바꾼다. + format_sql: false + show_sql: false + # 통계 수집도 오버헤드다. 쿼리 관측은 performance_schema로 한다. + generate_statistics: false + show-sql: false + # 뷰 렌더링이 없다. 열려 있으면 커넥션 점유 구간이 실제보다 길어진다. + open-in-view: false + + flyway: + enabled: true + baseline-on-migrate: true + locations: + - classpath:database/migration + - classpath:database/seed + +inu: + course-api: + # InuCourseApiProperties가 바인딩한다. 연계 API는 IP 화이트리스트라 로컬에서 호출되지 않는다. 기동에 필요한 자리만 채운다. + base-url: http://localhost/unused-in-perf-profile + auth-key: unused + mod-date: "20260101" + +security: + jwt: + # 로컬 측정 전용 키. perf-env.sh가 이 값을 읽어 토큰을 만든다. 바꾸면 토큰도 다시 만든다. 32바이트 이상. + secret-key: perf-only-local-secret-key-not-for-any-real-environment + # JwtProvider 생성자가 요구하는 두 키. 측정 중 만료되면 401이 섞이므로 넉넉히 잡는다. + access-token-expiration-time: 86400000 + admin-access-token-expiration-time: 86400000 + +management: + server: + # Phase 2의 actuator 확인 명령이 8081을 본다. + port: 8081 + endpoints: + web: + exposure: + include: health,prometheus + metrics: + tags: + application: uss-server-perf + distribution: + # 없으면 Phase 2의 히스토그램 버킷 확인이 0으로 나온다. + percentiles-histogram: + http.server.requests: true + slo: + http.server.requests: 100ms,200ms,500ms,1s,2s,5s + +logging: + level: + root: WARN + uss.code: WARN + org.hibernate.SQL: OFF + org.hibernate.orm.jdbc.bind: OFF + p6spy: OFF