Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
10 changes: 10 additions & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ 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.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'

}

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,37 @@
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 * 15); // 15 days
cookie.setHttpOnly(true);
response.addCookie(cookie);
}
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,23 @@ 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
) {
String viewerId = customUserDetails != null ? String.valueOf(customUserDetails.getUserId()) : guestId;
PostDetailResponse postDetailResponse = postService.getPost(postId, viewerId);
return ResponseEntity.ok(postDetailResponse);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package org.juniortown.backend.post.dto.response;

import java.time.LocalDateTime;

import lombok.Builder;
import lombok.Getter;

@Getter
public class PostDetailResponse {
private final Long id;
private final String title;
private final String content;
private final Long userId;
private final String userName;
private final Long likeCount;
private final Boolean isLiked;
private final Long readCount;
private final LocalDateTime createdAt;
private final LocalDateTime updatedAt;
private final 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();
}
}
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,41 @@ public class PostResponse {
private final String content;
private final Long userId;
private final String userName;
private final Long likeCount;
private final Long readCount;
private final 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())
.likeCount(0L) // 기본값 넣어주기
.isLiked(false) // 기본값 넣어주기
.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