Skip to content

Commit ec0e3a6

Browse files
authored
feature: 상품 좋아요 수에 로컬캐시 적용 (#21)
1 parent 9631946 commit ec0e3a6

4 files changed

Lines changed: 161 additions & 5 deletions

File tree

apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogProductFacade.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,8 @@ public ProductInfoList getProducts(Long brandId, String sort, int page, int size
9494
// 캐시 저장
9595
productCacheService.cacheProductList(brandId, normalizedSort, page, size, result);
9696

97-
return result;
97+
// 로컬 캐시의 좋아요 수 델타 적용 (DB 조회 결과에도 델타 반영)
98+
return productCacheService.applyLikeCountDelta(result);
9899
}
99100

100101
/**
@@ -133,7 +134,8 @@ public ProductInfo getProduct(Long productId) {
133134
// 캐시에 저장
134135
productCacheService.cacheProduct(productId, result);
135136

136-
return result;
137+
// 로컬 캐시의 좋아요 수 델타 적용 (DB 조회 결과에도 델타 반영)
138+
return productCacheService.applyLikeCountDelta(result);
137139
}
138140

139141
}

apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductCacheService.java

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@
22

33
import com.fasterxml.jackson.core.type.TypeReference;
44
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import com.loopers.domain.product.ProductDetail;
56
import lombok.RequiredArgsConstructor;
67
import org.springframework.data.redis.core.RedisTemplate;
78
import org.springframework.stereotype.Service;
89

910
import java.time.Duration;
11+
import java.util.List;
12+
import java.util.concurrent.ConcurrentHashMap;
13+
import java.util.stream.Collectors;
1014

1115
/**
1216
* 상품 조회 결과를 Redis에 캐시하는 서비스.
@@ -33,13 +37,25 @@ public class ProductCacheService {
3337

3438
private final RedisTemplate<String, String> redisTemplate;
3539
private final ObjectMapper objectMapper;
40+
41+
/**
42+
* 로컬 캐시: 상품별 좋아요 수 델타 (productId -> likeCount delta)
43+
* <p>
44+
* 좋아요 추가/취소 시 델타를 저장하고, 캐시 조회 시 델타를 적용하여 반환합니다.
45+
* 배치 집계 후에는 초기화됩니다.
46+
* </p>
47+
*/
48+
private final ConcurrentHashMap<Long, Long> likeCountDeltaCache = new ConcurrentHashMap<>();
3649

3750
/**
3851
* 상품 목록 조회 결과를 캐시에서 조회합니다.
3952
* <p>
4053
* 페이지 번호와 관계없이 캐시를 확인하고, 캐시에 있으면 반환합니다.
4154
* 캐시에 없으면 null을 반환하여 DB 조회를 유도합니다.
4255
* </p>
56+
* <p>
57+
* 로컬 캐시의 좋아요 수 델타를 적용하여 반환합니다.
58+
* </p>
4359
*
4460
* @param brandId 브랜드 ID (null이면 전체)
4561
* @param sort 정렬 기준
@@ -55,7 +71,11 @@ public ProductInfoList getCachedProductList(Long brandId, String sort, int page,
5571
if (cachedValue == null) {
5672
return null;
5773
}
58-
return objectMapper.readValue(cachedValue, new TypeReference<ProductInfoList>() {});
74+
75+
ProductInfoList cachedList = objectMapper.readValue(cachedValue, new TypeReference<ProductInfoList>() {});
76+
77+
// 로컬 캐시의 좋아요 수 델타 적용
78+
return applyLikeCountDelta(cachedList);
5979
} catch (Exception e) {
6080
return null;
6181
}
@@ -90,6 +110,9 @@ public void cacheProductList(Long brandId, String sort, int page, int size, Prod
90110

91111
/**
92112
* 상품 상세 조회 결과를 캐시에서 조회합니다.
113+
* <p>
114+
* 로컬 캐시의 좋아요 수 델타를 적용하여 반환합니다.
115+
* </p>
93116
*
94117
* @param productId 상품 ID
95118
* @return 캐시된 상품 정보 (없으면 null)
@@ -102,7 +125,11 @@ public ProductInfo getCachedProduct(Long productId) {
102125
if (cachedValue == null) {
103126
return null;
104127
}
105-
return objectMapper.readValue(cachedValue, new TypeReference<ProductInfo>() {});
128+
129+
ProductInfo cachedInfo = objectMapper.readValue(cachedValue, new TypeReference<ProductInfo>() {});
130+
131+
// 로컬 캐시의 좋아요 수 델타 적용
132+
return applyLikeCountDelta(cachedInfo);
106133
} catch (Exception e) {
107134
return null;
108135
}
@@ -150,5 +177,96 @@ private String buildListCacheKey(Long brandId, String sort, int page, int size)
150177
private String buildDetailCacheKey(Long productId) {
151178
return CACHE_KEY_PREFIX_DETAIL + productId;
152179
}
180+
181+
/**
182+
* 좋아요 수 델타를 증가시킵니다.
183+
* <p>
184+
* 좋아요 추가 시 호출됩니다.
185+
* </p>
186+
*
187+
* @param productId 상품 ID
188+
*/
189+
public void incrementLikeCountDelta(Long productId) {
190+
likeCountDeltaCache.merge(productId, 1L, Long::sum);
191+
}
192+
193+
/**
194+
* 좋아요 수 델타를 감소시킵니다.
195+
* <p>
196+
* 좋아요 취소 시 호출됩니다.
197+
* </p>
198+
*
199+
* @param productId 상품 ID
200+
*/
201+
public void decrementLikeCountDelta(Long productId) {
202+
likeCountDeltaCache.merge(productId, -1L, Long::sum);
203+
}
204+
205+
/**
206+
* 모든 좋아요 수 델타를 초기화합니다.
207+
* <p>
208+
* 배치 집계 후 호출됩니다.
209+
* </p>
210+
*/
211+
public void clearAllLikeCountDelta() {
212+
likeCountDeltaCache.clear();
213+
}
214+
215+
/**
216+
* 상품 목록에 좋아요 수 델타를 적용합니다.
217+
* <p>
218+
* DB에서 직접 조회한 결과에도 델타를 적용하기 위해 public으로 제공합니다.
219+
* </p>
220+
*
221+
* @param productInfoList 상품 목록
222+
* @return 델타가 적용된 상품 목록
223+
*/
224+
public ProductInfoList applyLikeCountDelta(ProductInfoList productInfoList) {
225+
if (likeCountDeltaCache.isEmpty()) {
226+
return productInfoList;
227+
}
228+
229+
List<ProductInfo> updatedProducts = productInfoList.products().stream()
230+
.map(this::applyLikeCountDelta)
231+
.collect(Collectors.toList());
232+
233+
return new ProductInfoList(
234+
updatedProducts,
235+
productInfoList.totalCount(),
236+
productInfoList.page(),
237+
productInfoList.size()
238+
);
239+
}
240+
241+
/**
242+
* 상품 정보에 좋아요 수 델타를 적용합니다.
243+
* <p>
244+
* DB에서 직접 조회한 결과에도 델타를 적용하기 위해 public으로 제공합니다.
245+
* </p>
246+
*
247+
* @param productInfo 상품 정보
248+
* @return 델타가 적용된 상품 정보
249+
*/
250+
public ProductInfo applyLikeCountDelta(ProductInfo productInfo) {
251+
Long delta = likeCountDeltaCache.get(productInfo.productDetail().getId());
252+
if (delta == null || delta == 0) {
253+
return productInfo;
254+
}
255+
256+
ProductDetail originalDetail = productInfo.productDetail();
257+
Long updatedLikesCount = originalDetail.getLikesCount() + delta;
258+
259+
ProductDetail updatedDetail = ProductDetail.of(
260+
originalDetail.getId(),
261+
originalDetail.getName(),
262+
originalDetail.getPrice(),
263+
originalDetail.getStock(),
264+
originalDetail.getBrandId(),
265+
originalDetail.getBrandName(),
266+
updatedLikesCount
267+
);
268+
269+
return new ProductInfo(updatedDetail);
270+
}
153271
}
154272

apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.loopers.application.like;
22

3+
import com.loopers.application.catalog.ProductCacheService;
34
import com.loopers.domain.like.Like;
45
import com.loopers.domain.like.LikeRepository;
56
import com.loopers.domain.product.Product;
@@ -32,6 +33,7 @@ public class LikeFacade {
3233
private final LikeRepository likeRepository;
3334
private final UserRepository userRepository;
3435
private final ProductRepository productRepository;
36+
private final ProductCacheService productCacheService;
3537

3638
/**
3739
* 상품에 좋아요를 추가합니다.
@@ -78,11 +80,14 @@ public void addLike(String userId, Long productId) {
7880
Like like = Like.of(user.getId(), productId);
7981
try {
8082
likeRepository.save(like);
83+
// 좋아요 추가 성공 시 로컬 캐시의 델타 증가
84+
productCacheService.incrementLikeCountDelta(productId);
8185
} catch (org.springframework.dao.DataIntegrityViolationException e) {
8286
// UNIQUE 제약조건 위반 = 이미 저장됨 (멱등성 보장)
8387
// 동시에 여러 요청이 들어와서 모두 "없음"으로 판단하고 저장을 시도할 때,
8488
// 첫 번째만 성공하고 나머지는 UNIQUE 제약조건 위반 예외 발생
8589
// 이미 좋아요가 존재하는 경우이므로 정상 처리로 간주
90+
// 로컬 캐시는 업데이트하지 않음 (이미 좋아요가 존재하므로)
8691
}
8792
}
8893

@@ -107,9 +112,12 @@ public void removeLike(String userId, Long productId) {
107112

108113
try {
109114
likeRepository.delete(like.get());
115+
// 좋아요 취소 성공 시 로컬 캐시의 델타 감소
116+
productCacheService.decrementLikeCountDelta(productId);
110117
} catch (Exception e) {
111118
// 동시성 상황에서 이미 삭제된 경우 등 예외 발생 가능
112119
// 멱등성 보장: 이미 삭제된 경우 정상 처리로 간주
120+
// 로컬 캐시는 업데이트하지 않음 (이미 삭제되었으므로)
113121
}
114122
}
115123

apps/commerce-api/src/main/java/com/loopers/config/batch/LikeCountSyncBatchConfig.java

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
package com.loopers.config.batch;
22

3+
import com.loopers.application.catalog.ProductCacheService;
34
import com.loopers.domain.like.LikeRepository;
4-
import com.loopers.domain.product.Product;
55
import com.loopers.domain.product.ProductRepository;
66
import lombok.RequiredArgsConstructor;
77
import lombok.extern.slf4j.Slf4j;
8+
import org.springframework.batch.core.ExitStatus;
89
import org.springframework.batch.core.Job;
910
import org.springframework.batch.core.Step;
11+
import org.springframework.batch.core.StepExecution;
12+
import org.springframework.batch.core.StepExecutionListener;
1013
import org.springframework.batch.core.configuration.annotation.StepScope;
1114
import org.springframework.batch.core.job.builder.JobBuilder;
1215
import org.springframework.batch.core.repository.JobRepository;
@@ -57,6 +60,7 @@ public class LikeCountSyncBatchConfig {
5760
private final PlatformTransactionManager transactionManager;
5861
private final ProductRepository productRepository;
5962
private final LikeRepository likeRepository;
63+
private final ProductCacheService productCacheService;
6064

6165
private static final int CHUNK_SIZE = 100; // 청크 크기: 100개씩 처리
6266

@@ -91,6 +95,7 @@ public Step likeCountSyncStep() {
9195
.reader(productIdReader())
9296
.processor(productLikeCountProcessor())
9397
.writer(productLikeCountWriter())
98+
.listener(likeCountSyncStepListener())
9499
.allowStartIfComplete(true) // ✅ 완료된 Step도 재실행 가능 (스케줄러에서 주기적 실행)
95100
.build();
96101
}
@@ -159,6 +164,29 @@ public ItemWriter<ProductLikeCount> productLikeCountWriter() {
159164
};
160165
}
161166

167+
/**
168+
* 좋아요 수 동기화 Step 완료 후 로컬 캐시를 초기화하는 Listener를 생성합니다.
169+
* <p>
170+
* 배치 집계가 완료되면 정확한 값으로 DB가 업데이트되므로,
171+
* 로컬 캐시의 델타를 초기화하여 다음 배치까지의 델타만 추적합니다.
172+
* </p>
173+
*
174+
* @return StepExecutionListener
175+
*/
176+
@Bean
177+
public StepExecutionListener likeCountSyncStepListener() {
178+
return new StepExecutionListener() {
179+
@Override
180+
public ExitStatus afterStep(StepExecution stepExecution) {
181+
// 배치 집계 완료 후 모든 로컬 캐시 델타 초기화
182+
// 배치가 정확한 값으로 DB를 업데이트했으므로, 델타는 0부터 다시 시작
183+
productCacheService.clearAllLikeCountDelta();
184+
log.debug("좋아요 수 동기화 배치 완료: 로컬 캐시 델타 초기화");
185+
return stepExecution.getExitStatus();
186+
}
187+
};
188+
}
189+
162190
/**
163191
* 상품 ID와 좋아요 수를 담는 레코드.
164192
*

0 commit comments

Comments
 (0)