-
Notifications
You must be signed in to change notification settings - Fork 0
Feat#11 조회수 #12
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
The head ref may contain hidden characters: "feat#11-\uC870\uD68C\uC218"
Feat#11 조회수 #12
Changes from 11 commits
34fec80
59ede6b
13c4e88
620df02
45967a9
f91e64f
fda589b
6c54067
44174ea
71b1263
344c616
0bf5d27
bd0b669
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| package org.juniortown.backend.config; | ||
|
|
||
| import org.springframework.beans.factory.annotation.Value; | ||
| import org.springframework.context.annotation.Bean; | ||
| import org.springframework.context.annotation.Configuration; | ||
| import org.springframework.context.annotation.Profile; | ||
| import org.springframework.data.redis.connection.RedisConnectionFactory; | ||
| import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; | ||
| import org.springframework.data.redis.core.RedisTemplate; | ||
| import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; | ||
| import org.springframework.data.redis.serializer.StringRedisSerializer; | ||
|
|
||
| @Configuration | ||
| @Profile("!test") // test 프로파일이 아닐 때만 활성화 | ||
| public class RedisConfig { | ||
| @Value("${spring.data.redis.host}") | ||
| private String host; | ||
| @Value("${spring.data.redis.port}") | ||
| private int port; | ||
|
|
||
| @Bean | ||
| public RedisConnectionFactory redisConnectionFactory() { | ||
| return new LettuceConnectionFactory(host, port); | ||
| } | ||
|
|
||
| @Bean | ||
| public RedisTemplate<String, Boolean> keyCheckRedisTemplate(RedisConnectionFactory redisConnectionFactory) { | ||
| RedisTemplate<String,Boolean> redisTemplate = new RedisTemplate<>(); | ||
| redisTemplate.setConnectionFactory(redisConnectionFactory); | ||
| redisTemplate.setKeySerializer(new StringRedisSerializer()); | ||
| redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Boolean.class)); | ||
| return redisTemplate; | ||
| } | ||
|
|
||
| @Bean | ||
| public RedisTemplate<String, Long> readCountRedisTemplate(RedisConnectionFactory redisConnectionFactory) { | ||
| RedisTemplate<String,Long> redisTemplate = new RedisTemplate<>(); | ||
| redisTemplate.setConnectionFactory(redisConnectionFactory); | ||
| redisTemplate.setKeySerializer(new StringRedisSerializer()); | ||
| redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Long.class)); | ||
| return redisTemplate; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| package org.juniortown.backend.interceptor; | ||
|
|
||
| import java.util.UUID; | ||
|
|
||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.web.servlet.HandlerInterceptor; | ||
|
|
||
| import jakarta.servlet.http.Cookie; | ||
| import jakarta.servlet.http.HttpServletRequest; | ||
| import jakarta.servlet.http.HttpServletResponse; | ||
|
|
||
| @Component | ||
| public class GuestCookieInterceptor implements HandlerInterceptor { | ||
| private static final String COOKIE_NAME = "guestId"; | ||
| @Override | ||
| public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { | ||
| Cookie[] cookies = request.getCookies(); | ||
| boolean hasGuestId = false; | ||
| if (cookies != null) { | ||
| for(Cookie c : cookies) { | ||
| if (COOKIE_NAME.equals(c.getName())) { | ||
| hasGuestId = true; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| if(!hasGuestId) { | ||
| String guestId = UUID.randomUUID().toString(); | ||
| Cookie cookie = new Cookie(COOKIE_NAME, guestId); | ||
| cookie.setPath("/"); | ||
| cookie.setMaxAge(60 * 60 * 24 * 365); // 1 year | ||
| response.addCookie(cookie); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 쿠키 보안 설정을 강화해주세요. 정적 분석 도구에서 지적한 바와 같이 쿠키 보안 설정이 부족합니다. 다음과 같이 수정하여 보안을 강화하세요: Cookie cookie = new Cookie(COOKIE_NAME, guestId);
cookie.setPath("/");
cookie.setMaxAge(60 * 60 * 24 * 365); // 1 year
+cookie.setHttpOnly(true);
+cookie.setSecure(true); // HTTPS 환경에서만
+cookie.setSameSite(Cookie.SameSite.STRICT.attributeValue());
response.addCookie(cookie);참고: HTTPS 환경이 아닌 경우
🧰 Tools🪛 ast-grep (0.38.6)[warning] 31-31: The application does not appear to verify inbound requests which can lead to a Cross-site request forgery (CSRF) vulnerability. If the application uses cookie-based authentication, an attacker can trick users into sending authenticated HTTP requests without their knowledge from any arbitrary domain they visit. To prevent this vulnerability start by identifying if the framework or library leveraged has built-in features or offers plugins for CSRF protection. CSRF tokens should be unique and securely random. The (cookie-missing-samesite-java) [warning] 31-31: A cookie was detected without setting the 'secure' flag. The 'secure' flag for cookies prevents the client from transmitting the cookie over insecure channels such as HTTP. Set the 'secure' flag by calling '.setSecure(true);'. (cookie-missing-secure-flag-java) [warning] 31-31: A cookie was detected without setting the 'HttpOnly' flag. The 'HttpOnly' flag for cookies instructs the browser to forbid client-side scripts from reading the cookie. Set the 'HttpOnly' flag by calling 'cookie.setHttpOnly(true); (cookie-missing-httponly-java) 🤖 Prompt for AI Agents |
||
| } | ||
| return true; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,16 +1,21 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||
| package org.juniortown.backend.post.controller; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| import java.util.stream.Collectors; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| import org.juniortown.backend.post.dto.request.PostCreateRequest; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import org.juniortown.backend.post.dto.response.PageResponse; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import org.juniortown.backend.post.dto.response.PostDetailResponse; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import org.juniortown.backend.post.dto.response.PostWithLikeCount; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import org.juniortown.backend.post.dto.response.PostResponse; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import org.juniortown.backend.post.dto.response.PostWithLikeCountProjection; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import org.juniortown.backend.post.service.PostService; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import org.juniortown.backend.post.service.ViewCountService; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import org.juniortown.backend.user.dto.CustomUserDetails; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.data.domain.Page; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.http.HttpStatus; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.http.ResponseEntity; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.security.core.annotation.AuthenticationPrincipal; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.web.bind.annotation.CookieValue; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.web.bind.annotation.DeleteMapping; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.web.bind.annotation.GetMapping; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.web.bind.annotation.PatchMapping; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -28,6 +33,7 @@ | |||||||||||||||||||||||||||||||||||||||||||||||
| @RequestMapping("/api") | ||||||||||||||||||||||||||||||||||||||||||||||||
| public class PostController { | ||||||||||||||||||||||||||||||||||||||||||||||||
| private final PostService postService; | ||||||||||||||||||||||||||||||||||||||||||||||||
| private final ViewCountService viewCountService; | ||||||||||||||||||||||||||||||||||||||||||||||||
| @PostMapping("/posts") | ||||||||||||||||||||||||||||||||||||||||||||||||
| public ResponseEntity<PostResponse> create(@AuthenticationPrincipal CustomUserDetails customUserDetails, | ||||||||||||||||||||||||||||||||||||||||||||||||
| @Valid @RequestBody PostCreateRequest postCreateRequest) { | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -55,19 +61,35 @@ public ResponseEntity<PostResponse> update(@AuthenticationPrincipal CustomUserDe | |||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| // 게시글 목록 조회, 페이지네이션 적용 | ||||||||||||||||||||||||||||||||||||||||||||||||
| @GetMapping("/posts/{page}") | ||||||||||||||||||||||||||||||||||||||||||||||||
| public ResponseEntity<PageResponse<PostWithLikeCountProjection>> getPosts(@AuthenticationPrincipal CustomUserDetails customUserDetails, @PathVariable int page) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| public ResponseEntity<PageResponse<PostResponse>> getPosts(@AuthenticationPrincipal CustomUserDetails customUserDetails, @PathVariable int page) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| Long userId = customUserDetails.getUserId(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| // null체크 | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| Page<PostWithLikeCountProjection> posts = postService.getPosts(userId, page); | ||||||||||||||||||||||||||||||||||||||||||||||||
| PageResponse<PostWithLikeCountProjection> response = new PageResponse<>(posts); | ||||||||||||||||||||||||||||||||||||||||||||||||
| Page<PostResponse> posts = postService.getPosts(userId, page); | ||||||||||||||||||||||||||||||||||||||||||||||||
| PageResponse<PostResponse> response = new PageResponse<>(posts); | ||||||||||||||||||||||||||||||||||||||||||||||||
| return ResponseEntity.ok(response); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| @GetMapping("/posts/details/{postId}") | ||||||||||||||||||||||||||||||||||||||||||||||||
| public ResponseEntity<PostResponse> getPost(@PathVariable Long postId) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| PostResponse postResponse = postService.getPost(postId); | ||||||||||||||||||||||||||||||||||||||||||||||||
| return ResponseEntity.ok(postResponse); | ||||||||||||||||||||||||||||||||||||||||||||||||
| public ResponseEntity<PostDetailResponse> getPost(@AuthenticationPrincipal CustomUserDetails customUserDetails | ||||||||||||||||||||||||||||||||||||||||||||||||
| ,@PathVariable Long postId | ||||||||||||||||||||||||||||||||||||||||||||||||
| ,@CookieValue(value = "guestId", required = false) String guestId | ||||||||||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| Long redisReadCount = 0L; | ||||||||||||||||||||||||||||||||||||||||||||||||
| if(customUserDetails == null) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| // 비회원인 경우 | ||||||||||||||||||||||||||||||||||||||||||||||||
| // 쿠키값을 레디스에 등록 | ||||||||||||||||||||||||||||||||||||||||||||||||
| redisReadCount = viewCountService.readCountUp(guestId, String.valueOf(postId)); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||
| // 회원인 경우 | ||||||||||||||||||||||||||||||||||||||||||||||||
| // userId,postId를 조합해서 레디스에 등록 | ||||||||||||||||||||||||||||||||||||||||||||||||
| Long userId = customUserDetails.getUserId(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| redisReadCount = viewCountService.readCountUp(String.valueOf(userId), String.valueOf(postId)); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| PostDetailResponse postDetailResponse = postService.getPost(postId); | ||||||||||||||||||||||||||||||||||||||||||||||||
| postDetailResponse.addReadCount(redisReadCount); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||
| Long redisReadCount = 0L; | |
| if(customUserDetails == null) { | |
| // 비회원인 경우 | |
| // 쿠키값을 레디스에 등록 | |
| redisReadCount = viewCountService.readCountUp(guestId, String.valueOf(postId)); | |
| } else { | |
| // 회원인 경우 | |
| // userId,postId를 조합해서 레디스에 등록 | |
| Long userId = customUserDetails.getUserId(); | |
| redisReadCount = viewCountService.readCountUp(String.valueOf(userId), String.valueOf(postId)); | |
| } | |
| PostDetailResponse postDetailResponse = postService.getPost(postId); | |
| postDetailResponse.addReadCount(redisReadCount); | |
| // 컨트롤러에서는 비즈니스 로직을 호출만 합니다. | |
| // viewCount 증가와 DTO 조립은 서비스 레이어로 위임 | |
| String viewerId = customUserDetails != null | |
| ? String.valueOf(customUserDetails.getUserId()) | |
| : guestId; | |
| Long currentUserId = customUserDetails != null ? customUserDetails.getUserId() : null; | |
| PostDetailResponse postDetailResponse = | |
| postService.getPostDetail(postId, currentUserId, viewerId); |
🤖 Prompt for AI Agents
In
backend/src/main/java/org/juniortown/backend/post/controller/PostController.java
around lines 78 to 91, move the read count increment logic from the controller
to the service layer. Refactor the service method to accept user information and
postId, handle the read count update internally, and return a fully constructed
PostDetailResponse with the updated read count included. This eliminates
business logic from the controller and avoids modifying the response DTO after
creation.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| package org.juniortown.backend.post.dto.response; | ||
|
|
||
| import java.time.LocalDateTime; | ||
|
|
||
| import lombok.Builder; | ||
| import lombok.Getter; | ||
|
|
||
| @Getter | ||
| public class PostDetailResponse { | ||
| private Long id; | ||
| private String title; | ||
| private String content; | ||
| private Long userId; | ||
| private String userName; | ||
| private Long likeCount; | ||
| private Boolean isLiked; | ||
| private Long readCount; | ||
| private LocalDateTime createdAt; | ||
| private LocalDateTime updatedAt; | ||
| private LocalDateTime deletedAt; | ||
coderabbitai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| @Builder | ||
| public PostDetailResponse(Long id, String title, String content, Long userId, String userName, Long likeCount, | ||
| Boolean isLiked, Long readCount, LocalDateTime createdAt, LocalDateTime updatedAt, LocalDateTime deletedAt) { | ||
| this.id = id; | ||
| this.title = title; | ||
| this.content = content; | ||
| this.userId = userId; | ||
| this.userName = userName; | ||
| this.likeCount = likeCount; | ||
| this.isLiked = isLiked; | ||
| this.readCount = readCount; | ||
| this.createdAt = createdAt; | ||
| this.updatedAt = updatedAt; | ||
| this.deletedAt = deletedAt; | ||
| } | ||
| public static PostDetailResponse from(PostResponse postResponse) { | ||
| return PostDetailResponse.builder() | ||
| .id(postResponse.getId()) | ||
| .title(postResponse.getTitle()) | ||
| .content(postResponse.getContent()) | ||
| .userId(postResponse.getUserId()) | ||
| .userName(postResponse.getUserName()) | ||
| .likeCount(postResponse.getLikeCount()) | ||
| .isLiked(postResponse.getIsLiked()) | ||
| .readCount(postResponse.getReadCount()) | ||
| .createdAt(postResponse.getCreatedAt()) | ||
| .updatedAt(postResponse.getUpdatedAt()) | ||
| .deletedAt(postResponse.getDeletedAt()) | ||
| .build(); | ||
| } | ||
| public void addReadCount(Long redisReadCount) { | ||
| this.readCount += redisReadCount; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -40,6 +40,9 @@ public class Post extends BaseTimeEntity { | |
| @Column(name = "deleted_at") | ||
| private LocalDateTime deletedAt; | ||
|
|
||
| @Column(name = "read_count", nullable = false) | ||
| private Long readCount = 0L; | ||
|
|
||
| @Builder | ||
| public Post(String title, String content, User user) { | ||
| this.title = title; | ||
|
|
@@ -49,11 +52,14 @@ public Post(String title, String content, User user) { | |
|
|
||
| public void softDelete(Clock clock) { | ||
| this.deletedAt = LocalDateTime.now(clock); | ||
|
|
||
| } | ||
|
|
||
| public void update(PostCreateRequest postCreateRequest, Clock clock) { | ||
| this.title = postCreateRequest.getTitle(); | ||
| this.content = postCreateRequest.getContent(); | ||
| } | ||
|
|
||
| public void addReadCount(Long redisReadCount) { | ||
| this.readCount += redisReadCount; | ||
| } | ||
|
Comment on lines
+62
to
+64
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Verification agent 🧩 Analysis chain동시성 고려사항을 확인해주세요.
다음 스크립트로 이 메서드의 사용 패턴을 확인해보겠습니다: 🏁 Script executed: #!/bin/bash
# ViewCountSyncService에서 addReadCount 메서드 사용 패턴 확인
ast-grep --pattern 'addReadCount($_)'Length of output: 39 🏁 Script executed: #!/bin/bash
# 코드베이스 전체에서 addReadCount 호출 위치 확인
rg "addReadCount" -n .Length of output: 563 동시성 이슈 확인 및 원자성 보장 필요
해결 방안 예시:
위 방안 중 하나를 선택해 동시성 문제를 방지해주세요. 🤖 Prompt for AI Agents
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. addReadCount는 5분마다 실행되는 @scheduled 작업이고 redis에서 받아오는 응답을 더할 때 사용되는데 이는 동기적으로 동작해서 동시성 이슈는 발생하지 않는다고 판단. Redisson을 통해 스케줄링 작업이 여러 인스턴스에서 동시에 실행되지 않기에 하나의 스레드에서만 스케줄링 작업이 발생함을 보장함. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
중복·불필요 의존성 정리 필요
spring-boot-starter-test가 이미 JUnit Jupiter를 포함하므로testImplementation 'org.junit.jupiter:junit-jupiter'는 중복입니다.redisson-spring-boot-starter내부에spring-boot-starter-data-redis가 포함되어 있어 두 스타터를 동시 선언하면RedisConnectionFactory빈 충돌이 발생할 수 있습니다(실행 시BeanDefinitionOverrideException가능). 하나만 유지하거나redisson-spring-boot-starter에서exclude group: 'org.springframework.boot', module: 'spring-boot-starter-data-redis'로 중복을 제거해 주세요.🤖 Prompt for AI Agents