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
12 changes: 12 additions & 0 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 행 락으로 직렬화돼 동시
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -42,6 +45,7 @@ class SessionServiceTest {

@Mock InterviewSessionRepository sessionRepository;
@Mock SessionContextRepository contextRepository;
@Mock InterviewMessageRepository messageRepository;
@Mock AnalyzedDocumentRepository documentRepository;
@Mock UserRepository userRepository;
@Mock SessionFeedbackRepository feedbackRepository;
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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: () => <div /> }))
vi.mock('./StageQuestion', () => ({ StageQuestion: () => <div /> }))
vi.mock('./WebcamSelfView', () => ({ WebcamSelfView: () => <div /> }))
vi.mock('./TranscriptDrawer', () => ({ TranscriptDrawer: () => <div /> }))
vi.mock('./AnswerComposer', () => ({ AnswerComposer: () => <div /> }))
vi.mock('./DeliveryModeToggle', () => ({ DeliveryModeToggle: () => <div /> }))

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(
<InterviewStage
session={session}
connection="open"
items={items.map((m) => ({ ...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()
})
})
10 changes: 8 additions & 2 deletions frontend/src/features/interview/ui/live/InterviewStage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 (
<section className="anim-screen-power-on relative flex h-full flex-col overflow-hidden">
Expand Down Expand Up @@ -255,7 +261,7 @@ export function InterviewStage({
<ConfirmDialog
open={endConfirmOpen}
title="면접을 종료하시겠습니까?"
description="진행 중인 면접이 끝나고 피드백 단계로 넘어갑니다. 이 작업은 되돌릴 수 없습니다."
description={endDescription}
confirmLabel="종료"
cancelLabel="계속 진행"
danger
Expand Down
Loading