Repository navigation
fix(session): 꼬리질문 콜백 유실 시 면접이 멈추는 구멍 + 실패 질문 TTS 낭비 - #256
Merged
Merged
Conversation
음성 답변의 `(transcribing)` 유실은 StaleTranscriptionSweeper 로 막아 뒀는데, **꼬리질문의 `(생성 중)` 에는 같은 대응이 없었다.** AI 가 실패를 *보고*하면 applyFollowupFailed 가 실패 문구로 확정하고 다음 일반질문 으로 넘어간다. 하지만 콜백 **자체가 오지 않으면**(AI 크래시·DLQ 격리·브로커 단절) 그 경로를 아무도 타지 못한다. placeholder 는 CREATED 로 남고, 화면엔 "(생성 중)" 만 떠 있고, 답할 질문이 없으니 면접이 그 자리에서 멈춘다 — 세션 시간 초과로 통째로 끝날 때까지. 운영에 실제로 있다: id=444, session=93, status=CREATED, content='(생성 중)', 678시간째 그 세션(93)은 시간초과로 COMPLETED 됐다. 사용자 입장에선 면접이 이유 없이 중간에 끝난 것이다. ## StaleFollowupSweeper 기본 2분 주기로 3분 넘게 확정되지 않은 placeholder 를 찾아 QuestionsCallbackService.failStaleFollowup 을 부른다. 3분 근거: 정상 생성은 flash 타임아웃(10초) + RAG(1.5초) 범위에서 끝나고, AI 쪽에 메시지 단위 재시도가 없어 실패는 곧장 DLQ 로 간다. 콜백 경로와 **같은 코드**를 타게 했다(markFollowupFailedAndAdvance). 어떻게 실패했는지에 따라 사용자가 보는 결과가 갈리면 안 된다. 이벤트 reason 만 FOLLOWUP_TIMEOUT 으로 구분한다. 확정 직전 상태를 다시 본다 — 조회 이후 콜백이 도착했거나(완성된 질문을 실패로 되돌리면 사용자가 받은 질문이 사라진다) 세션이 끝났을 수 있다. ## 곁가지 1: 실패한 질문에 TTS 를 쓰고 있었다 QuestionPersistedEvent 에 가드가 없어서, 실패로 확정된 꼬리질문도 generate.tts 를 탄다. 실패 사실은 화면에 보여야 하니 SSE 는 그대로 나가야 하지만 음성은 아니다. 운영 확인: id=434, status=FAILED, tts_status=SUCCEEDED id=437, status=FAILED, tts_status=SUCCEEDED Gemini TTS 를 한 번씩 써서 "질문 생성에 실패했습니다. 다음 질문으로 넘어갑니다" 를 음성으로 만들어 뒀다. 면접관이 오류 문구를 읽는 셈이고, 대화 기록의 재생 버튼도 그걸 들려준다. SessionTtsRequester 에서 FAILED 면 건너뛴다. ## 곁가지 2: 스위퍼 조회 쿼리에 테스트가 없었다 findStaleTranscribing 도 findStaleFollowupPlaceholders 도 검증된 적이 없다. 기본 테스트 프로파일은 리포지토리를 목으로 대체해 JPQL 이 아무 검증을 못 받는다 (backend/CLAUDE.md §15.1). 멈춘 면접을 푸는 유일한 장치인데 넓게 잡으면 정상 진행 중인 턴을 실패로 되돌리고, 좁게 잡으면 조용히 0건이 되어 있으나 마나가 된다 — 둘 다 배포 전엔 신호가 없다. InterviewMessageSweepQueryTest(@PostgresRepositoryTest)로 둘 다 덮었다. createdAt 은 감사 필드라 못 바꾸므로 cutoff 를 움직여 `createdAt < :before` 를 양방향으로 확인한다. 대조군으로 확정된 질문·이미 실패한 질문·다른 스위퍼 몫인 메시지·종료 세션·삭제 세션을 넣어 서로를 침범하지 않는지 본다.
i3months
added a commit
to i3months/stackup
that referenced
this pull request
Oct 6, 2026
꼬리질문 스위퍼를 붙이다(Team-StackUp#256) 스케줄러 구조를 보고 발견했다. `@Scheduled` 가 7개인데 Spring Boot 기본 `spring.task.scheduling.pool.size` 는 **1** 이다. 전부 한 줄로 선다. ExpiredRefreshTokenSweeper 1일 ProcessedMessageSweeper 1일 AiRequestLogSweeper 1일 OrphanedObjectSweeper 15분 ← S3/MinIO 를 돈다 SessionTimeoutSweeper 5분 ← 면접 복구 StaleTranscriptionSweeper 2분 ← 면접 복구 StaleFollowupSweeper (Team-StackUp#256) 2분 ← 면접 복구 뒤의 셋은 **멈춘 면접을 푸는 유일한 장치**다. 느린 작업 하나가 스레드를 잡으면 면접 복구가 통째로 멈춘다. 가장 유력한 후보는 OrphanedObjectSweeper 다. ## 1) 풀 크기 `${SCHEDULING_POOL_SIZE:4}`. 설정 한 줄이 지워져도 컴파일·테스트가 통과하고 운영에서도 장애 때까지 아무 증상이 없어서 SchedulingPoolSizeTest 로 지킨다. ## 2) S3 호출 상한 AWS SDK v2 기본값에는 **호출 전체 상한이 없다** — 소켓 타임아웃과 재시도뿐이다. 같은 S3Client 를 요청 스레드(업로드·프록시)와 스케줄러가 공유하므로, MinIO 가 느려지면 전자는 톰캣 스레드를, 후자는 스케줄러를 붙잡는다. apiCallTimeout 30초 / apiCallAttemptTimeout 10초를 건다. ## 3) 그래서 지금 돌고 있기는 한가 — 알 방법이 없었다 스위퍼들은 할 일이 없으면 로그를 남기지 않는다. 그래서 "돌았는데 대상이 없었다" 와 "아예 안 돌았다"를 구분할 수 없다. 운영 백엔드 로그를 통째로 뒤져도 sweeper 관련 줄이 **0건**이다. SchedulerHeartbeat 가 1분마다 타임스탬프만 갱신하고, 5분 넘게 멈추면 SchedulerHealthIndicator 가 DOWN 을 낸다(/api/system/health 의 scheduler). DB·네트워크를 건드리지 않는다 — 심장박동이 자기가 감시하는 문제(느린 I/O)로 막히면 안 된다. BACKUP 과 같이 **informational** 이라 aggregate 는 끌어내리지 않는다. 스케줄러가 멈춰도 면접·로그인·조회는 정상이다. 여기서 DOWN 은 "서비스가 죽었다"가 아니라 "고장 복구가 안 되고 있다"는 뜻이고, 섞으면 업타임 감시가 헛울린다.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
왜
음성 답변의
(transcribing)유실은StaleTranscriptionSweeper로 막아 뒀는데,꼬리질문의
(생성 중)에는 같은 대응이 없었다.AI 가 실패를 보고하면
applyFollowupFailed가 실패 문구로 확정하고 다음 일반질문으로넘어간다. 하지만 콜백 자체가 오지 않으면(AI 크래시·DLQ 격리·브로커 단절) 그 경로를
아무도 타지 못한다. placeholder 는 CREATED 로 남고, 화면엔 "(생성 중)" 만 떠 있고,
답할 질문이 없으니 면접이 그 자리에서 멈춘다 — 세션 시간 초과로 통째로 끝날 때까지.
운영에 실제로 남아 있다:
세션 93 은 시간초과로 COMPLETED 됐다. 사용자 입장에선 면접이 이유 없이 중간에 끝났다.
StaleFollowupSweeper
2분 주기로 3분 넘게 확정되지 않은 placeholder 를 찾아
failStaleFollowup을 부른다.콜백 경로와 같은 코드를 타게 했다(
markFollowupFailedAndAdvance) — 실패 문구 확정 +SESSION_MESSAGE발행 +advanceToNextGeneral. 어떻게 실패했는지에 따라 사용자가 보는결과가 갈리면 안 된다. 이벤트 reason 만
FOLLOWUP_TIMEOUT으로 구분한다.확정 직전 상태를 다시 본다 — 조회 이후 콜백이 도착했거나(완성된 질문을 실패로 되돌리면
사용자가 받은 질문이 화면에서 사라진다) 세션이 끝났을 수 있다.
곁가지 1 — 실패한 질문에 TTS 를 쓰고 있었다
작업 중 발견.
QuestionPersistedEvent에 가드가 없어 실패로 확정된 꼬리질문도generate.tts를 탄다. 실패 사실은 화면에 보여야 하니 SSE 는 나가야 하지만 음성은 아니다.Gemini TTS 를 한 번씩 써서 "질문 생성에 실패했습니다. 다음 질문으로 넘어갑니다"를 음성으로
만들어 뒀다. 면접관이 오류 문구를 읽는 셈이고, 대화 기록의 재생 버튼(
ttsStatus==='SUCCEEDED'일 때 노출)도 그걸 들려준다.
SessionTtsRequester에서 FAILED 면 건너뛴다.곁가지 2 — 스위퍼 조회 쿼리에 테스트가 없었다
findStaleTranscribing도findStaleFollowupPlaceholders도 검증된 적이 없다. 기본 테스트프로파일은 리포지토리를 목으로 대체해 JPQL 이 아무 검증을 못 받는다(backend/CLAUDE.md §15.1).
멈춘 면접을 푸는 유일한 장치인데, 넓게 잡으면 정상 진행 중인 턴을 실패로 되돌리고,
좁게 잡으면 조용히 0건이 되어 있으나 마나가 된다 — 둘 다 배포 전엔 아무 신호가 없다.
InterviewMessageSweepQueryTest(@PostgresRepositoryTest)로 둘 다 덮었다.createdAt은감사 필드라 못 바꾸므로 cutoff 를 움직여
createdAt < :before를 양방향으로 확인한다.대조군으로 확정된 질문·이미 실패한 질문·다른 스위퍼 몫인 메시지·종료 세션·삭제 세션을 넣어
서로를 침범하지 않는지 본다.
검증
QuestionsCallbackServiceTest24,StaleFollowupSweeperTest4,SessionTtsRequesterTest3 — 통과DockerClientProviderStrategy예외이고 변경과 무관하다. 새로 추가한InterviewMessageSweepQueryTest도 같은 이유로 로컬에서 실행되지 않았다 — CI 결과로 확인해야 한다.남은 것
운영의 444(세션 93)는 세션이 이미 COMPLETED 라 스위퍼 대상이 아니다. 과거 데이터 정리는
이 PR 범위 밖으로 둔다 — 그 면접은 이미 끝났고, 지금 고치는 것은 재발이다.