Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public record PostGetResponse(
Long commentCount,
List<CommentDto> comments
) {
public static PostGetResponse of(Post post, List<CommentDto> comments, String imagePath, String writerProfileImageUrl) {
public static PostGetResponse of(Post post, List<CommentDto> comments,Long viewCountFromRedis ,String imagePath, String writerProfileImageUrl) {
return PostGetResponse.builder()
.postId(post.getPostId())
.writerId(post.getMember().getId())
Expand All @@ -36,7 +36,7 @@ public static PostGetResponse of(Post post, List<CommentDto> comments, String im
.content(post.getContent())
.imagePath(imagePath)
.createDate(post.getCreationTime())
.viewCount(post.getViewCount())
.viewCount(viewCountFromRedis)
.likeCount(post.getLikesCount())
.commentCount(post.getCommentsCount())
.comments(comments)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,18 @@
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;
import com.querydsl.core.types.Order;
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;

Expand All @@ -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;
Expand All @@ -28,49 +33,36 @@ public class PostCustomRepositoryImpl implements PostCustomRepository {
public List<PostListObject> 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<Post> 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<PostListObject> getPopularList(List<Long> 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<Post> 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
Expand Down Expand Up @@ -124,4 +116,31 @@ public Long getTotalPageSize(int size){
if (totalPageSize == null) return 0L;
return (long) Math.ceil((double) totalPageSize / size);
}

private Map<Long, Long> getViewCountsFromRedis(List<Post> posts) {
Map<Long, Long> viewCountsFromRedis = new HashMap<>();
// getBuckets()로 키를 한 번에 조회해와 네트워크 I/O를 줄임
Map<String, Long> 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<PostListObject> convertPostsToDto(List<Post> posts, String keyword) {
Map<Long, Long> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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<String> redisKeysIterable = keys.getKeysByPattern(VIEW_COUNT_PREFIX + "*");
Set<String> redisKeys = StreamSupport.stream(redisKeysIterable.spliterator(), false)
.collect(Collectors.toSet());

if (redisKeys.isEmpty()) {
log.info("저장된 조회수가 없어 동기화를 종료합니다.");
}else{
// Redis 에서 모든 조회수를 가져와서 postId 별로 매핑
Map<Long, Long> viewCounts = redisKeys.stream()
.collect(Collectors.toMap(
key -> Long.parseLong(key.replace(VIEW_COUNT_PREFIX, "")), // postId 추출
key -> redissonClient.getAtomicLong(key).get() // 조회수 가져오기
));

// DB 에서 해당 postId 목록 가져오기
List<Post> 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로 동기화 완료");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<CommentDto> commentList = commentService.getCommentsOfPost(postId,sortType);
.orElseThrow(() -> new PostException(PostErrorCode.POST_NOT_FOUND, HttpStatus.NOT_FOUND, postId + "번 post가 존재하지 않습니다."));

long viewCount = increaseViewCount(postId,post,canUpdateViewCount);
List<CommentDto> 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);
}

Expand All @@ -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;
}
}