From 2a0ca927d2d3285b7ad189fde8985de37826e033 Mon Sep 17 00:00:00 2001 From: juhanByeon Date: Fri, 8 May 2026 10:02:12 +0900 Subject: [PATCH] =?UTF-8?q?#=20perf(remittance):=20user=20=EC=B5=9C?= =?UTF-8?q?=EC=8B=A0=EC=88=9C=20=EC=A1=B0=ED=9A=8C=20=EC=9D=B8=EB=8D=B1?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80=20+=20=EC=B8=A1=EC=A0=95=20?= =?UTF-8?q?=EC=8B=9C=EB=93=9C/=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Summary `remittances` 사용자별 최신순 조회 쿼리(`WHERE user_id = ? ORDER BY created_at DESC`)의 filesort 비용을 줄이기 위해 `(user_id, created_at)` 복합 인덱스를 추가했습니다. 또한 재현 가능한 성능 측정을 위해 대량 시드 SQL을 추가하고, README에 BEFORE/AFTER 실행 계획과 튜닝 근거를 문서화했습니다. --- ### Changes #### 1) 인덱스 마이그레이션 추가 - `remittance-api/src/main/resources/db/migration/V6__index_tuning.sql` (신규) - `ALTER TABLE remittances ADD INDEX idx_remittances_user_created (user_id, created_at);` - 기존 `idx_remittances_user_status (user_id, status)`는 유지 #### 2) 성능 측정용 시드 스크립트 추가 - `docs/perf/seed.sql` (신규) - 측정용 대량 데이터 적재 스크립트 추가 - 규모: - users 10,000 - wallets 10,000 - remittances 500,000 - 측정 후 정리 SQL 포함 #### 3) README 튜닝 사례 문서화 - `README.md` - `remittances` 사용자 최신순 조회 인덱스 튜닝 섹션 추가 - BEFORE/AFTER `EXPLAIN`, `EXPLAIN ANALYZE` 결과 및 해석 추가 - `(user_id, status)`와 `(user_id, created_at)`를 분리 유지하는 이유 명시 --- ### Checklist - [x] `remittances` 최신순 조회용 인덱스 추가 - [x] 성능 측정 재현용 시드 SQL 추가 - [x] 튜닝 근거 및 실행 계획 문서화 --- ### How to Test ```bash # 1) 마이그레이션 적용 ./gradlew :remittance-api:bootRun # 2) 측정용 시드 적재 docker exec -i openremit-mysql mysql -uroot -prootpw openremit < docs/perf/seed.sql # 3) 실행 계획 확인 docker exec -it openremit-mysql mysql -uroot -prootpw openremit -e " EXPLAIN ANALYZE SELECT * FROM remittances WHERE user_id = ORDER BY created_at DESC; " --- README.md | 62 +++++++++ docs/perf/seed.sql | 128 ++++++++++++++++++ .../db/migration/V6__index_tuning.sql | 14 ++ 3 files changed, 204 insertions(+) create mode 100644 docs/perf/seed.sql create mode 100644 remittance-api/src/main/resources/db/migration/V6__index_tuning.sql diff --git a/README.md b/README.md index d24cc19..a74250b 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,68 @@ mismatch 가 발견되어도 잡 자체는 SUCCESS 로 종료합니다. `reconci ./gradlew :reconciler:bootRun # 매일 04:00 KST 자동 (openremit.reconcile.cron 으로 변경) ``` +## 인덱스 튜닝 사례 — `remittances` 사용자별 최신순 조회 + +대상 쿼리: `RemittanceRepository.findByUserIdOrderByCreatedAtDesc(userId)` + +```sql +SELECT * FROM remittances WHERE user_id = ? ORDER BY created_at DESC; +``` + +측정 환경: MySQL 8 / 사용자 10,000명 × 송금 50건 = `remittances` 500,000행 (시드: `docs/perf/seed.sql`). + +**BEFORE — `idx_remittances_user_status (user_id, status)` 만 존재** + +``` +EXPLAIN: type=ref key=idx_remittances_user_status rows=50 Extra=Using filesort + +EXPLAIN ANALYZE: + -> Sort: remittances.created_at DESC (cost=17.5 rows=50) + (actual time=0.10..0.43 rows=50 loops=1) + -> Index lookup on remittances using idx_remittances_user_status (user_id=…) + (cost=17.5 rows=50) (actual time=0.018..0.243 rows=50 loops=1) +``` + +`(user_id, status)` 인덱스는 user_id 필터까지만 도와주고 `ORDER BY created_at` 단계에서 **별도 정렬(filesort)** 이 발생합니다. + +**AFTER — V6 마이그레이션으로 `(user_id, created_at)` 인덱스 추가** + +```sql +ALTER TABLE remittances ADD INDEX idx_remittances_user_created (user_id, created_at); +``` + +``` +EXPLAIN: type=ref key=idx_remittances_user_created rows=50 Extra=Backward index scan + +EXPLAIN ANALYZE: + -> Index lookup on remittances using idx_remittances_user_created (user_id=…) (reverse) + (cost=17.5 rows=50) (actual time=0.010..0.292 rows=50 loops=1) +``` + +옵티마이저가 새 인덱스를 선택해 **plan에서 Sort 노드 자체가 제거**됩니다. `created_at`을 인덱스 정의에 ASC로 남기고 InnoDB의 backward index scan으로 DESC 요구를 그대로 처리합니다. + +| 관점 | BEFORE | AFTER | +|---|---|---| +| Plan 노드 | Sort + Index lookup (2단계) | Index lookup (reverse) (1단계) | +| 정렬 비용 | filesort (사용자당 N건 정렬) | 0 (인덱스가 이미 정렬) | +| 시간 복잡도 | O(N log N) | O(N) | +| 측정 시간 (50건) | 0.10 ~ 0.43 ms | 0.05 ~ 0.29 ms | + +50행 정렬은 절대 비용이 작아 시간 차이는 마이크로초 단위지만, **사용자당 송금 건수 N이 커질수록 격차가 비선형으로 벌어집니다** (filesort N log N vs 인덱스 스캔 N). + +**왜 기존 `(user_id, status)` 인덱스를 유지하는가** + +두 인덱스는 워크로드가 다릅니다. + +| 쿼리 | 옵티마이저가 고르는 인덱스 | +|---|---| +| `WHERE user_id=? ORDER BY created_at DESC` | `idx_remittances_user_created` (이번에 추가) | +| `WHERE user_id=? AND status=?` (정렬 없음) | `idx_remittances_user_status` (기존) | + +`(user_id, status, created_at)` 한 개로 둘을 묶는 안도 있지만 status enum 카디널리티가 6에 불과해 prefix 가치가 떨어집니다. 워크로드별로 인덱스를 분리하는 편이 더 명확합니다. + +자세한 표·전체 EXPLAIN 출력은 [`docs/04-erd.md`](../docs/04-erd.md#인덱스-튜닝-사례--remittances-사용자별-최신순-조회) 참고. + ## 테스트 ```bash diff --git a/docs/perf/seed.sql b/docs/perf/seed.sql new file mode 100644 index 0000000..b962b2e --- /dev/null +++ b/docs/perf/seed.sql @@ -0,0 +1,128 @@ +-- Day 11 인덱스 튜닝 측정용 시드 데이터 +-- 운영 마이그레이션이 아니라 측정 전용. Flyway에 포함되지 않음. +-- +-- 사용법 (root 권한 필요 — sql_log_bin 제어): +-- docker exec -i openremit-mysql mysql -uroot -prootpw openremit < OpenRemit/docs/perf/seed.sql +-- +-- 결과: +-- users +10,000 (email LIKE 'seed-user-%@example.com') +-- wallets +10,000 (시드 유저당 KRW 지갑 1개) +-- remittances +500,000 (시드 유저당 50건, 균등 분배) +-- +-- 주의: +-- sql_log_bin=0 으로 적재하므로 Debezium에는 이 데이터가 보이지 않습니다. +-- 측정 후 정리: +-- docker exec openremit-mysql mysql -uroot -prootpw openremit -e " +-- SET sql_log_bin = 0; +-- DELETE FROM remittances WHERE user_id IN (SELECT id FROM users WHERE email LIKE 'seed-user-%@example.com'); +-- DELETE FROM wallets WHERE user_id IN (SELECT id FROM users WHERE email LIKE 'seed-user-%@example.com'); +-- DELETE FROM users WHERE email LIKE 'seed-user-%@example.com';" + +SET @@SESSION.sql_log_bin = 0; +SET @@SESSION.foreign_key_checks = 0; +SET @@SESSION.unique_checks = 0; +SET @@SESSION.cte_max_recursion_depth = 1000000; + +-- 1) 시퀀스 임시 테이블 — 1..500000 dense +DROP TABLE IF EXISTS _seq; +CREATE TABLE _seq (n BIGINT NOT NULL PRIMARY KEY) ENGINE=InnoDB; +INSERT INTO _seq (n) +WITH RECURSIVE r(i) AS ( + SELECT 1 UNION ALL SELECT i + 1 FROM r WHERE i < 500000 +) +SELECT i FROM r; + +-- 2) USERS — 10,000명 +INSERT INTO users (email, password_hash, name) +SELECT + CONCAT('seed-user-', n, '@example.com'), + '$2a$10$dummyhashdummyhashdummyhashdummyhashdummyhashdumm', + CONCAT('Seed User ', n) +FROM _seq +WHERE n <= 10000; + +SET @user_min := (SELECT MIN(id) FROM users WHERE email LIKE 'seed-user-%@example.com'); + +-- 3) WALLETS — 시드 유저당 KRW 지갑 1개 +INSERT INTO wallets (user_id, currency, balance, version) +SELECT id, 'KRW', 1000000.0000, 0 +FROM users +WHERE email LIKE 'seed-user-%@example.com'; + +-- 4) REMITTANCES — 100,000건씩 5회 chunk INSERT (redo log 회수 유도) +-- user_id 분배: @user_min + (n % 10000) → 사용자당 정확히 50건 +-- created_at 분포: 결정적 큰-소수 곱으로 1년 범위에 의사난수 분산 +INSERT INTO remittances + (user_id, from_currency, from_amount, to_currency, to_amount, fx_rate, + receiver_name, receiver_account, status, created_at, updated_at) +SELECT + @user_min + (n % 10000), + 'KRW', 10000.0000, + 'USD', 7.5000, 0.00075000, + CONCAT('Receiver ', n), CONCAT('ACC-', n), + ELT(1 + (n % 6), 'REQUESTED', 'PAID', 'PROCESSING', 'COMPLETED', 'FAILED', 'CANCELLED'), + DATE_SUB(NOW(6), INTERVAL ((n * 1234577) MOD 31536000) SECOND), + DATE_SUB(NOW(6), INTERVAL ((n * 1234577) MOD 31536000) SECOND) +FROM _seq WHERE n BETWEEN 1 AND 100000; + +INSERT INTO remittances + (user_id, from_currency, from_amount, to_currency, to_amount, fx_rate, + receiver_name, receiver_account, status, created_at, updated_at) +SELECT + @user_min + (n % 10000), + 'KRW', 10000.0000, + 'USD', 7.5000, 0.00075000, + CONCAT('Receiver ', n), CONCAT('ACC-', n), + ELT(1 + (n % 6), 'REQUESTED', 'PAID', 'PROCESSING', 'COMPLETED', 'FAILED', 'CANCELLED'), + DATE_SUB(NOW(6), INTERVAL ((n * 1234577) MOD 31536000) SECOND), + DATE_SUB(NOW(6), INTERVAL ((n * 1234577) MOD 31536000) SECOND) +FROM _seq WHERE n BETWEEN 100001 AND 200000; + +INSERT INTO remittances + (user_id, from_currency, from_amount, to_currency, to_amount, fx_rate, + receiver_name, receiver_account, status, created_at, updated_at) +SELECT + @user_min + (n % 10000), + 'KRW', 10000.0000, + 'USD', 7.5000, 0.00075000, + CONCAT('Receiver ', n), CONCAT('ACC-', n), + ELT(1 + (n % 6), 'REQUESTED', 'PAID', 'PROCESSING', 'COMPLETED', 'FAILED', 'CANCELLED'), + DATE_SUB(NOW(6), INTERVAL ((n * 1234577) MOD 31536000) SECOND), + DATE_SUB(NOW(6), INTERVAL ((n * 1234577) MOD 31536000) SECOND) +FROM _seq WHERE n BETWEEN 200001 AND 300000; + +INSERT INTO remittances + (user_id, from_currency, from_amount, to_currency, to_amount, fx_rate, + receiver_name, receiver_account, status, created_at, updated_at) +SELECT + @user_min + (n % 10000), + 'KRW', 10000.0000, + 'USD', 7.5000, 0.00075000, + CONCAT('Receiver ', n), CONCAT('ACC-', n), + ELT(1 + (n % 6), 'REQUESTED', 'PAID', 'PROCESSING', 'COMPLETED', 'FAILED', 'CANCELLED'), + DATE_SUB(NOW(6), INTERVAL ((n * 1234577) MOD 31536000) SECOND), + DATE_SUB(NOW(6), INTERVAL ((n * 1234577) MOD 31536000) SECOND) +FROM _seq WHERE n BETWEEN 300001 AND 400000; + +INSERT INTO remittances + (user_id, from_currency, from_amount, to_currency, to_amount, fx_rate, + receiver_name, receiver_account, status, created_at, updated_at) +SELECT + @user_min + (n % 10000), + 'KRW', 10000.0000, + 'USD', 7.5000, 0.00075000, + CONCAT('Receiver ', n), CONCAT('ACC-', n), + ELT(1 + (n % 6), 'REQUESTED', 'PAID', 'PROCESSING', 'COMPLETED', 'FAILED', 'CANCELLED'), + DATE_SUB(NOW(6), INTERVAL ((n * 1234577) MOD 31536000) SECOND), + DATE_SUB(NOW(6), INTERVAL ((n * 1234577) MOD 31536000) SECOND) +FROM _seq WHERE n BETWEEN 400001 AND 500000; + +DROP TABLE _seq; + +-- 옵티마이저 통계 갱신 +ANALYZE TABLE users, wallets, remittances; + +-- 검증 +SELECT 'users seed' AS t, COUNT(*) AS cnt FROM users WHERE email LIKE 'seed-user-%@example.com' +UNION ALL SELECT 'wallets total', COUNT(*) FROM wallets +UNION ALL SELECT 'remittances total', COUNT(*) FROM remittances; diff --git a/remittance-api/src/main/resources/db/migration/V6__index_tuning.sql b/remittance-api/src/main/resources/db/migration/V6__index_tuning.sql new file mode 100644 index 0000000..714f579 --- /dev/null +++ b/remittance-api/src/main/resources/db/migration/V6__index_tuning.sql @@ -0,0 +1,14 @@ +-- Day 11 인덱스 튜닝 (자세한 사례는 docs/04-erd.md "인덱스 튜닝 사례" 참고) +-- +-- 추가 동기: +-- RemittanceRepository.findByUserIdOrderByCreatedAtDesc(userId) +-- → WHERE user_id = ? ORDER BY created_at DESC +-- 기존 (user_id, status) 인덱스로는 user_id 필터까지만 인덱스가 도와주고 +-- ORDER BY created_at DESC 단계에서 filesort 발생. +-- +-- (user_id, created_at) 만으로는 status 필터 쿼리(idx_remittances_user_status)와 둘 다 필요. +-- 그래서 기존 인덱스를 유지하고 (user_id, created_at)을 추가한다. +-- created_at 자체를 DESC로 만들 필요는 없다 — InnoDB는 backward index scan 가능. + +ALTER TABLE remittances + ADD INDEX idx_remittances_user_created (user_id, created_at);