Conversation
- 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>
Walkthrough투표 리워드 기능이 추가되며, 연속 정답 관리( Redis + 분산락), 티켓 보상 지급, 투표 응답 DTO 확장, 그리고 기존 투표 플로우에 리워드 처리 통합이 이루어졌습니다. 구성 프로퍼티와 락 매니저가 확장되었고, 관련 서비스/엔터티/테스트가 신규로 도입되었습니다. Changes
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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
Pre-merge checks and finishing touches❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing touches
🧪 Generate unit tests
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. Comment |
There was a problem hiding this comment.
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에 대해 동시에
processVoteReward2회 호출 시 단 1회만 보상되는지 확인하는 테스트(CountDownLatch/Executor) 추가를 권장합니다.원하시면 테스트 템플릿을 드리겠습니다.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 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예외 시 업데이트/보상 미수행을 검증하고 있어 적절합니다.
| // 리워드 티켓 지급 (발급 시간은 변경하지 않음) | ||
| public void increaseTicket(final long amount) { | ||
| if (amount <= 0) { | ||
| return; | ||
| } | ||
|
|
||
| this.ticketCount += amount; | ||
| } | ||
|
|
There was a problem hiding this comment.
🧩 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" || trueLength 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.
| // 3연속 정답 달성 시 초기화 | ||
| if (isCorrect && streak.getStreakCount() >= voteProperties.getStreakRewardCount()) { | ||
| streak.resetStreak(); | ||
| } | ||
|
|
||
| streakRepository.save(streak); | ||
| log.debug("연속 정답 업데이트 (리셋 포함): userId={}, current={}, next={}, isCorrect={}", |
There was a problem hiding this comment.
리셋 시점이 보상 발급과 분리되어 원자성이 깨질 수 있습니다
현재 이 메서드는 임계치 도달 시 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.
| // 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.
| private UserVoteStreak getOrCreateStreak(Long userId) { | ||
| return streakRepository.findById(userId) | ||
| .orElse(UserVoteStreak.builder().userId(userId).build()); | ||
| } |
There was a problem hiding this comment.
🧩 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=javaLength 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.
| @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); | ||
| } |
There was a problem hiding this comment.
치명적 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.
| boolean receivedTicket = false; | ||
| if (willReceiveReward) { | ||
| ticketService.giveRewardTicket(userId); | ||
| receivedTicket = true; | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
보상 지급 멱등성 확인
giveRewardTicket(userId)가 중복 호출 시 단일 지급만 보장되는지(유니크 제약/비즈니스 키) 확인 필요. 락 보완 전까지는 특히 중요합니다.
아래 스크립트로 구현을 추적해 보세요:
🏁 Script executed:
#!/bin/bash
rg -nP 'class\s+TicketService\b|giveRewardTicket\s*\(' -C3 --type=javaLength 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 || trueLength 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.
Summary
투표 리워드 시스템을 처음부터 완전히 구현하여 사용자의 연속 정답 달성에 따른 티켓 지급 기능을 제공합니다.
🎯 핵심 기능
투표 리워드 시스템 구현
🔒 동시성 제어 및 안정성
⚙️ 설정 기반 운영
📁 주요 변경사항
새로 추가된 컴포넌트
VoteRewardService- 투표 리워드 처리 핵심 로직UserVoteStreakService- 사용자 연속 정답 관리UserVoteStreak- Redis 엔티티로 연속 정답 데이터 저장VoteRewardResult- 리워드 처리 결과 반환 객체VoteProperties- 리워드 관련 설정값 관리기존 컴포넌트 확장
VotingService- 투표 참여 시 리워드 처리 통합TicketService- 리워드 티켓 지급 기능 추가LockManager- vote streak 전용 분산 락 추가VoteParticipationResponse- 리워드 정보 응답 확장🏗️ 아키텍처 특징
📊 처리 흐름
📈 성과
Test plan
Closes #175
Summary by CodeRabbit
New Features
Chores
Tests