Skip to content

Commit

Permalink
Merge pull request #194 from team-offonoff/concurrency/count/optimistic
Browse files Browse the repository at this point in the history
feat: 투표와 댓글 추가 시 낙관적 락 사용한다
  • Loading branch information
melonturtle authored Mar 27, 2024
2 parents 12265ce + 0f1d2d2 commit 4c56f55
Show file tree
Hide file tree
Showing 12 changed files with 230 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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));
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
@@ -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<Member> 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);
}

}
31 changes: 31 additions & 0 deletions src/test/java/life/offonoff/ab/application/testutil/AbCleaner.java
Original file line number Diff line number Diff line change
@@ -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<String> 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");
}
}
7 changes: 5 additions & 2 deletions src/test/java/life/offonoff/ab/domain/TestEntityUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down

0 comments on commit 4c56f55

Please sign in to comment.