diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md
index 251f5baa..fc7f379e 100644
--- a/backend/CLAUDE.md
+++ b/backend/CLAUDE.md
@@ -552,6 +552,30 @@ docker compose up -d
(`STT_CALLBACK_TIMEOUT`). 그러면 기존 STT 실패 경로를 그대로 타서 사용자가 같은 질문에
텍스트로 다시 답할 수 있다. 목록 생성 후 콜백이 도착한 경우를 위해 확정 직전 상태를 다시
확인한다(완료된 답변을 실패로 되돌리지 않는다).
+- **꼬리질문 콜백 유실 복구 본 구현**: 스트리밍 꼬리질문은 `(생성 중)` placeholder 를 먼저
+ INSERT 하고 `callback.questions(FOLLOWUP)` 이 도착해야 확정된다. AI 가 **실패를 보고**하면
+ `applyFollowupFailed` 가 처리하지만, **콜백 자체가 오지 않으면**(AI 크래시·DLQ 격리·브로커
+ 단절) 그 경로를 아무도 타지 못한다 — placeholder 가 CREATED 로 남고, 답할 질문이 없어
+ 면접이 그 자리에서 멈춘다(세션 시간 초과로 통째로 끝날 때까지). 음성 답변의
+ `(transcribing)` 과 정확히 같은 구멍인데 질문 쪽에만 대응이 없었다. 운영에서 1건 발생
+ (messageId=444, sessionId=93 — 28일째 CREATED, 해당 세션은 시간초과로 조기 종료됨).
+ `StaleFollowupSweeper`(기본 2분 주기)가 `interview.followup.stale-generation-minutes`
+ (기본 3분)를 넘긴 placeholder 를 찾아 `QuestionsCallbackService.failStaleFollowup` 을 부른다.
+ - **콜백 경로와 같은 코드를 탄다**(`markFollowupFailedAndAdvance`) — 실패 문구 확정 +
+ `SESSION_MESSAGE` 발행 + `advanceToNextGeneral`. "어떻게 실패했는지"에 따라 사용자가
+ 보는 결과가 갈리면 안 된다. 이벤트 reason 만 `FOLLOWUP_TIMEOUT` 으로 구분한다.
+ - 확정 직전 상태를 다시 확인한다 — 스위퍼 조회 이후 콜백이 도착했거나(완성된 질문을
+ 실패로 되돌리면 안 된다) 세션이 종료됐을 수 있다.
+ - **실패로 확정된 질문은 TTS 를 요청하지 않는다**(`SessionTtsRequester` 가드). 실패 사실은
+ 화면에 보여야 하므로 SSE 는 그대로 나가는데, `QuestionPersistedEvent` 에 가드가 없어
+ Gemini TTS 가 "질문 생성에 실패했습니다"를 음성으로 만들고 있었다 — 운영 2건
+ (messageId=434·437, `tts_status=SUCCEEDED`). 면접관이 오류 문구를 읽고, 대화 기록의
+ 재생 버튼도 그걸 들려준다.
+ - 두 스위퍼의 조회 JPQL 은 `InterviewMessageSweepQueryTest`(`@PostgresRepositoryTest`)로
+ 검증한다. **멈춘 면접을 푸는 유일한 장치인데 지금까지 테스트가 없었다** — 넓으면 정상
+ 진행 중인 턴을 실패로 되돌리고, 좁으면 조용히 0건이 되어 스위퍼가 있으나 마나가 되는데
+ 둘 다 배포 전에는 신호가 없다(§15.1). `createdAt` 은 감사 필드라 못 바꾸므로 cutoff 를
+ 움직여 양방향으로 확인한다.
- **STT 실패 재전사 본 구현**: STT 가 실패하면 `failVoiceTranscription()` 이 content 를 실패
문구로 덮고 FAILED 로 확정해 턴을 풀어 줬는데, **오디오는 S3 에 그대로 남아 있는데 꺼낼
경로가 없어** 사용자가 답변을 통째로 다시 입력해야 했다. 실패의 대부분은 Deepgram 이
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 b801c21e..a84e036a 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
@@ -328,11 +328,50 @@ private void applyFollowupFailed(InterviewSession session, QuestionsCallbackPayl
publishErrorEvent(session, "FOLLOWUP", payload);
return;
}
+ markFollowupFailedAndAdvance(session, placeholder, "FOLLOWUP_FAILED");
+ }
+
+ /**
+ * 꼬리질문 콜백을 영영 못 받은 placeholder 를 실패로 확정하고 면접을 진행시킨다.
+ *
+ *
{@link StaleFollowupSweeper} 가 메시지마다 호출한다. AI 가 status=FAILED 콜백을
+ * 보내 준 경우는 {@link #applyFollowupFailed} 가 처리하지만, 콜백 자체가 오지 않으면
+ * (AI 크래시·DLQ 격리·브로커 단절) 그 경로를 타지 못한다 — 음성 답변의
+ * `(transcribing)` 과 정확히 같은 구멍이고, 여기가 그 대응이다.
+ *
+ *
확정 직전 상태를 다시 확인한다: 스위퍼가 목록을 만든 뒤 콜백이 도착했을 수 있고,
+ * 완성된 질문을 실패로 되돌리면 사용자가 받은 질문이 사라진다.
+ */
+ @Transactional
+ public void failStaleFollowup(Long messageId) {
+ InterviewMessage placeholder = messageRepository.findById(messageId).orElse(null);
+ if (placeholder == null) {
+ return;
+ }
+ if (placeholder.getStatus() != MessageStatus.CREATED
+ || !InterviewMessage.FOLLOWUP_GENERATING_TEXT.equals(placeholder.getContent())) {
+ return;
+ }
+ InterviewSession session = placeholder.getSession();
+ // 스위퍼 조회 이후 세션이 끝났을 수 있다(시간초과·수동종료). 종료 세션에 질문을
+ // 더하지 않는 것은 콜백 경로의 terminal 가드와 같은 원칙.
+ if (session == null || session.isDeleted() || session.getStatus().isTerminal()) {
+ return;
+ }
+ log.warn("follow-up stuck in generation — no callback arrived. sessionId={}, messageId={}",
+ session.getId(), messageId);
+ markFollowupFailedAndAdvance(session, placeholder, "FOLLOWUP_TIMEOUT");
+ }
+
+ // 실패 확정 + 화면 갱신 + 다음 일반질문. 콜백 경로와 스위퍼 경로가 같은 코드를 타야
+ // "어떻게 실패했는지"에 따라 사용자가 보는 결과가 갈리지 않는다.
+ private void markFollowupFailedAndAdvance(InterviewSession session, InterviewMessage placeholder,
+ String reason) {
placeholder.failFollowup();
InterviewMessage message = messageRepository.save(placeholder);
- publishQuestionEvents(session, message, "FOLLOWUP_FAILED");
- log.info("callback.questions FOLLOWUP marked failed, advancing to next general. sessionId={}, msg={}",
- session.getId(), message.getId());
+ publishQuestionEvents(session, message, reason);
+ log.info("follow-up marked failed({}), advancing to next general. sessionId={}, msg={}",
+ reason, session.getId(), message.getId());
advanceToNextGeneral(session.getId());
}
diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionTtsRequester.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionTtsRequester.java
index ac5dd3cc..c88af363 100644
--- a/backend/src/main/java/com/stackup/stackup/session/application/SessionTtsRequester.java
+++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionTtsRequester.java
@@ -8,6 +8,7 @@
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.MessageStatus;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -36,6 +37,15 @@ public void onQuestionPersisted(QuestionPersistedEvent event) {
log.warn("generate.tts skipped — message not found. messageId={}", event.messageId());
return;
}
+ // 실패로 확정된 질문은 읽어 주지 않는다. FOLLOWUP 생성 실패/유실도 이 이벤트를 타는데
+ // (실패 사실을 화면에 보여야 하므로 SSE 는 그대로 나간다), 그대로 두면 Gemini TTS 를
+ // 한 번 써서 "질문 생성에 실패했습니다"를 음성으로 만든다 — 실제로 운영에 2건 있다
+ // (messageId=434·437, tts_status=SUCCEEDED). 면접관이 오류 문구를 말하는 셈이고,
+ // 대화 기록의 재생 버튼도 그걸 들려준다.
+ if (message.getStatus() == MessageStatus.FAILED) {
+ log.info("generate.tts skipped — message is FAILED. messageId={}", event.messageId());
+ return;
+ }
InterviewSession session = message.getSession();
GenerateTtsPayload payload = new GenerateTtsPayload(
session.getId(),
diff --git a/backend/src/main/java/com/stackup/stackup/session/application/StaleFollowupSweeper.java b/backend/src/main/java/com/stackup/stackup/session/application/StaleFollowupSweeper.java
new file mode 100644
index 00000000..8f43887a
--- /dev/null
+++ b/backend/src/main/java/com/stackup/stackup/session/application/StaleFollowupSweeper.java
@@ -0,0 +1,71 @@
+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;
+
+/**
+ * 꼬리질문 콜백을 영영 못 받은 placeholder 를 정리한다.
+ *
+ *
스트리밍 꼬리질문은 `(생성 중)` placeholder 를 먼저 INSERT 하고
+ * `callback.questions(FOLLOWUP)` 이 도착해야 확정된다. AI 가 실패를 보고하면
+ * {@code QuestionsCallbackService.applyFollowupFailed} 가 실패 문구로 확정하고 다음
+ * 일반질문으로 넘어가지만, 콜백 자체가 오지 않으면(AI 크래시·DLQ 격리·브로커 단절)
+ * 그 경로를 아무도 타지 못한다. placeholder 는 CREATED 인 채로 남고, 화면에는 "(생성 중)"
+ * 만 떠 있으며, 답할 질문이 없으니 면접이 그 자리에서 멈춘다 — 세션 시간 초과로 통째로
+ * 끝날 때까지.
+ *
+ *
음성 답변의 `(transcribing)` 이 정확히 같은 구멍이었고 {@link StaleTranscriptionSweeper}
+ * 로 막았다. 이건 그 짝이다. 운영에서 1건 발생했다(messageId=444, sessionId=93 —
+ * 28일째 CREATED 로 남아 있었고 그 세션은 시간초과로 조기 종료됐다).
+ */
+@Component
+@RequiredArgsConstructor
+public class StaleFollowupSweeper {
+
+ private static final Logger log = LoggerFactory.getLogger(StaleFollowupSweeper.class);
+
+ private final InterviewMessageRepository messageRepository;
+ private final QuestionsCallbackService callbackService;
+
+ // 이 시간이 지나도 확정되지 않으면 유실로 본다. 정상 생성은 flash 타임아웃(10초) + RAG(1.5초)
+ // 범위에서 끝나고, AI 쪽에 메시지 단위 재시도가 없어 실패는 곧장 DLQ 로 간다 —
+ // 3분이면 정상 케이스를 자를 위험 없이 넉넉하다.
+ @Value("${interview.followup.stale-generation-minutes:3}")
+ private long staleAfterMinutes = 3;
+
+ @Transactional(propagation = Propagation.NOT_SUPPORTED)
+ @Scheduled(
+ fixedDelayString = "${interview.followup.sweep-interval-ms:120000}",
+ initialDelayString = "${interview.followup.sweep-initial-delay-ms:90000}")
+ public void sweep() {
+ Instant before = Instant.now().minus(Duration.ofMinutes(staleAfterMinutes));
+ List stale = messageRepository.findStaleFollowupPlaceholders(
+ InterviewMessage.FOLLOWUP_GENERATING_TEXT, before);
+ if (stale.isEmpty()) {
+ return;
+ }
+ int recovered = 0;
+ for (InterviewMessage m : stale) {
+ try {
+ // 메시지마다 독립 트랜잭션 — 하나가 실패해도 나머지는 정리된다.
+ callbackService.failStaleFollowup(m.getId());
+ recovered++;
+ } catch (RuntimeException e) {
+ log.warn("stale follow-up recovery failed. messageId={}", m.getId(), e);
+ }
+ }
+ log.info("stale follow-up sweeper recovered {} of {} stuck placeholder(s)",
+ recovered, stale.size());
+ }
+}
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 97b51cb5..a17d3a22 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
@@ -45,6 +45,21 @@ public interface InterviewMessageRepository extends JpaRepository findStaleTranscribing(@Param("pendingText") String pendingText,
@Param("before") java.time.Instant before);
+ // AI 꼬리질문 콜백을 기다리다 멈춰 있는 placeholder. 음성 쪽 findStaleTranscribing 과
+ // 같은 성격이다 — callback.questions(FOLLOWUP) 이 유실되면 "(생성 중)" 인 채로 영구히 남고
+ // 면접이 그 자리에서 멈춘다.
+ @Query("""
+ select m from InterviewMessage m
+ where m.role = com.stackup.stackup.session.domain.MessageRole.INTERVIEWER
+ and m.status = com.stackup.stackup.session.domain.MessageStatus.CREATED
+ and m.content = :generatingText
+ and m.createdAt < :before
+ and m.session.deleted = false
+ and m.session.status = com.stackup.stackup.session.domain.SessionStatus.IN_PROGRESS
+ """)
+ List findStaleFollowupPlaceholders(@Param("generatingText") String generatingText,
+ @Param("before") java.time.Instant before);
+
// 질문에 달린 답변(있으면 1개). 오답노트에 '내 답변 + 코칭'을 함께 보여주기 위해.
List findByParentMessage_IdIn(List parentMessageIds);
}
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 6d6d3b23..2ec83d88 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
@@ -586,6 +586,77 @@ void advanceToNextGeneral_endsWithMaxReached_whenMainQuestionsHitLimit() {
verify(poolRepository, never()).findFirstBySessionIdAndUsedFalseOrderByIdxAsc(any());
}
+ // ── 콜백 유실 복구 (StaleFollowupSweeper 경로) ──────────────────────────────
+
+ @Test
+ void failStaleFollowup_marksFailedAndAdvances() {
+ InterviewSession session = sessionFixture(40L, SessionStatus.IN_PROGRESS);
+ InterviewMessage placeholder = InterviewMessage.followupPlaceholder(
+ session, 3, parentMessageFixture(session));
+ ReflectionTestUtils.setField(placeholder, "id", 444L);
+
+ when(messageRepository.findById(444L)).thenReturn(Optional.of(placeholder));
+ when(messageRepository.save(any(InterviewMessage.class))).thenAnswer(inv -> inv.getArgument(0));
+ when(sessionRepository.findById(40L)).thenReturn(Optional.of(session));
+ when(poolRepository.findFirstBySessionIdAndUsedFalseOrderByIdxAsc(40L)).thenReturn(Optional.empty());
+ when(sessionRepository.finishIfInProgress(any(), any(), any())).thenReturn(1);
+
+ service.failStaleFollowup(444L);
+
+ // AI 가 실패를 보고한 경우와 같은 결과여야 한다 — 어떻게 실패했는지에 따라
+ // 사용자가 보는 화면이 갈리면 안 된다.
+ assertThat(placeholder.getContent())
+ .isEqualTo(InterviewMessage.FOLLOWUP_GENERATION_FAILED_TEXT);
+ assertThat(placeholder.getStatus())
+ .isEqualTo(com.stackup.stackup.session.domain.MessageStatus.FAILED);
+ assertThat(session.getStatus()).isEqualTo(SessionStatus.COMPLETED);
+ }
+
+ // 스위퍼가 목록을 만든 뒤 콜백이 도착했을 수 있다. 완성된 질문을 실패로 되돌리면
+ // 사용자가 받은 질문이 화면에서 사라진다.
+ @Test
+ void failStaleFollowup_skipsWhenCallbackArrivedMeanwhile() {
+ InterviewSession session = sessionFixture(41L, SessionStatus.IN_PROGRESS);
+ InterviewMessage placeholder = InterviewMessage.followupPlaceholder(
+ session, 3, parentMessageFixture(session));
+ ReflectionTestUtils.setField(placeholder, "id", 445L);
+ placeholder.completeFollowup("뒤늦게 도착한 진짜 꼬리질문?", false);
+
+ when(messageRepository.findById(445L)).thenReturn(Optional.of(placeholder));
+
+ service.failStaleFollowup(445L);
+
+ assertThat(placeholder.getContent()).isEqualTo("뒤늦게 도착한 진짜 꼬리질문?");
+ verify(messageRepository, never()).save(any(InterviewMessage.class));
+ verify(events, never()).publishEvent(any());
+ }
+
+ // 스위퍼 조회 이후 세션이 시간초과로 끝났을 수 있다 — 종료 세션에 질문을 더하지 않는다.
+ @Test
+ void failStaleFollowup_skipsWhenSessionAlreadyTerminal() {
+ InterviewSession session = sessionFixture(42L, SessionStatus.IN_PROGRESS);
+ InterviewMessage placeholder = InterviewMessage.followupPlaceholder(
+ session, 3, parentMessageFixture(session));
+ ReflectionTestUtils.setField(placeholder, "id", 446L);
+ ReflectionTestUtils.setField(session, "status", SessionStatus.COMPLETED);
+
+ when(messageRepository.findById(446L)).thenReturn(Optional.of(placeholder));
+
+ service.failStaleFollowup(446L);
+
+ assertThat(placeholder.getContent()).isEqualTo(InterviewMessage.FOLLOWUP_GENERATING_TEXT);
+ verify(events, never()).publishEvent(any());
+ }
+
+ @Test
+ void failStaleFollowup_isNoopWhenMessageMissing() {
+ when(messageRepository.findById(447L)).thenReturn(Optional.empty());
+
+ service.failStaleFollowup(447L);
+
+ verify(events, never()).publishEvent(any());
+ }
+
private QuestionsCallbackEnvelope poolEnvelope(Long sessionId, List questions) {
QuestionsCallbackPayload payload = new QuestionsCallbackPayload(
sessionId, "POOL", questions, null, null, null, null, null, null
diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionTtsRequesterTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionTtsRequesterTest.java
index c93d7a02..ce67cbac 100644
--- a/backend/src/test/java/com/stackup/stackup/session/application/SessionTtsRequesterTest.java
+++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionTtsRequesterTest.java
@@ -51,6 +51,20 @@ void publishesGenerateTts_whenQuestionPersisted() {
org.assertj.core.api.Assertions.assertThat(captor.getValue().text()).isEqualTo("당신의 프로젝트에서...");
}
+ // 꼬리질문 생성 실패/유실도 QuestionPersistedEvent 를 탄다(실패 사실을 화면에 보여야
+ // 하므로). 그대로 두면 Gemini TTS 를 써서 "질문 생성에 실패했습니다"를 음성으로 만들고
+ // 면접관이 오류 문구를 읽는다 — 운영에 2건 있었다(messageId=434·437).
+ @Test
+ void skips_whenMessageIsFailed() {
+ InterviewMessage message = org.mockito.Mockito.mock(InterviewMessage.class);
+ when(message.getStatus()).thenReturn(com.stackup.stackup.session.domain.MessageStatus.FAILED);
+ when(messageRepository.findById(43L)).thenReturn(Optional.of(message));
+
+ requester.onQuestionPersisted(new QuestionPersistedEvent(1L, 7L, 43L));
+
+ verify(publisher, never()).publishToAi(any(), any(), any());
+ }
+
@Test
void skips_whenMessageNotFound() {
when(messageRepository.findById(99L)).thenReturn(Optional.empty());
diff --git a/backend/src/test/java/com/stackup/stackup/session/application/StaleFollowupSweeperTest.java b/backend/src/test/java/com/stackup/stackup/session/application/StaleFollowupSweeperTest.java
new file mode 100644
index 00000000..05058809
--- /dev/null
+++ b/backend/src/test/java/com/stackup/stackup/session/application/StaleFollowupSweeperTest.java
@@ -0,0 +1,107 @@
+package com.stackup.stackup.session.application;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+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.SessionMode;
+import com.stackup.stackup.user.domain.User;
+import java.time.Instant;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+
+/**
+ * `callback.questions(FOLLOWUP)` 이 유실되면 꼬리질문이 `(생성 중)` 으로 남고 답할 질문이
+ * 없어 면접이 멈춘다. 음성 답변의 `(transcribing)` 과 같은 구멍이다.
+ */
+@ExtendWith(MockitoExtension.class)
+class StaleFollowupSweeperTest {
+
+ @Mock InterviewMessageRepository messageRepository;
+ @Mock QuestionsCallbackService callbackService;
+ @InjectMocks StaleFollowupSweeper sweeper;
+
+ @Test
+ void 멈춘_placeholder_를_복구한다() {
+ ReflectionTestUtils.setField(sweeper, "staleAfterMinutes", 3L);
+ when(messageRepository.findStaleFollowupPlaceholders(anyString(), any(Instant.class)))
+ .thenReturn(List.of(placeholder(444L), placeholder(445L)));
+
+ sweeper.sweep();
+
+ verify(callbackService).failStaleFollowup(444L);
+ verify(callbackService).failStaleFollowup(445L);
+ }
+
+ // sentinel 문자열이 어긋나면 조회가 조용히 0건이 되고 스위퍼가 아무 일도 안 한다.
+ @Test
+ void 조회는_생성중_sentinel_과_임계시각으로_한다() {
+ ReflectionTestUtils.setField(sweeper, "staleAfterMinutes", 3L);
+ when(messageRepository.findStaleFollowupPlaceholders(anyString(), any(Instant.class)))
+ .thenReturn(List.of());
+
+ Instant before = Instant.now();
+ sweeper.sweep();
+
+ ArgumentCaptor text = ArgumentCaptor.forClass(String.class);
+ ArgumentCaptor cutoff = ArgumentCaptor.forClass(Instant.class);
+ verify(messageRepository).findStaleFollowupPlaceholders(text.capture(), cutoff.capture());
+ org.assertj.core.api.Assertions.assertThat(text.getValue())
+ .isEqualTo(InterviewMessage.FOLLOWUP_GENERATING_TEXT);
+ org.assertj.core.api.Assertions.assertThat(cutoff.getValue())
+ .isBefore(before.minusSeconds(170));
+ verifyNoInteractions(callbackService);
+ }
+
+ // 한 건이 터져도 나머지는 정리돼야 한다 — 멈춘 면접이 남는 게 이 스위퍼가 막으려는 것이다.
+ @Test
+ void 한_건이_실패해도_나머지를_계속_처리한다() {
+ ReflectionTestUtils.setField(sweeper, "staleAfterMinutes", 3L);
+ when(messageRepository.findStaleFollowupPlaceholders(anyString(), any(Instant.class)))
+ .thenReturn(List.of(placeholder(444L), placeholder(445L)));
+ doThrow(new IllegalStateException("boom")).when(callbackService).failStaleFollowup(444L);
+
+ sweeper.sweep();
+
+ verify(callbackService).failStaleFollowup(445L);
+ }
+
+ @Test
+ void 대상이_없으면_아무것도_하지_않는다() {
+ ReflectionTestUtils.setField(sweeper, "staleAfterMinutes", 3L);
+ when(messageRepository.findStaleFollowupPlaceholders(anyString(), any(Instant.class)))
+ .thenReturn(List.of());
+
+ sweeper.sweep();
+
+ verify(callbackService, never()).failStaleFollowup(any());
+ }
+
+ private InterviewMessage placeholder(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 parent = InterviewMessage.interviewer(session, 1, "질문");
+ InterviewMessage m = InterviewMessage.followupPlaceholder(session, 3, parent);
+ ReflectionTestUtils.setField(m, "id", id);
+ return m;
+ }
+}
diff --git a/backend/src/test/java/com/stackup/stackup/session/domain/InterviewMessageSweepQueryTest.java b/backend/src/test/java/com/stackup/stackup/session/domain/InterviewMessageSweepQueryTest.java
new file mode 100644
index 00000000..a92eb456
--- /dev/null
+++ b/backend/src/test/java/com/stackup/stackup/session/domain/InterviewMessageSweepQueryTest.java
@@ -0,0 +1,130 @@
+package com.stackup.stackup.session.domain;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.stackup.stackup.support.PostgresRepositoryTest;
+import com.stackup.stackup.user.domain.User;
+import com.stackup.stackup.user.domain.UserRepository;
+import java.time.Instant;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+
+/**
+ * 두 스위퍼의 조회 쿼리를 실제 PostgreSQL 에서 검증한다.
+ *
+ * 이 쿼리들은 멈춘 면접을 푸는 유일한 장치인데 지금까지 아무 테스트도 없었다.
+ * 기본 테스트 프로파일은 리포지토리를 목으로 대체하므로 JPQL 이 검증되지 않는다
+ * (backend/CLAUDE.md §15.1). 너무 넓게 잡으면 정상 진행 중인 질문·답변을 실패로
+ * 되돌리고, 너무 좁게 잡으면 조용히 0건이 되어 스위퍼가 있으나 마나가 된다 —
+ * 둘 다 배포 전에는 아무 신호도 주지 않는다.
+ *
+ *
`createdAt` 은 감사 필드라 테스트에서 못 바꾼다. 대신 cutoff 를 움직여
+ * `createdAt < :before` 를 양방향으로 확인한다.
+ */
+@PostgresRepositoryTest
+class InterviewMessageSweepQueryTest {
+
+ private static final Instant FUTURE = Instant.now().plusSeconds(600);
+ private static final Instant PAST = Instant.now().minusSeconds(600);
+
+ @Autowired UserRepository userRepository;
+ @Autowired InterviewSessionRepository sessionRepository;
+ @Autowired InterviewMessageRepository messageRepository;
+
+ @Test
+ void 멈춘_꼬리질문만_고른다() {
+ User user = userRepository.save(User.createGithubUser(98101L, "sweep-followup", null, null, "t"));
+ InterviewSession live = startedSession(user);
+ InterviewMessage question = messageRepository.save(InterviewMessage.interviewer(live, 1, "질문?"));
+
+ InterviewMessage stuck = messageRepository.save(
+ InterviewMessage.followupPlaceholder(live, 2, question));
+
+ // 대조군 1: 콜백이 도착해 확정된 꼬리질문.
+ InterviewMessage done = InterviewMessage.followupPlaceholder(live, 3, question);
+ done.completeFollowup("진짜 꼬리질문?", false);
+ messageRepository.save(done);
+
+ // 대조군 2: 이미 실패로 확정된 꼬리질문(두 번 처리하면 안 된다).
+ InterviewMessage alreadyFailed = InterviewMessage.followupPlaceholder(live, 4, question);
+ alreadyFailed.failFollowup();
+ messageRepository.save(alreadyFailed);
+
+ // 대조군 3: 전사 대기 중인 음성 답변 — 다른 스위퍼의 몫이다.
+ messageRepository.save(InterviewMessage.voiceInterviewee(live, 5, question, "idem-a"));
+
+ List found = messageRepository.findStaleFollowupPlaceholders(
+ InterviewMessage.FOLLOWUP_GENERATING_TEXT, FUTURE);
+
+ assertThat(found).extracting(InterviewMessage::getId).containsExactly(stuck.getId());
+ }
+
+ @Test
+ void 멈춘_음성답변만_고른다() {
+ User user = userRepository.save(User.createGithubUser(98102L, "sweep-voice", null, null, "t"));
+ InterviewSession live = startedSession(user);
+ InterviewMessage question = messageRepository.save(InterviewMessage.interviewer(live, 1, "질문?"));
+
+ InterviewMessage stuck = messageRepository.save(
+ InterviewMessage.voiceInterviewee(live, 2, question, "idem-b"));
+
+ // 대조군: 전사가 도착한 답변, 그리고 꼬리질문 placeholder(다른 스위퍼의 몫).
+ InterviewMessage done = InterviewMessage.voiceInterviewee(live, 3, question, "idem-c");
+ done.completeWithTranscript("전사된 답변입니다");
+ messageRepository.save(done);
+ messageRepository.save(InterviewMessage.followupPlaceholder(live, 4, question));
+
+ List found = messageRepository.findStaleTranscribing(
+ InterviewMessage.VOICE_TRANSCRIPTION_PENDING_TEXT, FUTURE);
+
+ assertThat(found).extracting(InterviewMessage::getId).containsExactly(stuck.getId());
+ }
+
+ // cutoff 가 실제로 걸리는지. 이게 안 걸리면 방금 만든 placeholder 를 즉시 실패시킨다.
+ @Test
+ void 임계시각_이전에_만들어진_것만_고른다() {
+ User user = userRepository.save(User.createGithubUser(98103L, "sweep-cutoff", null, null, "t"));
+ InterviewSession live = startedSession(user);
+ InterviewMessage question = messageRepository.save(InterviewMessage.interviewer(live, 1, "질문?"));
+ messageRepository.save(InterviewMessage.followupPlaceholder(live, 2, question));
+ messageRepository.save(InterviewMessage.voiceInterviewee(live, 3, question, "idem-d"));
+
+ assertThat(messageRepository.findStaleFollowupPlaceholders(
+ InterviewMessage.FOLLOWUP_GENERATING_TEXT, PAST)).isEmpty();
+ assertThat(messageRepository.findStaleTranscribing(
+ InterviewMessage.VOICE_TRANSCRIPTION_PENDING_TEXT, PAST)).isEmpty();
+ }
+
+ // 끝난 세션·삭제된 세션에는 손대지 않는다 — 스위퍼가 종료된 면접을 사후 변조하면 안 된다.
+ @Test
+ void 종료되거나_삭제된_세션은_제외한다() {
+ User user = userRepository.save(User.createGithubUser(98104L, "sweep-terminal", null, null, "t"));
+
+ InterviewSession ended = startedSession(user);
+ InterviewMessage endedQ = messageRepository.save(InterviewMessage.interviewer(ended, 1, "질문?"));
+ messageRepository.save(InterviewMessage.followupPlaceholder(ended, 2, endedQ));
+ messageRepository.save(InterviewMessage.voiceInterviewee(ended, 3, endedQ, "idem-e"));
+ ended.end();
+ sessionRepository.save(ended);
+
+ InterviewSession removed = startedSession(user);
+ InterviewMessage removedQ = messageRepository.save(InterviewMessage.interviewer(removed, 1, "질문?"));
+ messageRepository.save(InterviewMessage.followupPlaceholder(removed, 2, removedQ));
+ messageRepository.save(InterviewMessage.voiceInterviewee(removed, 3, removedQ, "idem-f"));
+ removed.markDeleted();
+ sessionRepository.save(removed);
+
+ assertThat(messageRepository.findStaleFollowupPlaceholders(
+ InterviewMessage.FOLLOWUP_GENERATING_TEXT, FUTURE)).isEmpty();
+ assertThat(messageRepository.findStaleTranscribing(
+ InterviewMessage.VOICE_TRANSCRIPTION_PENDING_TEXT, FUTURE)).isEmpty();
+ }
+
+ private InterviewSession startedSession(User user) {
+ InterviewSession s = InterviewSession.create(
+ user, "면접", null, SessionMode.TECHNICAL, JobCategory.BACKEND, 5, 30, null, null);
+ s.start();
+ return sessionRepository.save(s);
+ }
+}