From 3d8d038c7f090ef754c2be7de2655304e323aaf9 Mon Sep 17 00:00:00 2001 From: Yooonjeong Date: Tue, 20 Jan 2026 00:28:33 +0900 Subject: [PATCH 01/24] =?UTF-8?q?[BE]=20[REFACTOR]=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../betting/service/BettingScheduler.java | 29 +++++++------------ 1 file changed, 11 insertions(+), 18 deletions(-) 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..0921e031 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,9 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import java.time.DayOfWeek; +import java.time.LocalDate; + @Component @Slf4j @RequiredArgsConstructor @@ -13,34 +16,24 @@ public class BettingScheduler { private final BettingService bettingService; - @Scheduled(cron = "0 0 9 * * MON-FRI", zone = "Asia/Seoul") + @Scheduled(cron = "0 5 0 * * MON-FRI", zone = "Asia/Seoul") public void dailyOpenScheduler() { + // 일일 라운드 생성 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().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(); } From adccd84a52fdd383b4e4838f871b1541d12b2685 Mon Sep 17 00:00:00 2001 From: Yooonjeong Date: Tue, 20 Jan 2026 00:29:13 +0900 Subject: [PATCH 02/24] =?UTF-8?q?[BE]=20[FEAT]=20UserBet=EC=97=90=20?= =?UTF-8?q?=EB=B2=A0=ED=8C=85=20=EB=AC=B4=EC=8A=B9=EB=B6=80=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/sejongisc/backend/betting/entity/UserBet.java | 6 ++++++ 1 file changed, 6 insertions(+) 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; From 50e1f6a7075337cd5d24996fbbce84eee3de3c5f Mon Sep 17 00:00:00 2001 From: Yooonjeong Date: Tue, 20 Jan 2026 00:29:47 +0900 Subject: [PATCH 03/24] =?UTF-8?q?[BE]=20[FEAT]=20BetRound=EC=97=90=20?= =?UTF-8?q?=EB=AC=B4=EC=8A=B9=EB=B6=80=20=EC=83=81=ED=83=9C=20=ED=8C=90?= =?UTF-8?q?=EB=8B=A8=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sejongisc/backend/betting/entity/BetRound.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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; } } From 78ffca09443ad4cba000c55f7159795be718e546 Mon Sep 17 00:00:00 2001 From: Yooonjeong Date: Tue, 20 Jan 2026 00:30:21 +0900 Subject: [PATCH 04/24] =?UTF-8?q?[BE]=20[FEAT]=20=EB=82=99=EA=B4=80?= =?UTF-8?q?=EC=A0=81=20=EB=9D=BD=20=EC=98=88=EC=99=B8=20=EB=B0=9C=EC=83=9D?= =?UTF-8?q?=20=EC=8B=9C=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=EC=96=B4=EB=85=B8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/common/annotation/OptimisticRetry.java | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 backend/src/main/java/org/sejongisc/backend/common/annotation/OptimisticRetry.java 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) +} From f98743976367157ed76153cc9a9bdd9719e33c68 Mon Sep 17 00:00:00 2001 From: Yooonjeong Date: Tue, 20 Jan 2026 00:30:32 +0900 Subject: [PATCH 05/24] =?UTF-8?q?[BE]=20[FEAT]=20=EB=82=99=EA=B4=80?= =?UTF-8?q?=EC=A0=81=20=EB=9D=BD=20=EC=98=88=EC=99=B8=20=EB=B0=9C=EC=83=9D?= =?UTF-8?q?=20=EC=8B=9C=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=9C=20Aspect=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/aspect/OptimisticRetryAspect.java | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 backend/src/main/java/org/sejongisc/backend/common/aspect/OptimisticRetryAspect.java 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; + } +} From f7563709adc222eb8b5b9cff0619e8ef57ec311f Mon Sep 17 00:00:00 2001 From: Yooonjeong Date: Tue, 20 Jan 2026 00:31:23 +0900 Subject: [PATCH 06/24] =?UTF-8?q?[BE]=20[REFACTOR]=20=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EC=8B=9C=20?= =?UTF-8?q?@Retryable=20=EB=B0=8F=20@Recover=20->=20=EC=96=B4=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=EC=9C=BC=EB=A1=9C=20=EB=8C=80?= =?UTF-8?q?=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../point/service/PointHistoryService.java | 38 ++----------------- 1 file changed, 3 insertions(+), 35 deletions(-) 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) { From 7c565e68dcb156fbcbe9b5887a183e0d3adee229 Mon Sep 17 00:00:00 2001 From: Yooonjeong Date: Tue, 20 Jan 2026 00:31:50 +0900 Subject: [PATCH 07/24] =?UTF-8?q?[BE]=20[FEAT]=20=EB=B0=B0=EB=8B=B9?= =?UTF-8?q?=EB=A5=A0=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EB=B2=A0=ED=8C=85=20?= =?UTF-8?q?=EB=B3=B4=EC=83=81=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../betting/service/BettingService.java | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) 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..45053e58 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 @@ -315,9 +315,33 @@ public void settleUserBets() { } /** - * 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); } } From cb76ed297ccccfc27d35a9340d4496e46b84da76 Mon Sep 17 00:00:00 2001 From: Yooonjeong Date: Tue, 20 Jan 2026 00:32:24 +0900 Subject: [PATCH 08/24] =?UTF-8?q?[BE]=20[FEAT]=20=EB=B2=A0=ED=8C=85=20?= =?UTF-8?q?=EC=A0=95=EC=82=B0=20=EC=8B=9C=20=EA=B0=80=EA=B2=A9=20=EB=B3=80?= =?UTF-8?q?=EB=8F=99=EC=9D=B4=20=EC=97=86=EC=9C=BC=EB=A9=B4=20=EC=A0=84?= =?UTF-8?q?=EC=9B=90=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=ED=99=98=EB=B6=88=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../betting/service/BettingService.java | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) 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 45053e58..35bc8dfe 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 @@ -292,21 +292,34 @@ public void settleUserBets() { betRoundRepository.save(round); List userBets = userBetRepository.findAllByRound(round); + BetOption resultOption = round.getResultOption(); for (UserBet bet : userBets) { if (bet.getBetStatus() != BetStatus.ACTIVE) continue; - if (bet.getOption() == round.getResultOption()) { + if (round.isDraw()) { + // 가격 변동이 없을 시 참여자 전원 원금 환불 + pointHistoryService.createPointHistory( + bet.getUserId(), + 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() + bet.getUserId(), + reward, + PointReason.BETTING_WIN, + PointOrigin.BETTING, + round.getBetRoundID() ); } else { + // 예측 실패 시 포인트 소멸 bet.lose(); } } From 077682c8ff50fa06dbb10692868f86a0c0643077 Mon Sep 17 00:00:00 2001 From: Yooonjeong Date: Tue, 20 Jan 2026 01:08:41 +0900 Subject: [PATCH 09/24] =?UTF-8?q?[BE]=20[REFACTOR]=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/betting/service/BettingService.java | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) 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 35bc8dfe..940c2efb 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 @@ -18,10 +18,8 @@ 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.*; @@ -36,12 +34,6 @@ public class BettingService { private final Random random = new Random(); - /** - * 현재 활성화된 베팅 라운드 조회 - */ - public Optional getActiveRound(Scope type) { - return betRoundRepository.findTopByStatusTrueAndScopeOrderByOpenAtDesc(type); - } /** * 전체 베팅 라운드 목록 조회 @@ -78,7 +70,6 @@ public PriceResponse getPriceData() { .build(); } - /** * 무료 베팅 가능 여부 (20% 확률) */ @@ -87,7 +78,7 @@ public boolean setAllowFree() { } /** - * 사용자의 전체 베팅 내역 조회 (수정됨) + * 사용자의 전체 베팅 내역 조회 */ @Transactional(readOnly = true) // 트랜잭션 유지 필수 public List getAllMyBets(UUID userId) { @@ -206,7 +197,9 @@ public UserBetResponse postUserBet(UUID userId, UserBetRequest userBetRequest) { } } - // [추가] getActiveRound 반환 타입 변경 대응 메서드 (Controller에서 사용) + /** + * 현재 활성화된 베팅 라운드 조회 + */ public Optional getActiveRoundResponse(Scope type) { return betRoundRepository.findTopByStatusTrueAndScopeOrderByOpenAtDesc(type) .map(BetRoundResponse::from); From e95ed9da1a9fd3abf25f1012619378c8cbfcd999 Mon Sep 17 00:00:00 2001 From: Yooonjeong Date: Tue, 20 Jan 2026 01:09:31 +0900 Subject: [PATCH 10/24] =?UTF-8?q?[BE]=20[FEAT]=20=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=ED=8F=AC?= =?UTF-8?q?=ED=95=A8=EB=90=9C=20=EB=A9=94=EC=84=9C=EB=93=9C=EC=97=90=20@Op?= =?UTF-8?q?timisticRetry=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/sejongisc/backend/betting/service/BettingService.java | 4 ++++ 1 file changed, 4 insertions(+) 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 940c2efb..ef3c82c7 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; @@ -132,6 +133,7 @@ public void closeBetRound() { * - 취소 이력이 있는 베팅도 베팅 가능한 상태에 한하여 재배팅 가능 */ @Transactional + @OptimisticRetry public UserBetResponse postUserBet(UUID userId, UserBetRequest userBetRequest) { // 라운드 조회 BetRound betRound = betRoundRepository.findById(userBetRequest.getRoundId()) @@ -209,6 +211,7 @@ public Optional getActiveRoundResponse(Scope type) { * 사용자 베팅 취소 (수정됨) */ @Transactional + @OptimisticRetry public void cancelUserBet(UUID userId, UUID userBetId) { try { // 1. 엔티티 조회 (UserBet) @@ -265,6 +268,7 @@ public void cancelUserBet(UUID userId, UUID userBetId) { * 베팅 결과 정산 */ @Transactional + @OptimisticRetry public void settleUserBets() { LocalDateTime now = LocalDateTime.now(); From c0b27ca2f273a3561eb6569b109e1550f12653aa Mon Sep 17 00:00:00 2001 From: Yooonjeong Date: Tue, 20 Jan 2026 01:10:10 +0900 Subject: [PATCH 11/24] =?UTF-8?q?[BE]=20[FEAT]=20=EB=B2=A0=ED=8C=85=20?= =?UTF-8?q?=EC=B7=A8=EC=86=8C=20=EC=8B=9C=20=EC=A7=81=EC=A0=91=20=EB=82=99?= =?UTF-8?q?=EA=B4=80=EC=A0=81=20=EB=9D=BD=20=EC=98=88=EC=99=B8=EB=A5=BC=20?= =?UTF-8?q?=EC=9E=A1=EB=8A=94=20=EB=B0=A9=EC=8B=9D=EC=97=90=EC=84=9C=20@Op?= =?UTF-8?q?timisticRetry=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../betting/service/BettingService.java | 75 ++++++++----------- 1 file changed, 32 insertions(+), 43 deletions(-) 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 ef3c82c7..d0ca940e 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 @@ -208,60 +208,49 @@ public Optional getActiveRoundResponse(Scope type) { } /** - * 사용자 베팅 취소 (수정됨) + * 사용자 베팅 취소 */ @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)); - - // 2. 이미 처리된 상태인지 검증 (중복 방지 1차) - if (userBet.getBetStatus() != BetStatus.ACTIVE) { - throw new CustomException(ErrorCode.BET_ALREADY_PROCESSED); - } + UserBet userBet = userBetRepository.findByUserBetIdAndUserId(userBetId, userId) + .orElseThrow(() -> new CustomException(ErrorCode.BET_NOT_FOUND)); - // 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(); // 마감 시간 등 체크 + // 이미 처리된 상태인지 검증 + if (userBet.getBetStatus() != BetStatus.ACTIVE) { + throw new CustomException(ErrorCode.BET_ALREADY_PROCESSED); + } - // 4. 상태 변경 (ACTIVE -> CANCELED) - // 여기서 @Version 필드 덕분에 커밋 시점에 버전 충돌 여부를 체크함 - userBet.cancel(); - userBetRepository.saveAndFlush(userBet); // 명시적 flush로 버전 충돌 즉시 감지 + // BetRound 조회 및 검증 + BetRound betRound = betRoundRepository.findById(userBet.getRound().getBetRoundID()) + .orElseThrow(() -> new CustomException(ErrorCode.BET_ROUND_NOT_FOUND)); - // 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)는 하지 않음 (이력 관리를 위해) + } /** From 2d435b9a374058807dda18d15254bca296caf378 Mon Sep 17 00:00:00 2001 From: Yooonjeong Date: Tue, 20 Jan 2026 01:17:14 +0900 Subject: [PATCH 12/24] =?UTF-8?q?[BE]=20[FIX]=20=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=A4=84=EB=9F=AC=20=EB=8F=99=EC=9E=91=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/sejongisc/backend/betting/service/BettingScheduler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0921e031..d9805569 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 @@ -16,7 +16,7 @@ public class BettingScheduler { private final BettingService bettingService; - @Scheduled(cron = "0 5 0 * * MON-FRI", zone = "Asia/Seoul") + @Scheduled(cron = "0 0 9 * * MON-FRI", zone = "Asia/Seoul") public void dailyOpenScheduler() { // 일일 라운드 생성 bettingService.createBetRound(Scope.DAILY); From 595706e15b38fbf0f9bc92d55eeb90d251b67899 Mon Sep 17 00:00:00 2001 From: Yooonjeong Date: Tue, 20 Jan 2026 01:31:31 +0900 Subject: [PATCH 13/24] =?UTF-8?q?[BE]=20[REFACTOR]=20=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=A4=84=EB=9F=AC=20=EB=A9=94=EC=84=9C=EB=93=9C=EB=AA=85=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/sejongisc/backend/betting/service/BettingScheduler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d9805569..9467d097 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 @@ -17,7 +17,7 @@ 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); From b55b49b3cbfd89055abb5cf38069faa7143a4777 Mon Sep 17 00:00:00 2001 From: Yooonjeong Date: Wed, 21 Jan 2026 23:48:29 +0900 Subject: [PATCH 14/24] =?UTF-8?q?[BE]=20[REFACTOR]=20=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=A4=84=EB=9F=AC=20=ED=98=84=EC=9E=AC=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=ED=8C=90=EB=8B=A8=20=EC=8B=9C=20=ED=83=80=EC=9E=84=EC=A1=B4(As?= =?UTF-8?q?ia/Seoul)=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sejongisc/backend/betting/service/BettingScheduler.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 9467d097..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 @@ -8,6 +8,7 @@ import java.time.DayOfWeek; import java.time.LocalDate; +import java.time.ZoneId; @Component @Slf4j @@ -22,7 +23,7 @@ public void openScheduler() { bettingService.createBetRound(Scope.DAILY); // 월요일: 주간 라운드 생성 - if (LocalDate.now().getDayOfWeek() == DayOfWeek.MONDAY) { + if (LocalDate.now(ZoneId.of("Asia/Seoul")).getDayOfWeek() == DayOfWeek.MONDAY) { bettingService.createBetRound(Scope.WEEKLY); } } From c6daf86b56285e8794643fe89813b9747f3a2e72 Mon Sep 17 00:00:00 2001 From: Yooonjeong Date: Thu, 22 Jan 2026 02:49:07 +0900 Subject: [PATCH 15/24] =?UTF-8?q?[BE]=20[REFACTOR]=20=EC=97=AC=EB=9F=AC=20?= =?UTF-8?q?=ED=99=9C=EC=84=B1=20=EB=9D=BC=EC=9A=B4=EB=93=9C=EC=97=90=20?= =?UTF-8?q?=EB=8C=80=ED=95=9C=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=B2=A0?= =?UTF-8?q?=ED=8C=85=EC=9D=84=20IN=EC=A0=88=EB=A1=9C=20=ED=95=9C=20?= =?UTF-8?q?=EB=B2=88=EC=97=90=20=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=BF=BC=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/betting/repository/UserBetRepository.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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..8f3d446b 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 @@ -15,7 +15,5 @@ public interface UserBetRepository extends JpaRepository { List findAllByUserIdOrderByRound_SettleAtDesc(UUID userId); - List findAllByRound(BetRound round); - - + List findAllByRoundIn(List rounds); } From 2a038aafa4520d227c478215712d5392667a70a5 Mon Sep 17 00:00:00 2001 From: Yooonjeong Date: Thu, 22 Jan 2026 02:49:39 +0900 Subject: [PATCH 16/24] =?UTF-8?q?[BE]=20[REFACTOR]=20FETCH=20JOIN=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20UserBet=20=EB=B0=8F=20BetRound=20=ED=95=9C=20?= =?UTF-8?q?=EB=B2=88=EC=97=90=20=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=BF=BC=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/betting/repository/UserBetRepository.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 8f3d446b..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,7 +13,12 @@ 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); From 6f56b482016a3246239934162c571f4f720cc139 Mon Sep 17 00:00:00 2001 From: Yooonjeong Date: Thu, 22 Jan 2026 02:50:40 +0900 Subject: [PATCH 17/24] =?UTF-8?q?[BE]=20[REFACTOR]=20=EB=B2=A0=ED=8C=85=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=A1=B0=ED=9A=8C=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=EB=93=A4=EC=97=90=20readOnly=20@Transactiona?= =?UTF-8?q?l=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sejongisc/backend/betting/service/BettingService.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 d0ca940e..f4fb8184 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 @@ -39,6 +39,7 @@ public class BettingService { /** * 전체 베팅 라운드 목록 조회 */ + @Transactional(readOnly = true) public List getAllBetRounds() { return betRoundRepository.findAllByOrderBySettleAtDesc(); } @@ -46,6 +47,7 @@ public List getAllBetRounds() { /** * PriceData 기반 무작위 종목 선택 (기존 Stock 대체) */ + @Transactional(readOnly = true) public PriceResponse getPriceData() { List allData = priceDataRepository.findAll(); if (allData.isEmpty()) { @@ -81,7 +83,7 @@ public boolean setAllowFree() { /** * 사용자의 전체 베팅 내역 조회 */ - @Transactional(readOnly = true) // 트랜잭션 유지 필수 + @Transactional(readOnly = true) public List getAllMyBets(UUID userId) { List userBets = userBetRepository.findAllByUserIdOrderByRound_SettleAtDesc(userId); @@ -202,6 +204,7 @@ public UserBetResponse postUserBet(UUID userId, UserBetRequest userBetRequest) { /** * 현재 활성화된 베팅 라운드 조회 */ + @Transactional(readOnly = true) public Optional getActiveRoundResponse(Scope type) { return betRoundRepository.findTopByStatusTrueAndScopeOrderByOpenAtDesc(type) .map(BetRoundResponse::from); From afbd86013df70401b5fc5043d4173af4423b7079 Mon Sep 17 00:00:00 2001 From: Yooonjeong Date: Thu, 22 Jan 2026 02:52:08 +0900 Subject: [PATCH 18/24] =?UTF-8?q?[BE]=20[REFACTOR]=20@Transactional=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20=EB=8D=94=ED=8B=B0=20?= =?UTF-8?q?=EC=B2=B4=ED=82=B9=EC=9C=BC=EB=A1=9C=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/sejongisc/backend/betting/service/BettingService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 f4fb8184..ff88db71 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 @@ -96,6 +96,7 @@ public List getAllMyBets(UUID userId) { /** * 새로운 베팅 라운드 생성 */ + @Transactional public void createBetRound(Scope scope) { LocalDateTime now = LocalDateTime.now(); @@ -119,13 +120,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); } /** From 77ed0f55300b17befa905de3be3b11c449636668 Mon Sep 17 00:00:00 2001 From: Yooonjeong Date: Thu, 22 Jan 2026 02:52:43 +0900 Subject: [PATCH 19/24] =?UTF-8?q?[BE]=20[REFACTOR]=20DB=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=20=EC=A0=84=20=EB=8B=A8=EC=88=9C=20=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EA=B2=80=EC=A6=9D=20=EB=A8=BC=EC=A0=80=20=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/betting/service/BettingService.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 ff88db71..76a31e6e 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 @@ -138,6 +138,11 @@ 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)); @@ -165,10 +170,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, // 포인트 차감 From 597e9cb8b860cf6118701d6d6fe0eba78290b13d Mon Sep 17 00:00:00 2001 From: Yooonjeong Date: Thu, 22 Jan 2026 02:53:19 +0900 Subject: [PATCH 20/24] =?UTF-8?q?[BE]=20[REFACTOR]=20userBet=20DB=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=A0=84=20=EB=B2=A0=ED=8C=85=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B4=EB=93=9C=20=EA=B2=80=EC=A6=9D=20=EB=A8=BC=EC=A0=80=20?= =?UTF-8?q?=EC=88=98=ED=96=89=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sejongisc/backend/betting/service/BettingService.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 76a31e6e..3f97812f 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 @@ -147,6 +147,9 @@ public UserBetResponse postUserBet(UUID userId, UserBetRequest userBetRequest) { BetRound betRound = betRoundRepository.findById(userBetRequest.getRoundId()) .orElseThrow(() -> new CustomException(ErrorCode.BET_ROUND_NOT_FOUND)); + // 베팅 가능한 라운드 상태인지 검증 + betRound.validate(); + UserBet existingBet = userBetRepository.findByRoundAndUserId(betRound, userId) .orElse(null); @@ -155,9 +158,6 @@ public UserBetResponse postUserBet(UUID userId, UserBetRequest userBetRequest) { throw new CustomException(ErrorCode.BET_DUPLICATE); } - // 베팅 가능한 라운드 상태인지 검증 - betRound.validate(); - // 베팅 포인트 결정 int stake = userBetRequest.isFree() ? 0 : userBetRequest.getStakePoints(); From 4833f57a0ece4af529f936e24d422210231cc52e Mon Sep 17 00:00:00 2001 From: Yooonjeong Date: Thu, 22 Jan 2026 02:53:56 +0900 Subject: [PATCH 21/24] =?UTF-8?q?[BE]=20[REFACTOR]=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?BetRound=20=EC=A1=B0=ED=9A=8C=20=EC=97=86=EC=9D=B4=20FETCH=20JO?= =?UTF-8?q?IN=EC=9C=BC=EB=A1=9C=20=ED=95=9C=20=EB=B2=88=EC=97=90=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/betting/service/BettingService.java | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) 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 3f97812f..1c6b81c2 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 @@ -218,18 +218,16 @@ public Optional getActiveRoundResponse(Scope type) { @Transactional @OptimisticRetry public void cancelUserBet(UUID userId, UUID userBetId) { - UserBet userBet = userBetRepository.findByUserBetIdAndUserId(userBetId, userId) + // fetch join으로 UserBet 및 BetRound 조회 + UserBet userBet = userBetRepository.findByUserBetIdAndUserIdWithRound(userBetId, userId) .orElseThrow(() -> new CustomException(ErrorCode.BET_NOT_FOUND)); + BetRound betRound = userBet.getRound(); - // 이미 처리된 상태인지 검증 + // 이미 처리된 상태인지 검증 if (userBet.getBetStatus() != BetStatus.ACTIVE) { throw new CustomException(ErrorCode.BET_ALREADY_PROCESSED); } - // BetRound 조회 및 검증 - BetRound betRound = betRoundRepository.findById(userBet.getRound().getBetRoundID()) - .orElseThrow(() -> new CustomException(ErrorCode.BET_ROUND_NOT_FOUND)); - // 베팅 가능한 라운드 상태인지 검증 betRound.validate(); From e45b5d5859e387415a83641692bd285e6c4e4f0a Mon Sep 17 00:00:00 2001 From: Yooonjeong Date: Thu, 22 Jan 2026 02:56:17 +0900 Subject: [PATCH 22/24] =?UTF-8?q?[BE]=20[REFACTOR]=20=ED=99=9C=EC=84=B1=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B4=EB=93=9C=EC=9D=98=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EB=B2=A0=ED=8C=85=20=ED=95=9C=20=EB=B2=88=EC=97=90=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=ED=95=98=EC=97=AC=20=EB=A9=94=EB=AA=A8=EB=A6=AC?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=9D=BC=EC=9A=B4=EB=93=9C=EB=B3=84=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=EB=A7=81=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/betting/service/BettingService.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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 1c6b81c2..aa70a15d 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 @@ -23,6 +23,7 @@ import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.*; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -264,9 +265,17 @@ public void cancelUserBet(UUID userId, UUID userBetId) { 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()); @@ -277,10 +286,12 @@ 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) { From 6d612f44d575c11caf3fd69b2e2f6411aed05c7b Mon Sep 17 00:00:00 2001 From: Yooonjeong Date: Thu, 22 Jan 2026 02:56:40 +0900 Subject: [PATCH 23/24] =?UTF-8?q?[BE]=20[REFACTOR]=20=EB=8D=94=ED=8B=B0?= =?UTF-8?q?=EC=B2=B4=ED=82=B9=20->=20=EB=AA=85=EC=8B=9C=EC=A0=81=20save=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/sejongisc/backend/betting/service/BettingService.java | 2 -- 1 file changed, 2 deletions(-) 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 aa70a15d..d4eb89cd 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 @@ -288,7 +288,6 @@ public void settleUserBets() { // 라운드 정산 round.settle(finalPrice); - betRoundRepository.save(round); // 현재 라운드의 베팅 리스트 List userBets = betMap.getOrDefault(round.getBetRoundID(), Collections.emptyList()); @@ -323,7 +322,6 @@ public void settleUserBets() { bet.lose(); } } - userBetRepository.saveAll(userBets); } } From 5ad339f8c3bd34b12499f958396421d84609a423 Mon Sep 17 00:00:00 2001 From: Yooonjeong Date: Thu, 22 Jan 2026 03:29:39 +0900 Subject: [PATCH 24/24] =?UTF-8?q?[BE]=20[FIX]=20=EB=B2=A0=ED=8C=85=20?= =?UTF-8?q?=EB=AC=B4=EC=8A=B9=EB=B6=80=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=ED=99=98=EB=B6=88=20=EC=8B=9C=20=EC=A1=B0=EA=B1=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/betting/service/BettingService.java | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) 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 d4eb89cd..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 @@ -298,13 +298,15 @@ public void settleUserBets() { if (round.isDraw()) { // 가격 변동이 없을 시 참여자 전원 원금 환불 - pointHistoryService.createPointHistory( - bet.getUserId(), - bet.getStakePoints(), - PointReason.BETTING, - PointOrigin.BETTING, - round.getBetRoundID() - ); + if (!bet.isFree() && bet.getStakePoints() > 0) { + pointHistoryService.createPointHistory( + bet.getUserId(), + bet.getStakePoints(), + PointReason.BETTING, + PointOrigin.BETTING, + round.getBetRoundID() + ); + } bet.draw(); } else if (bet.getOption() == resultOption) { // 예측 성공 시 보상 포인트 지급