Skip to content

fix(backend): 음성 답변이 조용히 사라질 수 있던 문제 - #173

Merged
i3months merged 1 commit into
devfrom
fix/voice-answer-publish-after-commit
Aug 18, 2026
Merged

fix(backend): 음성 답변이 조용히 사라질 수 있던 문제#173
i3months merged 1 commit into
devfrom
fix/voice-answer-publish-after-commit

Conversation

@i3months

Copy link
Copy Markdown
Member

변경 사항

감사 백로그 A-4. VoiceAnswerUploadService.submit() 이 하나의 @Transactional 안에서 S3 PUT + RabbitMQ 발행까지 전부 하고 있었다.

1. 발행이 커밋보다 먼저 나갔다 (본 버그)

publisher.publishToAi("analyze.voice", …) 가 트랜잭션 안에 있어서, 발행 이후 커밋이 실패하면:

  1. AI 는 존재하지 않는 메시지 id 로 STT 를 돌린다
  2. 돌아온 callback.voiceVoiceCallbackService 에서 message not found 로 드롭된다
  3. 사용자는 답변을 말했는데 아무 일도 일어나지 않고 에러도 뜨지 않는다

Core→AI 발행 경로 중 이 하나만 AFTER_COMMIT 이 아니었다. 나머지(generate.questions·generate.followup·generate.tts·generate.feedback·analyze.resume·analyze.repository·analyze.cover_letter)는 전부 이미 커밋 후 발행이다.

2. 업로드가 DB 커넥션을 붙잡았다

최대 25MB 업로드가 끝날 때까지 트랜잭션이 열려 있어, 동시 음성 답변이 커넥션 풀을 잠식한다.

구조

VoiceAnswerController
  └─ VoiceAnswerSubmitService        (신설, tx 없음)  검증 → placeholder(tx) → S3 PUT → 부착(tx)
       └─ VoiceAnswerUploadService   (DB 쓰기 전담)   createVoicePlaceholder / attachAudioAndRequestAnalysis
                                                      / describe / failVoiceUpload
            └─ VoiceAnalysisRequester (신설)          AFTER_COMMIT → analyze.voice 발행

@Transactional 메서드를 같은 빈에서 자기호출하면 프록시를 타지 않아 트랜잭션이 안 걸린다. 그래서 오케스트레이션을 별 빈으로 분리했다. 스트리밍 경로(POST …/stream-beginVoiceStreamService)는 createVoicePlaceholder 만 쓰므로 영향 없다.

업로드 실패 보상 (동작 변경)

S3 PUT 이 트랜잭션 밖으로 나가면서 placeholder 는 이미 커밋된 상태가 된다. 그대로 두면 STT 콜백이 영원히 오지 않아 "음성 인식 중…" 에서 턴이 잠긴다. 그래서 업로드 실패 시 메시지를 FAILED 로 확정하고 SSE 로 알린다(신규 에러코드 VOICE_UPLOAD_FAILED).

프론트는 기존 STT 실패 경로와 동일하게 턴을 풀고, 사용자는 같은 질문에 다시 답할 수 있다 (InterviewMessageService.resolveAnswerParent 의 FAILED 음성 재답변 경로).

  • 이전: S3 실패 → 전체 롤백 → placeholder 없음 → 500
  • 이후: S3 실패 → placeholder 가 FAILED 로 남음 → 500. 화면에 실패한 답변 버블이 보이고 재시도 가능

부수 효과로 같은 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 test BUILD SUCCESSFUL (ArchUnit 8룰 포함). 음성 관련 25건 green.

영향 범위

  • DB 마이그레이션: 없음
  • API contract 변경: 없음 (backend/openapi.json 무변동 — 컨트롤러 시그니처 그대로)
  • 환경변수: 없음
  • 신규 에러코드: VOICE_UPLOAD_FAILED (500)

리뷰어 체크포인트

  • 업로드 실패 시 placeholder 를 지우지 않고 FAILED 로 남기는 선택 — 턴 잠김 방지가 목적인데, 실패 버블이 대화에 남는 걸 감수한 트레이드오프다
  • VoiceAnswerUploadService 는 이제 업로드를 하지 않는다(이름 vs 책임). 스트리밍 경로와 테스트 churn 을 줄이려 이름은 유지하고 클래스 주석으로 역할 분리를 명시했다 — 리네임이 낫다면 후속으로
  • docs/messaging.md §5.14 에 "커밋 후 발행" 규약 + 발행 주체 표 추가 (같은 버그 재발 방지)

`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` 에 "커밋 후 발행" 규약과 발행 주체 표를 추가했다 — 이 버그가
다시 들어오지 않게 하는 게 목적이다.
@i3months
i3months merged commit 8ca4e94 into dev Aug 18, 2026
5 checks passed
@i3months
i3months deleted the fix/voice-answer-publish-after-commit branch August 18, 2026 17:56
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