Skip to content

Commit

Permalink
feat: 투표와 댓글 추가 시 낙관적 락 사용한다
Browse files Browse the repository at this point in the history
  • Loading branch information
melonturtle committed Mar 14, 2024
1 parent ef014a8 commit 0f1d2d2
Show file tree
Hide file tree
Showing 10 changed files with 102 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/life/offonoff/ab/domain/topic/Topic.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ==//
Expand Down
7 changes: 6 additions & 1 deletion src/main/java/life/offonoff/ab/exception/AbCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -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,
;
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@
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;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;

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 형태로 반환할 때 사용하는 어노테이션
Expand Down Expand Up @@ -74,6 +74,20 @@ private ResponseEntity<ErrorWrapper> handleHttpMessageNotReadableException(final
return handleException(e);
}

@ExceptionHandler(ObjectOptimisticLockingFailureException.class)
private ResponseEntity<ErrorWrapper> 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<ErrorWrapper> handleException(final Exception exception) {
log.warn("! Unfiltered Exception = ", exception);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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();
});
});
Expand All @@ -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);
}

}

0 comments on commit 0f1d2d2

Please sign in to comment.