diff --git a/src/main/java/com/jiwon/mylog/domain/post/controller/PostController.java b/src/main/java/com/jiwon/mylog/domain/post/controller/PostController.java index f33a0bc..db9e3d2 100644 --- a/src/main/java/com/jiwon/mylog/domain/post/controller/PostController.java +++ b/src/main/java/com/jiwon/mylog/domain/post/controller/PostController.java @@ -1,6 +1,7 @@ package com.jiwon.mylog.domain.post.controller; import com.jiwon.mylog.domain.post.service.PostService; +import com.jiwon.mylog.global.security.auth.annotation.AllUser; import com.jiwon.mylog.global.security.auth.annotation.LoginUser; import com.jiwon.mylog.domain.post.dto.request.PostRequest; import com.jiwon.mylog.domain.post.dto.response.PostDetailResponse; @@ -103,8 +104,9 @@ public ResponseEntity getAllNotices( } ) public ResponseEntity getPost( + @AllUser String userKey, @PathVariable("postId") Long postId) { - PostDetailResponse response = postService.getPost(postId); + PostDetailResponse response = postService.getPost(postId, userKey); return new ResponseEntity<>(response, HttpStatus.OK); } diff --git a/src/main/java/com/jiwon/mylog/domain/post/dto/response/PostDetailResponse.java b/src/main/java/com/jiwon/mylog/domain/post/dto/response/PostDetailResponse.java index d288df4..5d81bdc 100644 --- a/src/main/java/com/jiwon/mylog/domain/post/dto/response/PostDetailResponse.java +++ b/src/main/java/com/jiwon/mylog/domain/post/dto/response/PostDetailResponse.java @@ -74,6 +74,10 @@ public static PostDetailResponse fromPost(Post post) { .build(); } + public void setViews(int views) { + this.views = views; + } + public void setTagsAndComments(List tags, List comments) { this.tags = tags; this.comments = comments; diff --git a/src/main/java/com/jiwon/mylog/domain/post/repository/PostRepository.java b/src/main/java/com/jiwon/mylog/domain/post/repository/PostRepository.java index 92cc9af..1271eed 100644 --- a/src/main/java/com/jiwon/mylog/domain/post/repository/PostRepository.java +++ b/src/main/java/com/jiwon/mylog/domain/post/repository/PostRepository.java @@ -7,6 +7,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -14,6 +15,10 @@ @Repository public interface PostRepository extends JpaRepository, PostRepositoryCustom { + @Modifying + @Query("update Post p set p.views = :view where p.id = :postId") + void updatePostView(@Param("postId") Long postId, @Param("view") int view); + @Query(value = "select p from Post p where p.deletedAt is null and p.isNotice = true order by p.createdAt desc", countQuery = "select count(p) from Post p where p.deletedAt is null and p.isNotice = true") Page findAllNotice(Pageable pageable); diff --git a/src/main/java/com/jiwon/mylog/domain/post/service/PostService.java b/src/main/java/com/jiwon/mylog/domain/post/service/PostService.java index fd7c024..f2ceb4f 100644 --- a/src/main/java/com/jiwon/mylog/domain/post/service/PostService.java +++ b/src/main/java/com/jiwon/mylog/domain/post/service/PostService.java @@ -39,6 +39,7 @@ public class PostService { private final ApplicationEventPublisher eventPublisher; + private final PostViewService postViewService; private final TagService tagService; private final PostRepository postRepository; private final UserRepository userRepository; @@ -167,11 +168,18 @@ public PageResponse getAllNotices(Pageable pageable) { condition = "#postId != null && #postId > 0" ) @Transactional(readOnly = true) - public PostDetailResponse getPost(Long postId) { + public PostDetailResponse getPostContent(Long postId) { return postRepository.findPostDetail(postId) .orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_POST)); } + public PostDetailResponse getPost(Long postId, String userKey) { + PostDetailResponse post = getPostContent(postId); + postViewService.incrementPostView(post.getPostId(), post.getViews(), userKey); + post.setViews(postViewService.getPostView(post.getPostId(), post.getViews())); + return post; + } + @Cacheable(value = "post::main", key = "'page:' + #pageable.pageNumber + ':size:' + #pageable.pageSize", condition = "#pageable != null") diff --git a/src/main/java/com/jiwon/mylog/domain/post/service/PostViewService.java b/src/main/java/com/jiwon/mylog/domain/post/service/PostViewService.java new file mode 100644 index 0000000..c07e368 --- /dev/null +++ b/src/main/java/com/jiwon/mylog/domain/post/service/PostViewService.java @@ -0,0 +1,29 @@ +package com.jiwon.mylog.domain.post.service; + +import com.jiwon.mylog.global.redis.RedisUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class PostViewService { + + private final RedisUtil redisUtil; + private static final String VIEW_KEY_PREFIX = "post:view:"; + private static final String VIEW_COUNT_KEY_PREFIX = "post:view:count"; + + public void incrementPostView(Long postId, int view, String userKey) { + String existKey = VIEW_KEY_PREFIX + postId; + String countKey = VIEW_COUNT_KEY_PREFIX; + + boolean exist = redisUtil.existPostViewUser(existKey, userKey); + if (!exist) { + redisUtil.addPostViewUser(existKey, userKey); + redisUtil.increasePostView(countKey, postId.toString(), String.valueOf(view)); + } + } + + public int getPostView(Long postId, int view) { + return redisUtil.getPostView(VIEW_COUNT_KEY_PREFIX, postId.toString(), view); + } +} diff --git a/src/main/java/com/jiwon/mylog/global/common/config/WebConfig.java b/src/main/java/com/jiwon/mylog/global/common/config/WebConfig.java index 1787d15..b248e0b 100644 --- a/src/main/java/com/jiwon/mylog/global/common/config/WebConfig.java +++ b/src/main/java/com/jiwon/mylog/global/common/config/WebConfig.java @@ -1,5 +1,6 @@ package com.jiwon.mylog.global.common.config; +import com.jiwon.mylog.global.security.auth.resolver.AllUserArgumentResolver; import com.jiwon.mylog.global.security.auth.resolver.LoginUserArgumentResolver; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; @@ -13,9 +14,11 @@ public class WebConfig implements WebMvcConfigurer { private final LoginUserArgumentResolver loginUserArgumentResolver; + private final AllUserArgumentResolver allUserArgumentResolver; @Override public void addArgumentResolvers(List resolvers) { resolvers.add(loginUserArgumentResolver); + resolvers.add(allUserArgumentResolver); } } diff --git a/src/main/java/com/jiwon/mylog/global/mail/service/MailService.java b/src/main/java/com/jiwon/mylog/global/mail/service/MailService.java index cface62..5c24e5d 100644 --- a/src/main/java/com/jiwon/mylog/global/mail/service/MailService.java +++ b/src/main/java/com/jiwon/mylog/global/mail/service/MailService.java @@ -38,7 +38,7 @@ public void verifyEmailCode(String email, String code) { @Transactional public void sendCodeMail(String email) { - if (redisUtil.existData(email)) { + if (redisUtil.existEmailData(email)) { redisUtil.deleteData(email); } diff --git a/src/main/java/com/jiwon/mylog/global/redis/RedisUtil.java b/src/main/java/com/jiwon/mylog/global/redis/RedisUtil.java index 9adbd43..8e7284c 100644 --- a/src/main/java/com/jiwon/mylog/global/redis/RedisUtil.java +++ b/src/main/java/com/jiwon/mylog/global/redis/RedisUtil.java @@ -1,6 +1,10 @@ package com.jiwon.mylog.global.redis; import java.time.Duration; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; @@ -15,7 +19,7 @@ public String getData(String key) { return redisTemplate.opsForValue().get(key); } - public boolean existData(String key) { + public boolean existEmailData(String key) { return Boolean.TRUE.equals(redisTemplate.hasKey(key)); } @@ -26,4 +30,36 @@ public void setDataExpire(String key, String value, long duration) { public void deleteData(String key) { redisTemplate.delete(key); } + + /** + * 조회수 관련 + */ + public boolean existPostViewUser(String key, String value) { + return redisTemplate.opsForSet().isMember(key, value); + } + + public void addPostViewUser(String key, String value) { + redisTemplate.opsForSet().add(key, value); + redisTemplate.expire(key, Duration.ofHours(12L)); + } + + public Long increasePostView(String key, String hashKey, String view) { + if (!redisTemplate.opsForHash().hasKey(key, hashKey)) { + redisTemplate.opsForHash().put(key, hashKey, view); + } + return redisTemplate.opsForHash().increment(key, hashKey, 1); + } + + public int getPostView(String key, String hashKey, int view) { + String value = (String) redisTemplate.opsForHash().get(key, hashKey); + return value != null ? Integer.parseInt(value) : view; + } + + public Map getAllPostView(String key) { + return redisTemplate.opsForHash().entries(key).entrySet().stream() + .collect(Collectors.toMap( + e -> Long.parseLong(e.getKey().toString()), + e -> Integer.parseInt(e.getValue().toString()) + )); + } } diff --git a/src/main/java/com/jiwon/mylog/global/schedular/PostViewScheduler.java b/src/main/java/com/jiwon/mylog/global/schedular/PostViewScheduler.java new file mode 100644 index 0000000..34d1548 --- /dev/null +++ b/src/main/java/com/jiwon/mylog/global/schedular/PostViewScheduler.java @@ -0,0 +1,34 @@ +package com.jiwon.mylog.global.schedular; + +import com.jiwon.mylog.domain.post.repository.PostRepository; +import com.jiwon.mylog.global.redis.RedisUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +@Slf4j +@RequiredArgsConstructor +@Component +public class PostViewScheduler { + + private final PostRepository postRepository; + private final RedisUtil redisUtil; + private static final String REDIS_KEY = "post:view:count"; + + @Scheduled(fixedRate = 10 * 60 * 1000L) + @Transactional + public void syncPostViewToDB() { + Map postCounts = redisUtil.getAllPostView(REDIS_KEY); + postCounts.forEach((postId, view) -> { + try { + postRepository.updatePostView(postId, view); + } catch (Exception e) { + log.error("조회수 동기화 실패, 게시글 {}: {}", postId, e.getMessage()); + } + }); + } +} diff --git a/src/main/java/com/jiwon/mylog/global/security/auth/annotation/AllUser.java b/src/main/java/com/jiwon/mylog/global/security/auth/annotation/AllUser.java new file mode 100644 index 0000000..40ccf01 --- /dev/null +++ b/src/main/java/com/jiwon/mylog/global/security/auth/annotation/AllUser.java @@ -0,0 +1,11 @@ +package com.jiwon.mylog.global.security.auth.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AllUser { +} diff --git a/src/main/java/com/jiwon/mylog/global/security/auth/resolver/AllUserArgumentResolver.java b/src/main/java/com/jiwon/mylog/global/security/auth/resolver/AllUserArgumentResolver.java new file mode 100644 index 0000000..7531c9d --- /dev/null +++ b/src/main/java/com/jiwon/mylog/global/security/auth/resolver/AllUserArgumentResolver.java @@ -0,0 +1,66 @@ +package com.jiwon.mylog.global.security.auth.resolver; + +import com.jiwon.mylog.global.security.auth.annotation.AllUser; +import com.jiwon.mylog.global.security.jwt.JwtService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class AllUserArgumentResolver implements HandlerMethodArgumentResolver { + + private final JwtService jwtService; + private final List headers = List.of( + "X-Forwarded-For", "Proxy-Client-IP", "WL-Proxy-Client-IP", + "HTTP_CLIENT_IP", "HTTP_X_FORWARDED_FOR"); + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterAnnotation(AllUser.class) != null && + parameter.getParameterType().equals(String.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + + HttpServletRequest httpServletRequest = webRequest.getNativeRequest(HttpServletRequest.class); + String header = httpServletRequest.getHeader(HttpHeaders.AUTHORIZATION); + String token = jwtService.getAccessToken(header); + + if (token == null || !jwtService.validateToken(token)) { + return getClientIP(httpServletRequest); + } + + return jwtService.getUserId(token).toString(); + } + + private String getClientIP(HttpServletRequest request) { + String clientIP = null; + + for(String header: headers) { + clientIP = request.getHeader(header); + if (!(clientIP == null || clientIP.isEmpty() || "unknown".equalsIgnoreCase(clientIP))) { + break; + } + } + + if (clientIP == null) { + clientIP = request.getRemoteAddr(); + } + if (clientIP != null || clientIP.contains(",")) { + clientIP = clientIP.split(",")[0].trim(); + } + + return clientIP; + } +} diff --git a/src/test/java/com/jiwon/mylog/domain/post/service/PostServiceCacheTest.java b/src/test/java/com/jiwon/mylog/domain/post/service/PostServiceCacheTest.java index 574e5fc..214de3e 100644 --- a/src/test/java/com/jiwon/mylog/domain/post/service/PostServiceCacheTest.java +++ b/src/test/java/com/jiwon/mylog/domain/post/service/PostServiceCacheTest.java @@ -60,7 +60,7 @@ void createPost() { PostDetailResponse post = postService.createPost(userId, postRequest, false); Long postId = post.getPostId(); - postService.getPost(postId); + postService.getPost(postId, "dummy"); postService.getUserPosts(userId, pageable); // 캐시 다시 생성 postService.getPostsByCategoryAndTags(userId, categoryId, List.of(), pageable); // 캐시 다시 생성 @@ -88,7 +88,7 @@ void updatePost() { // when PostDetailResponse post = postService.updatePost(userId, postId, postRequest, false); - postService.getPost(postId); + postService.getPost(postId, "dummy"); postService.getUserPosts(userId, pageable); // 캐시 다시 생성 postService.getPostsByCategoryAndTags(userId, categoryId, List.of(), pageable); // 캐시 다시 생성 @@ -113,11 +113,11 @@ void deletePost() { .willReturn(Optional.empty()); // when - postService.getPost(postId); + postService.getPost(postId, "dummy"); postService.deletePost(userId, postId); // then - assertThrows(NotFoundException.class, () -> postService.getPost(postId)); + assertThrows(NotFoundException.class, () -> postService.getPost(postId, "dummy")); verify(postRepository, times(2)).findPostDetail(postId); } @@ -131,10 +131,10 @@ void getPost() { .willReturn(Optional.of(post)); // when & then - PostDetailResponse firstPost = postService.getPost(postId); + PostDetailResponse firstPost = postService.getPost(postId, "dummy"); verify(postRepository, times(1)).findPostDetail(postId); - PostDetailResponse secondPost = postService.getPost(postId); + PostDetailResponse secondPost = postService.getPost(postId, "dummy"); verify(postRepository, times(1)).findPostDetail(postId); assertThat(firstPost).isEqualTo(secondPost);