diff --git a/backend/src/main/java/org/sejongisc/backend/betting/entity/BetRound.java b/backend/src/main/java/org/sejongisc/backend/betting/entity/BetRound.java index b0c9e510..d1523561 100644 --- a/backend/src/main/java/org/sejongisc/backend/betting/entity/BetRound.java +++ b/backend/src/main/java/org/sejongisc/backend/betting/entity/BetRound.java @@ -105,6 +105,12 @@ public boolean isClosed() { return !this.status; } + // 라운드가 무승부로 종료되었는지 여부 반환 + // TODO: flyway 도입 시 BetOption에 DRAW 상태 추가 고려 + public boolean isDraw() { + return this.settleAt != null && this.resultOption == null; + } + // 베팅 시작 public void open() { this.status = true; @@ -152,7 +158,8 @@ public void settle(BigDecimal finalPrice) { // 결과 판정 로직 - 이전 종가와 비교하여 상승/하락 결정 private BetOption determineResult(BigDecimal finalPrice) { int compare = finalPrice.compareTo(previousClosePrice); - if (compare >= 0) return BetOption.RISE; - return BetOption.FALL; + if (compare > 0) return BetOption.RISE; + else if (compare < 0) return BetOption.FALL; + return null; } } diff --git a/backend/src/main/java/org/sejongisc/backend/betting/entity/UserBet.java b/backend/src/main/java/org/sejongisc/backend/betting/entity/UserBet.java index 6f1810c6..80553d6b 100644 --- a/backend/src/main/java/org/sejongisc/backend/betting/entity/UserBet.java +++ b/backend/src/main/java/org/sejongisc/backend/betting/entity/UserBet.java @@ -70,6 +70,12 @@ public void lose() { this.betStatus = BetStatus.CLOSED; } + public void draw() { + this.payoutPoints = this.stakePoints; + this.isCorrect = false; + this.betStatus = BetStatus.CLOSED; + } + // 취소 상태 변경 메서드 public void cancel() { this.betStatus = BetStatus.DELETED; diff --git a/backend/src/main/java/org/sejongisc/backend/betting/repository/UserBetRepository.java b/backend/src/main/java/org/sejongisc/backend/betting/repository/UserBetRepository.java index 30330a55..045f56e7 100644 --- a/backend/src/main/java/org/sejongisc/backend/betting/repository/UserBetRepository.java +++ b/backend/src/main/java/org/sejongisc/backend/betting/repository/UserBetRepository.java @@ -3,6 +3,8 @@ import org.sejongisc.backend.betting.entity.BetRound; import org.sejongisc.backend.betting.entity.UserBet; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -11,11 +13,14 @@ public interface UserBetRepository extends JpaRepository { Optional findByRoundAndUserId(BetRound round, UUID userId); - Optional findByUserBetIdAndUserId(UUID userBetId, UUID userId); + @Query( + "SELECT ub FROM UserBet ub " + + "JOIN FETCH ub.round " + + "WHERE ub.userBetId = :userBetId " + + "AND ub.userId = :userId") + Optional findByUserBetIdAndUserIdWithRound(@Param("userBetId") UUID userBetId, @Param("userId") UUID userId); List findAllByUserIdOrderByRound_SettleAtDesc(UUID userId); - List findAllByRound(BetRound round); - - + List findAllByRoundIn(List rounds); } diff --git a/backend/src/main/java/org/sejongisc/backend/betting/service/BettingScheduler.java b/backend/src/main/java/org/sejongisc/backend/betting/service/BettingScheduler.java index 2adb673a..7e102191 100644 --- a/backend/src/main/java/org/sejongisc/backend/betting/service/BettingScheduler.java +++ b/backend/src/main/java/org/sejongisc/backend/betting/service/BettingScheduler.java @@ -6,6 +6,10 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.ZoneId; + @Component @Slf4j @RequiredArgsConstructor @@ -14,33 +18,23 @@ public class BettingScheduler { private final BettingService bettingService; @Scheduled(cron = "0 0 9 * * MON-FRI", zone = "Asia/Seoul") - public void dailyOpenScheduler() { + public void openScheduler() { + // 일일 라운드 생성 bettingService.createBetRound(Scope.DAILY); -// log.info("✅ 스케줄러 정상 작동 중: {}", LocalDateTime.now()); - } - @Scheduled(cron = "0 0 9 * * MON", zone = "Asia/Seoul") - public void weeklyOpenScheduler() { - bettingService.createBetRound(Scope.WEEKLY); + // 월요일: 주간 라운드 생성 + if (LocalDate.now(ZoneId.of("Asia/Seoul")).getDayOfWeek() == DayOfWeek.MONDAY) { + bettingService.createBetRound(Scope.WEEKLY); + } } @Scheduled(cron = "0 0 22 * * MON-FRI", zone = "Asia/Seoul") - public void dailyCloseScheduler() { - bettingService.closeBetRound(); - } - - @Scheduled(cron = "0 0 22 * * FRI", zone = "Asia/Seoul") - public void weeklyCloseScheduler() { + public void closeScheduler() { bettingService.closeBetRound(); } @Scheduled(cron = "0 5 22 * * MON-FRI", zone = "Asia/Seoul") - public void dailySettleScheduler() { - bettingService.settleUserBets(); - } - - @Scheduled(cron = "0 5 22 * * FRI", zone = "Asia/Seoul") - public void weeklySettleScheduler() { + public void settleScheduler() { bettingService.settleUserBets(); } diff --git a/backend/src/main/java/org/sejongisc/backend/betting/service/BettingService.java b/backend/src/main/java/org/sejongisc/backend/betting/service/BettingService.java index c8a8405c..81a76b6b 100644 --- a/backend/src/main/java/org/sejongisc/backend/betting/service/BettingService.java +++ b/backend/src/main/java/org/sejongisc/backend/betting/service/BettingService.java @@ -9,6 +9,7 @@ import org.sejongisc.backend.betting.repository.UserBetRepository; import org.sejongisc.backend.common.exception.CustomException; import org.sejongisc.backend.common.exception.ErrorCode; +import org.sejongisc.backend.common.annotation.OptimisticRetry; import org.sejongisc.backend.point.entity.PointOrigin; import org.sejongisc.backend.point.entity.PointReason; import org.sejongisc.backend.point.service.PointHistoryService; @@ -18,12 +19,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.sejongisc.backend.betting.dto.UserBetResponse; -import org.springframework.orm.ObjectOptimisticLockingFailureException; // import 확인 import java.math.BigDecimal; -import java.time.LocalDate; import java.time.LocalDateTime; import java.util.*; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -36,16 +36,11 @@ public class BettingService { private final Random random = new Random(); - /** - * 현재 활성화된 베팅 라운드 조회 - */ - public Optional getActiveRound(Scope type) { - return betRoundRepository.findTopByStatusTrueAndScopeOrderByOpenAtDesc(type); - } /** * 전체 베팅 라운드 목록 조회 */ + @Transactional(readOnly = true) public List getAllBetRounds() { return betRoundRepository.findAllByOrderBySettleAtDesc(); } @@ -53,6 +48,7 @@ public List getAllBetRounds() { /** * PriceData 기반 무작위 종목 선택 (기존 Stock 대체) */ + @Transactional(readOnly = true) public PriceResponse getPriceData() { List allData = priceDataRepository.findAll(); if (allData.isEmpty()) { @@ -78,7 +74,6 @@ public PriceResponse getPriceData() { .build(); } - /** * 무료 베팅 가능 여부 (20% 확률) */ @@ -87,9 +82,9 @@ public boolean setAllowFree() { } /** - * 사용자의 전체 베팅 내역 조회 (수정됨) + * 사용자의 전체 베팅 내역 조회 */ - @Transactional(readOnly = true) // 트랜잭션 유지 필수 + @Transactional(readOnly = true) public List getAllMyBets(UUID userId) { List userBets = userBetRepository.findAllByUserIdOrderByRound_SettleAtDesc(userId); @@ -102,6 +97,7 @@ public List getAllMyBets(UUID userId) { /** * 새로운 베팅 라운드 생성 */ + @Transactional public void createBetRound(Scope scope) { LocalDateTime now = LocalDateTime.now(); @@ -125,13 +121,13 @@ public void createBetRound(Scope scope) { /** * 종료 조건을 만족한 라운드 종료 */ + @Transactional public void closeBetRound() { LocalDateTime now = LocalDateTime.now(); List toClose = betRoundRepository.findByStatusTrueAndLockAtLessThanEqual(now); if (toClose.isEmpty()) return; toClose.forEach(BetRound::close); - betRoundRepository.saveAll(toClose); } /** @@ -141,11 +137,20 @@ public void closeBetRound() { * - 취소 이력이 있는 베팅도 베팅 가능한 상태에 한하여 재배팅 가능 */ @Transactional + @OptimisticRetry public UserBetResponse postUserBet(UUID userId, UserBetRequest userBetRequest) { + // 베팅 포인트 검증 + if (!userBetRequest.isFree() && !userBetRequest.isStakePointsValid()) { + throw new CustomException(ErrorCode.BET_POINT_TOO_LOW); + } + // 라운드 조회 BetRound betRound = betRoundRepository.findById(userBetRequest.getRoundId()) .orElseThrow(() -> new CustomException(ErrorCode.BET_ROUND_NOT_FOUND)); + // 베팅 가능한 라운드 상태인지 검증 + betRound.validate(); + UserBet existingBet = userBetRepository.findByRoundAndUserId(betRound, userId) .orElse(null); @@ -154,9 +159,6 @@ public UserBetResponse postUserBet(UUID userId, UserBetRequest userBetRequest) { throw new CustomException(ErrorCode.BET_DUPLICATE); } - // 베팅 가능한 라운드 상태인지 검증 - betRound.validate(); - // 베팅 포인트 결정 int stake = userBetRequest.isFree() ? 0 : userBetRequest.getStakePoints(); @@ -169,10 +171,6 @@ public UserBetResponse postUserBet(UUID userId, UserBetRequest userBetRequest) { // 포인트 차감 및 이력 생성 (유료 베팅인 경우) if (!userBetRequest.isFree()) { - if (!userBetRequest.isStakePointsValid()) { - throw new CustomException(ErrorCode.BET_POINT_TOO_LOW); - } - pointHistoryService.createPointHistory( userId, -stake, // 포인트 차감 @@ -206,78 +204,78 @@ public UserBetResponse postUserBet(UUID userId, UserBetRequest userBetRequest) { } } - // [추가] getActiveRound 반환 타입 변경 대응 메서드 (Controller에서 사용) + /** + * 현재 활성화된 베팅 라운드 조회 + */ + @Transactional(readOnly = true) public Optional getActiveRoundResponse(Scope type) { return betRoundRepository.findTopByStatusTrueAndScopeOrderByOpenAtDesc(type) .map(BetRoundResponse::from); } /** - * 사용자 베팅 취소 (수정됨) + * 사용자 베팅 취소 */ @Transactional + @OptimisticRetry public void cancelUserBet(UUID userId, UUID userBetId) { - try { - // 1. 엔티티 조회 (UserBet) - UserBet userBet = userBetRepository.findByUserBetIdAndUserId(userBetId, userId) - .orElseThrow(() -> new CustomException(ErrorCode.BET_NOT_FOUND)); + // fetch join으로 UserBet 및 BetRound 조회 + UserBet userBet = userBetRepository.findByUserBetIdAndUserIdWithRound(userBetId, userId) + .orElseThrow(() -> new CustomException(ErrorCode.BET_NOT_FOUND)); + BetRound betRound = userBet.getRound(); - // 2. 이미 처리된 상태인지 검증 (중복 방지 1차) - if (userBet.getBetStatus() != BetStatus.ACTIVE) { - throw new CustomException(ErrorCode.BET_ALREADY_PROCESSED); - } + // 이미 처리된 상태인지 검증 + if (userBet.getBetStatus() != BetStatus.ACTIVE) { + throw new CustomException(ErrorCode.BET_ALREADY_PROCESSED); + } - // 3. BetRound 조회 및 검증 - // (Lazy Loading 문제 방지를 위해 ID로 다시 조회하는 기존 로직 유지 권장) - UUID roundId = userBet.getRound().getBetRoundID(); - BetRound betRound = betRoundRepository.findById(roundId) - .orElseThrow(() -> new CustomException(ErrorCode.BET_ROUND_NOT_FOUND)); - - betRound.validate(); // 마감 시간 등 체크 - - // 4. 상태 변경 (ACTIVE -> CANCELED) - // 여기서 @Version 필드 덕분에 커밋 시점에 버전 충돌 여부를 체크함 - userBet.cancel(); - userBetRepository.saveAndFlush(userBet); // 명시적 flush로 버전 충돌 즉시 감지 - - // 5. 포인트 환불 - if (!userBet.isFree() && userBet.getStakePoints() > 0) { - pointHistoryService.createPointHistory( - userId, - userBet.getStakePoints(), - PointReason.BETTING, - PointOrigin.BETTING, - betRound.getBetRoundID() // targetId 통일 (리뷰 반영) - ); - } + // 베팅 가능한 라운드 상태인지 검증 + betRound.validate(); - // 6. 통계 차감 - int stake = userBet.getStakePoints(); - if (userBet.getOption() == BetOption.RISE) { - betRoundRepository.decrementUpStats(betRound.getBetRoundID(), stake); - } else { - betRoundRepository.decrementDownStats(betRound.getBetRoundID(), stake); - } + // 상태 변경 (ACTIVE -> DELETED) + userBet.cancel(); + userBetRepository.saveAndFlush(userBet); - // userBetRepository.save(userBet); // Transactional이라 자동 저장되지만 명시해도 됨 + // 포인트 환불 + if (!userBet.isFree() && userBet.getStakePoints() > 0) { + pointHistoryService.createPointHistory( + userId, + userBet.getStakePoints(), + PointReason.BETTING, + PointOrigin.BETTING, + betRound.getBetRoundID() + ); + } - } catch (ObjectOptimisticLockingFailureException e) { - // 동시에 취소 요청이 들어온 경우 하나만 성공하고 나머지는 여기서 걸러짐 - throw new CustomException(ErrorCode.BET_ALREADY_PROCESSED); + // 통계 차감 + int stake = userBet.getStakePoints(); + if (userBet.getOption() == BetOption.RISE) { + betRoundRepository.decrementUpStats(betRound.getBetRoundID(), stake); + } else { + betRoundRepository.decrementDownStats(betRound.getBetRoundID(), stake); } - } // 삭제(delete)는 하지 않음 (이력 관리를 위해) + } /** * 베팅 결과 정산 */ @Transactional + @OptimisticRetry public void settleUserBets() { LocalDateTime now = LocalDateTime.now(); + // 정산 대상 활성 라운드 조회 List activeRounds = betRoundRepository.findByStatusFalseAndSettleAtIsNullAndLockAtLessThanEqual(now); + // 활성 라운드의 전체 베팅 한 번에 조회 + List allUserBets = userBetRepository.findAllByRoundIn(activeRounds); + + // 라운드별 그룹화 + Map> betMap = allUserBets.stream() + .collect(Collectors.groupingBy(bet -> bet.getRound().getBetRoundID())); + for (BetRound round : activeRounds) { // PriceData를 이용해 시세 조회 Optional priceOpt = priceDataRepository.findTopByTickerOrderByDateDesc(round.getSymbol()); @@ -288,36 +286,75 @@ public void settleUserBets() { if (finalPrice == null) continue; + // 라운드 정산 round.settle(finalPrice); - betRoundRepository.save(round); - List userBets = userBetRepository.findAllByRound(round); + // 현재 라운드의 베팅 리스트 + List userBets = betMap.getOrDefault(round.getBetRoundID(), Collections.emptyList()); + BetOption resultOption = round.getResultOption(); for (UserBet bet : userBets) { if (bet.getBetStatus() != BetStatus.ACTIVE) continue; - if (bet.getOption() == round.getResultOption()) { - int reward = calculateReward(bet); - bet.win(reward); - pointHistoryService.createPointHistory( + if (round.isDraw()) { + // 가격 변동이 없을 시 참여자 전원 원금 환불 + if (!bet.isFree() && bet.getStakePoints() > 0) { + pointHistoryService.createPointHistory( bet.getUserId(), - reward, - PointReason.BETTING_WIN, + bet.getStakePoints(), + PointReason.BETTING, PointOrigin.BETTING, round.getBetRoundID() + ); + } + bet.draw(); + } else if (bet.getOption() == resultOption) { + // 예측 성공 시 보상 포인트 지급 + int reward = calculateReward(bet); + bet.win(reward); + pointHistoryService.createPointHistory( + bet.getUserId(), + reward, + PointReason.BETTING_WIN, + PointOrigin.BETTING, + round.getBetRoundID() ); } else { + // 예측 실패 시 포인트 소멸 bet.lose(); } } - userBetRepository.saveAll(userBets); } } /** - * TODO: 향후 배당 비율에 따른 보상 계산 로직 + * 배당률에 따른 보상 계산 + * - 무료: 맞추면 10P + * - 유료: 배당률 적용 */ private int calculateReward(UserBet bet) { - return 10; + BetRound round = bet.getRound(); + + // 무료 베팅: 10 포인트 + if (bet.isFree()) { + return 10; + } + + long upPoints = round.getUpTotalPoints(); + long downPoints = round.getDownTotalPoints(); + long total = upPoints + downPoints; + + long winning = (round.getResultOption() == BetOption.RISE) ? upPoints : downPoints; + + // 호출 시점에 정답자 존재가 보장되지만 ArithmeticException 방지용 + if (winning == 0) { + return 0; + } + + // 배당률 계산: 내 베팅액 * (전체 포인트 / 정답 측 포인트 합) + double multiplier = (double) total / winning; + + // 소수점 floor -> TODO: 복식부기 도입 시 남는 포인트는 시스템으로 이동시키기 + return (int) Math.floor(bet.getStakePoints() * multiplier); } } diff --git a/backend/src/main/java/org/sejongisc/backend/common/annotation/OptimisticRetry.java b/backend/src/main/java/org/sejongisc/backend/common/annotation/OptimisticRetry.java new file mode 100644 index 00000000..492ac0b0 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/common/annotation/OptimisticRetry.java @@ -0,0 +1,10 @@ +package org.sejongisc.backend.common.annotation; + +import java.lang.annotation.*; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface OptimisticRetry { + int maxAttempts() default 3; // 최대 재시도 횟수 + long backoff() default 100L; // 재시도 사이 지연 시간 (ms) +} diff --git a/backend/src/main/java/org/sejongisc/backend/common/aspect/OptimisticRetryAspect.java b/backend/src/main/java/org/sejongisc/backend/common/aspect/OptimisticRetryAspect.java new file mode 100644 index 00000000..5c67fccb --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/common/aspect/OptimisticRetryAspect.java @@ -0,0 +1,85 @@ +package org.sejongisc.backend.common.aspect; + +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.sejongisc.backend.common.exception.CustomException; +import org.sejongisc.backend.common.exception.ErrorCode; +import org.sejongisc.backend.common.annotation.OptimisticRetry; +import org.springframework.core.annotation.Order; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.retry.backoff.FixedBackOffPolicy; +import org.springframework.retry.policy.SimpleRetryPolicy; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Aspect +@Component +@Slf4j +@Order(-1) // @Transactional보다 먼저 실행되어야 함 +public class OptimisticRetryAspect { + + @Around("@annotation(optimisticRetry)") + public Object doRetry(ProceedingJoinPoint joinPoint, OptimisticRetry optimisticRetry) throws Throwable { + // Retry 설정 생성 + RetryTemplate retryTemplate = createRetryTemplate(optimisticRetry); + + return retryTemplate.execute(context -> { + if (context.getRetryCount() > 0) { + log.warn("데이터 동시 수정 충돌 발생 ({}회차 재시도): {}", context.getRetryCount(), joinPoint.getSignature().getName()); + } + + try { + return joinPoint.proceed(); + } catch (OptimisticLockingFailureException | CustomException e) { + // 낙관적 락 예외: RetryTemplate이 잡아 재시도 + // 비즈니스 예외: 즉시 종료 + throw e; + } catch (Throwable e) { + // 그 외 미처리 예외: 로깅 후 종료 + log.error("재시도 로직 실행 중 미처리 예외 발생: ", e); + throw e; + } + }, context -> { + Throwable lastThrowable = context.getLastThrowable(); + + // 비즈니스 예외라면 그대로 throw + if (lastThrowable instanceof CustomException) { + throw (CustomException) lastThrowable; + } + + // RuntimeException인 경우에도 throw + if (lastThrowable instanceof RuntimeException) { + throw (RuntimeException) lastThrowable; + } + + // 재시도 횟수 소진 후 최종 실패 시 + log.error("재시도 횟수 초과로 업데이트 최종 실패: {}", joinPoint.getSignature().getName()); + throw new CustomException(ErrorCode.CONCURRENT_UPDATE); + }); + } + + /** + * @OptimisticRetry 어노테이션 설정 값을 기반으로 RetryTemplate 생성 + */ + private RetryTemplate createRetryTemplate(OptimisticRetry optimisticRetry) { + RetryTemplate template = new RetryTemplate(); + + // 낙관적 락 예외에 대해서만 재시도 + SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy( + optimisticRetry.maxAttempts(), + Map.of(OptimisticLockingFailureException.class, true) + ); + + // 재시도 사이의 대기 시간 설정 + FixedBackOffPolicy backOffPolicy = new FixedBackOffPolicy(); + backOffPolicy.setBackOffPeriod(optimisticRetry.backoff()); + + template.setRetryPolicy(retryPolicy); + template.setBackOffPolicy(backOffPolicy); + return template; + } +} diff --git a/backend/src/main/java/org/sejongisc/backend/point/service/PointHistoryService.java b/backend/src/main/java/org/sejongisc/backend/point/service/PointHistoryService.java index d48dadc9..93214530 100644 --- a/backend/src/main/java/org/sejongisc/backend/point/service/PointHistoryService.java +++ b/backend/src/main/java/org/sejongisc/backend/point/service/PointHistoryService.java @@ -11,13 +11,8 @@ import org.sejongisc.backend.point.repository.PointHistoryRepository; import org.sejongisc.backend.user.dao.UserRepository; import org.sejongisc.backend.user.entity.User; -import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.domain.PageRequest; -import org.springframework.retry.annotation.Backoff; -import org.springframework.retry.annotation.Recover; -import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.util.UUID; @@ -27,6 +22,8 @@ * userId에 대한 검증 로직이 없습니다. * 따라서 customUserDetails.getUserId() 에서 가져오는 userId를 사용해야합니다. * 해당 userId는 필터에서 검증이 완료되기에, 검증할 필요가 없기 때문입니다. + * createPointHistory를 호출하는 외부 메서드는 @OptimisticRetry 어노테이션을 붙여야 합니다. + * 해당 어노테이션을 붙이면 낙관적 락 예외 발생 시 최초 호출 포함 최대 3회까지 재시도합니다. */ @Service @RequiredArgsConstructor @@ -55,15 +52,7 @@ public PointHistoryResponse getPointHistoryListByUserId(UUID userId, PageRequest } // 포인트 증감 기록 생성 및 유저 포인트 업데이트 - @Transactional(propagation = Propagation.REQUIRES_NEW) - @Retryable( - // 어떤 예외가 발생했을 때 재시도할지 지정합니다. - include = {OptimisticLockingFailureException.class}, - // 최대 재시도 횟수를 지정합니다 - 최초 1회 + 재시도 2회 = 총 3회 - maxAttempts = 3, - // 재시도 사이의 지연 시간을 설정합니다 - 100ms - backoff = @Backoff(delay = 100) - ) + @Transactional public PointHistory createPointHistory(UUID userId, int amount, PointReason reason, PointOrigin origin, UUID originId) { if (amount == 0) { throw new CustomException(ErrorCode.INVALID_POINT_AMOUNT); @@ -83,27 +72,6 @@ public PointHistory createPointHistory(UUID userId, int amount, PointReason reas return pointHistoryRepository.save(history); } - /** - * OptimisticLockingFailureException가 아닌 CustomException이 발생했을 때 재시도 없이 예외를 던집니다. - * @param e @Retryable에서 발생한 예외 - */ - @Recover - public PointHistory recover(CustomException e) { - log.warn("포인트 업데이트 중 비즈니스 로직 예외 발생: {}", e.getMessage()); - throw e; - } - - /** - * @Retryable에서 모든 재시도를 실패했을 때 호출될 메서드입니다. - * @param e @Retryable에서 발생한 마지막 예외 - * @param userId, ... 원본 메서드와 동일한 파라미터 - */ - @Recover - public PointHistory recover(OptimisticLockingFailureException e, UUID userId, int amount, PointReason reason, PointOrigin origin, UUID originId) { - log.error("포인트 업데이트 최종 실패: userId={}, amount={}", userId, amount, e); - throw new CustomException(ErrorCode.CONCURRENT_UPDATE); - } - // 유저 탈퇴 시 특정 유저의 모든 포인트 기록 삭제 @Transactional public void deleteAllPointHistoryByUserId(UUID userId) {