Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
d0bc31e
refactor: 주문 목록 조회에서 불필요한 FETCH JOIN 제거 및 조회 경로 분리
iohyeon Mar 12, 2026
3ce4c19
refactor: 상품 JOIN/서브쿼리 모두 제거, getActiveBrandIds() + 리터럴 IN으로 전환
iohyeon Mar 12, 2026
b19176f
feat: 상품/주문 목록 API에 Cursor 기반 페이지네이션 적용 (COUNT 쿼리 제거)
iohyeon Mar 12, 2026
b336d85
feat: Redis Cache-Aside 패턴 적용 (상품 상세/목록, 주문 목록)
iohyeon Mar 12, 2026
7c04f8f
feat: 성능 테스트용 14개 테이블 대량 데이터 시더 구현
iohyeon Mar 12, 2026
5318045
feat: k6 성능 테스트 스크립트 추가 (목록/상세/부하)
iohyeon Mar 12, 2026
2c6124d
feat: k6 성능 테스트 스크립트 추가 (캐시 우회 테스트/DB 보호 효과 테스트-캐시 워밍업 후 20 VU 부하)
iohyeon Mar 12, 2026
02f07f1
fix: OrderSeeder 주문 금액과 주문상품 정합성 불일치 수정
iohyeon Mar 12, 2026
b95558b
feat: JPA Entity에 @Index 어노테이션 추가 (수동 DDL → 코드 관리 전환)
iohyeon Mar 12, 2026
5ef3650
fix: UserSeeder 실제 BCrypt 해시로 패스워드 설정
iohyeon Mar 13, 2026
31473e2
fix: 쿠폰 발급 http 수정
iohyeon Mar 13, 2026
9ec13d2
refactor: @Transactional(readOnly = true) 제거. Service에 이미 있으므로 캐시 히트…
iohyeon Mar 14, 2026
9608d46
refactor: LikeFacade 캐시 무효화 누락 수정
iohyeon Mar 14, 2026
36e1432
refactor: 인덱스 4개에서 deleted_at 제거, 정렬 인덱스를 커서 페이지네이션 최적화 구조로 변경
iohyeon Mar 17, 2026
ab24290
refactor: Caffeine 로컬 캐시
iohyeon Mar 17, 2026
8855e8e
refactor: Cache Decomposition(ID 리스트 + 상세 분리), SCAN, Redis 장애 fallback
iohyeon Mar 18, 2026
99afc9b
test: 목록 캐시가 ID만 저장하는 구조로 테스트코드 수정
iohyeon Mar 19, 2026
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
3 changes: 3 additions & 0 deletions apps/commerce-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ dependencies {
// web
implementation("org.springframework.boot:spring-boot-starter-web")

// local cache
implementation("com.github.ben-manes.caffeine:caffeine")

// security (BCrypt)
implementation("org.springframework.security:spring-security-crypto")
implementation("org.springframework.boot:spring-boot-starter-actuator")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.loopers.application.brand;

import com.loopers.application.cache.ProductCacheManager;
import com.loopers.application.product.ProductInfo;
import com.loopers.domain.brand.Brand;
import com.loopers.domain.brand.BrandService;
Expand All @@ -26,13 +27,16 @@ public class BrandAdminFacade {
private final ProductService productService;
private final InventoryService inventoryService;
private final CartItemService cartItemService;
private final ProductCacheManager productCacheManager;

public BrandAdminFacade(BrandService brandService, ProductService productService,
InventoryService inventoryService, CartItemService cartItemService) {
InventoryService inventoryService, CartItemService cartItemService,
ProductCacheManager productCacheManager) {
this.brandService = brandService;
this.productService = productService;
this.inventoryService = inventoryService;
this.cartItemService = cartItemService;
this.productCacheManager = productCacheManager;
}

/** 어드민 브랜드 상세 조회 (브랜드 + 전체 상품 목록, status 포함) */
Expand All @@ -54,11 +58,15 @@ public void deleteBrand(Long brandId) {
brandService.delete(brandId);

List<Product> products = productService.getAllProductsByBrandId(brandId);
List<Long> productIds = products.stream().map(Product::getId).toList();

for (Product product : products) {
productService.delete(product.getId());
inventoryService.delete(product.getId());
cartItemService.deleteByProductId(product.getId());
}

productCacheManager.registerBrandDeleteEvictAfterCommit(productIds);
}

/** 전체 브랜드 목록 페이지네이션 조회 */
Expand Down Expand Up @@ -89,10 +97,13 @@ public BrandInfo updateBrand(Long brandId, String name, String description) {
return BrandInfo.from(brand);
}

/** 브랜드 상태 변경 */
/** 브랜드 상태 변경 — 상품 목록 캐시 무효화 (브랜드 필터 변경) */
@Transactional
public BrandInfo changeBrandStatus(Long brandId, BrandStatus status) {
Brand brand = brandService.changeStatus(brandId, status);

productCacheManager.registerListOnlyEvictAfterCommit();

return BrandInfo.from(brand);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package com.loopers.application.cache;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loopers.application.order.OrderFacade;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;

import java.time.Duration;
import java.util.Optional;
import java.util.concurrent.ThreadLocalRandom;

/**
* 주문 목록 캐시 매니저
*
* Cache-Aside 패턴의 캐시 읽기/저장/무효화를 담당한다.
* 무효화 전략: afterCommit DELETE + TTL 안전망 (Double Delete 안 함)
*
* 키 설계:
* 주문 목록: orders:list:{userId}
* - 첫 페이지(cursor=null) + 기본 조회(최근 3개월)만 캐싱
* - 커스텀 기간 조회는 캐시 우회
*/
@Component
public class OrderCacheManager {

private static final Logger log = LoggerFactory.getLogger(OrderCacheManager.class);

private static final String LIST_KEY_PREFIX = "orders:list:";
private static final int LIST_TTL_BASE = 120;
private static final int LIST_TTL_JITTER = 15;

private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;

public OrderCacheManager(RedisTemplate<String, String> redisTemplate,
ObjectMapper objectMapper) {
this.redisTemplate = redisTemplate;
this.objectMapper = objectMapper;
}

// ── 주문 목록 캐시 ──

public Optional<OrderFacade.OrderCursorResult> getOrderList(Long userId) {
String key = LIST_KEY_PREFIX + userId;
String json = redisTemplate.opsForValue().get(key);
if (json == null) {
return Optional.empty();
}
try {
return Optional.of(objectMapper.readValue(json, OrderFacade.OrderCursorResult.class));
} catch (JsonProcessingException e) {
log.warn("주문 목록 캐시 역직렬화 실패 (userId={}), 캐시 삭제 후 DB 조회", userId, e);
redisTemplate.delete(key);
return Optional.empty();
}
}

public void putOrderList(Long userId, OrderFacade.OrderCursorResult result) {
String key = LIST_KEY_PREFIX + userId;
try {
String json = objectMapper.writeValueAsString(result);
redisTemplate.opsForValue().set(key, json, ttlWithJitter());
} catch (JsonProcessingException e) {
log.warn("주문 목록 캐시 직렬화 실패 (userId={})", userId, e);
}
}

/**
* @Transactional 메서드에서 호출 — afterCommit에서 캐시 삭제.
*/
public void registerEvictAfterCommit(Long userId) {
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
evictOrderList(userId);
}
}
);
}

/**
* txTemplate 등 프로그래밍 방식 트랜잭션에서 커밋 후 직접 호출.
*/
public void evictOrderList(Long userId) {
redisTemplate.delete(LIST_KEY_PREFIX + userId);
}

private Duration ttlWithJitter() {
int jitter = ThreadLocalRandom.current().nextInt(-LIST_TTL_JITTER, LIST_TTL_JITTER + 1);
return Duration.ofSeconds(LIST_TTL_BASE + jitter);
}
}
Loading