From 61ff5334a2dc8cb102e6aef5bfd2cb95ed3895c1 Mon Sep 17 00:00:00 2001 From: najang Date: Thu, 12 Mar 2026 23:12:45 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20products/likes/orders=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=EB=A1=9C=20=EC=A1=B0=ED=9A=8C=20=EC=84=B1=EB=8A=A5=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - products: like_count, created_at, price 단일 인덱스 및 brand_id 복합 인덱스 6개 추가 - likes: user_id, created_at DESC 복합 인덱스 추가 - orders: user_id, created_at DESC 복합 인덱스 추가 - 전체 테이블 스캔 + filesort 제거로 조회 시간 약 87~490배 개선 Co-Authored-By: Claude Sonnet 4.6 --- .../java/com/loopers/domain/like/Like.java | 5 +- .../java/com/loopers/domain/order/Order.java | 5 +- .../com/loopers/domain/product/Product.java | 10 +- docs/design/index-optimization.md | 461 ++++++++++++++++++ 4 files changed, 478 insertions(+), 3 deletions(-) create mode 100644 docs/design/index-optimization.md diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java index e63fd82d7..8100be263 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -5,6 +5,7 @@ import jakarta.persistence.Column; import jakarta.persistence.EmbeddedId; import jakarta.persistence.Entity; +import jakarta.persistence.Index; import jakarta.persistence.PrePersist; import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; @@ -14,7 +15,9 @@ @Getter @Entity -@Table(name = "likes") +@Table(name = "likes", indexes = { + @Index(name = "idx_user_created_at", columnList = "user_id, created_at DESC") +}) public class Like { @EmbeddedId diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java index 19bd5dacd..b1a978c29 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -11,6 +11,7 @@ import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; +import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; @@ -22,7 +23,9 @@ @Getter @Entity -@Table(name = "orders") +@Table(name = "orders", indexes = { + @Index(name = "idx_user_created_at", columnList = "user_id, created_at DESC") +}) public class Order extends BaseEntity { @Column(name = "user_id", nullable = false) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 5689b902d..578acfe8f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -11,6 +11,7 @@ import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; +import jakarta.persistence.Index; import jakarta.persistence.Table; import jakarta.persistence.Version; import lombok.AccessLevel; @@ -18,7 +19,14 @@ @Getter @Entity -@Table(name = "products") +@Table(name = "products", indexes = { + @Index(name = "idx_like_count", columnList = "like_count DESC"), + @Index(name = "idx_brand_like_count", columnList = "brand_id, like_count DESC"), + @Index(name = "idx_created_at", columnList = "created_at DESC"), + @Index(name = "idx_brand_created_at", columnList = "brand_id, created_at DESC"), + @Index(name = "idx_price", columnList = "price ASC"), + @Index(name = "idx_brand_price", columnList = "brand_id, price ASC") +}) public class Product extends BaseEntity { @Column(name = "brand_id", nullable = false) diff --git a/docs/design/index-optimization.md b/docs/design/index-optimization.md new file mode 100644 index 000000000..e154b6cb3 --- /dev/null +++ b/docs/design/index-optimization.md @@ -0,0 +1,461 @@ +# 1. 상품 목록 조회 + +- 실행 환경: 브랜드 10,000개, 상품 100,000개, soft-delete 비율 5% + +## 1-1. 좋아요 순 정렬 + +**쿼리** + +```sql +SELECT * + FROM products + WHERE deleted_at IS NULL + ORDER BY like_count DESC + LIMIT 20; +``` + +**분석** + +| type | rows | Extra | +| --- | --- | --- | +| ALL | 99465 | Using where; Using filesort | + +**문제** + +- `deleted_at IS NULL` 조건의 선택도가 낮아 필터링만으로는 조회 대상 건수를 충분히 줄이기 어려움 +- `like_count DESC` 정렬에 사용할 인덱스가 없어 전체 데이터를 스캔한 뒤 `filesort`를 수행함 +- 상위 20개 상품만 필요하지만, 이를 위해 전체 데이터 약 9.9만 건을 읽고 정렬하는 비효율이 발생함 + +**실행 시간** + +약 76.4ms + +**인덱스 설계** + +```java +@Index(name="idx_like_count", columnList="like_count DESC") +``` + +**설계 근거** + +- 이 쿼리의 주요 비용은 `WHERE deleted_at IS NULL` 필터링보다 `ORDER BY like_count DESC LIMIT 20` 정렬 처리에서 발생함 +- soft-delete 비율이 5%로 낮아 `deleted_at` 조건만으로는 충분한 범위 축소 효과를 기대하기 어려우므로, 정렬 기준인 `like_count`에 인덱스를 두는 것이 더 효과적임 +- `like_count DESC` 인덱스를 사용하면 좋아요 수가 높은 순서대로 정렬된 데이터를 인덱스에서 바로 읽을 수 있어 `filesort`를 제거할 수 있음 +- 또한 인덱스를 앞에서부터 스캔하면서 `deleted_at IS NULL` 조건을 확인하고, 상위 20건이 확보되는 시점에 즉시 스캔을 종료할 수 있음 +- 이를 통해 불필요한 전체 스캔 및 정렬 비용을 크게 줄일 수 있음 + +**인덱스 생성 후 분석** + +| key | type | rows | Extra | +| --- | --- | --- | --- | +| idx_like_count | index | 20 | Using where | + +**개선 결과** + +- actual rows: 99,465 → 20 (약 **99.98% 감소**, 약 **4,973배 개선**) +- actual time: 76.4ms → 0.478ms (약 **99.37% 감소**, 약 **160배 개선**) + +--- + +## 1-2. 브랜드별 좋아요 순 정렬 + +**쿼리** + +```sql +SELECT * + FROM products + WHERE brand_id = ? + AND deleted_at IS NULL + ORDER BY like_count DESC + LIMIT 20; +``` + +**분석** + +| type | rows | Extra | +| --- | --- | --- | +| ALL | 99465 | Using where; Using filesort | + +**문제** + +- `brand_id = ?` 조건으로 특정 브랜드만 조회하지만, 이를 활용할 인덱스가 없어 전체 테이블을 스캔함 +- `deleted_at IS NULL` 조건은 추가 필터 역할만 수행하고, `like_count DESC` 정렬에 사용할 인덱스가 없어 `filesort`가 발생함 +- 특정 브랜드의 상위 20개 상품만 필요함에도 불구하고, 브랜드 필터링과 정렬을 위해 전체 데이터 약 9.9만 건을 읽고 정렬해야 하는 비효율이 발생함 + +**실행 시간** + +약 56.9ms + +**인덱스 설계** + +```java +@Index(name="idx_brand_like_count", columnList="brand_id, like_count DESC") +``` + +**설계 근거** + +- 이 쿼리는 `brand_id = ?`로 조회 범위를 먼저 좁힌 뒤, 해당 브랜드 내에서 `like_count DESC` 기준으로 내림차순 정렬 후 상위 20건만 조회하는 패턴임 +- 따라서 `brand_id`를 선두 컬럼으로 두고, 그 뒤에 `like_count DESC`를 배치한 복합 인덱스를 설계하면 브랜드별 데이터 범위를 빠르게 찾으면서 좋아요 정렬도 인덱스로 처리할 수 있음 +- `deleted_at IS NULL` 조건은 인덱스에 포함되지 않았지만, 좋아요 순으로 정렬된 인덱스를 앞에서부터 스캔하면서 조건을 확인하고 20건이 채워지는 시점에 즉시 스캔을 종료할 수 있음 +- 이를 통해 전체 스캔과 `filesort`를 제거하고, 브랜드별 인기 상품 조회를 매우 빠르게 수행할 수 있음 + +**인덱스 생성 후 분석** + +| key | type | rows | Extra | +| --- | --- | --- | --- | +| idx_brand_like_count | ref | 1067 | Using where | + +`EXPLAIN`의 `rows=1067`은 최종 반환 건수가 아니라 옵티마이저가 예상한 후보 행 수이다. + +실제 실행에서는 인덱스를 따라 좋아요 순으로 조회하면서 20건만 읽고 바로 종료되었다. + +**개선 결과** + +- actual rows: 99,465 → 20 (약 **99.98% 감소**, 약 **4,973배 개선**) +- actual time: 56.9ms → 0.611ms (약 **98.93% 감소**, 약 **93.1배 개선**) + +--- + +## 1-3. 최신 순 정렬 + +**쿼리** + +```sql +SELECT * + FROM products + WHERE deleted_at IS NULL + ORDER BY created_at DESC + LIMIT 20; +``` + +**분석** + +| type | rows | Extra | +| --- | --- | --- | +| ALL | 99465 | Using where; Using filesort | + +**문제** + +- `deleted_at IS NULL` 조건의 선택도가 낮아 필터링만으로는 조회 대상 건수를 충분히 줄이기 어려움 +- `created_at DESC` 정렬에 사용할 인덱스가 없어 전체 데이터를 스캔한 뒤 `filesort`를 수행함 +- 최신 상품 20건만 필요하지만, 이를 위해 불필요한 대량 스캔과 정렬이 발생함 + +**실행 시간** + +약 90.2ms + +**인덱스 설계** + +```java +@Index(name="idx_created_at", columnList="created_at DESC") +``` + +**설계 근거** + +- 이 쿼리의 주요 비용은 `WHERE deleted_at IS NULL` 필터링보다 `ORDER BY created_at DESC LIMIT 20` 정렬 처리에서 발생함 +- soft-delete 비율이 5%로 낮아 `deleted_at` 조건만으로는 충분한 범위 축소 효과를 기대하기 어려우므로, 정렬 기준인 `created_at`에 인덱스를 두는 것이 더 효과적임 +- `created_at DESC` 인덱스를 사용하면 최신 순으로 정렬된 데이터를 인덱스에서 바로 읽을 수 있어 `filesort`를 제거할 수 있음 +- 또한 인덱스를 앞에서부터 스캔하면서 `deleted_at IS NULL` 조건을 확인하고, 상위 20건이 확보되는 시점에 즉시 스캔을 종료할 수 있음 +- 이를 통해 불필요한 전체 스캔 및 정렬 비용을 크게 줄일 수 있음 + +**인덱스 생성 후 분석** + +| key | type | rows | Extra | +| --- | --- | --- | --- | +| idx_created_at | index | 20 | Using where | + +**개선 결과** + +- actual rows: 99,465 → 20 (약 **99.98% 감소**, 약 **4,973배 개선**) +- actual time: 90.2ms → 0.184ms (약 **99.80% 감소**, 약 **490.2배 개선**) + +--- + +## 1-4. 브랜드별 최신 순 정렬 + +**쿼리** + +```sql +SELECT * + FROM products + WHERE brand_id = ? + AND deleted_at IS NULL + ORDER BY created_at DESC + LIMIT 20; +``` + +**분석** + +| type | rows | Extra | +| --- | --- | --- | +| ALL | 99465 | Using where; Using filesort | + +**문제** + +- `brand_id = ?` 조건으로 특정 브랜드만 조회하지만, 이를 활용할 인덱스가 없어 전체 테이블을 스캔함 +- `deleted_at IS NULL` 조건은 추가 필터 역할만 수행하고, `created_at DESC` 정렬에 사용할 인덱스가 없어 `filesort`가 발생함 +- 특정 브랜드의 최신 상품 20건만 필요함에도 불구하고, 브랜드 필터링과 정렬을 위해 전체 데이터 약 9.9만 건을 읽고 정렬해야 하는 비효율이 발생함 + +**실행 시간** + +약 70ms + +**인덱스 설계** + +```java +@Index(name="idx_brand_created_at", columnList="brand_id, created_at DESC") +``` + +**설계 근거** + +- 이 쿼리는 `brand_id = ?`로 조회 범위를 먼저 좁힌 뒤, 해당 브랜드 내에서 `created_at DESC` 기준으로 내림차순 정렬 후 상위 20건만 조회하는 패턴임 +- 따라서 `brand_id`를 선두 컬럼으로 두고, 그 뒤에 `created_at DESC`를 배치한 복합 인덱스를 설계하면 브랜드별 데이터 범위를 빠르게 찾으면서 최신순 정렬도 인덱스로 처리할 수 있음 +- `deleted_at IS NULL` 조건은 인덱스에 포함되지 않았지만, 최신순으로 정렬된 인덱스를 앞에서부터 스캔하면서 조건을 확인하고 20건이 채워지는 시점에 즉시 스캔을 종료할 수 있음 +- 이를 통해 전체 스캔과 `filesort`를 제거하고, 브랜드별 최신 상품 조회를 매우 빠르게 수행할 수 있음 + +**인덱스 생성 후 분석** + +| key | type | rows | Extra | +| --- | --- | --- | --- | +| idx_brand_created_at | ref | 1067 | Using where | + +`EXPLAIN`의 `rows=1067`은 최종 반환 건수가 아니라 옵티마이저가 예상한 후보 행 수이다. + +실제 실행에서는 인덱스를 따라 최신순으로 조회하면서 20건만 읽고 바로 종료되었다. + +**개선 결과** + +- actual rows: 99,465 → 20 (약 **99.98% 감소**, 약 **4,973배 개선**) +- actual time: 70ms → 0.805ms (약 **98.85% 감소**, 약 **87.0배 개선**) + +--- + +## 1-5. 가격 순 정렬 + +**쿼리** + +```sql +SELECT * + FROM products + WHERE deleted_at IS NULL + ORDER BY price ASC + LIMIT 20; +``` + +**분석** + +| type | rows | Extra | +| --- | --- | --- | +| ALL | 99465 | Using where; Using filesort | + +**문제** + +- `deleted_at IS NULL` 조건의 선택도가 낮아 필터링만으로는 조회 대상 건수를 충분히 줄이기 어려움 +- `price ASC` 정렬에 사용할 인덱스가 없어 전체 데이터를 스캔한 뒤 `filesort`를 수행함 +- 최저가 상품 20건만 필요하지만, 이를 위해 전체 데이터 약 9.9만 건을 읽고 정렬하는 비효율이 발생함 + +**실행 시간** + +약 74.7ms + +**인덱스 설계** + +```java +@Index(name="idx_price", columnList="price ASC") +``` + +**설계 근거** + +- 이 쿼리의 주요 비용은 `WHERE deleted_at IS NULL` 필터링보다 `ORDER BY price ASC LIMIT 20` 정렬 처리에서 발생함 +- soft-delete 비율이 5%로 낮아 `deleted_at` 조건만으로는 충분한 범위 축소 효과를 기대하기 어려우므로, 정렬 기준인 `price`에 인덱스를 두는 것이 더 효과적임 +- `price ASC` 인덱스를 사용하면 가격이 낮은 순서대로 정렬된 데이터를 인덱스에서 바로 읽을 수 있어 `filesort`를 제거할 수 있음 +- 또한 인덱스를 앞에서부터 스캔하면서 `deleted_at IS NULL` 조건을 확인하고, 상위 20건이 확보되는 시점에 즉시 스캔을 종료할 수 있음 +- 이를 통해 불필요한 전체 스캔 및 정렬 비용을 크게 줄일 수 있음 + +**인덱스 생성 후 분석** + +| key | type | rows | Extra | +| --- | --- | --- | --- | +| idx_price | index | 20 | Using where | + +**개선 결과** + +- actual rows: 99,465 → 20 (약 **99.98% 감소**, 약 **4,973배 개선**) +- actual time: 74.7ms → 0.369ms (약 **99.51% 감소**, 약 **202.4배 개선**) + +--- + +## 1-6. 브랜드별 가격 순 정렬 + +**쿼리** + +```sql +SELECT * + FROM products + WHERE brand_id = ? + AND deleted_at IS NULL + ORDER BY price ASC + LIMIT 20; +``` + +**분석** + +| type | rows | Extra | +| --- | --- | --- | +| ALL | 99465 | Using where; Using filesort | + +**문제** + +- `brand_id = ?` 조건으로 특정 브랜드만 조회하지만, 이를 활용할 인덱스가 없어 전체 테이블을 스캔함 +- `deleted_at IS NULL` 조건은 추가 필터 역할만 수행하고, `price ASC` 정렬에 사용할 인덱스가 없어 `filesort`가 발생함 +- 특정 브랜드의 최저가 상품 20건만 필요함에도 불구하고, 브랜드 필터링과 정렬을 위해 전체 데이터 약 9.9만 건을 읽고 정렬해야 하는 비효율이 발생함 + +**실행 시간** + +약 67.1ms + +**인덱스 설계** + +```java +@Index(name="idx_brand_price", columnList="brand_id, price ASC") +``` + +**설계 근거** + +- 이 쿼리는 `brand_id = ?`로 조회 범위를 먼저 좁힌 뒤, 해당 브랜드 내에서 `price ASC` 기준으로 오름차순 정렬 후 상위 20건만 조회하는 패턴임 +- 따라서 `brand_id`를 선두 컬럼으로 두고, 그 뒤에 `price ASC`를 배치한 복합 인덱스를 설계하면 브랜드별 데이터 범위를 빠르게 찾으면서 가격 정렬도 인덱스로 처리할 수 있음 +- `deleted_at IS NULL` 조건은 인덱스에 포함되지 않았지만, 가격순으로 정렬된 인덱스를 앞에서부터 스캔하면서 조건을 확인하고 20건이 채워지는 시점에 즉시 스캔을 종료할 수 있음 +- 이를 통해 전체 스캔과 `filesort`를 제거하고, 브랜드별 최저가 상품 조회를 매우 빠르게 수행할 수 있음 + +**인덱스 생성 후 분석** + +| key | type | rows | Extra | +| --- | --- | --- | --- | +| idx_brand_price | ref | 1067 | Using where | + +`EXPLAIN`의 `rows=1067`은 최종 반환 건수가 아니라 옵티마이저가 예상한 후보 행 수이다. + +실제 실행에서는 인덱스를 따라 가격 오름차순으로 조회하면서 20건만 읽고 바로 종료되었다. + +**개선 결과** + +- actual rows: 99,465 → 20 (약 **99.98% 감소**, 약 **4,973배 개선**) +- actual time: 67.1ms → 0.36ms (약 **99.46% 감소**, 약 **186.4배 개선**) + +--- + +# 2. 사용자별 좋아요 목록 조회 + +- 실행 환경: 좋아요 100,000개(유저 100명, 상품 1000개) + +**쿼리** + +```sql +EXPLAIN +SELECT * FROM likes + WHERE user_id = ? + ORDER BY created_at DESC; +``` + +**분석** + +| type | rows | Extra | +| --- | --- | --- | +| ALL | 99341 | Using where; Using filesort | + +**문제** + +- `user_id = ?` 조건으로 특정 사용자의 좋아요 목록만 조회하면 되지만, 이를 활용할 인덱스가 없어 전체 테이블을 스캔함 +- 조회 대상은 특정 사용자의 좋아요 데이터에 불과하지만, 전체 좋아요 데이터 약 9.9만 건을 모두 읽은 뒤 `WHERE user_id = ?` 조건으로 필터링하는 비효율이 발생함 +- 또한 `ORDER BY created_at DESC` 정렬에 사용할 인덱스가 없어, 필터링 이후 결과에 대해 추가적인 `filesort`가 수행됨 +- 즉, 사용자 조건 필터링과 최신순 정렬을 모두 테이블 스캔 + 정렬로 처리하고 있어 데이터가 증가할수록 조회 성능이 크게 저하될 수 있음 + +**실행 시간** + +약 44.3ms + +**인덱스 설계** + +```java +@Index(name="idx_user_created_at", columnList="user_id, created_at DESC") +``` + +**설계 근거** + +- 이 쿼리는 `user_id = ?`로 특정 사용자의 좋아요만 조회한 뒤, `created_at DESC` 기준으로 최신순 정렬하는 패턴임 +- 따라서 `user_id`를 선두 컬럼으로 두고, 그 뒤에 `created_at DESC`를 배치한 복합 인덱스를 설계하면 사용자별 데이터 범위를 빠르게 찾으면서 정렬도 인덱스로 처리할 수 있음 +- 단일 `user_id` 인덱스만으로는 사용자별 범위 탐색은 가능하지만, `created_at DESC` 정렬까지 처리하지 못해 별도의 `filesort`가 발생할 수 있음 +- 반면 `user_id, created_at DESC` 복합 인덱스는 특정 사용자에 해당하는 좋아요를 최신순으로 정렬된 상태 그대로 읽을 수 있으므로 추가 정렬 비용을 제거할 수 있음 +- 이를 통해 전체 테이블 스캔과 `filesort`를 제거하고, 사용자별 좋아요 목록을 효율적으로 조회할 수 있음 + +**인덱스 생성 후 분석** + +| key | type | rows | Extra | +| --- | --- | --- | --- | +| idx_user_created_at | ref | 1000 | | + +**개선 결과** + +- actual rows: 99341 → 1000 (약 **98.99% 감소**, 약 **99.3배 개선**) +- actual time: 44.3ms → 0.666ms (약 **98.50% 감소**, 약 **66.5배 개선**) + +--- + +# 사용자별 주문 목록 조회 + +- 실행 환경: 주문 100,000개(유저 100명) + +**쿼리** + +```sql +SELECT * + FROM orders + WHERE user_id = ? + ORDER BY created_at DESC; +``` + +**분석** + +| type | rows | Extra | +| --- | --- | --- | +| ALL | 99875 | Using where; Using filesort | + +**문제** + +- `user_id = ?` 조건으로 특정 사용자의 주문 목록만 조회하면 되지만, 이를 활용할 인덱스가 없어 전체 테이블을 스캔함 +- 조회 대상은 특정 사용자의 주문 데이터에 불과하지만, 전체 주문 데이터 10만 건을 모두 읽은 뒤 `WHERE user_id = ?` 조건으로 필터링하는 비효율이 발생함 +- 또한 `ORDER BY created_at DESC` 정렬에 사용할 인덱스가 없어, 필터링 이후 결과에 대해 추가적인 `filesort`가 수행됨 +- 즉, 사용자 조건 필터링과 최신순 정렬을 모두 테이블 스캔 + 정렬로 처리하고 있어 주문 데이터가 증가할수록 조회 성능이 저하될 수 있음 + +**실행 시간** + +약 53.2ms + +**인덱스 설계** + +```java +@Index(name="idx_user_created_at", columnList="user_id, created_at DESC") +``` + +**설계 근거** + +- 이 쿼리는 `user_id = ?`로 특정 사용자의 주문만 조회한 뒤, `created_at DESC` 기준으로 최신순 정렬하는 패턴임 +- 따라서 `user_id`를 선두 컬럼으로 두고, 그 뒤에 `created_at DESC`를 배치한 복합 인덱스를 설계하면 사용자별 데이터 범위를 빠르게 찾으면서 정렬도 인덱스로 처리할 수 있음 +- 단일 `user_id` 인덱스만으로는 사용자별 범위 탐색은 가능하지만, `created_at DESC` 정렬까지 처리하지 못해 별도의 `filesort`가 발생할 수 있음 +- 반면 `user_id, created_at DESC` 복합 인덱스는 특정 사용자에 해당하는 주문을 최신순으로 정렬된 상태 그대로 읽을 수 있으므로 추가 정렬 비용을 제거할 수 있음 +- 이를 통해 전체 테이블 스캔과 `filesort`를 제거하고, 사용자별 주문 목록을 효율적으로 조회할 수 있음 + +**인덱스 생성 후 분석** + +| key | type | rows | Extra | +| --- | --- | --- | --- | +| idx_user_created_at | ref | 1000 | | + +`EXPLAIN`의 `rows=1000`은 최종 반환 건수가 아니라 옵티마이저가 예상한 후보 행 수이다. + +실제 실행에서는 `user_id = ?`에 해당하는 주문 데이터만 인덱스를 통해 조회하고, `created_at DESC` 순서도 인덱스에 이미 반영되어 있으므로 추가적인 정렬 없이 결과를 빠르게 반환할 수 있었다. + +**개선 결과** + +- actual rows: 100,000 → 1,000 (약 **99.00% 감소**, 약 **100배 개선**) +- actual time: 53.2ms → 0.352ms (**약 99.34% 감소**, **약 151.1배 개선**) \ No newline at end of file From fc02ca1949be94d24472010769b925ba0a64c432 Mon Sep 17 00:00:00 2001 From: najang Date: Thu, 12 Mar 2026 23:54:46 +0900 Subject: [PATCH 2/5] =?UTF-8?q?test:=20=EC=83=81=ED=92=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20Cache-Aside=20E2E=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../interfaces/api/ProductV1ApiE2ETest.java | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java index f98ba5d25..18d70978c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java @@ -7,6 +7,7 @@ import com.loopers.infrastructure.product.ProductJpaRepository; import com.loopers.interfaces.api.product.ProductV1Dto; import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -15,7 +16,9 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -26,6 +29,9 @@ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class ProductV1ApiE2ETest { + private static final String ADMIN_HEADER = "X-Loopers-Ldap"; + private static final String ADMIN_VALUE = "loopers.admin"; + @Autowired private TestRestTemplate testRestTemplate; @@ -38,9 +44,22 @@ class ProductV1ApiE2ETest { @Autowired private DatabaseCleanUp databaseCleanUp; + @Autowired + private RedisCleanUp redisCleanUp; + + @Autowired + private RedisTemplate redisTemplate; + @AfterEach void tearDown() { databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + } + + private HttpHeaders createAdminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set(ADMIN_HEADER, ADMIN_VALUE); + return headers; } private Brand createBrand(String name) { @@ -185,6 +204,118 @@ void returns200WithBrandIdFilter() { } } + @DisplayName("Cache-Aside") + @Nested + class ProductCacheE2E { + + @DisplayName("첫 페이지 조회 결과가 Redis에 캐시된다.") + @Test + void 첫_페이지_조회_결과가_Redis에_캐시된다() { + // arrange + Brand brand = createBrand("Nike"); + createProduct(brand.getId(), "에어맥스", 100000, 0); + + // act + testRestTemplate.exchange( + "/api/v1/products?page=0&size=20&sort=latest", + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference>() {} + ); + + // assert + String cacheKey = "product:list:all:latest:0:20"; + assertThat(redisTemplate.hasKey(cacheKey)).isTrue(); + } + + @DisplayName("캐시 적중 시 DB 변경 후에도 이전 결과를 반환한다.") + @Test + void 캐시_적중_시_DB_변경_후에도_이전_결과를_반환한다() { + // arrange + Brand brand = createBrand("Nike"); + createProduct(brand.getId(), "에어맥스", 100000, 0); + + ResponseEntity> first = testRestTemplate.exchange( + "/api/v1/products?page=0&size=20&sort=latest", + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + int originalSize = first.getBody().data().content().size(); + + // DB에 새 상품 추가 + createProduct(brand.getId(), "조던", 200000, 0); + + // act: 두 번째 조회 (캐시 적중) + ResponseEntity> second = testRestTemplate.exchange( + "/api/v1/products?page=0&size=20&sort=latest", + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + + // assert: 캐시된 결과(이전 상품 수) 반환 + assertThat(second.getBody().data().content()).hasSize(originalSize); + } + + @DisplayName("2페이지 이상은 캐시하지 않는다.") + @Test + void 두번째_페이지_이상은_캐시하지_않는다() { + // arrange + Brand brand = createBrand("Nike"); + createProduct(brand.getId(), "에어맥스", 100000, 0); + + // act + testRestTemplate.exchange( + "/api/v1/products?page=1&size=20&sort=latest", + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference>() {} + ); + + // assert: page=1 은 캐시 키 없음 + String cacheKey = "product:list:all:latest:1:20"; + assertThat(redisTemplate.hasKey(cacheKey)).isFalse(); + assertThat(redisTemplate.keys("product:list:*")).isEmpty(); + } + + @DisplayName("어드민 상품 삭제 시 캐시가 무효화된다.") + @Test + void 어드민_상품_삭제_시_캐시가_무효화된다() { + // arrange: 상품 생성 후 첫 페이지 조회로 캐시 적재 + Brand brand = createBrand("Nike"); + Product product = createProduct(brand.getId(), "에어맥스", 100000, 0); + + testRestTemplate.exchange( + "/api/v1/products?page=0&size=20&sort=latest", + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference>() {} + ); + assertThat(redisTemplate.keys("product:list:*")).isNotEmpty(); + + // act: 어드민 삭제 + testRestTemplate.exchange( + "/api-admin/v1/products/" + product.getId(), + HttpMethod.DELETE, + new HttpEntity<>(createAdminHeaders()), + Void.class + ); + + // assert: 캐시 무효화 확인 + assertThat(redisTemplate.keys("product:list:*")).isEmpty(); + + // 재조회 시 삭제된 상품 반영 + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/products?page=0&size=20&sort=latest", + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + assertThat(response.getBody().data().content()).isEmpty(); + } + } + @DisplayName("GET /api/v1/products/{productId}") @Nested class GetProduct { From e544dd49324334d3a7a9c8f0a04af65ad3a79e98 Mon Sep 17 00:00:00 2001 From: najang Date: Thu, 12 Mar 2026 23:54:58 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20Cache-Aside=20=ED=8C=A8?= =?UTF-8?q?=ED=84=B4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../application/product/ProductFacade.java | 139 +++++++++++++++++- .../product/ProductListCacheEntry.java | 11 ++ .../api/product/ProductV1Controller.java | 2 +- 3 files changed, 147 insertions(+), 5 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductListCacheEntry.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 373c20b95..4fab9b4ef 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 @@ -1,22 +1,55 @@ package com.loopers.application.product; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.config.redis.RedisConfig; import com.loopers.domain.brand.BrandService; import com.loopers.application.like.LikeApplicationService; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; import com.loopers.domain.product.SellingStatus; -import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ScanOptions; import org.springframework.stereotype.Component; -@RequiredArgsConstructor +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + @Component public class ProductFacade { + // 읽기 전용 조회 (Replica Preferred) - 캐시 히트 확인에 사용 + private final RedisTemplate redisTemplate; + // 쓰기 전용 (Master) - 캐시 저장 및 삭제에 사용 + private final RedisTemplate masterRedisTemplate; + private final ProductService productService; private final BrandService brandService; private final LikeApplicationService likeService; + private final ObjectMapper objectMapper; + + public ProductFacade( + ProductService productService, + BrandService brandService, + LikeApplicationService likeService, + RedisTemplate redisTemplate, + @Qualifier(RedisConfig.REDIS_TEMPLATE_MASTER) RedisTemplate masterRedisTemplate, + ObjectMapper objectMapper + ) { + this.productService = productService; + this.brandService = brandService; + this.likeService = likeService; + this.redisTemplate = redisTemplate; + this.masterRedisTemplate = masterRedisTemplate; + this.objectMapper = objectMapper; + } public ProductInfo findById(Long productId) { return findById(productId, null); @@ -28,8 +61,37 @@ public ProductInfo findById(Long productId, Long userId) { return ProductInfo.from(product, isLiked); } + /** + * Cache-Aside 패턴으로 상품 목록을 조회한다. + * - page == 0: 캐시 우선 조회 → 미스 시 DB 조회 후 캐시 저장 (TTL 5분) + * - page > 0: 캐시 없이 DB 직접 조회 (페이지 수가 많아질수록 캐시 효용이 낮아 적용 제외) + */ public Page findAll(Long brandId, Pageable pageable) { - return productService.findAll(brandId, pageable).map(ProductInfo::from); + // 첫 페이지가 아닌 경우 캐시 미적용 + if (pageable.getPageNumber() != 0) { + return productService.findAll(brandId, pageable).map(ProductInfo::from); + } + + String cacheKey = buildCacheKey(brandId, pageable); + + // 캐시 히트: 직렬화된 JSON을 PageImpl로 복원해 반환 + String cached = redisTemplate.opsForValue().get(cacheKey); + if (cached != null) { + try { + ProductListCacheEntry entry = objectMapper.readValue(cached, ProductListCacheEntry.class); + return new PageImpl<>(entry.content(), pageable, entry.totalElements()); + } catch (JsonProcessingException ignored) { + } + } + + // 캐시 미스: DB 조회 후 결과를 캐시에 저장 + Page result = productService.findAll(brandId, pageable).map(ProductInfo::from); + try { + ProductListCacheEntry entry = new ProductListCacheEntry(result.getContent(), result.getTotalElements()); + masterRedisTemplate.opsForValue().set(cacheKey, objectMapper.writeValueAsString(entry), Duration.ofMinutes(5)); + } catch (JsonProcessingException ignored) { + } + return result; } public ProductInfo create(Long brandId, String name, String description, int price, int stock, SellingStatus sellingStatus) { @@ -37,11 +99,80 @@ public ProductInfo create(Long brandId, String name, String description, int pri return ProductInfo.from(productService.create(brandId, name, description, price, stock, sellingStatus)); } + /** + * 상품 수정 후 해당 브랜드의 캐시를 무효화한다. + * update는 반환된 ProductInfo에서 brandId를 얻을 수 있으므로 별도 조회 불필요. + */ public ProductInfo update(Long productId, String name, String description, int price, SellingStatus sellingStatus) { - return ProductInfo.from(productService.update(productId, name, description, price, sellingStatus)); + ProductInfo info = ProductInfo.from(productService.update(productId, name, description, price, sellingStatus)); + evictProductListCache(info.brandId()); + return info; } + /** + * 상품 삭제 전 brandId를 먼저 확보한 뒤 삭제 후 캐시를 무효화한다. + * delete는 void 반환이므로 삭제 전에 별도로 조회해 brandId를 얻는다. + */ public void delete(Long productId) { + Product product = productService.findById(productId); + Long brandId = product.getBrandId(); productService.delete(productId); + evictProductListCache(brandId); + } + + /** + * brandId와 관련된 캐시 키를 모두 삭제한다. + * - "product:list:all:*" : brandId 필터 없는 전체 목록 캐시 + * - "product:list:{brandId}:*" : 특정 브랜드 목록 캐시 + */ + private void evictProductListCache(Long brandId) { + deleteByPattern("product:list:all:*"); + if (brandId != null) { + deleteByPattern("product:list:" + brandId + ":*"); + } + } + + /** + * SCAN 기반으로 패턴에 일치하는 키를 순회해 삭제한다. + * KEYS 명령은 싱글 스레드 Redis를 블로킹할 수 있어 운영 환경에서 사용 금지. + */ + private void deleteByPattern(String pattern) { + masterRedisTemplate.execute((RedisCallback) connection -> { + ScanOptions options = ScanOptions.scanOptions().match(pattern).count(100).build(); + List keys = new ArrayList<>(); + try (var cursor = connection.keyCommands().scan(options)) { + cursor.forEachRemaining(keys::add); + } catch (Exception ignored) { + } + if (!keys.isEmpty()) { + connection.keyCommands().del(keys.toArray(new byte[0][])); + } + return null; + }); + } + + /** + * 캐시 키 형식: product:list:{brandId|all}:{sort}:0:{size} + * 예) product:list:all:latest:0:20 + * product:list:123:price_asc:0:20 + */ + private String buildCacheKey(Long brandId, Pageable pageable) { + String brandPart = brandId != null ? String.valueOf(brandId) : "all"; + String sortPart = sortKey(pageable.getSort()); + return "product:list:" + brandPart + ":" + sortPart + ":0:" + pageable.getPageSize(); + } + + /** + * Sort 객체를 캐시 키용 문자열로 변환한다. + * ProductV1Controller.toSort()의 역매핑. + */ + private String sortKey(Sort sort) { + if (!sort.isSorted()) return "latest"; + Sort.Order order = sort.iterator().next(); + String prop = order.getProperty(); + if (prop.equals("createdAt")) return "latest"; + if (prop.equals("price.value")) return "price_asc"; + if (prop.equals("likeCount.value")) return "likes_desc"; + return prop + "_" + order.getDirection().name().toLowerCase(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListCacheEntry.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListCacheEntry.java new file mode 100644 index 000000000..829538824 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListCacheEntry.java @@ -0,0 +1,11 @@ +package com.loopers.application.product; + +import java.util.List; + +/** + * Page는 PageImpl 역직렬화 이슈로 Redis에 직접 저장할 수 없어, + * 캐시 직렬화/역직렬화용 래퍼 record로 분리한다. + * 복원 시: new PageImpl<>(entry.content(), pageable, entry.totalElements()) + */ +public record ProductListCacheEntry(List content, long totalElements) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java index 20cf8a599..7160880cf 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -26,7 +26,7 @@ public ApiResponse getProducts( @RequestParam(required = false) Long brandId, @RequestParam(defaultValue = "latest") String sort, @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size + @RequestParam(defaultValue = "20") int size // 캐시 키 설계 기준 사이즈 (기본값 20) ) { PageRequest pageable = PageRequest.of(page, size, toSort(sort)); return ApiResponse.success(ProductV1Dto.ProductPageResponse.from(productFacade.findAll(brandId, pageable))); From 7a7f521712c9e1dfa737043aaeb18ca86fb5bb98 Mon Sep 17 00:00:00 2001 From: najang Date: Fri, 13 Mar 2026 00:30:42 +0900 Subject: [PATCH 4/5] =?UTF-8?q?test:=20=EC=83=81=ED=92=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20Cache-Aside=20E2E=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../interfaces/api/ProductV1ApiE2ETest.java | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java index 18d70978c..e2610da1c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java @@ -5,6 +5,7 @@ import com.loopers.domain.product.SellingStatus; import com.loopers.infrastructure.brand.BrandJpaRepository; import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.interfaces.api.product.ProductAdminV1Dto; import com.loopers.interfaces.api.product.ProductV1Dto; import com.loopers.utils.DatabaseCleanUp; import com.loopers.utils.RedisCleanUp; @@ -21,6 +22,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import static org.assertj.core.api.Assertions.assertThat; @@ -359,5 +361,121 @@ void returns404_whenProductNotFound() { // assert assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); } + + @DisplayName("상품 상세 조회 결과가 Redis에 캐시된다.") + @Test + void 상품_상세_조회_결과가_Redis에_캐시된다() { + // arrange + Brand brand = createBrand("Nike"); + Product product = createProduct(brand.getId(), "에어맥스", 100000, 0); + + // act + testRestTemplate.exchange( + "/api/v1/products/" + product.getId(), + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference>() {} + ); + + // assert + String cacheKey = "product:detail:" + product.getId(); + assertThat(redisTemplate.hasKey(cacheKey)).isTrue(); + } + + @DisplayName("캐시 적중 시 DB 변경 후에도 이전 결과를 반환한다.") + @Test + void 캐시_적중_시_DB_변경_후에도_이전_결과를_반환한다() { + // arrange + Brand brand = createBrand("Nike"); + Product product = createProduct(brand.getId(), "에어맥스", 100000, 0); + + testRestTemplate.exchange( + "/api/v1/products/" + product.getId(), + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference>() {} + ); + + // DB 직접 수정 (캐시 우회) + Product saved = productJpaRepository.findById(product.getId()).orElseThrow(); + saved.changeProductInfo("조던", null, 200000, SellingStatus.SELLING); + productJpaRepository.save(saved); + + // act: 두 번째 조회 (캐시 적중) + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/products/" + product.getId(), + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + + // assert: 캐시된 이전 이름이 반환됨 + assertThat(response.getBody().data().name()).isEqualTo("에어맥스"); + } + + @DisplayName("어드민 상품 수정 시 상세 캐시가 무효화된다.") + @Test + void 어드민_상품_수정_시_상세_캐시가_무효화된다() { + // arrange: 조회로 캐시 적재 + Brand brand = createBrand("Nike"); + Product product = createProduct(brand.getId(), "에어맥스", 100000, 0); + + testRestTemplate.exchange( + "/api/v1/products/" + product.getId(), + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference>() {} + ); + assertThat(redisTemplate.hasKey("product:detail:" + product.getId())).isTrue(); + + // act: 어드민 상품 수정 + ProductAdminV1Dto.UpdateRequest updateRequest = new ProductAdminV1Dto.UpdateRequest( + "조던", null, 200000, SellingStatus.SELLING + ); + HttpHeaders headers = createAdminHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + testRestTemplate.exchange( + "/api-admin/v1/products/" + product.getId(), + HttpMethod.PATCH, + new HttpEntity<>(updateRequest, headers), + Void.class + ); + + // assert: 캐시 무효화 후 재조회 시 변경된 데이터 반영 + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/products/" + product.getId(), + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + assertThat(response.getBody().data().name()).isEqualTo("조던"); + } + + @DisplayName("어드민 상품 삭제 시 상세 캐시가 무효화된다.") + @Test + void 어드민_상품_삭제_시_상세_캐시가_무효화된다() { + // arrange: 조회로 캐시 적재 + Brand brand = createBrand("Nike"); + Product product = createProduct(brand.getId(), "에어맥스", 100000, 0); + + testRestTemplate.exchange( + "/api/v1/products/" + product.getId(), + HttpMethod.GET, + new HttpEntity<>(null), + new ParameterizedTypeReference>() {} + ); + assertThat(redisTemplate.hasKey("product:detail:" + product.getId())).isTrue(); + + // act: 어드민 상품 삭제 + testRestTemplate.exchange( + "/api-admin/v1/products/" + product.getId(), + HttpMethod.DELETE, + new HttpEntity<>(createAdminHeaders()), + Void.class + ); + + // assert: 상세 캐시 키 삭제 확인 + assertThat(redisTemplate.hasKey("product:detail:" + product.getId())).isFalse(); + } } } From f92393374e7e38ca34e8364672096a1efab6ec3d Mon Sep 17 00:00:00 2001 From: najang Date: Fri, 13 Mar 2026 00:30:54 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20Cache-Aside=20=ED=8C=A8?= =?UTF-8?q?=ED=84=B4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../application/product/ProductFacade.java | 37 +++++++++++++++++-- .../application/product/ProductInfo.java | 8 ++++ 2 files changed, 42 insertions(+), 3 deletions(-) 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 4fab9b4ef..450deee39 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 @@ -55,10 +55,35 @@ public ProductInfo findById(Long productId) { return findById(productId, null); } + /** + * Cache-Aside 패턴으로 상품 상세를 조회한다. + * - 캐시 히트: isLiked는 사용자별 데이터이므로 캐시에 저장하지 않고 별도 조회 후 오버레이 + * - 캐시 미스: DB 조회 후 isLiked=null 상태로 캐시 저장 (TTL 1분), 이후 isLiked 오버레이 + * - 캐시 키: product:detail:{productId} + */ public ProductInfo findById(Long productId, Long userId) { + String cacheKey = "product:detail:" + productId; + + // 캐시 히트: isLiked를 별도 조회해 오버레이 후 반환 + String cached = redisTemplate.opsForValue().get(cacheKey); + if (cached != null) { + try { + ProductInfo info = objectMapper.readValue(cached, ProductInfo.class); + Boolean isLiked = userId != null ? likeService.isLiked(userId, productId) : null; + return info.withIsLiked(isLiked); + } catch (JsonProcessingException ignored) { + } + } + + // 캐시 미스: DB 조회 후 isLiked=null 상태로 캐시 저장 Product product = productService.findById(productId); + ProductInfo info = ProductInfo.from(product); + try { + masterRedisTemplate.opsForValue().set(cacheKey, objectMapper.writeValueAsString(info), Duration.ofMinutes(1)); + } catch (JsonProcessingException ignored) { + } Boolean isLiked = userId != null ? likeService.isLiked(userId, productId) : null; - return ProductInfo.from(product, isLiked); + return info.withIsLiked(isLiked); } /** @@ -100,17 +125,22 @@ public ProductInfo create(Long brandId, String name, String description, int pri } /** - * 상품 수정 후 해당 브랜드의 캐시를 무효화한다. + * 상품 수정 후 목록 캐시 및 상세 캐시를 무효화한다. + * - 목록 캐시: brandId 기반 SCAN 패턴 삭제 + * - 상세 캐시: product:detail:{productId} exact key 삭제 * update는 반환된 ProductInfo에서 brandId를 얻을 수 있으므로 별도 조회 불필요. */ public ProductInfo update(Long productId, String name, String description, int price, SellingStatus sellingStatus) { ProductInfo info = ProductInfo.from(productService.update(productId, name, description, price, sellingStatus)); evictProductListCache(info.brandId()); + masterRedisTemplate.delete("product:detail:" + productId); return info; } /** - * 상품 삭제 전 brandId를 먼저 확보한 뒤 삭제 후 캐시를 무효화한다. + * 상품 삭제 후 목록 캐시 및 상세 캐시를 무효화한다. + * - 목록 캐시: brandId 기반 SCAN 패턴 삭제 + * - 상세 캐시: product:detail:{productId} exact key 삭제 * delete는 void 반환이므로 삭제 전에 별도로 조회해 brandId를 얻는다. */ public void delete(Long productId) { @@ -118,6 +148,7 @@ public void delete(Long productId) { Long brandId = product.getBrandId(); productService.delete(productId); evictProductListCache(brandId); + masterRedisTemplate.delete("product:detail:" + productId); } /** diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java index 62ebed4b0..cb52720d2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -41,4 +41,12 @@ public static ProductInfo from(Product product, Boolean isLiked) { isLiked ); } + + /** + * isLiked만 교체한 새 인스턴스를 반환한다. + * Cache-Aside 조회 시 캐시에 저장된 상품 정보(isLiked=null)에 사용자별 좋아요 여부를 오버레이할 때 사용한다. + */ + public ProductInfo withIsLiked(Boolean isLiked) { + return new ProductInfo(id, brandId, name, description, price, stock, sellingStatus, likeCount, isLiked); + } }