diff --git a/src/main/java/life/offonoff/ab/application/service/CommentService.java b/src/main/java/life/offonoff/ab/application/service/CommentService.java index e75b1173..ab4470d0 100644 --- a/src/main/java/life/offonoff/ab/application/service/CommentService.java +++ b/src/main/java/life/offonoff/ab/application/service/CommentService.java @@ -19,14 +19,17 @@ import life.offonoff.ab.web.response.CommentReactionResponse; import life.offonoff.ab.web.response.CommentResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; +import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import static life.offonoff.ab.application.service.common.LengthInfo.COMMENT_CONTENT; +@Slf4j @RequiredArgsConstructor @Transactional(readOnly = true) @Service @@ -93,9 +96,16 @@ private Vote findVote(Long memberId, Long topicId) { //== save ==// @Transactional public CommentResponse register(Long memberId, CommentRequest request) { - Comment comment = createComment(memberId, request); + try { + // 토픽의 댓글수 먼저 업데이트 + topicRepository.flush(); + } catch (ObjectOptimisticLockingFailureException e) { + log.warn(e.getMessage()); + throw new CommentConcurrencyException(); + } + commentRepository.save(comment); eventPublisher.publishEvent(new CommentedEvent(comment)); diff --git a/src/main/java/life/offonoff/ab/application/service/TopicService.java b/src/main/java/life/offonoff/ab/application/service/TopicService.java index 37fd320e..6588d10b 100644 --- a/src/main/java/life/offonoff/ab/application/service/TopicService.java +++ b/src/main/java/life/offonoff/ab/application/service/TopicService.java @@ -27,6 +27,7 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; +import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -199,6 +200,15 @@ private VoteResponse voteForTopic(final Member member, final Topic topic, final Vote vote = new Vote(choiceOption, votedAt); vote.associate(member, topic); + + try { + // 토픽의 투표수 먼저 업데이트 + topicRepository.flush(); + } catch (ObjectOptimisticLockingFailureException e) { + log.warn(e.getMessage()); + throw new VoteConcurrencyException(); + } + voteRepository.save(vote); // publish VotedEvent diff --git a/src/main/java/life/offonoff/ab/domain/topic/Topic.java b/src/main/java/life/offonoff/ab/domain/topic/Topic.java index 73280aeb..03663d1d 100644 --- a/src/main/java/life/offonoff/ab/domain/topic/Topic.java +++ b/src/main/java/life/offonoff/ab/domain/topic/Topic.java @@ -60,6 +60,9 @@ public class Topic extends BaseEntity { @Enumerated(EnumType.STRING) private TopicStatus status = TopicStatus.VOTING; + @Version + private Integer version; + private int commentCount = 0; private int voteCount = 0; private int hideCount = 0; diff --git a/src/main/java/life/offonoff/ab/domain/topic/choice/Choice.java b/src/main/java/life/offonoff/ab/domain/topic/choice/Choice.java index 41322103..0b9af590 100644 --- a/src/main/java/life/offonoff/ab/domain/topic/choice/Choice.java +++ b/src/main/java/life/offonoff/ab/domain/topic/choice/Choice.java @@ -28,6 +28,9 @@ public class Choice extends BaseEntity { @JoinColumn(name = "choice_content_id") private ChoiceContent content; + @Version + private Integer version; + private int voteCount; //== Constructor ==// diff --git a/src/main/java/life/offonoff/ab/exception/AbCode.java b/src/main/java/life/offonoff/ab/exception/AbCode.java index 32606666..f9071770 100644 --- a/src/main/java/life/offonoff/ab/exception/AbCode.java +++ b/src/main/java/life/offonoff/ab/exception/AbCode.java @@ -63,5 +63,10 @@ public enum AbCode { NOT_KOREAN_ENGLISH_NUMBER, - S3_INVALID_FILE_URL, S3_INVALID_KEY_NAME, DEACTIVATED_MEMBER; + S3_INVALID_FILE_URL, + S3_INVALID_KEY_NAME, + DEACTIVATED_MEMBER, + + CONCURRENCY_VIOLATION, + ; } diff --git a/src/main/java/life/offonoff/ab/exception/CommentConcurrencyException.java b/src/main/java/life/offonoff/ab/exception/CommentConcurrencyException.java new file mode 100644 index 00000000..0614dd55 --- /dev/null +++ b/src/main/java/life/offonoff/ab/exception/CommentConcurrencyException.java @@ -0,0 +1,9 @@ +package life.offonoff.ab.exception; + +public class CommentConcurrencyException extends ConcurrencyViolationException { + private static final String HINT = "토픽의 댓글수 업데이트 중 낙관적 락 실패"; + @Override + public String getHint() { + return HINT; + } +} diff --git a/src/main/java/life/offonoff/ab/exception/ConcurrencyViolationException.java b/src/main/java/life/offonoff/ab/exception/ConcurrencyViolationException.java new file mode 100644 index 00000000..f365294b --- /dev/null +++ b/src/main/java/life/offonoff/ab/exception/ConcurrencyViolationException.java @@ -0,0 +1,22 @@ +package life.offonoff.ab.exception; + +import org.springframework.http.HttpStatus; + +public abstract class ConcurrencyViolationException extends AbException{ + private static final AbCode AB_CODE = AbCode.CONCURRENCY_VIOLATION; + private static final String MESSAGE = "서버 문제가 발생했습니다. 다시 시도해주세요."; + + public ConcurrencyViolationException() { + super(MESSAGE); + } + + @Override + public int getHttpStatusCode() { + return HttpStatus.CONFLICT.value(); + } + + @Override + public AbCode getAbCode() { + return AB_CODE; + } +} diff --git a/src/main/java/life/offonoff/ab/exception/VoteConcurrencyException.java b/src/main/java/life/offonoff/ab/exception/VoteConcurrencyException.java new file mode 100644 index 00000000..2ab83333 --- /dev/null +++ b/src/main/java/life/offonoff/ab/exception/VoteConcurrencyException.java @@ -0,0 +1,10 @@ +package life.offonoff.ab.exception; + +public class VoteConcurrencyException extends ConcurrencyViolationException { + private static final String HINT = "투표 시 동시성 문제 발생"; + + @Override + public String getHint() { + return HINT; + } +} diff --git a/src/main/java/life/offonoff/ab/web/common/GlobalExceptionHandler.java b/src/main/java/life/offonoff/ab/web/common/GlobalExceptionHandler.java index a066e4f0..04d35fa8 100644 --- a/src/main/java/life/offonoff/ab/web/common/GlobalExceptionHandler.java +++ b/src/main/java/life/offonoff/ab/web/common/GlobalExceptionHandler.java @@ -5,6 +5,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -12,8 +13,7 @@ import java.util.stream.Collectors; -import static life.offonoff.ab.exception.AbCode.INTERNAL_SERVER_ERROR; -import static life.offonoff.ab.exception.AbCode.INVALID_FIELD; +import static life.offonoff.ab.exception.AbCode.*; @Slf4j // 예외가 발생했을 때 json 형태로 반환할 때 사용하는 어노테이션 @@ -74,6 +74,20 @@ private ResponseEntity handleHttpMessageNotReadableException(final return handleException(e); } + @ExceptionHandler(ObjectOptimisticLockingFailureException.class) + private ResponseEntity handleOptimisticLockingFailureException( + final ObjectOptimisticLockingFailureException e) { + log.warn("Unhandled Optimistic Lock! = ", e); + final String message = "서버 문제가 발생했습니다. 다시 시도해주세요."; + final ErrorWrapper errorWrapper = new ErrorWrapper( + CONCURRENCY_VIOLATION, + ErrorContent.of(message, HttpStatus.CONFLICT.value()) + ); + return ResponseEntity + .status(HttpStatus.CONFLICT) + .body(errorWrapper); + } + @ExceptionHandler(Exception.class) private ResponseEntity handleException(final Exception exception) { log.warn("! Unfiltered Exception = ", exception); diff --git a/src/test/java/life/offonoff/ab/application/service/TopicServiceConcurrencyTest.java b/src/test/java/life/offonoff/ab/application/service/TopicServiceConcurrencyTest.java new file mode 100644 index 00000000..6629178a --- /dev/null +++ b/src/test/java/life/offonoff/ab/application/service/TopicServiceConcurrencyTest.java @@ -0,0 +1,104 @@ +package life.offonoff.ab.application.service; + +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; +import life.offonoff.ab.application.service.member.MemberService; +import life.offonoff.ab.application.service.request.VoteRequest; +import life.offonoff.ab.application.testutil.AbCleaner; +import life.offonoff.ab.domain.member.Member; +import life.offonoff.ab.domain.topic.Topic; +import life.offonoff.ab.domain.topic.TopicSide; +import life.offonoff.ab.domain.topic.choice.Choice; +import life.offonoff.ab.domain.topic.choice.ChoiceOption; +import life.offonoff.ab.exception.VoteConcurrencyException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.transaction.TestTransaction; + +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.IntStream; + +import static life.offonoff.ab.domain.TestEntityUtil.createRandomMember; +import static life.offonoff.ab.domain.TestEntityUtil.createRandomTopicByMemberWithChoices; +import static org.assertj.core.api.Assertions.assertThat; + +@Transactional +@SpringBootTest +public class TopicServiceConcurrencyTest { + + @Autowired + private MemberService memberService; + + @Autowired + private TopicService topicService; + + @Autowired + private EntityManager em; + + @Autowired + private AbCleaner cleaner; + + @AfterEach + void tearDown() { + cleaner.cleanTables(); + } + + @Test + void voteForTopicByMember() throws InterruptedException { + // given + final int COUNT = 10; + final ExecutorService executorService = Executors.newFixedThreadPool(COUNT); + + // 토픽 생성 + Member topicAuthor = createRandomMember(); + em.persist(topicAuthor); + Topic topic = createRandomTopicByMemberWithChoices( + topicAuthor, TopicSide.TOPIC_A, ChoiceOption.CHOICE_A, ChoiceOption.CHOICE_B); + em.persist(topic); + + // 투표자 COUNT만큼 생성 + List voters = IntStream.range(0, COUNT) + .mapToObj(__ -> { + Member voter = createRandomMember(); + em.persist(voter); + return voter; + }) + .toList(); + + TestTransaction.flagForCommit(); + TestTransaction.end(); + TestTransaction.start(); + + // when + long votedAt = System.currentTimeMillis() / 1000; + final CountDownLatch latch = new CountDownLatch(COUNT); + AtomicInteger failureCounter = new AtomicInteger(0); + voters.forEach(voter -> { + executorService.execute(() -> { + try { + topicService.voteForTopicByMember( + topic.getId(), voter.getId(), new VoteRequest(ChoiceOption.CHOICE_A, votedAt)); + } catch (VoteConcurrencyException e) { + failureCounter.incrementAndGet(); + } + latch.countDown(); + }); + }); + latch.await(); + + // then + Topic updatedTopic = em.find(Topic.class, topic.getId()); + Choice votedChoice = updatedTopic.getChoices().stream().filter(c -> c.getChoiceOption().equals(ChoiceOption.CHOICE_A)).findAny().get(); + // 투표수는 COUNT와 동일해야함 + int successfulVoteCount = COUNT - failureCounter.get(); + assertThat(updatedTopic.getVoteCount()).isEqualTo(successfulVoteCount); + assertThat(votedChoice.getVoteCount()).isEqualTo(successfulVoteCount); + } + +} diff --git a/src/test/java/life/offonoff/ab/application/testutil/AbCleaner.java b/src/test/java/life/offonoff/ab/application/testutil/AbCleaner.java new file mode 100644 index 00000000..218481fb --- /dev/null +++ b/src/test/java/life/offonoff/ab/application/testutil/AbCleaner.java @@ -0,0 +1,31 @@ +package life.offonoff.ab.application.testutil; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@RequiredArgsConstructor +@Service +public class AbCleaner { + private final JdbcTemplate jdbcTemplate; + private List truncateQueries; + + @PostConstruct + public void loadTruncateQueries(){ + truncateQueries = jdbcTemplate.queryForList( + "SELECT Concat('TRUNCATE TABLE ', TABLE_NAME, ';') AS q FROM INFORMATION_SCHEMA.TABLES " + + "WHERE TABLE_SCHEMA IN ('PUBLIC', 'localab', 'ab')", + String.class); + } + + @Transactional + public void cleanTables() { + jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0"); + truncateQueries.forEach(jdbcTemplate::execute); + jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1"); + } +} diff --git a/src/test/java/life/offonoff/ab/domain/TestEntityUtil.java b/src/test/java/life/offonoff/ab/domain/TestEntityUtil.java index 79078082..53e2917b 100644 --- a/src/test/java/life/offonoff/ab/domain/TestEntityUtil.java +++ b/src/test/java/life/offonoff/ab/domain/TestEntityUtil.java @@ -10,7 +10,6 @@ import life.offonoff.ab.domain.topic.choice.Choice; import life.offonoff.ab.domain.topic.choice.ChoiceOption; import life.offonoff.ab.domain.topic.choice.content.ChoiceContent; -import life.offonoff.ab.domain.topic.choice.content.ImageTextChoiceContent; import life.offonoff.ab.domain.vote.Vote; import lombok.Builder; import org.springframework.data.domain.PageRequest; @@ -64,7 +63,11 @@ public static Topic createRandomTopic() { } public static Topic createRandomTopicHavingChoices(ChoiceOption... options) { - Topic topic = createRandomTopic(); + return createRandomTopicByMemberWithChoices(createRandomMember(), TopicSide.TOPIC_B, options); + } + + public static Topic createRandomTopicByMemberWithChoices(Member author, TopicSide side, ChoiceOption... options) { + Topic topic = createRandomTopicByMember(author, side); for (var option : options) { createChoice(topic, option, null);