diff --git a/src/main/java/com/hf/healthfriend/HealthfriendApplication.java b/src/main/java/com/hf/healthfriend/HealthfriendApplication.java index 63023463..735d84b2 100644 --- a/src/main/java/com/hf/healthfriend/HealthfriendApplication.java +++ b/src/main/java/com/hf/healthfriend/HealthfriendApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling public class HealthfriendApplication { public static void main(String[] args) { diff --git a/src/main/java/com/hf/healthfriend/domain/like/service/LikeService.java b/src/main/java/com/hf/healthfriend/domain/like/service/LikeService.java index e0844f90..33fdcf11 100644 --- a/src/main/java/com/hf/healthfriend/domain/like/service/LikeService.java +++ b/src/main/java/com/hf/healthfriend/domain/like/service/LikeService.java @@ -60,7 +60,6 @@ public Long addPostLike(Long memberId, Long postId) throws DuplicatePostLikeExce ); try { Like savedLike = this.likeRepository.save(like); - // TODO 동시성 처리 필요 postRepository.incrementLikeCount(postId); notificationPublishService.publishPostLikeNot(memberId, postId); savePopularPost(postId); @@ -75,7 +74,6 @@ public Long addPostLike(Long memberId, Long postId) throws DuplicatePostLikeExce throw new DuplicatePostLikeException(postId, memberId); } like.uncancel(); - // TODO 동시성 처리 필요 postRepository.incrementLikeCount(postId); savePopularPost(postId); notificationPublishService.publishPostLikeNot(memberId, postId); diff --git a/src/main/java/com/hf/healthfriend/domain/post/dto/response/PostGetResponse.java b/src/main/java/com/hf/healthfriend/domain/post/dto/response/PostGetResponse.java index f268663b..491480dd 100644 --- a/src/main/java/com/hf/healthfriend/domain/post/dto/response/PostGetResponse.java +++ b/src/main/java/com/hf/healthfriend/domain/post/dto/response/PostGetResponse.java @@ -24,7 +24,7 @@ public record PostGetResponse( Long commentCount, List comments ) { - public static PostGetResponse of(Post post, List comments, String imagePath, String writerProfileImageUrl) { + public static PostGetResponse of(Post post, List comments,Long viewCountFromRedis ,String imagePath, String writerProfileImageUrl) { return PostGetResponse.builder() .postId(post.getPostId()) .writerId(post.getMember().getId()) @@ -36,7 +36,7 @@ public static PostGetResponse of(Post post, List comments, String im .content(post.getContent()) .imagePath(imagePath) .createDate(post.getCreationTime()) - .viewCount(post.getViewCount()) + .viewCount(viewCountFromRedis) .likeCount(post.getLikesCount()) .commentCount(post.getCommentsCount()) .comments(comments) diff --git a/src/main/java/com/hf/healthfriend/domain/post/dto/response/PostListObject.java b/src/main/java/com/hf/healthfriend/domain/post/dto/response/PostListObject.java index 16bcc8bf..622ea962 100644 --- a/src/main/java/com/hf/healthfriend/domain/post/dto/response/PostListObject.java +++ b/src/main/java/com/hf/healthfriend/domain/post/dto/response/PostListObject.java @@ -19,12 +19,12 @@ public record PostListObject( String fitnessLevel, String writerProfileImageUrl ) { - public static PostListObject of(Post post, String content, FileUrlResolver fileUrlResolver) { + public static PostListObject of(Post post, String content, FileUrlResolver fileUrlResolver, long viewCountFromRedis) { return PostListObject.builder() .postId(post.getPostId()) .title(post.getTitle()) .category(post.getCategory().name()) - .viewCount(post.getViewCount()) + .viewCount(viewCountFromRedis) .creationTime(post.getCreationTime()) .content(content) .fitnessLevel(post.getMember().getFitnessLevel().name()) diff --git a/src/main/java/com/hf/healthfriend/domain/post/entity/Post.java b/src/main/java/com/hf/healthfriend/domain/post/entity/Post.java index 2c02c053..59b039ca 100644 --- a/src/main/java/com/hf/healthfriend/domain/post/entity/Post.java +++ b/src/main/java/com/hf/healthfriend/domain/post/entity/Post.java @@ -77,8 +77,8 @@ public void update(String title, String content, PostCategory category) { this.category = category; } - public void updateViewCount(Long viewCount) { - this.viewCount = viewCount+1; + public void updateViewCount(long newViewCount) { + this.viewCount = newViewCount; } diff --git a/src/main/java/com/hf/healthfriend/domain/post/repository/querydsl/PostCustomRepositoryImpl.java b/src/main/java/com/hf/healthfriend/domain/post/repository/querydsl/PostCustomRepositoryImpl.java index cc5a3e1d..5d549937 100644 --- a/src/main/java/com/hf/healthfriend/domain/post/repository/querydsl/PostCustomRepositoryImpl.java +++ b/src/main/java/com/hf/healthfriend/domain/post/repository/querydsl/PostCustomRepositoryImpl.java @@ -3,6 +3,7 @@ import com.hf.healthfriend.domain.member.constant.FitnessLevel; import com.hf.healthfriend.domain.post.constant.PostCategory; import com.hf.healthfriend.domain.post.dto.response.PostListObject; +import com.hf.healthfriend.domain.post.entity.Post; import com.hf.healthfriend.domain.post.entity.QPost; import com.hf.healthfriend.global.file.FileUrlResolver; import com.querydsl.core.BooleanBuilder; @@ -10,7 +11,10 @@ import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.dsl.Expressions; import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.HashMap; +import java.util.Map; import lombok.RequiredArgsConstructor; +import org.redisson.api.RedissonClient; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; @@ -20,6 +24,7 @@ @RequiredArgsConstructor public class PostCustomRepositoryImpl implements PostCustomRepository { + private final RedissonClient redissonClient; private final FileUrlResolver fileUrlResolver; private final JPAQueryFactory queryFactory; private final QPost post = QPost.post; @@ -28,49 +33,36 @@ public class PostCustomRepositoryImpl implements PostCustomRepository { public List getList(FitnessLevel fitnessLevel, PostCategory postCategory, String keyword, Pageable pageable) { OrderSpecifier orderSpecifier = new OrderSpecifier<>(Order.DESC, post.creationTime); BooleanBuilder builder = filter(fitnessLevel, postCategory, keyword); - return queryFactory + + List posts = queryFactory .selectFrom(post) .where(builder) .groupBy(post) .orderBy(orderSpecifier) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) - .fetch() - .stream().map(post-> { - String content = post.getContent(); - if (keyword!=null){ - String sentence = getSentenceContainKeyword(keyword,post.getContent()); - content = (sentence!=null)?sentence:content; - } - return PostListObject.of(post,content,fileUrlResolver); - }).toList(); + .fetch(); + + return convertPostsToDto(posts, keyword); } @Override public List getPopularList(List postIdList, FitnessLevel fitnessLevel, String keyword, Pageable pageable) { BooleanBuilder builder = filter(fitnessLevel, null, keyword); OrderSpecifier[] orderSpecifier = new OrderSpecifier[]{ - /* 어차피 sortedSet 에서 정렬돼있기 때문에 실제론 수행되지 않는다. - 2차로 최신순 정렬을 수행하려고 쓸 뿐이다. - */ new OrderSpecifier<>(Order.DESC, post.likesCount), new OrderSpecifier<>(Order.DESC, post.creationTime) }; - return queryFactory + + List posts = queryFactory .selectFrom(post) .where(post.postId.in(postIdList).and(builder)) .orderBy(orderSpecifier) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) - .fetch() - .stream().map(post-> { - String content = post.getContent(); - if (keyword!=null){ - String sentence = getSentenceContainKeyword(keyword,post.getContent()); - content = (sentence!=null)?sentence:content; - } - return PostListObject.of(post,content,fileUrlResolver); - }).toList(); + .fetch(); + + return convertPostsToDto(posts, keyword); } @Override @@ -124,4 +116,31 @@ public Long getTotalPageSize(int size){ if (totalPageSize == null) return 0L; return (long) Math.ceil((double) totalPageSize / size); } + + private Map getViewCountsFromRedis(List posts) { + Map viewCountsFromRedis = new HashMap<>(); + // getBuckets()로 키를 한 번에 조회해와 네트워크 I/O를 줄임 + Map redisValues = redissonClient.getBuckets().get(posts.stream() + .map(post -> "post:viewCount:" + post.getPostId()) + .toArray(String[]::new)); + + for (Post post : posts) { + viewCountsFromRedis.put(post.getPostId(), redisValues.getOrDefault("post:viewCount:" + post.getPostId(), 0L)); + } + + return viewCountsFromRedis; + } + + private List convertPostsToDto(List posts, String keyword) { + Map viewCounts = getViewCountsFromRedis(posts); + + return posts.stream().map(post -> { + String content = post.getContent(); + if (keyword != null) { + String sentence = getSentenceContainKeyword(keyword, post.getContent()); + content = (sentence != null) ? sentence : content; + } + return PostListObject.of(post, content, fileUrlResolver, viewCounts.getOrDefault(post.getPostId(), 0L)); + }).toList(); + } } diff --git a/src/main/java/com/hf/healthfriend/domain/post/service/PostBatchService.java b/src/main/java/com/hf/healthfriend/domain/post/service/PostBatchService.java new file mode 100644 index 00000000..eeb1168d --- /dev/null +++ b/src/main/java/com/hf/healthfriend/domain/post/service/PostBatchService.java @@ -0,0 +1,71 @@ +package com.hf.healthfriend.domain.post.service; + + +import com.hf.healthfriend.domain.post.entity.Post; +import com.hf.healthfriend.domain.post.repository.PostRepository; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RKeys; +import org.redisson.api.RedissonClient; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PostBatchService { + + private final RedissonClient redissonClient; + private final PostRepository postRepository; + + private static final String VIEW_COUNT_PREFIX = "post:viewCount:"; + + @Transactional + @Scheduled(fixedRate = 1000*60*60*24) // 24시간 주기 + public void syncVIewCountsToDB(){ + log.info("Redis 조회수를 DB로 동기화 시작"); + + // 1. Redis 에 저장된 조회수 key 찾기 + RKeys keys = redissonClient.getKeys(); + Iterable redisKeysIterable = keys.getKeysByPattern(VIEW_COUNT_PREFIX + "*"); + Set redisKeys = StreamSupport.stream(redisKeysIterable.spliterator(), false) + .collect(Collectors.toSet()); + + if (redisKeys.isEmpty()) { + log.info("저장된 조회수가 없어 동기화를 종료합니다."); + }else{ + // Redis 에서 모든 조회수를 가져와서 postId 별로 매핑 + Map viewCounts = redisKeys.stream() + .collect(Collectors.toMap( + key -> Long.parseLong(key.replace(VIEW_COUNT_PREFIX, "")), // postId 추출 + key -> redissonClient.getAtomicLong(key).get() // 조회수 가져오기 + )); + + // DB 에서 해당 postId 목록 가져오기 + List posts = postRepository.findAllById(viewCounts.keySet()); + + for (Post post : posts) { + Long redisViewCount = viewCounts.get(post.getPostId()); + + if (redisViewCount != null && redisViewCount > post.getViewCount()) { + log.info("postId={} 조회수 업데이트: DB={}, Redis={}", + post.getPostId(), post.getViewCount(), redisViewCount); + post.updateViewCount(redisViewCount); + } + } + + postRepository.saveAll(posts); // 일괄 저장 + + // 동기화 후 Redis 에서 조회수 초기화 + redisKeys.forEach(key -> redissonClient.getAtomicLong(key).delete()); + + log.info("Redis 조회수를 DB로 동기화 완료"); + } + } +} diff --git a/src/main/java/com/hf/healthfriend/domain/post/service/PostService.java b/src/main/java/com/hf/healthfriend/domain/post/service/PostService.java index 83a389e5..2d9a5266 100644 --- a/src/main/java/com/hf/healthfriend/domain/post/service/PostService.java +++ b/src/main/java/com/hf/healthfriend/domain/post/service/PostService.java @@ -19,8 +19,11 @@ import com.hf.healthfriend.domain.post.repository.PostRepository; import com.hf.healthfriend.global.file.FileUrlResolver; import jakarta.transaction.Transactional; +import java.time.Duration; +import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RAtomicLong; import org.redisson.api.RScoredSortedSet; import org.redisson.api.RedissonClient; import org.springframework.data.domain.Pageable; @@ -62,20 +65,24 @@ public Long update(PostWriteRequest postWriteRequest, Long postId){ public PostGetResponse get(Long postId, boolean canUpdateViewCount, CommentSortType sortType) { Post post = postRepository.findByPostIdAndIsDeletedFalse(postId) - .orElseThrow(() -> new PostException(PostErrorCode.POST_NOT_FOUND, HttpStatus.NOT_FOUND,postId + "번 post가 존재하지 않습니다.")); - if(canUpdateViewCount) { - post.updateViewCount(post.getViewCount()); - } - List commentList = commentService.getCommentsOfPost(postId,sortType); + .orElseThrow(() -> new PostException(PostErrorCode.POST_NOT_FOUND, HttpStatus.NOT_FOUND, postId + "번 post가 존재하지 않습니다.")); + + long viewCount = increaseViewCount(postId,post,canUpdateViewCount); + List commentList = commentService.getCommentsOfPost(postId, sortType); + String imagePath = fileUrlResolver.resolveFileUrl(post.getImagePath()); String writerProfileImageUrl = fileUrlResolver.resolveFileUrl(post.getMember().getProfileImageUrl()); - return PostGetResponse.of(post, commentList,imagePath, writerProfileImageUrl); + + return PostGetResponse.of(post, commentList, viewCount, imagePath, writerProfileImageUrl); } public void delete(Long postId) { Post post = postRepository.findByPostIdAndIsDeletedFalse(postId) .orElseThrow(() -> new PostException(PostErrorCode.POST_NOT_FOUND, HttpStatus.NOT_FOUND,postId + "번 post가 존재하지 않습니다.")); post.delete(); + // Redis 에서도 조회수 제거 + String redisKey = "post:viewCount:" + postId; + redissonClient.getAtomicLong(redisKey).delete(); likeRepository.deleteLikeByPostId(postId); } @@ -101,7 +108,27 @@ public PostSearchResponse getPopularList(int pageNumber, int size, FitnessLevel .build(); } + private Long increaseViewCount(Long postId, Post post, boolean canUpdateViewCount) { + String redisKey = "post:viewCount:" + postId; + RAtomicLong counter = redissonClient.getAtomicLong(redisKey); -} + long viewCount = counter.get(); + // 값이 없으면 DB 값으로 초기화 + TTL 6시간 + if (viewCount == 0) { + viewCount = post.getViewCount(); + counter.set(viewCount); + counter.expire(Duration.ofHours(6)); + log.info("Redis에 조회수 초기화: postId={}, newViewCount={}", postId, viewCount); + } + + // 조회수 증가 + TTL 연장 + if (canUpdateViewCount) { + viewCount = counter.incrementAndGet(); + counter.expire(Duration.ofHours(6)); // 연장 + log.info("조회수 증가: postId={}, newViewCount={}", postId, viewCount); + } + return viewCount; + } +}