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 6feac6bb..6a98f377 100644 --- a/src/main/java/life/offonoff/ab/application/service/CommentService.java +++ b/src/main/java/life/offonoff/ab/application/service/CommentService.java @@ -18,14 +18,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 @@ -92,9 +95,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 feab8627..2b88a417 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 index ef4cde32..6629178a 100644 --- a/src/test/java/life/offonoff/ab/application/service/TopicServiceConcurrencyTest.java +++ b/src/test/java/life/offonoff/ab/application/service/TopicServiceConcurrencyTest.java @@ -10,6 +10,7 @@ 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; @@ -20,6 +21,7 @@ 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; @@ -76,10 +78,15 @@ void voteForTopicByMember() throws InterruptedException { // when long votedAt = System.currentTimeMillis() / 1000; final CountDownLatch latch = new CountDownLatch(COUNT); + AtomicInteger failureCounter = new AtomicInteger(0); voters.forEach(voter -> { executorService.execute(() -> { - topicService.voteForTopicByMember( - topic.getId(), voter.getId(), new VoteRequest(ChoiceOption.CHOICE_A, votedAt)); + try { + topicService.voteForTopicByMember( + topic.getId(), voter.getId(), new VoteRequest(ChoiceOption.CHOICE_A, votedAt)); + } catch (VoteConcurrencyException e) { + failureCounter.incrementAndGet(); + } latch.countDown(); }); }); @@ -89,8 +96,9 @@ void voteForTopicByMember() throws InterruptedException { Topic updatedTopic = em.find(Topic.class, topic.getId()); Choice votedChoice = updatedTopic.getChoices().stream().filter(c -> c.getChoiceOption().equals(ChoiceOption.CHOICE_A)).findAny().get(); // 투표수는 COUNT와 동일해야함 - assertThat(updatedTopic.getVoteCount()).isEqualTo(COUNT); - assertThat(votedChoice.getVoteCount()).isEqualTo(COUNT); + int successfulVoteCount = COUNT - failureCounter.get(); + assertThat(updatedTopic.getVoteCount()).isEqualTo(successfulVoteCount); + assertThat(votedChoice.getVoteCount()).isEqualTo(successfulVoteCount); } }