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
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package com.example.solidconnection.cache;

import static com.example.solidconnection.community.post.service.RedisConstants.CREATE_CHANNEL;
import static com.example.solidconnection.community.post.service.RedisConstants.LOCK_TIMEOUT_MS;
import static com.example.solidconnection.community.post.service.RedisConstants.MAX_WAIT_TIME_MS;
import static com.example.solidconnection.community.post.service.RedisConstants.REFRESH_LIMIT_PERCENT;
import static com.example.solidconnection.redis.RedisConstants.CREATE_CHANNEL;
import static com.example.solidconnection.redis.RedisConstants.LOCK_TIMEOUT_MS;
import static com.example.solidconnection.redis.RedisConstants.MAX_WAIT_TIME_MS;
import static com.example.solidconnection.redis.RedisConstants.REFRESH_LIMIT_PERCENT;

import com.example.solidconnection.cache.annotation.ThunderingHerdCaching;
import com.example.solidconnection.cache.manager.CacheManager;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.example.solidconnection.common.config.redis;

import static com.example.solidconnection.community.post.service.RedisConstants.CREATE_CHANNEL;
import static com.example.solidconnection.redis.RedisConstants.CREATE_CHANNEL;

import com.example.solidconnection.cache.CacheUpdateListener;
import org.springframework.beans.factory.annotation.Value;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ public enum ErrorCode {
INVALID_BOARD_CODE(HttpStatus.BAD_REQUEST.value(), "잘못된 게시판 코드입니다."),
INVALID_POST_ID(HttpStatus.BAD_REQUEST.value(), "존재하지 않는 게시글입니다."), // todo: NOT_FOUND로 통일 필요
INVALID_POST_ACCESS(HttpStatus.BAD_REQUEST.value(), "자신의 게시글만 제어할 수 있습니다."),
DUPLICATE_POST_CREATE_REQUEST(HttpStatus.BAD_REQUEST.value(), "게시글이 이미 생성 중입니다."),
CAN_NOT_DELETE_OR_UPDATE_QUESTION(HttpStatus.BAD_REQUEST.value(), "질문글은 수정이나 삭제할 수 없습니다."),
CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES(HttpStatus.BAD_REQUEST.value(), "5개 이상의 파일을 업로드할 수 없습니다."),
INVALID_COMMENT_ID(HttpStatus.BAD_REQUEST.value(), "존재하지 않는 댓글입니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import static com.example.solidconnection.common.exception.ErrorCode.CAN_NOT_DELETE_OR_UPDATE_QUESTION;
import static com.example.solidconnection.common.exception.ErrorCode.CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES;
import static com.example.solidconnection.common.exception.ErrorCode.DUPLICATE_POST_CREATE_REQUEST;
import static com.example.solidconnection.common.exception.ErrorCode.INVALID_POST_ACCESS;
import static com.example.solidconnection.common.exception.ErrorCode.INVALID_POST_CATEGORY;
import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND;
Expand All @@ -23,7 +24,6 @@
import com.example.solidconnection.s3.service.S3Service;
import com.example.solidconnection.siteuser.domain.SiteUser;
import com.example.solidconnection.siteuser.repository.SiteUserRepository;
import com.example.solidconnection.util.RedisUtils;
import java.util.List;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
Expand All @@ -40,18 +40,23 @@ public class PostCommandService {
private final BoardRepository boardRepository;
private final SiteUserRepository siteUserRepository;
private final S3Service s3Service;
private final RedisService redisService;
private final RedisUtils redisUtils;
private final PostRedisManager postRedisManager;

@Transactional
public PostCreateResponse createPost(long siteUserId, PostCreateRequest postCreateRequest,
List<MultipartFile> imageFile) {
SiteUser siteUser = siteUserRepository.findById(siteUserId)
.orElseThrow(() -> new CustomException(USER_NOT_FOUND));

// 유효성 검증
validatePostCategory(postCreateRequest.postCategory());
validateFileSize(imageFile);

// 중복 생성 방지
if (!postRedisManager.isPostCreationAllowed(siteUserId)) {
throw new CustomException(DUPLICATE_POST_CREATE_REQUEST);
}
Comment on lines +55 to +58
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd "PostRedisManager.java" -t f --exec cat {}

Repository: solid-connection/solid-connect-server

Length of output: 2330


🏁 Script executed:

fd "PostCommandService.java" -t f --exec cat {}

Repository: solid-connection/solid-connect-server

Length of output: 6411


🏁 Script executed:

fd "RedisService.java" -t f --exec cat {}

Repository: solid-connection/solid-connect-server

Length of output: 1878


🏁 Script executed:

fd "RedisConstants.java" -t f --exec cat {}

Repository: solid-connection/solid-connect-server

Length of output: 789


생성 실패 시 Redis 락이 해제되지 않는 문제를 수정하세요.

  1. 문제의 원인

    • isPostCreationAllowed()는 Redis의 SETNX(setIfAbsent) 명령으로 5초 TTL의 락을 설정합니다.
    • 락이 성공적으로 획득되면 메서드가 true를 반환하고, 이후 Board 조회·Post 저장·S3 이미지 업로드가 진행됩니다.
    • 이 과정 중 예외가 발생하면 트랜잭션은 롤백되지만, Redis 락은 그대로 남아 5초의 TTL이 만료될 때까지 유지됩니다.
  2. 사용자 영향

    • 사용자가 게시글 생성 중 S3 업로드 실패, DB 저장 실패 등의 예외를 만나면, 실제로는 게시글이 생성되지 않습니다.
    • 그러나 Redis 락이 여전히 활성화되어 있어서, 사용자가 즉시 재시도할 때 "게시글이 이미 생성 중입니다" 400 에러를 받게 됩니다.
    • 실제 게시글이 없는데도 최대 5초간 재시도가 차단되므로 사용 경험이 저하됩니다.
  3. 수정 방향

    • 생성 실패 시 락을 명시적으로 해제해야 합니다.
    • try-catch 블록으로 감싸거나, PostRedisManager에 락 해제 메서드를 추가하는 방식을 권장합니다.
🛠️ 제안하는 구현 예시
         // 중복 생성 방지
         if (!postRedisManager.isPostCreationAllowed(siteUserId)) {
             throw new CustomException(DUPLICATE_POST_CREATE_REQUEST);
         }

-        // 객체 생성
-        Board board = boardRepository.getByCode(postCreateRequest.boardCode());
-        Post post = postCreateRequest.toEntity(siteUser, board);
-
-        // 이미지 처리
-        savePostImages(imageFile, post);
-        Post createdPost = postRepository.save(post);
-
-        return PostCreateResponse.from(createdPost);
+        try {
+            // 객체 생성
+            Board board = boardRepository.getByCode(postCreateRequest.boardCode());
+            Post post = postCreateRequest.toEntity(siteUser, board);
+
+            // 이미지 처리
+            savePostImages(imageFile, post);
+            Post createdPost = postRepository.save(post);
+
+            return PostCreateResponse.from(createdPost);
+        } catch (Exception e) {
+            postRedisManager.releasePostCreationLock(siteUserId);
+            throw e;
+        }

PostRedisManager에 다음 메서드를 추가하세요:

public void releasePostCreationLock(long siteUserId) {
    String key = getPostCreateRedisKey(siteUserId);
    redisService.deleteKey(key);
}
🤖 Prompt for AI Agents
In
`@src/main/java/com/example/solidconnection/community/post/service/PostCommandService.java`
around lines 55 - 58, The Redis lock acquired by
PostRedisManager.isPostCreationAllowed(siteUserId) is not released on failures;
wrap the post creation flow in PostCommandService.create* (where
isPostCreationAllowed is called) with try/catch/finally and call a new
PostRedisManager.releasePostCreationLock(siteUserId) in the failure path (or
finally when creation did not succeed) so the SETNX lock is deleted on
exceptions; add releasePostCreationLock in PostRedisManager that deletes the key
(e.g., getPostCreateRedisKey(siteUserId) -> redisService.deleteKey(key)) and
invoke it from the catch/finally in the service when DB/S3/upload or any
subsequent step fails.


// 객체 생성
Board board = boardRepository.getByCode(postCreateRequest.boardCode());
Post post = postCreateRequest.toEntity(siteUser, board);
Expand Down Expand Up @@ -104,8 +109,7 @@ public PostDeleteResponse deletePostById(long siteUserId, Long postId) {
validateQuestion(post);

removePostImages(post);
// cache out
redisService.deleteKey(redisUtils.getPostViewCountRedisKey(postId));
postRedisManager.deleteViewCountCache(postId);
postRepository.deleteById(post.getId());

return new PostDeleteResponse(postId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
import com.example.solidconnection.siteuser.dto.PostFindSiteUserResponse;
import com.example.solidconnection.siteuser.repository.SiteUserRepository;
import com.example.solidconnection.siteuser.repository.UserBlockRepository;
import com.example.solidconnection.util.RedisUtils;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
Expand All @@ -42,8 +41,7 @@ public class PostQueryService {
private final SiteUserRepository siteUserRepository;
private final UserBlockRepository userBlockRepository;
private final CommentService commentService;
private final RedisService redisService;
private final RedisUtils redisUtils;
private final PostRedisManager postRedisManager;

@Transactional(readOnly = true)
public List<PostListResponse> findPostsByCodeAndPostCategoryOrderByCreatedAtDesc(String code, String category, Long siteUserId) {
Expand Down Expand Up @@ -81,10 +79,7 @@ public PostFindResponse findPostById(long siteUserId, Long postId) {
List<PostFindPostImageResponse> postImageFindResultDTOList = PostFindPostImageResponse.from(post.getPostImageList());
List<PostFindCommentResponse> commentFindResultDTOList = commentService.findCommentsByPostId(siteUser.getId(), postId);

// caching && 어뷰징 방지
if (redisService.isPresent(redisUtils.getValidatePostViewCountRedisKey(siteUser.getId(), postId))) {
redisService.increaseViewCount(redisUtils.getPostViewCountRedisKey(postId));
}
postRedisManager.incrementViewCountIfFirstAccess(siteUser.getId(), postId);

return PostFindResponse.from(
post, isOwner, isLiked, boardPostFindResultDTO, siteUserPostFindResultDTO, commentFindResultDTOList, postImageFindResultDTOList);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.example.solidconnection.community.post.service;

import static com.example.solidconnection.redis.RedisConstants.POST_CREATE_PREFIX;
import static com.example.solidconnection.redis.RedisConstants.VALIDATE_POST_CREATE_TTL;
import static com.example.solidconnection.redis.RedisConstants.VALIDATE_VIEW_COUNT_KEY_PREFIX;
import static com.example.solidconnection.redis.RedisConstants.VALIDATE_VIEW_COUNT_TTL;
import static com.example.solidconnection.redis.RedisConstants.VIEW_COUNT_KEY_PREFIX;

import com.example.solidconnection.redis.RedisService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class PostRedisManager {
Copy link
Member

Choose a reason for hiding this comment

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

흩어진 Post 관련 레디스 메서드 한 곳으로 통합하는 거 좋은 거 같아요 !


private final RedisService redisService;

public Long getPostIdFromPostViewCountRedisKey(String key) {
return Long.parseLong(key.substring(VIEW_COUNT_KEY_PREFIX.getValue().length()));
}

public Long getAndDeleteViewCount(String key) {
return redisService.getAndDelete(key);
}

public void deleteViewCountCache(Long postId) {
String key = getPostViewCountRedisKey(postId);
redisService.deleteKey(key);
}

public void incrementViewCountIfFirstAccess(long siteUserId, Long postId) {
String validateKey = getValidatePostViewCountRedisKey(siteUserId, postId);
boolean isFirstAccess = redisService.isPresent(validateKey, VALIDATE_VIEW_COUNT_TTL.getValue());

if (isFirstAccess) {
String viewCountKey = getPostViewCountRedisKey(postId);
redisService.increaseViewCount(viewCountKey);
}
}

public String getPostViewCountRedisKey(Long postId) {
return VIEW_COUNT_KEY_PREFIX.getValue() + postId;
}

public String getValidatePostViewCountRedisKey(long siteUserId, Long postId) {
return VALIDATE_VIEW_COUNT_KEY_PREFIX.getValue() + postId + ":" + siteUserId;
}

public boolean isPostCreationAllowed(Long siteUserId) {
String key = getPostCreateRedisKey(siteUserId);
return redisService.isPresent(key, VALIDATE_POST_CREATE_TTL.getValue());
}

public String getPostCreateRedisKey(Long siteUserId) {
return POST_CREATE_PREFIX.getValue() + siteUserId;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import com.example.solidconnection.community.post.domain.Post;
import com.example.solidconnection.community.post.repository.PostRepository;
import com.example.solidconnection.util.RedisUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
Expand All @@ -17,14 +16,13 @@
public class UpdateViewCountService {

private final PostRepository postRepository;
private final RedisService redisService;
private final RedisUtils redisUtils;
private final PostRedisManager postRedisManager;

@Transactional
@Async
public void updateViewCount(String key) {
Long postId = redisUtils.getPostIdFromPostViewCountRedisKey(key);
Post post = postRepository.getById(postId);
postRepository.increaseViewCount(postId, redisService.getAndDelete(key));
Long postId = postRedisManager.getPostIdFromPostViewCountRedisKey(key);
Long viewCount = postRedisManager.getAndDeleteViewCount(key);
postRepository.increaseViewCount(postId, viewCount);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.example.solidconnection.community.post.service;
package com.example.solidconnection.redis;

import lombok.Getter;

Expand All @@ -15,7 +15,10 @@ public enum RedisConstants {
REFRESH_LOCK_PREFIX("refresh_lock:"),
LOCK_TIMEOUT_MS("10000"),
MAX_WAIT_TIME_MS("3000"),
CREATE_CHANNEL("create_channel");
CREATE_CHANNEL("create_channel"),

POST_CREATE_PREFIX("post_create_lock:"),
VALIDATE_POST_CREATE_TTL("5");

private final String value;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.example.solidconnection.community.post.service;
package com.example.solidconnection.redis;

import static com.example.solidconnection.community.post.service.RedisConstants.VALIDATE_VIEW_COUNT_TTL;
import static com.example.solidconnection.community.post.service.RedisConstants.VIEW_COUNT_TTL;
import static com.example.solidconnection.redis.RedisConstants.VIEW_COUNT_TTL;

import java.util.Collections;
import java.util.concurrent.TimeUnit;
Expand Down Expand Up @@ -34,12 +33,13 @@ public void deleteKey(String key) {
}

public Long getAndDelete(String key) {
return Long.valueOf(redisTemplate.opsForValue().getAndDelete(key));
String value = redisTemplate.opsForValue().getAndDelete(key);
return value != null ? Long.valueOf(value) : null;
}

public boolean isPresent(String key) {
public boolean isPresent(String key, String ttl) {
return Boolean.TRUE.equals(redisTemplate.opsForValue()
.setIfAbsent(key, "1", Long.parseLong(VALIDATE_VIEW_COUNT_TTL.getValue()), TimeUnit.SECONDS));
.setIfAbsent(key, "1", Long.parseLong(ttl), TimeUnit.SECONDS));
}

public boolean isKeyExists(String key) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.example.solidconnection.scheduler;

import static com.example.solidconnection.community.post.service.RedisConstants.VIEW_COUNT_KEY_PATTERN;
import static com.example.solidconnection.redis.RedisConstants.VIEW_COUNT_KEY_PATTERN;

import com.example.solidconnection.community.post.service.UpdateViewCountService;
import com.example.solidconnection.util.RedisUtils;
Expand Down
18 changes: 2 additions & 16 deletions src/main/java/com/example/solidconnection/util/RedisUtils.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
package com.example.solidconnection.util;

import static com.example.solidconnection.community.post.service.RedisConstants.CREATE_LOCK_PREFIX;
import static com.example.solidconnection.community.post.service.RedisConstants.REFRESH_LOCK_PREFIX;
import static com.example.solidconnection.community.post.service.RedisConstants.VALIDATE_VIEW_COUNT_KEY_PREFIX;
import static com.example.solidconnection.community.post.service.RedisConstants.VIEW_COUNT_KEY_PREFIX;
import static com.example.solidconnection.redis.RedisConstants.CREATE_LOCK_PREFIX;
import static com.example.solidconnection.redis.RedisConstants.REFRESH_LOCK_PREFIX;

import java.util.Collections;
import java.util.Comparator;
Expand Down Expand Up @@ -39,18 +37,6 @@ public Long getExpirationTime(String key) {
return redisTemplate.getExpire(key, TimeUnit.MILLISECONDS);
}

public String getPostViewCountRedisKey(Long postId) {
return VIEW_COUNT_KEY_PREFIX.getValue() + postId;
}

public String getValidatePostViewCountRedisKey(long siteUserId, Long postId) {
return VALIDATE_VIEW_COUNT_KEY_PREFIX.getValue() + postId + ":" + siteUserId;
}

public Long getPostIdFromPostViewCountRedisKey(String key) {
return Long.parseLong(key.substring(VIEW_COUNT_KEY_PREFIX.getValue().length()));
}

public String generateCacheKey(String keyPattern, Object[] args) {
for (int i = 0; i < args.length; i++) {
// 키 패턴에 {i}가 포함된 경우에만 해당 인덱스의 파라미터를 삽입
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@
import com.example.solidconnection.community.post.fixture.PostFixture;
import com.example.solidconnection.community.post.fixture.PostImageFixture;
import com.example.solidconnection.community.post.repository.PostRepository;
import com.example.solidconnection.redis.RedisService;
import com.example.solidconnection.s3.domain.UploadPath;
import com.example.solidconnection.s3.dto.UploadedFileUrlResponse;
import com.example.solidconnection.s3.service.S3Service;
import com.example.solidconnection.siteuser.domain.SiteUser;
import com.example.solidconnection.siteuser.fixture.SiteUserFixture;
import com.example.solidconnection.support.TestContainerSpringBootTest;
import com.example.solidconnection.util.RedisUtils;
import jakarta.transaction.Transactional;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
Expand All @@ -57,7 +57,7 @@ class PostCommandServiceTest {
private RedisService redisService;

@Autowired
private RedisUtils redisUtils;
private PostRedisManager postRedisManager;

@Autowired
private PostRepository postRepository;
Expand Down Expand Up @@ -266,7 +266,7 @@ class 게시글_삭제_테스트 {
// given
String originImageUrl = "origin-image-url";
postImageFixture.게시글_이미지(originImageUrl, post);
String viewCountKey = redisUtils.getPostViewCountRedisKey(post.getId());
String viewCountKey = postRedisManager.getPostViewCountRedisKey(post.getId());
redisService.increaseViewCount(viewCountKey);

// when
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@
import com.example.solidconnection.community.post.dto.PostListResponse;
import com.example.solidconnection.community.post.fixture.PostFixture;
import com.example.solidconnection.community.post.fixture.PostImageFixture;
import com.example.solidconnection.redis.RedisService;
import com.example.solidconnection.siteuser.domain.SiteUser;
import com.example.solidconnection.siteuser.fixture.SiteUserFixture;
import com.example.solidconnection.siteuser.fixture.UserBlockFixture;
import com.example.solidconnection.support.TestContainerSpringBootTest;
import com.example.solidconnection.util.RedisUtils;
import java.time.ZonedDateTime;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
Expand All @@ -40,7 +40,7 @@ class PostQueryServiceTest {
private RedisService redisService;

@Autowired
private RedisUtils redisUtils;
private PostRedisManager postRedisManager;

@Autowired
private SiteUserFixture siteUserFixture;
Expand Down Expand Up @@ -176,8 +176,8 @@ void setUp() {
Comment comment2 = commentFixture.부모_댓글("댓글2", post, user);
List<Comment> comments = List.of(comment1, comment2);

String validateKey = redisUtils.getValidatePostViewCountRedisKey(user.getId(), post.getId());
String viewCountKey = redisUtils.getPostViewCountRedisKey(post.getId());
String validateKey = postRedisManager.getValidatePostViewCountRedisKey(user.getId(), post.getId());
String viewCountKey = postRedisManager.getPostViewCountRedisKey(post.getId());

// when
PostFindResponse response = postQueryService.findPostById(user.getId(), post.getId());
Expand Down
Loading
Loading