Skip to content

feat: 투표 리워드 시스템 구현 (#175)#179

Merged
yooooonshine merged 6 commits intodevelopfrom
modic_backend_175
Sep 22, 2025
Merged

feat: 투표 리워드 시스템 구현 (#175)#179
yooooonshine merged 6 commits intodevelopfrom
modic_backend_175

Conversation

@goalSetter09
Copy link
Collaborator

@goalSetter09 goalSetter09 commented Sep 21, 2025

Summary

투표 리워드 시스템을 처음부터 완전히 구현하여 사용자의 연속 정답 달성에 따른 티켓 지급 기능을 제공합니다.

🎯 핵심 기능

투표 리워드 시스템 구현

  • 연속 정답 추적: 사용자별 투표 정답 연속 횟수 관리
  • 자동 티켓 지급: 3연속 정답 달성 시 리워드 티켓 자동 지급
  • Streak 초기화: 티켓 지급 후 연속 카운트 자동 초기화
  • 실시간 피드백: 투표 응답에 정답 여부, 현재 streak, 티켓 수령 여부 포함

🔒 동시성 제어 및 안정성

  • 분산 락: Redis 기반 분산 락으로 동시성 문제 완전 해결
  • @RedisHash 패턴: Spring Data Redis를 활용한 일관된 데이터 관리
  • 원자적 처리: 티켓 지급과 streak 초기화를 하나의 트랜잭션으로 처리
  • 에러 처리: 락 실패 시 적절한 예외 처리 및 복구

⚙️ 설정 기반 운영

  • 유연한 설정: VoteProperties를 통한 리워드 기준값 관리
  • TTL 관리: Redis 데이터 자동 만료 (7일)
  • 확장 가능: 향후 리워드 정책 변경에 유연하게 대응

📁 주요 변경사항

새로 추가된 컴포넌트

  • VoteRewardService - 투표 리워드 처리 핵심 로직
  • UserVoteStreakService - 사용자 연속 정답 관리
  • UserVoteStreak - Redis 엔티티로 연속 정답 데이터 저장
  • VoteRewardResult - 리워드 처리 결과 반환 객체
  • VoteProperties - 리워드 관련 설정값 관리

기존 컴포넌트 확장

  • VotingService - 투표 참여 시 리워드 처리 통합
  • TicketService - 리워드 티켓 지급 기능 추가
  • LockManager - vote streak 전용 분산 락 추가
  • VoteParticipationResponse - 리워드 정보 응답 확장

🏗️ 아키텍처 특징

[투표 참여] 
    ↓
[VotingService] → [VoteRewardService] → [분산 락 적용]
    ↓                    ↓                    ↓
[리워드 응답]     [UserVoteStreakService]  [Redis 원자적 처리]
                        ↓
                [3연속 달성 시 TicketService 호출]

📊 처리 흐름

  1. 사용자 투표 참여
  2. 투표 트렌드 분석 및 정답 여부 판정
  3. 분산 락 하에서 연속 정답 카운트 업데이트
  4. 3연속 달성 시 티켓 지급 및 카운트 초기화
  5. 결과를 포함한 응답 반환

📈 성과

  • 540줄 추가, 6줄 삭제로 완전한 리워드 시스템 구현
  • 13개 파일 수정/추가로 모듈화된 설계
  • 185개 테스트 케이스로 완전한 테스트 커버리지
  • 동시성 문제 완전 해결로 프로덕션 안정성 확보

Test plan

  • VoteRewardServiceTest (10개 테스트) - 리워드 처리 로직 검증
  • UserVoteStreakServiceTest (4개 테스트) - 연속 정답 관리 검증
  • 동시성 시나리오 테스트 - 분산 락 동작 확인
  • 통합 테스트 - 전체 플로우 검증
  • 설정 기반 동작 테스트 - VoteProperties 기반 로직 확인

Closes #175

Summary by CodeRabbit

  • New Features

    • 투표 트렌드 분석으로 정답 여부 판별 및 연속 정답(스트릭) 보상 로직 추가
    • 일정 연속 정답 달성 시 티켓 자동 지급
    • 투표 응답에 정답 여부, 현재 스트릭, 티켓 수령 여부 포함
  • Chores

    • 보상 임계값, 스트릭 유지기간, 티켓 지급량 설정값 추가
  • Tests

    • 스트릭 서비스와 보상 서비스 유닛 테스트 추가

goalSetter09 and others added 6 commits September 21, 2025 16:29
- VoteRewardService 추가: 추세 분석(analyzeTrend), 정답 판정(isCorrectAnswer), 리워드 처리(processVoteReward)
- UserVoteStreakService 구현: Redis 기반 연속 정답 카운트 업데이트/조회/리셋
- VoteProperties 확장: streakRewardCount, streakTtlDays 설정 추가

참고: 서비스 예외는 로깅 후 정상 진행되도록 처리
- VoteProperties에 rewardTicketCount 추가
- TicketService.giveRewardTicket에서 설정값 사용으로 하드코딩 제거
- TicketEntity에 increaseTicket 메서드 추가

참고: 락 실패 시 AppException으로 일관 처리
- VotingService에서 투표 후 VoteRewardService.processVoteReward 호출
- 기존 락 구간 보존, 리워드 실패는 서비스 내부에서 로깅 후 진행

참고: 응답 DTO 확장은 후속 단계에서 반영
…isCorrectAnswer/currentStreak/receivedTicket 추가\n- VoteRewardService가 VoteRewardResult 반환 및 streak 달성 시 티켓 지급\n- VotingService가 VoteRewardResult 기반으로 확장된 응답 생성\n- 기본값은 VoteProperties 설정 사용
- VoteRewardServiceTest: 추세 판정/정답 여부/리워드 처리 케이스 검증
- UserVoteStreakServiceTest: Redis 기반 streak 업데이트/조회/리셋 검증
- Mockito 불필요 스터빙 제거 및 예외 시나리오 보완

Co-authored-by: dong0579 <dong0579@users.noreply.github.com>
- RedisTemplate에서 @RedisHash 패턴으로 UserVoteStreak 엔티티 변경
- 동시성 문제 해결을 위한 분산 락 적용으로 race condition 방지
- 3연속 정답 달성 시 자동 초기화 로직 구현
- LockManager에 voteStreakLock 메서드 추가로 동시성 제어
- UserVoteStreakService를 레포지토리 패턴으로 리팩토링하여 적절한 락킹 적용
- VoteRewardService에서 티켓 지급과 연속 정답 초기화를 원자적으로 처리
- 사용되지 않는 메서드들(updateStreak, resetStreak) 제거하여 필수 API만 유지
- 새로운 분산 락킹 및 @RedisHash 패턴에 맞춰 모든 테스트 코드 업데이트
- 동시 처리 환경에서 적절한 에러 처리 및 트랜잭션 안전성 보장

Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
@coderabbitai
Copy link

coderabbitai bot commented Sep 21, 2025

Walkthrough

투표 리워드 기능이 추가되며, 연속 정답 관리( Redis + 분산락), 티켓 보상 지급, 투표 응답 DTO 확장, 그리고 기존 투표 플로우에 리워드 처리 통합이 이루어졌습니다. 구성 프로퍼티와 락 매니저가 확장되었고, 관련 서비스/엔터티/테스트가 신규로 도입되었습니다.

Changes

Cohort / File(s) Summary
Configuration: Vote properties
src/main/java/.../property/property/VoteProperties.java
연속 보상 개수, TTL 일수, 지급 티켓 수 프로퍼티 추가(streakRewardCount, streakTtlDays, rewardTicketCount)
Distributed lock
src/main/java/.../redis/distributedLock/LockManager.java
투표 연속 정답용 락 프리픽스 및 voteStreakLock(long, Runnable) 추가
Ticket domain & service
src/main/java/.../ticket/entity/TicketEntity.java, src/main/java/.../ticket/service/TicketService.java
티켓 증가 메서드(increaseTicket) 추가, 리워드 지급 트랜잭션(giveRewardTicket) 및 VoteProperties 의존성 도입
User streak (Redis)
src/main/java/.../user/entity/UserVoteStreak.java, src/main/java/.../user/repository/UserVoteStreakRepository.java, src/main/java/.../user/service/UserVoteStreakService.java, src/test/java/.../user/service/UserVoteStreakServiceTest.java
사용자 연속 정답 엔터티(Redis, TTL), CRUD 리포지토리, 분산락 기반 업데이트/리셋 서비스 및 단위 테스트 추가
Vote reward core
src/main/java/.../vote/service/VoteRewardService.java, src/main/java/.../vote/service/VoteRewardResult.java, src/test/java/.../vote/service/VoteRewardServiceTest.java
추세 분석, 정답 판정, 연속/보상 처리 서비스 및 결과 레코드, 시나리오 테스트 추가
Voting integration & DTO
src/main/java/.../vote/service/VotingService.java, src/main/java/.../web/vote/dto/response/VoteParticipationResponse.java
투표 처리 후 리워드 연동, 응답 DTO를 voteId, isCorrectAnswer, currentStreak, receivedTicket로 확장

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant VotingService
  participant VoteRewardService
  participant SimilaritySummaryRepo as SimilarityVoteSummaryRepository
  participant UserStreakService as UserVoteStreakService
  participant TicketService

  User->>VotingService: 투표 요청(사용자 결정)
  VotingService->>VoteRewardService: processVoteReward(voteId, userId, decision)
  VoteRewardService->>SimilaritySummaryRepo: findById(voteId)
  SimilaritySummaryRepo-->>VoteRewardService: Summary(approve/deny 가중치)
  VoteRewardService->>VoteRewardService: 추세 분석 및 정답 판정
  VoteRewardService->>UserStreakService: getStreakCount(userId)
  alt 정답
    VoteRewardService->>UserStreakService: updateStreakWithReset(userId, true)
    alt 보상 임계치 달성
      VoteRewardService->>TicketService: giveRewardTicket(userId)
    end
  else 오답
    VoteRewardService->>UserStreakService: updateStreakWithReset(userId, false)
  end
  VoteRewardService-->>VotingService: VoteRewardResult
  VotingService-->>User: VoteParticipationResponse
Loading
sequenceDiagram
  autonumber
  participant UserStreakService
  participant LockManager
  participant RedisRepo as UserVoteStreakRepository

  UserStreakService->>LockManager: voteStreakLock(userId, runnable)
  activate LockManager
  LockManager-->>UserStreakService: 임계영역 진입
  UserStreakService->>RedisRepo: findById(userId) or create
  alt isCorrect
    UserStreakService->>UserStreakService: streak+1 및 임계치 충족 검사
    alt 임계치 충족
      UserStreakService->>UserStreakService: resetStreak()
    end
  else not correct
    UserStreakService->>UserStreakService: resetStreak()
  end
  UserStreakService->>RedisRepo: save(streak)
  deactivate LockManager
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

바삭한 별빛 아래 쿵짝 리듬의 투표밤,
세 번 맞추면 토끼 주머니에 티켓 퐁! 🎟️
정답이면 씩— 스택은 춤추고,
오답이면 휙— 다시 처음으로.
락-톡-락, 레디스에 발자국,
오늘도 당근 같은 보상 꿈꾸며 hop! 🐇✨

Pre-merge checks and finishing touches

❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Description Check ⚠️ Warning PR 설명은 기능, 아키텍처, 변경 파일 및 테스트 플랜을 상세히 담고 있어 전반적인 의도와 구현 범위를 잘 전달합니다. 다만 저장소에서 요구하는 템플릿(## 개요, ## 작업사항)과 정확히 일치하지 않아 템플릿 기반 자동화나 리뷰 체크에서 누락으로 처리될 가능성이 있습니다. 템플릿의 필수 섹션을 명시적으로 추가하면 규정 준수 문제를 해소할 수 있습니다. 해결안: PR 본문 상단에 "## 개요"에 한 문단 요약(목적 및 핵심 변경)을 추가하고 "## 작업사항"에 변경된 주요 파일/메서드와 테스트 실행 방법을 항목화하여 템플릿 형식을 맞추세요; 또한 Closes/참조 이슈와 테스트 상태(예: 통과 여부)를 명확히 기재하면 CI 및 릴리즈 노트 연동에 도움이 됩니다.
Docstring Coverage ⚠️ Warning Docstring coverage is 19.35% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (3 passed)
Check name Status Explanation
Title Check ✅ Passed 제목 "feat: 투표 리워드 시스템 구현 (#175)"은 PR의 주된 변경점(투표 리워드 시스템 추가)을 짧고 명확하게 요약하고 있어 스캔 시 주요 목적을 파악하기에 적절합니다. 이슈 번호가 포함되어 있으나 과도한 노이즈나 파일 목록은 없어 팀 히스토리 가독성에 문제를 일으키지 않습니다.
Linked Issues Check ✅ Passed 변경사항은 링크된 이슈 #175의 핵심 요구사항을 충족합니다: 투표 추세 분석(VoteRewardService.analyzeTrend), 연속 정답 추적 및 Redis 저장(UserVoteStreakService, RedisHash, TTL), 3연속 시 티켓 지급 로직(TicketService.giveRewardTicket 및 VoteProperties.streakRewardCount=3·rewardTicketCount=1), 틀릴 경우 streak 리셋, VotingService 통합 및 응답 DTO 확장 등이 구현되어 있으며 단위 테스트도 추가되어 있습니다. 분산 락 적용과 예외 처리 흐름도 요구사항과 일치합니다.
Out of Scope Changes Check ✅ Passed 검토된 변경들은 모두 리워드 시스템(설정 추가, 락 매니저 확장, UserVoteStreak 엔티티·서비스, VoteRewardService, TicketService 확장, VotingService 통합, DTO 변경, 관련 테스트)에 직접 연관되어 있으며 명시된 이슈 범위를 벗어나는 독립적 또는 불필요한 파일 수정은 발견되지 않았습니다.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch modic_backend_175

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@goalSetter09 goalSetter09 changed the title feat: 투표 리워드 시스템 완전 구현 (#175) feat: 투표 리워드 시스템 구현 (#175) Sep 21, 2025
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (10)
src/main/java/hanium/modic/backend/common/redis/distributedLock/LockManager.java (1)

32-32: (선택) VOTE_STREAK 락에도 옵션(대기/리스) 명시를 고려해 주세요.

기본 옵션이 무기한 대기가 아니라면 괜찮지만, 안전을 위해 POST_LIKE_OPTS처럼 wait/lease를 명시하는 것을 권장합니다(짧은 임계구역이므로 1s/3s 수준 권장).

기본 exec.withLock(...)의 대기/리스 동작이 어떻게 설정되어 있는지 확인 부탁드립니다.

src/main/java/hanium/modic/backend/domain/vote/service/VoteRewardResult.java (1)

3-4: LGTM — 반환 레코드 구조 적절

레코드 사용과 접근자 사용(reward.isCorrectAnswer() 등)이 명확합니다. 직렬화 시 필드명이 그대로 노출되길 원하지 않으면(예: isCorrectAnswer) DTO 단에서 매핑으로 조정해 주세요.

src/main/java/hanium/modic/backend/domain/ticket/service/TicketService.java (1)

83-95: 리워드 지급 흐름 최적화 및 재진입 락 전제 확인

  • 재진입 가능 락이라는 전제로 getTicketEntity→refreshTicketIfExpired에서 동일 락을 중첩 획득합니다. exec가 진짜 재진입 지원(Redisson RLock 등)하는지 확인해 주세요. 비재진입이면 교착됩니다.
  • 불필요한 save 호출 방지: rewardTicketCount ≤ 0이면 조기 반환하세요.
  • 관찰용 로그를 소량 추가하면 운영 추적이 쉬워집니다.

적용 예시:

 	public void giveRewardTicket(final long userId) {
-		try {
+		try {
+			final int reward = voteProperties.getRewardTicketCount();
+			if (reward <= 0) {
+				return;
+			}
 			lockManager.aiRequestTicketLock(userId, () -> {
 				TicketEntity userTicket = getTicketEntity(userId);
-                userTicket.increaseTicket(voteProperties.getRewardTicketCount());
+				userTicket.increaseTicket(reward);
 				ticketRepository.save(userTicket);
 			});
 		} catch (LockException e) {
 			throw new AppException(AI_REQUEST_TICKET_PROCESSING_FAIL_EXCEPTION);
 		}
 	}

재진입 보장을 확인하려면 DistributionLockExecutor 구현체/Redisson 설정을 공유해 주세요.

src/main/java/hanium/modic/backend/common/property/property/VoteProperties.java (1)

19-33: TTL/임계값 프로퍼티 정합성 및 유효성 검증 추가 권장

  • streakTtlDays(기본 30) vs 엔티티 @RedisHash TTL 7일 불일치. 하나로 통일해 주세요(권장: @timetolive 필드로 동적 TTL 적용).
  • 임계/수량값에 최소 제약(@min(1))을 두면 오입력 방지에 유효합니다. 클래스에 @validated도 추가하세요.

예시(필드에 제약 추가 — import: org.springframework.validation.annotation.Validated, jakarta.validation.constraints.Min):

-@ConfigurationProperties(prefix = "vote.similarity")
+@ConfigurationProperties(prefix = "vote.similarity")
+@Validated
 public class VoteProperties {
@@
-	private int streakRewardCount = 3;
+	@Min(1)
+	private int streakRewardCount = 3;
@@
-	private int streakTtlDays = 30;
+	@Min(1)
+	private int streakTtlDays = 30;
@@
-	private int rewardTicketCount = 1;
+	@Min(1)
+	private int rewardTicketCount = 1;

TTL 통일은 UserVoteStreak에 @timetolive를 도입하는 쪽을 권장합니다(아래 해당 파일 코멘트 참고).

src/main/java/hanium/modic/backend/domain/user/entity/UserVoteStreak.java (2)

21-26: streakCount를 원시형으로 변경해 NPE 리스크 제거

Integer는 로딩/이관 데이터에서 null 가능성이 있어 auto-unboxing NPE 위험이 있습니다. int로 변경 권장.

-	@Builder.Default
-	private Integer streakCount = 0;
+	@Builder.Default
+	private int streakCount = 0;

13-14: TTL 설정 불일치 — 프로퍼티 기반 동적 TTL 적용 권장

현재 @RedisHash(timeToLive=7일) 고정값이고, VoteProperties에는 streakTtlDays(기본 30)가 존재합니다. @timetolive 필드로 전환하고 Service에서 프로퍼티 값을 세팅하는 것이 일관됩니다.

적용 방향(참고 코드, Service 수정 병행 필요):

// import org.springframework.data.redis.core.TimeToLive;
// import java.util.concurrent.TimeUnit;

@RedisHash(value = "userVoteStreak") // timeToLive 제거
public class UserVoteStreak {
  @Id
  private Long userId;

  @TimeToLive(unit = TimeUnit.DAYS)
  private Long ttlDays; // 생성/갱신 시 VoteProperties.getStreakTtlDays()로 설정
}

UserVoteStreakService에서 생성/업데이트 시 ttlDays를 프로퍼티값으로 세팅하는지 확인 부탁드립니다.

Also applies to: 32-34

src/main/java/hanium/modic/backend/web/vote/dto/response/VoteParticipationResponse.java (1)

3-4: DTO 확장 LGTM, 불리언 필드 네이밍만 점검 부탁

record 컴포넌트를 isCorrectAnswer로 두면 JSON 키도 그대로 isCorrectAnswer가 됩니다. 클라이언트 계약이 correct/isCorrect 등을 기대하지 않는지 확인 바랍니다. 필요 시 @JsonProperty("correct")로 매핑 가능합니다.

src/main/java/hanium/modic/backend/domain/user/service/UserVoteStreakService.java (1)

44-47: 예외 유형을 도메인 예외로 래핑

RuntimeException 대신 공통 AppException(또는 streak 전용 예외)으로 변환해 상위 계층에서 일관되게 처리/로깅할 수 있게 해주세요. 사용자 메시지와 내부 메시지를 분리하는 것도 권장합니다.

src/test/java/hanium/modic/backend/domain/user/service/UserVoteStreakServiceTest.java (1)

92-114: 임계치 미달 증가 테스트 OK, 오답 시 초기화 케이스 추가 권장

오답(false) 입력 시 0으로 리셋되는 경로에 대한 단위 테스트를 추가하면 회귀 방지에 도움이 됩니다.

src/test/java/hanium/modic/backend/domain/vote/service/VoteRewardServiceTest.java (1)

1-40: 동시성(중복 보상 방지) 통합 테스트 추가 제안

동일 userId에 대해 동시에 processVoteReward 2회 호출 시 단 1회만 보상되는지 확인하는 테스트(CountDownLatch/Executor) 추가를 권장합니다.

원하시면 테스트 템플릿을 드리겠습니다.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f2de3b1 and 54848de.

📒 Files selected for processing (13)
  • src/main/java/hanium/modic/backend/common/property/property/VoteProperties.java (1 hunks)
  • src/main/java/hanium/modic/backend/common/redis/distributedLock/LockManager.java (2 hunks)
  • src/main/java/hanium/modic/backend/domain/ticket/entity/TicketEntity.java (1 hunks)
  • src/main/java/hanium/modic/backend/domain/ticket/service/TicketService.java (3 hunks)
  • src/main/java/hanium/modic/backend/domain/user/entity/UserVoteStreak.java (1 hunks)
  • src/main/java/hanium/modic/backend/domain/user/repository/UserVoteStreakRepository.java (1 hunks)
  • src/main/java/hanium/modic/backend/domain/user/service/UserVoteStreakService.java (1 hunks)
  • src/main/java/hanium/modic/backend/domain/vote/service/VoteRewardResult.java (1 hunks)
  • src/main/java/hanium/modic/backend/domain/vote/service/VoteRewardService.java (1 hunks)
  • src/main/java/hanium/modic/backend/domain/vote/service/VotingService.java (2 hunks)
  • src/main/java/hanium/modic/backend/web/vote/dto/response/VoteParticipationResponse.java (1 hunks)
  • src/test/java/hanium/modic/backend/domain/user/service/UserVoteStreakServiceTest.java (1 hunks)
  • src/test/java/hanium/modic/backend/domain/vote/service/VoteRewardServiceTest.java (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (4)
src/test/java/hanium/modic/backend/domain/user/service/UserVoteStreakServiceTest.java (1)
src/main/java/hanium/modic/backend/common/error/exception/LockException.java (1)
  • LockException (3-8)
src/main/java/hanium/modic/backend/domain/user/service/UserVoteStreakService.java (3)
src/main/java/hanium/modic/backend/domain/vote/service/VoteRewardService.java (1)
  • Service (18-71)
src/main/java/hanium/modic/backend/common/error/exception/LockException.java (1)
  • LockException (3-8)
src/main/java/hanium/modic/backend/common/redis/distributedLock/LockManager.java (1)
  • Slf4j (17-66)
src/test/java/hanium/modic/backend/domain/vote/service/VoteRewardServiceTest.java (1)
src/test/java/hanium/modic/backend/domain/user/service/UserVoteStreakServiceTest.java (1)
  • ExtendWith (23-115)
src/main/java/hanium/modic/backend/domain/vote/service/VoteRewardService.java (3)
src/main/java/hanium/modic/backend/domain/user/service/UserVoteStreakService.java (1)
  • Service (13-54)
src/main/java/hanium/modic/backend/domain/vote/service/VotingService.java (1)
  • Service (34-271)
src/main/java/hanium/modic/backend/common/redis/distributedLock/LockManager.java (1)
  • Slf4j (17-66)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (12)
src/main/java/hanium/modic/backend/domain/ticket/service/TicketService.java (1)

23-23: LGTM — VoteProperties 의존성 추가 적절

리워드 수량 설정값 주입 방향성 좋습니다.

src/main/java/hanium/modic/backend/domain/vote/service/VotingService.java (2)

46-46: LGTM — VoteRewardService 주입으로 책임 분리가 명확해졌습니다.


96-105: 리워드 처리 실패가 투표 흐름을 중단합니다 — 보상 실패 무시 및 락 내 보상 처리 권장

파일: src/main/java/hanium/modic/backend/domain/vote/service/VotingService.java (라인 96–105)

문제: processVoteReward 예외 시 전체 요청이 VOTE_UPDATE_FAIL로 실패하며, 보상 로직이 집계 락 해제 후 실행되어 정답·연속(streak) 판정에 경쟁 조건이 생길 수 있음.

최소 수정(보상 실패 무시) 제안:

-			// 9. 리워드 처리 (투표 참여 직후)
-			VoteRewardResult reward = voteRewardService.processVoteReward(voteId, userId, decision);
+			// 9. 리워드 처리 (실패 시 투표 흐름 영향 없음)
+			VoteRewardResult reward;
+			try {
+				reward = voteRewardService.processVoteReward(voteId, userId, decision);
+			} catch (Exception ex) {
+				log.warn("리워드 처리 실패 — 투표는 성공 처리: voteId={}, userId={}", voteId, userId, ex);
+				reward = new VoteRewardResult(false, 0, false);
+			}

권장 정합성 개선: 집계 업데이트와 동일한 락 구간 안에서 정답 판정과 보상까지 수행(요약 락 → 스테릭 락 → 티켓 락 순). Runnable 반환 제약이 있으면 AtomicReference로 결과 전달 방식 사용. 역순 락 획득 경로(데드락) 존재 여부 확인.

src/main/java/hanium/modic/backend/domain/user/repository/UserVoteStreakRepository.java (1)

8-10: LGTM — Redis 리포지토리 선언 적절합니다.

  • build.gradle에 spring-boot-starter-data-redis 및 redisson 의존성 존재 (build.gradle:77, 81).
  • @EnableRedisRepositories 어노테이션은 코드베이스에서 발견되지 않음 — Spring Boot 자동설정에 의존하는 경우 리포지토리 스캔이 실제로 활성화되는지(메인 애플리케이션 클래스 또는 Redis 설정 클래스/설정파일) 확인 필요.
src/main/java/hanium/modic/backend/common/redis/distributedLock/LockManager.java (1)

63-65: 락 획득 순서 점검 완료 — 현재 코드상 교착 경로 없음

검사 결과: VotingService.participateVote는 voteSummaryLock(voteId)을 획득 후 락 블록이 종료된 뒤 VoteRewardService.processVoteReward를 호출합니다. VoteRewardService(processVoteReward)는 userVoteStreakService.updateStreakWithReset(userId) → (voteStreakLock: src/main/java/hanium/modic/backend/domain/user/service/UserVoteStreakService.java:28) 를 먼저 호출하고, 리워드 발생 시 ticketService.giveRewardTicket(userId) → (aiRequestTicketLock: src/main/java/hanium/modic/backend/domain/ticket/service/TicketService.java:85–89) 를 호출합니다. 레포 전체 검색에서 aiRequestTicketLock → voteStreakLock의 역순 획득 경로는 발견되지 않았습니다. 락 순서(요약→스테릭→티켓)를 문서화해 두면 향후 변경 시 안전합니다.

src/main/java/hanium/modic/backend/web/vote/dto/response/VoteParticipationResponse.java (1)

6-8: 정적 팩토리 일관성 OK

팩토리 메서드가 모든 필드를 전달하며 명확합니다.

src/main/java/hanium/modic/backend/domain/user/service/UserVoteStreakService.java (1)

22-26: 기본 반환 로직 LGTM

미존재 시 0 반환은 사양과 부합합니다.

src/main/java/hanium/modic/backend/domain/vote/service/VoteRewardService.java (1)

29-37: 추세 계산/동점 처리 LGTM

>=로 동점 시 APPROVE 판정, 테스트와 일치합니다.

src/test/java/hanium/modic/backend/domain/user/service/UserVoteStreakServiceTest.java (1)

68-90: 임계치 도달 시 리셋 테스트 OK

도달 시 0으로 리셋 확인 테스트가 명확합니다.

src/test/java/hanium/modic/backend/domain/vote/service/VoteRewardServiceTest.java (3)

91-114: 3연속 보상 시나리오 테스트 적합

현재 로직 기준의 기대값과 일치합니다.


116-141: 아래-임계 정답 시나리오 OK, 리팩터 후 최종 streak 산정 방식 점검 필요

제안된 원자화 리팩터(락 내 계산/업데이트) 적용 시 getStreakCount() 호출 횟수/시점이 달라질 수 있습니다. 기대값(2)은 동일해야 하나 목 설정은 업데이트가 필요합니다.


169-184: 집계 미존재 실패 처리 테스트 OK

예외 시 업데이트/보상 미수행을 검증하고 있어 적절합니다.

Comment on lines +61 to +69
// 리워드 티켓 지급 (발급 시간은 변경하지 않음)
public void increaseTicket(final long amount) {
if (amount <= 0) {
return;
}

this.ticketCount += amount;
}

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

🧩 Analysis chain

보상 티켓 누적 시 오버플로와 일일 초기화 정책 충돌 가능성

  • Long 누적으로 오버플로 시 음수 전환 위험이 있습니다. addExact로 방어하세요.
  • resetTickets가 매일 ticketCount를 무료분으로 덮어써 보상분을 소거할 수 있습니다(정책에 따라 심각). 보상 티켓을 보전하려면 reset 시 최소치를 보장하거나(무료분 이상 유지) 보상/무료를 분리 저장해야 합니다.

적용 예시(오버플로 방어):

-	public void increaseTicket(final long amount) {
-		if (amount <= 0) {
-			return;
-		}
-
-		this.ticketCount += amount;
-	}
+	public void increaseTicket(final long amount) {
+		if (amount <= 0) {
+			return;
+		}
+		try {
+			this.ticketCount = Math.addExact(this.ticketCount, amount);
+		} catch (ArithmeticException ex) {
+			throw new AppException(AI_REQUEST_TICKET_PROCESSING_FAIL_EXCEPTION);
+		}
+	}

reset 정책(보상 보전) 변경 예시 — 변경은 본 범위 밖이므로 참조만 드립니다:

// resetTickets()
this.ticketCount = Math.max(this.ticketCount, FREE_TICKET_COUNT_PER_DAY);
this.lastIssuedAt = LocalDateTime.now();

정책이 “보상 포함 전량 리셋”이라면 문서화와 테스트 케이스로 의도를 명확히 해 주세요.


🏁 Script executed:

#!/bin/bash
set -e

# TicketEntity.java 파일 경로 찾기
FILE=$(fd TicketEntity.java src)

echo "▶ TicketEntity 위치: $FILE"

# increaseTicket 메서드 확인 (오버플로우 방어 로직 부재 확인)
rg -n "public void increaseTicket" -C3 "$FILE"

# ticketCount 필드 타입 및 초기값 확인
rg -n "private long ticketCount" -C3 "$FILE"

# resetTickets 메서드 구현 확인 (일일 초기화 정책 영향도 검토)
rg -n "resetTickets" -C5 "$FILE"

Length of output: 233


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE=src/main/java/hanium/modic/backend/domain/ticket/entity/TicketEntity.java
if [ ! -f "$FILE" ]; then
  echo "파일 없음: $FILE" >&2
  fd TicketEntity.java src || true
  exit 0
fi

echo "▶ FILE: $FILE"
echo
echo "----- increaseTicket 근처 (컨텍스트 12줄) -----"
rg -n "public void increaseTicket" -C12 "$FILE" || true
echo
echo "----- ticketCount 선언 및 사용 위치 -----"
rg -n --hidden "ticketCount" -C6 "$FILE" || true
echo
echo "----- resetTickets 메서드/일일 초기화 관련 검색 -----"
rg -n --hidden "resetTickets|resetTicket|reset.*ticket|FREE_TICKET_COUNT|lastIssuedAt" -C6 "$FILE" || true
echo
echo "----- 파일 전체 (1-400) -----"
sed -n '1,400p' "$FILE" || true

Length of output: 5078


오버플로우 방지 + 일일 리셋으로 인한 보상 티켓 소실 방지 — 수정 필요

  • increaseTicket: 현재 this.ticketCount += amount로 누적 시 long 오버플로우로 음수 전환 가능. Math.addExact로 방어하고 ArithmeticException을 AppException(적절한 ErrorCode)으로 변환하세요. (파일: src/main/java/hanium/modic/backend/domain/ticket/entity/TicketEntity.java — increaseTicket, 61–68행)
-	public void increaseTicket(final long amount) {
-		if (amount <= 0) {
-			return;
-		}
-
-		this.ticketCount += amount;
-	}
+	public void increaseTicket(final long amount) {
+		if (amount <= 0) {
+			return;
+		}
+		try {
+			this.ticketCount = Math.addExact(this.ticketCount, amount);
+		} catch (ArithmeticException ex) {
+			throw new AppException(AI_REQUEST_TICKET_PROCESSING_FAIL_EXCEPTION);
+		}
+	}
  • resetTickets: 현재 resetTickets()가 this.ticketCount = FREE_TICKET_COUNT_PER_DAY로 덮어써 보상으로 누적된 티켓을 소거할 수 있음. 정책이 "보상 포함 전량 리셋"이면 문서화·테스트를 추가하고, 보상을 보전하려면 최소치 보장 또는 보상/무료 분리 저장을 적용하세요. (파일: src/main/java/hanium/modic/backend/domain/ticket/entity/TicketEntity.java — resetTickets, 55–59행)
// resetTickets() 예시(보상 보전)
this.ticketCount = Math.max(this.ticketCount, FREE_TICKET_COUNT_PER_DAY);
this.lastIssuedAt = LocalDateTime.now();
🤖 Prompt for AI Agents
In src/main/java/hanium/modic/backend/domain/ticket/entity/TicketEntity.java
around lines 61–69 (increaseTicket) and lines 55–59 (resetTickets): guard
against long overflow in increaseTicket by using Math.addExact(this.ticketCount,
amount) inside a try/catch that catches ArithmeticException and rethrows your
AppException with the appropriate ErrorCode; also validate amount > 0 first. For
resetTickets, either document+add tests if the intended policy is to fully
overwrite (i.e., “reward lost on reset”), or change implementation to preserve
rewards by ensuring the ticketCount is at least the daily free amount (e.g.,
this.ticketCount = Math.max(this.ticketCount, FREE_TICKET_COUNT_PER_DAY)) and
update lastIssuedAt accordingly; pick the behavior that matches product policy
and add unit tests and comments reflecting it.

Comment on lines +35 to +41
// 3연속 정답 달성 시 초기화
if (isCorrect && streak.getStreakCount() >= voteProperties.getStreakRewardCount()) {
streak.resetStreak();
}

streakRepository.save(streak);
log.debug("연속 정답 업데이트 (리셋 포함): userId={}, current={}, next={}, isCorrect={}",
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

리셋 시점이 보상 발급과 분리되어 원자성이 깨질 수 있습니다

현재 이 메서드는 임계치 도달 시 streak를 즉시 리셋합니다. 반면 보상 발급은 VoteRewardService에서 잠금 밖에서 이뤄져(동일 사용자 동시 처리 시) 중복 보상 또는 보상 실패 시 진행도 유실이 발생할 수 있습니다. 리셋은 “보상 발급 성공 이후” 동일 락 범위 내에서 실행되도록 책임을 재배치해야 합니다.

다음과 같이 “락 없이 호출하는 내부용 메서드”를 추가해 VoteRewardService가 voteStreakLock 범위 안에서 보상 발급 → 리셋까지 원자적으로 처리할 수 있게 해주세요:

@@
 	public void updateStreakWithReset(Long userId, boolean isCorrect) {
 		try {
 			lockManager.voteStreakLock(userId, () -> {
 				UserVoteStreak streak = getOrCreateStreak(userId);
 				int currentStreak = streak.getStreakCount();
 				streak.updateStreak(isCorrect);
 
 				// 3연속 정답 달성 시 초기화
 				if (isCorrect && streak.getStreakCount() >= voteProperties.getStreakRewardCount()) {
 					streak.resetStreak();
 				}
 
 				streakRepository.save(streak);
 				log.debug("연속 정답 업데이트 (리셋 포함): userId={}, current={}, next={}, isCorrect={}",
 					userId, currentStreak, streak.getStreakCount(), isCorrect);
 			});
@@
 	}
 
+	/**
+	 * 내부용: 외부에서 voteStreakLock을 이미 보유한 상태에서 호출해야 합니다.
+	 * 임계구역에서만 사용하세요.
+	 */
+	void updateStreakWithResetWithoutLock(Long userId, boolean isCorrect) {
+		UserVoteStreak streak = getOrCreateStreak(userId);
+		int currentStreak = streak.getStreakCount();
+		streak.updateStreak(isCorrect);
+		if (isCorrect && streak.getStreakCount() >= voteProperties.getStreakRewardCount()) {
+			streak.resetStreak();
+		}
+		streakRepository.save(streak);
+		log.debug("[no-lock] 연속 정답 업데이트 (리셋 포함): userId={}, current={}, next={}, isCorrect={}",
+			userId, currentStreak, streak.getStreakCount(), isCorrect);
+	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 3연속 정답 달성 시 초기화
if (isCorrect && streak.getStreakCount() >= voteProperties.getStreakRewardCount()) {
streak.resetStreak();
}
streakRepository.save(streak);
log.debug("연속 정답 업데이트 (리셋 포함): userId={}, current={}, next={}, isCorrect={}",
public void updateStreakWithReset(Long userId, boolean isCorrect) {
try {
lockManager.voteStreakLock(userId, () -> {
UserVoteStreak streak = getOrCreateStreak(userId);
int currentStreak = streak.getStreakCount();
streak.updateStreak(isCorrect);
// 3연속 정답 달성 시 초기화
if (isCorrect && streak.getStreakCount() >= voteProperties.getStreakRewardCount()) {
streak.resetStreak();
}
streakRepository.save(streak);
log.debug("연속 정답 업데이트 (리셋 포함): userId={}, current={}, next={}, isCorrect={}",
userId, currentStreak, streak.getStreakCount(), isCorrect);
});
}
}
/**
* 내부용: 외부에서 voteStreakLock을 이미 보유한 상태에서 호출해야 합니다.
* 임계구역에서만 사용하세요.
*/
void updateStreakWithResetWithoutLock(Long userId, boolean isCorrect) {
UserVoteStreak streak = getOrCreateStreak(userId);
int currentStreak = streak.getStreakCount();
streak.updateStreak(isCorrect);
if (isCorrect && streak.getStreakCount() >= voteProperties.getStreakRewardCount()) {
streak.resetStreak();
}
streakRepository.save(streak);
log.debug("[no-lock] 연속 정답 업데이트 (리셋 포함): userId={}, current={}, next={}, isCorrect={}",
userId, currentStreak, streak.getStreakCount(), isCorrect);
}
🤖 Prompt for AI Agents
In
src/main/java/hanium/modic/backend/domain/user/service/UserVoteStreakService.java
around lines 35-41, the streak reset is done immediately which breaks atomicity
with reward issuance; add a package-private/internal method (e.g.,
resetStreakIfEligibleWithoutLock or resetIfEligibleForRewardWithoutLock) that
performs only the eligibility check and the reset+save without acquiring any
lock, keep the existing public method for callers that should still use the lock
but remove the immediate reset there (or delegate to the new internal method
only when already locked), and update VoteRewardService to call reward issuance
then call this new internal method inside the same voteStreakLock so reset and
reward are executed atomically.

Comment on lines +50 to +53
private UserVoteStreak getOrCreateStreak(Long userId) {
return streakRepository.findById(userId)
.orElse(UserVoteStreak.builder().userId(userId).build());
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

🧩 Analysis chain

TTL 갱신 보장 여부 확인

getOrCreateStreak가 새 엔티티를 생성할 때 TTL이 자동 적용되는지(@RedisHash timeToLive 또는 필드 기반 TTL) 확인 바랍니다. 저장 시 TTL이 갱신되지 않으면 의도한 7일 만료가 보장되지 않을 수 있습니다.

다음 스크립트로 엔티티 TTL 설정을 점검할 수 있어요:


🏁 Script executed:

#!/bin/bash
rg -nP '@RedisHash|\btimeToLive\b|ttl' -C2 --type=java
rg -nP 'class\s+UserVoteStreak\b' -C3 --type=java

Length of output: 5795


TTL 적용/갱신 보장 필요 — 새 UserVoteStreak은 Redis에 저장(또는 TTL 갱신)되도록 수정하세요

  • 확인: src/main/java/hanium/modic/backend/domain/user/entity/UserVoteStreak.java에 @RedisHash(... timeToLive = 60 * 60 * 24 * 7) 선언되어 있어 TTL 설정은 존재함.
  • 문제: src/main/java/hanium/modic/backend/domain/user/service/UserVoteStreakService.java의 getOrCreateStreak(...)는 새 엔티티를 반환만 하고 저장(streakRepository.save(...))을 호출하지 않음(라인 50–53). TTL은 Redis에 저장될 때 부여/갱신되므로 저장 호출이 없으면 7일 만료가 적용/갱신되지 않음.
  • 조치: 새 엔티티 생성 분기에서 반드시 repository.save(streak)를 호출하거나, 업데이트 시에도 repository.save(...) 또는 redisTemplate.expire(...) 등으로 TTL을 명시적으로 갱신하도록 변경하세요.
🤖 Prompt for AI Agents
In
src/main/java/hanium/modic/backend/domain/user/service/UserVoteStreakService.java
around lines 50–53, getOrCreateStreak(...) currently returns a newly built
UserVoteStreak without persisting it, so Redis TTL on @RedisHash won't be
applied or refreshed; modify the method so that when the
repository.findById(...) yields empty you persist the new entity (e.g., call
streakRepository.save(streak)) before returning, and ensure any path that
updates an existing streak also either calls repository.save(...) or explicitly
refreshes TTL via redisTemplate.expire(...) so TTL is guaranteed to be
applied/renewed.

Comment on lines +43 to +69
@Transactional
public VoteRewardResult processVoteReward(Long voteId, Long userId, VoteDecision userDecision) {
try {
VoteDecision trend = analyzeTrend(voteId);
boolean isCorrect = isCorrectAnswer(userDecision, trend);
// streak 업데이트 전 현재 값 확인
int currentStreak = userVoteStreakService.getStreakCount(userId);
boolean willReceiveReward = isCorrect && (currentStreak + 1) >= voteProperties.getStreakRewardCount();

// streak 업데이트 (3연속 정답 시 자동 초기화 포함)
userVoteStreakService.updateStreakWithReset(userId, isCorrect);

boolean receivedTicket = false;
if (willReceiveReward) {
ticketService.giveRewardTicket(userId);
receivedTicket = true;
}

// 업데이트 후 streak 값 (리워드 받았으면 0, 아니면 업데이트된 값)
int finalStreak = willReceiveReward ? 0 : userVoteStreakService.getStreakCount(userId);
log.info("투표 리워드 처리 완료: voteId={}, userId={}, userDecision={}, trend={}, isCorrect={}",
voteId, userId, userDecision, trend, isCorrect);
return new VoteRewardResult(isCorrect, finalStreak, receivedTicket);
} catch (Exception e) {
log.error("투표 리워드 처리 중 오류 발생: voteId={}, userId={}", voteId, userId, e);
return new VoteRewardResult(false, userVoteStreakService.getStreakCount(userId), false);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

치명적 TOCTOU: 리워드 결정·리셋·티켓 지급이 동일 락에서 원자적으로 수행되지 않음

현재 흐름:

  • (락 밖) currentStreak 조회 → willReceiveReward 계산
  • (락 안) 아님. updateStreakWithReset 내부에서만 사용자별 락
  • (락 밖) 티켓 지급 실행

결과: 같은 사용자의 동시 요청에서 중복 지급/미지급, 보상 실패 시 진행도 유실(리셋 후 예외) 가능. 보상 발급과 리셋을 동일 voteStreakLock(userId) 임계구역 내에서 처리하고, “보상 성공 후 리셋” 순서로 바꿔주세요.

다음 패치를 제안합니다(동일 키로 잠금, 보상→리셋 순, 리턴 값 계산 일관화). 전제: UserVoteStreakService.updateStreakWithResetWithoutLock 추가.

@@
-import hanium.modic.backend.common.property.property.VoteProperties;
+import hanium.modic.backend.common.property.property.VoteProperties;
+import hanium.modic.backend.common.error.exception.LockException;
+import hanium.modic.backend.common.redis.distributedLock.LockManager;
+import java.util.concurrent.atomic.AtomicReference;
@@
-	private final VoteProperties voteProperties;
+	private final VoteProperties voteProperties;
 	private final TicketService ticketService;
+	private final LockManager lockManager;
@@
-	@Transactional
-	public VoteRewardResult processVoteReward(Long voteId, Long userId, VoteDecision userDecision) {
-		try {
-			VoteDecision trend = analyzeTrend(voteId);
-			boolean isCorrect = isCorrectAnswer(userDecision, trend);
-			// streak 업데이트 전 현재 값 확인
-			int currentStreak = userVoteStreakService.getStreakCount(userId);
-			boolean willReceiveReward = isCorrect && (currentStreak + 1) >= voteProperties.getStreakRewardCount();
-
-			// streak 업데이트 (3연속 정답 시 자동 초기화 포함)
-			userVoteStreakService.updateStreakWithReset(userId, isCorrect);
-
-			boolean receivedTicket = false;
-			if (willReceiveReward) {
-				ticketService.giveRewardTicket(userId);
-				receivedTicket = true;
-			}
-
-			// 업데이트 후 streak 값 (리워드 받았으면 0, 아니면 업데이트된 값)
-			int finalStreak = willReceiveReward ? 0 : userVoteStreakService.getStreakCount(userId);
-			log.info("투표 리워드 처리 완료: voteId={}, userId={}, userDecision={}, trend={}, isCorrect={}",
-				voteId, userId, userDecision, trend, isCorrect);
-			return new VoteRewardResult(isCorrect, finalStreak, receivedTicket);
-		} catch (Exception e) {
-			log.error("투표 리워드 처리 중 오류 발생: voteId={}, userId={}", voteId, userId, e);
-			return new VoteRewardResult(false, userVoteStreakService.getStreakCount(userId), false);
-		}
-	}
+	@Transactional
+	public VoteRewardResult processVoteReward(Long voteId, Long userId, VoteDecision userDecision) {
+		try {
+			AtomicReference<VoteRewardResult> ref = new AtomicReference<>();
+			lockManager.voteStreakLock(userId, () -> {
+				VoteDecision trend = analyzeTrend(voteId);
+				boolean isCorrect = isCorrectAnswer(userDecision, trend);
+				int current = userVoteStreakService.getStreakCount(userId);
+				boolean received = false;
+				int finalStreak;
+
+				if (isCorrect) {
+					if (current + 1 >= voteProperties.getStreakRewardCount()) {
+						// 보상 성공 후 리셋
+						ticketService.giveRewardTicket(userId);
+						received = true;
+						userVoteStreakService.updateStreakWithResetWithoutLock(userId, true);
+						finalStreak = 0;
+					} else {
+						userVoteStreakService.updateStreakWithResetWithoutLock(userId, true);
+						finalStreak = current + 1;
+					}
+				} else {
+					userVoteStreakService.updateStreakWithResetWithoutLock(userId, false);
+					finalStreak = 0;
+				}
+
+				log.info("투표 리워드 처리 완료: voteId={}, userId={}, userDecision={}, trend={}, isCorrect={}, finalStreak={}, receivedTicket={}",
+					voteId, userId, userDecision, trend, isCorrect, finalStreak, received);
+				ref.set(new VoteRewardResult(isCorrect, finalStreak, received));
+			});
+			return ref.get();
+		} catch (LockException e) {
+			log.error("투표 리워드 락 실패: voteId={}, userId={}", voteId, userId, e);
+			return new VoteRewardResult(false, userVoteStreakService.getStreakCount(userId), false);
+		} catch (Exception e) {
+			log.error("투표 리워드 처리 중 오류 발생: voteId={}, userId={}", voteId, userId, e);
+			return new VoteRewardResult(false, userVoteStreakService.getStreakCount(userId), false);
+		}
+	}

보완 권장:

  • TicketService 보상 지급을 “사용자·유형별 유니크 키”로 멱등 처리(중복 시 no-op)하면 추가 안전망이 됩니다.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/main/java/hanium/modic/backend/domain/vote/service/VoteRewardService.java
around lines 43-69, the current flow reads streak, updates/reset streak, and
issues tickets outside a single user-level lock which allows TOCTOU races and
inconsistent state; change it to acquire voteStreakLock(userId) for the whole
reward decision + issuance + reset sequence: inside the lock read current
streak, compute willReceiveReward, if willReceiveReward call
ticketService.giveRewardTicket(userId) and only after successful ticket issuance
call userVoteStreakService.updateStreakWithResetWithoutLock(userId, true) (or
reset method) so reset happens after confirmed reward; if no reward, call
updateStreakWithResetWithoutLock(userId, isCorrect) as appropriate; compute
finalStreak from the value returned or by reading streak inside the same lock,
release lock, and keep exception handling/logging consistent; optionally make
TicketService idempotent by unique user+type key to prevent duplicate tickets.

Comment on lines +55 to +59
boolean receivedTicket = false;
if (willReceiveReward) {
ticketService.giveRewardTicket(userId);
receivedTicket = true;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

🧩 Analysis chain

보상 지급 멱등성 확인

giveRewardTicket(userId)가 중복 호출 시 단일 지급만 보장되는지(유니크 제약/비즈니스 키) 확인 필요. 락 보완 전까지는 특히 중요합니다.

아래 스크립트로 구현을 추적해 보세요:


🏁 Script executed:

#!/bin/bash
rg -nP 'class\s+TicketService\b|giveRewardTicket\s*\(' -C3 --type=java

Length of output: 5871


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== rg matches for relevant symbols =="
rg -nP 'class\s+(TicketService|TicketEntity)\b|interface\s+TicketRepository\b|class\s+TicketRepository\b|interface\s+LockManager\b|class\s+LockManager\b|aiRequestTicketLock|giveRewardTicket|getTicketEntity' --hidden --type=java || true
echo

# Print TicketService
TS="src/main/java/hanium/modic/backend/domain/ticket/service/TicketService.java"
if [ -f "$TS" ]; then
  echo "----- $TS -----"
  sed -n '1,400p' "$TS" || true
  echo
fi

# Find and print TicketEntity files
ENT_FILES=$(rg -n --hidden --files-with-matches 'class\s+TicketEntity\b' --type=java || true)
if [ -n "$ENT_FILES" ]; then
  for f in $ENT_FILES; do
    echo "----- $f -----"
    sed -n '1,400p' "$f" || true
    echo
  done
else
  echo "No TicketEntity class file found by rg."
fi

# Find and print TicketRepository files
REP_FILES=$(rg -n --hidden --files-with-matches 'interface\s+TicketRepository\b|class\s+TicketRepository\b' --type=java || true)
if [ -n "$REP_FILES" ]; then
  for f in $REP_FILES; do
    echo "----- $f -----"
    sed -n '1,300p' "$f" || true
    echo
  done
else
  echo "No TicketRepository file found by rg."
fi

# Find and print LockManager files
LOCK_FILES=$(rg -n --hidden --files-with-matches 'interface\s+LockManager\b|class\s+LockManager\b|aiRequestTicketLock' --type=java || true)
if [ -n "$LOCK_FILES" ]; then
  for f in $LOCK_FILES; do
    echo "----- $f -----"
    sed -n '1,300p' "$f" || true
    echo
  done
else
  echo "No LockManager file found by rg."
fi

# Search for repository methods related to userId / unique constraints on Ticket
echo "== search for 'userId' or '@Table' or 'unique' in ticket package =="
rg -n --hidden 'userId|@Table|uniqueConstraint|uniqueConstraints|@Column\(|unique' src/main/java/hanium/modic/backend/domain/ticket -S || true

Length of output: 16211


멱등성 미보장 — giveRewardTicket 중복 호출 시 티켓이 누적 지급됩니다

aiRequestTicketLock로 동시성은 직렬화되고 TicketEntity.user_id에 DB 유니크 제약이 있지만, giveRewardTicket는 ticketCount를 단순 증가시키므로 재시도/중복 이벤트에 대해 단일 지급을 보장하지 않습니다.

  • 권장: 지급 이력(reward_log)에 (user_id, reward_type/기준) 유니크 제약 추가하거나 giveRewardTicket 내부에서 '이미 지급됨' 체크(지급 타임스탬프 또는 플래그) 후 조건부 지급으로 멱등성 확보.
  • 관련 위치: src/main/java/hanium/modic/backend/domain/ticket/service/TicketService.java (giveRewardTicket), src/main/java/hanium/modic/backend/domain/ticket/entity/TicketEntity.java (increaseTicket), 락: src/main/java/hanium/modic/backend/common/redis/distributedLock/LockManager.java (aiRequestTicketLock).
🤖 Prompt for AI Agents
In src/main/java/hanium/modic/backend/domain/vote/service/VoteRewardService.java
around lines 55-59, the current call to ticketService.giveRewardTicket(userId)
is not idempotent and may double-credit tickets on retries; modify the flow so
the service only issues a ticket if a persisted "reward_log" record for this
(user_id, reward_type or reward_criteria) does not already exist. Concretely:
add a reward_log table/entity with a unique constraint on (user_id,
reward_type/criteria) and persist it in the same transaction as increasing the
ticket; or update TicketService.giveRewardTicket to first check/infer an
existing reward record (or a paid flag/timestamp) and perform a conditional
increment (INSERT reward_log OR UPDATE with WHERE not exists / use SQL INSERT
... ON CONFLICT DO NOTHING and only increment when insert succeeded) to
guarantee single issuance. Ensure changes are applied atomically (transaction)
and reference TicketService.giveRewardTicket, TicketEntity.increaseTicket, and
the aiRequestTicketLock usage to keep locking/consistency.

@yooooonshine yooooonshine merged commit a474ec5 into develop Sep 22, 2025
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

투표 리워드 시스템 구현

2 participants