diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md
index 5eee6a8..cfad844 100644
--- a/backend/CLAUDE.md
+++ b/backend/CLAUDE.md
@@ -485,6 +485,16 @@ docker compose up -d
재개된 세션은 IN_PROGRESS 라 그대로 통과한다. 그래서 `applyFollowup` 이 **이미 FAILED 인
placeholder 를 되살리지 않도록** 막는다 — 복구가 실패 확정 + 다음 질문까지 마친 뒤 늦은 콜백이
그 자리를 채우면 살아있는 질문이 두 개가 된다. POOL 은 `countBySessionId > 0` 로 이미 멱등.
+- **STT 콜백 유실 복구 본 구현**: 음성 답변은 `(transcribing)` placeholder 로 먼저 저장되고
+ `callback.voice` 가 도착해야 채워진다. 그 콜백이 유실되면(AI 크래시·DLQ 격리·브로커 단절)
+ 메시지가 그 상태로 남고 프론트 턴 판정상 **답변 차례가 오지 않아 면접이 멈춘다** —
+ 세션 시간 초과로 통째로 끝날 때까지. 질문 생성 쪽은 실패 신호로 이미 해결했지만 음성엔
+ 대응이 없었다. `StaleTranscriptionSweeper`(기본 2분 주기)가 `interview.voice.
+ stale-transcription-minutes`(기본 5분)를 넘긴 placeholder 를 찾아
+ `VoiceTranscriptionRecoveryService.failStaleTranscription` 으로 FAILED 확정한다
+ (`STT_CALLBACK_TIMEOUT`). 그러면 기존 STT 실패 경로를 그대로 타서 사용자가 같은 질문에
+ 텍스트로 다시 답할 수 있다. 목록 생성 후 콜백이 도착한 경우를 위해 확정 직전 상태를 다시
+ 확인한다(완료된 답변을 실패로 되돌리지 않는다).
- **Spring AI 미사용** — LLM·임베딩 호출은 모두 AI 서버 위임. Core는 RabbitMQ 발행만 담당.
- **Redis 미사용** — 휘발성 데이터는 DB short-lived 레코드 또는 인메모리로.
diff --git a/backend/src/main/java/com/stackup/stackup/session/application/StaleTranscriptionSweeper.java b/backend/src/main/java/com/stackup/stackup/session/application/StaleTranscriptionSweeper.java
new file mode 100644
index 0000000..c662e8d
--- /dev/null
+++ b/backend/src/main/java/com/stackup/stackup/session/application/StaleTranscriptionSweeper.java
@@ -0,0 +1,67 @@
+package com.stackup.stackup.session.application;
+
+import com.stackup.stackup.session.domain.InterviewMessage;
+import com.stackup.stackup.session.domain.InterviewMessageRepository;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * STT 콜백을 영영 못 받은 음성 답변을 정리한다.
+ *
+ *
음성 답변은 `(transcribing)` placeholder 로 먼저 저장되고 `callback.voice` 가 도착해야
+ * 채워진다. 그 콜백이 유실되면(AI 크래시·DLQ 격리·브로커 단절) 메시지는 그 상태로 남고,
+ * 프론트의 턴 판정상 답변 차례가 오지 않아 면접이 그대로 멈춘다 — 세션 시간 초과로
+ * 통째로 끝날 때까지. 질문 생성 쪽은 실패 신호(callback status=FAILED)로 이미 해결했지만
+ * 음성에는 대응이 없었다.
+ *
+ *
여기서는 오래 멈춘 placeholder 를 FAILED 로 확정한다. 그러면 기존 STT 실패 경로를 그대로
+ * 타서(프론트 `currentTurn` 의 INTERVIEWEE+FAILED 분기, 백엔드 `resolveAnswerParent`)
+ * 사용자는 같은 질문에 텍스트로 다시 답할 수 있다.
+ */
+@Component
+@RequiredArgsConstructor
+public class StaleTranscriptionSweeper {
+
+ private static final Logger log = LoggerFactory.getLogger(StaleTranscriptionSweeper.class);
+
+ private final InterviewMessageRepository messageRepository;
+ private final VoiceTranscriptionRecoveryService recoveryService;
+
+ // 이 시간이 지나도 전사가 안 채워지면 유실로 본다. 배치 STT 는 보통 수십 초 안에 끝난다.
+ @Value("${interview.voice.stale-transcription-minutes:5}")
+ private long staleAfterMinutes = 5;
+
+ @Transactional(propagation = Propagation.NOT_SUPPORTED)
+ @Scheduled(
+ fixedDelayString = "${interview.voice.sweep-interval-ms:120000}",
+ initialDelayString = "${interview.voice.sweep-initial-delay-ms:60000}")
+ public void sweep() {
+ Instant before = Instant.now().minus(Duration.ofMinutes(staleAfterMinutes));
+ List stale = messageRepository.findStaleTranscribing(
+ InterviewMessage.VOICE_TRANSCRIPTION_PENDING_TEXT, before);
+ if (stale.isEmpty()) {
+ return;
+ }
+ int failed = 0;
+ for (InterviewMessage m : stale) {
+ try {
+ // 메시지마다 독립 트랜잭션 — 하나가 실패해도 나머지는 정리된다.
+ recoveryService.failStaleTranscription(m.getId());
+ failed++;
+ } catch (RuntimeException e) {
+ log.warn("stale transcription recovery failed. messageId={}", m.getId(), e);
+ }
+ }
+ log.info("stale transcription sweeper failed {} of {} pending voice answer(s)",
+ failed, stale.size());
+ }
+}
diff --git a/backend/src/main/java/com/stackup/stackup/session/application/VoiceTranscriptionRecoveryService.java b/backend/src/main/java/com/stackup/stackup/session/application/VoiceTranscriptionRecoveryService.java
new file mode 100644
index 0000000..e53a331
--- /dev/null
+++ b/backend/src/main/java/com/stackup/stackup/session/application/VoiceTranscriptionRecoveryService.java
@@ -0,0 +1,48 @@
+package com.stackup.stackup.session.application;
+
+import com.stackup.stackup.common.messaging.RealtimeNotifyEvent;
+import com.stackup.stackup.common.sse.SseEventType;
+import com.stackup.stackup.session.domain.InterviewMessage;
+import com.stackup.stackup.session.domain.InterviewMessageRepository;
+import com.stackup.stackup.session.domain.MessageStatus;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+// STT 콜백 유실 복구. 스위퍼가 메시지마다 이 서비스를 호출한다(각자 독립 트랜잭션).
+@Service
+@RequiredArgsConstructor
+public class VoiceTranscriptionRecoveryService {
+
+ private static final Logger log = LoggerFactory.getLogger(VoiceTranscriptionRecoveryService.class);
+ private static final String ERROR_CODE = "STT_CALLBACK_TIMEOUT";
+
+ private final InterviewMessageRepository messageRepository;
+ private final ApplicationEventPublisher events;
+
+ @Transactional
+ public void failStaleTranscription(Long messageId) {
+ InterviewMessage message = messageRepository.findById(messageId).orElse(null);
+ if (message == null) {
+ return;
+ }
+ // 스위퍼가 목록을 만든 뒤 콜백이 도착했을 수 있다 — 이미 채워졌으면 건드리지 않는다.
+ if (message.getStatus() != MessageStatus.CREATED
+ || !InterviewMessage.VOICE_TRANSCRIPTION_PENDING_TEXT.equals(message.getContent())) {
+ return;
+ }
+ message.failVoiceTranscription();
+
+ Long sessionId = message.getSession().getId();
+ VoiceCallbackService.VoiceFailedNotice notice = new VoiceCallbackService.VoiceFailedNotice(
+ sessionId, message.getId(), ERROR_CODE, message.getContent());
+ events.publishEvent(RealtimeNotifyEvent.session(sessionId, SseEventType.SESSION_MESSAGE, notice));
+ events.publishEvent(RealtimeNotifyEvent.user(message.getSession().getUser().getId(),
+ SseEventType.SESSION_MESSAGE, notice));
+ log.warn("voice answer stuck in transcription — marked FAILED so the turn unlocks. "
+ + "sessionId={}, messageId={}", sessionId, messageId);
+ }
+}
diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessageRepository.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessageRepository.java
index bac3e08..97b51cb 100644
--- a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessageRepository.java
+++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessageRepository.java
@@ -31,6 +31,20 @@ public interface InterviewMessageRepository extends JpaRepository findBookmarkedByOwner(@Param("userId") Long userId);
+ // STT 콜백을 기다리다 멈춰 있는 음성 답변. content 가 아직 pending sentinel 이고
+ // 진행 중 세션에 속한 것만. (callback.voice 가 유실되면 이 상태로 영구히 남는다.)
+ @Query("""
+ select m from InterviewMessage m
+ where m.role = com.stackup.stackup.session.domain.MessageRole.INTERVIEWEE
+ and m.status = com.stackup.stackup.session.domain.MessageStatus.CREATED
+ and m.content = :pendingText
+ and m.createdAt < :before
+ and m.session.deleted = false
+ and m.session.status = com.stackup.stackup.session.domain.SessionStatus.IN_PROGRESS
+ """)
+ List findStaleTranscribing(@Param("pendingText") String pendingText,
+ @Param("before") java.time.Instant before);
+
// 질문에 달린 답변(있으면 1개). 오답노트에 '내 답변 + 코칭'을 함께 보여주기 위해.
List findByParentMessage_IdIn(List parentMessageIds);
}
diff --git a/backend/src/test/java/com/stackup/stackup/session/application/VoiceTranscriptionRecoveryServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/VoiceTranscriptionRecoveryServiceTest.java
new file mode 100644
index 0000000..c09f898
--- /dev/null
+++ b/backend/src/test/java/com/stackup/stackup/session/application/VoiceTranscriptionRecoveryServiceTest.java
@@ -0,0 +1,90 @@
+package com.stackup.stackup.session.application;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.stackup.stackup.common.messaging.RealtimeNotifyEvent;
+import com.stackup.stackup.session.domain.InterviewMessage;
+import com.stackup.stackup.session.domain.InterviewMessageRepository;
+import com.stackup.stackup.session.domain.InterviewSession;
+import com.stackup.stackup.session.domain.JobCategory;
+import com.stackup.stackup.session.domain.MessageStatus;
+import com.stackup.stackup.session.domain.SessionMode;
+import com.stackup.stackup.user.domain.User;
+import java.util.List;
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.test.util.ReflectionTestUtils;
+
+/**
+ * `callback.voice` 가 유실되면 음성 답변이 `(transcribing)` 으로 남고 답변 차례가 오지 않아
+ * 면접이 멈춘다. 이를 FAILED 로 확정해 기존 STT 실패 경로(텍스트 재답변)를 타게 한다.
+ */
+@ExtendWith(MockitoExtension.class)
+class VoiceTranscriptionRecoveryServiceTest {
+
+ @Mock InterviewMessageRepository messageRepository;
+ @Mock ApplicationEventPublisher events;
+ @InjectMocks VoiceTranscriptionRecoveryService service;
+
+ @Test
+ void failStaleTranscription_marksFailedAndNotifies() {
+ InterviewMessage pending = pendingVoiceAnswer(200L);
+ when(messageRepository.findById(200L)).thenReturn(Optional.of(pending));
+
+ service.failStaleTranscription(200L);
+
+ assertThat(pending.getStatus()).isEqualTo(MessageStatus.FAILED);
+ // 내용이 실패 안내로 바뀌어야 프론트가 '텍스트로 다시 답변' 턴으로 인식한다.
+ assertThat(pending.getContent())
+ .isNotEqualTo(InterviewMessage.VOICE_TRANSCRIPTION_PENDING_TEXT);
+ // 세션 채널 + 유저 채널 양쪽에 알린다(다른 탭에서도 턴이 풀려야 한다).
+ verify(events, times(2)).publishEvent(any(RealtimeNotifyEvent.class));
+ }
+
+ // 스위퍼가 목록을 만든 뒤 콜백이 도착했을 수 있다 — 완료된 답변을 실패로 되돌리면 안 된다.
+ @Test
+ void failStaleTranscription_skipsWhenTranscriptArrivedMeanwhile() {
+ InterviewMessage completed = pendingVoiceAnswer(200L);
+ completed.completeWithTranscript("실제로는 전사가 도착했습니다");
+ when(messageRepository.findById(200L)).thenReturn(Optional.of(completed));
+
+ service.failStaleTranscription(200L);
+
+ assertThat(completed.getStatus()).isEqualTo(MessageStatus.COMPLETED);
+ assertThat(completed.getContent()).isEqualTo("실제로는 전사가 도착했습니다");
+ verify(events, never()).publishEvent(any());
+ }
+
+ @Test
+ void failStaleTranscription_isNoopWhenMessageMissing() {
+ when(messageRepository.findById(200L)).thenReturn(Optional.empty());
+
+ service.failStaleTranscription(200L);
+
+ verify(events, never()).publishEvent(any());
+ }
+
+ private InterviewMessage pendingVoiceAnswer(Long id) {
+ User user = User.createGithubUser(1L, "u", null, null, "t");
+ ReflectionTestUtils.setField(user, "id", 1L);
+ InterviewSession session = InterviewSession.create(
+ user, "면접", null, SessionMode.TECHNICAL, List.of(JobCategory.BACKEND), 5, 30, null, null);
+ ReflectionTestUtils.setField(session, "id", 10L);
+ session.start();
+
+ InterviewMessage question = InterviewMessage.interviewer(session, 1, "질문");
+ InterviewMessage pending = InterviewMessage.voiceInterviewee(session, 2, question, null);
+ ReflectionTestUtils.setField(pending, "id", id);
+ return pending;
+ }
+}