Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ad3a350
build: redisson 종속성 추가
wonjuneee Feb 18, 2026
26602e9
mod: 분산락을 통한 설문참여 가능여부 판단 로직 동시성 이슈 해결
wonjuneee Feb 18, 2026
31a3417
feat: Redis 락/데이터 타입 처리를 위한 유틸리티 메서드 구현
wonjuneee Feb 18, 2026
6c10887
mod: Redis 유틸리티 클래스를 사용하도록 수정
wonjuneee Feb 18, 2026
f4ab2c6
mod: Redis 유틸리티 클래스를 컴포넌트로 변경하여 주입
wonjuneee Feb 19, 2026
9e30182
fix: 코드리뷰 반영
wonjuneee Feb 19, 2026
2b78f84
fix: 설문응답 제출 시 락을 통해 중복제출이 발생하지 않도록 수정
wonjuneee Feb 20, 2026
51aa780
fix: 응답완료 시에도 락 활용하도록 수정
wonjuneee Feb 21, 2026
06aa8c5
feat: 갱신손실, 팬텀리드를 방지하기 위한 락 획득 후 트랜잭션을 수행하는 메서드 구현
wonjuneee Feb 21, 2026
84aa5ec
fix: surveyInfo를 조회하는 타이밍에 따라 completed_count의 값의 갱신손실이 발생하는 케이스 수정
wonjuneee Feb 22, 2026
5a16de4
feat: 마감기한 지난 설문 종료하는 스케줄러 추가
wonjuneee Feb 23, 2026
56bc0cc
fix: RedisConfig 수정 및 누락된 트랜잭션 어노테이션 추가
wonjuneee Mar 6, 2026
063c05b
fix: Redisson 락이 WatchDog를 활성화되도록 명시적인 leaseTime 설정을 제거
wonjuneee Mar 7, 2026
e9a421c
feat: RDB-Redis 트랜잭션 동기화 이슈 해결
wonjuneee Mar 7, 2026
d5ac7f7
Merge branch 'develop' into fix/OMF-132
wonjuneee Mar 7, 2026
09f137f
Merge branch 'develop' into fix/OMF-132
wonjuneee Mar 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ dependencies {

// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.redisson:redisson-spring-boot-starter:3.52.0'

// QueryDSL
implementation 'io.github.openfeign.querydsl:querydsl-core:7.0'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ public AdminSurveyDetailResponse getSurveyDetail(Long surveyId) {
}

@Override
@Transactional
public void changeSurveyOwner(Long surveyId, Long memberId) {
surveyPort.updateSurveyOwner(surveyId, memberId);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
import OneQ.OnSurvey.domain.participation.model.dto.AnswerInsertDto;

public interface AnswerCommand<E extends AbstractAnswer> {
Boolean upsertAnswers(AnswerInsertDto insertDto);
Boolean upsertAnswers(AnswerInsertDto insertDto, Long surveyId, Long userKey, Long memberId);
Boolean insertAnswer(AnswerInsertDto.AnswerInfo answerInfo);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public abstract class AnswerCommandService<E extends AbstractAnswer> implements
protected final AnswerRepository<E> answerRepository;
protected final ResponseRepository responseRepository;

public Boolean upsertAnswers(AnswerInsertDto insertDto) {
public Boolean upsertAnswers(AnswerInsertDto insertDto, Long surveyId, Long userKey, Long memberId) {
List<E> answerList = insertDto.getAnswerInfoList().stream()
.map(this::createAnswerFromDto)
.toList();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@
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.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;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -20,105 +25,120 @@

@Slf4j
@Service
@Transactional
public class QuestionAnswerCommandService extends AnswerCommandService<QuestionAnswer> {

private final QuestionRepository questionRepository;
@Value("${redis.survey-key-prefix.lock}")
private String surveyLockKeyPrefix;

private final RedisAgent redisAgent;

public QuestionAnswerCommandService(
AnswerRepository<QuestionAnswer> answerRepository,
ResponseRepository responseRepository,
QuestionRepository questionRepository
RedisAgent redisAgent
) {
super(answerRepository, responseRepository);
this.questionRepository = questionRepository;
this.redisAgent = redisAgent;
}

@Override
@Transactional
public QuestionAnswer createAnswerFromDto(AnswerInsertDto.AnswerInfo answerInfo) {
return QuestionAnswer.from(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<Long, Set<QuestionAnswer>> newQuestionAnswerMap = insertDto.getAnswerInfoList().stream()
.map(this::createAnswerFromDto)
.collect(Collectors.groupingBy(QuestionAnswer::getQuestionId, Collectors.toSet()));

// 새로운 응답의 questionId로부터 기존 응답 조회 및 그룹화
List<Long> questionIdList = newQuestionAnswerMap.keySet().stream().toList();
Map<Long, Set<QuestionAnswer>> existingQuestionAnswerMap =
answerRepository.getAnswerListByQuestionIdsAndMemberId(questionIdList, memberId)
.stream()
.collect(Collectors.groupingBy(QuestionAnswer::getQuestionId, Collectors.toSet()));

// 새로 저장할 응답 리스트
List<QuestionAnswer> finalAnswersToSave = new ArrayList<>();
// 삭제하지 않을 ID
Set<Long> idSetToKeep = new HashSet<>();

questionIdList.forEach(questionId -> {
// questionId에 대한 새로운 응답과 기존 응답의 content 집합 생성
Set<QuestionAnswer> newAnswerContentSet = newQuestionAnswerMap.getOrDefault(questionId, Set.of());
Set<String> newContents = newAnswerContentSet.stream()
.map(QuestionAnswer::getContent)
.map(content -> content == null ? null : content.strip())
.collect(Collectors.toSet());
Set<QuestionAnswer> existingAnswerContentSet = existingQuestionAnswerMap.getOrDefault(questionId, Set.of());
Set<String> 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<Long> 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.executeNewTransactionAfterLock(lockKey, 3, 5, () -> {
/*
새로운 응답의 questionId로부터 기존 응답 조회 및 그룹화
Phantom Read 방지를 위해 조회 로직도 락 내부에서 실행
*/
Map<Long, Set<QuestionAnswer>> existingQuestionAnswerMap =
answerRepository.getAnswerListByQuestionIdsAndMemberId(questionIdList, memberId)
.stream()
.collect(Collectors.groupingBy(QuestionAnswer::getQuestionId, Collectors.toSet()));

// 새로 저장할 응답 리스트
List<QuestionAnswer> finalAnswersToSave = new ArrayList<>();
// 삭제하지 않을 ID
Set<Long> idSetToKeep = new HashSet<>();

questionIdList.forEach(questionId -> {
// questionId에 대한 새로운 응답과 기존 응답의 content 집합 생성
Set<QuestionAnswer> newAnswerContentSet = newQuestionAnswerMap.getOrDefault(questionId, Set.of());
Set<String> newContents = newAnswerContentSet.stream()
.map(QuestionAnswer::getContent)
.map(content -> content == null ? null : content.strip())
.collect(Collectors.toSet());
Set<QuestionAnswer> existingAnswerContentSet = existingQuestionAnswerMap.getOrDefault(questionId, Set.of());
Set<String> 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<Long> 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());
throw new CustomException(SurveyErrorCode.SURVEY_PARTICIPATION_IN_PROCESS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("[QUESTION_ANSWER:COMMAND] 문항 응답 저장 중 오류 발생 - memberId: {}, error: {}", memberId, e.getMessage());
throw new CustomException(ErrorCode.SERVER_UNTRACKED_ERROR);
}
}

// 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);
// 완료된 응답이 잘못 업데이트 되는 것을 방지하기 위해, 응답이 완료되지 않은 경우에만 저장
if (Boolean.FALSE.equals(response.getIsResponded())) {
responseRepository.save(response);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,87 +10,95 @@
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;
import org.redisson.client.RedisException;
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;

@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;
private final SurveyGlobalStatsService surveyGlobalStatsService;
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}")
private String completedKey;

@Value("${redis.survey-key-prefix.due-count}")
private String dueCountKey;

@Value("${redis.survey-key-prefix.creator-userkey}")
private String creatorKey;

@Override
public Boolean createResponse(Long surveyId, Long memberId, Long userKey) {
Response response = responseRepository
.findBySurveyIdAndMemberId(surveyId, memberId)
.orElseGet(() -> Response.of(surveyId, memberId));

if (Boolean.TRUE.equals(response.getIsResponded())) {
throw new CustomException(SurveyErrorCode.SURVEY_ALREADY_PARTICIPATED);
try {
return redisAgent.executeNewTransactionAfterLock(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);
}

response.markResponded();
responseRepository.save(response);

surveyGlobalStatsService.addCompletedCount(1);

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
));
}

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);
}

response.markResponded();
responseRepository.save(response);

surveyGlobalStatsService.addCompletedCount(1);

SurveyInfo surveyInfo = surveyInfoRepository.findBySurveyId(surveyId)
.orElseThrow(() -> new CustomException(SurveyErrorCode.SURVEY_INFO_NOT_FOUND));

surveyInfo.increaseCompletedCount();
Integer currCompleted = updateCounter(surveyId, userKey);
if (currCompleted != null && currCompleted.equals(surveyInfo.getDueCount())) {
Survey survey = surveyRepository.getSurveyById(surveyId)
.orElseThrow(() -> new CustomException(SurveyErrorCode.SURVEY_NOT_FOUND));

survey.updateSurveyStatus(SurveyStatus.CLOSED);
deleteAllRedisKeys(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 = redisAgent.incrementValue(this.completedKey + surveyId);
// 잠재 응답자 Sorted Set에서 제거
redisTemplate.opsForZSet().remove(potentialKey, memberValue);
return currCompleted != null ? currCompleted.intValue() : null;
redisAgent.removeFromZSet(this.potentialKey + surveyId, String.valueOf(userKey));

if (currCompleted == null) {
log.error("[RESPONSE:COMMAND] 레디스 완료 값 갱신 실패 - surveyId: {}, userKey: {}", surveyId, userKey);
throw new CustomException(ErrorCode.SERVER_UNTRACKED_ERROR);
}
return currCompleted.intValue();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading
Loading