삭제 버튼 뒤에도 외부 생성은 끝날 수 있다

AI Commons의 보이스 클론은 외부 공급자에 음성 샘플을 보내 보이스 자산을 만든다. 그동안 사용자가 보이스를 삭제할 수 있다. 큐를 취소하거나 호출 전에 대상을 확인해도 이미 진행 중인 외부 요청이 멈췄다는 보장은 없다. 생성된 보이스 ID가 뒤늦게 돌아오면 제품에는 더 이상 연결할 대상이 없을 수 있다.

보이스 클론에서는 조건부 완료와 보상 삭제를 구현했다. 이후 TTS의 결과 불명 폴백 제한과 300초 호출 기한을 보완하고, 차시 생성의 중첩 재시도 예산도 별도로 정리했다.

외부 생성 결과를 무조건 READY로 저장하면 삭제된 보이스가 다시 준비 상태가 되거나, 이미 끝난 작업을 COMPLETED로 덮을 수 있다. 필요한 것은 “호출 전에 존재했다”는 검사에 더해, 완료를 저장하는 순간에도 대상과 작업이 유효한지 확인하는 일이었다.

보이스와 작업 상태를 조건부로 함께 쓴다

변경은 같은 DB 트랜잭션 안의 두 쓰기를 updateMany로 바꿨다. 보이스는 삭제되지 않은 행에만, 작업은 QUEUED 또는 PROCESSING 상태에만 완료를 기록한다. 실제 조건을 줄이면 다음과 같다.

voice.updateMany({
  where: { id: existingVoiceId, deletedAt: null },
  data: { status: "READY", providerVoiceId, /* ... */ }
});
voiceJob.updateMany({
  where: { id: jobId, status: { in: ["QUEUED", "PROCESSING"] } },
  data: { status: "COMPLETED", progress: 100 }
});

각 쓰기의 영향 행 수가 0이면 예외를 던진다. 첫 번째 쓰기가 성공해도 두 번째 조건이 맞지 않으면 앞의 보이스 갱신까지 롤백한다. ID 존재 여부만 확인하는 대신 완료 전제와 변경을 한 문장에 묶고, 두 대상의 상태가 함께 맞을 때만 DB 완료를 확정했다.

완료 시 상태DB 결과외부 자산 처리
보이스 유효 · 작업 진행 중두 완료 쓰기 커밋생성 ID를 제품에 연결
보이스가 삭제됨영향 행 0 · 롤백방금 만든 보이스 보상 삭제
작업이 이미 종료됨작업 영향 행 0 · 보이스 쓰기도 롤백방금 만든 보이스 보상 삭제

대상 소멸 오류는 바깥의 일반 실패 처리에서 다시 ERROR나 FAILED로 덮지 않고 전달한다. 삭제·취소와 일반 생성 실패가 같은 후처리를 사용하면 이미 끝난 상태를 되살릴 수 있기 때문이다.

DB 롤백은 공급자 자산을 지우지 않는다

보상 삭제는 트랜잭션 밖에서 수행한다. DB가 완료 기록을 취소해도 공급자의 보이스는 남아 있기 때문이다. compensateDeleteProviderVoice는 새 providerVoiceId로 외부 삭제를 요청하고, 실패하면 jobId·voiceId·providerVoiceId와 함께 경고를 남긴다. 보상 실패가 원래 대상 소멸 이유를 덮지 않게 한다.

이는 DB와 외부 API를 하나로 롤백하는 원자적 처리가 아니다. 삭제 API도 실패할 수 있고, 프로세스가 DB 롤백과 보상 사이에서 종료될 수 있다. 경고의 식별자는 후속 정리의 단서이며, 이 코드만으로 자동 정리 완료를 보장하지 않는다. 현재 테스트는 조건부 쓰기와 롤백 뒤 보상 호출을 모킹해 확인한다.

여기서는 외부 생성이 성공했고 새 ID를 알고 있다. 반대로 응답을 받지 못한 경우에는 어느 자산을 지울지도 모를 수 있다. 이 두 상황의 복구를 같은 “실패 재시도”로 처리할 수는 없었다.

시간 초과는 합성이 시작되지 않았다는 뜻이 아니다

별도의 TTS 호출 정책에서는 타임스탬프를 포함한 합성이 실패할 때 일반 합성으로 다시 보내던 폴백을 제한했다. 5xx, 네트워크 오류, 시간 초과는 공급자가 이미 합성했지만 응답만 잃은 상황일 수 있다. 그때 같은 청크를 다른 메서드로 보내면 유료 작업이 중복될 위험이 있다.

TTS 폴백 구현은 공급자 SDK 오류의 4xx 중 408·429를 제외한 경우만 확정 거절로 분류해 폴백한다. 나머지는 결과 불명으로 기록하고 오류를 반환한다. 이 분류는 해당 공급자의 오류 계약에 의존하며 모든 외부 API에 그대로 적용할 규칙은 아니다.

추가 폴백을 막아도 SDK 재시도는 남는다

같은 TTS 브랜치는 요청·SDK 재시도·본문 수신을 메서드 호출 전체 300초 기한으로 묶고, SDK 시도 시간 240초와 maxRetries: 2를 명시한다. 각 타이머를 더해 호출 시간으로 계산하는 구조는 아니다. 전체 기한이 별도로 전체 대기를 제한한다. 이 변경을 모든 중복 호출이나 과금을 없앤 것으로 설명할 수 없는 이유다.

작업 계층의 재시도도 별도로 정리했다

차시 콘텐츠 생성은 통신 재시도를 SDK에 맡기고 형식 오류에만 한 번 더 호출한다. 차시당 실제 요청 최대 6회, 작업 시간 예산과 저장 실패의 처리는 겹치는 재시도와 시간 예산에서 다룬다. TTS의 호출 횟수 보장과는 다른 계약이다.

클론 보상과 TTS·차시 재시도 정책은 별도의 구현이다. 실제 중복 비용 절감액은 측정하지 않았다. AI Commons에서 확인한 것은 외부 성공 후 연결할 대상이 없어진 경우와, 외부 결과 자체를 모르는 경우에 서로 다른 대응이 필요하다는 점이다.