diff --git a/src/main/java/com/example/solidconnection/cache/ThunderingHerdCachingAspect.java b/src/main/java/com/example/solidconnection/cache/ThunderingHerdCachingAspect.java index b6a9fe0b0..6f0abc7f2 100644 --- a/src/main/java/com/example/solidconnection/cache/ThunderingHerdCachingAspect.java +++ b/src/main/java/com/example/solidconnection/cache/ThunderingHerdCachingAspect.java @@ -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; diff --git a/src/main/java/com/example/solidconnection/common/config/redis/RedisConfig.java b/src/main/java/com/example/solidconnection/common/config/redis/RedisConfig.java index a59558993..f94443580 100644 --- a/src/main/java/com/example/solidconnection/common/config/redis/RedisConfig.java +++ b/src/main/java/com/example/solidconnection/common/config/redis/RedisConfig.java @@ -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; diff --git a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java index d00ce52b3..e638466b9 100644 --- a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java @@ -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(), "존재하지 않는 댓글입니다."), diff --git a/src/main/java/com/example/solidconnection/community/post/service/PostCommandService.java b/src/main/java/com/example/solidconnection/community/post/service/PostCommandService.java index daea4558d..52cba18ec 100644 --- a/src/main/java/com/example/solidconnection/community/post/service/PostCommandService.java +++ b/src/main/java/com/example/solidconnection/community/post/service/PostCommandService.java @@ -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; @@ -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; @@ -40,14 +40,19 @@ 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 imageFile) { SiteUser siteUser = siteUserRepository.findById(siteUserId) .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + + // 중복 생성 방지 + if (!postRedisManager.isPostCreationAllowed(siteUserId)) { + throw new CustomException(DUPLICATE_POST_CREATE_REQUEST); + } + // 유효성 검증 validatePostCategory(postCreateRequest.postCategory()); validateFileSize(imageFile); @@ -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); diff --git a/src/main/java/com/example/solidconnection/community/post/service/PostQueryService.java b/src/main/java/com/example/solidconnection/community/post/service/PostQueryService.java index 413ec400d..9beea5e8d 100644 --- a/src/main/java/com/example/solidconnection/community/post/service/PostQueryService.java +++ b/src/main/java/com/example/solidconnection/community/post/service/PostQueryService.java @@ -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; @@ -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 findPostsByCodeAndPostCategoryOrderByCreatedAtDesc(String code, String category, Long siteUserId) { @@ -81,10 +79,7 @@ public PostFindResponse findPostById(long siteUserId, Long postId) { List postImageFindResultDTOList = PostFindPostImageResponse.from(post.getPostImageList()); List 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); diff --git a/src/main/java/com/example/solidconnection/community/post/service/PostRedisManager.java b/src/main/java/com/example/solidconnection/community/post/service/PostRedisManager.java new file mode 100644 index 000000000..e074b45f4 --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/service/PostRedisManager.java @@ -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 { + + 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; + } +} diff --git a/src/main/java/com/example/solidconnection/community/post/service/UpdateViewCountService.java b/src/main/java/com/example/solidconnection/community/post/service/UpdateViewCountService.java index 42a5f8b95..480c89d64 100644 --- a/src/main/java/com/example/solidconnection/community/post/service/UpdateViewCountService.java +++ b/src/main/java/com/example/solidconnection/community/post/service/UpdateViewCountService.java @@ -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; @@ -17,14 +16,14 @@ 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); + Long postId = postRedisManager.getPostIdFromPostViewCountRedisKey(key); Post post = postRepository.getById(postId); - postRepository.increaseViewCount(postId, redisService.getAndDelete(key)); + Long viewCount = postRedisManager.getAndDeleteViewCount(key); + postRepository.increaseViewCount(postId, viewCount); } } diff --git a/src/main/java/com/example/solidconnection/community/post/service/RedisConstants.java b/src/main/java/com/example/solidconnection/redis/RedisConstants.java similarity index 77% rename from src/main/java/com/example/solidconnection/community/post/service/RedisConstants.java rename to src/main/java/com/example/solidconnection/redis/RedisConstants.java index 46260596c..58d6258cf 100644 --- a/src/main/java/com/example/solidconnection/community/post/service/RedisConstants.java +++ b/src/main/java/com/example/solidconnection/redis/RedisConstants.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.community.post.service; +package com.example.solidconnection.redis; import lombok.Getter; @@ -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; diff --git a/src/main/java/com/example/solidconnection/community/post/service/RedisService.java b/src/main/java/com/example/solidconnection/redis/RedisService.java similarity index 80% rename from src/main/java/com/example/solidconnection/community/post/service/RedisService.java rename to src/main/java/com/example/solidconnection/redis/RedisService.java index 7b701fc2b..52e01019e 100644 --- a/src/main/java/com/example/solidconnection/community/post/service/RedisService.java +++ b/src/main/java/com/example/solidconnection/redis/RedisService.java @@ -1,7 +1,7 @@ -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.VALIDATE_VIEW_COUNT_TTL; +import static com.example.solidconnection.redis.RedisConstants.VIEW_COUNT_TTL; import java.util.Collections; import java.util.concurrent.TimeUnit; @@ -37,9 +37,9 @@ public Long getAndDelete(String key) { return Long.valueOf(redisTemplate.opsForValue().getAndDelete(key)); } - 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) { diff --git a/src/main/java/com/example/solidconnection/scheduler/UpdateViewCountScheduler.java b/src/main/java/com/example/solidconnection/scheduler/UpdateViewCountScheduler.java index 9a5561728..202d8f05c 100644 --- a/src/main/java/com/example/solidconnection/scheduler/UpdateViewCountScheduler.java +++ b/src/main/java/com/example/solidconnection/scheduler/UpdateViewCountScheduler.java @@ -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; diff --git a/src/main/java/com/example/solidconnection/util/RedisUtils.java b/src/main/java/com/example/solidconnection/util/RedisUtils.java index df4d7572d..a18fe1791 100644 --- a/src/main/java/com/example/solidconnection/util/RedisUtils.java +++ b/src/main/java/com/example/solidconnection/util/RedisUtils.java @@ -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; @@ -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}가 포함된 경우에만 해당 인덱스의 파라미터를 삽입 diff --git a/src/test/java/com/example/solidconnection/community/post/service/PostCommandServiceTest.java b/src/test/java/com/example/solidconnection/community/post/service/PostCommandServiceTest.java index 3b3847d81..c752c11e4 100644 --- a/src/test/java/com/example/solidconnection/community/post/service/PostCommandServiceTest.java +++ b/src/test/java/com/example/solidconnection/community/post/service/PostCommandServiceTest.java @@ -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; @@ -57,7 +57,7 @@ class PostCommandServiceTest { private RedisService redisService; @Autowired - private RedisUtils redisUtils; + private PostRedisManager postRedisManager; @Autowired private PostRepository postRepository; @@ -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 diff --git a/src/test/java/com/example/solidconnection/community/post/service/PostQueryServiceTest.java b/src/test/java/com/example/solidconnection/community/post/service/PostQueryServiceTest.java index f3eaf41a8..66803d688 100644 --- a/src/test/java/com/example/solidconnection/community/post/service/PostQueryServiceTest.java +++ b/src/test/java/com/example/solidconnection/community/post/service/PostQueryServiceTest.java @@ -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; @@ -40,7 +40,7 @@ class PostQueryServiceTest { private RedisService redisService; @Autowired - private RedisUtils redisUtils; + private PostRedisManager postRedisManager; @Autowired private SiteUserFixture siteUserFixture; @@ -176,8 +176,8 @@ void setUp() { Comment comment2 = commentFixture.부모_댓글("댓글2", post, user); List 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()); diff --git a/src/test/java/com/example/solidconnection/concurrency/PostCreateConcurrencyTest.java b/src/test/java/com/example/solidconnection/concurrency/PostCreateConcurrencyTest.java new file mode 100644 index 000000000..72b132fbb --- /dev/null +++ b/src/test/java/com/example/solidconnection/concurrency/PostCreateConcurrencyTest.java @@ -0,0 +1,149 @@ +package com.example.solidconnection.concurrency; + +import static com.example.solidconnection.redis.RedisConstants.VALIDATE_POST_CREATE_TTL; +import static org.assertj.core.api.Assertions.assertThat; + +import com.example.solidconnection.community.post.service.PostRedisManager; +import com.example.solidconnection.redis.RedisService; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@TestContainerSpringBootTest +@DisplayName("게시글 생성 동시성 테스트") +class PostCreateConcurrencyTest { + + @Autowired + private RedisService redisService; + + @Autowired + private PostRedisManager postRedisManager; + + @Autowired + private SiteUserFixture siteUserFixture; + + private SiteUser user; + + @BeforeEach + void setUp() { + user = siteUserFixture.사용자(); + redisService.deleteKey(postRedisManager.getPostCreateRedisKey(user.getId())); + } + + @Test + void 동시에_여러_요청이_들어오면_첫_번째_요청만_허용된다() throws InterruptedException { + // given + ExecutorService executorService = Executors.newFixedThreadPool(5); + CountDownLatch readyLatch = new CountDownLatch(5); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(5); + + AtomicInteger allowedCount = new AtomicInteger(0); + AtomicInteger deniedCount = new AtomicInteger(0); + + // when + for (int i = 0; i < 5; i++) { + executorService.submit(() -> { + try { + readyLatch.countDown(); + startLatch.await(); + + boolean isAllowed = postRedisManager.isPostCreationAllowed(user.getId()); + if (isAllowed) { + allowedCount.incrementAndGet(); + } else { + deniedCount.incrementAndGet(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + doneLatch.countDown(); + } + }); + } + + readyLatch.await(5, TimeUnit.SECONDS); //모든 스레드가 준비 상태가 될 때까지 대기 + startLatch.countDown(); //동시 실행 + doneLatch.await(5, TimeUnit.SECONDS); //모든 스레드의 작업이 끝날 때까지 대기 + executorService.shutdown(); + executorService.awaitTermination(5, TimeUnit.SECONDS); + + // then + assertThat(allowedCount.get()).isEqualTo(1); + assertThat(deniedCount.get()).isEqualTo(4); + } + + @Test + void TTL이_지나면_다시_게시글_생성이_허용된다() throws InterruptedException { + // given + boolean firstAttempt = postRedisManager.isPostCreationAllowed(user.getId()); + boolean secondAttemptBeforeTtl = postRedisManager.isPostCreationAllowed(user.getId()); + + // when + long ttlSeconds = Long.parseLong(VALIDATE_POST_CREATE_TTL.getValue()); + Thread.sleep((ttlSeconds + 1) * 1000); + + boolean attemptAfterTtl = postRedisManager.isPostCreationAllowed(user.getId()); + + // then + assertThat(firstAttempt).isTrue(); + assertThat(secondAttemptBeforeTtl).isFalse(); + assertThat(attemptAfterTtl).isTrue(); + } + + @Test + void 서로_다른_사용자는_동시에_게시글을_생성할_수_있다() throws InterruptedException { + // given + SiteUser user1 = siteUserFixture.사용자(1, "사용자1"); + SiteUser user2 = siteUserFixture.사용자(2, "사용자2"); + SiteUser user3 = siteUserFixture.사용자(3, "사용자3"); + + redisService.deleteKey(postRedisManager.getPostCreateRedisKey(user1.getId())); + redisService.deleteKey(postRedisManager.getPostCreateRedisKey(user2.getId())); + redisService.deleteKey(postRedisManager.getPostCreateRedisKey(user3.getId())); + + ExecutorService executorService = Executors.newFixedThreadPool(3); + CountDownLatch readyLatch = new CountDownLatch(3); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(3); + + AtomicInteger allowedCount = new AtomicInteger(0); + + // when + for (SiteUser currentUser : new SiteUser[]{user1, user2, user3}) { + executorService.submit(() -> { + try { + readyLatch.countDown(); + startLatch.await(); + + boolean isAllowed = postRedisManager.isPostCreationAllowed(currentUser.getId()); + if (isAllowed) { + allowedCount.incrementAndGet(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + doneLatch.countDown(); + } + }); + } + + readyLatch.await(5, TimeUnit.SECONDS); //모든 스레드가 준비 상태가 될 때까지 대기 + startLatch.countDown(); //동시 실행 + doneLatch.await(5, TimeUnit.SECONDS); //모든 스레드의 작업이 끝날 때까지 대기 + executorService.shutdown(); + executorService.awaitTermination(5, TimeUnit.SECONDS); + + // then + assertThat(allowedCount.get()).isEqualTo(3); + } +} diff --git a/src/test/java/com/example/solidconnection/concurrency/PostViewCountConcurrencyTest.java b/src/test/java/com/example/solidconnection/concurrency/PostViewCountConcurrencyTest.java index 4396e697b..a3529a0ab 100644 --- a/src/test/java/com/example/solidconnection/concurrency/PostViewCountConcurrencyTest.java +++ b/src/test/java/com/example/solidconnection/concurrency/PostViewCountConcurrencyTest.java @@ -1,6 +1,6 @@ package com.example.solidconnection.concurrency; -import static com.example.solidconnection.community.post.service.RedisConstants.VALIDATE_VIEW_COUNT_TTL; +import static com.example.solidconnection.redis.RedisConstants.VALIDATE_VIEW_COUNT_TTL; import static org.junit.jupiter.api.Assertions.assertEquals; import com.example.solidconnection.community.board.domain.Board; @@ -8,11 +8,11 @@ import com.example.solidconnection.community.post.domain.Post; import com.example.solidconnection.community.post.domain.PostCategory; import com.example.solidconnection.community.post.repository.PostRepository; -import com.example.solidconnection.community.post.service.RedisService; +import com.example.solidconnection.community.post.service.PostRedisManager; +import com.example.solidconnection.redis.RedisService; 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 java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -37,7 +37,7 @@ class PostViewCountConcurrencyTest { private BoardRepository boardRepository; @Autowired - private RedisUtils redisUtils; + private PostRedisManager postRedisManager; @Autowired private SiteUserFixture siteUserFixture; @@ -84,7 +84,7 @@ private Post createPost(Board board, SiteUser siteUser) { @Test void 게시글을_조회할_때_조회수_동시성_문제를_해결한다() throws InterruptedException { - redisService.deleteKey(redisUtils.getValidatePostViewCountRedisKey(user.getId(), post.getId())); + redisService.deleteKey(postRedisManager.getValidatePostViewCountRedisKey(user.getId(), post.getId())); ExecutorService executorService = Executors.newFixedThreadPool(THREAD_POOL_SIZE); CountDownLatch doneSignal = new CountDownLatch(THREAD_NUMS); @@ -92,7 +92,7 @@ private Post createPost(Board board, SiteUser siteUser) { for (int i = 0; i < THREAD_NUMS; i++) { executorService.submit(() -> { try { - redisService.increaseViewCount(redisUtils.getPostViewCountRedisKey(post.getId())); + redisService.increaseViewCount(postRedisManager.getPostViewCountRedisKey(post.getId())); } finally { doneSignal.countDown(); } @@ -114,7 +114,7 @@ private Post createPost(Board board, SiteUser siteUser) { @Test void 게시글을_조회할_때_조회수_조작_문제를_해결한다() throws InterruptedException { - redisService.deleteKey(redisUtils.getValidatePostViewCountRedisKey(user.getId(), post.getId())); + redisService.deleteKey(postRedisManager.getValidatePostViewCountRedisKey(user.getId(), post.getId())); ExecutorService executorService = Executors.newFixedThreadPool(THREAD_POOL_SIZE); CountDownLatch doneSignal = new CountDownLatch(THREAD_NUMS); @@ -122,9 +122,9 @@ private Post createPost(Board board, SiteUser siteUser) { for (int i = 0; i < THREAD_NUMS; i++) { executorService.submit(() -> { try { - boolean isFirstTime = redisService.isPresent(redisUtils.getValidatePostViewCountRedisKey(user.getId(), post.getId())); + boolean isFirstTime = redisService.isPresent(postRedisManager.getValidatePostViewCountRedisKey(user.getId(), post.getId()), VALIDATE_VIEW_COUNT_TTL.getValue()); if (isFirstTime) { - redisService.increaseViewCount(redisUtils.getPostViewCountRedisKey(post.getId())); + redisService.increaseViewCount(postRedisManager.getPostViewCountRedisKey(post.getId())); } } finally { doneSignal.countDown(); @@ -135,9 +135,9 @@ private Post createPost(Board board, SiteUser siteUser) { for (int i = 0; i < THREAD_NUMS; i++) { executorService.submit(() -> { try { - boolean isFirstTime = redisService.isPresent(redisUtils.getValidatePostViewCountRedisKey(user.getId(), post.getId())); + boolean isFirstTime = redisService.isPresent(postRedisManager.getValidatePostViewCountRedisKey(user.getId(), post.getId()), VALIDATE_VIEW_COUNT_TTL.getValue()); if (isFirstTime) { - redisService.increaseViewCount(redisUtils.getPostViewCountRedisKey(post.getId())); + redisService.increaseViewCount(postRedisManager.getPostViewCountRedisKey(post.getId())); } } finally { doneSignal.countDown();