Skip to content

Commit 4003796

Browse files
committed
feat: 좋아요 기능 개선 - Redis 캐시 적용 및 타입 안정성 향상
좋아요 조회/등록 성능 개선을 위한 캐시 레이어 추가 및 memberId 타입 변경 - ProductLikeCountCache: 상품별 좋아요 수 Redis 캐싱 (INCR/DECR) - MemberLikesCache: 회원별 좋아요 상품 목록 Redis 캐싱 - LikeCountSyncScheduler: DB와 Redis 좋아요 수 주기적 동기화 - LikeService/LikeReadService: 캐시 우선 조회 로직 적용 - LikeRepository: 배치 조회 메서드 추가로 N+1 문제 해결 - memberId 타입을 String에서 Long으로 변경하여 타입 안정성 향상
1 parent af777a8 commit 4003796

17 files changed

Lines changed: 596 additions & 114 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ public class LikeFacade {
1212

1313
private final LikeService likeService;
1414

15-
public void likeProduct(String memberId, Long productId) {
15+
public void likeProduct(Long memberId, Long productId) {
1616
likeService.like(memberId, productId);
1717
}
1818

19-
public void unlikeProduct(String memberId, Long productId) {
19+
public void unlikeProduct(Long memberId, Long productId) {
2020
likeService.unlike(memberId, productId);
2121
}
2222
}

apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,21 @@
2121
)
2222
public class Like extends BaseEntity {
2323

24-
@Column(name = "member_id", nullable = false, length = 10)
25-
private String memberId;
24+
@Column(name = "member_id", nullable = false)
25+
private Long memberId;
2626

2727
@Column(name = "product_id", nullable = false)
2828
private Long productId;
2929

30-
public Like(String memberId, Long productId) {
30+
public Like(Long memberId, Long productId) {
3131
validateMemberId(memberId);
3232
validateProductId(productId);
3333
this.memberId = memberId;
3434
this.productId = productId;
3535
}
3636

37-
private static void validateMemberId(String memberId) {
38-
if (memberId == null || memberId.trim().isEmpty()) {
37+
private static void validateMemberId(Long memberId) {
38+
if (memberId == null) {
3939
throw new CoreException(ErrorType.BAD_REQUEST, "회원 ID는 필수입니다.");
4040
}
4141
}

apps/commerce-api/src/main/java/com/loopers/domain/like/repository/LikeRepository.java

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,18 @@
66
import java.util.Set;
77

88
public interface LikeRepository {
9-
10-
Optional<Like> findByMemberIdAndProductId(String memberId, Long productId);
11-
12-
boolean existsByMemberIdAndProductId(String memberId, Long productId);
13-
9+
10+
Optional<Like> findByMemberIdAndProductId(Long memberId, Long productId);
11+
12+
boolean existsByMemberIdAndProductId(Long memberId, Long productId);
13+
1414
long countByProductId(Long productId);
15-
15+
1616
Like save(Like like);
17-
18-
void deleteByMemberIdAndProductId(String memberId, Long productId);
19-
20-
Set<Long> findLikedProductIds(String memberId, List<Long> productIds);
17+
18+
void deleteByMemberIdAndProductId(Long memberId, Long productId);
19+
20+
Set<Long> findLikedProductIds(Long memberId, List<Long> productIds);
21+
22+
Set<Long> findLikedProductIdsByMemberId(Long memberId);
2123
}
Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,65 @@
11
package com.loopers.domain.like.service;
22

33
import com.loopers.domain.like.repository.LikeRepository;
4+
import com.loopers.infrastructure.cache.MemberLikesCache;
45
import lombok.RequiredArgsConstructor;
6+
import lombok.extern.slf4j.Slf4j;
57
import org.springframework.stereotype.Component;
68
import org.springframework.transaction.annotation.Transactional;
79

810
import java.util.List;
911
import java.util.Set;
1012

13+
@Slf4j
1114
@RequiredArgsConstructor
1215
@Component
1316
@Transactional(readOnly = true)
1417
public class LikeReadService {
1518

1619
private final LikeRepository likeRepository;
20+
private final MemberLikesCache memberLikesCache;
1721

1822
public long countByProductId(Long productId) {
1923
return likeRepository.countByProductId(productId);
2024
}
2125

22-
public boolean isLikedBy(String memberId, Long productId) {
26+
public boolean isLikedBy(Long memberId, Long productId) {
2327
if (memberId == null) {
2428
return false;
2529
}
26-
return likeRepository.existsByMemberIdAndProductId(memberId, productId);
30+
31+
// 1. 캐시 존재 여부 확인
32+
if (!memberLikesCache.exists(memberId)) {
33+
// 캐시 miss: DB에서 전체 좋아요 목록 조회 후 캐시 워밍
34+
Set<Long> likedProductIds = likeRepository.findLikedProductIdsByMemberId(memberId);
35+
memberLikesCache.initialize(memberId, likedProductIds);
36+
log.debug("[LikeReadService] Cache warmed for memberId={}, count={}", memberId, likedProductIds.size());
37+
return likedProductIds.contains(productId);
38+
}
39+
40+
// 2. 캐시에서 조회
41+
return memberLikesCache.isMember(memberId, productId);
2742
}
2843

29-
public Set<Long> findLikedProductIds(String memberId, List<Long> productIds) {
30-
return likeRepository.findLikedProductIds(memberId, productIds);
44+
public Set<Long> findLikedProductIds(Long memberId, List<Long> productIds) {
45+
if (memberId == null || productIds.isEmpty()) {
46+
return Set.of();
47+
}
48+
49+
// 1. 캐시 존재 여부 확인
50+
if (!memberLikesCache.exists(memberId)) {
51+
// 캐시 miss: DB에서 전체 좋아요 목록 조회 후 캐시 워밍
52+
Set<Long> allLikedProductIds = likeRepository.findLikedProductIdsByMemberId(memberId);
53+
memberLikesCache.initialize(memberId, allLikedProductIds);
54+
log.debug("[LikeReadService] Cache warmed for memberId={}, count={}", memberId, allLikedProductIds.size());
55+
56+
// productIds 중 좋아요한 것만 필터링
57+
return productIds.stream()
58+
.filter(allLikedProductIds::contains)
59+
.collect(java.util.stream.Collectors.toSet());
60+
}
61+
62+
// 2. 캐시에서 조회
63+
return memberLikesCache.findLikedProductIds(memberId, productIds);
3164
}
3265
}

apps/commerce-api/src/main/java/com/loopers/domain/like/service/LikeService.java

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
import com.loopers.domain.like.repository.LikeRepository;
55
import com.loopers.domain.product.Product;
66
import com.loopers.domain.product.repository.ProductRepository;
7+
import com.loopers.infrastructure.cache.CacheInvalidationService;
8+
import com.loopers.infrastructure.cache.MemberLikesCache;
9+
import com.loopers.infrastructure.cache.ProductLikeCountCache;
710
import com.loopers.support.error.CoreException;
811
import com.loopers.support.error.ErrorType;
912
import lombok.RequiredArgsConstructor;
@@ -15,30 +18,51 @@ public class LikeService {
1518

1619
private final LikeRepository likeRepository;
1720
private final ProductRepository productRepository;
21+
private final CacheInvalidationService cacheInvalidationService;
22+
private final MemberLikesCache memberLikesCache;
23+
private final ProductLikeCountCache productLikeCountCache;
1824

19-
public void like(String memberId, Long productId) {
25+
public void like(Long memberId, Long productId) {
2026
if (likeRepository.existsByMemberIdAndProductId(memberId, productId)) {
2127
return;
2228
}
2329

30+
// 1. 상품 존재 확인
31+
Product product = productRepository.findById(productId)
32+
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
33+
34+
// 2. DB 저장
2435
likeRepository.save(new Like(memberId, productId));
2536

26-
int updated = productRepository.incrementLikeCount(productId);
27-
if (updated == 0) {
28-
throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.");
29-
}
37+
// 3. like_count 캐시 증가 (Redis INCR)
38+
productLikeCountCache.increment(productId);
39+
40+
// 4. 회원 좋아요 캐시 업데이트
41+
memberLikesCache.add(memberId, productId);
42+
43+
// 5. 상품 캐시 무효화
44+
cacheInvalidationService.invalidateOnLikeChange(productId, product.getBrandId());
3045
}
3146

32-
public void unlike(String memberId, Long productId) {
47+
public void unlike(Long memberId, Long productId) {
3348
if (!likeRepository.existsByMemberIdAndProductId(memberId, productId)) {
3449
return;
3550
}
3651

52+
// 1. 상품 존재 확인
53+
Product product = productRepository.findById(productId)
54+
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
55+
56+
// 2. DB 삭제
3757
likeRepository.deleteByMemberIdAndProductId(memberId, productId);
3858

39-
int updated = productRepository.decrementLikeCount(productId);
40-
if (updated == 0) {
41-
throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.");
42-
}
59+
// 3. like_count 캐시 감소 (Redis DECR)
60+
productLikeCountCache.decrement(productId);
61+
62+
// 4. 회원 좋아요 캐시 업데이트
63+
memberLikesCache.remove(memberId, productId);
64+
65+
// 5. 상품 캐시 무효화
66+
cacheInvalidationService.invalidateOnLikeChange(productId, product.getBrandId());
4367
}
4468
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package com.loopers.infrastructure.cache;
2+
3+
import com.loopers.domain.product.repository.ProductRepository;
4+
import lombok.RequiredArgsConstructor;
5+
import lombok.extern.slf4j.Slf4j;
6+
import org.springframework.scheduling.annotation.Scheduled;
7+
import org.springframework.stereotype.Component;
8+
import org.springframework.transaction.annotation.Transactional;
9+
10+
import java.util.Map;
11+
12+
/**
13+
* 좋아요 카운터 동기화 스케줄러
14+
* Redis의 좋아요 카운트를 주기적으로 DB에 동기화
15+
*/
16+
@Slf4j
17+
@RequiredArgsConstructor
18+
@Component
19+
public class LikeCountSyncScheduler {
20+
21+
private final ProductLikeCountCache productLikeCountCache;
22+
private final ProductRepository productRepository;
23+
24+
/**
25+
* 1분마다 Redis → DB 동기화
26+
*/
27+
@Scheduled(fixedDelay = 60000) // 1분
28+
@Transactional
29+
public void syncLikeCountsToDatabase() {
30+
try {
31+
// 1. Redis에서 모든 카운터 조회
32+
Map<Long, Long> cacheCounts = productLikeCountCache.getAllCounts();
33+
34+
if (cacheCounts.isEmpty()) {
35+
log.debug("[LikeCountSyncScheduler] No cache counts to sync");
36+
return;
37+
}
38+
39+
int successCount = 0;
40+
int failCount = 0;
41+
42+
// 2. 각 상품의 카운트를 DB에 업데이트
43+
for (Map.Entry<Long, Long> entry : cacheCounts.entrySet()) {
44+
Long productId = entry.getKey();
45+
Long count = entry.getValue();
46+
47+
try {
48+
int updated = productRepository.updateLikeCount(productId, count);
49+
50+
if (updated > 0) {
51+
successCount++;
52+
log.debug("[LikeCountSyncScheduler] Synced productId={}, count={}", productId, count);
53+
} else {
54+
failCount++;
55+
log.warn("[LikeCountSyncScheduler] Product not found for sync: productId={}", productId);
56+
}
57+
} catch (Exception e) {
58+
failCount++;
59+
log.error("[LikeCountSyncScheduler] Failed to sync productId={}, error={}", productId, e.getMessage());
60+
}
61+
}
62+
63+
log.info("[LikeCountSyncScheduler] Sync completed. total={}, success={}, fail={}",
64+
cacheCounts.size(), successCount, failCount);
65+
66+
} catch (Exception e) {
67+
log.error("[LikeCountSyncScheduler] Sync failed with error={}", e.getMessage(), e);
68+
}
69+
}
70+
}

0 commit comments

Comments
 (0)