Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -11,4 +15,23 @@ public interface InterviewRepository extends JpaRepository<InterviewEntity, Long
List<InterviewEntity> findAllByUserId(Long userId);

Optional<InterviewEntity> findBySessionId(String sessionId);

/**
* 면접 기록 저장을 차지한다. 진행 중인 면접만 종료 상태로 바꾸고 1을 돌려받는다.
*
* <p>면접을 종료 상태로 바꾸는 곳은 기록 저장뿐이고 같은 트랜잭션에서 질문·답변을 넣는다. 그래서 종료 상태면 기록이
* 이미 저장된 것이다. 채팅 서버가 같은 기록을 다시 보내면 0을 받아 아무것도 바꾸지 않는다.
*
* <p>먼저 읽어보고 판단하면 동시에 온 두 요청이 모두 처음이라고 보고 질문을 두 번 넣는다. 조건을 담은 갱신 한 번으로
* 판단해, 뒤에 온 요청은 앞 요청이 끝날 때까지 행 잠금에서 기다렸다가 바뀐 상태를 보고 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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ public class ChatInterviewResultService {
public void handleResult(SaveInterviewRequest request) {
interviewService.saveInterview(request);

// 이미 저장한 기록을 다시 받았어도 부른다. 처음 저장이 커밋된 직후 서버가 내려갔거나 여기서 실패했으면 채점·음성
// 분석 대기 행이 없고, 스윕은 있는 대기 행만 훑어 아무도 채우지 않는다. 종류마다 한 번만 요청하므로 다시 불러도
// 중복 요청은 없다.

try {
feedbackDispatchService.onTranscriptSaved(request.getInterviewId());
} catch (RuntimeException e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -347,20 +347,35 @@ private ChatAnswerResponse toChatAnswer(InterviewEntity interview, QuestionEntit
* 채팅 서버가 면접을 마치고 넘겨주는 전체 기록을 저장한다.
*
* <p>채팅 서버는 이 요청을 보낸 직후 세션을 지운다. 여기가 마지막 기회라 실패하면 면접
* 내용이 사라진다. 콜백이 두 번 와도 같은 결과가 되도록 기존 기록을 지우고 다시 넣는다.
* 내용이 사라진다. 그래서 질문·답변과 종료 상태를 한 트랜잭션에서 함께 남긴다 — 중간에 실패하면 모두 롤백되고
* 면접은 진행 중으로 남아, 다시 보낸 기록이 처음부터 저장된다.
*
* <p>채팅 서버는 저장이 끝났는데 응답을 받지 못하면 같은 기록을 다시 보낸다. 이미 저장한 면접이면 아무것도 바꾸지
* 않는다. 지우고 다시 넣으면 질문 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());
Expand Down
Original file line number Diff line number Diff line change
@@ -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에 대고 본다.
*
* <p>동시에 온 요청은 각자 트랜잭션에서 돈다. 테스트 트랜잭션으로 감싸지 않고, 끝나면 만든 면접과 기록을 지운다.
*/
@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<QuestionEntity> questions() {
return questionRepository.findAllByInterviewIdOrderByQuestionIdAsc(interviewId);
}

@Test
void 같은_기록을_다시_받아도_질문과_답변이_그대로다() {
chatInterviewResultService.handleResult(record("조회가 쓰기보다 많았습니다."));
List<QuestionEntity> first = questions();
List<AnswerEntity> 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<CompletableFuture<Void>> 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<Long> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -297,4 +302,26 @@ private List<AnswerEntity> 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);
}
}
Loading