Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ dependencies {
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"

implementation 'org.springframework.boot:spring-boot-starter-data-redis'


testImplementation 'org.junit.jupiter:junit-jupiter'
testImplementation 'org.testcontainers:testcontainers'
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'com.redis:testcontainers-redis:2.2.2'

implementation 'org.redisson:redisson-spring-boot-starter:3.27.0'

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

중복·불필요 의존성 정리 필요

  1. spring-boot-starter-test 가 이미 JUnit Jupiter를 포함하므로 testImplementation 'org.junit.jupiter:junit-jupiter' 는 중복입니다.
  2. 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' 로 중복을 제거해 주세요.
  3. 필요하지 않은 테스트컨테이너 모듈이 포함됐는지 확인 바랍니다.
-    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
-    ...
-    testImplementation 'org.junit.jupiter:junit-jupiter'
+    // Redis
+    implementation 'org.redisson:redisson-spring-boot-starter:3.27.0' // 포함된 lettuce 혹은 jedis 중복 여부 확인
+    // 테스트
+    // spring-boot-starter-test 내부에 Jupiter 포함 → 중복 제거

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In backend/build.gradle around lines 51 to 62, remove the redundant
'org.junit.jupiter:junit-jupiter' dependency since 'spring-boot-starter-test'
already includes it. Also, avoid declaring both 'spring-boot-starter-data-redis'
and 'redisson-spring-boot-starter' together to prevent RedisConnectionFactory
bean conflicts; either keep only one or exclude 'spring-boot-starter-data-redis'
from 'redisson-spring-boot-starter' using the exclude directive. Finally, review
and remove any unnecessary testcontainers dependencies that are not used in the
project.

}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableJpaAuditing
@EnableScheduling
public class BackendApplication {

public static void main(String[] args) {
Expand Down
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
Expand Up @@ -76,7 +76,7 @@ public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
//경로별 인가 작업
http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/api/auth/login", "/", "/api/auth/signup","/swagger-ui/**","/v3/api-docs/**").permitAll()
.requestMatchers("/api/auth/login", "/", "/api/auth/signup","/swagger-ui/**","/v3/api-docs/**","/api/posts/details/**").permitAll()
.requestMatchers("/admin").hasRole("ADMIN")
.anyRequest().authenticated());

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
package org.juniortown.backend.config;

import org.juniortown.backend.interceptor.GuestCookieInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import lombok.RequiredArgsConstructor;

@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final GuestCookieInterceptor guestCookieInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(guestCookieInterceptor)
.addPathPatterns("/**");
}

@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
Expand Down
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);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

쿠키 보안 설정을 강화해주세요.

정적 분석 도구에서 지적한 바와 같이 쿠키 보안 설정이 부족합니다.

다음과 같이 수정하여 보안을 강화하세요:

 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 환경이 아닌 경우 setSecure(false)로 설정하되, 프로덕션에서는 반드시 HTTPS를 사용하세요.

Committable suggestion skipped: line range outside the PR's diff.

🧰 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 Synchronizer Token or Double Submit Cookie patterns with defense-in-depth mechanisms such as the sameSite cookie flag can help prevent CSRF. For more information, see: [Cross-site request forgery prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Req\ uest_Forgery_Prevention_Cheat_Sheet.html).
Context: response.addCookie(cookie);
Note: [CWE-352] Cross-Site Request Forgery (CSRF). [REFERENCES]
- https://stackoverflow.com/questions/42717210/samesite-cookie-in-java-application

(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);'.
Context: response.addCookie(cookie);
Note: [CWE-614] Sensitive Cookie in HTTPS Session Without 'Secure' Attribute. [REFERENCES]
- https://owasp.org/www-community/controls/SecureCookieAttribute

(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);
Context: response.addCookie(cookie);
Note: [CWE-1004] Sensitive Cookie Without 'HttpOnly' Flag. [REFERENCES]
- https://owasp.org/www-community/HttpOnly

(cookie-missing-httponly-java)

🤖 Prompt for AI Agents
In
backend/src/main/java/org/juniortown/backend/interceptor/GuestCookieInterceptor.java
around lines 29 to 32, the cookie security settings are insufficient. Enhance
security by setting the cookie's Secure flag to true when in a production HTTPS
environment, and set HttpOnly to true to prevent client-side script access.
Also, consider setting the SameSite attribute to Lax or Strict to mitigate CSRF
risks. If HTTPS is not used, explicitly set Secure to false, but ensure
production uses HTTPS.

}
return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@
@Repository
public interface LikeRepository extends JpaRepository<Like, Long> {
Optional<Like> findByUserIdAndPostId(Long userId, Long postId);
Long countByPostId(Long postId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public LikeResponse likePost(Long userId, Long postId) {
.post(post)
.build();
likeRepository.save(newLike);

return LikeResponse.builder()
.userId(userId)
.postId(postId)
Expand Down
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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

컨트롤러의 비즈니스 로직을 서비스 레이어로 이동

조회수 증가 로직이 컨트롤러에 있습니다. 이는 서비스 레이어의 책임입니다. 또한 응답 DTO를 생성 후 수정하는 것은 좋지 않은 패턴입니다.

서비스 메서드가 사용자 정보를 받아 완전한 응답을 반환하도록 수정:

-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);
+String viewerId = customUserDetails != null 
+    ? String.valueOf(customUserDetails.getUserId()) 
+    : guestId;
+Long currentUserId = customUserDetails != null ? customUserDetails.getUserId() : null;
+
+PostDetailResponse postDetailResponse = postService.getPostDetail(postId, currentUserId, viewerId);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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.

return ResponseEntity.ok(postDetailResponse);
}

}
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;

@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
Expand Up @@ -4,7 +4,9 @@

import org.juniortown.backend.post.entity.Post;

import lombok.Builder;
import lombok.Getter;
import lombok.Setter;

@Getter
public class PostResponse {
Expand All @@ -13,18 +15,39 @@ public class PostResponse {
private final String content;
private final Long userId;
private final String userName;
private final Long likeCount;
private Long readCount;
private Boolean isLiked;
private final LocalDateTime createdAt;
private final LocalDateTime updatedAt;
private final LocalDateTime deletedAt;

public PostResponse(Post post) {
this.id = post.getId();
this.title = post.getTitle();
this.content = post.getContent();
this.userId = post.getUser().getId();
this.userName = post.getUser().getName();
this.createdAt = post.getCreatedAt();
this.updatedAt = post.getUpdatedAt();
this.deletedAt = post.getDeletedAt();
@Builder
public PostResponse(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 PostResponse from(Post post) {
return PostResponse.builder()
.id(post.getId())
.title(post.getTitle())
.content(post.getContent())
.userId(post.getUser().getId())
.userName(post.getUser().getName())
.readCount(post.getReadCount())
.createdAt(post.getCreatedAt())
.updatedAt(post.getUpdatedAt())
.deletedAt(post.getDeletedAt())
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

import java.time.LocalDateTime;


public interface PostWithLikeCountProjection {
Long getId();
String getTitle();
String getUsername();
Long getUserId();
Long getLikeCount();
Boolean getIsLiked();
Long getReadCount();
LocalDateTime getCreatedAt();
LocalDateTime getUpdatedAt();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Copy link

@coderabbitai coderabbitai bot Jul 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

동시성 고려사항을 확인해주세요.

addReadCount 메서드가 단순한 증가 로직을 구현하고 있습니다. Redis 동기화 서비스에서 호출될 때 동시성 문제가 발생할 수 있는지 확인이 필요합니다.

다음 스크립트로 이 메서드의 사용 패턴을 확인해보겠습니다:


🏁 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


동시성 이슈 확인 및 원자성 보장 필요

addReadCount(Long redisReadCount) 메서드가 단순히 인스턴스 필드에 덧셈만 수행하고 있어, 여러 쓰레드(또는 트랜잭션)에서 동시에 같은 게시물의 readCount를 갱신할 경우 최종 카운트가 누락되거나 덮어쓰여질 가능성이 있습니다.
호출 패턴 확인 결과, 아래 위치에서 사용 중입니다:

  • backend/src/main/java/org/juniortown/backend/post/service/ViewCountSyncService.java:47
  • backend/src/main/java/org/juniortown/backend/post/controller/PostController.java:91

해결 방안 예시:

  • 엔티티에 @Version 필드를 추가해 JPA 낙관적 락 적용
  • @Modifying + JPQL/Native 쿼리로 UPDATE post SET read_count = read_count + :count WHERE id = :id 처럼 DB 레벨에서 원자적 증가 수행
  • 서비스 계층에서 Java 동기화 블록(synchronized) 또는 분산 락(Redis Lock 등) 적용

위 방안 중 하나를 선택해 동시성 문제를 방지해주세요.

🤖 Prompt for AI Agents
In backend/src/main/java/org/juniortown/backend/post/entity/Post.java around
lines 62 to 64, the addReadCount method increments the readCount field
non-atomically, causing potential concurrency issues when multiple threads
update it simultaneously. To fix this, implement one of the suggested
concurrency control methods: add a @Version field for optimistic locking in the
entity, or replace the method with a database-level atomic update using a
@Modifying JPQL or native query that increments read_count directly, or apply
synchronization or distributed locking in the service layer to ensure atomic
increments and prevent lost updates.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addReadCount는 5분마다 실행되는 @scheduled 작업이고 redis에서 받아오는 응답을 더할 때 사용되는데 이는 동기적으로 동작해서 동시성 이슈는 발생하지 않는다고 판단. Redisson을 통해 스케줄링 작업이 여러 인스턴스에서 동시에 실행되지 않기에 하나의 스레드에서만 스케줄링 작업이 발생함을 보장함.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

}
Loading