Skip to content

feat(ai,core): 질문/꼬리질문 생성 실패 시 무기한 대기 제거 + 타임아웃 하드닝 - #144

Merged
i3months merged 2 commits into
devfrom
feature/questions-followup-failure-signal
Aug 6, 2026
Merged

feat(ai,core): 질문/꼬리질문 생성 실패 시 무기한 대기 제거 + 타임아웃 하드닝#144
i3months merged 2 commits into
devfrom
feature/questions-followup-failure-signal

Conversation

@i3months

@i3months i3months commented Aug 6, 2026

Copy link
Copy Markdown
Member

Summary

  • questions_consumer/followup_consumer의 메인 생성 호출(generate()/stream())이 무방비라 실패하면 예외가 그대로 새서 DLQ로 격리되고 Core는 아무 신호도 못 받아 세션이 "생성 중"에 무기한 멈추는 문제를 고침. 특히 꼬리질문은 Core가 이미 선INSERT한 placeholder가 영원히 안 채워지는 더 나쁜 증상이 있었음.
  • AI: 두 consumer 모두 생성 호출을 try/except로 감싸 실패해도 항상 status=FAILED(+errorCode/errorMessage/retriable) 콜백을 발행하도록 수정.
  • Core: QuestionsCallbackPayload에 같은 필드 추가(구버전 9-arg 생성자는 하위호환 오버로드로 유지). POOL 실패는 SSE ERROR 이벤트로만 알리고, FOLLOWUP 실패는 placeholder를 failFollowup()으로 확정 표시 후 DONT_KNOW와 동일하게 다음 질문으로 진행.
  • 추가 하드닝: questions_consumer의 다문서 RAG 경로에 타임아웃 추가(followup과 대칭), 모든 ChatOpenAI 호출에 요청 타임아웃 명시(이전엔 미설정 — SDK 기본값까지 무기한 대기 가능).

호환성

AI/Core 어느 쪽이 먼저 배포돼도 안전함 — 구버전 Core는 새 필드를 무시(Jackson 기본 동작)하고, 구버전 AI는 애초에 새 필드를 안 보냄.

스코프 밖(후속 과제로 남김)

  • FOLLOWUP 실패 시 "같은 질문 재시도" UI(현재는 DONT_KNOW처럼 다음 질문으로 스킵)
  • POOL 실패 시 세션 재시도 트리거(현재는 SSE 알림만, 세션 상태는 유지)

Test plan

  • AI: uv run pytest 321개 전체 통과 (신규 6건)
  • Core: ./gradlew test 126개 전체 통과 (신규 2건)
  • black/flake8 클린
  • docs/messaging.md, docs/environment.md, ai/CLAUDE.md, backend/CLAUDE.md, .env.example 갱신

questions_consumer/followup_consumer 의 메인 생성 호출(generate()/stream())이
무방비라 실패하면 예외가 그대로 새서 DLQ 로 격리되고 Core 는 아무 신호도
못 받아 세션이 '생성 중'에 무기한 멈추는 문제(꼬리질문은 Core 가 이미
선INSERT 한 placeholder 가 영원히 안 채워짐). 두 consumer 모두 생성 호출을
try/except 로 감싸 실패해도 항상 콜백(status=FAILED, errorCode,
errorMessage, retriable)을 발행하도록 수정.

TypeError(LLM 출력 스키마 불일치)는 재시도 무의미하다고 보고 retriable=false,
그 외는 retriable=true 로 구분.

추가로:
- questions_consumer 의 다문서 RAG 경로에 questions_rag_timeout_sec(1.5s)
  타임아웃 추가 — followup_consumer 와 대칭
- 모든 ChatOpenAI 호출에 llm_pro_timeout_sec(30s)/llm_flash_timeout_sec(10s)
  요청 타임아웃 명시 — 미설정 시 SDK 기본값까지 무기한 대기 가능했음
- docs/messaging.md, docs/environment.md, .env.example 갱신
QuestionsCallbackPayload(Java)에 status/errorCode/errorMessage/retriable
필드 추가(구버전 9-arg 생성자는 status=OK 로 위임하는 오버로드로 하위호환
유지). QuestionsCallbackService.apply 가 kind 분기 전에 isFailed() 를
먼저 확인:

- POOL 실패: 저장할 게 없으니 SseEventType.ERROR(SessionErrorNotice)로
  세션/유저 채널에만 알린다. 세션 상태는 그대로 유지(재시도 트리거는
  후속 과제).
- FOLLOWUP 실패: placeholder 를 삭제하지 않고 InterviewMessage.failFollowup()
  (content=FOLLOWUP_GENERATION_FAILED_TEXT, status=FAILED)로 확정한 뒤
  SESSION_MESSAGE(FOLLOWUP_FAILED) 발행 + DONT_KNOW 와 동일하게
  advanceToNextGeneral 로 다음 일반질문으로 진행 — 턴이 사라진 것처럼
  보이지 않으면서 면접은 멈추지 않는다.

회귀 테스트 2건 추가(POOL 실패 시 에러 이벤트, FOLLOWUP 실패 시 placeholder
확정+진행). backend/CLAUDE.md 갱신.
@i3months
i3months merged commit 7424c67 into dev Aug 6, 2026
1 check passed
i3months added a commit to i3months/stackup that referenced this pull request Aug 28, 2026
dev 배포가 PR Team-StackUp#144 부터 4회 연속 실패했다. 원인은 백엔드 Docker 빌드:

  Exception in thread "main" java.io.IOException:
    Downloading from https://services.gradle.org/distributions/gradle-9.4.0-bin.zip
    failed: timeout (10000ms)

베이스 이미지가 gradle:8.12-jdk21 인데 wrapper 는 9.4.0 을 요구해서, 매 빌드마다
배포판(약 130MB)을 새로 내려받고 있었다. self-hosted runner 에서 services.gradle.org
접속이 타임아웃돼(wrapper 기본 10s) 실패한다. 재시도해도 27초만에 같은 지점에서
죽어 일시적 문제가 아니었다.

Team-StackUp#143 까지 성공한 건 그 PR 들이 backend/ 를 건드리지 않아 Docker 레이어 캐시가
살아있었기 때문이다. Team-StackUp#144 에서 backend 소스가 바뀌자 캐시가 깨지고 증상이 드러났다.

수정: 베이스 이미지를 gradle:9.4.0-jdk21 로 올리고 wrapper 대신 이미지에 들어있는
gradle 을 쓴다 → 네트워크 의존이 사라진다. 태그는 gradle-wrapper.properties 의
버전과 정확히 맞췄다(9.4-jdk21 은 9.4.1 로 해석돼 로컬과 어긋난다).
의존성 해결을 별도 레이어로 분리해 소스만 바뀔 때 재다운로드도 건너뛴다.

wrapper 의 networkTimeout 은 10s → 120s. Docker 빌드와는 무관해졌지만 로컬에서
./gradlew 로 빌드할 때의 여유를 둔다.

검증: 로컬에서 docker build 성공(BUILD SUCCESSFUL, 배포판 다운로드 없음),
./gradlew --version 도 9.4.0 으로 이미지와 일치.
i3months added a commit to i3months/stackup that referenced this pull request Aug 28, 2026
음성 답변은 `(transcribing)` placeholder 로 먼저 저장되고 `callback.voice` 가 도착해야
내용이 채워진다. 그 콜백이 오지 않으면(AI 크래시·DLQ 격리·브로커 단절) 메시지는 그 상태로
남는다. 프론트 턴 판정(`currentTurn`)에서 이 메시지는 FAILED 도 아니고 질문도 아니라
**답변 차례가 영영 오지 않는다** — 세션 시간 초과로 면접이 통째로 끝날 때까지 사용자는
"답변을 받아 적고 있어요…" 화면에 갇힌다.

질문 생성 쪽은 Team-StackUp#144 로 실패 신호(callback status=FAILED)를 만들어 같은 문제를 없앴는데
음성 경로에는 대응이 없었다.

- `StaleTranscriptionSweeper` — 기본 2분 주기로 `interview.voice.stale-transcription-minutes`
  (기본 5분)를 넘긴 placeholder 를 찾는다. 진행 중·미삭제 세션의 것만 본다
- `VoiceTranscriptionRecoveryService.failStaleTranscription` — FAILED 로 확정하고
  세션·유저 채널에 SSE 통지(`STT_CALLBACK_TIMEOUT`). 메시지마다 독립 트랜잭션이라
  하나가 실패해도 나머지는 정리된다
- 확정 직전 상태를 다시 확인한다 — 스위퍼가 목록을 만든 뒤 콜백이 도착했으면 건드리지
  않는다(완료된 답변을 실패로 되돌리는 것이 더 나쁘다)

FAILED 로 두면 기존 STT 실패 경로를 그대로 탄다: 프론트 `currentTurn` 의 INTERVIEWEE+FAILED
분기가 답변 차례로 돌리고, 백엔드 `resolveAnswerParent` 가 같은 질문에 대한 텍스트 재답변을
받는다. 새 UX 를 만들지 않고 이미 검증된 경로에 합류시켰다.

테스트 `VoiceTranscriptionRecoveryServiceTest` (3) — FAILED 확정 + 양 채널 통지,
그 사이 전사가 도착한 경우 건드리지 않음, 메시지 부재 시 무동작.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant