fix(backend): 음성 답변이 조용히 사라질 수 있던 문제 - #173
Merged
Merged
Conversation
`VoiceAnswerUploadService.submit()` 이 하나의 `@Transactional` 안에서 S3 PUT 과 RabbitMQ 발행까지 전부 처리하고 있었다. 두 가지가 문제였다. **1. 발행이 커밋보다 먼저 나갔다.** publish 이후 커밋이 실패하면 AI 는 존재하지 않는 메시지로 STT 를 돌리고, 돌아온 `callback.voice` 는 `VoiceCallbackService` 에서 "message not found" 로 드롭된다. 사용자는 답변을 말했는데 아무 일도 일어나지 않고 에러도 안 뜬다. Core→AI 발행 중 이 경로만 유일하게 AFTER_COMMIT 이 아니었다 (questions·followup·tts·feedback·analyze.resume 등은 모두 이미 커밋 후 발행). **2. 업로드가 DB 커넥션을 붙잡았다.** 최대 25MB 업로드가 끝날 때까지 트랜잭션이 열려 있어 동시 음성 답변이 커넥션 풀을 잠식한다. ## 변경 - `VoiceAnalysisRequester` 신설 — `VoiceAnswerUploadedEvent` 를 `AFTER_COMMIT` 에 받아 `analyze.voice` 발행. 기존 `SessionTtsRequester`·`SessionFollowupRequester` 와 같은 패턴 - `VoiceAnswerSubmitService` 신설 — 오케스트레이션(검증 → placeholder(tx) → S3 → 부착(tx)). S3 PUT 이 트랜잭션 경계 밖으로 나갔다. `@Transactional` 메서드를 같은 빈에서 자기호출하면 프록시를 타지 않으므로 오케스트레이션을 별 빈으로 분리했다 - `VoiceAnswerUploadService` 는 DB 쓰기 단계만 담당 (`createVoicePlaceholder` / `attachAudioAndRequestAnalysis` / `describe` / `failVoiceUpload`) ## 업로드 실패 보상 S3 PUT 이 트랜잭션 밖으로 나가면서 placeholder 는 이미 커밋된 상태가 된다. 그대로 두면 STT 콜백이 영원히 오지 않아 "음성 인식 중…" 에서 턴이 잠기므로, 업로드 실패 시 메시지를 `FAILED` 로 확정하고 SSE 로 알린다(`VOICE_UPLOAD_FAILED`). 프론트는 기존 STT 실패 경로와 동일하게 턴을 풀고, 사용자는 같은 질문에 다시 답할 수 있다 (`InterviewMessageService.resolveAnswerParent` 의 FAILED 음성 재답변 경로). 부수 효과로 같은 Idempotency-Key 재시도가 실제로 복구 경로가 된다 — 예전에는 전체가 롤백돼 placeholder 자체가 사라졌지만, 이제 남은 메시지에 오디오만 다시 붙인다. ## 테스트 - `VoiceAnalysisRequesterTest` 신설 (2) — 페이로드 필드·메시지 부재 시 스킵 - `VoiceAnswerSubmitServiceTest` 신설 (7) — S3 PUT 이 placeholder 생성 이후·부착 이전에 일어나는지 `InOrder` 로 고정, 업로드 실패 시 FAILED 보상, 멱등 재요청 시 재업로드 안 함, 코덱 파라미터 MIME 키 매핑, 검증 3종 - `VoiceAnswerUploadServiceTest` 재작성 (11) — placeholder 생성 검증 5종 + 부착/보상 `./gradlew cleanTest test` 통과 (ArchUnit 8룰 포함). 음성 관련 25건 전부 green. `docs/messaging.md §5.14` 에 "커밋 후 발행" 규약과 발행 주체 표를 추가했다 — 이 버그가 다시 들어오지 않게 하는 게 목적이다.
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.
변경 사항
감사 백로그 A-4.
VoiceAnswerUploadService.submit()이 하나의@Transactional안에서 S3 PUT + RabbitMQ 발행까지 전부 하고 있었다.1. 발행이 커밋보다 먼저 나갔다 (본 버그)
publisher.publishToAi("analyze.voice", …)가 트랜잭션 안에 있어서, 발행 이후 커밋이 실패하면:callback.voice는VoiceCallbackService에서message not found로 드롭된다Core→AI 발행 경로 중 이 하나만 AFTER_COMMIT 이 아니었다. 나머지(
generate.questions·generate.followup·generate.tts·generate.feedback·analyze.resume·analyze.repository·analyze.cover_letter)는 전부 이미 커밋 후 발행이다.2. 업로드가 DB 커넥션을 붙잡았다
최대 25MB 업로드가 끝날 때까지 트랜잭션이 열려 있어, 동시 음성 답변이 커넥션 풀을 잠식한다.
구조
@Transactional메서드를 같은 빈에서 자기호출하면 프록시를 타지 않아 트랜잭션이 안 걸린다. 그래서 오케스트레이션을 별 빈으로 분리했다. 스트리밍 경로(POST …/stream-begin→VoiceStreamService)는createVoicePlaceholder만 쓰므로 영향 없다.업로드 실패 보상 (동작 변경)
S3 PUT 이 트랜잭션 밖으로 나가면서 placeholder 는 이미 커밋된 상태가 된다. 그대로 두면 STT 콜백이 영원히 오지 않아 "음성 인식 중…" 에서 턴이 잠긴다. 그래서 업로드 실패 시 메시지를
FAILED로 확정하고 SSE 로 알린다(신규 에러코드VOICE_UPLOAD_FAILED).프론트는 기존 STT 실패 경로와 동일하게 턴을 풀고, 사용자는 같은 질문에 다시 답할 수 있다 (
InterviewMessageService.resolveAnswerParent의 FAILED 음성 재답변 경로).부수 효과로 같은
Idempotency-Key재시도가 실제 복구 경로가 된다 — 남은 메시지에 오디오만 다시 붙이고, STT 콜백이 도착하면completeWithTranscript로 정상 완료된다.테스트
VoiceAnalysisRequesterTest신설 (2) — 페이로드 8필드 검증, 메시지 부재 시 발행 스킵VoiceAnswerSubmitServiceTest신설 (7) — S3 PUT 이 placeholder 생성 이후·오디오 부착 이전에 일어나는지InOrder로 고정, 업로드 실패 시 FAILED 보상, 멱등 재요청 시 재업로드 안 함, 코덱 파라미터 MIME(audio/webm;codecs=opus) 키 매핑, 검증 3종VoiceAnswerUploadServiceTest재작성 (11) — placeholder 생성 검증 5종 + 부착 3종 + 보상 2종./gradlew cleanTest testBUILD SUCCESSFUL (ArchUnit 8룰 포함). 음성 관련 25건 green.영향 범위
backend/openapi.json무변동 — 컨트롤러 시그니처 그대로)VOICE_UPLOAD_FAILED(500)리뷰어 체크포인트
VoiceAnswerUploadService는 이제 업로드를 하지 않는다(이름 vs 책임). 스트리밍 경로와 테스트 churn 을 줄이려 이름은 유지하고 클래스 주석으로 역할 분리를 명시했다 — 리네임이 낫다면 후속으로docs/messaging.md §5.14에 "커밋 후 발행" 규약 + 발행 주체 표 추가 (같은 버그 재발 방지)