Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,19 @@ public class VoteProperties {
private int humanVoteWeight;
private Boolean enableAiAssessment;
private int maxVotesPerUserPerDay;

/**
* 연속 정답 리워드 지급 기준 횟수 (기본값 3)
*/
private int streakRewardCount = 3;

/**
* 연속 정답 카운트 TTL (일, 기본값 30)
*/
private int streakTtlDays = 30;

/**
* 리워드 지급 시 증가하는 티켓 수 (기본값 1)
*/
private int rewardTicketCount = 1;
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public class LockManager {
private final String POST_LIKE_PREFIX = "lock:postlike:";
private final String AI_PERMISSION_PREFIX = "lock:ai:perm:";
private final String VOTE_SUMMARY_PREFIX = "lock:vote:summary:";
private final String VOTE_STREAK_PREFIX = "lock:vote:streak:";

public void userLock(long userId, Runnable block) throws LockException {
exec.withLock(USER_PREFIX + userId, block);
Expand Down Expand Up @@ -58,4 +59,8 @@ public void aiImagePermissionLock(long userId, long postId, Runnable block) thro
public void voteSummaryLock(long voteId, Runnable block) throws LockException {
exec.withLock(VOTE_SUMMARY_PREFIX + voteId, block);
}

public void voteStreakLock(long userId, Runnable block) throws LockException {
exec.withLock(VOTE_STREAK_PREFIX + userId, block);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ public void resetTickets() {
this.lastIssuedAt = LocalDateTime.now();
}

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

this.ticketCount += amount;
}

Comment on lines +61 to +69
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.

public boolean isTicketExpired() {
return LocalDateTime.now().isAfter(this.lastIssuedAt.plusDays(1));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import hanium.modic.backend.common.error.exception.AppException;
import hanium.modic.backend.common.error.exception.LockException;
import hanium.modic.backend.common.redis.distributedLock.LockManager;
import hanium.modic.backend.common.property.property.VoteProperties;
import hanium.modic.backend.domain.ticket.entity.TicketEntity;
import hanium.modic.backend.domain.ticket.repository.TicketRepository;
import hanium.modic.backend.web.ticket.dto.response.GetTicketInformationResponse;
Expand All @@ -19,6 +20,7 @@ public class TicketService {

private final TicketRepository ticketRepository;
private final LockManager lockManager;
private final VoteProperties voteProperties;

// 티켓 관련 정보 조회
public GetTicketInformationResponse getTicketInformation(Long userId) {
Expand Down Expand Up @@ -77,4 +79,18 @@ public TicketEntity createInitialTicket(Long userId) {

return ticketRepository.save(newUserTicket);
}

// 리워드 티켓 지급
@Transactional
public void giveRewardTicket(final long userId) {
try {
lockManager.aiRequestTicketLock(userId, () -> {
TicketEntity userTicket = getTicketEntity(userId);
userTicket.increaseTicket(voteProperties.getRewardTicketCount());
ticketRepository.save(userTicket);
});
} catch (LockException e) {
throw new AppException(AI_REQUEST_TICKET_PROCESSING_FAIL_EXCEPTION);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package hanium.modic.backend.domain.user.entity;

import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Builder
@RedisHash(value = "userVoteStreak", timeToLive = 60 * 60 * 24 * 7) // 7 days TTL
@NoArgsConstructor
@AllArgsConstructor
public class UserVoteStreak {

@Id
private Long userId;

@Builder.Default
private Integer streakCount = 0;

public void updateStreak(boolean isCorrect) {
this.streakCount = isCorrect ? this.streakCount + 1 : 0;
}

public void resetStreak() {
this.streakCount = 0;
}

public boolean shouldReceiveReward(int rewardThreshold) {
return this.streakCount >= rewardThreshold;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package hanium.modic.backend.domain.user.repository;

import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;

import hanium.modic.backend.domain.user.entity.UserVoteStreak;

@Repository
public interface UserVoteStreakRepository extends CrudRepository<UserVoteStreak, Long> {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package hanium.modic.backend.domain.user.service;

import org.springframework.stereotype.Service;

import hanium.modic.backend.common.error.exception.LockException;
import hanium.modic.backend.common.property.property.VoteProperties;
import hanium.modic.backend.common.redis.distributedLock.LockManager;
import hanium.modic.backend.domain.user.entity.UserVoteStreak;
import hanium.modic.backend.domain.user.repository.UserVoteStreakRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Service
@RequiredArgsConstructor
@Slf4j
public class UserVoteStreakService {

private final UserVoteStreakRepository streakRepository;
private final LockManager lockManager;
private final VoteProperties voteProperties;

public int getStreakCount(Long userId) {
return streakRepository.findById(userId)
.map(UserVoteStreak::getStreakCount)
.orElse(0);
}

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={}",
Comment on lines +35 to +41
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.

userId, currentStreak, streak.getStreakCount(), isCorrect);
});
} catch (LockException e) {
log.error("투표 연속 정답 업데이트 (리셋 포함) 락 실패: userId={}", userId, e);
throw new RuntimeException("투표 연속 정답 업데이트에 실패했습니다.", e);
}
}

private UserVoteStreak getOrCreateStreak(Long userId) {
return streakRepository.findById(userId)
.orElse(UserVoteStreak.builder().userId(userId).build());
}
Comment on lines +50 to +53
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.

}


Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package hanium.modic.backend.domain.vote.service;

public record VoteRewardResult(boolean isCorrectAnswer, int currentStreak, boolean receivedTicket) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package hanium.modic.backend.domain.vote.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import hanium.modic.backend.common.error.exception.AppException;
import hanium.modic.backend.common.property.property.VoteProperties;
import hanium.modic.backend.domain.user.service.UserVoteStreakService;
import hanium.modic.backend.domain.ticket.service.TicketService;
import hanium.modic.backend.domain.vote.entity.SimilarityVoteSummaryEntity;
import hanium.modic.backend.domain.vote.enums.VoteDecision;
import hanium.modic.backend.domain.vote.repository.SimilarityVoteSummaryRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import static hanium.modic.backend.common.error.ErrorCode.*;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Slf4j
public class VoteRewardService {

private final SimilarityVoteSummaryRepository voteSummaryRepository;
private final UserVoteStreakService userVoteStreakService;
private final VoteProperties voteProperties;
private final TicketService ticketService;

public VoteDecision analyzeTrend(Long voteId) {
SimilarityVoteSummaryEntity summary = voteSummaryRepository.findByVoteId(voteId)
.orElseThrow(() -> new AppException(VOTE_SUMMARY_NOT_FOUND_EXCEPTION));

Long approveWeight = summary.getApproveWeight();
Long denyWeight = summary.getDenyWeight();

return approveWeight >= denyWeight ? VoteDecision.APPROVE : VoteDecision.DENY;
}

public boolean isCorrectAnswer(VoteDecision userDecision, VoteDecision trend) {
return userDecision == trend;
}

@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;
}
Comment on lines +55 to +59
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.


// 업데이트 후 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);
}
Comment on lines +43 to +69
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.

}
}


Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public class VotingService {
private final PostEntityRepository postEntityRepository;
private final LockManager lockManager;
private final VoteProperties voteProperties;
private final VoteRewardService voteRewardService;

/**
* 투표 참여 메서드
Expand Down Expand Up @@ -92,8 +93,16 @@ public VoteParticipationResponse participateVote(Long voteId, Long userId, VoteD
checkAndCompleteVote(voteId);
});

// 9. 단순한 응답 생성
return VoteParticipationResponse.of(voteId);
// 9. 리워드 처리 (투표 참여 직후)
VoteRewardResult reward = voteRewardService.processVoteReward(voteId, userId, decision);

// 10. 응답 생성
return VoteParticipationResponse.of(
voteId,
reward.isCorrectAnswer(),
reward.currentStreak(),
reward.receivedTicket()
);
} catch (LockException e) {
log.error("투표 참여 락 획득 실패: voteId={}, userId={}", voteId, userId, e);
throw new AppException(VOTE_UPDATE_FAIL_EXCEPTION);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package hanium.modic.backend.web.vote.dto.response;

public record VoteParticipationResponse(Long voteId) {

public static VoteParticipationResponse of(Long voteId) {
return new VoteParticipationResponse(voteId);
public record VoteParticipationResponse(Long voteId, boolean isCorrectAnswer, int currentStreak,
boolean receivedTicket) {

public static VoteParticipationResponse of(Long voteId, boolean isCorrectAnswer, int currentStreak,
boolean receivedTicket) {
return new VoteParticipationResponse(voteId, isCorrectAnswer, currentStreak, receivedTicket);
}
}
Loading