diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index fa8f317d..f42ab8db 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -362,8 +362,15 @@ docker compose up -d - **세션 시간초과 자동 종료 본 구현**: `@EnableScheduling`(`common/config/SchedulingConfig`) + `SessionTimeoutSweeper` (`@Scheduled` 기본 5분 주기)가 `maxDurationMinutes` 초과한 IN_PROGRESS 세션을 찾아 `SessionTimeoutService.endTimedOut` 호출 — 답변 있으면 COMPLETED(→`SessionEndedEvent(DURATION_EXCEEDED)`→피드백), 없으면 INTERRUPTED(피드백 없음). - 좀비 세션(자기소개 미답변·STT 실패·탭 종료) 방지. 멀티 인스턴스 중복 실행도 IN_PROGRESS 재확인+피드백 멱등으로 안전. + 좀비 세션(자기소개 미답변·STT 실패·탭 종료) 방지. 주기: `interview.session.sweep-interval-ms`(기본 300000)·`sweep-initial-delay-ms`(기본 60000). + - **동시 종료 안전(원자적 전이)**: 모든 종료 경로(스위퍼·수동 `SessionService.end`·콜백 `endSession`)는 + `InterviewSessionRepository.finishIfInProgress`(조건부 UPDATE `WHERE status=IN_PROGRESS`)로 전이를 + 차지하고, **영향 행 1인 트랜잭션만** `SessionEndedEvent` 를 발행한다 → DB 행 락으로 직렬화돼 동시 + 종료 시에도 `generate.feedback` 가 중복 발행되지 않는다. + - **종료 후 콜백 드롭**: `QuestionsCallbackService.apply` 와 `SessionFollowupRequester.onAnswerSubmitted` 는 + 세션이 `isTerminal()` 이면 처리를 건너뛴다 → 자동종료 뒤 늦게 도착한 POOL/FOLLOWUP 콜백이나 + 막판 답변 발화가 종료 세션에 질문·placeholder 를 추가하는 사후 변조를 차단. - **질문별 복기 본 구현**: V19 로 `interview_messages` 에 `model_answer`/`answer_rewrite`/`coaching_comment` 추가. AI 가 `callback.feedback.answerCoaching[{messageId,…}]` 로 답변별 모범 답안·리라이트·코칭을 보내면 `FeedbackCallbackService` 가 각 메시지에 `recordCoaching` 기록. `MessageResult`/`MessageResponse` 가 답변 diff --git a/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java b/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java index af663934..ad336d0d 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java @@ -18,6 +18,7 @@ import com.stackup.stackup.session.domain.SessionQuestionPool; import com.stackup.stackup.session.domain.SessionQuestionPoolRepository; import com.stackup.stackup.session.domain.SessionStatus; +import java.time.Instant; import java.util.List; import lombok.RequiredArgsConstructor; import org.slf4j.Logger; @@ -68,6 +69,14 @@ public void apply(QuestionsCallbackEnvelope envelope) { markProcessed(envelope.messageId()); return; } + // 종료된 세션에 늦게 도착한 콜백은 드롭(멱등 마킹). 스위퍼 자동종료·수동종료 후 + // 처리 대기 중이던 POOL/FOLLOWUP 콜백이 종료 세션을 사후 변조하는 것을 방지. + if (session.getStatus().isTerminal()) { + log.info("callback.questions for terminal session, drop. id={}, status={}, messageId={}", + sessionId, session.getStatus(), envelope.messageId()); + markProcessed(envelope.messageId()); + return; + } if (payload.isPool()) { applyInitialQuestion(session, payload); @@ -163,18 +172,22 @@ private void publishQuestionEvents(InterviewSession session, InterviewMessage me } private void endSession(InterviewSession session, String reason) { - try { - session.end(); - events.publishEvent(RealtimeNotifyEvent.session(session.getId(), SseEventType.SESSION_STATE, - new SessionStateNotice(session.getId(), session.getStatus().name(), reason))); - events.publishEvent(RealtimeNotifyEvent.user(session.getUser().getId(), SseEventType.SESSION_STATE, - new SessionStateNotice(session.getId(), session.getStatus().name(), reason))); - events.publishEvent(new SessionEndedEvent(session.getUser().getId(), session.getId(), reason)); - log.info("session auto-completed. sessionId={}, reason={}", session.getId(), reason); - } catch (IllegalStateException e) { - log.warn("auto-end skipped — session not IN_PROGRESS. sessionId={}, status={}", + // 원자적 종료 전이: IN_PROGRESS 일 때만 1행 갱신. 0이면 다른 트랜잭션(스위퍼·수동 종료)이 + // 먼저 종료한 것 → 종료 부수효과 미발행(중복 피드백 방지). + int claimed = sessionRepository.finishIfInProgress( + session.getId(), SessionStatus.COMPLETED, Instant.now()); + if (claimed == 0) { + log.warn("auto-end skipped — session already ended. sessionId={}, status={}", session.getId(), session.getStatus()); + return; } + session.end(); // 인메모리 동기화(SSE status 표기용). DB는 위 조건부 UPDATE 로 이미 COMPLETED. + events.publishEvent(RealtimeNotifyEvent.session(session.getId(), SseEventType.SESSION_STATE, + new SessionStateNotice(session.getId(), session.getStatus().name(), reason))); + events.publishEvent(RealtimeNotifyEvent.user(session.getUser().getId(), SseEventType.SESSION_STATE, + new SessionStateNotice(session.getId(), session.getStatus().name(), reason))); + events.publishEvent(new SessionEndedEvent(session.getUser().getId(), session.getId(), reason)); + log.info("session auto-completed. sessionId={}, reason={}", session.getId(), reason); } private void applyFollowup(InterviewSession session, QuestionsCallbackPayload payload) { diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionFollowupRequester.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionFollowupRequester.java index 80338149..7f967ab0 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionFollowupRequester.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionFollowupRequester.java @@ -14,6 +14,7 @@ import com.stackup.stackup.session.domain.InterviewSession; import com.stackup.stackup.session.domain.MessageRole; import com.stackup.stackup.session.domain.SessionContextRepository; +import com.stackup.stackup.session.domain.SessionStatus; import java.util.ArrayList; import java.util.List; import lombok.RequiredArgsConstructor; @@ -53,6 +54,15 @@ public void onAnswerSubmitted(AnswerSubmittedEvent event) { } InterviewSession session = parent.getSession(); + // 종료된 세션이면 꼬리질문/풀 생성을 요청하지 않는다. maxDuration 직전 제출한 답변의 + // AFTER_COMMIT 발화가 스위퍼 자동종료와 겹칠 때, 종료 세션에 placeholder·질문이 + // 추가되거나 불필요한 AI 호출이 발생하는 것을 방지(콜백단 terminal 가드와 같은 선상). + if (session.getStatus().isTerminal()) { + log.info("generate.followup skipped — session already terminal. sessionId={}, status={}", + session.getId(), session.getStatus()); + return; + } + // RAG(자료 근거/correctness) 용 세션 컨텍스트 문서 — 피드백 발행부와 동일 패턴. List contextDocumentIds = contextRepository.findBySession_Id(session.getId()).stream() .map(c -> c.getDocument().getId()) diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java index 52cae9a3..490ef4d2 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java @@ -16,8 +16,10 @@ import com.stackup.stackup.session.domain.SessionMode; import com.stackup.stackup.session.domain.SessionContext; import com.stackup.stackup.session.domain.SessionContextRepository; +import com.stackup.stackup.session.domain.SessionStatus; import com.stackup.stackup.user.domain.User; import com.stackup.stackup.user.domain.UserRepository; +import java.time.Instant; import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; @@ -108,11 +110,14 @@ public SessionResult start(Long userId, Long sessionId) { @Transactional public SessionResult end(Long userId, Long sessionId) { InterviewSession session = loadOwned(userId, sessionId); - try { - session.end(); - } catch (IllegalStateException e) { + // 원자적 종료 전이: IN_PROGRESS 일 때만 1행 갱신. 0이면 다른 트랜잭션(스위퍼 등)이 + // 먼저 종료한 것 → 기존 동작과 동일하게 INVALID_STATE. 1을 받은 호출자만 종료 이벤트 발행. + int claimed = sessionRepository.finishIfInProgress( + sessionId, SessionStatus.COMPLETED, Instant.now()); + if (claimed == 0) { throw new DomainException(ApiErrorCode.SESSION_INVALID_STATE); } + session.end(); // 응답·인메모리 동기화(DB는 위 조건부 UPDATE 로 이미 COMPLETED). events.publishEvent(new SessionEndedEvent(userId, sessionId, "USER_REQUEST")); return SessionResult.of(session, contextDocumentIds(sessionId)); } diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionTimeoutService.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionTimeoutService.java index 35970865..58b5aa9e 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionTimeoutService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionTimeoutService.java @@ -8,6 +8,7 @@ import com.stackup.stackup.session.domain.InterviewSessionRepository; import com.stackup.stackup.session.domain.MessageRole; import com.stackup.stackup.session.domain.SessionStatus; +import java.time.Instant; import lombok.RequiredArgsConstructor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -38,28 +39,30 @@ public void endTimedOut(Long sessionId) { boolean hasAnswer = messageRepository.existsBySession_IdAndRole(sessionId, MessageRole.INTERVIEWEE); Long userId = session.getUser().getId(); - try { - if (hasAnswer) { - session.end(); // COMPLETED → SessionEndedEvent 로 피드백 생성 - publishState(session, userId); - events.publishEvent(new SessionEndedEvent(userId, sessionId, REASON)); - log.info("session auto-completed (timeout). sessionId={}", sessionId); - } else { - session.interrupt(); // 답변 없음 → 피드백 없이 정리 - publishState(session, userId); - log.info("session auto-interrupted (timeout, no answers). sessionId={}", sessionId); - } - } catch (IllegalStateException e) { - log.warn("auto-end skipped — session not IN_PROGRESS. sessionId={}, status={}", - sessionId, session.getStatus()); + SessionStatus target = hasAnswer ? SessionStatus.COMPLETED : SessionStatus.INTERRUPTED; + + // 원자적 종료 전이: IN_PROGRESS 일 때만 1행이 갱신된다. 0이면 다른 트랜잭션 + // (다른 스위퍼 인스턴스·수동 종료·콜백 종료)이 먼저 종료한 것 → 부수효과 미발행. + int claimed = sessionRepository.finishIfInProgress(sessionId, target, Instant.now()); + if (claimed == 0) { + log.info("session auto-end skipped — already ended by another tx. sessionId={}", sessionId); + return; + } + + publishState(sessionId, userId, target); + if (target == SessionStatus.COMPLETED) { + // COMPLETED → SessionEndedEvent 로 피드백 생성. 전이를 차지한 트랜잭션에서 단 한 번만. + events.publishEvent(new SessionEndedEvent(userId, sessionId, REASON)); + log.info("session auto-completed (timeout). sessionId={}", sessionId); + } else { + log.info("session auto-interrupted (timeout, no answers). sessionId={}", sessionId); } } - private void publishState(InterviewSession session, Long userId) { - SessionStateNotice notice = - new SessionStateNotice(session.getId(), session.getStatus().name(), REASON); + private void publishState(Long sessionId, Long userId, SessionStatus status) { + SessionStateNotice notice = new SessionStateNotice(sessionId, status.name(), REASON); events.publishEvent(RealtimeNotifyEvent.session( - session.getId(), SseEventType.SESSION_STATE, notice)); + sessionId, SseEventType.SESSION_STATE, notice)); events.publishEvent(RealtimeNotifyEvent.user( userId, SseEventType.SESSION_STATE, notice)); } diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java index 2db9bc7b..901443cb 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java @@ -1,10 +1,12 @@ package com.stackup.stackup.session.domain; +import java.time.Instant; import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; 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; @@ -36,4 +38,15 @@ List findRecentSessionIds(@Param("userId") Long userId, List findByStatusAndDeletedFalse(SessionStatus status); List findByUser_IdAndStatusOrderByEndedAtDesc(Long userId, SessionStatus status, Pageable pageable); + + // 원자적 종료 전이: IN_PROGRESS 일 때만 단일 조건부 UPDATE 로 종료 상태로 바꾼다. + // 영향 행 수(0 또는 1)를 반환 — 1을 받은 호출자만 '종료를 차지(claim)'했으므로 + // SessionEndedEvent 등 종료 부수효과를 단 한 번만 발행한다. 동시 종료(스위퍼·수동·콜백) + // 시 DB 행 락으로 직렬화돼 정확히 하나만 1을 받는다(중복 피드백 발행 방지). + @Modifying + @Query("update InterviewSession s set s.status = :to, s.endedAt = :now " + + "where s.id = :id and s.status = com.stackup.stackup.session.domain.SessionStatus.IN_PROGRESS") + int finishIfInProgress(@Param("id") Long id, + @Param("to") SessionStatus to, + @Param("now") Instant now); } diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/SessionStatus.java b/backend/src/main/java/com/stackup/stackup/session/domain/SessionStatus.java index 69da2e8a..c3c8a5a3 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/SessionStatus.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/SessionStatus.java @@ -5,5 +5,10 @@ public enum SessionStatus { IN_PROGRESS, INTERRUPTED, COMPLETED, - CANCELLED + CANCELLED; + + // 더 이상 진행/전이가 불가능한 종료 상태. 종료 후 도착하는 비동기 콜백을 드롭하는 가드에 사용. + public boolean isTerminal() { + return this == INTERRUPTED || this == COMPLETED || this == CANCELLED; + } } diff --git a/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java index 5135dfec..210f9d5d 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java @@ -306,6 +306,8 @@ void apply_followupDontKnow_deletesPlaceholderAndAdvances() { // advanceToNextGeneral 내부에서 sessionRepository.findById 재호출 when(poolRepository.findFirstBySessionIdAndUsedFalseOrderByIdxAsc(22L)) .thenReturn(java.util.Optional.empty()); + // endSession 의 원자적 종료 전이 — 전이를 차지(1). + when(sessionRepository.finishIfInProgress(any(), any(), any())).thenReturn(1); service.apply(env); @@ -322,6 +324,8 @@ void advanceToNextGeneral_endsWithMaxReached_whenMainQuestionsHitLimit() { // maxQuestions=5; 메인질문 5개를 이미 던진 상태 → 다음 advance 에서 풀을 보지 않고 종료. ReflectionTestUtils.setField(session, "totalQuestionCount", 5); when(sessionRepository.findById(33L)).thenReturn(Optional.of(session)); + // endSession 의 원자적 종료 전이 — 전이를 차지(1). + when(sessionRepository.finishIfInProgress(any(), any(), any())).thenReturn(1); service.advanceToNextGeneral(33L); diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceTest.java index e24b7ba6..09dbacea 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceTest.java @@ -187,6 +187,7 @@ void end_transitionsInProgressToCompleted() { session.start(); when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(50L, 1L)) .thenReturn(Optional.of(session)); + when(sessionRepository.finishIfInProgress(any(), any(), any())).thenReturn(1); SessionResult result = service.end(1L, 50L); diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionTimeoutServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionTimeoutServiceTest.java index d28358d7..f6e438a8 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/SessionTimeoutServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionTimeoutServiceTest.java @@ -1,7 +1,7 @@ package com.stackup.stackup.session.application; -import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -15,6 +15,7 @@ import com.stackup.stackup.session.domain.SessionMode; import com.stackup.stackup.session.domain.SessionStatus; import com.stackup.stackup.user.domain.User; +import java.time.Instant; import java.util.Optional; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -33,30 +34,46 @@ class SessionTimeoutServiceTest { @InjectMocks SessionTimeoutService service; @Test - void endTimedOut_withAnswer_completesAndEmitsEndedEvent() { + void endTimedOut_withAnswer_claimsCompletedAndEmitsEndedEvent() { InterviewSession session = inProgressFixture(50L); when(sessionRepository.findById(50L)).thenReturn(Optional.of(session)); when(messageRepository.existsBySession_IdAndRole(50L, MessageRole.INTERVIEWEE)).thenReturn(true); + when(sessionRepository.finishIfInProgress(eq(50L), eq(SessionStatus.COMPLETED), any(Instant.class))) + .thenReturn(1); service.endTimedOut(50L); - assertThat(session.getStatus()).isEqualTo(SessionStatus.COMPLETED); verify(events).publishEvent(any(SessionEndedEvent.class)); } @Test - void endTimedOut_withoutAnswer_interruptsAndDoesNotTriggerFeedback() { + void endTimedOut_withoutAnswer_claimsInterruptedAndDoesNotTriggerFeedback() { InterviewSession session = inProgressFixture(51L); when(sessionRepository.findById(51L)).thenReturn(Optional.of(session)); when(messageRepository.existsBySession_IdAndRole(51L, MessageRole.INTERVIEWEE)).thenReturn(false); + when(sessionRepository.finishIfInProgress(eq(51L), eq(SessionStatus.INTERRUPTED), any(Instant.class))) + .thenReturn(1); service.endTimedOut(51L); - assertThat(session.getStatus()).isEqualTo(SessionStatus.INTERRUPTED); // 답변이 없으면 피드백을 만들지 않는다 — SessionEndedEvent 미발행. verify(events, never()).publishEvent(any(SessionEndedEvent.class)); } + @Test + void endTimedOut_lostRace_emitsNothing() { + // 조건부 UPDATE 가 0행 → 다른 트랜잭션이 먼저 종료. 어떤 이벤트도 발행하지 않는다(중복 방지). + InterviewSession session = inProgressFixture(53L); + when(sessionRepository.findById(53L)).thenReturn(Optional.of(session)); + when(messageRepository.existsBySession_IdAndRole(53L, MessageRole.INTERVIEWEE)).thenReturn(true); + when(sessionRepository.finishIfInProgress(eq(53L), eq(SessionStatus.COMPLETED), any(Instant.class))) + .thenReturn(0); + + service.endTimedOut(53L); + + verify(events, never()).publishEvent(any()); + } + @Test void endTimedOut_notInProgress_isNoop() { InterviewSession session = inProgressFixture(52L); @@ -67,6 +84,7 @@ void endTimedOut_notInProgress_isNoop() { verify(events, never()).publishEvent(any()); verify(messageRepository, never()).existsBySession_IdAndRole(any(), any()); + verify(sessionRepository, never()).finishIfInProgress(any(), any(), any()); } private InterviewSession inProgressFixture(Long id) {