diff --git a/src/main/java/swyp/team5/greening/comment/service/CommentCommandService.java b/src/main/java/swyp/team5/greening/comment/service/CommentCommandService.java index cbe221b..28f2a75 100644 --- a/src/main/java/swyp/team5/greening/comment/service/CommentCommandService.java +++ b/src/main/java/swyp/team5/greening/comment/service/CommentCommandService.java @@ -17,8 +17,6 @@ import swyp.team5.greening.post.domain.repository.PostRepository; import swyp.team5.greening.post.exception.PostExceptionMessage; -//todo: 댓글 추가나 삭제를 통해 발생하는 댓글 수 증감 -> 이벤트 처리 -//todo: 동시성 문제 처리 @Service @RequiredArgsConstructor public class CommentCommandService { @@ -34,7 +32,7 @@ public SaveCommentResponseDto saveComment( ) { //게시물 존재 조회 Post post = postRepository - .findByIdAndState(requestDto.postId(), PostState.IN_PROGRESS) + .findByIdAndStateWithLock(requestDto.postId(), PostState.IN_PROGRESS) .orElseThrow(() -> new GreeningGlobalException(PostExceptionMessage.NOT_FOUND_POST)); @@ -82,7 +80,7 @@ public void deleteComment( CommentExceptionMessage.NOT_FOUND_COMMENT)); //게시물 존재 조회 - Post post = postRepository.findByIdAndState(comment.getPostId(), PostState.IN_PROGRESS) + Post post = postRepository.findByIdAndStateWithLock(comment.getPostId(), PostState.IN_PROGRESS) .orElseThrow(() -> new GreeningGlobalException(PostExceptionMessage.NOT_FOUND_POST)); @@ -91,11 +89,11 @@ public void deleteComment( throw new GreeningGlobalException(CommentExceptionMessage.BAD_REQUEST_COMMENT_WRITER); } - //게시물 댓글 수 감소 - post.decreaseCommentCount(); - //댓글 삭제 commentRepository.deleteById(requestDto.commentId()); + + //게시물 댓글 수 감소 + post.decreaseCommentCount(); } } diff --git a/src/main/java/swyp/team5/greening/post/domain/repository/PostRepository.java b/src/main/java/swyp/team5/greening/post/domain/repository/PostRepository.java index a8eb9a2..72d307c 100644 --- a/src/main/java/swyp/team5/greening/post/domain/repository/PostRepository.java +++ b/src/main/java/swyp/team5/greening/post/domain/repository/PostRepository.java @@ -14,4 +14,6 @@ public interface PostRepository { Optional findByIdAndState(Long postId, PostState state); + Optional findByIdAndStateWithLock(Long postId, PostState state); + } diff --git a/src/main/java/swyp/team5/greening/post/infrastructure/PostJpaRepository.java b/src/main/java/swyp/team5/greening/post/infrastructure/PostJpaRepository.java index cd738a7..24899af 100644 --- a/src/main/java/swyp/team5/greening/post/infrastructure/PostJpaRepository.java +++ b/src/main/java/swyp/team5/greening/post/infrastructure/PostJpaRepository.java @@ -1,9 +1,27 @@ package swyp.team5.greening.post.infrastructure; +import jakarta.persistence.LockModeType; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import swyp.team5.greening.post.domain.entity.Post; +import swyp.team5.greening.post.domain.entity.PostState; import swyp.team5.greening.post.domain.repository.PostRepository; public interface PostJpaRepository extends JpaRepository, PostRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + SELECT post + FROM Post post + WHERE post.id = :postId + AND post.state = :state + """) + Optional findByIdAndStateWithLock( + @Param("postId") Long postId, + @Param("state") PostState state + ); + } diff --git a/src/main/java/swyp/team5/greening/postLike/service/PostLikeCommandService.java b/src/main/java/swyp/team5/greening/postLike/service/PostLikeCommandService.java index 3a72cea..c3e9400 100644 --- a/src/main/java/swyp/team5/greening/postLike/service/PostLikeCommandService.java +++ b/src/main/java/swyp/team5/greening/postLike/service/PostLikeCommandService.java @@ -13,8 +13,6 @@ import swyp.team5.greening.postLike.domain.repository.LikeRepository; import swyp.team5.greening.postLike.dto.PostLikeResponseDto; -//todo: 게시글 좋아요 수 상태 변화 -> 이벤트 처리 -//todo: 동시성 문제 처리 @Service @RequiredArgsConstructor public class PostLikeCommandService { @@ -26,7 +24,7 @@ public class PostLikeCommandService { @Transactional public PostLikeResponseDto likeOrCancel(Long userId, Long postId) { //게시글 조회 - Post post = postRepository.findByIdAndState(postId, PostState.IN_PROGRESS) + Post post = postRepository.findByIdAndStateWithLock(postId, PostState.IN_PROGRESS) .orElseThrow( () -> new GreeningGlobalException(PostExceptionMessage.NOT_FOUND_POST)); diff --git a/src/test/java/swyp/team5/greening/comment/service/CommentCommandServiceTest.java b/src/test/java/swyp/team5/greening/comment/service/CommentCommandServiceTest.java index 89a2910..4f86839 100644 --- a/src/test/java/swyp/team5/greening/comment/service/CommentCommandServiceTest.java +++ b/src/test/java/swyp/team5/greening/comment/service/CommentCommandServiceTest.java @@ -71,7 +71,7 @@ class saveCommentTest { @DisplayName("사용자는 게시물에 댓글을 작성할 수 있다. 이 때 게시물의 댓글 수가 증가한다.") void testCase1() { //given - given(postRepository.findByIdAndState(postId, PostState.IN_PROGRESS)).willReturn( + given(postRepository.findByIdAndStateWithLock(postId, PostState.IN_PROGRESS)).willReturn( Optional.of(postEntity)); given(commentRepository.save(any(Comment.class))).willReturn(commentEntity); ReflectionTestUtils.setField(postEntity, "id", postId); @@ -82,7 +82,7 @@ void testCase1() { userId, new SaveCommentRequestDto(postId, comment)); //then - verify(postRepository).findByIdAndState(postId, PostState.IN_PROGRESS); + verify(postRepository).findByIdAndStateWithLock(postId, PostState.IN_PROGRESS); verify(commentRepository).save(any(Comment.class)); assertThat(responseDto.id()).isEqualTo(commentEntity.getId()); assertThat(postEntity.getCommentCount()).isEqualTo(1L); @@ -92,7 +92,7 @@ void testCase1() { @DisplayName("게시물이 존재하지 않을 경우, 예외가 발생한다.") void testCase2() { //given - given(postRepository.findByIdAndState(postId, PostState.IN_PROGRESS)).willReturn( + given(postRepository.findByIdAndStateWithLock(postId, PostState.IN_PROGRESS)).willReturn( Optional.empty()); //when @@ -170,7 +170,7 @@ void testCase2() { @DisplayName("댓글 삭제할 수 있다. 이 때 게시글의 댓글 수는 감소한다.") void testCase3() { //given - given(postRepository.findByIdAndState(postId, PostState.IN_PROGRESS)).willReturn( + given(postRepository.findByIdAndStateWithLock(postId, PostState.IN_PROGRESS)).willReturn( Optional.of(postEntity)); given(commentRepository.findById(commentId)).willReturn(Optional.of(testComment)); diff --git a/src/test/java/swyp/team5/greening/comment/service/PostCommentCountConcurrencyTest.java b/src/test/java/swyp/team5/greening/comment/service/PostCommentCountConcurrencyTest.java new file mode 100644 index 0000000..ce47bb9 --- /dev/null +++ b/src/test/java/swyp/team5/greening/comment/service/PostCommentCountConcurrencyTest.java @@ -0,0 +1,86 @@ +package swyp.team5.greening.comment.service; + +import static org.assertj.core.api.Assertions.*; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import swyp.team5.greening.comment.domain.repository.CommentRepository; +import swyp.team5.greening.comment.dto.request.SaveCommentRequestDto; +import swyp.team5.greening.post.domain.entity.Post; +import swyp.team5.greening.post.domain.entity.PostState; +import swyp.team5.greening.post.domain.repository.PostRepository; +import swyp.team5.greening.support.TestContainerSupport; + +@SpringBootTest +public class PostCommentCountConcurrencyTest extends TestContainerSupport { + + @Autowired + private CommentCommandService commentCommandService; + + @Autowired + private CommentRepository commentRepository; + + @Autowired + private PostRepository postRepository; + + @BeforeEach + void init() { + commentRepository.deleteAll(); + postRepository.deleteAll(); + } + + @Nested + @DisplayName("게시글 1개가 존재한다.") + class TestCase1 { + + Post post; + + @BeforeEach + void init() { + post = Post.builder() + .title("제목") + .commentCount(0L) + .likeCount(0L) + .state(PostState.IN_PROGRESS) + .categoryId(1L) + .userId(1L) + .build(); + postRepository.save(post); + } + + @Test + @DisplayName("동시에 20명의 유저가 게시글의 댓글을 작성한다면, 댓글 수는 20이다.") + void saveCommentConcurrencyTest() throws InterruptedException { + //given + ExecutorService executorService = Executors.newCachedThreadPool(); + CountDownLatch countDownLatch = new CountDownLatch(20); + + //when + for (long i = 0; i < 20; i++) { + final long userId = i; + executorService.submit(() -> { + commentCommandService.saveComment(userId, + new SaveCommentRequestDto( + post.getId(), "댓글" + userId + )); + + countDownLatch.countDown(); + }); + } + + countDownLatch.await(); + + Post result = postRepository.findByIdAndState(post.getId(), PostState.IN_PROGRESS) + .orElseThrow(); + + assertThat(result.getCommentCount()).isEqualTo(20L); + } + } +} diff --git a/src/test/java/swyp/team5/greening/postLike/service/PostLikeCommandServiceTest.java b/src/test/java/swyp/team5/greening/postLike/service/PostLikeCommandServiceTest.java index 7382ace..2685a2c 100644 --- a/src/test/java/swyp/team5/greening/postLike/service/PostLikeCommandServiceTest.java +++ b/src/test/java/swyp/team5/greening/postLike/service/PostLikeCommandServiceTest.java @@ -52,7 +52,7 @@ class TestCase1 { @DisplayName("게시글을 좋아요 한 상태라면, 좋아요가 취소되며 좋아요 수가 감소한다.") void likeCancelTest() { //given - given(postRepository.findByIdAndState(postId, PostState.IN_PROGRESS)).willReturn( + given(postRepository.findByIdAndStateWithLock(postId, PostState.IN_PROGRESS)).willReturn( Optional.of(post)); ReflectionTestUtils.setField(post, "id", postId); given(likeRepository.findByUserIdAndPostId(userId, postId)).willReturn( @@ -71,7 +71,7 @@ void likeCancelTest() { @DisplayName("게시글을 좋아요 하지 않은 상태라면, 좋아요 처리되며 좋아요 수가 증가한ㄷ.") void likeTest() { //given - given(postRepository.findByIdAndState(postId, PostState.IN_PROGRESS)).willReturn( + given(postRepository.findByIdAndStateWithLock(postId, PostState.IN_PROGRESS)).willReturn( Optional.of(post)); ReflectionTestUtils.setField(post, "id", postId); diff --git a/src/test/java/swyp/team5/greening/postLike/service/PostLikeCountConcurrencyTest.java b/src/test/java/swyp/team5/greening/postLike/service/PostLikeCountConcurrencyTest.java new file mode 100644 index 0000000..e9d0f47 --- /dev/null +++ b/src/test/java/swyp/team5/greening/postLike/service/PostLikeCountConcurrencyTest.java @@ -0,0 +1,110 @@ +package swyp.team5.greening.postLike.service; + +import static org.assertj.core.api.Assertions.*; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import swyp.team5.greening.post.domain.entity.Post; +import swyp.team5.greening.post.domain.entity.PostState; +import swyp.team5.greening.post.domain.repository.PostRepository; +import swyp.team5.greening.postLike.domain.repository.LikeRepository; +import swyp.team5.greening.support.TestContainerSupport; + +@SpringBootTest +@DisplayName("게시글 좋아요 동시성 테스트") +class PostLikeCountConcurrencyTest extends TestContainerSupport { + + @Autowired + private PostLikeCommandService postLikeCommandService; + + @Autowired + private PostRepository postRepository; + + @Autowired + private LikeRepository likeRepository; + + @BeforeEach + void init() { + postRepository.deleteAll(); + likeRepository.deleteAll(); + } + + @Nested + @DisplayName("게시글 1개가 존재한다.") + class TestCase1 { + + Post post; + + @BeforeEach + void init() { + post = Post.builder() + .title("제목") + .commentCount(0L) + .likeCount(0L) + .state(PostState.IN_PROGRESS) + .categoryId(1L) + .userId(1L) + .build(); + postRepository.save(post); + } + + @Test + @DisplayName("동시에 20명의 유저가 게시글을 좋아요 할 경우, 좋아요 수가 알맞게 증가한다.") + void likeConcurrencyTest() throws InterruptedException { + //given + ExecutorService executorService = Executors.newCachedThreadPool(); + CountDownLatch countDownLatch = new CountDownLatch(20); + + //when + for (long i = 0; i < 20; i++) { + final long userId = i; + executorService.submit(() -> { + postLikeCommandService.likeOrCancel(userId, post.getId()); + countDownLatch.countDown(); + }); + } + + countDownLatch.await(); + + Post result = postRepository.findByIdAndState(post.getId(), PostState.IN_PROGRESS) + .orElseThrow(); + + //then + assertThat(result.getLikeCount()).isEqualTo(20L); + + executorService.shutdown(); + } + + @Test + @DisplayName("한 명의 유저가 해당 기능을 30번 호출할 경우, 좋아요 수는 0이다.") + void likeOrCancelConcurrencyTest() throws InterruptedException { + //given + ExecutorService executorService = Executors.newCachedThreadPool(); + CountDownLatch countDownLatch = new CountDownLatch(30); + + //when + for (int i = 0; i < 30; i++) { + executorService.submit(() -> { + postLikeCommandService.likeOrCancel(1L, post.getId()); + countDownLatch.countDown(); + }); + } + + countDownLatch.await(); + + Post result = postRepository.findByIdAndState(post.getId(), PostState.IN_PROGRESS) + .orElseThrow(); + + //then + assertThat(result.getLikeCount()).isEqualTo(0L); + + } + } +} \ No newline at end of file