diff --git a/build.gradle b/build.gradle index b7baea0a..7cd5ffdc 100644 --- a/build.gradle +++ b/build.gradle @@ -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' diff --git a/src/main/java/org/cotato/csquiz/common/error/ErrorCode.java b/src/main/java/org/cotato/csquiz/common/error/ErrorCode.java index c1258a05..293fccc1 100644 --- a/src/main/java/org/cotato/csquiz/common/error/ErrorCode.java +++ b/src/main/java/org/cotato/csquiz/common/error/ErrorCode.java @@ -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; diff --git a/src/main/java/org/cotato/csquiz/domain/education/cache/ScorerExistRedisRepository.java b/src/main/java/org/cotato/csquiz/domain/education/cache/ScorerExistRedisRepository.java deleted file mode 100644 index 0510a982..00000000 --- a/src/main/java/org/cotato/csquiz/domain/education/cache/ScorerExistRedisRepository.java +++ /dev/null @@ -1,58 +0,0 @@ -package org.cotato.csquiz.domain.education.cache; - -import org.cotato.csquiz.domain.education.entity.Quiz; -import org.cotato.csquiz.domain.education.repository.QuizRepository; -import java.util.List; -import java.util.concurrent.TimeUnit; -import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Repository; - -@Repository -@RequiredArgsConstructor -public class ScorerExistRedisRepository { - - private static final String KEY_PREFIX = "$Scorer for "; - private static final Long NONE_VALUE = Long.MAX_VALUE; - private static final Integer SCORER_EXPIRATION = 60 * 24; - private final RedisTemplate redisTemplate; - - public void saveAllScorerNone(List quizzes) { - for (Quiz quiz : quizzes) { - String quizKey = KEY_PREFIX + quiz.getId(); - redisTemplate.opsForValue().set( - quizKey, - NONE_VALUE, - SCORER_EXPIRATION, - TimeUnit.MINUTES - ); - } - } - - public void saveScorer(Quiz quiz, Long ticketNumber) { - String quizKey = KEY_PREFIX + quiz.getId(); - redisTemplate.opsForValue().set( - quizKey, - ticketNumber, - SCORER_EXPIRATION, - TimeUnit.MINUTES - ); - } - - public boolean saveScorerIfIsFastest(Quiz quiz, Long ticketNumber) { - if (getScorerTicketNumber(quiz) > ticketNumber) { - saveScorer(quiz, ticketNumber); - return true; - } else { - return false; - } - } - - public Long getScorerTicketNumber(Quiz quiz) { - String quizKey = KEY_PREFIX + quiz.getId(); - if (redisTemplate.opsForValue().get(quizKey) == null) { - return NONE_VALUE; - } - return redisTemplate.opsForValue().get(quizKey); - } -} diff --git a/src/main/java/org/cotato/csquiz/domain/education/entity/Scorer.java b/src/main/java/org/cotato/csquiz/domain/education/entity/Scorer.java index 9f3f8d4c..c90ee26f 100644 --- a/src/main/java/org/cotato/csquiz/domain/education/entity/Scorer.java +++ b/src/main/java/org/cotato/csquiz/domain/education/entity/Scorer.java @@ -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; + } } diff --git a/src/main/java/org/cotato/csquiz/domain/education/facade/RedissonScorerFacade.java b/src/main/java/org/cotato/csquiz/domain/education/facade/RedissonScorerFacade.java new file mode 100644 index 00000000..738ca59b --- /dev/null +++ b/src/main/java/org/cotato/csquiz/domain/education/facade/RedissonScorerFacade.java @@ -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(); + } +} diff --git a/src/main/java/org/cotato/csquiz/domain/education/service/RecordService.java b/src/main/java/org/cotato/csquiz/domain/education/service/RecordService.java index dc23e6c6..89cac76c 100644 --- a/src/main/java/org/cotato/csquiz/domain/education/service/RecordService.java +++ b/src/main/java/org/cotato/csquiz/domain/education/service/RecordService.java @@ -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; @@ -40,6 +40,7 @@ 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; @@ -47,7 +48,7 @@ public class RecordService { private final ScorerRepository scorerRepository; private final QuizAnswerRedisRepository quizAnswerRedisRepository; private final TicketCountRedisRepository ticketCountRedisRepository; - private final ScorerExistRedisRepository scorerExistRedisRepository; + @Transactional public ReplyResponse replyToQuiz(ReplyRequest request) { @@ -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); @@ -106,7 +99,6 @@ private Quiz findQuizById(Long quizId) { public void saveAnswersToCache(QuizOpenRequest request) { List quizzes = quizRepository.findAllByEducationId(request.educationId()); - scorerExistRedisRepository.saveAllScorerNone(quizzes); quizAnswerRedisRepository.saveAllQuizAnswers(quizzes); } @@ -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) { @@ -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); @@ -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); } } diff --git a/src/main/java/org/cotato/csquiz/domain/education/service/ScorerService.java b/src/main/java/org/cotato/csquiz/domain/education/service/ScorerService.java new file mode 100644 index 00000000..15cccda2 --- /dev/null +++ b/src/main/java/org/cotato/csquiz/domain/education/service/ScorerService.java @@ -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 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)); + } +}