diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/repository/InterviewRepository.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/repository/InterviewRepository.java index 009ba307..6bae8a57 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/interview/repository/InterviewRepository.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/repository/InterviewRepository.java @@ -1,7 +1,11 @@ package repit.repit_api_server.domain.userdata.interview.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import repit.repit_api_server.domain.userdata.interview.entity.InterviewEntity; +import repit.repit_api_server.domain.userdata.interview.entity.enums.Status; import repit.repit_api_server.domain.userdata.question.entity.QuestionTailorEntity; import java.util.List; @@ -11,4 +15,23 @@ public interface InterviewRepository extends JpaRepository findAllByUserId(Long userId); Optional findBySessionId(String sessionId); + + /** + * 면접 기록 저장을 차지한다. 진행 중인 면접만 종료 상태로 바꾸고 1을 돌려받는다. + * + *

면접을 종료 상태로 바꾸는 곳은 기록 저장뿐이고 같은 트랜잭션에서 질문·답변을 넣는다. 그래서 종료 상태면 기록이 + * 이미 저장된 것이다. 채팅 서버가 같은 기록을 다시 보내면 0을 받아 아무것도 바꾸지 않는다. + * + *

먼저 읽어보고 판단하면 동시에 온 두 요청이 모두 처음이라고 보고 질문을 두 번 넣는다. 조건을 담은 갱신 한 번으로 + * 판단해, 뒤에 온 요청은 앞 요청이 끝날 때까지 행 잠금에서 기다렸다가 바뀐 상태를 보고 0을 받는다. 앞 요청이 + * 롤백되면 뒤 요청이 차지한다. + */ + @Modifying(flushAutomatically = true) + @Query(""" + update InterviewEntity i + set i.status = :status + where i.interviewId = :interviewId + and i.status = repit.repit_api_server.domain.userdata.interview.entity.enums.Status.IN_PROGRESS + """) + int claimResultSave(@Param("interviewId") Long interviewId, @Param("status") Status status); } diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewResultService.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewResultService.java index 8bc17c34..d412ca3d 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewResultService.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewResultService.java @@ -38,6 +38,10 @@ public class ChatInterviewResultService { public void handleResult(SaveInterviewRequest request) { interviewService.saveInterview(request); + // 이미 저장한 기록을 다시 받았어도 부른다. 처음 저장이 커밋된 직후 서버가 내려갔거나 여기서 실패했으면 채점·음성 + // 분석 대기 행이 없고, 스윕은 있는 대기 행만 훑어 아무도 채우지 않는다. 종류마다 한 번만 요청하므로 다시 불러도 + // 중복 요청은 없다. + try { feedbackDispatchService.onTranscriptSaved(request.getInterviewId()); } catch (RuntimeException e) { diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewService.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewService.java index 2ed9344d..5080b6f9 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewService.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewService.java @@ -347,20 +347,35 @@ private ChatAnswerResponse toChatAnswer(InterviewEntity interview, QuestionEntit * 채팅 서버가 면접을 마치고 넘겨주는 전체 기록을 저장한다. * *

채팅 서버는 이 요청을 보낸 직후 세션을 지운다. 여기가 마지막 기회라 실패하면 면접 - * 내용이 사라진다. 콜백이 두 번 와도 같은 결과가 되도록 기존 기록을 지우고 다시 넣는다. + * 내용이 사라진다. 그래서 질문·답변과 종료 상태를 한 트랜잭션에서 함께 남긴다 — 중간에 실패하면 모두 롤백되고 + * 면접은 진행 중으로 남아, 다시 보낸 기록이 처음부터 저장된다. + * + *

채팅 서버는 저장이 끝났는데 응답을 받지 못하면 같은 기록을 다시 보낸다. 이미 저장한 면접이면 아무것도 바꾸지 + * 않는다. 지우고 다시 넣으면 질문 PK가 바뀌어 그 질문에 매달린 채점·녹음과 어긋난다. 내용이 달라도 처음 것을 둔다. */ @Transactional public void saveInterview(SaveInterviewRequest request) { InterviewEntity interview = interviewRepository.findById(request.getInterviewId()) .orElseThrow(() -> BusinessException.notFound("면접을 찾을 수 없습니다")); + // 기록을 넘겼다는 것 자체가 면접이 끝났다는 뜻이다. 상태가 비거나 진행 중으로 와도 진행 중으로 두지 않는다 — + // 진행 중으로 두면 다시 온 기록을 처음 것으로 보고 또 저장한다. + Status status = request.getStatus() == null || request.getStatus() == Status.IN_PROGRESS + ? Status.COMPLETED + : request.getStatus(); + if (interviewRepository.claimResultSave(interview.getInterviewId(), status) == 0) { + log.info("이미 기록을 저장한 면접이라 다시 저장하지 않습니다. interviewId={}, 받은 상태={}, 받은 문항 수={}", + interview.getInterviewId(), request.getStatus(), + request.getQnaRequests() == null ? 0 : request.getQnaRequests().size()); + return; + } + // 세션과 상태는 비어 오면 덮어쓰지 않는다. 둘 다 not null 컬럼이라 null을 넣으면 저장이 // 통째로 실패하고, 그 500이 채팅 서버의 완료 처리까지 끊어 세션을 남긴 채로 끝난다. if (request.getSessionId() != null && !request.getSessionId().isBlank()) { interview.setSessionId(request.getSessionId()); } - // 기록을 넘겼다는 것 자체가 면접이 끝났다는 뜻이다. 상태가 비어 와도 진행 중으로 두지 않는다. - interview.setStatus(request.getStatus() == null ? Status.COMPLETED : request.getStatus()); + interview.setStatus(status); interviewRepository.save(interview); answerRepository.deleteAllByInterviewId(interview.getInterviewId()); diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewResultIdempotencyTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewResultIdempotencyTest.java new file mode 100644 index 00000000..6e314dd5 --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewResultIdempotencyTest.java @@ -0,0 +1,175 @@ +package repit.repit_api_server.domain.userdata.interview.service; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.support.TransactionTemplate; +import repit.repit_api_server.domain.userdata.answer.entity.AnswerEntity; +import repit.repit_api_server.domain.userdata.answer.repository.AnswerRepository; +import repit.repit_api_server.domain.userdata.feedback.service.FeedbackDispatchService; +import repit.repit_api_server.domain.userdata.interview.dto.request.SaveInterviewRequest; +import repit.repit_api_server.domain.userdata.interview.entity.InterviewEntity; +import repit.repit_api_server.domain.userdata.interview.entity.enums.InterviewMode; +import repit.repit_api_server.domain.userdata.interview.entity.enums.Status; +import repit.repit_api_server.domain.userdata.interview.repository.InterviewRepository; +import repit.repit_api_server.domain.userdata.question.entity.QuestionEntity; +import repit.repit_api_server.domain.userdata.question.repository.QuestionRepository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.tuple; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * 채팅 서버가 같은 면접 기록을 여러 번 보내는 경우(N-3). 실제 DB에 대고 본다. + * + *

동시에 온 요청은 각자 트랜잭션에서 돈다. 테스트 트랜잭션으로 감싸지 않고, 끝나면 만든 면접과 기록을 지운다. + */ +@SpringBootTest +class InterviewResultIdempotencyTest { + + @Autowired + private ChatInterviewResultService chatInterviewResultService; + @Autowired + private InterviewRepository interviewRepository; + @Autowired + private QuestionRepository questionRepository; + @Autowired + private AnswerRepository answerRepository; + @Autowired + private TransactionTemplate transactionTemplate; + + // 채점·음성 분석 준비는 분석 서버를 부른다. 여기서 볼 것은 몇 번 불렸는지다. + @MockitoBean + private FeedbackDispatchService feedbackDispatchService; + + private Long interviewId; + + @BeforeEach + void setUp() { + interviewId = interviewRepository.save(InterviewEntity.builder() + .userId(9L) + .mode(InterviewMode.SOLO) + .sessionId(UUID.randomUUID().toString()) + .status(Status.IN_PROGRESS) + .build()).getInterviewId(); + } + + @AfterEach + void cleanUp() { + transactionTemplate.executeWithoutResult(status -> { + answerRepository.deleteAllByInterviewId(interviewId); + questionRepository.deleteAllByInterviewId(interviewId); + interviewRepository.deleteById(interviewId); + }); + } + + private SaveInterviewRequest record(String answerContent) { + return new SaveInterviewRequest(null, interviewId, 9L, Status.COMPLETED, null, List.of( + new SaveInterviewRequest.QnA( + new SaveInterviewRequest.Question(1L, null, "tech_choice", "Redis를 왜 썼나요?", + "선택 근거", "캐시 선택 근거", List.of()), + new SaveInterviewRequest.Answer(1L, 60, answerContent, LocalDateTime.now())), + new SaveInterviewRequest.QnA( + new SaveInterviewRequest.Question(-1L, null, "follow", "다른 선택지는요?", + "대안 비교", null, List.of()), + new SaveInterviewRequest.Answer(-1L, 40, "로컬 캐시도 봤습니다.", LocalDateTime.now())))); + } + + private List questions() { + return questionRepository.findAllByInterviewIdOrderByQuestionIdAsc(interviewId); + } + + @Test + void 같은_기록을_다시_받아도_질문과_답변이_그대로다() { + chatInterviewResultService.handleResult(record("조회가 쓰기보다 많았습니다.")); + List first = questions(); + List firstAnswers = answerRepository.findAllByInterviewIdOrderByAnswerIdAsc(interviewId); + + // 응답을 받지 못한 채팅 서버가 다시 보낸다. 내용이 달라도 처음 것을 둔다. + chatInterviewResultService.handleResult(record("다시 보낸 다른 답변")); + chatInterviewResultService.handleResult(record("조회가 쓰기보다 많았습니다.")); + + assertThat(questions()).extracting(QuestionEntity::getQuestionId) + .containsExactlyElementsOf(first.stream().map(QuestionEntity::getQuestionId).toList()); + assertThat(questions()).extracting(QuestionEntity::getCreatedAt) + .containsExactlyElementsOf(first.stream().map(QuestionEntity::getCreatedAt).toList()); + assertThat(answerRepository.findAllByInterviewIdOrderByAnswerIdAsc(interviewId)) + .extracting(AnswerEntity::getAnswerId, AnswerEntity::getContent) + .containsExactlyElementsOf(firstAnswers.stream() + .map(answer -> tuple(answer.getAnswerId(), answer.getContent())) + .toList()); + assertThat(interviewRepository.findById(interviewId).orElseThrow().getStatus()).isEqualTo(Status.COMPLETED); + // 다시 받은 기록에도 채점 준비를 부른다. 빠진 대기 행을 채우고, 있는 것은 다시 요청하지 않는다 + // (FeedbackDispatchServiceTest가 확인한다). + verify(feedbackDispatchService, times(3)).onTranscriptSaved(interviewId); + } + + @Test + void 동시에_받아도_한_번만_저장한다() { + CountDownLatch start = new CountDownLatch(1); + List> requests = List.of(1, 2, 3).stream() + .map(i -> CompletableFuture.runAsync(() -> { + try { + start.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + chatInterviewResultService.handleResult(record("답변 " + i)); + })) + .toList(); + + start.countDown(); + // 모두 예외 없이 끝난다. 채팅 서버는 어느 응답이든 성공으로 받는다. + CompletableFuture.allOf(requests.toArray(CompletableFuture[]::new)).join(); + + assertThat(questions()).hasSize(2); + assertThat(answerRepository.findAllByInterviewIdOrderByAnswerIdAsc(interviewId)).hasSize(2); + verify(feedbackDispatchService, times(3)).onTranscriptSaved(interviewId); + } + + /** + * 처음 저장은 커밋됐는데 채점·음성 분석 준비가 빠진 경우(그 직후 서버 종료, DB 오류). 기록은 다시 저장하지 않지만 + * 다시 받은 기록으로 준비를 채운다. 스윕은 있는 대기 행만 훑으므로 이 길이 아니면 아무도 채우지 않는다. + */ + @Test + void 저장_뒤_채점_준비가_빠졌으면_다시_받은_기록으로_채운다() { + doThrow(new IllegalStateException("대기 행을 만들지 못함")).doNothing() + .when(feedbackDispatchService).onTranscriptSaved(interviewId); + + chatInterviewResultService.handleResult(record("조회가 쓰기보다 많았습니다.")); + List firstIds = questions().stream().map(QuestionEntity::getQuestionId).toList(); + chatInterviewResultService.handleResult(record("조회가 쓰기보다 많았습니다.")); + + assertThat(questions()).extracting(QuestionEntity::getQuestionId).containsExactlyElementsOf(firstIds); + verify(feedbackDispatchService, times(2)).onTranscriptSaved(interviewId); + } + + /** 중간에 실패하면 질문까지 함께 롤백되고 면접은 진행 중으로 남는다. 다시 받은 기록이 처음부터 저장된다. */ + @Test + void 저장_중_실패하면_다시_받은_기록을_처음부터_저장한다() { + // 답변 내용은 NOT NULL이라 질문을 넣은 뒤 답변을 넣다가 실패한다. + assertThatThrownBy(() -> chatInterviewResultService.handleResult(record(null))) + .isInstanceOf(DataIntegrityViolationException.class); + assertThat(questions()).isEmpty(); + assertThat(interviewRepository.findById(interviewId).orElseThrow().getStatus()).isEqualTo(Status.IN_PROGRESS); + + chatInterviewResultService.handleResult(record("조회가 쓰기보다 많았습니다.")); + + assertThat(questions()).hasSize(2); + assertThat(interviewRepository.findById(interviewId).orElseThrow().getStatus()).isEqualTo(Status.COMPLETED); + verify(feedbackDispatchService, times(1)).onTranscriptSaved(interviewId); + } +} diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceSaveResultTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceSaveResultTest.java index 55b8d8c3..8ff05d0e 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceSaveResultTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceSaveResultTest.java @@ -34,6 +34,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -82,6 +85,8 @@ void setUp() { .status(Status.IN_PROGRESS) .build())); when(interviewRepository.save(any(InterviewEntity.class))).thenAnswer(call -> call.getArgument(0)); + // 진행 중인 면접이라 처음 저장을 차지한다. 이미 저장한 경우는 따로 검증한다. + when(interviewRepository.claimResultSave(eq(3L), any())).thenReturn(1); // 저장하면 DB가 PK를 발급한다. 채팅 서버 번호와 겹치지 않는 값을 준다. AtomicLong nextId = new AtomicLong(900L); @@ -297,4 +302,26 @@ private List savedAnswers() { verify(questionRepository, org.mockito.Mockito.never()).save(any(QuestionEntity.class)); } + + /** 채팅 서버는 저장이 끝났는데 응답을 받지 못하면 같은 기록을 다시 보낸다. 지우고 다시 넣으면 질문 PK가 바뀐다. */ + @Test + void 이미_저장한_면접이면_질문과_답변을_건드리지_않는다() { + when(interviewRepository.claimResultSave(eq(3L), any())).thenReturn(0); + + service.saveInterview(request()); + + verify(answerRepository, never()).deleteAllByInterviewId(anyLong()); + verify(questionRepository, never()).deleteAllByInterviewId(anyLong()); + verify(questionRepository, never()).save(any()); + verify(answerRepository, never()).saveAll(any()); + verify(interviewRepository, never()).save(any()); + } + + /** 진행 중으로 남기면 다시 온 기록을 처음 것으로 보고 또 저장한다. */ + @Test + void 진행_중_상태로_와도_끝난_것으로_저장한다() { + service.saveInterview(new SaveInterviewRequest("sess-1", 3L, 7L, Status.IN_PROGRESS, null, List.of())); + + verify(interviewRepository).claimResultSave(3L, Status.COMPLETED); + } }