From ad3a3509db60058d44be159fae9f499daa6a8306 Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Wed, 18 Feb 2026 19:43:25 +0900 Subject: [PATCH 01/14] =?UTF-8?q?build:=20redisson=20=EC=A2=85=EC=86=8D?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 721a695fe2dcaa9ae3a1139716f7c257ff0a9ebf) --- build.gradle | 1 + .../global/common/config/RedisConfig.java | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 src/main/java/OneQ/OnSurvey/global/common/config/RedisConfig.java diff --git a/build.gradle b/build.gradle index ab893a61..3e309712 100644 --- a/build.gradle +++ b/build.gradle @@ -50,6 +50,7 @@ dependencies { // Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.redisson:redisson-spring-boot-starter:3.17.7' // QueryDSL implementation 'io.github.openfeign.querydsl:querydsl-core:7.0' diff --git a/src/main/java/OneQ/OnSurvey/global/common/config/RedisConfig.java b/src/main/java/OneQ/OnSurvey/global/common/config/RedisConfig.java new file mode 100644 index 00000000..e27f166c --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/global/common/config/RedisConfig.java @@ -0,0 +1,29 @@ +package OneQ.OnSurvey.global.common.config; + +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Value("${spring.data.redis.password}") + private String password; + + @Bean + public RedissonClient redisson() { + Config config = new Config(); + config.useSingleServer().setAddress("redis://" + host + ":" + port).setPassword(password); + + return Redisson.create(config); + } +} From 26602e90a6b35b6384827397760f41e66cab315b Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Wed, 18 Feb 2026 20:04:21 +0900 Subject: [PATCH 02/14] =?UTF-8?q?mod:=20=EB=B6=84=EC=82=B0=EB=9D=BD?= =?UTF-8?q?=EC=9D=84=20=ED=86=B5=ED=95=9C=20=EC=84=A4=EB=AC=B8=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=20=EA=B0=80=EB=8A=A5=EC=97=AC=EB=B6=80=20=ED=8C=90?= =?UTF-8?q?=EB=8B=A8=20=EB=A1=9C=EC=A7=81=20=EB=8F=99=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=EC=9D=B4=EC=8A=88=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 8bb50110f46a7f229933623cebd18cfc800e121e) --- .../service/query/SurveyQueryService.java | 149 ++++++++++++------ 1 file changed, 103 insertions(+), 46 deletions(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQueryService.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQueryService.java index f2e72fbb..affcb9e2 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQueryService.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQueryService.java @@ -33,6 +33,8 @@ import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -44,6 +46,7 @@ import java.time.Duration; import java.time.LocalDateTime; import java.util.*; +import java.util.concurrent.TimeUnit; import java.util.function.Function; import java.util.stream.Collectors; @@ -57,6 +60,7 @@ public class SurveyQueryService implements SurveyQuery { private final StringRedisTemplate redisTemplate; + private final RedissonClient redisson; private final SurveyRepository surveyRepository; private final SurveyInfoRepository surveyInfoRepository; @@ -222,7 +226,7 @@ public ParticipationScreeningSingleResponse getScreeningSingleResponse(Long scre public ParticipationInfoResponse getParticipationInfo(Long surveyId, Long userKey, Long memberId) { log.info("[SURVEY:QUERY:getParticipationInfo] 설문 기본정보 조회 - surveyId: {}", surveyId); - if (checkValidSegmentation(surveyId, userKey)) { + if (checkValidSegmentation(surveyId, userKey) && AuthorizationUtils.validateOwnershipOrAdmin(surveyId, userKey)) { log.warn("[SURVEY:QUERY] 세그먼트 불일치로 인한 설문 응답 불가 - surveyId: {}, userKey: {}", surveyId, userKey); throw new CustomException(SurveyErrorCode.SURVEY_WRONG_SEGMENTATION); } @@ -254,9 +258,7 @@ public ParticipationInfoResponse getParticipationInfo(Long surveyId, Long userKe public ParticipationQuestionResponse getParticipationQuestionInfo(Long surveyId, Integer sectionOrder, Long userKey) { log.info("[SURVEY:QUERY] 설문 문항정보 조회 - surveyId: {}, userKey: {}", surveyId, userKey); - cleanupExpiredPotentials(surveyId); - - if (AuthorizationUtils.validateOwnershipOrAdmin(userKey, getLongValue(surveyId, this.creatorKey))) { + if (AuthorizationUtils.validateNonOwnershipOrAdmin(userKey, getLongValue(surveyId, this.creatorKey))) { log.warn("[SURVEY:QUERY] 설문 제작자는 참여 불가 - surveyId: {}, userKey: {}", surveyId, userKey); throw new CustomException(SurveyErrorCode.SURVEY_PARTICIPATION_OWN_SURVEY); } @@ -345,7 +347,7 @@ public void validateSurveyRequest(Long surveyId, Long memberId, SurveyStatus sta Survey survey = surveyRepository.getSurveyById(surveyId) .orElseThrow(() -> new CustomException(SurveyErrorCode.SURVEY_NOT_FOUND)); - if (AuthorizationUtils.validateOwnershipOrAdmin(survey.getMemberId(), memberId)) { + if (AuthorizationUtils.validateNonOwnershipOrAdmin(survey.getMemberId(), memberId)) { log.warn("[SURVEY:QUERY:VALIDATE] 접근 권한 없음 - surveyId: {}, memberId: {}, surveyMemberId: {}", surveyId, memberId, survey.getMemberId()); throw new CustomException(SurveyErrorCode.SURVEY_FORBIDDEN); @@ -358,74 +360,121 @@ public void validateSurveyRequest(Long surveyId, Long memberId, SurveyStatus sta } } + /** + * 설문 접근 가능 여부 판단 + * + * @return 설문상태 == 진행중 : true + *

설문상태 != 진행중 : false + */ private boolean isSurveyAccessible(SurveyStatus status) { return ONGOING.equals(status); } - /* 활성 사용자 등록 및 등록가능 여부 판단 (true: 가능, false: 불가능) */ + /** + * 활성 참여자 등록 및 등록가능 여부 판단 + * @return 등록가능: true + *

등록불가능: false + */ private boolean isActivationAvailable(Long surveyId, Long userKey) { log.info("[SURVEY:QUERY] 활성 참여자 등록 및 등록가능 여부 판단 - surveyId: {}, userKey: {}", surveyId, userKey); + String lockKey = "lock:survey:" + surveyId; + RLock lock = redisson.getLock(lockKey); + String potentialKey = this.potentialKey + surveyId; String memberValue = String.valueOf(userKey); Double existingScore = redisTemplate.opsForZSet().score(potentialKey, memberValue); + Integer dueCount = getIntValue(surveyId, this.dueCountKey); + /* dueCount가 설정되어 있지 않을 경우 0으로 반환되므로 이를 설정해줄 필요가 있음. (임의로 시작된 설문 등에 대한 방어코드) */ + if (dueCount == 0) { + dueCount = initialDueCount(surveyId); + } - // 새로운 참여자인 경우 - // TODO : 원자성을 유지하도록 수정 필요 - if (existingScore == null) { - Integer dueCount = getIntValue(surveyId, this.dueCountKey); - if (dueCount == 0) { - SurveyInfo surveyInfo = surveyInfoRepository.findBySurveyId(surveyId) - .orElseThrow(() -> new CustomException(SurveyErrorCode.SURVEY_INFO_NOT_FOUND)); + try { + boolean available = lock.tryLock(5, 10, TimeUnit.SECONDS); + if (!available) { + log.warn("[SURVEY:QUERY] 설문 참여자 등록을 위한 락 획득 실패 - surveyId: {}, userKey: {}", surveyId, userKey); + throw new CustomException(SurveyErrorCode.SURVEY_PARTICIPATION_TEMP_EXCEEDED); + } - Survey survey = surveyRepository.getSurveyById(surveyId) - .orElseThrow(() -> new CustomException(SurveyErrorCode.SURVEY_NOT_FOUND)); + // 새로운 참여자인 경우 + if (existingScore == null) { + long activePotentialCount = getZSetCount(potentialKey, System.currentTimeMillis() - potentialDuration.toMillis()); + long completedCount = getIntValue(surveyId, this.completedKey); - Duration duration = Duration.between( - LocalDateTime.now(), - survey.getDeadline() - ); - redisTemplate.opsForValue().set(this.dueCountKey + surveyId, String.valueOf(surveyInfo.getDueCount()), duration); - dueCount = surveyInfo.getDueCount(); + if (activePotentialCount + 1 + completedCount > dueCount) { + return false; + } + + // Sorted Set에 현재 시간을 score로 사용자 추가 + redisTemplate.opsForZSet().add(potentialKey, memberValue, System.currentTimeMillis()); + } else { + // 기존 참여자 - score 갱신 + redisTemplate.opsForZSet().add(potentialKey, memberValue, System.currentTimeMillis()); } - if (!isEnough(surveyId, dueCount)) { - return false; + } catch (InterruptedException e) { + log.error("[SURVEY:QUERY] 설문 참여자 등록 락 획득 중 에러가 발생했습니다."); + Thread.currentThread().interrupt(); + throw new CustomException(SurveyErrorCode.SURVEY_PARTICIPATION_TEMP_EXCEEDED); + } finally { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); } - // Sorted Set에 현재 시간을 score로 사용자 추가 - redisTemplate.opsForZSet().add(potentialKey, memberValue, System.currentTimeMillis()); - } else { - // 기존 참여자 - score 갱신 - redisTemplate.opsForZSet().add(potentialKey, memberValue, System.currentTimeMillis()); + try { + redisTemplate.opsForZSet().removeRangeByScore( + this.potentialKey + surveyId, + 0, + System.currentTimeMillis() - potentialDuration.toMillis() + ); + } catch (Exception ignore) { + log.warn("[SURVEY:QUERY] 만료된 참여자 정리 중 오류 발생", ignore); + } } - return true; } - /* 타임아웃된 참여자를 Sorted Set에서 제거 */ - private void cleanupExpiredPotentials(Long surveyId) { - long expirationTime = System.currentTimeMillis() - potentialDuration.toMillis(); + private Integer initialDueCount(Long surveyId) { + String lockKey = "lock:survey:" + surveyId; + RLock lock = redisson.getLock(lockKey); + try { + boolean initAvailable = lock.tryLock(3, 6, TimeUnit.SECONDS); - // score가 expirationTime 이전인 모든 멤버 제거 - Long removedCount = redisTemplate.opsForZSet().removeRangeByScore(this.potentialKey + surveyId, 0, expirationTime); - if (removedCount != null && removedCount > 0) { - log.debug("[SURVEY:QUERY] {}에서 만료된 사용자 {}명 제거", this.potentialKey + surveyId, removedCount); + if (!initAvailable) { + log.warn("[SURVEY:QUERY] 설문 참여자 등록을 위한 락 획득 실패 - surveyId: {}", surveyId); + throw new CustomException(SurveyErrorCode.SURVEY_PARTICIPATION_TEMP_EXCEEDED); + } + + int dueCount = getIntValue(surveyId, this.dueCountKey); + // 다른 스레드에서 값이 설정된 경우, 재조회하지 않고 그대로 값 반환하도록 더블체크 + if (dueCount > 0) { + return dueCount; + } + + return setDueCount(surveyId); + } catch (InterruptedException e) { + log.error("[SURVEY:QUERY] 설문 참여가능 인원 초기화 락 획득 중 에러가 발생했습니다."); + Thread.currentThread().interrupt(); + throw new CustomException(SurveyErrorCode.SURVEY_PARTICIPATION_TEMP_EXCEEDED); + } finally { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } } } - private boolean isEnough(Long surveyId, int maxParticipants) { - // 현재 활성 참여자 수 (Sorted Set 크기 + 현재 사용자) - int potential = getZSetIntValue(surveyId, this.potentialKey) + 1; - // 현재 완료된 참여자 수 - int completed = getIntValue(surveyId, this.completedKey); - - boolean result = potential + completed <= maxParticipants; + private Integer setDueCount(Long surveyId) { + SurveyInfo surveyInfo = surveyInfoRepository.findBySurveyId(surveyId) + .orElseThrow(() -> new CustomException(SurveyErrorCode.SURVEY_INFO_NOT_FOUND)); + Survey survey = surveyRepository.getSurveyById(surveyId) + .orElseThrow(() -> new CustomException(SurveyErrorCode.SURVEY_NOT_FOUND)); - log.info("[SURVEY:QUERY] 활성 사용자 등록가능 여부 판단 - surveyId: {}, potential: {}, completed: {}, dueCount: {}, isEnough: {}", - surveyId, potential, completed, maxParticipants, result); + Duration duration = Duration.between( + LocalDateTime.now(), survey.getDeadline()); - return result; + redisTemplate.opsForValue().set(this.dueCountKey + surveyId, String.valueOf(surveyInfo.getDueCount()), duration); + return surveyInfo.getDueCount(); } private int getZSetIntValue(Long surveyId, String keyPrefix) { @@ -433,6 +482,12 @@ private int getZSetIntValue(Long surveyId, String keyPrefix) { return value != null ? value.intValue() : 0; } + private long getZSetCount(String key, Long threshold) { + Long value = redisTemplate.opsForZSet().count(key, threshold, Double.MAX_VALUE); + + return value != null ? value : 0L; + } + private long getLongValue(Long surveyId, String keyPrefix) { String value = redisTemplate.opsForValue().get(keyPrefix + surveyId); return value != null ? Long.parseLong(value) : 0; @@ -454,6 +509,8 @@ public boolean checkValidSegmentation(Long surveyId, Long userKey) { throw new CustomException(SurveyErrorCode.SURVEY_WRONG_SEGMENTATION); } + log.info("{}", !(checkAgeSegmentation(surveySegmentation.getAges(), memberSegmentation.convertBirthDayIntoAgeRange()) + && checkGenderSegmentation(surveySegmentation.getGender(), memberSegmentation.getGender()))); return !(checkAgeSegmentation(surveySegmentation.getAges(), memberSegmentation.convertBirthDayIntoAgeRange()) && checkGenderSegmentation(surveySegmentation.getGender(), memberSegmentation.getGender())); // || checkResidenceSegmentation(surveySegmentation.residence(), memberSegmentation.residence()); From 31a3417582910db7f5d51ac542b3d1b2edc75456 Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Wed, 18 Feb 2026 21:49:29 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat:=20Redis=20=EB=9D=BD/=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=ED=83=80=EC=9E=85=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=EC=9C=A0=ED=8B=B8=EB=A6=AC?= =?UTF-8?q?=ED=8B=B0=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit e7a7c1d62c366e462c94e78a4ba74e1c578b5804) --- build.gradle | 2 +- .../service/query/SurveyQueryService.java | 136 +++++++---------- .../global/common/util/RedisUtils.java | 141 ++++++++++++++++++ 3 files changed, 192 insertions(+), 87 deletions(-) create mode 100644 src/main/java/OneQ/OnSurvey/global/common/util/RedisUtils.java diff --git a/build.gradle b/build.gradle index 3e309712..a6252a57 100644 --- a/build.gradle +++ b/build.gradle @@ -50,7 +50,7 @@ dependencies { // Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' - implementation 'org.redisson:redisson-spring-boot-starter:3.17.7' + implementation 'org.redisson:redisson-spring-boot-starter:3.52.0' // QueryDSL implementation 'io.github.openfeign.querydsl:querydsl-core:7.0' diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQueryService.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQueryService.java index affcb9e2..c758bee8 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQueryService.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQueryService.java @@ -30,23 +30,21 @@ import OneQ.OnSurvey.global.common.exception.CustomException; import OneQ.OnSurvey.global.common.exception.ErrorCode; import OneQ.OnSurvey.global.common.util.AuthorizationUtils; +import OneQ.OnSurvey.global.common.util.RedisUtils; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.redisson.api.RLock; -import org.redisson.api.RedissonClient; +import org.redisson.client.RedisException; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; -import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.Duration; import java.time.LocalDateTime; import java.util.*; -import java.util.concurrent.TimeUnit; import java.util.function.Function; import java.util.stream.Collectors; @@ -59,9 +57,6 @@ @Transactional(readOnly = true) public class SurveyQueryService implements SurveyQuery { - private final StringRedisTemplate redisTemplate; - private final RedissonClient redisson; - private final SurveyRepository surveyRepository; private final SurveyInfoRepository surveyInfoRepository; private final ScreeningRepository screeningRepository; @@ -239,7 +234,7 @@ public ParticipationInfoResponse getParticipationInfo(Long surveyId, Long userKe throw new CustomException(SurveyErrorCode.SURVEY_INCORRECT_STATUS); } - int completedCount = getIntValue(surveyId, this.completedKey); + int completedCount = RedisUtils.getIntValue(this.completedKey + surveyId); ParticipationStatus participationStatus = surveyRepository.getParticipationStatus(surveyId, memberId); if (participationStatus.isScreenRequired()) { log.warn("[SURVEY:QUERY] 스크리닝 퀴즈 응답이 필요합니다. - surveyId: {}, memberId: {}", surveyId, memberId); @@ -258,7 +253,7 @@ public ParticipationInfoResponse getParticipationInfo(Long surveyId, Long userKe public ParticipationQuestionResponse getParticipationQuestionInfo(Long surveyId, Integer sectionOrder, Long userKey) { log.info("[SURVEY:QUERY] 설문 문항정보 조회 - surveyId: {}, userKey: {}", surveyId, userKey); - if (AuthorizationUtils.validateNonOwnershipOrAdmin(userKey, getLongValue(surveyId, this.creatorKey))) { + if (AuthorizationUtils.validateNonOwnershipOrAdmin(userKey, RedisUtils.getLongValue(this.creatorKey + surveyId))) { log.warn("[SURVEY:QUERY] 설문 제작자는 참여 불가 - surveyId: {}, userKey: {}", surveyId, userKey); throw new CustomException(SurveyErrorCode.SURVEY_PARTICIPATION_OWN_SURVEY); } @@ -378,89 +373,79 @@ private boolean isSurveyAccessible(SurveyStatus status) { private boolean isActivationAvailable(Long surveyId, Long userKey) { log.info("[SURVEY:QUERY] 활성 참여자 등록 및 등록가능 여부 판단 - surveyId: {}, userKey: {}", surveyId, userKey); - String lockKey = "lock:survey:" + surveyId; - RLock lock = redisson.getLock(lockKey); - - String potentialKey = this.potentialKey + surveyId; - String memberValue = String.valueOf(userKey); + final String potentialKey = this.potentialKey + surveyId; + final String memberValue = String.valueOf(userKey); - Double existingScore = redisTemplate.opsForZSet().score(potentialKey, memberValue); - Integer dueCount = getIntValue(surveyId, this.dueCountKey); + Integer dueCount = RedisUtils.getIntValue(this.dueCountKey + surveyId); /* dueCount가 설정되어 있지 않을 경우 0으로 반환되므로 이를 설정해줄 필요가 있음. (임의로 시작된 설문 등에 대한 방어코드) */ if (dueCount == 0) { dueCount = initialDueCount(surveyId); } + boolean result; try { - boolean available = lock.tryLock(5, 10, TimeUnit.SECONDS); - if (!available) { - log.warn("[SURVEY:QUERY] 설문 참여자 등록을 위한 락 획득 실패 - surveyId: {}, userKey: {}", surveyId, userKey); - throw new CustomException(SurveyErrorCode.SURVEY_PARTICIPATION_TEMP_EXCEEDED); - } - - // 새로운 참여자인 경우 - if (existingScore == null) { - long activePotentialCount = getZSetCount(potentialKey, System.currentTimeMillis() - potentialDuration.toMillis()); - long completedCount = getIntValue(surveyId, this.completedKey); - - if (activePotentialCount + 1 + completedCount > dueCount) { - return false; + Integer finalDueCount = dueCount; + result = RedisUtils.executeWithLock("lock:survey:" + surveyId, 5, 10, () -> { + Double existingScore = RedisUtils.getZSetScore(potentialKey, memberValue); + + // 새로운 참여자인 경우 + if (existingScore == null) { + long activePotentialCount = RedisUtils.getZSetCount( + potentialKey, + System.currentTimeMillis() - potentialDuration.toMillis(), + Long.MAX_VALUE + ); + int completedCount = RedisUtils.getIntValue(this.completedKey + surveyId); + + if (activePotentialCount + 1 + completedCount > finalDueCount) { + return false; + } + + // Sorted Set에 현재 시간을 score로 사용자 추가 + RedisUtils.addToZSet(potentialKey, memberValue, System.currentTimeMillis()); + } else { + // 기존 참여자 - score 갱신 + RedisUtils.addToZSet(potentialKey, memberValue, System.currentTimeMillis()); } - - // Sorted Set에 현재 시간을 score로 사용자 추가 - redisTemplate.opsForZSet().add(potentialKey, memberValue, System.currentTimeMillis()); - } else { - // 기존 참여자 - score 갱신 - redisTemplate.opsForZSet().add(potentialKey, memberValue, System.currentTimeMillis()); - } + return true; + }); + } catch (RedisException e) { + log.warn("[SURVEY:QUERY] 설문 참여자 등록을 위한 락 획득 실패 - surveyId: {}, userKey: {}", surveyId, userKey); + throw new CustomException(SurveyErrorCode.SURVEY_PARTICIPATION_TEMP_EXCEEDED); } catch (InterruptedException e) { log.error("[SURVEY:QUERY] 설문 참여자 등록 락 획득 중 에러가 발생했습니다."); Thread.currentThread().interrupt(); throw new CustomException(SurveyErrorCode.SURVEY_PARTICIPATION_TEMP_EXCEEDED); } finally { - if (lock.isHeldByCurrentThread()) { - lock.unlock(); - } - try { - redisTemplate.opsForZSet().removeRangeByScore( - this.potentialKey + surveyId, - 0, - System.currentTimeMillis() - potentialDuration.toMillis() - ); + RedisUtils.rangeRemoveFromZSet(potentialKey, 0, System.currentTimeMillis() - potentialDuration.toMillis()); } catch (Exception ignore) { log.warn("[SURVEY:QUERY] 만료된 참여자 정리 중 오류 발생", ignore); } } - return true; + + return result; } private Integer initialDueCount(Long surveyId) { - String lockKey = "lock:survey:" + surveyId; - RLock lock = redisson.getLock(lockKey); try { - boolean initAvailable = lock.tryLock(3, 6, TimeUnit.SECONDS); + return RedisUtils.executeWithLock("lock:survey:" + surveyId, 3, 6, () -> { + int dueCount = RedisUtils.getIntValue(this.dueCountKey + surveyId); - if (!initAvailable) { - log.warn("[SURVEY:QUERY] 설문 참여자 등록을 위한 락 획득 실패 - surveyId: {}", surveyId); - throw new CustomException(SurveyErrorCode.SURVEY_PARTICIPATION_TEMP_EXCEEDED); - } - - int dueCount = getIntValue(surveyId, this.dueCountKey); - // 다른 스레드에서 값이 설정된 경우, 재조회하지 않고 그대로 값 반환하도록 더블체크 - if (dueCount > 0) { - return dueCount; - } + // 다른 스레드에서 값이 설정된 경우, 재조회하지 않고 그대로 값 반환하도록 더블체크 + if (dueCount > 0) { + return dueCount; + } - return setDueCount(surveyId); + return setDueCount(surveyId); + }); + } catch (RedisException e) { + log.warn("[SURVEY:QUERY] 설문 참여자 등록을 위한 락 획득 실패 - surveyId: {}", surveyId); + throw new CustomException(SurveyErrorCode.SURVEY_PARTICIPATION_TEMP_EXCEEDED); } catch (InterruptedException e) { log.error("[SURVEY:QUERY] 설문 참여가능 인원 초기화 락 획득 중 에러가 발생했습니다."); Thread.currentThread().interrupt(); throw new CustomException(SurveyErrorCode.SURVEY_PARTICIPATION_TEMP_EXCEEDED); - } finally { - if (lock.isHeldByCurrentThread()) { - lock.unlock(); - } } } @@ -473,31 +458,10 @@ private Integer setDueCount(Long surveyId) { Duration duration = Duration.between( LocalDateTime.now(), survey.getDeadline()); - redisTemplate.opsForValue().set(this.dueCountKey + surveyId, String.valueOf(surveyInfo.getDueCount()), duration); + RedisUtils.setValue(this.dueCountKey + surveyId, String.valueOf(surveyInfo.getDueCount()), duration); return surveyInfo.getDueCount(); } - private int getZSetIntValue(Long surveyId, String keyPrefix) { - Long value = redisTemplate.opsForZSet().zCard(keyPrefix + surveyId); - return value != null ? value.intValue() : 0; - } - - private long getZSetCount(String key, Long threshold) { - Long value = redisTemplate.opsForZSet().count(key, threshold, Double.MAX_VALUE); - - return value != null ? value : 0L; - } - - private long getLongValue(Long surveyId, String keyPrefix) { - String value = redisTemplate.opsForValue().get(keyPrefix + surveyId); - return value != null ? Long.parseLong(value) : 0; - } - - private int getIntValue(Long surveyId, String keyPrefix) { - String value = redisTemplate.opsForValue().get(keyPrefix + surveyId); - return value != null ? Integer.parseInt(value) : 0; - } - @Override public boolean checkValidSegmentation(Long surveyId, Long userKey) { SurveySegmentation surveySegmentation = surveyInfoRepository.findSegmentationBySurveyId(surveyId); diff --git a/src/main/java/OneQ/OnSurvey/global/common/util/RedisUtils.java b/src/main/java/OneQ/OnSurvey/global/common/util/RedisUtils.java new file mode 100644 index 00000000..7d2f7c87 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/global/common/util/RedisUtils.java @@ -0,0 +1,141 @@ +package OneQ.OnSurvey.global.common.util; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.redisson.client.RedisException; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +@Component +@RequiredArgsConstructor +public final class RedisUtils { + + private final StringRedisTemplate redisTemplate; + private final RedissonClient redisson; + + private static RedissonClient staticRedisson; + private static StringRedisTemplate staticRedisTemplate; + + @PostConstruct + public void init() { + staticRedisson = this.redisson; + staticRedisTemplate = this.redisTemplate; + } + + public static RLock getLock(String lockKey) { + return staticRedisson.getLock(lockKey); + } + + /** + * 락 획득 후 실행할 로직을 인자로 받아 분산락을 이용하여 실행하는 유틸리티 메서드 + * @param lockKey 분산락 설정을 위한 키 + * @param waitTIme 분산락 획득 대기시간 (단위: 초) + * @param leaseTime 분산락 최대 점유시간 (단위: 초) + * @param action 분산락 획득 후 실행할 로직 + * @return {@code action}의 실행 결과 + * @throws InterruptedException 락 획득 대기 중 인터럽트 발생 시 예외 + */ + public static R executeWithLock( + String lockKey, long waitTIme, long leaseTime, Supplier action + ) throws InterruptedException, RedisException { + RLock lock = getLock(lockKey); + boolean available = lock.tryLock(waitTIme, leaseTime, TimeUnit.SECONDS); + + if (!available) { + throw new RedisException("락 획득 실패"); + } + + try { + return action.get(); + } finally { + lock.unlock(); + } + } + + /** + * 저장된 값을 조회하는 유틸리티 메서드 + * @param key 조회할 키 (keyPrefix + id 형태로 사용) + * @return String + */ + public static String getValue(String key) { + return staticRedisTemplate.opsForValue().get(key); + } + + /** + * 저장된 값을 int로 조회하는 유틸리티 메서드 + * @param key 조회할 키 (keyPrefix + id 형태로 사용) + * @return int, 존재하지 않으면 0 반환 + */ + public static int getIntValue(String key) { + String value = staticRedisTemplate.opsForValue().get(key); + return value != null ? Integer.parseInt(value) : 0; + } + + /** + * 저장된 값을 long으로 조회하는 유틸리티 메서드 + * @param key 조회할 키 (keyPrefix + id 형태로 사용) + * @return long, 존재하지 않으면 0 반환 + */ + public static long getLongValue(String key) { + String value = staticRedisTemplate.opsForValue().get(key); + return value != null ? Long.parseLong(value) : 0L; + } + + /** + * 값을 저장하는 유틸리티 메서드 + * @param key 저장할 키 (keyPrefix + id 형태로 사용) + * @param value 저장할 값 + * @param duration TTL, 예: {@code Duration.ofSeconds(60)} - 60초 동안 유효 + */ + public static void setValue(String key, String value, Duration duration) { + staticRedisTemplate.opsForValue().set(key, value, duration); + } + + /** + * Sorted Set의 score 범위 내 요소 개수를 조회하는 유틸리티 메서드 + * @param key 조회할 키 (keyPrefix + id 형태로 사용) + * @param min 조회할 범위(score)의 최소값 + * @param max 조회할 범위(score)의 최대값 + * @return score 범위 내 요소 개수, 존재하지 않으면 0 반환 + */ + public static long getZSetCount(String key, long min, long max) { + Long count = staticRedisTemplate.opsForZSet().count(key, min, max); + return count != null ? count : 0L; + } + + /** + * Sorted Set에서 특정 요소의 score를 조회하는 유틸리티 메서드 + * @param key 조회할 키 (keyPrefix + id 형태로 사용) + * @param value 조회할 요소 값 + * @return 요소의 score, 존재하지 않으면 null 반환 + */ + public static Double getZSetScore(String key, String value) { + return staticRedisTemplate.opsForZSet().score(key, value); + } + + /** + * Sorted Set에 요소를 추가하거나 갱신하는 유틸리티 메서드 + * @param key 요소를 추가/갱신할 키 (keyPrefix + id 형태로 사용) + * @param value Sorted Set에 추가/갱신할 값 + * @param score Sorted Set에 추가/갱신할 값의 score + */ + public static void addToZSet(String key, String value, long score) { + staticRedisTemplate.opsForZSet().add(key, value, score); + } + + /** + * Sorted Set에서 score 범위 내 요소를 제거하는 유틸리티 메서드 + * @param key 삭제할 키 (keyPrefix + id 형태로 사용) + * @param min 삭제할 범위(score)의 최소값 + * @param max 삭제할 범위(score)의 최대값 + */ + public static void rangeRemoveFromZSet(String key, long min, long max) { + staticRedisTemplate.opsForZSet().removeRangeByScore(key, min, max); + } +} From 6c10887a653ad15a5fddd100ec699cee658b37ef Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Wed, 18 Feb 2026 22:57:01 +0900 Subject: [PATCH 04/14] =?UTF-8?q?mod:=20Redis=20=EC=9C=A0=ED=8B=B8?= =?UTF-8?q?=EB=A6=AC=ED=8B=B0=20=ED=81=B4=EB=9E=98=EC=8A=A4=EB=A5=BC=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 564cc52b22fbce28a424b751b32c36539f70335b) --- .../response/ResponseCommandService.java | 43 ++++----- .../service/SurveyGlobalStatsService.java | 17 ++-- .../service/command/SurveyCommandService.java | 34 ++----- .../service/query/SurveyQueryService.java | 18 ++-- .../auth/application/TossAuthFacade.java | 7 +- .../global/auth/token/TokenStore.java | 19 +--- .../global/common/util/RedisUtils.java | 89 ++++++++++++++++++- 7 files changed, 136 insertions(+), 91 deletions(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java b/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java index 797d3336..84098a19 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java @@ -10,21 +10,22 @@ import OneQ.OnSurvey.domain.survey.repository.surveyInfo.SurveyInfoRepository; import OneQ.OnSurvey.domain.survey.service.SurveyGlobalStatsService; import OneQ.OnSurvey.global.common.exception.CustomException; +import OneQ.OnSurvey.global.common.util.RedisUtils; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Objects; +@Slf4j @Service @RequiredArgsConstructor @Transactional public class ResponseCommandService implements ResponseCommand { - private final StringRedisTemplate redisTemplate; - private final ResponseRepository responseRepository; private final SurveyRepository surveyRepository; private final SurveyInfoRepository surveyInfoRepository; @@ -32,13 +33,10 @@ public class ResponseCommandService implements ResponseCommand { @Value("${redis.survey-key-prefix.potential-count}") private String potentialKey; - @Value("${redis.survey-key-prefix.completed-count}") private String completedKey; - @Value("${redis.survey-key-prefix.due-count}") private String dueCountKey; - @Value("${redis.survey-key-prefix.creator-userkey}") private String creatorKey; @@ -61,36 +59,29 @@ public Boolean createResponse(Long surveyId, Long memberId, Long userKey) { .orElseThrow(() -> new CustomException(SurveyErrorCode.SURVEY_INFO_NOT_FOUND)); surveyInfo.increaseCompletedCount(); - Integer currCompleted = updateCounter(surveyId, userKey); - if (currCompleted != null && currCompleted.equals(surveyInfo.getDueCount())) { + int currCompleted = updateCounter(surveyId, userKey); + if (Objects.equals(currCompleted, surveyInfo.getDueCount())) { Survey survey = surveyRepository.getSurveyById(surveyId) .orElseThrow(() -> new CustomException(SurveyErrorCode.SURVEY_NOT_FOUND)); survey.updateSurveyStatus(SurveyStatus.CLOSED); - deleteAllRedisKeys(surveyId); + RedisUtils.deleteKeys(List.of( + this.dueCountKey + surveyId, + this.completedKey + surveyId, + this.potentialKey + surveyId, + this.creatorKey + surveyId + )); } return true; } - private void deleteAllRedisKeys(Long surveyId) { - redisTemplate.delete(List.of( - this.dueCountKey + surveyId, - this.completedKey + surveyId, - this.potentialKey + surveyId, - this.creatorKey + surveyId - )); - } - - private Integer updateCounter(Long surveyId, Long userKey) { - String potentialKey = this.potentialKey + surveyId; - String completedKey = this.completedKey + surveyId; - String memberValue = String.valueOf(userKey); - + private int updateCounter(Long surveyId, Long userKey) { // 완료 인원 추가 - Long currCompleted = redisTemplate.opsForValue().increment(completedKey); + Long currCompleted = RedisUtils.incrementValue(this.completedKey + surveyId); // 잠재 응답자 Sorted Set에서 제거 - redisTemplate.opsForZSet().remove(potentialKey, memberValue); - return currCompleted != null ? currCompleted.intValue() : null; + RedisUtils.removeFromZSet(this.potentialKey + surveyId, String.valueOf(userKey)); + // 완료 인원이 없어 증가가 되지 않은 경우 (null), 기존 완료 인원을 0으로 간주하여 1로 반환 + return currCompleted != null ? currCompleted.intValue() : 1; } } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/SurveyGlobalStatsService.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/SurveyGlobalStatsService.java index e6acb794..ef31b90d 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/SurveyGlobalStatsService.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/SurveyGlobalStatsService.java @@ -3,9 +3,9 @@ import OneQ.OnSurvey.domain.survey.entity.SurveyGlobalStats; import OneQ.OnSurvey.domain.survey.model.dto.GlobalStats; import OneQ.OnSurvey.domain.survey.repository.SurveyGlobalStatsRepository; +import OneQ.OnSurvey.global.common.util.RedisUtils; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; @@ -16,11 +16,10 @@ @Transactional public class SurveyGlobalStatsService { - private final StringRedisTemplate redisTemplate; private final SurveyGlobalStatsRepository statsRepository; @Value("${redis.global-key-prefix.daily-user}") - private String dailyUserKeyPrefix; + private String dailyUserKey; private SurveyGlobalStats getOrInit() { return statsRepository.findById(1L) @@ -47,7 +46,11 @@ public GlobalStats getStats() { SurveyGlobalStats surveyGlobalStats = statsRepository.findById(1L) .orElse(SurveyGlobalStats.init()); - Long dailyUserCount = redisTemplate.opsForZSet().zCard(dailyUserKeyPrefix); + // 24시간 동안 활동한 유저 수 계산 + Long dailyUserCount = RedisUtils.getZSetCount( + dailyUserKey, + System.currentTimeMillis() - (24 * 60 * 60 * 1000L), + Long.MAX_VALUE); return GlobalStats.of( surveyGlobalStats.getTotalDueCount(), surveyGlobalStats.getTotalCompletedCount(), @@ -59,7 +62,9 @@ public GlobalStats getStats() { @Scheduled(fixedRate = 3600000) // 매 시간 실행 @Transactional(propagation = Propagation.NOT_SUPPORTED) public void removeOldDailyUsers() { - long dailyRange = System.currentTimeMillis() - (24 * 60 * 60 * 1000L); - redisTemplate.opsForZSet().removeRangeByScore(dailyUserKeyPrefix, 0, dailyRange); + RedisUtils.rangeRemoveFromZSet( + dailyUserKey, + 0, + System.currentTimeMillis() - (24 * 60 * 60 * 1000L)); } } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java index 8a2c0c40..0c56b446 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java @@ -26,13 +26,13 @@ import OneQ.OnSurvey.global.common.exception.CustomException; import OneQ.OnSurvey.global.common.exception.ErrorCode; import OneQ.OnSurvey.global.common.util.AuthorizationUtils; +import OneQ.OnSurvey.global.common.util.RedisUtils; import OneQ.OnSurvey.global.infra.discord.notifier.AlertNotifier; import OneQ.OnSurvey.global.infra.discord.notifier.dto.SurveySubmittedAlert; import OneQ.OnSurvey.global.infra.transaction.AfterCommitExecutor; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -50,8 +50,6 @@ @Transactional public class SurveyCommandService implements SurveyCommand { - private final StringRedisTemplate redisTemplate; - private final SurveyRepository surveyRepository; private final ScreeningRepository screeningRepository; private final SurveyInfoRepository surveyInfoRepository; @@ -64,13 +62,10 @@ public class SurveyCommandService implements SurveyCommand { @Value("${redis.survey-key-prefix.potential-count}") private String potentialKey; - @Value("${redis.survey-key-prefix.completed-count}") private String completedKey; - @Value("${redis.survey-key-prefix.due-count}") private String dueCountKey; - @Value("${redis.survey-key-prefix.creator-userkey}") private String creatorKey; @@ -246,11 +241,11 @@ public boolean sendSurveyHeartbeat(Long surveyId, Long userKey) { String potentialKey = this.potentialKey + surveyId; String memberValue = String.valueOf(userKey); - if (redisTemplate.opsForZSet().score(potentialKey, memberValue) == null) { + if (RedisUtils.getZSetScore(potentialKey, memberValue) == null) { return false; } // 잠재 응답자 목록에 현재 시간을 score로 사용자 갱신 - redisTemplate.opsForZSet().add(potentialKey, memberValue, System.currentTimeMillis()); + RedisUtils.addToZSet(potentialKey, memberValue, System.currentTimeMillis()); return true; } @@ -266,18 +261,6 @@ public void updateSurveyOwner(SurveyOwnerChangeDto changeDto) { changeDto.surveyId(), changeDto.newMemberId()); } - private void setValue(String keyPrefix, Long surveyId, String value, Duration duration) { - redisTemplate.opsForValue().set( - keyPrefix + surveyId, value, duration - ); - } - - private void addZSetValue(String keyPrefix, Long surveyId, String value) { - redisTemplate.opsForZSet().add( - keyPrefix + surveyId, value, System.currentTimeMillis() - ); - } - private Survey getSurvey(Long surveyId) { return surveyRepository.getSurveyById(surveyId) .orElseThrow(() -> new CustomException(ErrorCode.INVALID_REQUEST)); @@ -343,10 +326,11 @@ private SurveyFormResponse finalizeSubmit( } private void applySurveyRuntimeCache(Long surveyId, Long userKey, Integer dueCount, LocalDateTime deadline) { - Duration duration = Duration.between(LocalDateTime.now(), deadline); - setValue(this.dueCountKey, surveyId, String.valueOf(dueCount), duration); - setValue(this.completedKey, surveyId, "0", duration); - addZSetValue(this.potentialKey, surveyId, String.valueOf(userKey)); - setValue(this.creatorKey, surveyId, String.valueOf(userKey), duration); + Duration ttl = Duration.between(LocalDateTime.now(), deadline); + + RedisUtils.setValue(this.dueCountKey + surveyId, String.valueOf(dueCount), ttl); + RedisUtils.setValue(this.completedKey + surveyId, "0", ttl); + RedisUtils.addToZSet(this.potentialKey + surveyId, String.valueOf(userKey), System.currentTimeMillis()); + RedisUtils.setValue(this.creatorKey + surveyId, String.valueOf(userKey), ttl); } } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQueryService.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQueryService.java index c758bee8..6f4db25f 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQueryService.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQueryService.java @@ -66,18 +66,16 @@ public class SurveyQueryService implements SurveyQuery { private final QuestionQueryService questionQueryService; + @Value("${redis.survey-key-prefix.lock}") + private String lockKey; @Value("${redis.survey-key-prefix.potential-count}") private String potentialKey; - @Value("${redis.survey-key-prefix.completed-count}") private String completedKey; - @Value("${redis.survey-key-prefix.due-count}") private String dueCountKey; - @Value("${redis.survey-key-prefix.creator-userkey}") private String creatorKey; - @Value("${redis.survey-potential-expiration-seconds}") private Integer potentialTimeout; @@ -221,7 +219,7 @@ public ParticipationScreeningSingleResponse getScreeningSingleResponse(Long scre public ParticipationInfoResponse getParticipationInfo(Long surveyId, Long userKey, Long memberId) { log.info("[SURVEY:QUERY:getParticipationInfo] 설문 기본정보 조회 - surveyId: {}", surveyId); - if (checkValidSegmentation(surveyId, userKey) && AuthorizationUtils.validateOwnershipOrAdmin(surveyId, userKey)) { + if (checkValidSegmentation(surveyId, userKey)) { log.warn("[SURVEY:QUERY] 세그먼트 불일치로 인한 설문 응답 불가 - surveyId: {}, userKey: {}", surveyId, userKey); throw new CustomException(SurveyErrorCode.SURVEY_WRONG_SEGMENTATION); } @@ -253,7 +251,7 @@ public ParticipationInfoResponse getParticipationInfo(Long surveyId, Long userKe public ParticipationQuestionResponse getParticipationQuestionInfo(Long surveyId, Integer sectionOrder, Long userKey) { log.info("[SURVEY:QUERY] 설문 문항정보 조회 - surveyId: {}, userKey: {}", surveyId, userKey); - if (AuthorizationUtils.validateNonOwnershipOrAdmin(userKey, RedisUtils.getLongValue(this.creatorKey + surveyId))) { + if (AuthorizationUtils.validateOwnershipOrAdmin(userKey, RedisUtils.getLongValue(this.creatorKey + surveyId))) { log.warn("[SURVEY:QUERY] 설문 제작자는 참여 불가 - surveyId: {}, userKey: {}", surveyId, userKey); throw new CustomException(SurveyErrorCode.SURVEY_PARTICIPATION_OWN_SURVEY); } @@ -342,7 +340,7 @@ public void validateSurveyRequest(Long surveyId, Long memberId, SurveyStatus sta Survey survey = surveyRepository.getSurveyById(surveyId) .orElseThrow(() -> new CustomException(SurveyErrorCode.SURVEY_NOT_FOUND)); - if (AuthorizationUtils.validateNonOwnershipOrAdmin(survey.getMemberId(), memberId)) { + if (AuthorizationUtils.validateOwnershipOrAdmin(survey.getMemberId(), memberId)) { log.warn("[SURVEY:QUERY:VALIDATE] 접근 권한 없음 - surveyId: {}, memberId: {}, surveyMemberId: {}", surveyId, memberId, survey.getMemberId()); throw new CustomException(SurveyErrorCode.SURVEY_FORBIDDEN); @@ -371,8 +369,6 @@ private boolean isSurveyAccessible(SurveyStatus status) { *

등록불가능: false */ private boolean isActivationAvailable(Long surveyId, Long userKey) { - log.info("[SURVEY:QUERY] 활성 참여자 등록 및 등록가능 여부 판단 - surveyId: {}, userKey: {}", surveyId, userKey); - final String potentialKey = this.potentialKey + surveyId; final String memberValue = String.valueOf(userKey); @@ -385,7 +381,7 @@ private boolean isActivationAvailable(Long surveyId, Long userKey) { boolean result; try { Integer finalDueCount = dueCount; - result = RedisUtils.executeWithLock("lock:survey:" + surveyId, 5, 10, () -> { + result = RedisUtils.executeWithLock(lockKey + surveyId, 5, 10, () -> { Double existingScore = RedisUtils.getZSetScore(potentialKey, memberValue); // 새로운 참여자인 경우 @@ -429,7 +425,7 @@ private boolean isActivationAvailable(Long surveyId, Long userKey) { private Integer initialDueCount(Long surveyId) { try { - return RedisUtils.executeWithLock("lock:survey:" + surveyId, 3, 6, () -> { + return RedisUtils.executeWithLock(lockKey + surveyId, 3, 6, () -> { int dueCount = RedisUtils.getIntValue(this.dueCountKey + surveyId); // 다른 스레드에서 값이 설정된 경우, 재조회하지 않고 그대로 값 반환하도록 더블체크 diff --git a/src/main/java/OneQ/OnSurvey/global/auth/application/TossAuthFacade.java b/src/main/java/OneQ/OnSurvey/global/auth/application/TossAuthFacade.java index e0cd6993..fd89e2c1 100644 --- a/src/main/java/OneQ/OnSurvey/global/auth/application/TossAuthFacade.java +++ b/src/main/java/OneQ/OnSurvey/global/auth/application/TossAuthFacade.java @@ -7,6 +7,7 @@ import OneQ.OnSurvey.global.auth.port.out.TossAuthPort; import OneQ.OnSurvey.global.common.exception.CustomException; import OneQ.OnSurvey.global.common.util.JwtDecodeUtils; +import OneQ.OnSurvey.global.common.util.RedisUtils; import OneQ.OnSurvey.global.infra.discord.notifier.AlertNotifier; import OneQ.OnSurvey.global.infra.discord.notifier.dto.TossAccessTokenAlert; import OneQ.OnSurvey.global.infra.toss.auth.TossMemberInfoDecryptService; @@ -18,7 +19,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -42,9 +42,8 @@ public class TossAuthFacade implements AuthUseCase { private String publicCrt; @Value("${redis.global-key-prefix.daily-user}") - private String dailyUserKeyPrefix; + private String dailyUserKey; - private final StringRedisTemplate redisTemplate; private final TossAuthPort tossAuthPort; private final MemberModifyService memberModifyService; private final TossMemberInfoDecryptService tossMemberInfoDecryptService; @@ -168,7 +167,7 @@ public void unlink(Long userKey, TossUnlinkValue referrer) { private void updateDailyUser(Long userKey) { try { - redisTemplate.opsForZSet().addIfAbsent(dailyUserKeyPrefix, String.valueOf(userKey), System.currentTimeMillis()); + RedisUtils.addToZSetIfAbsent(dailyUserKey, String.valueOf(userKey), System.currentTimeMillis()); } catch (Exception e) { log.warn("[TossAuthFacade] 일간 활성 사용자 업데이트 실패 - userKey: {}", userKey, e); } diff --git a/src/main/java/OneQ/OnSurvey/global/auth/token/TokenStore.java b/src/main/java/OneQ/OnSurvey/global/auth/token/TokenStore.java index 9a02d5bd..57134b9d 100644 --- a/src/main/java/OneQ/OnSurvey/global/auth/token/TokenStore.java +++ b/src/main/java/OneQ/OnSurvey/global/auth/token/TokenStore.java @@ -1,32 +1,21 @@ package OneQ.OnSurvey.global.auth.token; +import OneQ.OnSurvey.global.common.util.RedisUtils; import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Repository; import java.time.Duration; -import java.util.Optional; +import java.util.List; @Repository @RequiredArgsConstructor public class TokenStore { - private final StringRedisTemplate redis; - - public void saveValue(String key, String value, Duration ttl) { - redis.opsForValue().set(key, value, ttl); - } - public Optional getValue(String key) { - return Optional.ofNullable(redis.opsForValue().get(key)); - } - public void deleteKey(String key) { redis.delete(key); } - - public boolean acquireLock(String key, Duration ttl) { - Boolean ok = redis.opsForValue().setIfAbsent(key, "1", ttl); + Boolean ok = RedisUtils.setValueIfAbsent(key, "1", ttl); return Boolean.TRUE.equals(ok); } public void releaseLock(String key) { - redis.delete(key); + RedisUtils.deleteKeys(List.of(key)); } } diff --git a/src/main/java/OneQ/OnSurvey/global/common/util/RedisUtils.java b/src/main/java/OneQ/OnSurvey/global/common/util/RedisUtils.java index 7d2f7c87..2d4c763a 100644 --- a/src/main/java/OneQ/OnSurvey/global/common/util/RedisUtils.java +++ b/src/main/java/OneQ/OnSurvey/global/common/util/RedisUtils.java @@ -9,6 +9,7 @@ import org.springframework.stereotype.Component; import java.time.Duration; +import java.util.List; import java.util.concurrent.TimeUnit; import java.util.function.Supplier; @@ -28,7 +29,7 @@ public void init() { staticRedisTemplate = this.redisTemplate; } - public static RLock getLock(String lockKey) { + private static RLock getLock(String lockKey) { return staticRedisson.getLock(lockKey); } @@ -39,6 +40,7 @@ public static RLock getLock(String lockKey) { * @param leaseTime 분산락 최대 점유시간 (단위: 초) * @param action 분산락 획득 후 실행할 로직 * @return {@code action}의 실행 결과 + * @throws RedisException 락 획득 실패 시 예외 * @throws InterruptedException 락 획득 대기 중 인터럽트 발생 시 예외 */ public static R executeWithLock( @@ -91,10 +93,70 @@ public static long getLongValue(String key) { * 값을 저장하는 유틸리티 메서드 * @param key 저장할 키 (keyPrefix + id 형태로 사용) * @param value 저장할 값 - * @param duration TTL, 예: {@code Duration.ofSeconds(60)} - 60초 동안 유효 + * @param ttl TTL, 예: {@code Duration.ofSeconds(60)} - 60초 동안 유효 */ - public static void setValue(String key, String value, Duration duration) { - staticRedisTemplate.opsForValue().set(key, value, duration); + public static void setValue(String key, String value, Duration ttl) { + staticRedisTemplate.opsForValue().set(key, value, ttl); + } + + /** + * 키가 존재하지 않을 때에만 값을 저장하는 유틸리티 메서드 + * @param key 저장할 키 (keyPrefix + id 형태로 사용) + * @param value 저장할 값 + * @param ttl TTL, 예: {@code Duration.ofSeconds(60)} - 60초 동안 유효 + * @return 값이 저장됨 : true + *

이미 키가 존재하여 저장되지 않음 : false + */ + public static Boolean setValueIfAbsent(String key, String value, Duration ttl) { + return staticRedisTemplate.opsForValue().setIfAbsent(key, value, ttl); + } + + /** + * 값을 1 증가시키는 유틸리티 메서드 + * @param key 조회할 키 (keyPrefix + id 형태로 사용) + * @return 증가된 값 + */ + public static Long incrementValue(String key) { + return staticRedisTemplate.opsForValue().increment(key); + } + + /** + * 값을 증가시키는 유틸리티 메서드 + * @param key 조회할 키 (keyPrefix + id 형태로 사용) + * @param delta 증가시킬 값 + * @return 증가된 값 + */ + public static Long incrementValue(String key, long delta) { + return staticRedisTemplate.opsForValue().increment(key, delta); + } + + /** + * 값을 1 감소시키는 유틸리티 메서드 + * @param key 조회할 키 (keyPrefix + id 형태로 사용) + * @return 감소된 값 + */ + public static Long decrementValue(String key) { + return staticRedisTemplate.opsForValue().decrement(key); + } + + /** + * 값을 감소시키는 유틸리티 메서드 + * @param key 조회할 키 (keyPrefix + id 형태로 사용) + * @param delta 감소시킬 값 + * @return 감소된 값 + */ + public static Long decrementValue(String key, long delta) { + return staticRedisTemplate.opsForValue().decrement(key, delta); + } + + /** + * 키를 삭제하는 유틸리티 메서드 + * @param keyList 삭제할 키 리스트 (keyPrefix + id 형태로 사용) + */ + public static void deleteKeys(List keyList) { + if (keyList != null && !keyList.isEmpty()) { + staticRedisTemplate.delete(keyList); + } } /** @@ -129,6 +191,25 @@ public static void addToZSet(String key, String value, long score) { staticRedisTemplate.opsForZSet().add(key, value, score); } + /** + * Sorted Set에 요소를 추가하는 유틸리티 메서드 (값을 갱신하지는 않음) + * @param key - 요소를 추가할 키 (keyPrefix + id 형태로 사용) + * @param value - Sorted Set에 추가할 값 + * @param score - Sorted Set에 추가할 값의 score + */ + public static void addToZSetIfAbsent(String key, String value, long score) { + staticRedisTemplate.opsForZSet().addIfAbsent(key, value, score); + } + + /** + * Sorted Set에서 특정 요소를 제거하는 유틸리티 메서드 + * @param key 삭제할 키 (keyPrefix + id 형태로 사용) + * @param value 삭제할 요소 값 + */ + public static void removeFromZSet(String key, String value) { + staticRedisTemplate.opsForZSet().remove(key, value); + } + /** * Sorted Set에서 score 범위 내 요소를 제거하는 유틸리티 메서드 * @param key 삭제할 키 (keyPrefix + id 형태로 사용) From f4ab2c6c8f493804efbe198056337ff43678fb73 Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Thu, 19 Feb 2026 19:18:25 +0900 Subject: [PATCH 05/14] =?UTF-8?q?mod:=20Redis=20=EC=9C=A0=ED=8B=B8?= =?UTF-8?q?=EB=A6=AC=ED=8B=B0=20=ED=81=B4=EB=9E=98=EC=8A=A4=EB=A5=BC=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=ED=95=98=EC=97=AC=20=EC=A3=BC=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 2e499144ba402c41cad57bac0ec9e8973543d0c4) --- .../response/ResponseCommandService.java | 9 +- .../service/SurveyGlobalStatsService.java | 7 +- .../service/command/SurveyCommandService.java | 15 +- .../service/query/SurveyQueryService.java | 29 +-- .../auth/application/TossAuthFacade.java | 5 +- .../global/auth/token/TokenStore.java | 8 +- .../global/common/util/RedisUtils.java | 222 ---------------- .../global/infra/redis/RedisAgent.java | 236 ++++++++++++++++++ .../global/infra/redis/RedisCacheAction.java | 39 +++ .../global/infra/redis/RedisLockAction.java | 13 + 10 files changed, 328 insertions(+), 255 deletions(-) delete mode 100644 src/main/java/OneQ/OnSurvey/global/common/util/RedisUtils.java create mode 100644 src/main/java/OneQ/OnSurvey/global/infra/redis/RedisAgent.java create mode 100644 src/main/java/OneQ/OnSurvey/global/infra/redis/RedisCacheAction.java create mode 100644 src/main/java/OneQ/OnSurvey/global/infra/redis/RedisLockAction.java diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java b/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java index 84098a19..67c376b7 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java @@ -10,7 +10,7 @@ import OneQ.OnSurvey.domain.survey.repository.surveyInfo.SurveyInfoRepository; import OneQ.OnSurvey.domain.survey.service.SurveyGlobalStatsService; import OneQ.OnSurvey.global.common.exception.CustomException; -import OneQ.OnSurvey.global.common.util.RedisUtils; +import OneQ.OnSurvey.global.infra.redis.RedisAgent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -30,6 +30,7 @@ public class ResponseCommandService implements ResponseCommand { private final SurveyRepository surveyRepository; private final SurveyInfoRepository surveyInfoRepository; private final SurveyGlobalStatsService surveyGlobalStatsService; + private final RedisAgent regisAgent; @Value("${redis.survey-key-prefix.potential-count}") private String potentialKey; @@ -65,7 +66,7 @@ public Boolean createResponse(Long surveyId, Long memberId, Long userKey) { .orElseThrow(() -> new CustomException(SurveyErrorCode.SURVEY_NOT_FOUND)); survey.updateSurveyStatus(SurveyStatus.CLOSED); - RedisUtils.deleteKeys(List.of( + regisAgent.deleteKeys(List.of( this.dueCountKey + surveyId, this.completedKey + surveyId, this.potentialKey + surveyId, @@ -78,9 +79,9 @@ public Boolean createResponse(Long surveyId, Long memberId, Long userKey) { private int updateCounter(Long surveyId, Long userKey) { // 완료 인원 추가 - Long currCompleted = RedisUtils.incrementValue(this.completedKey + surveyId); + Long currCompleted = regisAgent.incrementValue(this.completedKey + surveyId); // 잠재 응답자 Sorted Set에서 제거 - RedisUtils.removeFromZSet(this.potentialKey + surveyId, String.valueOf(userKey)); + regisAgent.removeFromZSet(this.potentialKey + surveyId, String.valueOf(userKey)); // 완료 인원이 없어 증가가 되지 않은 경우 (null), 기존 완료 인원을 0으로 간주하여 1로 반환 return currCompleted != null ? currCompleted.intValue() : 1; } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/SurveyGlobalStatsService.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/SurveyGlobalStatsService.java index ef31b90d..b435b575 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/SurveyGlobalStatsService.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/SurveyGlobalStatsService.java @@ -3,7 +3,7 @@ import OneQ.OnSurvey.domain.survey.entity.SurveyGlobalStats; import OneQ.OnSurvey.domain.survey.model.dto.GlobalStats; import OneQ.OnSurvey.domain.survey.repository.SurveyGlobalStatsRepository; -import OneQ.OnSurvey.global.common.util.RedisUtils; +import OneQ.OnSurvey.global.infra.redis.RedisAgent; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; @@ -17,6 +17,7 @@ public class SurveyGlobalStatsService { private final SurveyGlobalStatsRepository statsRepository; + private final RedisAgent redisAgent; @Value("${redis.global-key-prefix.daily-user}") private String dailyUserKey; @@ -47,7 +48,7 @@ public GlobalStats getStats() { .orElse(SurveyGlobalStats.init()); // 24시간 동안 활동한 유저 수 계산 - Long dailyUserCount = RedisUtils.getZSetCount( + Long dailyUserCount = redisAgent.getZSetCount( dailyUserKey, System.currentTimeMillis() - (24 * 60 * 60 * 1000L), Long.MAX_VALUE); @@ -62,7 +63,7 @@ public GlobalStats getStats() { @Scheduled(fixedRate = 3600000) // 매 시간 실행 @Transactional(propagation = Propagation.NOT_SUPPORTED) public void removeOldDailyUsers() { - RedisUtils.rangeRemoveFromZSet( + redisAgent.rangeRemoveFromZSet( dailyUserKey, 0, System.currentTimeMillis() - (24 * 60 * 60 * 1000L)); diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java index 0c56b446..f71e1934 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java @@ -26,9 +26,9 @@ import OneQ.OnSurvey.global.common.exception.CustomException; import OneQ.OnSurvey.global.common.exception.ErrorCode; import OneQ.OnSurvey.global.common.util.AuthorizationUtils; -import OneQ.OnSurvey.global.common.util.RedisUtils; import OneQ.OnSurvey.global.infra.discord.notifier.AlertNotifier; import OneQ.OnSurvey.global.infra.discord.notifier.dto.SurveySubmittedAlert; +import OneQ.OnSurvey.global.infra.redis.RedisAgent; import OneQ.OnSurvey.global.infra.transaction.AfterCommitExecutor; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -56,6 +56,7 @@ public class SurveyCommandService implements SurveyCommand { private final MemberRepository memberRepository; private final SurveyRefundPolicy surveyRefundPolicy; private final SurveyGlobalStatsService surveyGlobalStatsService; + private final RedisAgent redisAgent; private final AlertNotifier alertNotifier; private final AfterCommitExecutor afterCommitExecutor; @@ -241,11 +242,11 @@ public boolean sendSurveyHeartbeat(Long surveyId, Long userKey) { String potentialKey = this.potentialKey + surveyId; String memberValue = String.valueOf(userKey); - if (RedisUtils.getZSetScore(potentialKey, memberValue) == null) { + if (redisAgent.getZSetScore(potentialKey, memberValue) == null) { return false; } // 잠재 응답자 목록에 현재 시간을 score로 사용자 갱신 - RedisUtils.addToZSet(potentialKey, memberValue, System.currentTimeMillis()); + redisAgent.addToZSet(potentialKey, memberValue, System.currentTimeMillis()); return true; } @@ -328,9 +329,9 @@ private SurveyFormResponse finalizeSubmit( private void applySurveyRuntimeCache(Long surveyId, Long userKey, Integer dueCount, LocalDateTime deadline) { Duration ttl = Duration.between(LocalDateTime.now(), deadline); - RedisUtils.setValue(this.dueCountKey + surveyId, String.valueOf(dueCount), ttl); - RedisUtils.setValue(this.completedKey + surveyId, "0", ttl); - RedisUtils.addToZSet(this.potentialKey + surveyId, String.valueOf(userKey), System.currentTimeMillis()); - RedisUtils.setValue(this.creatorKey + surveyId, String.valueOf(userKey), ttl); + redisAgent.setValue(this.dueCountKey + surveyId, String.valueOf(dueCount), ttl); + redisAgent.setValue(this.completedKey + surveyId, "0", ttl); + redisAgent.addToZSet(this.potentialKey + surveyId, String.valueOf(userKey), System.currentTimeMillis()); + redisAgent.setValue(this.creatorKey + surveyId, String.valueOf(userKey), ttl); } } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQueryService.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQueryService.java index 6f4db25f..856d4b60 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQueryService.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQueryService.java @@ -30,7 +30,7 @@ import OneQ.OnSurvey.global.common.exception.CustomException; import OneQ.OnSurvey.global.common.exception.ErrorCode; import OneQ.OnSurvey.global.common.util.AuthorizationUtils; -import OneQ.OnSurvey.global.common.util.RedisUtils; +import OneQ.OnSurvey.global.infra.redis.RedisAgent; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -63,6 +63,7 @@ public class SurveyQueryService implements SurveyQuery { private final ResponseRepository responseRepository; private final MemberRepository memberRepository; private final SectionRepository sectionRepository; + private final RedisAgent redisAgent; private final QuestionQueryService questionQueryService; @@ -232,7 +233,7 @@ public ParticipationInfoResponse getParticipationInfo(Long surveyId, Long userKe throw new CustomException(SurveyErrorCode.SURVEY_INCORRECT_STATUS); } - int completedCount = RedisUtils.getIntValue(this.completedKey + surveyId); + int completedCount = redisAgent.getIntValue(this.completedKey + surveyId); ParticipationStatus participationStatus = surveyRepository.getParticipationStatus(surveyId, memberId); if (participationStatus.isScreenRequired()) { log.warn("[SURVEY:QUERY] 스크리닝 퀴즈 응답이 필요합니다. - surveyId: {}, memberId: {}", surveyId, memberId); @@ -251,7 +252,7 @@ public ParticipationInfoResponse getParticipationInfo(Long surveyId, Long userKe public ParticipationQuestionResponse getParticipationQuestionInfo(Long surveyId, Integer sectionOrder, Long userKey) { log.info("[SURVEY:QUERY] 설문 문항정보 조회 - surveyId: {}, userKey: {}", surveyId, userKey); - if (AuthorizationUtils.validateOwnershipOrAdmin(userKey, RedisUtils.getLongValue(this.creatorKey + surveyId))) { + if (AuthorizationUtils.validateOwnershipOrAdmin(userKey, redisAgent.getLongValue(this.creatorKey + surveyId))) { log.warn("[SURVEY:QUERY] 설문 제작자는 참여 불가 - surveyId: {}, userKey: {}", surveyId, userKey); throw new CustomException(SurveyErrorCode.SURVEY_PARTICIPATION_OWN_SURVEY); } @@ -372,7 +373,7 @@ private boolean isActivationAvailable(Long surveyId, Long userKey) { final String potentialKey = this.potentialKey + surveyId; final String memberValue = String.valueOf(userKey); - Integer dueCount = RedisUtils.getIntValue(this.dueCountKey + surveyId); + Integer dueCount = redisAgent.getIntValue(this.dueCountKey + surveyId); /* dueCount가 설정되어 있지 않을 경우 0으로 반환되므로 이를 설정해줄 필요가 있음. (임의로 시작된 설문 등에 대한 방어코드) */ if (dueCount == 0) { dueCount = initialDueCount(surveyId); @@ -381,27 +382,27 @@ private boolean isActivationAvailable(Long surveyId, Long userKey) { boolean result; try { Integer finalDueCount = dueCount; - result = RedisUtils.executeWithLock(lockKey + surveyId, 5, 10, () -> { - Double existingScore = RedisUtils.getZSetScore(potentialKey, memberValue); + result = redisAgent.executeWithLock(lockKey + surveyId, 5, 10, () -> { + Double existingScore = redisAgent.getZSetScore(potentialKey, memberValue); // 새로운 참여자인 경우 if (existingScore == null) { - long activePotentialCount = RedisUtils.getZSetCount( + long activePotentialCount = redisAgent.getZSetCount( potentialKey, System.currentTimeMillis() - potentialDuration.toMillis(), Long.MAX_VALUE ); - int completedCount = RedisUtils.getIntValue(this.completedKey + surveyId); + int completedCount = redisAgent.getIntValue(this.completedKey + surveyId); if (activePotentialCount + 1 + completedCount > finalDueCount) { return false; } // Sorted Set에 현재 시간을 score로 사용자 추가 - RedisUtils.addToZSet(potentialKey, memberValue, System.currentTimeMillis()); + redisAgent.addToZSet(potentialKey, memberValue, System.currentTimeMillis()); } else { // 기존 참여자 - score 갱신 - RedisUtils.addToZSet(potentialKey, memberValue, System.currentTimeMillis()); + redisAgent.addToZSet(potentialKey, memberValue, System.currentTimeMillis()); } return true; }); @@ -414,7 +415,7 @@ private boolean isActivationAvailable(Long surveyId, Long userKey) { throw new CustomException(SurveyErrorCode.SURVEY_PARTICIPATION_TEMP_EXCEEDED); } finally { try { - RedisUtils.rangeRemoveFromZSet(potentialKey, 0, System.currentTimeMillis() - potentialDuration.toMillis()); + redisAgent.rangeRemoveFromZSet(potentialKey, 0, System.currentTimeMillis() - potentialDuration.toMillis()); } catch (Exception ignore) { log.warn("[SURVEY:QUERY] 만료된 참여자 정리 중 오류 발생", ignore); } @@ -425,8 +426,8 @@ private boolean isActivationAvailable(Long surveyId, Long userKey) { private Integer initialDueCount(Long surveyId) { try { - return RedisUtils.executeWithLock(lockKey + surveyId, 3, 6, () -> { - int dueCount = RedisUtils.getIntValue(this.dueCountKey + surveyId); + return redisAgent.executeWithLock(lockKey + surveyId, 3, 6, () -> { + int dueCount = redisAgent.getIntValue(this.dueCountKey + surveyId); // 다른 스레드에서 값이 설정된 경우, 재조회하지 않고 그대로 값 반환하도록 더블체크 if (dueCount > 0) { @@ -454,7 +455,7 @@ private Integer setDueCount(Long surveyId) { Duration duration = Duration.between( LocalDateTime.now(), survey.getDeadline()); - RedisUtils.setValue(this.dueCountKey + surveyId, String.valueOf(surveyInfo.getDueCount()), duration); + redisAgent.setValue(this.dueCountKey + surveyId, String.valueOf(surveyInfo.getDueCount()), duration); return surveyInfo.getDueCount(); } diff --git a/src/main/java/OneQ/OnSurvey/global/auth/application/TossAuthFacade.java b/src/main/java/OneQ/OnSurvey/global/auth/application/TossAuthFacade.java index fd89e2c1..5db2ddaa 100644 --- a/src/main/java/OneQ/OnSurvey/global/auth/application/TossAuthFacade.java +++ b/src/main/java/OneQ/OnSurvey/global/auth/application/TossAuthFacade.java @@ -7,9 +7,9 @@ import OneQ.OnSurvey.global.auth.port.out.TossAuthPort; import OneQ.OnSurvey.global.common.exception.CustomException; import OneQ.OnSurvey.global.common.util.JwtDecodeUtils; -import OneQ.OnSurvey.global.common.util.RedisUtils; import OneQ.OnSurvey.global.infra.discord.notifier.AlertNotifier; import OneQ.OnSurvey.global.infra.discord.notifier.dto.TossAccessTokenAlert; +import OneQ.OnSurvey.global.infra.redis.RedisAgent; import OneQ.OnSurvey.global.infra.toss.auth.TossMemberInfoDecryptService; import OneQ.OnSurvey.global.infra.toss.auth.TossUnlinkValue; import OneQ.OnSurvey.global.infra.toss.common.dto.auth.*; @@ -49,6 +49,7 @@ public class TossAuthFacade implements AuthUseCase { private final TossMemberInfoDecryptService tossMemberInfoDecryptService; private final WithdrawalService withdrawalService; private final MemberQueryService memberQueryService; + private final RedisAgent redisAgent; private final AlertNotifier alertNotifier; @@ -167,7 +168,7 @@ public void unlink(Long userKey, TossUnlinkValue referrer) { private void updateDailyUser(Long userKey) { try { - RedisUtils.addToZSetIfAbsent(dailyUserKey, String.valueOf(userKey), System.currentTimeMillis()); + redisAgent.addToZSetIfAbsent(dailyUserKey, String.valueOf(userKey), System.currentTimeMillis()); } catch (Exception e) { log.warn("[TossAuthFacade] 일간 활성 사용자 업데이트 실패 - userKey: {}", userKey, e); } diff --git a/src/main/java/OneQ/OnSurvey/global/auth/token/TokenStore.java b/src/main/java/OneQ/OnSurvey/global/auth/token/TokenStore.java index 57134b9d..993b8f2d 100644 --- a/src/main/java/OneQ/OnSurvey/global/auth/token/TokenStore.java +++ b/src/main/java/OneQ/OnSurvey/global/auth/token/TokenStore.java @@ -1,6 +1,6 @@ package OneQ.OnSurvey.global.auth.token; -import OneQ.OnSurvey.global.common.util.RedisUtils; +import OneQ.OnSurvey.global.infra.redis.RedisAgent; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -11,11 +11,13 @@ @RequiredArgsConstructor public class TokenStore { + private final RedisAgent redisAgent; + public boolean acquireLock(String key, Duration ttl) { - Boolean ok = RedisUtils.setValueIfAbsent(key, "1", ttl); + Boolean ok = redisAgent.setValueIfAbsent(key, "1", ttl); return Boolean.TRUE.equals(ok); } public void releaseLock(String key) { - RedisUtils.deleteKeys(List.of(key)); + redisAgent.deleteKeys(List.of(key)); } } diff --git a/src/main/java/OneQ/OnSurvey/global/common/util/RedisUtils.java b/src/main/java/OneQ/OnSurvey/global/common/util/RedisUtils.java deleted file mode 100644 index 2d4c763a..00000000 --- a/src/main/java/OneQ/OnSurvey/global/common/util/RedisUtils.java +++ /dev/null @@ -1,222 +0,0 @@ -package OneQ.OnSurvey.global.common.util; - -import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import org.redisson.api.RLock; -import org.redisson.api.RedissonClient; -import org.redisson.client.RedisException; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Component; - -import java.time.Duration; -import java.util.List; -import java.util.concurrent.TimeUnit; -import java.util.function.Supplier; - -@Component -@RequiredArgsConstructor -public final class RedisUtils { - - private final StringRedisTemplate redisTemplate; - private final RedissonClient redisson; - - private static RedissonClient staticRedisson; - private static StringRedisTemplate staticRedisTemplate; - - @PostConstruct - public void init() { - staticRedisson = this.redisson; - staticRedisTemplate = this.redisTemplate; - } - - private static RLock getLock(String lockKey) { - return staticRedisson.getLock(lockKey); - } - - /** - * 락 획득 후 실행할 로직을 인자로 받아 분산락을 이용하여 실행하는 유틸리티 메서드 - * @param lockKey 분산락 설정을 위한 키 - * @param waitTIme 분산락 획득 대기시간 (단위: 초) - * @param leaseTime 분산락 최대 점유시간 (단위: 초) - * @param action 분산락 획득 후 실행할 로직 - * @return {@code action}의 실행 결과 - * @throws RedisException 락 획득 실패 시 예외 - * @throws InterruptedException 락 획득 대기 중 인터럽트 발생 시 예외 - */ - public static R executeWithLock( - String lockKey, long waitTIme, long leaseTime, Supplier action - ) throws InterruptedException, RedisException { - RLock lock = getLock(lockKey); - boolean available = lock.tryLock(waitTIme, leaseTime, TimeUnit.SECONDS); - - if (!available) { - throw new RedisException("락 획득 실패"); - } - - try { - return action.get(); - } finally { - lock.unlock(); - } - } - - /** - * 저장된 값을 조회하는 유틸리티 메서드 - * @param key 조회할 키 (keyPrefix + id 형태로 사용) - * @return String - */ - public static String getValue(String key) { - return staticRedisTemplate.opsForValue().get(key); - } - - /** - * 저장된 값을 int로 조회하는 유틸리티 메서드 - * @param key 조회할 키 (keyPrefix + id 형태로 사용) - * @return int, 존재하지 않으면 0 반환 - */ - public static int getIntValue(String key) { - String value = staticRedisTemplate.opsForValue().get(key); - return value != null ? Integer.parseInt(value) : 0; - } - - /** - * 저장된 값을 long으로 조회하는 유틸리티 메서드 - * @param key 조회할 키 (keyPrefix + id 형태로 사용) - * @return long, 존재하지 않으면 0 반환 - */ - public static long getLongValue(String key) { - String value = staticRedisTemplate.opsForValue().get(key); - return value != null ? Long.parseLong(value) : 0L; - } - - /** - * 값을 저장하는 유틸리티 메서드 - * @param key 저장할 키 (keyPrefix + id 형태로 사용) - * @param value 저장할 값 - * @param ttl TTL, 예: {@code Duration.ofSeconds(60)} - 60초 동안 유효 - */ - public static void setValue(String key, String value, Duration ttl) { - staticRedisTemplate.opsForValue().set(key, value, ttl); - } - - /** - * 키가 존재하지 않을 때에만 값을 저장하는 유틸리티 메서드 - * @param key 저장할 키 (keyPrefix + id 형태로 사용) - * @param value 저장할 값 - * @param ttl TTL, 예: {@code Duration.ofSeconds(60)} - 60초 동안 유효 - * @return 값이 저장됨 : true - *

이미 키가 존재하여 저장되지 않음 : false - */ - public static Boolean setValueIfAbsent(String key, String value, Duration ttl) { - return staticRedisTemplate.opsForValue().setIfAbsent(key, value, ttl); - } - - /** - * 값을 1 증가시키는 유틸리티 메서드 - * @param key 조회할 키 (keyPrefix + id 형태로 사용) - * @return 증가된 값 - */ - public static Long incrementValue(String key) { - return staticRedisTemplate.opsForValue().increment(key); - } - - /** - * 값을 증가시키는 유틸리티 메서드 - * @param key 조회할 키 (keyPrefix + id 형태로 사용) - * @param delta 증가시킬 값 - * @return 증가된 값 - */ - public static Long incrementValue(String key, long delta) { - return staticRedisTemplate.opsForValue().increment(key, delta); - } - - /** - * 값을 1 감소시키는 유틸리티 메서드 - * @param key 조회할 키 (keyPrefix + id 형태로 사용) - * @return 감소된 값 - */ - public static Long decrementValue(String key) { - return staticRedisTemplate.opsForValue().decrement(key); - } - - /** - * 값을 감소시키는 유틸리티 메서드 - * @param key 조회할 키 (keyPrefix + id 형태로 사용) - * @param delta 감소시킬 값 - * @return 감소된 값 - */ - public static Long decrementValue(String key, long delta) { - return staticRedisTemplate.opsForValue().decrement(key, delta); - } - - /** - * 키를 삭제하는 유틸리티 메서드 - * @param keyList 삭제할 키 리스트 (keyPrefix + id 형태로 사용) - */ - public static void deleteKeys(List keyList) { - if (keyList != null && !keyList.isEmpty()) { - staticRedisTemplate.delete(keyList); - } - } - - /** - * Sorted Set의 score 범위 내 요소 개수를 조회하는 유틸리티 메서드 - * @param key 조회할 키 (keyPrefix + id 형태로 사용) - * @param min 조회할 범위(score)의 최소값 - * @param max 조회할 범위(score)의 최대값 - * @return score 범위 내 요소 개수, 존재하지 않으면 0 반환 - */ - public static long getZSetCount(String key, long min, long max) { - Long count = staticRedisTemplate.opsForZSet().count(key, min, max); - return count != null ? count : 0L; - } - - /** - * Sorted Set에서 특정 요소의 score를 조회하는 유틸리티 메서드 - * @param key 조회할 키 (keyPrefix + id 형태로 사용) - * @param value 조회할 요소 값 - * @return 요소의 score, 존재하지 않으면 null 반환 - */ - public static Double getZSetScore(String key, String value) { - return staticRedisTemplate.opsForZSet().score(key, value); - } - - /** - * Sorted Set에 요소를 추가하거나 갱신하는 유틸리티 메서드 - * @param key 요소를 추가/갱신할 키 (keyPrefix + id 형태로 사용) - * @param value Sorted Set에 추가/갱신할 값 - * @param score Sorted Set에 추가/갱신할 값의 score - */ - public static void addToZSet(String key, String value, long score) { - staticRedisTemplate.opsForZSet().add(key, value, score); - } - - /** - * Sorted Set에 요소를 추가하는 유틸리티 메서드 (값을 갱신하지는 않음) - * @param key - 요소를 추가할 키 (keyPrefix + id 형태로 사용) - * @param value - Sorted Set에 추가할 값 - * @param score - Sorted Set에 추가할 값의 score - */ - public static void addToZSetIfAbsent(String key, String value, long score) { - staticRedisTemplate.opsForZSet().addIfAbsent(key, value, score); - } - - /** - * Sorted Set에서 특정 요소를 제거하는 유틸리티 메서드 - * @param key 삭제할 키 (keyPrefix + id 형태로 사용) - * @param value 삭제할 요소 값 - */ - public static void removeFromZSet(String key, String value) { - staticRedisTemplate.opsForZSet().remove(key, value); - } - - /** - * Sorted Set에서 score 범위 내 요소를 제거하는 유틸리티 메서드 - * @param key 삭제할 키 (keyPrefix + id 형태로 사용) - * @param min 삭제할 범위(score)의 최소값 - * @param max 삭제할 범위(score)의 최대값 - */ - public static void rangeRemoveFromZSet(String key, long min, long max) { - staticRedisTemplate.opsForZSet().removeRangeByScore(key, min, max); - } -} diff --git a/src/main/java/OneQ/OnSurvey/global/infra/redis/RedisAgent.java b/src/main/java/OneQ/OnSurvey/global/infra/redis/RedisAgent.java new file mode 100644 index 00000000..6eb87300 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/global/infra/redis/RedisAgent.java @@ -0,0 +1,236 @@ +package OneQ.OnSurvey.global.infra.redis; + +import lombok.RequiredArgsConstructor; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.redisson.client.RedisException; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +/* + Lettuce, Embedded Redis 등 다른 환경별 다른 Redis 클라이언트로 변경 시에 새로운 구현체를 구현하면 됨 + */ +@Component("RedisAgent") +@RequiredArgsConstructor +public class RedisAgent implements RedisLockAction, RedisCacheAction { + + private final RedissonClient redisson; + private final StringRedisTemplate redisTemplate; + + /** + * 락 획득을 위한 RLock 객체를 반환 + * @param lockKey 분산락 설정을 위한 키 + * @return RLock + */ + public RLock getLock(String lockKey) { + return redisson.getLock(lockKey); + } + + /** + * 락 획득 후 실행할 로직을 인자로 받아 분산락을 이용하여 실행 + * @param lockKey 분산락 설정을 위한 키 + * @param waitTIme 분산락 획득 대기시간 (단위: 초) + * @param leaseTime 분산락 최대 점유시간 (단위: 초) + * @param action 분산락 획득 후 실행할 로직 + * @return {@code action}의 실행 결과 + * @throws RedisException 락 획득 실패 시 예외 + * @throws InterruptedException 락 획득 대기 중 인터럽트 발생 시 예외 + */ + public R executeWithLock( + String lockKey, long waitTIme, long leaseTime, Supplier action + ) throws InterruptedException, RedisException { + RLock lock = getLock(lockKey); + boolean available = lock.tryLock(waitTIme, leaseTime, TimeUnit.SECONDS); + + if (!available) { + throw new RedisException("락 획득 실패"); + } + + try { + return action.get(); + } finally { + lock.unlock(); + } + } + + /** + * 저장된 값을 조회 + * + * @param key 조회할 키 (keyPrefix + id 형태로 사용) + * @return String + */ + public String getValue(String key) { + return redisTemplate.opsForValue().get(key); + } + + /** + * 저장된 값을 int로 조회 + * + * @param key 조회할 키 (keyPrefix + id 형태로 사용) + * @return int, 존재하지 않으면 0 반환 + */ + public int getIntValue(String key) { + String value = redisTemplate.opsForValue().get(key); + return value != null ? Integer.parseInt(value) : 0; + } + + /** + * 저장된 값을 long으로 조회 + * + * @param key 조회할 키 (keyPrefix + id 형태로 사용) + * @return long, 존재하지 않으면 0 반환 + */ + public long getLongValue(String key) { + String value = redisTemplate.opsForValue().get(key); + return value != null ? Long.parseLong(value) : 0L; + } + + /** + * 값을 저장 + * + * @param key 저장할 키 (keyPrefix + id 형태로 사용) + * @param value 저장할 값 + * @param ttl TTL, 예: {@code Duration.ofSeconds(60)} - 60초 동안 유효 + */ + public void setValue(String key, String value, Duration ttl) { + redisTemplate.opsForValue().set(key, value, ttl); + } + + /** + * 키가 존재하지 않을 때에만 값을 저장 + * + * @param key 저장할 키 (keyPrefix + id 형태로 사용) + * @param value 저장할 값 + * @param ttl TTL, 예: {@code Duration.ofSeconds(60)} - 60초 동안 유효 + * @return 값이 저장됨 : true + *

이미 키가 존재하여 저장되지 않음 : false + */ + public Boolean setValueIfAbsent(String key, String value, Duration ttl) { + return redisTemplate.opsForValue().setIfAbsent(key, value, ttl); + } + + /** + * 값을 1 증가 + * + * @param key 조회할 키 (keyPrefix + id 형태로 사용) + * @return 증가된 값 + */ + public Long incrementValue(String key) { + return redisTemplate.opsForValue().increment(key); + } + + /** + * 값을 증가 + * + * @param key 조회할 키 (keyPrefix + id 형태로 사용) + * @param delta 증가시킬 값 + * @return 증가된 값 + */ + public Long incrementValue(String key, long delta) { + return redisTemplate.opsForValue().increment(key, delta); + } + + /** + * 값을 1 감소 + * + * @param key 조회할 키 (keyPrefix + id 형태로 사용) + * @return 감소된 값 + */ + public Long decrementValue(String key) { + return redisTemplate.opsForValue().decrement(key); + } + + /** + * 값을 감소 + * + * @param key 조회할 키 (keyPrefix + id 형태로 사용) + * @param delta 감소시킬 값 + * @return 감소된 값 + */ + public Long decrementValue(String key, long delta) { + return redisTemplate.opsForValue().decrement(key, delta); + } + + /** + * 키를 삭제 + * + * @param keyList 삭제할 키 리스트 (keyPrefix + id 형태로 사용) + */ + public void deleteKeys(List keyList) { + if (keyList != null && !keyList.isEmpty()) { + redisTemplate.delete(keyList); + } + } + + /** + * Sorted Set의 score 범위 내 요소 개수를 조회 + * + * @param key 조회할 키 (keyPrefix + id 형태로 사용) + * @param min 조회할 범위(score)의 최소값 + * @param max 조회할 범위(score)의 최대값 + * @return score 범위 내 요소 개수, 존재하지 않으면 0 반환 + */ + public long getZSetCount(String key, long min, long max) { + Long count = redisTemplate.opsForZSet().count(key, min, max); + return count != null ? count : 0L; + } + + /** + * Sorted Set에서 특정 요소의 score를 조회 + * + * @param key 조회할 키 (keyPrefix + id 형태로 사용) + * @param value 조회할 요소 값 + * @return 요소의 score, 존재하지 않으면 null 반환 + */ + public Double getZSetScore(String key, String value) { + return redisTemplate.opsForZSet().score(key, value); + } + + /** + * Sorted Set에 요소를 추가하거나 갱신 + * + * @param key 요소를 추가/갱신할 키 (keyPrefix + id 형태로 사용) + * @param value Sorted Set에 추가/갱신할 값 + * @param score Sorted Set에 추가/갱신할 값의 score + */ + public void addToZSet(String key, String value, long score) { + redisTemplate.opsForZSet().add(key, value, score); + } + + /** + * Sorted Set에 요소를 추가 (값을 갱신하지는 않음) + * + * @param key - 요소를 추가할 키 (keyPrefix + id 형태로 사용) + * @param value - Sorted Set에 추가할 값 + * @param score - Sorted Set에 추가할 값의 score + */ + public void addToZSetIfAbsent(String key, String value, long score) { + redisTemplate.opsForZSet().addIfAbsent(key, value, score); + } + + /** + * Sorted Set에서 특정 요소를 제거 + * + * @param key 삭제할 키 (keyPrefix + id 형태로 사용) + * @param value 삭제할 요소 값 + */ + public void removeFromZSet(String key, String value) { + redisTemplate.opsForZSet().remove(key, value); + } + + /** + * Sorted Set에서 score 범위 내 요소를 제거 + * + * @param key 삭제할 키 (keyPrefix + id 형태로 사용) + * @param min 삭제할 범위(score)의 최소값 + * @param max 삭제할 범위(score)의 최대값 + */ + public void rangeRemoveFromZSet(String key, long min, long max) { + redisTemplate.opsForZSet().removeRangeByScore(key, min, max); + } +} diff --git a/src/main/java/OneQ/OnSurvey/global/infra/redis/RedisCacheAction.java b/src/main/java/OneQ/OnSurvey/global/infra/redis/RedisCacheAction.java new file mode 100644 index 00000000..4c25439a --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/global/infra/redis/RedisCacheAction.java @@ -0,0 +1,39 @@ +package OneQ.OnSurvey.global.infra.redis; + +import java.time.Duration; +import java.util.List; + +public interface RedisCacheAction { + + String getValue(String key); + + int getIntValue(String key); + + long getLongValue(String key); + + void setValue(String key, String value, Duration ttl); + + Boolean setValueIfAbsent(String key, String value, Duration ttl); + + Long incrementValue(String key); + + Long incrementValue(String key, long delta); + + Long decrementValue(String key); + + Long decrementValue(String key, long delta); + + void deleteKeys(List keyList); + + long getZSetCount(String key, long min, long max); + + Double getZSetScore(String key, String value); + + void addToZSet(String key, String value, long score); + + void addToZSetIfAbsent(String key, String value, long score); + + void removeFromZSet(String key, String value); + + void rangeRemoveFromZSet(String key, long min, long max); +} diff --git a/src/main/java/OneQ/OnSurvey/global/infra/redis/RedisLockAction.java b/src/main/java/OneQ/OnSurvey/global/infra/redis/RedisLockAction.java new file mode 100644 index 00000000..e1445c8f --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/global/infra/redis/RedisLockAction.java @@ -0,0 +1,13 @@ +package OneQ.OnSurvey.global.infra.redis; + +import org.redisson.api.RLock; +import org.redisson.client.RedisException; + +import java.util.function.Supplier; + +public interface RedisLockAction { + + RLock getLock(String lockKey); + + R executeWithLock(String lockKey, long waitTIme, long leaseTime, Supplier action) throws InterruptedException, RedisException; +} From 9e30182874b4351c493db8202448e6a223887db6 Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Thu, 19 Feb 2026 23:52:39 +0900 Subject: [PATCH 06/14] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 6a461a9fdd899a61c721d83f28a6068a673a4d3a) --- .../response/ResponseCommandService.java | 18 +++++++----- .../service/command/SurveyCommandService.java | 4 +++ .../global/common/config/RedisConfig.java | 9 ++++-- .../global/infra/redis/RedisAgent.java | 28 +++++++++++++++---- 4 files changed, 44 insertions(+), 15 deletions(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java b/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java index 67c376b7..496caf8b 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java @@ -10,6 +10,7 @@ import OneQ.OnSurvey.domain.survey.repository.surveyInfo.SurveyInfoRepository; import OneQ.OnSurvey.domain.survey.service.SurveyGlobalStatsService; import OneQ.OnSurvey.global.common.exception.CustomException; +import OneQ.OnSurvey.global.common.exception.ErrorCode; import OneQ.OnSurvey.global.infra.redis.RedisAgent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -18,7 +19,6 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; -import java.util.Objects; @Slf4j @Service @@ -44,8 +44,8 @@ public class ResponseCommandService implements ResponseCommand { @Override public Boolean createResponse(Long surveyId, Long memberId, Long userKey) { Response response = responseRepository - .findBySurveyIdAndMemberId(surveyId, memberId) - .orElseGet(() -> Response.of(surveyId, memberId)); + .findBySurveyIdAndMemberId(surveyId, memberId) + .orElseGet(() -> Response.of(surveyId, memberId)); if (Boolean.TRUE.equals(response.getIsResponded())) { throw new CustomException(SurveyErrorCode.SURVEY_ALREADY_PARTICIPATED); @@ -57,11 +57,11 @@ public Boolean createResponse(Long surveyId, Long memberId, Long userKey) { surveyGlobalStatsService.addCompletedCount(1); SurveyInfo surveyInfo = surveyInfoRepository.findBySurveyId(surveyId) - .orElseThrow(() -> new CustomException(SurveyErrorCode.SURVEY_INFO_NOT_FOUND)); + .orElseThrow(() -> new CustomException(SurveyErrorCode.SURVEY_INFO_NOT_FOUND)); surveyInfo.increaseCompletedCount(); int currCompleted = updateCounter(surveyId, userKey); - if (Objects.equals(currCompleted, surveyInfo.getDueCount())) { + if (currCompleted >= surveyInfo.getDueCount()) { Survey survey = surveyRepository.getSurveyById(surveyId) .orElseThrow(() -> new CustomException(SurveyErrorCode.SURVEY_NOT_FOUND)); @@ -82,7 +82,11 @@ private int updateCounter(Long surveyId, Long userKey) { Long currCompleted = regisAgent.incrementValue(this.completedKey + surveyId); // 잠재 응답자 Sorted Set에서 제거 regisAgent.removeFromZSet(this.potentialKey + surveyId, String.valueOf(userKey)); - // 완료 인원이 없어 증가가 되지 않은 경우 (null), 기존 완료 인원을 0으로 간주하여 1로 반환 - return currCompleted != null ? currCompleted.intValue() : 1; + + if (currCompleted == null) { + log.error("[RESPONSE:COMMAND] 레디스 완료 값 갱신 실패 - surveyId: {}, userKey: {}", surveyId, userKey); + throw new CustomException(ErrorCode.SERVER_UNTRACKED_ERROR); + } + return currCompleted.intValue(); } } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java index f71e1934..809cbb45 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java @@ -328,6 +328,10 @@ private SurveyFormResponse finalizeSubmit( private void applySurveyRuntimeCache(Long surveyId, Long userKey, Integer dueCount, LocalDateTime deadline) { Duration ttl = Duration.between(LocalDateTime.now(), deadline); + if (ttl.isNegative() || ttl.isZero()) { + log.warn("[SURVEY:COMMAND] 이미 지난 날짜가 마감기한으로 설정 - surveyId: {}, deadline: {}", surveyId, deadline); + throw new CustomException(SurveyErrorCode.SURVEY_INCORRECT_STATUS); + } redisAgent.setValue(this.dueCountKey + surveyId, String.valueOf(dueCount), ttl); redisAgent.setValue(this.completedKey + surveyId, "0", ttl); diff --git a/src/main/java/OneQ/OnSurvey/global/common/config/RedisConfig.java b/src/main/java/OneQ/OnSurvey/global/common/config/RedisConfig.java index e27f166c..4f5557cb 100644 --- a/src/main/java/OneQ/OnSurvey/global/common/config/RedisConfig.java +++ b/src/main/java/OneQ/OnSurvey/global/common/config/RedisConfig.java @@ -3,6 +3,7 @@ import org.redisson.Redisson; import org.redisson.api.RedissonClient; import org.redisson.config.Config; +import org.redisson.config.SingleServerConfig; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -16,13 +17,17 @@ public class RedisConfig { @Value("${spring.data.redis.port}") private int port; - @Value("${spring.data.redis.password}") + @Value("${spring.data.redis.password:}") private String password; @Bean public RedissonClient redisson() { Config config = new Config(); - config.useSingleServer().setAddress("redis://" + host + ":" + port).setPassword(password); + SingleServerConfig singleServerConfig = config.useSingleServer() + .setAddress("redis://" + host + ":" + port); + if (password != null && !password.isBlank()) { + singleServerConfig.setPassword(password); + } return Redisson.create(config); } diff --git a/src/main/java/OneQ/OnSurvey/global/infra/redis/RedisAgent.java b/src/main/java/OneQ/OnSurvey/global/infra/redis/RedisAgent.java index 6eb87300..2bd6fe8e 100644 --- a/src/main/java/OneQ/OnSurvey/global/infra/redis/RedisAgent.java +++ b/src/main/java/OneQ/OnSurvey/global/infra/redis/RedisAgent.java @@ -1,6 +1,9 @@ package OneQ.OnSurvey.global.infra.redis; +import OneQ.OnSurvey.global.common.exception.CustomException; +import OneQ.OnSurvey.global.common.exception.ErrorCode; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.redisson.client.RedisException; @@ -15,6 +18,7 @@ /* Lettuce, Embedded Redis 등 다른 환경별 다른 Redis 클라이언트로 변경 시에 새로운 구현체를 구현하면 됨 */ +@Slf4j @Component("RedisAgent") @RequiredArgsConstructor public class RedisAgent implements RedisLockAction, RedisCacheAction { @@ -34,7 +38,7 @@ public RLock getLock(String lockKey) { /** * 락 획득 후 실행할 로직을 인자로 받아 분산락을 이용하여 실행 * @param lockKey 분산락 설정을 위한 키 - * @param waitTIme 분산락 획득 대기시간 (단위: 초) + * @param waitTime 분산락 획득 대기시간 (단위: 초) * @param leaseTime 분산락 최대 점유시간 (단위: 초) * @param action 분산락 획득 후 실행할 로직 * @return {@code action}의 실행 결과 @@ -42,10 +46,10 @@ public RLock getLock(String lockKey) { * @throws InterruptedException 락 획득 대기 중 인터럽트 발생 시 예외 */ public R executeWithLock( - String lockKey, long waitTIme, long leaseTime, Supplier action + String lockKey, long waitTime, long leaseTime, Supplier action ) throws InterruptedException, RedisException { RLock lock = getLock(lockKey); - boolean available = lock.tryLock(waitTIme, leaseTime, TimeUnit.SECONDS); + boolean available = lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS); if (!available) { throw new RedisException("락 획득 실패"); @@ -54,7 +58,9 @@ public R executeWithLock( try { return action.get(); } finally { - lock.unlock(); + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } } } @@ -76,7 +82,12 @@ public String getValue(String key) { */ public int getIntValue(String key) { String value = redisTemplate.opsForValue().get(key); - return value != null ? Integer.parseInt(value) : 0; + try { + return value != null ? Integer.parseInt(value) : 0; + } catch (NumberFormatException e) { + log.warn("[RedisAgent] 캐시값 Integer 파싱 실패 - key: {}, value: {}", key, value); + throw new CustomException(ErrorCode.SERVER_UNTRACKED_ERROR); + } } /** @@ -87,7 +98,12 @@ public int getIntValue(String key) { */ public long getLongValue(String key) { String value = redisTemplate.opsForValue().get(key); - return value != null ? Long.parseLong(value) : 0L; + try { + return value != null ? Long.parseLong(value) : 0L; + } catch (NumberFormatException e) { + log.warn("[RedisAgent] 캐시값 Long 파싱 실패 - key: {}, value: {}", key, value); + throw new CustomException(ErrorCode.SERVER_UNTRACKED_ERROR); + } } /** From 2b78f8491f0c19f93a00ceda53cbd324a3ce0b40 Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Fri, 20 Feb 2026 18:45:04 +0900 Subject: [PATCH 07/14] =?UTF-8?q?fix:=20=EC=84=A4=EB=AC=B8=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EC=A0=9C=EC=B6=9C=20=EC=8B=9C=20=EB=9D=BD=EC=9D=84?= =?UTF-8?q?=20=ED=86=B5=ED=95=B4=20=EC=A4=91=EB=B3=B5=EC=A0=9C=EC=B6=9C?= =?UTF-8?q?=EC=9D=B4=20=EB=B0=9C=EC=83=9D=ED=95=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 7d89cc95962e03684d4b313a35095aff0abe7174) --- .../service/answer/AnswerCommand.java | 2 +- .../service/answer/AnswerCommandService.java | 2 +- .../answer/QuestionAnswerCommandService.java | 149 ++++++++++-------- .../controller/ParticipationController.java | 2 +- .../service/query/SurveyQueryService.java | 2 - 5 files changed, 84 insertions(+), 73 deletions(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/AnswerCommand.java b/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/AnswerCommand.java index 58d4d011..1396c8ac 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/AnswerCommand.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/AnswerCommand.java @@ -4,6 +4,6 @@ import OneQ.OnSurvey.domain.participation.model.dto.AnswerInsertDto; public interface AnswerCommand { - Boolean upsertAnswers(AnswerInsertDto insertDto); + Boolean upsertAnswers(AnswerInsertDto insertDto, Long surveyId, Long userKey, Long memberId); Boolean insertAnswer(AnswerInsertDto.AnswerInfo answerInfo); } diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/AnswerCommandService.java b/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/AnswerCommandService.java index dd1bacab..7f45fb50 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/AnswerCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/AnswerCommandService.java @@ -16,7 +16,7 @@ public abstract class AnswerCommandService implements protected final AnswerRepository answerRepository; protected final ResponseRepository responseRepository; - public Boolean upsertAnswers(AnswerInsertDto insertDto) { + public Boolean upsertAnswers(AnswerInsertDto insertDto, Long surveyId, Long userKey, Long memberId) { List answerList = insertDto.getAnswerInfoList().stream() .map(this::createAnswerFromDto) .toList(); diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/QuestionAnswerCommandService.java b/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/QuestionAnswerCommandService.java index ad84423d..07f18dd2 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/QuestionAnswerCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/QuestionAnswerCommandService.java @@ -5,8 +5,10 @@ import OneQ.OnSurvey.domain.participation.model.dto.AnswerInsertDto; import OneQ.OnSurvey.domain.participation.repository.answer.AnswerRepository; import OneQ.OnSurvey.domain.participation.repository.response.ResponseRepository; -import OneQ.OnSurvey.domain.question.repository.question.QuestionRepository; +import OneQ.OnSurvey.global.infra.redis.RedisAgent; import lombok.extern.slf4j.Slf4j; +import org.redisson.client.RedisException; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -23,15 +25,18 @@ @Transactional public class QuestionAnswerCommandService extends AnswerCommandService { - private final QuestionRepository questionRepository; + @Value("${redis.survey-key-prefix.lock}") + private String surveyLockKeyPrefix; + + private final RedisAgent redisAgent; public QuestionAnswerCommandService( AnswerRepository answerRepository, ResponseRepository responseRepository, - QuestionRepository questionRepository + RedisAgent redisAgent ) { super(answerRepository, responseRepository); - this.questionRepository = questionRepository; + this.redisAgent = redisAgent; } @Override @@ -40,84 +45,92 @@ public QuestionAnswer createAnswerFromDto(AnswerInsertDto.AnswerInfo answerInfo) } @Override - public Boolean upsertAnswers(AnswerInsertDto insertDto) { - AnswerInsertDto.AnswerInfo first = insertDto.getAnswerInfoList().getFirst(); - Long memberId = first.getMemberId(); + public Boolean upsertAnswers(AnswerInsertDto insertDto, Long surveyId, Long userKey, Long memberId) { log.info("[QUESTION_ANSWER:COMMAND] 문항 응답 생성 - memberId: {}", memberId); // 새로운 응답을 questionId 기준으로 그룹화 Map> newQuestionAnswerMap = insertDto.getAnswerInfoList().stream() .map(this::createAnswerFromDto) .collect(Collectors.groupingBy(QuestionAnswer::getQuestionId, Collectors.toSet())); - - // 새로운 응답의 questionId로부터 기존 응답 조회 및 그룹화 List questionIdList = newQuestionAnswerMap.keySet().stream().toList(); - Map> existingQuestionAnswerMap = - answerRepository.getAnswerListByQuestionIdsAndMemberId(questionIdList, memberId) - .stream() - .collect(Collectors.groupingBy(QuestionAnswer::getQuestionId, Collectors.toSet())); - - // 새로 저장할 응답 리스트 - List finalAnswersToSave = new ArrayList<>(); - // 삭제하지 않을 ID - Set idSetToKeep = new HashSet<>(); - - questionIdList.forEach(questionId -> { - // questionId에 대한 새로운 응답과 기존 응답의 content 집합 생성 - Set newAnswerContentSet = newQuestionAnswerMap.getOrDefault(questionId, Set.of()); - Set newContents = newAnswerContentSet.stream() - .map(QuestionAnswer::getContent) - .map(content -> content == null ? null : content.strip()) - .collect(Collectors.toSet()); - Set existingAnswerContentSet = existingQuestionAnswerMap.getOrDefault(questionId, Set.of()); - Set existingContents = existingAnswerContentSet.stream() - .map(QuestionAnswer::getContent) - .collect(Collectors.toSet()); - - // 새로운 응답이 null을 포함한 경우(객관식) 혹은 빈 문자열인 경우(단답/장문), 해당 문항의 기존 응답은 모두 삭제 대상에 남겨둠 - if (!newContents.contains(null) && !newContents.contains("")) { - // 새로운 응답 중 기존에 없는 content는 저장 대상에 추가 - newAnswerContentSet.stream() - .filter(newAnswer -> !existingContents.contains(newAnswer.getContent())) - .forEach(finalAnswersToSave::add); - // 새로운 응답에 포함된 기존 응답은 삭제 대상에서 제외 - existingAnswerContentSet.stream() - .filter(existingAnswer -> newContents.contains(existingAnswer.getContent())) - .map(QuestionAnswer::getAnswerId) - .forEach(idSetToKeep::add); - } - }); - // 삭제할 기존 응답 ID 리스트 (초기값: 기존 응답 전체) - List finalAnswerIdsToDelete = existingQuestionAnswerMap.values().stream() - .flatMap(Collection::stream) - .map(QuestionAnswer::getAnswerId) - .collect(Collectors.toList()); - finalAnswerIdsToDelete.removeAll(idSetToKeep); - - if (!finalAnswersToSave.isEmpty()) { - answerRepository.saveAll(finalAnswersToSave); - } - if (!finalAnswerIdsToDelete.isEmpty()) { - answerRepository.deleteAllByIds(finalAnswerIdsToDelete); - } - Long surveyId = getSurveyIdFromQuestion(first.getId()); - updateResponseAfterQuestionAnswers(surveyId, first); - return true; - } - - private Long getSurveyIdFromQuestion(Long questionId) { - return questionRepository.getSurveyId(questionId); + String lockKey = surveyLockKeyPrefix + surveyId + ":" + userKey; + try { + return redisAgent.executeWithLock(lockKey, 3, 5, () -> { + /* + 새로운 응답의 questionId로부터 기존 응답 조회 및 그룹화 + Phantom Read 방지를 위해 조회 로직도 락 내부에서 실행 + */ + Map> existingQuestionAnswerMap = + answerRepository.getAnswerListByQuestionIdsAndMemberId(questionIdList, memberId) + .stream() + .collect(Collectors.groupingBy(QuestionAnswer::getQuestionId, Collectors.toSet())); + + // 새로 저장할 응답 리스트 + List finalAnswersToSave = new ArrayList<>(); + // 삭제하지 않을 ID + Set idSetToKeep = new HashSet<>(); + + questionIdList.forEach(questionId -> { + // questionId에 대한 새로운 응답과 기존 응답의 content 집합 생성 + Set newAnswerContentSet = newQuestionAnswerMap.getOrDefault(questionId, Set.of()); + Set newContents = newAnswerContentSet.stream() + .map(QuestionAnswer::getContent) + .map(content -> content == null ? null : content.strip()) + .collect(Collectors.toSet()); + Set existingAnswerContentSet = existingQuestionAnswerMap.getOrDefault(questionId, Set.of()); + Set existingContents = existingAnswerContentSet.stream() + .map(QuestionAnswer::getContent) + .collect(Collectors.toSet()); + + // 새로운 응답이 null을 포함한 경우(객관식) 혹은 빈 문자열인 경우(단답/장문), 해당 문항의 기존 응답은 모두 삭제 대상에 남겨둠 + if (!newContents.contains(null) && !newContents.contains("")) { + // 새로운 응답 중 기존에 없는 content는 저장 대상에 추가 + newAnswerContentSet.stream() + .filter(newAnswer -> !existingContents.contains(newAnswer.getContent())) + .forEach(finalAnswersToSave::add); + // 새로운 응답에 포함된 기존 응답은 삭제 대상에서 제외 + existingAnswerContentSet.stream() + .filter(existingAnswer -> newContents.contains(existingAnswer.getContent())) + .map(QuestionAnswer::getAnswerId) + .forEach(idSetToKeep::add); + } + }); + // 삭제할 기존 응답 ID 리스트 (초기값: 기존 응답 전체) + List finalAnswerIdsToDelete = existingQuestionAnswerMap.values().stream() + .flatMap(Collection::stream) + .map(QuestionAnswer::getAnswerId) + .collect(Collectors.toList()); + finalAnswerIdsToDelete.removeAll(idSetToKeep); + + if (!finalAnswersToSave.isEmpty()) { + answerRepository.saveAll(finalAnswersToSave); + } + if (!finalAnswerIdsToDelete.isEmpty()) { + answerRepository.deleteAllByIds(finalAnswerIdsToDelete); + } + + updateResponseAfterQuestionAnswers(surveyId, memberId); + + return true; + }); + } catch (RedisException e) { + log.warn("[QUESTION_ANSWER:COMMAND] 문항 응답 저장 락 획득 실패 - memberId: {}, error: {}", memberId, e.getMessage()); + return false; + } catch (InterruptedException e) { + log.error("[QUESTION_ANSWER:COMMAND] 문항 응답 저장 중 오류 발생 - memberId: {}, error: {}", memberId, e.getMessage()); + return false; + } } + // TODO - memberId를 userKey로 변경 public void updateResponseAfterQuestionAnswers( - Long surveyId, - AnswerInsertDto.AnswerInfo answerInfo + Long surveyId, Long memberId ) { Response response = responseRepository - .findBySurveyIdAndMemberId(surveyId, answerInfo.getMemberId()) - .orElseGet(() -> Response.of(surveyId, answerInfo.getMemberId())); + .findBySurveyIdAndMemberId(surveyId, memberId) + .orElseGet(() -> Response.of(surveyId, memberId)); responseRepository.save(response); } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/controller/ParticipationController.java b/src/main/java/OneQ/OnSurvey/domain/survey/controller/ParticipationController.java index dbaa9447..23327f22 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/controller/ParticipationController.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/controller/ParticipationController.java @@ -260,7 +260,7 @@ public SuccessResponse createQuestionAnswer( AnswerInsertDto answerInsertDto = request.toDto(principal.getMemberId()); - return SuccessResponse.ok(questionAnswerCommand.upsertAnswers(answerInsertDto)); + return SuccessResponse.ok(questionAnswerCommand.upsertAnswers(answerInsertDto, surveyId, principal.getUserKey(), principal.getMemberId())); } @PostMapping("surveys/{surveyId}/complete") diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQueryService.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQueryService.java index 856d4b60..7852c8e4 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQueryService.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQueryService.java @@ -470,8 +470,6 @@ public boolean checkValidSegmentation(Long surveyId, Long userKey) { throw new CustomException(SurveyErrorCode.SURVEY_WRONG_SEGMENTATION); } - log.info("{}", !(checkAgeSegmentation(surveySegmentation.getAges(), memberSegmentation.convertBirthDayIntoAgeRange()) - && checkGenderSegmentation(surveySegmentation.getGender(), memberSegmentation.getGender()))); return !(checkAgeSegmentation(surveySegmentation.getAges(), memberSegmentation.convertBirthDayIntoAgeRange()) && checkGenderSegmentation(surveySegmentation.getGender(), memberSegmentation.getGender())); // || checkResidenceSegmentation(surveySegmentation.residence(), memberSegmentation.residence()); From 51aa7803d98d5fe5766686ce6a7fb74faddb8cb2 Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Sat, 21 Feb 2026 18:20:32 +0900 Subject: [PATCH 08/14] =?UTF-8?q?fix:=20=EC=9D=91=EB=8B=B5=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EC=8B=9C=EC=97=90=EB=8F=84=20=EB=9D=BD=20=ED=99=9C?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit ba95d62feae922d901e1dd15d95745d786b1a8ef) --- .../answer/QuestionAnswerCommandService.java | 15 ++-- .../response/ResponseCommandService.java | 70 +++++++++++-------- .../domain/survey/SurveyErrorCode.java | 1 + 3 files changed, 54 insertions(+), 32 deletions(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/QuestionAnswerCommandService.java b/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/QuestionAnswerCommandService.java index 07f18dd2..eff8a001 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/QuestionAnswerCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/QuestionAnswerCommandService.java @@ -5,6 +5,9 @@ import OneQ.OnSurvey.domain.participation.model.dto.AnswerInsertDto; import OneQ.OnSurvey.domain.participation.repository.answer.AnswerRepository; import OneQ.OnSurvey.domain.participation.repository.response.ResponseRepository; +import OneQ.OnSurvey.domain.survey.SurveyErrorCode; +import OneQ.OnSurvey.global.common.exception.CustomException; +import OneQ.OnSurvey.global.common.exception.ErrorCode; import OneQ.OnSurvey.global.infra.redis.RedisAgent; import lombok.extern.slf4j.Slf4j; import org.redisson.client.RedisException; @@ -57,7 +60,7 @@ public Boolean upsertAnswers(AnswerInsertDto insertDto, Long surveyId, Long user String lockKey = surveyLockKeyPrefix + surveyId + ":" + userKey; try { - return redisAgent.executeWithLock(lockKey, 3, 5, () -> { + return redisAgent.executeNewTransactionAfterLock(lockKey, 3, 5, () -> { /* 새로운 응답의 questionId로부터 기존 응답 조회 및 그룹화 Phantom Read 방지를 위해 조회 로직도 락 내부에서 실행 @@ -117,10 +120,11 @@ public Boolean upsertAnswers(AnswerInsertDto insertDto, Long surveyId, Long user }); } catch (RedisException e) { log.warn("[QUESTION_ANSWER:COMMAND] 문항 응답 저장 락 획득 실패 - memberId: {}, error: {}", memberId, e.getMessage()); - return false; + throw new CustomException(SurveyErrorCode.SURVEY_PARTICIPATION_IN_PROCESS); } catch (InterruptedException e) { + Thread.currentThread().interrupt(); log.error("[QUESTION_ANSWER:COMMAND] 문항 응답 저장 중 오류 발생 - memberId: {}, error: {}", memberId, e.getMessage()); - return false; + throw new CustomException(ErrorCode.SERVER_UNTRACKED_ERROR); } } @@ -132,6 +136,9 @@ public void updateResponseAfterQuestionAnswers( .findBySurveyIdAndMemberId(surveyId, memberId) .orElseGet(() -> Response.of(surveyId, memberId)); - responseRepository.save(response); + // 완료된 응답이 잘못 업데이트 되는 것을 방지하기 위해, 응답이 완료되지 않은 경우에만 저장 + if (Boolean.FALSE.equals(response.getIsResponded())) { + responseRepository.save(response); + } } } diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java b/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java index 496caf8b..5202ca2e 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java @@ -14,6 +14,7 @@ import OneQ.OnSurvey.global.infra.redis.RedisAgent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.redisson.client.RedisException; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -30,8 +31,10 @@ public class ResponseCommandService implements ResponseCommand { private final SurveyRepository surveyRepository; private final SurveyInfoRepository surveyInfoRepository; private final SurveyGlobalStatsService surveyGlobalStatsService; - private final RedisAgent regisAgent; + private final RedisAgent redisAgent; + @Value("${redis.survey-key-prefix.lock}") + private String surveyLockKeyPrefix; @Value("${redis.survey-key-prefix.potential-count}") private String potentialKey; @Value("${redis.survey-key-prefix.completed-count}") @@ -43,45 +46,56 @@ public class ResponseCommandService implements ResponseCommand { @Override public Boolean createResponse(Long surveyId, Long memberId, Long userKey) { - Response response = responseRepository - .findBySurveyIdAndMemberId(surveyId, memberId) - .orElseGet(() -> Response.of(surveyId, memberId)); + try { + return redisAgent.executeWithLock(surveyLockKeyPrefix + surveyId + ":" + userKey, 3, 5, () -> { + Response response = responseRepository + .findBySurveyIdAndMemberId(surveyId, memberId) + .orElseGet(() -> Response.of(surveyId, memberId)); - if (Boolean.TRUE.equals(response.getIsResponded())) { - throw new CustomException(SurveyErrorCode.SURVEY_ALREADY_PARTICIPATED); - } + if (Boolean.TRUE.equals(response.getIsResponded())) { + throw new CustomException(SurveyErrorCode.SURVEY_ALREADY_PARTICIPATED); + } - response.markResponded(); - responseRepository.save(response); + response.markResponded(); + responseRepository.save(response); - surveyGlobalStatsService.addCompletedCount(1); + surveyGlobalStatsService.addCompletedCount(1); - SurveyInfo surveyInfo = surveyInfoRepository.findBySurveyId(surveyId) - .orElseThrow(() -> new CustomException(SurveyErrorCode.SURVEY_INFO_NOT_FOUND)); + SurveyInfo surveyInfo = surveyInfoRepository.findBySurveyId(surveyId) + .orElseThrow(() -> new CustomException(SurveyErrorCode.SURVEY_INFO_NOT_FOUND)); - surveyInfo.increaseCompletedCount(); - int currCompleted = updateCounter(surveyId, userKey); - if (currCompleted >= surveyInfo.getDueCount()) { - Survey survey = surveyRepository.getSurveyById(surveyId) - .orElseThrow(() -> new CustomException(SurveyErrorCode.SURVEY_NOT_FOUND)); + surveyInfo.increaseCompletedCount(); + int currCompleted = updateCounter(surveyId, userKey); + if (currCompleted >= surveyInfo.getDueCount()) { + Survey survey = surveyRepository.getSurveyById(surveyId) + .orElseThrow(() -> new CustomException(SurveyErrorCode.SURVEY_NOT_FOUND)); - survey.updateSurveyStatus(SurveyStatus.CLOSED); - regisAgent.deleteKeys(List.of( - this.dueCountKey + surveyId, - this.completedKey + surveyId, - this.potentialKey + surveyId, - this.creatorKey + surveyId - )); - } + survey.updateSurveyStatus(SurveyStatus.CLOSED); + redisAgent.deleteKeys(List.of( + this.dueCountKey + surveyId, + this.completedKey + surveyId, + this.potentialKey + surveyId, + this.creatorKey + surveyId + )); + } - return true; + return true; + }); + } catch (RedisException e) { + log.warn("[RESPONSE:COMMAND] 응답완료 처리 중 레디스 락 획득 실패 - surveyId: {}, userKey: {}. 잠시 후 다시 시도해주세요.", surveyId, userKey, e); + throw new CustomException(SurveyErrorCode.SURVEY_PARTICIPATION_IN_PROCESS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("[RESPONSE:COMMAND] 응답완료 처리 중 인터럽트 발생 - surveyId: {}, userKey: {}", surveyId, userKey, e); + throw new CustomException(ErrorCode.SERVER_UNTRACKED_ERROR); + } } private int updateCounter(Long surveyId, Long userKey) { // 완료 인원 추가 - Long currCompleted = regisAgent.incrementValue(this.completedKey + surveyId); + Long currCompleted = redisAgent.incrementValue(this.completedKey + surveyId); // 잠재 응답자 Sorted Set에서 제거 - regisAgent.removeFromZSet(this.potentialKey + surveyId, String.valueOf(userKey)); + redisAgent.removeFromZSet(this.potentialKey + surveyId, String.valueOf(userKey)); if (currCompleted == null) { log.error("[RESPONSE:COMMAND] 레디스 완료 값 갱신 실패 - surveyId: {}, userKey: {}", surveyId, userKey); diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/SurveyErrorCode.java b/src/main/java/OneQ/OnSurvey/domain/survey/SurveyErrorCode.java index dfad10ae..6c5550e7 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/SurveyErrorCode.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/SurveyErrorCode.java @@ -17,6 +17,7 @@ public enum SurveyErrorCode implements ApiErrorCode { SURVEY_PARTICIPATION_TEMP_EXCEEDED("SURVEY_PARTICIPATION_TEMP_EXCEEDED_409", "설문 참여 가능 인원이 일시적으로 초과되었습니다.", HttpStatus.CONFLICT), SURVEY_PARTICIPATION_OWN_SURVEY("SURVEY_PARTICIPATION_OWN_403", "본인이 생성한 설문에는 참여할 수 없습니다.", HttpStatus.FORBIDDEN), + SURVEY_PARTICIPATION_IN_PROCESS("SURVEY_PARTICIPATION_IN_409", "제출한 설문 응답이 처리 중입니다.", HttpStatus.CONFLICT), SURVEY_INCORRECT_STATUS("SURVEY_STATUS_400", "요청과 설문 상태가 올바르지 않습니다.", HttpStatus.BAD_REQUEST), SURVEY_FORM_INVALID_QUESTION_TYPE("SURVEY_FORM_QUESTION_TYPE_400", "문항 타입이 올바르지 않습니다.", HttpStatus.BAD_REQUEST), From 06aa8c5d565d4d72c51fe34021b925eacda0c160 Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Sat, 21 Feb 2026 19:08:25 +0900 Subject: [PATCH 09/14] =?UTF-8?q?feat:=20=EA=B0=B1=EC=8B=A0=EC=86=90?= =?UTF-8?q?=EC=8B=A4,=20=ED=8C=AC=ED=85=80=EB=A6=AC=EB=93=9C=EB=A5=BC=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80=ED=95=98=EA=B8=B0=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EB=9D=BD=20=ED=9A=8D=EB=93=9D=20=ED=9B=84=20=ED=8A=B8=EB=9E=9C?= =?UTF-8?q?=EC=9E=AD=EC=85=98=EC=9D=84=20=EC=88=98=ED=96=89=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 491bbf02bb07c17f930068c206c97d219fcc03e8) --- .../answer/QuestionAnswerCommandService.java | 2 +- .../response/ResponseCommandService.java | 4 +-- .../global/infra/redis/RedisAgent.java | 33 +++++++++++++++++++ .../infra/transaction/TransactionHandler.java | 16 +++++++++ 4 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 src/main/java/OneQ/OnSurvey/global/infra/transaction/TransactionHandler.java diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/QuestionAnswerCommandService.java b/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/QuestionAnswerCommandService.java index eff8a001..9d0c82e9 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/QuestionAnswerCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/QuestionAnswerCommandService.java @@ -25,7 +25,6 @@ @Slf4j @Service -@Transactional public class QuestionAnswerCommandService extends AnswerCommandService { @Value("${redis.survey-key-prefix.lock}") @@ -43,6 +42,7 @@ public QuestionAnswerCommandService( } @Override + @Transactional public QuestionAnswer createAnswerFromDto(AnswerInsertDto.AnswerInfo answerInfo) { return QuestionAnswer.from(answerInfo); } diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java b/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java index 5202ca2e..e53853ca 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java @@ -17,14 +17,12 @@ import org.redisson.client.RedisException; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import java.util.List; @Slf4j @Service @RequiredArgsConstructor -@Transactional public class ResponseCommandService implements ResponseCommand { private final ResponseRepository responseRepository; @@ -47,7 +45,7 @@ public class ResponseCommandService implements ResponseCommand { @Override public Boolean createResponse(Long surveyId, Long memberId, Long userKey) { try { - return redisAgent.executeWithLock(surveyLockKeyPrefix + surveyId + ":" + userKey, 3, 5, () -> { + return redisAgent.executeNewTransactionAfterLock(surveyLockKeyPrefix + surveyId + ":" + userKey, 3, 5, () -> { Response response = responseRepository .findBySurveyIdAndMemberId(surveyId, memberId) .orElseGet(() -> Response.of(surveyId, memberId)); diff --git a/src/main/java/OneQ/OnSurvey/global/infra/redis/RedisAgent.java b/src/main/java/OneQ/OnSurvey/global/infra/redis/RedisAgent.java index 2bd6fe8e..29bf1390 100644 --- a/src/main/java/OneQ/OnSurvey/global/infra/redis/RedisAgent.java +++ b/src/main/java/OneQ/OnSurvey/global/infra/redis/RedisAgent.java @@ -2,6 +2,7 @@ import OneQ.OnSurvey.global.common.exception.CustomException; import OneQ.OnSurvey.global.common.exception.ErrorCode; +import OneQ.OnSurvey.global.infra.transaction.TransactionHandler; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.redisson.api.RLock; @@ -25,6 +26,7 @@ public class RedisAgent implements RedisLockAction, RedisCacheAction { private final RedissonClient redisson; private final StringRedisTemplate redisTemplate; + private final TransactionHandler transactionhandler; /** * 락 획득을 위한 RLock 객체를 반환 @@ -64,6 +66,37 @@ public R executeWithLock( } } + /** + * 락 획득 후 실행할 트랜잭션 로직을 인자로 받아 분산락을 이용하여 실행 + *

락 획득 후 DB에 접근하여 값을 수정하는 로직을 실행해야 할 때 사용, + *
강력한 일관성이 필요한 데이터에 대해서는 단순 조회 로직에도 사용해야 함 + * @param lockKey 분산락 설정을 위한 키 + * @param waitTime 분산락 획득 대기시간 (단위: 초) + * @param leaseTime 분산락 최대 점유시간 (단위: 초) + * @param action 분산락 획득 후 실행할 로직 + * @return {@code action}의 실행 결과 + * @throws RedisException 락 획득 실패 시 예외 + * @throws InterruptedException 락 획득 대기 중 인터럽트 발생 시 예외 + */ + public R executeNewTransactionAfterLock( + String lockKey, long waitTime, long leaseTime, Supplier action + ) throws InterruptedException, RedisException { + RLock lock = getLock(lockKey); + boolean available = lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS); + + if (!available) { + throw new RedisException("락 획득 실패"); + } + + try { + return transactionhandler.runInTransaction(action); + } finally { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } + } + /** * 저장된 값을 조회 * diff --git a/src/main/java/OneQ/OnSurvey/global/infra/transaction/TransactionHandler.java b/src/main/java/OneQ/OnSurvey/global/infra/transaction/TransactionHandler.java new file mode 100644 index 00000000..7fb8f1ca --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/global/infra/transaction/TransactionHandler.java @@ -0,0 +1,16 @@ +package OneQ.OnSurvey.global.infra.transaction; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.function.Supplier; + +@Component +public class TransactionHandler { + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public T runInTransaction(Supplier supplier) { + return supplier.get(); + } +} From 84aa5eca60592f0d4926ce851220a512d3a8ebc2 Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Sun, 22 Feb 2026 16:21:30 +0900 Subject: [PATCH 10/14] =?UTF-8?q?fix:=20surveyInfo=EB=A5=BC=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=ED=95=98=EB=8A=94=20=ED=83=80=EC=9D=B4=EB=B0=8D?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=9D=BC=20completed=5Fcount=EC=9D=98=20?= =?UTF-8?q?=EA=B0=92=EC=9D=98=20=EA=B0=B1=EC=8B=A0=EC=86=90=EC=8B=A4?= =?UTF-8?q?=EC=9D=B4=20=EB=B0=9C=EC=83=9D=ED=95=98=EB=8A=94=20=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit ec91ca4a008a3b66b0e11095eb8e40e3fd1babf9) --- .../service/response/ResponseCommandService.java | 4 ++-- .../java/OneQ/OnSurvey/domain/survey/entity/SurveyInfo.java | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java b/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java index e53853ca..b43d0bdf 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java @@ -61,9 +61,9 @@ public Boolean createResponse(Long surveyId, Long memberId, Long userKey) { SurveyInfo surveyInfo = surveyInfoRepository.findBySurveyId(surveyId) .orElseThrow(() -> new CustomException(SurveyErrorCode.SURVEY_INFO_NOT_FOUND)); - - surveyInfo.increaseCompletedCount(); int currCompleted = updateCounter(surveyId, userKey); + surveyInfo.updateCompletedCount(currCompleted); + if (currCompleted >= surveyInfo.getDueCount()) { Survey survey = surveyRepository.getSurveyById(surveyId) .orElseThrow(() -> new CustomException(SurveyErrorCode.SURVEY_NOT_FOUND)); diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/entity/SurveyInfo.java b/src/main/java/OneQ/OnSurvey/domain/survey/entity/SurveyInfo.java index c06d7d55..5728a1d6 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/entity/SurveyInfo.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/entity/SurveyInfo.java @@ -109,5 +109,9 @@ public void increaseCompletedCount() { } this.completedCount++; } + + public void updateCompletedCount(Integer count) { + this.completedCount = count; + } } From 5a16de4f6db2e6da6fbf4265aeb08d39998b0b28 Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Mon, 23 Feb 2026 16:34:16 +0900 Subject: [PATCH 11/14] =?UTF-8?q?feat:=20=EB=A7=88=EA=B0=90=EA=B8=B0?= =?UTF-8?q?=ED=95=9C=20=EC=A7=80=EB=82=9C=20=EC=84=A4=EB=AC=B8=20=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=ED=95=98=EB=8A=94=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=9F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit b90a4f521846ffba95cefe4873dffbc1fecc6cae) --- .../domain/survey/repository/SurveyRepository.java | 1 + .../domain/survey/repository/SurveyRepositoryImpl.java | 10 ++++++++++ .../domain/survey/service/command/SurveyCommand.java | 4 +--- .../survey/service/command/SurveyCommandService.java | 7 +++++++ 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/repository/SurveyRepository.java b/src/main/java/OneQ/OnSurvey/domain/survey/repository/SurveyRepository.java index 88aa0408..65568fa2 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/repository/SurveyRepository.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/repository/SurveyRepository.java @@ -33,4 +33,5 @@ Slice getSurveyListWithEligibility( SurveyStatus getSurveyStatusById(Long surveyId); ParticipationStatus getParticipationStatus(Long surveyId, Long memberId); + void closeDueSurveys(); } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/repository/SurveyRepositoryImpl.java b/src/main/java/OneQ/OnSurvey/domain/survey/repository/SurveyRepositoryImpl.java index aaee20bf..c7a151dd 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/repository/SurveyRepositoryImpl.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/repository/SurveyRepositoryImpl.java @@ -279,4 +279,14 @@ public List getSurveyIdListByFilters( .limit(pageable.getPageSize() + 1) .fetch(); } + + @Override + public void closeDueSurveys() { + jpaQueryFactory.update(survey) + .set(survey.status, SurveyStatus.CLOSED) + .where( + survey.status.eq(SurveyStatus.ONGOING), + survey.deadline.before(LocalDateTime.now())) + .execute(); + } } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommand.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommand.java index d9ff70c1..5b07b9f0 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommand.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommand.java @@ -15,12 +15,10 @@ public interface SurveyCommand { SurveyFormResponse upsertSurvey(Long memberId, Long surveyId, SurveyFormCreateRequest request); SurveyFormResponse submitSurvey(Long userKey, Long surveyId, SurveyFormRequest request); SurveyFormResponse submitFreeSurvey(Long userKey, Long surveyId, FreeSurveyFormRequest request); - ScreeningResponse upsertScreening(Long surveyId, String content, Boolean answer); Boolean refundSurvey(Long memberId, Long surveyId); - InterestResponse upsertInterest(Long surveyId, Set interestSet); - boolean sendSurveyHeartbeat(Long surveyId, Long userKey); void updateSurveyOwner(SurveyOwnerChangeDto changeDto); + void closeDueSurveys(); } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java index 809cbb45..a79454c1 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java @@ -33,6 +33,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -262,6 +263,12 @@ public void updateSurveyOwner(SurveyOwnerChangeDto changeDto) { changeDto.surveyId(), changeDto.newMemberId()); } + @Override + @Scheduled(cron = "0 0 0 * * *") // 매 자정마다 실행 + public void closeDueSurveys() { + surveyRepository.closeDueSurveys(); + } + private Survey getSurvey(Long surveyId) { return surveyRepository.getSurveyById(surveyId) .orElseThrow(() -> new CustomException(ErrorCode.INVALID_REQUEST)); From 56bc0ccbe9c03d62338df4f576196b5cc492f9bd Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Fri, 6 Mar 2026 19:46:46 +0900 Subject: [PATCH 12/14] =?UTF-8?q?fix:=20RedisConfig=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=EB=B0=8F=20=EB=88=84=EB=9D=BD=EB=90=9C=20=ED=8A=B8=EB=9E=9C?= =?UTF-8?q?=EC=9E=AD=EC=85=98=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/admin/application/AdminFacade.java | 1 + .../global/common/config/RedisConfig.java | 20 ++++++------------- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/application/AdminFacade.java b/src/main/java/OneQ/OnSurvey/domain/admin/application/AdminFacade.java index cfcf5327..e681694a 100644 --- a/src/main/java/OneQ/OnSurvey/domain/admin/application/AdminFacade.java +++ b/src/main/java/OneQ/OnSurvey/domain/admin/application/AdminFacade.java @@ -96,6 +96,7 @@ public AdminSurveyDetailResponse getSurveyDetail(Long surveyId) { } @Override + @Transactional public void changeSurveyOwner(Long surveyId, Long memberId) { surveyPort.updateSurveyOwner(surveyId, memberId); } diff --git a/src/main/java/OneQ/OnSurvey/global/common/config/RedisConfig.java b/src/main/java/OneQ/OnSurvey/global/common/config/RedisConfig.java index 4f5557cb..461c6ed7 100644 --- a/src/main/java/OneQ/OnSurvey/global/common/config/RedisConfig.java +++ b/src/main/java/OneQ/OnSurvey/global/common/config/RedisConfig.java @@ -4,29 +4,21 @@ import org.redisson.api.RedissonClient; import org.redisson.config.Config; import org.redisson.config.SingleServerConfig; -import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.data.redis.RedisProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class RedisConfig { - @Value("${spring.data.redis.host}") - private String host; - - @Value("${spring.data.redis.port}") - private int port; - - @Value("${spring.data.redis.password:}") - private String password; - @Bean - public RedissonClient redisson() { + public RedissonClient redisson(RedisProperties redisProperties) { Config config = new Config(); + SingleServerConfig singleServerConfig = config.useSingleServer() - .setAddress("redis://" + host + ":" + port); - if (password != null && !password.isBlank()) { - singleServerConfig.setPassword(password); + .setAddress(String.format("redis://%s:%d", redisProperties.getHost(), redisProperties.getPort())); + if (redisProperties.getPassword() != null && !redisProperties.getPassword().isBlank()) { + singleServerConfig.setPassword(redisProperties.getPassword()); } return Redisson.create(config); From 063c05b6547b3d4266e6b9098ef5911572494a7f Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Sun, 8 Mar 2026 00:36:33 +0900 Subject: [PATCH 13/14] =?UTF-8?q?fix:=20Redisson=20=EB=9D=BD=EC=9D=B4=20Wa?= =?UTF-8?q?tchDog=EB=A5=BC=20=ED=99=9C=EC=84=B1=ED=99=94=EB=90=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=AA=85=EC=8B=9C=EC=A0=81=EC=9D=B8=20leaseTime=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=EC=9D=84=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../answer/QuestionAnswerCommandService.java | 2 +- .../response/ResponseCommandService.java | 2 +- .../service/query/SurveyQueryService.java | 4 +-- .../global/infra/redis/RedisAgent.java | 35 ++++++++++++++----- .../global/infra/redis/RedisLockAction.java | 4 ++- 5 files changed, 33 insertions(+), 14 deletions(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/QuestionAnswerCommandService.java b/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/QuestionAnswerCommandService.java index 9d0c82e9..03aab5f7 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/QuestionAnswerCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/QuestionAnswerCommandService.java @@ -60,7 +60,7 @@ public Boolean upsertAnswers(AnswerInsertDto insertDto, Long surveyId, Long user String lockKey = surveyLockKeyPrefix + surveyId + ":" + userKey; try { - return redisAgent.executeNewTransactionAfterLock(lockKey, 3, 5, () -> { + return redisAgent.executeNewTransactionAfterLock(lockKey, 3, () -> { /* 새로운 응답의 questionId로부터 기존 응답 조회 및 그룹화 Phantom Read 방지를 위해 조회 로직도 락 내부에서 실행 diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java b/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java index b43d0bdf..9bced12c 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java @@ -45,7 +45,7 @@ public class ResponseCommandService implements ResponseCommand { @Override public Boolean createResponse(Long surveyId, Long memberId, Long userKey) { try { - return redisAgent.executeNewTransactionAfterLock(surveyLockKeyPrefix + surveyId + ":" + userKey, 3, 5, () -> { + return redisAgent.executeNewTransactionAfterLock(surveyLockKeyPrefix + surveyId + ":" + userKey, 3, () -> { Response response = responseRepository .findBySurveyIdAndMemberId(surveyId, memberId) .orElseGet(() -> Response.of(surveyId, memberId)); diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQueryService.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQueryService.java index 7852c8e4..ee5e9f4b 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQueryService.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/query/SurveyQueryService.java @@ -382,7 +382,7 @@ private boolean isActivationAvailable(Long surveyId, Long userKey) { boolean result; try { Integer finalDueCount = dueCount; - result = redisAgent.executeWithLock(lockKey + surveyId, 5, 10, () -> { + result = redisAgent.executeWithLock(lockKey + surveyId, 5, () -> { Double existingScore = redisAgent.getZSetScore(potentialKey, memberValue); // 새로운 참여자인 경우 @@ -426,7 +426,7 @@ private boolean isActivationAvailable(Long surveyId, Long userKey) { private Integer initialDueCount(Long surveyId) { try { - return redisAgent.executeWithLock(lockKey + surveyId, 3, 6, () -> { + return redisAgent.executeWithLock(lockKey + surveyId, 3, () -> { int dueCount = redisAgent.getIntValue(this.dueCountKey + surveyId); // 다른 스레드에서 값이 설정된 경우, 재조회하지 않고 그대로 값 반환하도록 더블체크 diff --git a/src/main/java/OneQ/OnSurvey/global/infra/redis/RedisAgent.java b/src/main/java/OneQ/OnSurvey/global/infra/redis/RedisAgent.java index 29bf1390..95b4a869 100644 --- a/src/main/java/OneQ/OnSurvey/global/infra/redis/RedisAgent.java +++ b/src/main/java/OneQ/OnSurvey/global/infra/redis/RedisAgent.java @@ -26,32 +26,33 @@ public class RedisAgent implements RedisLockAction, RedisCacheAction { private final RedissonClient redisson; private final StringRedisTemplate redisTemplate; - private final TransactionHandler transactionhandler; + private final TransactionHandler transactionHandler; /** * 락 획득을 위한 RLock 객체를 반환 * @param lockKey 분산락 설정을 위한 키 * @return RLock */ + @Override public RLock getLock(String lockKey) { return redisson.getLock(lockKey); } /** - * 락 획득 후 실행할 로직을 인자로 받아 분산락을 이용하여 실행 + * 락 획득 후 실행할 로직을 인자로 받아 분산락을 이용하여 실행 ( * @param lockKey 분산락 설정을 위한 키 * @param waitTime 분산락 획득 대기시간 (단위: 초) - * @param leaseTime 분산락 최대 점유시간 (단위: 초) * @param action 분산락 획득 후 실행할 로직 * @return {@code action}의 실행 결과 * @throws RedisException 락 획득 실패 시 예외 * @throws InterruptedException 락 획득 대기 중 인터럽트 발생 시 예외 */ + @Override public R executeWithLock( - String lockKey, long waitTime, long leaseTime, Supplier action + String lockKey, long waitTime, Supplier action ) throws InterruptedException, RedisException { RLock lock = getLock(lockKey); - boolean available = lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS); + boolean available = lock.tryLock(waitTime, TimeUnit.SECONDS); if (!available) { throw new RedisException("락 획득 실패"); @@ -72,24 +73,24 @@ public R executeWithLock( *
강력한 일관성이 필요한 데이터에 대해서는 단순 조회 로직에도 사용해야 함 * @param lockKey 분산락 설정을 위한 키 * @param waitTime 분산락 획득 대기시간 (단위: 초) - * @param leaseTime 분산락 최대 점유시간 (단위: 초) * @param action 분산락 획득 후 실행할 로직 * @return {@code action}의 실행 결과 * @throws RedisException 락 획득 실패 시 예외 * @throws InterruptedException 락 획득 대기 중 인터럽트 발생 시 예외 */ + @Override public R executeNewTransactionAfterLock( - String lockKey, long waitTime, long leaseTime, Supplier action + String lockKey, long waitTime, Supplier action ) throws InterruptedException, RedisException { RLock lock = getLock(lockKey); - boolean available = lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS); + boolean available = lock.tryLock(waitTime, TimeUnit.SECONDS); if (!available) { throw new RedisException("락 획득 실패"); } try { - return transactionhandler.runInTransaction(action); + return transactionHandler.runInTransaction(action); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); @@ -103,6 +104,7 @@ public R executeNewTransactionAfterLock( * @param key 조회할 키 (keyPrefix + id 형태로 사용) * @return String */ + @Override public String getValue(String key) { return redisTemplate.opsForValue().get(key); } @@ -113,6 +115,7 @@ public String getValue(String key) { * @param key 조회할 키 (keyPrefix + id 형태로 사용) * @return int, 존재하지 않으면 0 반환 */ + @Override public int getIntValue(String key) { String value = redisTemplate.opsForValue().get(key); try { @@ -129,6 +132,7 @@ public int getIntValue(String key) { * @param key 조회할 키 (keyPrefix + id 형태로 사용) * @return long, 존재하지 않으면 0 반환 */ + @Override public long getLongValue(String key) { String value = redisTemplate.opsForValue().get(key); try { @@ -146,6 +150,7 @@ public long getLongValue(String key) { * @param value 저장할 값 * @param ttl TTL, 예: {@code Duration.ofSeconds(60)} - 60초 동안 유효 */ + @Override public void setValue(String key, String value, Duration ttl) { redisTemplate.opsForValue().set(key, value, ttl); } @@ -159,6 +164,7 @@ public void setValue(String key, String value, Duration ttl) { * @return 값이 저장됨 : true *

이미 키가 존재하여 저장되지 않음 : false */ + @Override public Boolean setValueIfAbsent(String key, String value, Duration ttl) { return redisTemplate.opsForValue().setIfAbsent(key, value, ttl); } @@ -169,6 +175,7 @@ public Boolean setValueIfAbsent(String key, String value, Duration ttl) { * @param key 조회할 키 (keyPrefix + id 형태로 사용) * @return 증가된 값 */ + @Override public Long incrementValue(String key) { return redisTemplate.opsForValue().increment(key); } @@ -180,6 +187,7 @@ public Long incrementValue(String key) { * @param delta 증가시킬 값 * @return 증가된 값 */ + @Override public Long incrementValue(String key, long delta) { return redisTemplate.opsForValue().increment(key, delta); } @@ -190,6 +198,7 @@ public Long incrementValue(String key, long delta) { * @param key 조회할 키 (keyPrefix + id 형태로 사용) * @return 감소된 값 */ + @Override public Long decrementValue(String key) { return redisTemplate.opsForValue().decrement(key); } @@ -201,6 +210,7 @@ public Long decrementValue(String key) { * @param delta 감소시킬 값 * @return 감소된 값 */ + @Override public Long decrementValue(String key, long delta) { return redisTemplate.opsForValue().decrement(key, delta); } @@ -210,6 +220,7 @@ public Long decrementValue(String key, long delta) { * * @param keyList 삭제할 키 리스트 (keyPrefix + id 형태로 사용) */ + @Override public void deleteKeys(List keyList) { if (keyList != null && !keyList.isEmpty()) { redisTemplate.delete(keyList); @@ -224,6 +235,7 @@ public void deleteKeys(List keyList) { * @param max 조회할 범위(score)의 최대값 * @return score 범위 내 요소 개수, 존재하지 않으면 0 반환 */ + @Override public long getZSetCount(String key, long min, long max) { Long count = redisTemplate.opsForZSet().count(key, min, max); return count != null ? count : 0L; @@ -236,6 +248,7 @@ public long getZSetCount(String key, long min, long max) { * @param value 조회할 요소 값 * @return 요소의 score, 존재하지 않으면 null 반환 */ + @Override public Double getZSetScore(String key, String value) { return redisTemplate.opsForZSet().score(key, value); } @@ -247,6 +260,7 @@ public Double getZSetScore(String key, String value) { * @param value Sorted Set에 추가/갱신할 값 * @param score Sorted Set에 추가/갱신할 값의 score */ + @Override public void addToZSet(String key, String value, long score) { redisTemplate.opsForZSet().add(key, value, score); } @@ -258,6 +272,7 @@ public void addToZSet(String key, String value, long score) { * @param value - Sorted Set에 추가할 값 * @param score - Sorted Set에 추가할 값의 score */ + @Override public void addToZSetIfAbsent(String key, String value, long score) { redisTemplate.opsForZSet().addIfAbsent(key, value, score); } @@ -268,6 +283,7 @@ public void addToZSetIfAbsent(String key, String value, long score) { * @param key 삭제할 키 (keyPrefix + id 형태로 사용) * @param value 삭제할 요소 값 */ + @Override public void removeFromZSet(String key, String value) { redisTemplate.opsForZSet().remove(key, value); } @@ -279,6 +295,7 @@ public void removeFromZSet(String key, String value) { * @param min 삭제할 범위(score)의 최소값 * @param max 삭제할 범위(score)의 최대값 */ + @Override public void rangeRemoveFromZSet(String key, long min, long max) { redisTemplate.opsForZSet().removeRangeByScore(key, min, max); } diff --git a/src/main/java/OneQ/OnSurvey/global/infra/redis/RedisLockAction.java b/src/main/java/OneQ/OnSurvey/global/infra/redis/RedisLockAction.java index e1445c8f..bdf263c0 100644 --- a/src/main/java/OneQ/OnSurvey/global/infra/redis/RedisLockAction.java +++ b/src/main/java/OneQ/OnSurvey/global/infra/redis/RedisLockAction.java @@ -9,5 +9,7 @@ public interface RedisLockAction { RLock getLock(String lockKey); - R executeWithLock(String lockKey, long waitTIme, long leaseTime, Supplier action) throws InterruptedException, RedisException; + R executeWithLock(String lockKey, long waitTIme, Supplier action) throws InterruptedException, RedisException; + + R executeNewTransactionAfterLock(String lockKey, long waitTime, Supplier action) throws InterruptedException, RedisException; } From e9a421cd4119591da1374731ea0a4345ffcb715c Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Sun, 8 Mar 2026 00:38:18 +0900 Subject: [PATCH 14/14] =?UTF-8?q?feat:=20RDB-Redis=20=ED=8A=B8=EB=9E=9C?= =?UTF-8?q?=EC=9E=AD=EC=85=98=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EC=9D=B4?= =?UTF-8?q?=EC=8A=88=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../response/ResponseCommandService.java | 48 ++++++++++++------- .../domain/survey/entity/SurveyInfo.java | 11 ----- .../survey/repository/SurveyRepository.java | 2 +- .../repository/SurveyRepositoryImpl.java | 17 ++++++- .../surveyInfo/SurveyInfoRepository.java | 1 + .../surveyInfo/SurveyInfoRepositoryImpl.java | 9 ++++ .../service/command/SurveyCommandService.java | 19 +++++++- .../transaction/AfterRollbackExecutor.java | 35 ++++++++++++++ 8 files changed, 109 insertions(+), 33 deletions(-) create mode 100644 src/main/java/OneQ/OnSurvey/global/infra/transaction/AfterRollbackExecutor.java diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java b/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java index 9bced12c..80645547 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java @@ -4,7 +4,6 @@ import OneQ.OnSurvey.domain.participation.repository.response.ResponseRepository; import OneQ.OnSurvey.domain.survey.SurveyErrorCode; import OneQ.OnSurvey.domain.survey.entity.Survey; -import OneQ.OnSurvey.domain.survey.entity.SurveyInfo; import OneQ.OnSurvey.domain.survey.model.SurveyStatus; import OneQ.OnSurvey.domain.survey.repository.SurveyRepository; import OneQ.OnSurvey.domain.survey.repository.surveyInfo.SurveyInfoRepository; @@ -12,6 +11,8 @@ import OneQ.OnSurvey.global.common.exception.CustomException; import OneQ.OnSurvey.global.common.exception.ErrorCode; import OneQ.OnSurvey.global.infra.redis.RedisAgent; +import OneQ.OnSurvey.global.infra.transaction.AfterCommitExecutor; +import OneQ.OnSurvey.global.infra.transaction.AfterRollbackExecutor; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.redisson.client.RedisException; @@ -29,6 +30,9 @@ public class ResponseCommandService implements ResponseCommand { private final SurveyRepository surveyRepository; private final SurveyInfoRepository surveyInfoRepository; private final SurveyGlobalStatsService surveyGlobalStatsService; + + private final AfterCommitExecutor afterCommitExecutor; + private final AfterRollbackExecutor afterRollbackExecutor; private final RedisAgent redisAgent; @Value("${redis.survey-key-prefix.lock}") @@ -56,25 +60,33 @@ public Boolean createResponse(Long surveyId, Long memberId, Long userKey) { response.markResponded(); responseRepository.save(response); - surveyGlobalStatsService.addCompletedCount(1); + surveyInfoRepository.increaseCompletedCount(surveyId); + + Survey survey = surveyRepository.getSurveyById(surveyId) + .orElseThrow(() -> new CustomException(SurveyErrorCode.SURVEY_NOT_FOUND)); + if (SurveyStatus.ONGOING.equals(survey.getStatus())) { + int currCompleted = updateCounter(surveyId, userKey); + int dueCount = redisAgent.getIntValue(this.dueCountKey + surveyId); + + if (currCompleted >= dueCount) { + survey.updateSurveyStatus(SurveyStatus.CLOSED); + + afterCommitExecutor.run(() -> { + redisAgent.deleteKeys(List.of( + this.dueCountKey + surveyId, + this.completedKey + surveyId, + this.potentialKey + surveyId, + this.creatorKey + surveyId + )); + }); + } - SurveyInfo surveyInfo = surveyInfoRepository.findBySurveyId(surveyId) - .orElseThrow(() -> new CustomException(SurveyErrorCode.SURVEY_INFO_NOT_FOUND)); - int currCompleted = updateCounter(surveyId, userKey); - surveyInfo.updateCompletedCount(currCompleted); - - if (currCompleted >= surveyInfo.getDueCount()) { - Survey survey = surveyRepository.getSurveyById(surveyId) - .orElseThrow(() -> new CustomException(SurveyErrorCode.SURVEY_NOT_FOUND)); - - survey.updateSurveyStatus(SurveyStatus.CLOSED); - redisAgent.deleteKeys(List.of( - this.dueCountKey + surveyId, - this.completedKey + surveyId, - this.potentialKey + surveyId, - this.creatorKey + surveyId - )); + // DB 롤백 시 캐시 롤백을 위한 보상 트랜잭션 + afterRollbackExecutor.run(() -> { + redisAgent.decrementValue(this.completedKey + surveyId); + redisAgent.addToZSet(this.potentialKey + surveyId, String.valueOf(userKey), System.currentTimeMillis()); + }); } return true; diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/entity/SurveyInfo.java b/src/main/java/OneQ/OnSurvey/domain/survey/entity/SurveyInfo.java index 5728a1d6..dc313d30 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/entity/SurveyInfo.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/entity/SurveyInfo.java @@ -102,16 +102,5 @@ public void updateSurveyInfo( public void markNonRefundable() { this.refundable = false; } - - public void increaseCompletedCount() { - if (this.completedCount == null) { - this.completedCount = 0; - } - this.completedCount++; - } - - public void updateCompletedCount(Integer count) { - this.completedCount = count; - } } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/repository/SurveyRepository.java b/src/main/java/OneQ/OnSurvey/domain/survey/repository/SurveyRepository.java index 65568fa2..65b568e8 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/repository/SurveyRepository.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/repository/SurveyRepository.java @@ -33,5 +33,5 @@ Slice getSurveyListWithEligibility( SurveyStatus getSurveyStatusById(Long surveyId); ParticipationStatus getParticipationStatus(Long surveyId, Long memberId); - void closeDueSurveys(); + List closeDueSurveys(); } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/repository/SurveyRepositoryImpl.java b/src/main/java/OneQ/OnSurvey/domain/survey/repository/SurveyRepositoryImpl.java index c7a151dd..e3df99a4 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/repository/SurveyRepositoryImpl.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/repository/SurveyRepositoryImpl.java @@ -29,6 +29,7 @@ import org.springframework.data.support.PageableExecutionUtils; import org.springframework.stereotype.Repository; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collection; @@ -281,12 +282,24 @@ public List getSurveyIdListByFilters( } @Override - public void closeDueSurveys() { + public List closeDueSurveys() { + + List dueSurveyIdList = jpaQueryFactory + .select(survey.id) + .from(survey) + .where( + survey.status.eq(SurveyStatus.ONGOING), + survey.deadline.before(LocalDate.now().atStartOfDay()) + ) + .fetch(); + jpaQueryFactory.update(survey) .set(survey.status, SurveyStatus.CLOSED) .where( survey.status.eq(SurveyStatus.ONGOING), - survey.deadline.before(LocalDateTime.now())) + survey.deadline.before(LocalDate.now().atStartOfDay())) .execute(); + + return dueSurveyIdList; } } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/repository/surveyInfo/SurveyInfoRepository.java b/src/main/java/OneQ/OnSurvey/domain/survey/repository/surveyInfo/SurveyInfoRepository.java index e4345054..5c6f4293 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/repository/surveyInfo/SurveyInfoRepository.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/repository/surveyInfo/SurveyInfoRepository.java @@ -10,6 +10,7 @@ public interface SurveyInfoRepository { SurveyInfo save(SurveyInfo surveyInfo); Optional findBySurveyId(Long surveyId); List findBySurveyIdIn(List surveyIds); + void increaseCompletedCount(Long surveyId); SurveySegmentation findSegmentationBySurveyId(Long surveyId); } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/repository/surveyInfo/SurveyInfoRepositoryImpl.java b/src/main/java/OneQ/OnSurvey/domain/survey/repository/surveyInfo/SurveyInfoRepositoryImpl.java index 4c8b5b25..96e30bcc 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/repository/surveyInfo/SurveyInfoRepositoryImpl.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/repository/surveyInfo/SurveyInfoRepositoryImpl.java @@ -44,6 +44,15 @@ public List findBySurveyIdIn(List surveyIds) { return surveyInfoJpaRepository.findBySurveyIdIn(surveyIds); } + @Override + public void increaseCompletedCount(Long surveyId) { + queryFactory + .update(surveyInfo) + .set(surveyInfo.completedCount, surveyInfo.completedCount.add(1)) + .where(surveyInfo.surveyId.eq(surveyId)) + .execute(); + } + @Override public SurveySegmentation findSegmentationBySurveyId(Long surveyId) { return queryFactory diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java index a79454c1..3f1907a2 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java @@ -40,8 +40,10 @@ import java.time.Duration; import java.time.LocalDateTime; import java.util.HashSet; +import java.util.List; import java.util.Objects; import java.util.Set; +import java.util.stream.Stream; import static OneQ.OnSurvey.domain.survey.model.SurveyStatus.REFUNDED; @@ -264,9 +266,11 @@ public void updateSurveyOwner(SurveyOwnerChangeDto changeDto) { } @Override + @Transactional @Scheduled(cron = "0 0 0 * * *") // 매 자정마다 실행 public void closeDueSurveys() { - surveyRepository.closeDueSurveys(); + List dueSurveyIdList = surveyRepository.closeDueSurveys(); + deleteSurveyRuntimeCache(dueSurveyIdList); } private Survey getSurvey(Long surveyId) { @@ -345,4 +349,17 @@ private void applySurveyRuntimeCache(Long surveyId, Long userKey, Integer dueCou redisAgent.addToZSet(this.potentialKey + surveyId, String.valueOf(userKey), System.currentTimeMillis()); redisAgent.setValue(this.creatorKey + surveyId, String.valueOf(userKey), ttl); } + + private void deleteSurveyRuntimeCache(List surveyIdList) { + List keysToDelete = surveyIdList.stream() + .flatMap(id -> Stream.of( + this.dueCountKey + id, + this.completedKey + id, + this.potentialKey + id, + this.creatorKey + id + )) + .toList(); + + redisAgent.deleteKeys(keysToDelete); + } } diff --git a/src/main/java/OneQ/OnSurvey/global/infra/transaction/AfterRollbackExecutor.java b/src/main/java/OneQ/OnSurvey/global/infra/transaction/AfterRollbackExecutor.java new file mode 100644 index 00000000..9428ba88 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/global/infra/transaction/AfterRollbackExecutor.java @@ -0,0 +1,35 @@ +package OneQ.OnSurvey.global.infra.transaction; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +@Slf4j +@Component +public class AfterRollbackExecutor { + + public void run(Runnable task) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCompletion(int status) { + + if (status != TransactionSynchronization.STATUS_COMMITTED) { + try { + task.run(); + } catch (Exception e) { + log.error("[AfterCommitExecutor] afterCommit task failed", e); + } + } + } + }); + } else { + try { + task.run(); + } catch (Exception e) { + log.error("[AfterCommitExecutor] non-tx task failed", e); + } + } + } +}