From 503a77cf998b1c01454c7757bf391c1b189d9e8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Tue, 10 Mar 2026 17:24:52 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat=20:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/seed-products.sql | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 db/seed-products.sql diff --git a/db/seed-products.sql b/db/seed-products.sql new file mode 100644 index 000000000..3d76d5d32 --- /dev/null +++ b/db/seed-products.sql @@ -0,0 +1,36 @@ +-- ============================================================ +-- 상품 더미 데이터 시드 스크립트 (10만 건) +-- 실행 전제: 앱을 먼저 실행해서 테이블이 생성된 상태여야 합니다. +-- 실행 방법: mysql -u application -papplication loopers < db/seed-products.sql +-- ============================================================ + +-- 브랜드 10개 삽입 +INSERT INTO brand (name, description, created_at, updated_at) +VALUES + ('나이키', '글로벌 스포츠 브랜드', NOW(), NOW()), + ('아디다스', '독일 스포츠 브랜드', NOW(), NOW()), + ('뉴발란스', '미국 스포츠 브랜드', NOW(), NOW()), + ('컨버스', '캐주얼 스니커즈 브랜드', NOW(), NOW()), + ('반스', '스케이트 브랜드', NOW(), NOW()), + ('푸마', '독일 스포츠 브랜드', NOW(), NOW()), + ('리복', '영국 스포츠 브랜드', NOW(), NOW()), + ('언더아머', '미국 퍼포먼스 브랜드', NOW(), NOW()), + ('살로몬', '아웃도어 브랜드', NOW(), NOW()), + ('노스페이스','아웃도어 브랜드', NOW(), NOW()); + +-- 상품 10만 건 삽입 (cross join으로 빠르게 생성) +SET @i = 0; + +INSERT INTO product (name, ref_brand_id, price, stock, like_count, created_at, updated_at) +SELECT + CONCAT('상품_', @i := @i + 1), + FLOOR(1 + RAND() * 10), + FLOOR(1 + RAND() * 100) * 1000, + FLOOR(RAND() * 500), + FLOOR(RAND() * 1000), + DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 365) DAY), + NOW() +FROM + information_schema.columns a, + information_schema.columns b +LIMIT 100000; From 70e50af6022d25208d9b9718c1cd2ebb352e514d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Tue, 10 Mar 2026 17:24:52 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat=20:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/seed-products.sql | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 db/seed-products.sql diff --git a/db/seed-products.sql b/db/seed-products.sql new file mode 100644 index 000000000..3d76d5d32 --- /dev/null +++ b/db/seed-products.sql @@ -0,0 +1,36 @@ +-- ============================================================ +-- 상품 더미 데이터 시드 스크립트 (10만 건) +-- 실행 전제: 앱을 먼저 실행해서 테이블이 생성된 상태여야 합니다. +-- 실행 방법: mysql -u application -papplication loopers < db/seed-products.sql +-- ============================================================ + +-- 브랜드 10개 삽입 +INSERT INTO brand (name, description, created_at, updated_at) +VALUES + ('나이키', '글로벌 스포츠 브랜드', NOW(), NOW()), + ('아디다스', '독일 스포츠 브랜드', NOW(), NOW()), + ('뉴발란스', '미국 스포츠 브랜드', NOW(), NOW()), + ('컨버스', '캐주얼 스니커즈 브랜드', NOW(), NOW()), + ('반스', '스케이트 브랜드', NOW(), NOW()), + ('푸마', '독일 스포츠 브랜드', NOW(), NOW()), + ('리복', '영국 스포츠 브랜드', NOW(), NOW()), + ('언더아머', '미국 퍼포먼스 브랜드', NOW(), NOW()), + ('살로몬', '아웃도어 브랜드', NOW(), NOW()), + ('노스페이스','아웃도어 브랜드', NOW(), NOW()); + +-- 상품 10만 건 삽입 (cross join으로 빠르게 생성) +SET @i = 0; + +INSERT INTO product (name, ref_brand_id, price, stock, like_count, created_at, updated_at) +SELECT + CONCAT('상품_', @i := @i + 1), + FLOOR(1 + RAND() * 10), + FLOOR(1 + RAND() * 100) * 1000, + FLOOR(RAND() * 500), + FLOOR(RAND() * 1000), + DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 365) DAY), + NOW() +FROM + information_schema.columns a, + information_schema.columns b +LIMIT 100000; From c3353fa011cdc558bacc71ee4faceee70fd73b58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Fri, 13 Mar 2026 02:05:21 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat=20:=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/infrastructure/product/ProductEntity.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java index 4c572d649..800b8466a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java @@ -4,6 +4,7 @@ import com.loopers.domain.product.Product; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.Index; import jakarta.persistence.Table; import lombok.NoArgsConstructor; import org.hibernate.annotations.Comment; @@ -12,7 +13,10 @@ * Product DB 엔티티 */ @Entity -@Table(name = "product") +@Table(name = "product", indexes = { + @Index(name = "idx_product_brand_like", columnList = "ref_brand_id, like_count DESC"), + @Index(name = "idx_product_like", columnList = "like_count DESC") +}) @NoArgsConstructor public class ProductEntity extends BaseEntity { From 403fc3f44b8c68439633f62186ca83c8318aa7c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Fri, 13 Mar 2026 02:05:47 +0900 Subject: [PATCH 4/9] =?UTF-8?q?docs=20:=20=EC=84=B1=EB=8A=A5=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B2=B0=EA=B3=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/index-performance-test.md | 361 +++++++++++++++++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 docs/index-performance-test.md diff --git a/docs/index-performance-test.md b/docs/index-performance-test.md new file mode 100644 index 000000000..9cd0594a3 --- /dev/null +++ b/docs/index-performance-test.md @@ -0,0 +1,361 @@ +# 상품 조회 인덱스 성능 테스트 + +## 1. 문제 인식 + +### 현재 상황 + +상품 목록 API에서 **브랜드 필터 + 좋아요 순 정렬** 기능을 제공하고 있다. + +```sql +SELECT * FROM product +WHERE ref_brand_id = ? +ORDER BY like_count DESC +LIMIT 10; +``` + +### 의문점 + +현재 `product` 테이블에 PRIMARY KEY만 있는 상태에서 EXPLAIN을 실행해봤다. + +``` +type: ALL (풀 테이블 스캔) +rows: 99,694 +Extra: Using where; Using filesort +``` + +**10만 개 전체를 스캔하고 있다.** 인덱스가 필요해 보인다. + +--- + +## 2. 어떤 인덱스를 추가해야 할까? + +### 후보 인덱스 + +| 인덱스 | 이유 | +|--------|------| +| `ref_brand_id` | WHERE 조건에 사용됨 | +| `like_count DESC` | ORDER BY에 사용됨 | +| `(ref_brand_id, like_count DESC)` | 둘 다 커버 | + +### 첫 번째 질문: 무조건 복합 인덱스가 좋을까? + +복합 인덱스가 WHERE + ORDER BY를 모두 커버하니까 무조건 좋을 것 같았다. + +하지만 고민이 생겼다: +- 복합 인덱스는 크기가 더 크다 +- 쓰기 성능에 영향을 줄 수 있다 +- 단일 인덱스로 충분하다면 굳이? + +**→ 실제로 테스트해서 비교해보자.** + +### 두 번째 질문: 데이터 분포가 중요할까? + +"브랜드당 상품 수"가 다르면 인덱스 효과가 달라질까? + +| 상황 | 예시 | +|------|------| +| 대형 브랜드 소수 | 나이키, 아디다스만 있고 각각 상품 1만개 | +| 소형 브랜드 다수 | 셀러 1000명이 각각 상품 100개씩 | + +**→ 데이터 분포별로 테스트해보자.** + +--- + +## 3. 테스트 설계 + +### 테스트 환경 +- MySQL 8.x (Docker) +- 총 상품 수: 100,000개 + +### 테스트 케이스 + +**데이터 분포:** + +| 케이스 | 브랜드 수 | 브랜드당 상품 | 시나리오 | +|-------|---------|-------------|---------| +| A | 10 | 10,000 | 대형 브랜드 소수 | +| B | 100 | 1,000 | 중형 브랜드 | +| C | 1,000 | 100 | 소형 브랜드 다수 | + +**인덱스 조합:** 4가지 (없음, ref_brand_id, like_count, 복합) + +**쿼리 패턴:** +- Q1: 브랜드 필터 + 좋아요순 +- Q2: 전체 좋아요순 (브랜드 필터 없음) + +왜 Q2도 테스트할까? → API에서 `brandId`가 선택적 파라미터라서 둘 다 사용될 수 있다. + +--- + +## 4. 테스트 결과 + +### Q1: 브랜드 필터 + 좋아요순 (단위: ms) + +| 인덱스 | 케이스A (10브랜드) | 케이스B (100브랜드) | 케이스C (1000브랜드) | +|--------|-------------------|--------------------|--------------------| +| 없음 | 29.2 | 20.4 | 27.3 | +| ref_brand_id | 21.8 | 1.01 | **0.17** | +| like_count | **0.28** | 0.58 | 6.27 | +| 복합 | **0.21** | **0.37** | **0.02** | + +### Q2: 전체 좋아요순 (단위: ms) + +| 인덱스 | 케이스A | 케이스B | 케이스C | +|--------|--------|--------|--------| +| 없음 | 33.0 | 34.7 | 30.7 | +| ref_brand_id | 32.1 | 29.6 | 30.1 | +| like_count | **0.05** | **0.05** | **0.05** | +| 복합 | 33.6 | 21.4 | 33.2 | + +--- + +## 5. 예상과 다른 결과들 + +### 발견 1: 데이터 분포에 따라 최적 인덱스가 달라진다 + +**케이스 A (브랜드 10개, 브랜드당 10,000개)** +- `like_count` 인덱스: 0.28ms ✅ +- `ref_brand_id` 인덱스: 21.8ms ❌ + +**케이스 C (브랜드 1000개, 브랜드당 100개)** +- `like_count` 인덱스: 6.27ms ❌ +- `ref_brand_id` 인덱스: 0.17ms ✅ + +**왜 이런 차이가 날까?** + +`like_count` 인덱스 동작 방식: +``` +좋아요 순으로 스캔하면서 해당 브랜드 찾기 +``` + +- 브랜드당 상품 많음 (10,000개) → 금방 찾음 (92개 스캔) +- 브랜드당 상품 적음 (100개) → 오래 걸림 (10,768개 스캔!) + +**EXPLAIN ANALYZE 결과:** + +케이스 A - like_count 인덱스: +``` +Index scan by like_count (92 rows) + → Filter by brand_id (10 rows) + → Limit 10 +⏱️ 0.28ms +``` + +케이스 C - like_count 인덱스: +``` +Index scan by like_count (10,768 rows) ← 10개 찾으려고 10,768개 스캔! + → Filter by brand_id (10 rows) + → Limit 10 +⏱️ 6.27ms +``` + +### 발견 2: 복합 인덱스는 데이터 분포와 무관하게 안정적 + +| 케이스 | 복합 인덱스 | +|-------|-----------| +| A | 0.21ms | +| B | 0.37ms | +| C | 0.02ms | + +모든 케이스에서 0.02 ~ 0.37ms로 안정적이다. + +``` +Index lookup by brand_id + like_count (10 rows) + → Limit 10 +⏱️ 0.02ms +``` + +인덱스에서 바로 10개만 가져온다. 필터링이나 정렬이 필요 없다. + +### 발견 3: 복합 인덱스는 Q2에 사용 불가 + +Q2 (전체 좋아요순)에서 복합 인덱스가 있어도: +``` +Table scan (100,000 rows) + → Sort by like_count + → Limit 10 +⏱️ 33.6ms +``` + +복합 인덱스 `(ref_brand_id, like_count)`는 `ref_brand_id` 조건이 없으면 사용할 수 없다. + +--- + +## 6. 추가 질문: 페이지네이션은 괜찮을까? + +인덱스가 있으면 깊은 페이지(OFFSET이 큰 경우)도 빠를까? + +### 테스트 + +```sql +SELECT * FROM product ORDER BY like_count DESC LIMIT 10 OFFSET ?; +``` + +### 결과 + +| OFFSET | 페이지 | 인덱스 없음 | like_count 인덱스 | +|--------|-------|-----------|------------------| +| 0 | 1 | 28.1ms | **0.27ms** ✅ | +| 1,000 | 101 | 34.0ms | 33.9ms ❌ | +| 10,000 | 1,001 | 34.8ms | 42.6ms ❌ | + +### 예상과 다른 결과 + +OFFSET이 커지면 **인덱스가 있어도 풀 스캔**을 한다! + +OFFSET 0: +``` +Index scan by like_count (10 rows) + → Limit 10 +⏱️ 0.27ms +``` + +OFFSET 1000: +``` +Table scan (100,000 rows) + → Sort by like_count (1,010 rows) + → Skip 1,000 rows + → Limit 10 +⏱️ 33.9ms +``` + +**왜?** MySQL 옵티마이저가 "어차피 1000개 건너뛰려면 많이 읽어야 하니까 풀 스캔이 낫다"고 판단한다. + +### 결론 + +**인덱스로는 깊은 페이지네이션 문제를 해결할 수 없다.** + +대안: +- 커서 기반 페이지네이션: `WHERE like_count < ? LIMIT 10` +- 페이지 수 제한: 최대 100페이지까지만 허용 + +--- + +## 7. 추가 질문: 쓰기 성능은 괜찮을까? + +인덱스를 추가하면 좋아요 증가(UPDATE) 시 느려지지 않을까? + +### 테스트 + +```sql +UPDATE product SET like_count = like_count + 1 WHERE id = ?; +-- 1000회 반복 +``` + +### 결과 + +| 인덱스 | 1000회 UPDATE | 평균/회 | 성능 저하 | +|-------|--------------|--------|----------| +| 없음 | 604ms | 0.60ms | - | +| like_count | 689ms | 0.69ms | **14% 느림** | +| like_count + 복합 | 752ms | 0.75ms | **25% 느림** | + +### 분석 + +인덱스 없음: +``` +UPDATE data +⏱️ 0.60ms/회 +``` + +인덱스 2개: +``` +UPDATE data + → UPDATE idx_like_count (인덱스 재정렬) + → UPDATE idx_brand_like (인덱스 재정렬) +⏱️ 0.75ms/회 +``` + +### 결론 + +- 인덱스 2개 추가 시 쓰기 25% 느려짐 +- 일반적인 서비스에서는 감수할 만한 수준 +- 초당 수천 건의 좋아요가 발생하면 Redis 캐시나 비동기 처리 고려 필요 + +--- + +## 8. 최종 결론 + +### 질문에 대한 답 + +| 질문 | 답 | +|------|-----| +| 무조건 복합 인덱스가 좋을까? | Q1에는 최적이지만, Q2에는 사용 불가 | +| 데이터 분포가 중요할까? | 단일 인덱스 선택 시 매우 중요, 복합 인덱스는 무관 | +| 페이지네이션도 빨라질까? | 첫 페이지만 빠름, 깊은 페이지는 인덱스 무용지물 | +| 쓰기 성능은 괜찮을까? | 25% 느려지지만 감수할 만함 | + +### 인덱스 선택 + +| 쿼리 패턴 | 추천 인덱스 | +|----------|------------| +| Q1만 사용 | `(ref_brand_id, like_count DESC)` 복합 | +| Q2만 사용 | `(like_count DESC)` 단일 | +| 둘 다 사용 | **두 인덱스 모두 필요** | + +### 적용할 인덱스 + +```sql +-- Q1 커버: 브랜드 필터 + 좋아요순 +CREATE INDEX idx_brand_like ON product(ref_brand_id, like_count DESC); + +-- Q2 커버: 전체 좋아요순 +CREATE INDEX idx_like_count ON product(like_count DESC); +``` + +### 남은 고려사항 + +- 깊은 페이지네이션은 커서 기반으로 변경 검토 +- 좋아요가 매우 빈번하면 Redis 캐시 검토 + +--- + +## 9. 복합 인덱스의 단점 + +복합 인덱스가 항상 정답은 아니다. 단점도 알아야 올바른 선택을 할 수 있다. + +### 9-1. 재사용성(범용성)이 낮음 + +복합 인덱스는 컬럼의 순서가 생명이다. `(ref_brand_id, like_count DESC)` 인덱스를 예로 들면: + +- **사용 가능 ✅**: `brand_id`로 검색할 때, 또는 `brand_id`로 검색하고 `like_count`로 정렬할 때 +- **사용 불가 ❌**: `like_count`로만 검색하거나 정렬할 때 + +복합 인덱스의 뒷순서 컬럼만 단독으로 쓰면 DB는 이 인덱스를 아예 사용하지 못하고 풀 스캔을 한다. 반면 `like_count` 단일 인덱스는 어떤 쿼리에서도 `like_count`가 들어가면 제 역할을 한다. + +### 9-2. 관리 비용과 디스크 공간 소모 + +인덱스는 결국 '별도의 복사본 장부'다. + +- **크기**: 단일 인덱스는 컬럼 하나만 복사하지만, 복합 인덱스는 지정한 모든 컬럼을 복사한다. 2개, 3개 컬럼이 묶일수록 인덱스 파일의 크기가 커지고, 이는 곧 DB 전체의 저장 공간 부담으로 이어진다. +- **메모리 효율**: DB는 성능을 위해 인덱스를 메모리(Buffer Pool)에 올려두고 싶어 하는데, 인덱스가 무거워지면 메모리에 다 안 올라가서 오히려 성능이 떨어지는 지점이 생긴다. + +### 9-3. 쓰기(CUD) 성능의 더 큰 저하 + +앞서 테스트에서 인덱스 2개일 때 업데이트 성능이 25% 저하되는 걸 확인했다. + +복합 인덱스는 묶여 있는 컬럼 중 어느 하나만 수정되어도 **인덱스의 해당 위치를 찾아 재배치**해야 한다. 특히 `like_count`처럼 값이 빈번하게 변하는 컬럼이 복합 인덱스에 포함되어 있으면, 수정이 일어날 때마다 DB가 해야 할 일이 단일 인덱스일 때보다 훨씬 많아진다. + +### 9-4. 설계의 난이도 (선택과 집중) + +단일 인덱스는 그냥 필요한 컬럼에 걸면 끝이지만, 복합 인덱스는 **"어떤 컬럼을 앞에 둘 것인가?"**를 치열하게 고민해야 한다. + +순서를 잘못 설계하면 인덱스를 만들어 놓고도 쓰지 못하는 '죽은 인덱스'가 되기 십상이다. 보통은 **카디널리티가 높은(중복도가 낮은) 컬럼을 앞에 둔다**. 왜냐하면 첫 번째 컬럼에서 대부분 걸러져서 뒤쪽 컬럼까지 탐색할 데이터가 줄어들기 때문이다. + +### 9-5. 그럼에도 복합 인덱스를 쓰는 이유 + +복합 인덱스를 쓰는 이유는, **WHERE + ORDER BY를 한 번에 커버해서 filesort를 피할 수 있기 때문**이다. 앞서 테스트에서 복합 인덱스가 모든 데이터 분포에서 안정적으로 0.02~0.37ms를 기록한 것이 그 증거다. + +### 테스트 결과와 연결 + +Q2(전체 좋아요순) 결과를 다시 보면: + +> 복합 인덱스는 Q2에 사용 불가 (풀 스캔 발생) + +바로 이 점이 가장 큰 단점이다. 복합 인덱스 `(brand, like)`만 믿고 `like` 단일 인덱스를 안 만들면, 전체 순위 페이지를 보여줄 때 시스템이 멈출 수도 있다. + +### 실무 전략 + +1. 가장 많이 쓰이는 복합 쿼리용으로 **복합 인덱스 하나** +2. 각각의 컬럼이 단독으로 쓰일 가능성이 높다면 **단일 인덱스 추가** +3. 단, 너무 많이 만들면 쓰기 성능이 망가지니 **트레이드오프를 계산해서 최소한으로 유지** From efd7b9a2848f14bd4bcc177634ab7670cc90b110 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Fri, 13 Mar 2026 14:32:28 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat=20:=20=EC=BA=90=EC=8B=9C=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/product/ProductFacade.java | 56 ++++++----- .../product/ProductCacheStore.java | 98 +++++++++++++++++++ 2 files changed, 132 insertions(+), 22 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheStore.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index ec392a2fb..fefacacbe 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -6,6 +6,7 @@ import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductSearchCondition; import com.loopers.domain.product.ProductService; +import com.loopers.infrastructure.product.ProductCacheStore; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -20,10 +21,11 @@ public class ProductFacade { private final BrandService brandService; private final ProductService productService; + private final ProductCacheStore productCacheStore; public List createProducts(CreateProductCommand command) { command.products().keySet().forEach(brandService::getById); - + Map domainRequest = new HashMap<>(); command.products().forEach((brandId, item) -> { domainRequest.put(brandId, new CreateProductRequest( @@ -32,7 +34,7 @@ public List createProducts(CreateProductCommand command) { item.stock() )); }); - + List products = productService.createProducts(domainRequest); return products.stream() .map(product -> { @@ -44,29 +46,39 @@ public List createProducts(CreateProductCommand command) { @Transactional(readOnly = true) public ProductInfo getProduct(Long productId) { - Product product = productService.getById(productId); - Brand brand = brandService.getById(product.getRefBrandId()); - return ProductInfo.of(product, brand); + return productCacheStore.getProduct(productId) + .orElseGet(() -> { + Product product = productService.getById(productId); + Brand brand = brandService.getById(product.getRefBrandId()); + ProductInfo info = ProductInfo.of(product, brand); + productCacheStore.putProduct(productId, info); + return info; + }); } @Transactional(readOnly = true) public List getProducts(ProductSearchCommand command) { - if (command.hasBrandId()) { - brandService.getById(command.brandId()); - } - - ProductSearchCondition condition = new ProductSearchCondition( - command.brandId(), - command.sortType(), - command.page(), - command.size() - ); - - return productService.findProducts(condition).stream() - .map(product -> { - Brand brand = brandService.getById(product.getRefBrandId()); - return ProductInfo.of(product, brand); - }) - .toList(); + return productCacheStore.getProducts(command) + .orElseGet(() -> { + if (command.hasBrandId()) { + brandService.getById(command.brandId()); + } + + ProductSearchCondition condition = new ProductSearchCondition( + command.brandId(), + command.sortType(), + command.page(), + command.size() + ); + + List list = productService.findProducts(condition).stream() + .map(product -> { + Brand brand = brandService.getById(product.getRefBrandId()); + return ProductInfo.of(product, brand); + }) + .toList(); + productCacheStore.putProducts(command, list); + return list; + }); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheStore.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheStore.java new file mode 100644 index 000000000..1bd80be9e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheStore.java @@ -0,0 +1,98 @@ +package com.loopers.infrastructure.product; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.product.ProductInfo; +import com.loopers.application.product.ProductSearchCommand; +import com.loopers.config.redis.RedisConfig; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class ProductCacheStore { + + private static final String DETAIL_KEY_PREFIX = "product:detail:"; + private static final String LIST_KEY_PREFIX = "product:list:"; + + private static final Duration DETAIL_TTL = Duration.ofSeconds(180); + private static final Duration LIST_TTL = Duration.ofSeconds(30); + + private final RedisTemplate redisTemplate; + private final RedisTemplate masterRedisTemplate; + private final ObjectMapper objectMapper; + + public ProductCacheStore( + RedisTemplate redisTemplate, + @Qualifier(RedisConfig.REDIS_TEMPLATE_MASTER) RedisTemplate masterRedisTemplate, + ObjectMapper objectMapper + ) { + this.redisTemplate = redisTemplate; + this.masterRedisTemplate = masterRedisTemplate; + this.objectMapper = objectMapper; + } + + public Optional getProduct(Long productId) { + String key = detailKey(productId); + try { + String value = redisTemplate.opsForValue().get(key); + if (value == null) { + return Optional.empty(); + } + return Optional.of(objectMapper.readValue(value, ProductInfo.class)); + } catch (JsonProcessingException e) { + log.warn("Failed to deserialize product cache. key={}", key, e); + return Optional.empty(); + } + } + + public void putProduct(Long productId, ProductInfo productInfo) { + String key = detailKey(productId); + try { + masterRedisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(productInfo), DETAIL_TTL); + } catch (JsonProcessingException e) { + log.warn("Failed to serialize product cache. key={}", key, e); + } + } + + public Optional> getProducts(ProductSearchCommand command) { + String key = listKey(command); + try { + String value = redisTemplate.opsForValue().get(key); + if (value == null) { + return Optional.empty(); + } + return Optional.of(objectMapper.readValue(value, new TypeReference<>() {})); + } catch (JsonProcessingException e) { + log.warn("Failed to deserialize product list cache. key={}", key, e); + return Optional.empty(); + } + } + + public void putProducts(ProductSearchCommand command, List productInfos) { + String key = listKey(command); + try { + masterRedisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(productInfos), LIST_TTL); + } catch (JsonProcessingException e) { + log.warn("Failed to serialize product list cache. key={}", key, e); + } + } + + private String detailKey(Long productId) { + return DETAIL_KEY_PREFIX + productId; + } + + private String listKey(ProductSearchCommand command) { + return LIST_KEY_PREFIX + + "brandId=" + command.brandId() + + ":sort=" + command.sortType() + + ":page=" + command.page() + + ":size=" + command.size(); + } +} From f4b00a330341e3cf4a57780126d1b818cbe3eece Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Fri, 13 Mar 2026 14:33:29 +0900 Subject: [PATCH 6/9] =?UTF-8?q?docs=20:=20=EC=BA=90=EC=8B=9C=20=EC=84=B1?= =?UTF-8?q?=EB=8A=A5=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/cache-performance-test.md | 227 +++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 docs/cache-performance-test.md diff --git a/docs/cache-performance-test.md b/docs/cache-performance-test.md new file mode 100644 index 000000000..35d6952a0 --- /dev/null +++ b/docs/cache-performance-test.md @@ -0,0 +1,227 @@ +# 상품 조회 캐시 성능 테스트 + +## 1. 문제 인식 + +### 현재 상황 + +상품 상세 API와 상품 목록 API는 매 요청마다 DB를 조회한다. + +**상품 상세 조회 (`getProduct`)** +``` +productService.getById(productId) → DB 쿼리 1번 (product) +brandService.getById(brandId) → DB 쿼리 1번 (brand) +``` + +**상품 목록 조회 (`getProducts`)** +``` +productService.findProducts(condition) → DB 쿼리 1번 (products) +brandService.getById(brandId) × N → DB 쿼리 N번 (N+1 문제) +``` + +### 의문점 + +상품 정보(이름, 가격, 브랜드)는 자주 변경되지 않는다. +동일한 상품을 수천 명이 반복 조회할 때, 매번 DB를 거치는 것이 합리적인가? + +**→ Redis 캐시를 도입해서 비교해보자.** + +--- + +## 2. 무엇을 캐시할까? + +### 캐시 전략 결정 + +Cache Aside 패턴을 사용한다. + +``` +읽기: 캐시 확인 → 히트 시 즉시 반환, 미스 시 DB 조회 후 캐시 저장 +쓰기: 캐시를 갱신하지 않음 → TTL 만료로 자연스럽게 갱신 +``` + +### TTL 설계 + +| API | TTL | 이유 | +|-----|-----|------| +| 상품 상세 | 3분 (180s) | 가격/브랜드 정보는 자주 안 바뀜 | +| 상품 목록 | 30초 | 무효화 없이 TTL만 사용하므로 짧게 | + +### 캐시 키 설계 + +``` +상품 상세: product:detail:{productId} +상품 목록: product:list:brandId={brandId}:sort={sortType}:page={page}:size={size} +``` + +--- + +## 3. 테스트 설계 + +### 테스트 환경 + +- Java 21 / Spring Boot 3.4.4 +- MySQL 8.x (Docker, port 3307) +- Redis Master-Replica (Docker, port 6379/6380) +- 총 상품 수: 100,000개 / 브랜드 수: 10개 + +### 측정 방식 + +**캐시 미스 (DB 직접 조회 시뮬레이션)** +- 매번 다른 ID로 요청 → 캐시 저장된 데이터 없음 → 항상 DB 조회 +- 100회 순차 요청 후 평균 측정 + +**캐시 히트** +- Redis FLUSHDB로 초기화 후 동일 ID/파라미터로 반복 요청 +- 첫 요청(미스)으로 캐시 워밍 후 ab로 1,000회 × 동시 10로 측정 + +### 측정 도구 + +- `curl -w "%{time_total}"`: 캐시 미스 개별 응답시간 +- `ab -n 1000 -c 10`: 캐시 히트 처리량 및 평균 응답시간 + +--- + +## 4. 테스트 결과 + +### 상품 상세 조회 `GET /api/v1/products/{productId}` + +| 구분 | 평균 응답시간 | 처리량 | +|------|------------|--------| +| 캐시 미스 (DB 조회) | 15.5ms | ~65 req/s | +| 캐시 히트 (Redis) | **0.7ms** | **1,401 req/s** | + +### 상품 목록 조회 `GET /api/v1/products` + +| 구분 | 평균 응답시간 | 처리량 | +|------|------------|--------| +| 캐시 미스 (DB 조회) | 49.1ms | ~20 req/s | +| 캐시 히트 (Redis) | **5.4ms** | **184 req/s** | + +--- + +## 5. 예상과 다른 결과들 + +### 발견 1: 목록 API의 캐시 미스가 상세보다 3배 느리다 + +상세 API 미스: 15.5ms +목록 API 미스: 49.1ms + +**왜 이렇게 차이가 날까?** + +목록 API는 캐시 미스 시 **N+1 문제**가 발생한다. + +```java +// 상품 10개 조회 후, 각 상품마다 brand 쿼리 발생 +productService.findProducts(condition) // 쿼리 1번 + .map(product -> brandService.getById(...)) // 쿼리 N번 (10번) +``` + +``` +캐시 미스 시 총 DB 왕복: 1 (상품 목록) + 10 (브랜드) = 11번 +캐시 히트 시 DB 왕복: 0번 +``` + +캐시 히트가 N+1을 통째로 회피하기 때문에 목록 API에서 개선 효과가 더 극적으로 나타난다. + +### 발견 2: 상세 API의 캐시 히트가 0.7ms로 극단적으로 빠르다 + +상세 API 미스: 15.5ms → 히트: 0.7ms (22배 향상) + +캐시 히트 시 Spring 애플리케이션은 Redis에서 JSON을 읽어 역직렬화만 하면 된다. + +``` +캐시 미스: HTTP 수신 → ProductService → DB → BrandService → DB → JSON 직렬화 → HTTP 응답 +캐시 히트: HTTP 수신 → Redis GET → JSON 역직렬화 → HTTP 응답 +``` + +DB 왕복 2번이 사라지면서 처리 경로가 대폭 단축된다. + +### 발견 3: 처리량 차이가 응답시간 차이보다 더 크다 + +| | 응답시간 개선 | 처리량 개선 | +|--|-------------|-----------| +| 상품 상세 | 22배 빠름 | 21배 향상 | +| 상품 목록 | 9배 빠름 | 9배 향상 | + +응답시간이 빨라지면 동시 처리 가능한 요청 수도 함께 늘어나므로 처리량 개선 비율도 유사하게 나타난다. + +--- + +## 6. 추가 질문: 로컬 환경에서 측정했는데 프로덕션에서도 같은 효과가 날까? + +### 환경 차이 + +로컬 환경에서는 앱, DB, Redis가 모두 같은 머신에 있어 네트워크 레이턴시가 거의 없다. +프로덕션에서는 각 서버가 분리되어 있어 DB 왕복 레이턴시가 훨씬 크다. + +| 항목 | 로컬 | 프로덕션 (예상) | +|------|------|----------------| +| DB 왕복 레이턴시 | ~1ms | 5~20ms | +| Redis 왕복 레이턴시 | ~0.1ms | 1~3ms | +| 캐시 미스 응답시간 | 15~50ms | 50~200ms | +| 캐시 히트 응답시간 | 0.7~5ms | 2~10ms | + +**DB 레이턴시가 높을수록 캐시 효과는 더 크게 나타난다.** + +특히 목록 API의 N+1 문제는 프로덕션에서 훨씬 치명적이다. +브랜드 쿼리 10번 × 10ms = 100ms가 캐시 히트 시 5ms 이내로 줄어든다. + +--- + +## 7. 캐시 도입의 트레이드오프 + +캐시가 항상 정답은 아니다. 단점도 알아야 올바른 선택을 할 수 있다. + +### 7-1. 데이터 불일치 (Stale Data) + +캐시 TTL 동안 DB 값이 바뀌어도 캐시는 이전 값을 반환한다. + +``` +1. 상품 가격: DB = 10,000원, 캐시 = 10,000원 ← 일치 +2. 관리자가 가격 변경: DB = 8,000원 +3. TTL 만료 전 조회: DB = 8,000원, 캐시 = 10,000원 ← 불일치 +4. TTL 만료 후 조회: DB = 8,000원, 캐시 = 8,000원 ← 일치 +``` + +**본 설계에서의 선택**: TTL(상세 3분, 목록 30초)을 짧게 설정해 불일치 시간을 최소화했다. +단, 가격 변경이 실시간으로 반영되어야 하는 요구사항이 생기면 `@CacheEvict`로 무효화 전략 추가가 필요하다. + +### 7-2. 목록 캐시 무효화의 어려움 + +목록 API는 `brandId`, `sortType`, `page`, `size` 조합으로 캐시 키가 생성된다. +상품 하나가 추가/변경될 때 영향받는 캐시 키를 특정하기 어렵다. + +``` +product:list:brandId=1:sort=LATEST:page=0:size=10 +product:list:brandId=1:sort=LATEST:page=1:size=10 +product:list:brandId=null:sort=LATEST:page=0:size=10 +... (조합이 무한히 늘어남) +``` + +**본 설계에서의 선택**: TTL(30초)만으로 관리. 신규 상품은 최대 30초 후 목록에 반영된다. + +### 7-3. Redis 장애 시 영향 + +Redis가 다운되면 모든 요청이 DB로 향한다. +현재 코드는 `JsonProcessingException` 발생 시 캐시 미스로 처리하도록 방어 코드가 있지만, +Redis 연결 자체가 끊기면 예외가 전파되어 API 전체 장애로 이어질 수 있다. + +**개선 방향**: Redis 연결 예외를 catch해서 캐시 미스로 폴백하는 처리 추가 검토 필요. + +--- + +## 8. 최종 결론 + +### 질문에 대한 답 + +| 질문 | 답 | +|------|-----| +| 캐시 도입이 효과가 있는가? | 상세 22배, 목록 9배 응답속도 개선 | +| 목록보다 상세가 더 효과적인가? | 절대 개선폭은 상세가 크지만, N+1 회피로 목록도 극적으로 개선 | +| 프로덕션에서도 같은 효과인가? | DB 레이턴시가 높을수록 효과가 더 크게 나타남 | +| 트레이드오프는 없는가? | Stale Data, 목록 무효화 어려움, Redis 장애 전파 위험 존재 | + +### 남은 고려사항 + +- Redis 연결 장애 시 폴백 처리 추가 검토 +- 가격/재고 변경 API 추가 시 `@CacheEvict` 무효화 전략 연결 필요 +- 목록 API의 N+1 문제는 캐시와 별개로 근본 해결(배치 조회) 검토 가치 있음 From f247916062b1cb79a775f2171551258c5fb577bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Fri, 13 Mar 2026 16:29:13 +0900 Subject: [PATCH 7/9] =?UTF-8?q?feat=20:=20=EC=83=9D=EC=84=B1=EC=9D=BC,=20?= =?UTF-8?q?=EB=B8=8C=EB=9E=9C=EB=93=9C=EB=B3=84=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=EC=9D=BC=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/infrastructure/product/ProductEntity.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java index 800b8466a..700645f9d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java @@ -15,7 +15,9 @@ @Entity @Table(name = "product", indexes = { @Index(name = "idx_product_brand_like", columnList = "ref_brand_id, like_count DESC"), - @Index(name = "idx_product_like", columnList = "like_count DESC") + @Index(name = "idx_product_like", columnList = "like_count DESC"), + @Index(name = "idx_product_brand_latest", columnList = "ref_brand_id, created_at DESC"), + @Index(name = "idx_product_latest", columnList = "created_at DESC") }) @NoArgsConstructor public class ProductEntity extends BaseEntity { From 7d8581d8206807f0d58f735f62c14b9e7655b7bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Fri, 13 Mar 2026 23:24:57 +0900 Subject: [PATCH 8/9] =?UTF-8?q?docs=20:=20=EC=B6=94=EA=B0=80=ED=95=9C=20?= =?UTF-8?q?=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=84=B1=EB=8A=A5=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B2=B0=EA=B3=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/index-performance-test.md | 130 ++++++++++++++++++++++++++++++++- 1 file changed, 126 insertions(+), 4 deletions(-) diff --git a/docs/index-performance-test.md b/docs/index-performance-test.md index 9cd0594a3..6a731ca04 100644 --- a/docs/index-performance-test.md +++ b/docs/index-performance-test.md @@ -77,13 +77,17 @@ Extra: Using where; Using filesort | B | 100 | 1,000 | 중형 브랜드 | | C | 1,000 | 100 | 소형 브랜드 다수 | -**인덱스 조합:** 4가지 (없음, ref_brand_id, like_count, 복합) +**인덱스 조합:** +- 좋아요순: 4가지 (없음, ref_brand_id, like_count, 복합) +- 최신순: 3가지 (없음, created_at, 복합) **쿼리 패턴:** - Q1: 브랜드 필터 + 좋아요순 - Q2: 전체 좋아요순 (브랜드 필터 없음) +- Q3: 브랜드 필터 + 최신순 +- Q4: 전체 최신순 (브랜드 필터 없음) -왜 Q2도 테스트할까? → API에서 `brandId`가 선택적 파라미터라서 둘 다 사용될 수 있다. +왜 Q2, Q4도 테스트할까? → API에서 `brandId`가 선택적 파라미터라서 둘 다 사용될 수 있다. --- @@ -107,6 +111,22 @@ Extra: Using where; Using filesort | like_count | **0.05** | **0.05** | **0.05** | | 복합 | 33.6 | 21.4 | 33.2 | +### Q3: 브랜드 필터 + 최신순 (단위: ms) + +| 인덱스 | 결과 | 비고 | +|--------|------|------| +| 없음 | 0.31 | brand 인덱스 사용 + filesort | +| created_at | 0.27 | brand 인덱스 사용 + filesort | +| 복합 (brand, created_at) | **0.04** ✅ | filesort 없음 | + +### Q4: 전체 최신순 (단위: ms) + +| 인덱스 | 결과 | 비고 | +|--------|------|------| +| 없음 | 22.2 | 풀 테이블 스캔 | +| created_at | **0.05** ✅ | 인덱스 스캔 | +| 복합 (brand, created_at) | 0.03 | created_at 인덱스 사용 | + --- ## 5. 예상과 다른 결과들 @@ -179,6 +199,46 @@ Table scan (100,000 rows) 복합 인덱스 `(ref_brand_id, like_count)`는 `ref_brand_id` 조건이 없으면 사용할 수 없다. +### 발견 4: 최신순도 동일한 패턴 + +Q3, Q4 테스트 결과 최신순(created_at) 정렬도 좋아요순과 동일한 패턴을 보인다. + +**Q3 (브랜드 필터 + 최신순)** + +인덱스 없을 때: +``` +Index lookup by brand_id (100 rows) + → Sort by created_at DESC + → Limit 10 +⏱️ 0.31ms +``` + +복합 인덱스 `(brand_id, created_at DESC)` 적용 후: +``` +Index lookup by brand_id + created_at (10 rows) + → Limit 10 +⏱️ 0.04ms +``` + +**Q4 (전체 최신순)** + +인덱스 없을 때: +``` +Table scan (100,000 rows) + → Sort by created_at DESC + → Limit 10 +⏱️ 22.2ms +``` + +`created_at` 단일 인덱스 적용 후: +``` +Index scan by created_at (10 rows) + → Limit 10 +⏱️ 0.05ms +``` + +**결론**: 좋아요순과 마찬가지로 최신순도 복합 인덱스 + 단일 인덱스 조합이 필요하다. + --- ## 6. 추가 질문: 페이지네이션은 괜찮을까? @@ -295,18 +355,80 @@ UPDATE data ### 적용할 인덱스 +처음에는 좋아요순만 고려해서 2개 인덱스를 설계했다: + ```sql -- Q1 커버: 브랜드 필터 + 좋아요순 -CREATE INDEX idx_brand_like ON product(ref_brand_id, like_count DESC); +CREATE INDEX idx_product_brand_like ON product(ref_brand_id, like_count DESC); -- Q2 커버: 전체 좋아요순 -CREATE INDEX idx_like_count ON product(like_count DESC); +CREATE INDEX idx_product_like ON product(like_count DESC); +``` + +### 추가 인덱스가 필요한가? + +잠깐, API를 다시 확인해보자. + +```java +@RequestParam(defaultValue = "LATEST") ProductSortType sortType +``` + +**기본 정렬이 최신순(LATEST)이다!** 그리고 정렬 옵션이 3가지가 있다: + +```java +case LATEST -> productEntity.createdAt.desc(); // 최신순 +case PRICE_ASC -> productEntity.price.asc(); // 가격순 +case LIKES_DESC -> productEntity.likeCount.desc(); // 좋아요순 +``` + +현재 인덱스는 좋아요순만 커버한다. 최신순과 가격순은? + +### 우선순위 결정 + +| 정렬 | 사용 빈도 | 인덱스 필요성 | +|------|----------|-------------| +| 최신순 (LATEST) | ⭐ 기본값이므로 가장 많이 호출 | **필수** | +| 좋아요순 (LIKES_DESC) | 이미 구현됨 | ✅ 있음 | +| 가격순 (PRICE_ASC) | 사용 빈도 불명 | 보류 | + +**최신순이 기본값**이므로 가장 많이 호출될 가능성이 높다. 인덱스 없이 방치하면 안 된다. + +가격순은 실제 트래픽을 보고 나중에 결정해도 된다. + +### 최종 적용 인덱스 (4개) + +```sql +-- 좋아요순 +CREATE INDEX idx_product_brand_like ON product(ref_brand_id, like_count DESC); +CREATE INDEX idx_product_like ON product(like_count DESC); + +-- 최신순 (추가) +CREATE INDEX idx_product_brand_latest ON product(ref_brand_id, created_at DESC); +CREATE INDEX idx_product_latest ON product(created_at DESC); ``` +| 인덱스 | 커버하는 쿼리 | +|--------|-------------| +| `idx_product_brand_like` | 브랜드 필터 + 좋아요순 | +| `idx_product_like` | 전체 좋아요순 | +| `idx_product_brand_latest` | 브랜드 필터 + 최신순 | +| `idx_product_latest` | 전체 최신순 | + +### 가격순은 왜 안 만들었나? + +- 인덱스가 많아지면 쓰기 성능 저하 (테스트에서 25% 느려짐 확인) +- 가격순의 실제 사용 빈도를 모름 +- 필요하면 나중에 추가해도 됨 + +**트레이드오프**: 모든 경우를 커버하는 6개 인덱스 vs 핵심만 커버하는 4개 인덱스 + +→ 쓰기 성능을 고려해서 **4개로 시작**하고, 모니터링 후 필요하면 추가한다. + ### 남은 고려사항 - 깊은 페이지네이션은 커서 기반으로 변경 검토 - 좋아요가 매우 빈번하면 Redis 캐시 검토 +- 가격순 정렬 사용량 모니터링 후 인덱스 추가 검토 --- From b3a827c23acaaa14c7f3a4069efc5bbe4cb540ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Fri, 13 Mar 2026 23:30:52 +0900 Subject: [PATCH 9/9] =?UTF-8?q?docs=20:=20md=ED=8C=8C=EC=9D=BC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/index-performance-test.md | 54 +--------------------------------- 1 file changed, 1 insertion(+), 53 deletions(-) diff --git a/docs/index-performance-test.md b/docs/index-performance-test.md index 6a731ca04..b2195667b 100644 --- a/docs/index-performance-test.md +++ b/docs/index-performance-test.md @@ -428,56 +428,4 @@ CREATE INDEX idx_product_latest ON product(created_at DESC); - 깊은 페이지네이션은 커서 기반으로 변경 검토 - 좋아요가 매우 빈번하면 Redis 캐시 검토 -- 가격순 정렬 사용량 모니터링 후 인덱스 추가 검토 - ---- - -## 9. 복합 인덱스의 단점 - -복합 인덱스가 항상 정답은 아니다. 단점도 알아야 올바른 선택을 할 수 있다. - -### 9-1. 재사용성(범용성)이 낮음 - -복합 인덱스는 컬럼의 순서가 생명이다. `(ref_brand_id, like_count DESC)` 인덱스를 예로 들면: - -- **사용 가능 ✅**: `brand_id`로 검색할 때, 또는 `brand_id`로 검색하고 `like_count`로 정렬할 때 -- **사용 불가 ❌**: `like_count`로만 검색하거나 정렬할 때 - -복합 인덱스의 뒷순서 컬럼만 단독으로 쓰면 DB는 이 인덱스를 아예 사용하지 못하고 풀 스캔을 한다. 반면 `like_count` 단일 인덱스는 어떤 쿼리에서도 `like_count`가 들어가면 제 역할을 한다. - -### 9-2. 관리 비용과 디스크 공간 소모 - -인덱스는 결국 '별도의 복사본 장부'다. - -- **크기**: 단일 인덱스는 컬럼 하나만 복사하지만, 복합 인덱스는 지정한 모든 컬럼을 복사한다. 2개, 3개 컬럼이 묶일수록 인덱스 파일의 크기가 커지고, 이는 곧 DB 전체의 저장 공간 부담으로 이어진다. -- **메모리 효율**: DB는 성능을 위해 인덱스를 메모리(Buffer Pool)에 올려두고 싶어 하는데, 인덱스가 무거워지면 메모리에 다 안 올라가서 오히려 성능이 떨어지는 지점이 생긴다. - -### 9-3. 쓰기(CUD) 성능의 더 큰 저하 - -앞서 테스트에서 인덱스 2개일 때 업데이트 성능이 25% 저하되는 걸 확인했다. - -복합 인덱스는 묶여 있는 컬럼 중 어느 하나만 수정되어도 **인덱스의 해당 위치를 찾아 재배치**해야 한다. 특히 `like_count`처럼 값이 빈번하게 변하는 컬럼이 복합 인덱스에 포함되어 있으면, 수정이 일어날 때마다 DB가 해야 할 일이 단일 인덱스일 때보다 훨씬 많아진다. - -### 9-4. 설계의 난이도 (선택과 집중) - -단일 인덱스는 그냥 필요한 컬럼에 걸면 끝이지만, 복합 인덱스는 **"어떤 컬럼을 앞에 둘 것인가?"**를 치열하게 고민해야 한다. - -순서를 잘못 설계하면 인덱스를 만들어 놓고도 쓰지 못하는 '죽은 인덱스'가 되기 십상이다. 보통은 **카디널리티가 높은(중복도가 낮은) 컬럼을 앞에 둔다**. 왜냐하면 첫 번째 컬럼에서 대부분 걸러져서 뒤쪽 컬럼까지 탐색할 데이터가 줄어들기 때문이다. - -### 9-5. 그럼에도 복합 인덱스를 쓰는 이유 - -복합 인덱스를 쓰는 이유는, **WHERE + ORDER BY를 한 번에 커버해서 filesort를 피할 수 있기 때문**이다. 앞서 테스트에서 복합 인덱스가 모든 데이터 분포에서 안정적으로 0.02~0.37ms를 기록한 것이 그 증거다. - -### 테스트 결과와 연결 - -Q2(전체 좋아요순) 결과를 다시 보면: - -> 복합 인덱스는 Q2에 사용 불가 (풀 스캔 발생) - -바로 이 점이 가장 큰 단점이다. 복합 인덱스 `(brand, like)`만 믿고 `like` 단일 인덱스를 안 만들면, 전체 순위 페이지를 보여줄 때 시스템이 멈출 수도 있다. - -### 실무 전략 - -1. 가장 많이 쓰이는 복합 쿼리용으로 **복합 인덱스 하나** -2. 각각의 컬럼이 단독으로 쓰일 가능성이 높다면 **단일 인덱스 추가** -3. 단, 너무 많이 만들면 쓰기 성능이 망가지니 **트레이드오프를 계산해서 최소한으로 유지** +- 가격순 정렬 사용량 모니터링 후 인덱스 추가 검토 \ No newline at end of file