Skip to content

Commit

Permalink
Feature: 분산락을 활용한 정답 제출 로직 구현 (#55)
Browse files Browse the repository at this point in the history
* chore: redisson client 의존성 추가

* feat: 득점자 테이블 요청 순서 추가

* feat: 분산락을 통한 정답 제출 로직 구현

* refactor: 재채점으로 인한 득점자 변경 시 분산락 활용

* feat: 득점자 확인용 캐시 저장소 제거
  • Loading branch information
Youthhing committed Jul 12, 2024
1 parent dbcf6ac commit 0fd599c
Show file tree
Hide file tree
Showing 7 changed files with 124 additions and 107 deletions.
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ dependencies {
//S3 관련 의존성 부여
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

implementation group: 'org.redisson', name: 'redisson-spring-boot-starter', version: '3.32.0'

implementation 'io.jsonwebtoken:jjwt:0.9.1'
implementation 'javax.xml.bind:jaxb-api:2.3.0'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ public enum ErrorCode {
IMAGE_DELETE_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "S-003", "s3 이미지 삭제처리를 실패했습니다"),
INTERNAL_SQL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S-004", "SQL 관련 에러 발생"),
ENUM_NOT_RESOLVED(HttpStatus.BAD_REQUEST, "S-005", "입력한 Enum이 존재하지 않습니다."),
SCORER_LOCK_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S-006", "득점자 락 획득 과정에서 에러 발생");
;

private final HttpStatus httpStatus;
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,25 @@ public class Scorer extends BaseTimeEntity {
@Column(name = "quiz_id", nullable = false)
private Long quizId;

private Scorer(final Long memberId, Quiz quiz) {
@Column(name = "ticket_number", nullable = false)
private Long ticketNumber;

private Scorer(final Long memberId, Quiz quiz, Long ticketNumber) {
this.memberId = memberId;
this.quizId = quiz.getId();
this.ticketNumber = ticketNumber;
}

public static Scorer of(final Long memberId, Quiz quiz) {
return new Scorer(memberId, quiz);
public static Scorer of(final Long memberId, Quiz quiz, Long ticketNumber) {
return new Scorer(memberId, quiz, ticketNumber);
}

public void updateMemberId(final Long memberId) {
this.memberId = memberId;
}

public void updateScorer(final Long memberId, Long ticketNumber){
this.memberId = memberId;
this.ticketNumber = ticketNumber;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package org.cotato.csquiz.domain.education.facade;

import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.cotato.csquiz.common.error.ErrorCode;
import org.cotato.csquiz.common.error.exception.AppException;
import org.cotato.csquiz.domain.education.entity.Quiz;
import org.cotato.csquiz.domain.education.entity.Record;
import org.cotato.csquiz.domain.education.service.ScorerService;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class RedissonScorerFacade {

private static final String KEY_PREFIX = "$Scorer_lock_";
private final ScorerService scorerService;
private final RedissonClient redissonClient;

public void checkAndThenUpdateScorer(Record memberReply) {
RLock lock = redissonClient.getLock(generateKey(memberReply.getQuiz()));

try {
boolean available = lock.tryLock(30, 1, TimeUnit.SECONDS);

if (!available) {
log.error("[락 획득 실패 (Record : {})]", memberReply.getId());
throw new AppException(ErrorCode.SCORER_LOCK_ERROR);
}
scorerService.checkAndThenUpdateScorer(memberReply);

} catch (InterruptedException e) {
throw new AppException(ErrorCode.SCORER_LOCK_ERROR);
} finally {
if (lock.isLocked() && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}

private String generateKey(final Quiz quiz) {
return KEY_PREFIX + quiz.getId();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,21 @@
import org.cotato.csquiz.api.record.dto.ScorerResponse;
import org.cotato.csquiz.api.socket.dto.QuizOpenRequest;
import org.cotato.csquiz.api.socket.dto.QuizSocketRequest;
import org.cotato.csquiz.common.error.ErrorCode;
import org.cotato.csquiz.common.error.exception.AppException;
import org.cotato.csquiz.domain.auth.entity.Member;
import org.cotato.csquiz.domain.auth.repository.MemberRepository;
import org.cotato.csquiz.domain.auth.service.MemberService;
import org.cotato.csquiz.domain.education.cache.QuizAnswerRedisRepository;
import org.cotato.csquiz.domain.education.cache.TicketCountRedisRepository;
import org.cotato.csquiz.domain.education.entity.MultipleQuiz;
import org.cotato.csquiz.domain.education.entity.Quiz;
import org.cotato.csquiz.domain.education.entity.Record;
import org.cotato.csquiz.domain.education.entity.Scorer;
import org.cotato.csquiz.domain.education.facade.RedissonScorerFacade;
import org.cotato.csquiz.domain.education.repository.QuizRepository;
import org.cotato.csquiz.domain.education.repository.RecordRepository;
import org.cotato.csquiz.domain.education.repository.ScorerRepository;
import org.cotato.csquiz.domain.auth.entity.Member;
import org.cotato.csquiz.common.error.exception.AppException;
import org.cotato.csquiz.common.error.ErrorCode;
import org.cotato.csquiz.domain.auth.repository.MemberRepository;
import org.cotato.csquiz.domain.auth.service.MemberService;
import org.cotato.csquiz.domain.education.cache.QuizAnswerRedisRepository;
import org.cotato.csquiz.domain.education.cache.ScorerExistRedisRepository;
import org.cotato.csquiz.domain.education.cache.TicketCountRedisRepository;
import org.cotato.csquiz.domain.education.util.AnswerUtil;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -40,14 +40,15 @@
public class RecordService {

private static final String INPUT_DELIMITER = ",";
private final RedissonScorerFacade redissonScorerFacade;
private final MemberService memberService;
private final RecordRepository recordRepository;
private final QuizRepository quizRepository;
private final MemberRepository memberRepository;
private final ScorerRepository scorerRepository;
private final QuizAnswerRedisRepository quizAnswerRedisRepository;
private final TicketCountRedisRepository ticketCountRedisRepository;
private final ScorerExistRedisRepository scorerExistRedisRepository;


@Transactional
public ReplyResponse replyToQuiz(ReplyRequest request) {
Expand All @@ -68,16 +69,8 @@ public ReplyResponse replyToQuiz(ReplyRequest request) {
String reply = String.join(INPUT_DELIMITER, inputs);
Record createdRecord = Record.of(reply, isCorrect, findMember, findQuiz, ticketNumber);

if (isCorrect && scorerExistRedisRepository.saveScorerIfIsFastest(findQuiz, ticketNumber)) {
scorerRepository.findByQuizId(findQuiz.getId())
.ifPresentOrElse(
scorer -> {
scorer.updateMemberId(findMember.getId());
scorerRepository.save(scorer);
},
() -> createScorer(createdRecord)
);
log.info("득점자 생성 : {}, 티켓번호: {}", findMember.getId(), ticketNumber);
if (isCorrect) {
redissonScorerFacade.checkAndThenUpdateScorer(createdRecord);
}

recordRepository.save(createdRecord);
Expand Down Expand Up @@ -106,7 +99,6 @@ private Quiz findQuizById(Long quizId) {
public void saveAnswersToCache(QuizOpenRequest request) {
List<Quiz> quizzes = quizRepository.findAllByEducationId(request.educationId());

scorerExistRedisRepository.saveAllScorerNone(quizzes);
quizAnswerRedisRepository.saveAllQuizAnswers(quizzes);
}

Expand All @@ -123,11 +115,9 @@ public void regradeRecords(RegradeRequest request) {
.min(Comparator.comparing(Record::getTicketNumber))
.orElseThrow(() -> new AppException(ErrorCode.REGRADE_FAIL));

scorerRepository.findByQuizId(quiz.getId())
.ifPresentOrElse(
scorer -> updateScorer(scorer, fastestRecord),
() -> createScorer(fastestRecord)
);
// 기존 득점자가 있어 -> 비교 후 업데이트
// 없어 -> 본인을 득점자로 등록
redissonScorerFacade.checkAndThenUpdateScorer(fastestRecord);
}

private void checkQuizType(Quiz quiz) {
Expand All @@ -136,26 +126,6 @@ private void checkQuizType(Quiz quiz) {
}
}

private void updateScorer(Scorer previousScorer, Record fastestRecord) {
if (isFaster(previousScorer, fastestRecord)) {
log.info("[득점자 변경] 새로운 티켓 번호: {}", fastestRecord.getTicketNumber());
previousScorer.updateMemberId(fastestRecord.getMemberId());
scorerRepository.save(previousScorer);
}
}

private boolean isFaster(Scorer previousScorer, Record fastestRecord) {
Quiz findQuiz = quizRepository.findById(previousScorer.getQuizId())
.orElseThrow(() -> new EntityNotFoundException("이전 득점자가 맞춘 퀴즈가 존재하지 않습니다."));
return scorerExistRedisRepository.getScorerTicketNumber(findQuiz) > fastestRecord.getTicketNumber();
}

private void createScorer(Record fastestRecord) {
Scorer scorer = Scorer.of(fastestRecord.getMemberId(), fastestRecord.getQuiz());
scorerRepository.save(scorer);
scorerExistRedisRepository.saveScorer(fastestRecord.getQuiz(), fastestRecord.getTicketNumber());
}

@Transactional
public RecordsAndScorerResponse findRecordsAndScorer(Long quizId) {
Quiz findQuiz = findQuizById(quizId);
Expand Down Expand Up @@ -188,6 +158,5 @@ public void saveAnswer(QuizSocketRequest request) {
Quiz findQuiz = quizRepository.findById(request.quizId())
.orElseThrow(() -> new EntityNotFoundException("해당 퀴즈를 찾을 수 없습니다."));
quizAnswerRedisRepository.saveQuizAnswer(findQuiz);
scorerExistRedisRepository.saveScorer(findQuiz, Long.MAX_VALUE);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package org.cotato.csquiz.domain.education.service;

import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.cotato.csquiz.domain.education.entity.Quiz;
import org.cotato.csquiz.domain.education.entity.Record;
import org.cotato.csquiz.domain.education.entity.Scorer;
import org.cotato.csquiz.domain.education.repository.ScorerRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ScorerService {

private final ScorerRepository scorerRepository;

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void checkAndThenUpdateScorer(Record memberReply) {
Optional<Scorer> maybeScorer = scorerRepository.findByQuizId(memberReply.getQuiz().getId());

maybeScorer.ifPresentOrElse(
scorer -> {
if (scorer.getTicketNumber() > memberReply.getTicketNumber()) {
scorer.updateScorer(memberReply.getMemberId(), memberReply.getTicketNumber());
scorerRepository.save(scorer);
log.info("득점자 업데이트 : 티켓번호: {}", memberReply.getTicketNumber());
}
},
() -> {
createScorer(memberReply.getMemberId(), memberReply.getQuiz(), memberReply.getTicketNumber());
log.info("득점자 생성 : {}, 티켓번호: {}", memberReply.getMemberId(), memberReply.getTicketNumber());
}

);
}

@Transactional
public void createScorer(final Long memberId, final Quiz quiz, final Long ticketNumber){
scorerRepository.save(Scorer.of(memberId, quiz, ticketNumber));
}
}

0 comments on commit 0fd599c

Please sign in to comment.