Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
128 changes: 128 additions & 0 deletions docs/perf/seed.sql
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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);
Loading