Skip to content

Commit 52c2a88

Browse files
[EA3-134] refactor : 통계 계산/재계산 기능 추가 및 낙관적 락 충돌 처리, 유효성 검증 로직 추가
1 parent eaed49c commit 52c2a88

File tree

1 file changed

+113
-0
lines changed

1 file changed

+113
-0
lines changed
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,123 @@
11
package grep.neogulcoder.domain.timevote.service;
22

3+
import static grep.neogulcoder.domain.timevote.exception.code.TimeVoteErrorCode.*;
4+
5+
import grep.neogulcoder.domain.study.StudyMember;
6+
import grep.neogulcoder.domain.study.repository.StudyMemberRepository;
7+
import grep.neogulcoder.domain.timevote.dto.response.TimeVoteStatResponse;
8+
import grep.neogulcoder.domain.timevote.entity.TimeVotePeriod;
9+
import grep.neogulcoder.domain.timevote.entity.TimeVoteStat;
10+
import grep.neogulcoder.domain.timevote.repository.TimeVotePeriodRepository;
11+
import grep.neogulcoder.domain.timevote.repository.TimeVoteStatQueryRepository;
12+
import grep.neogulcoder.domain.timevote.repository.TimeVoteStatRepository;
13+
import grep.neogulcoder.global.exception.business.BusinessException;
14+
import jakarta.persistence.OptimisticLockException;
15+
import java.time.LocalDateTime;
16+
import java.util.List;
17+
import java.util.Map;
18+
import java.util.function.Function;
19+
import java.util.stream.Collectors;
320
import lombok.RequiredArgsConstructor;
21+
import lombok.extern.slf4j.Slf4j;
422
import org.springframework.stereotype.Service;
23+
import org.springframework.transaction.annotation.Transactional;
524

25+
@Slf4j
626
@Service
727
@RequiredArgsConstructor
28+
@Transactional
829
public class TimeVoteStatService {
930

31+
private final StudyMemberRepository studyMemberRepository;
32+
private final TimeVotePeriodRepository timeVotePeriodRepository;
33+
private final TimeVoteStatRepository timeVoteStatRepository;
34+
private final TimeVoteStatQueryRepository timeVoteStatQueryRepository;
35+
36+
@Transactional(readOnly = true)
37+
public TimeVoteStatResponse getStats(Long studyId, Long userId) {
38+
getValidStudyMember(studyId, userId);
39+
TimeVotePeriod period = getValidTimeVotePeriodByStudyId(studyId);
40+
41+
List<TimeVoteStat> stats = timeVoteStatRepository.findAllByPeriodId(period.getPeriodId());
42+
43+
validateStatTimeSlotsWithinPeriod(period, stats);
44+
45+
return TimeVoteStatResponse.from(period, stats);
46+
}
47+
48+
public void incrementStats(Long periodId, List<LocalDateTime> timeSlots) {
49+
log.info("[TimeVoteStatService] 투표 통계 계산 시작: periodId={}, timeSlots={}", periodId, timeSlots);
50+
TimeVotePeriod period = getValidTimeVotePeriodByPeriodId(periodId);
51+
52+
Map<LocalDateTime, Long> countMap = timeSlots.stream()
53+
.collect(Collectors.groupingBy(Function.identity(), Collectors.counting()));
54+
55+
countMap.forEach((slot, count) -> {
56+
boolean success = false;
57+
int retry = 0;
58+
59+
while (!success && retry < 3) {
60+
try {
61+
timeVoteStatQueryRepository.incrementOrInsert(period, slot, count);
62+
success = true;
63+
} catch (OptimisticLockException e) {
64+
retry++;
65+
log.warn("낙관적 락 충돌 발생: slot={} 재시도 {}/3", slot, retry); // 통계 동시성 충돌 시 재시도 (최대 3회)
66+
try {
67+
Thread.sleep(10L);
68+
} catch (InterruptedException e2) {
69+
Thread.currentThread().interrupt();
70+
throw new BusinessException(TIME_VOTE_THREAD_INTERRUPTED);
71+
}
72+
}
73+
}
74+
75+
if (!success) {
76+
throw new BusinessException(TIME_VOTE_STAT_CONFLICT);
77+
}
78+
});
79+
}
80+
81+
public void recalculateStats(Long periodId) {
82+
log.info("[TimeVoteStatService] 투표 통계 재계산 시작: periodId={}", periodId);
83+
synchronized (("recalc-lock:" + periodId).intern()) {
84+
TimeVotePeriod period = getValidTimeVotePeriodByPeriodId(periodId);
85+
86+
timeVoteStatRepository.deleteByPeriod(period);
87+
88+
List<TimeVoteStat> stats = timeVoteStatQueryRepository.countStatsByPeriod(period);
89+
90+
timeVoteStatRepository.saveAll(stats);
91+
}
92+
}
93+
94+
// ================================= 검증 로직 ================================
95+
// 투표 시 유효한 스터디 멤버인지 확인 (활성화된 멤버만 허용)
96+
private StudyMember getValidStudyMember(Long studyId, Long userId) {
97+
return studyMemberRepository.findByStudyIdAndUserIdAndActivatedTrue(studyId, userId)
98+
.orElseThrow(() -> new BusinessException(STUDY_MEMBER_NOT_FOUND));
99+
}
100+
101+
// studyId 기준으로 스터디에 등록된 가장 최신의 투표 기간 정보 조회 (없으면 예외)
102+
private TimeVotePeriod getValidTimeVotePeriodByStudyId(Long studyId) {
103+
return timeVotePeriodRepository.findTopByStudyIdOrderByStartDateDesc(studyId)
104+
.orElseThrow(() -> new BusinessException(TIME_VOTE_PERIOD_NOT_FOUND));
105+
}
106+
107+
// periodId 기준으로 투표 기간이 존재하는지 정보 조회
108+
private TimeVotePeriod getValidTimeVotePeriodByPeriodId(Long periodId) {
109+
return timeVotePeriodRepository.findById(periodId)
110+
.orElseThrow(() -> new BusinessException(TIME_VOTE_PERIOD_NOT_FOUND));
111+
}
112+
113+
// 통계에 포함된 각 시간대가 기간 안에 들어가는지 확인
114+
private void validateStatTimeSlotsWithinPeriod(TimeVotePeriod period, List<TimeVoteStat> stats) {
115+
boolean invalid = stats.stream()
116+
.map(TimeVoteStat::getTimeSlot)
117+
.anyMatch(slot -> slot.isBefore(period.getStartDate()) || slot.isAfter(period.getEndDate()));
118+
119+
if (invalid) {
120+
throw new BusinessException(TIME_VOTE_OUT_OF_RANGE);
121+
}
122+
}
10123
}

0 commit comments

Comments
 (0)