diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index ef8d21b0..103d3e60 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -411,6 +411,18 @@ docker compose up -d 호출 — 답변 있으면 COMPLETED(→`SessionEndedEvent(DURATION_EXCEEDED)`→피드백), 없으면 INTERRUPTED(피드백 없음). 좀비 세션(자기소개 미답변·STT 실패·탭 종료) 방지. 주기: `interview.session.sweep-interval-ms`(기본 300000)·`sweep-initial-delay-ms`(기본 60000). + - **"답변 0개면 COMPLETED 가 아니다" 는 수동 종료에도 적용된다**(2026-09-29). 위 규칙은 스위퍼에만 + 있었고 `SessionService.end` 는 무조건 COMPLETED + `SessionEndedEvent` 였다 — 자기소개만 받고 + '종료' 를 누른 세션이 완료로 남아 피드백 생성까지 탔다(운영 10건, 최근 session 98). 세션당 + LLM 을 7회 가까이 쓰고(패널·종합·첫인상·코칭·임베딩) 점수가 전부 null 인 피드백 페이지가 + 만들어진다. 이제 `existsBySession_IdAndRole(INTERVIEWEE)` 로 분기해 없으면 INTERRUPTED. + - **COMPLETED 로 두고 이벤트만 막으면 더 나쁘다** — 피드백 없는 COMPLETED 는 조회가 + `FEEDBACK_NOT_READY`(404)라 화면이 "생성 중"에서 영구히 멈춘다. INTERRUPTED 는 피드백을 + 기대하지 않고 `PATCH /resume` 로 이어서 할 수 있다. + - 콜백 종료(`QuestionsCallbackService.endSession`)에는 같은 가드를 두지 않았다 — POOL 요청이 + 자기소개 **답변** 이후에만 발행되므로 그 경로에 답변 0개 세션이 도달하지 않는다. + - 프론트 종료 확인 문구도 답변 유무로 갈린다(`InterviewStage`) — 답변이 없을 때 + "피드백 단계로 넘어갑니다 / 되돌릴 수 없습니다" 는 둘 다 거짓이다. - **동시 종료 안전(원자적 전이)**: 모든 종료 경로(스위퍼·수동 `SessionService.end`·콜백 `endSession`)는 `InterviewSessionRepository.finishIfInProgress`(조건부 UPDATE `WHERE status=IN_PROGRESS`)로 전이를 차지하고, **영향 행 1인 트랜잭션만** `SessionEndedEvent` 를 발행한다 → DB 행 락으로 직렬화돼 동시 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 4924ad37..5462e2da 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 @@ -12,9 +12,11 @@ import com.stackup.stackup.session.application.dto.SessionResult; import com.stackup.stackup.session.application.event.SessionCreatedEvent; import com.stackup.stackup.session.application.event.SessionEndedEvent; +import com.stackup.stackup.session.domain.InterviewMessageRepository; import com.stackup.stackup.session.domain.InterviewSession; import com.stackup.stackup.session.domain.InterviewSessionRepository; import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.MessageRole; import com.stackup.stackup.session.domain.SessionMode; import com.stackup.stackup.session.domain.SessionContext; import com.stackup.stackup.session.domain.SessionContextRepository; @@ -51,6 +53,7 @@ public class SessionService { private final InterviewSessionRepository sessionRepository; private final SessionContextRepository contextRepository; + private final InterviewMessageRepository messageRepository; private final AnalyzedDocumentRepository documentRepository; private final SessionFeedbackRepository feedbackRepository; private final UserRepository userRepository; @@ -237,15 +240,31 @@ public SessionResult start(Long userId, Long sessionId) { @Transactional public SessionResult end(Long userId, Long sessionId) { InterviewSession session = loadOwned(userId, sessionId); + // 답변이 하나도 없으면 COMPLETED 가 아니라 INTERRUPTED 다 — 시간초과 스위퍼 + // (SessionTimeoutService.endTimedOut)가 처음부터 쓰던 규칙이고, 수동 종료만 빠져 있었다. + // 그래서 자기소개만 받고 종료를 누른 세션이 COMPLETED 로 남아 피드백 생성까지 태웠다 + // (운영 10건: 평가위원 패널·종합·첫인상·코칭·임베딩으로 LLM 을 세션당 7회 가까이 쓰고, + // 점수가 전부 null 인 피드백 페이지가 만들어졌다). + // INTERRUPTED 로 두면 피드백을 만들지 않고 `PATCH /resume` 로 이어서 할 수 있다 — + // COMPLETED + 피드백 없음은 조회가 FEEDBACK_NOT_READY(404)라 "생성 중"에서 멈춘다. + boolean hasAnswer = + messageRepository.existsBySession_IdAndRole(sessionId, MessageRole.INTERVIEWEE); + SessionStatus target = hasAnswer ? SessionStatus.COMPLETED : SessionStatus.INTERRUPTED; // 원자적 종료 전이: IN_PROGRESS 일 때만 1행 갱신. 0이면 다른 트랜잭션(스위퍼 등)이 // 먼저 종료한 것 → 기존 동작과 동일하게 INVALID_STATE. 1을 받은 호출자만 종료 이벤트 발행. - int claimed = sessionRepository.finishIfInProgress( - sessionId, SessionStatus.COMPLETED, Instant.now()); + int claimed = sessionRepository.finishIfInProgress(sessionId, target, 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")); + // 응답·인메모리 동기화(DB는 위 조건부 UPDATE 로 이미 전이됨). + if (target == SessionStatus.COMPLETED) { + session.end(); + events.publishEvent(new SessionEndedEvent(userId, sessionId, "USER_REQUEST")); + } else { + session.interrupt(); + log.info("session ended with no answers — interrupted instead of completed. sessionId={}", + sessionId); + } return SessionResult.of(session, contextDocumentIds(sessionId)); } 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 e927d11b..7f6cee69 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 @@ -3,6 +3,7 @@ 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.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -16,9 +17,11 @@ import com.stackup.stackup.session.application.dto.SessionResult; import com.stackup.stackup.session.application.event.SessionCreatedEvent; import com.stackup.stackup.session.application.event.SessionEndedEvent; +import com.stackup.stackup.session.domain.InterviewMessageRepository; import com.stackup.stackup.session.domain.InterviewSession; import com.stackup.stackup.session.domain.InterviewSessionRepository; import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.MessageRole; import com.stackup.stackup.session.domain.SessionContext; import com.stackup.stackup.session.domain.SessionContextRepository; import com.stackup.stackup.session.domain.SessionFeedback; @@ -42,6 +45,7 @@ class SessionServiceTest { @Mock InterviewSessionRepository sessionRepository; @Mock SessionContextRepository contextRepository; + @Mock InterviewMessageRepository messageRepository; @Mock AnalyzedDocumentRepository documentRepository; @Mock UserRepository userRepository; @Mock SessionFeedbackRepository feedbackRepository; @@ -394,11 +398,51 @@ void end_transitionsInProgressToCompleted() { session.start(); when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(50L, 1L)) .thenReturn(Optional.of(session)); - when(sessionRepository.finishIfInProgress(any(), any(), any())).thenReturn(1); + when(messageRepository.existsBySession_IdAndRole(50L, MessageRole.INTERVIEWEE)) + .thenReturn(true); + when(sessionRepository.finishIfInProgress(eq(50L), eq(SessionStatus.COMPLETED), any())) + .thenReturn(1); SessionResult result = service.end(1L, 50L); assertThat(result.status()).isEqualTo(SessionStatus.COMPLETED); + verify(events).publishEvent(any(SessionEndedEvent.class)); + } + + @Test + void end_withoutAnyAnswer_interruptsInsteadOfCompleting() { + // 자기소개만 받고(답변 0개) 종료를 누른 세션. 예전엔 COMPLETED 로 남아 + // SessionEndedEvent → generate.feedback 까지 태웠고, 점수가 전부 null 인 + // 피드백이 만들어졌다(운영 10건). 시간초과 스위퍼는 처음부터 이 규칙이었다. + InterviewSession session = sessionFixture(50L); + session.start(); + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(50L, 1L)) + .thenReturn(Optional.of(session)); + when(messageRepository.existsBySession_IdAndRole(50L, MessageRole.INTERVIEWEE)) + .thenReturn(false); + when(sessionRepository.finishIfInProgress(eq(50L), eq(SessionStatus.INTERRUPTED), any())) + .thenReturn(1); + + SessionResult result = service.end(1L, 50L); + + assertThat(result.status()).isEqualTo(SessionStatus.INTERRUPTED); + verify(events, never()).publishEvent(any(SessionEndedEvent.class)); + } + + @Test + void end_withoutAnyAnswer_stillFailsWhenTransitionIsLost() { + // 전이를 놓친 쪽은 부수효과를 내지 않는다 — 답변 없는 경로에서도 같아야 한다. + InterviewSession session = sessionFixture(50L); + session.start(); + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(50L, 1L)) + .thenReturn(Optional.of(session)); + when(messageRepository.existsBySession_IdAndRole(50L, MessageRole.INTERVIEWEE)) + .thenReturn(false); + when(sessionRepository.finishIfInProgress(eq(50L), eq(SessionStatus.INTERRUPTED), any())) + .thenReturn(0); + + assertThatThrownBy(() -> service.end(1L, 50L)).isInstanceOf(DomainException.class); + verify(events, never()).publishEvent(any(SessionEndedEvent.class)); } @Test diff --git a/frontend/src/features/interview/ui/live/InterviewStage.endCopy.test.tsx b/frontend/src/features/interview/ui/live/InterviewStage.endCopy.test.tsx new file mode 100644 index 00000000..f9c946ee --- /dev/null +++ b/frontend/src/features/interview/ui/live/InterviewStage.endCopy.test.tsx @@ -0,0 +1,68 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { InterviewStage } from './InterviewStage' +import type { Message, Session } from '@/domain/session' + +vi.mock('./InterviewerAvatar', () => ({ InterviewerAvatar: () =>
})) +vi.mock('./StageQuestion', () => ({ StageQuestion: () =>
})) +vi.mock('./WebcamSelfView', () => ({ WebcamSelfView: () =>
})) +vi.mock('./TranscriptDrawer', () => ({ TranscriptDrawer: () =>
})) +vi.mock('./AnswerComposer', () => ({ AnswerComposer: () =>
})) +vi.mock('./DeliveryModeToggle', () => ({ DeliveryModeToggle: () =>
})) + +const session: Session = { + id: 7, + title: '백엔드 모의면접', + status: 'IN_PROGRESS', + maxQuestions: 5, + generalQuestionCount: 5, + totalQuestionCount: 2, +} + +const question: Message = { id: 1, sequenceNumber: 1, role: 'INTERVIEWER', content: '자기소개' } +const answer: Message = { id: 2, sequenceNumber: 2, role: 'INTERVIEWEE', content: '안녕하세요' } + +function renderStage(items: Message[]) { + render( + ({ ...m, key: `m-${m.id}` }))} + awaitingQuestion={false} + questionStreaming={false} + onSubmit={vi.fn()} + onSubmitVoice={vi.fn()} + voiceUploading={false} + onEnd={vi.fn()} + onInterrupt={vi.fn()} + deliveryMode="text" + onDeliveryModeChange={vi.fn()} + wasSegmented={() => false} + isSpeaking={() => false} + onRetranscribe={vi.fn()} + retranscribing={false} + />, + ) +} + +// 서버는 답변이 0개인 세션을 COMPLETED 가 아니라 INTERRUPTED 로 끝낸다(SessionService.end). +// 그 경우 "피드백 단계로 넘어갑니다 / 되돌릴 수 없습니다" 는 둘 다 거짓이 된다. +describe('InterviewStage 종료 안내 문구', () => { + it('답변이 있으면 피드백 단계로 넘어간다고 안내한다', async () => { + renderStage([question, answer]) + + await userEvent.click(screen.getByRole('button', { name: '종료' })) + + expect(screen.getByText(/피드백 단계로 넘어갑니다/)).toBeTruthy() + }) + + it('답변이 없으면 피드백을 약속하지 않고 이어서 할 수 있다고 안내한다', async () => { + renderStage([question]) + + await userEvent.click(screen.getByRole('button', { name: '종료' })) + + expect(screen.getByText(/피드백은 만들어지지 않습니다/)).toBeTruthy() + expect(screen.queryByText(/되돌릴 수 없습니다/)).toBeNull() + }) +}) diff --git a/frontend/src/features/interview/ui/live/InterviewStage.tsx b/frontend/src/features/interview/ui/live/InterviewStage.tsx index ece9aadf..bc1754f4 100644 --- a/frontend/src/features/interview/ui/live/InterviewStage.tsx +++ b/frontend/src/features/interview/ui/live/InterviewStage.tsx @@ -3,7 +3,7 @@ import { StatusBadge } from '@/shared/ui/StatusBadge' import { focusAreaLabel } from '../../lib/focusArea' import { Button } from '@/shared/ui/Button' import { ConfirmDialog } from '@/shared/ui/ConfirmDialog' -import { isQuestion, isTranscribing, sessionProgress } from '@/domain/session' +import { isAnswer, isQuestion, isTranscribing, sessionProgress } from '@/domain/session' import type { Session } from '@/domain/session' import type { ConnectionStatus, ThreadItem } from '../../model/useLiveInterview' import type { DeliveryMode } from '../../model/useDeliveryMode' @@ -106,6 +106,12 @@ export function InterviewStage({ ? lastItem.id : null const speaking = currentQuestion ? isSpeaking(currentQuestion.id ?? -1) : false + // 답변이 하나도 없으면 서버가 COMPLETED 가 아니라 INTERRUPTED 로 끝낸다 + // (SessionService.end) — 피드백을 만들지 않고 이어서 할 수도 있으므로, + // "피드백 단계로 넘어갑니다 / 되돌릴 수 없습니다" 는 그 경우 둘 다 거짓이다. + const endDescription = items.some(isAnswer) + ? '진행 중인 면접이 끝나고 피드백 단계로 넘어갑니다. 이 작업은 되돌릴 수 없습니다.' + : '아직 답변한 질문이 없어 피드백은 만들어지지 않습니다. 중단된 면접으로 남고, 나중에 이어서 진행할 수 있어요.' return (
@@ -255,7 +261,7 @@ export function InterviewStage({