통화를 카톡처럼 말풍선으로 보기 — Audio Chunking과 Degeneration Guard
통화 녹음을 화자별 말풍선으로 바꿉니다. 긴 통화를 통짜로 넣으면 모델이 완주하지 못합니다. 8분 조각으로 나눠 시간축을 다시 맞추는 방법과, 구조는 멀쩡한데 내용만 무너진 전사문을 걸러내는 판정을 실측값과 함께 공개합니다.
링커 기능 하나를 골라 안이 어떻게 움직이는지 적는 시리즈입니다. 지난 글은 흩어진 문자와 통화를 한 고객 아래로 모으기입니다.
통화가 끝나면 녹음이 서버로 올라옵니다. 글로 옮겨지고, 누가 무슨 말을 했는지 화자별로 나뉘어 카톡 대화처럼 말풍선으로 쌓입니다. 그 위에서 요약과 할 일이 나옵니다.
「음성을 텍스트로」는 API 한 줄이면 될 것 같은데, 실제로 시간을 쓴 곳은 전부 다른 데였습니다. 긴 오디오에서 모델이 완주하지 못하는 문제와, 구조는 멀쩡한데 내용만 무너진 결과를 알아보는 문제입니다.
전체 흐름
flowchart TD
A["폰: 통화 종료<br/>OS 녹음 파일"] --> B["S3 업로드"]
B --> C["SQS 메시지<br/>callId 하나만"]
C --> D{"이미 분석됐나"}
D -->|예| E["skip · ack"]
D -->|아니오| F["처리권 획득<br/>PENDING → PROCESSING"]
F --> G["크레딧 선차감"]
G --> H["S3 → 임시 파일"]
H --> I{"8분 초과?"}
I -->|아니오| J["통짜 전사"]
I -->|예| K["조각으로 나눠 전사"]
J --> L{"쓸 만한 전사인가"}
K --> L
L -->|아니오| M["실패 표시 · 크레딧 환원"]
L -->|예| N["전사 저장"]
N --> O["요약"]
N --> P["관계·이름"]
N --> Q["링커 카드 감지"]큐에 실리는 건 통화 id 하나뿐입니다. 파일 경로도 사용자 정보도 안 싣습니다. 워커가 DB에서 다시 읽습니다. 메시지가 오래 큐에 머물러도 그 사이 바뀐 정보가 반영되고, 메시지에 개인정보가 남지 않습니다.
같은 통화를 두 번 분석하지 않기
SQS는 같은 메시지를 두 번 보낼 수 있습니다. 전사는 이 파이프라인에서 가장 비싼 단계라 중복 실행이 곧 요금입니다.
방어가 세 겹입니다.
// ① 이미 요약까지 끝났으면 재분석하지 않는다
if (analysisQueryService.isAlreadyAnalyzed(callId)) return
// ② 전사는 끝났는데 요약이 없으면 «요약만» 복구한다.
// 전사를 다시 하면 원가만 두 배 나간다.
if (analysisCommandService.needsSummaryOnly(callId)) {
recoverSummary(callId, context)
return
}
// ③ 처리권 획득 — PENDING/FAILED → PROCESSING 원자적 전이.
// 실패하면 다른 워커가 이미 잡은 것이다.
if (!analysisCommandService
.claimTranscriptProcessing(callId)) return
③이 지난 글에서 다룬 행 락 claim 과 같은 패턴입니다. 상태를 바꾸는 것으로 처리권을 잡습니다.
②는 부분 실패를 다루는 방법입니다. 전사는 성공했는데 요약이 실패하면, 다시 왔을 때 전사를 건너뛰고 요약부터 합니다. 이걸 안 나누면 재시도할 때마다 가장 비싼 단계를 다시 밟습니다.
요금은 먼저 깎습니다
/**
* 분석 1건분을 «먼저» 차감한다(선차감).
* 「확인만 하고 성공 시 차감」이면
* 통화 20건이 동시에 들어올 때 전부 통과한다.
*/
잔액을 확인만 하고 통과시키면 동시에 들어온 요청이 모두 같은 잔액을 봅니다. 먼저 깎고, 실패하면 되돌립니다.
전사가 쓸 수 없는 결과로 끝났을 때도 되돌립니다. 우리가 못 만든 것을 사용자에게 물릴 수는 없습니다.
오디오를 메모리에 올리지 않습니다
// 오디오는 메모리에 올리지 않는다 — 길이 상한이 없어져
// 3시간 통화(약 166MB)도 들어온다.
val audioFile =
fileManager.downloadToTempFile(context.fileId)
try {
geminiClient.transcribeAudio(
audioFile, mimeType, durationSec)
} finally {
audioFile.delete()
}
처음에는 바이트 배열로 다뤘습니다. 녹음 길이 제한을 없애자 3시간짜리가 들어왔습니다. 워커 한 대가 동시에 몇 건을 처리하는지 생각하면 답이 안 나옵니다.
분할도 업로드도 파일 경로로 하므로 바이트로 들고 있을 이유가 없었습니다.
긴 오디오는 모델이 완주하지 못합니다
여기가 이 작업의 중심이었습니다.
긴 통화를 통짜로 넣으면 전사가 중간에 끝납니다. 실측이 분명했습니다.
- 21분 이상 3건이 어떤 설정으로도 전부 실패
- 42분·52.8분·54.7분·60분을 통짜로 넣으니 재시도 포함 4건 모두 세그먼트 0개
- 같은 파일을 8분 조각으로 나누자 조각 28개가 전부 한 번에 통과
- 원가도 44% 감소
파라미터를 조절해서 풀리는 문제가 아니었습니다. 그래서 8분을 넘으면 나눕니다.
if (durationSec > CHUNK_SEC) {
return transcribeInChunks(
audioFile, mimeType, durationSec)
}
8분 이하는 조각이 하나뿐이라 나누기 전과 동작이 같습니다. 원가 분포상 통화의 95% 이상이 여기 해당하고 그 구간은 이미 무결합니다(10분 미만 55건 중 55건 정상). 잘 되는 쪽은 건드리지 않았습니다.
조각 하나가 실패하면 통화 전체를 실패시킵니다
/**
* 조각이 하나라도 끝내 실패하면 통화 전체를 실패시킨다.
* 건너뛰고 나머지로 합치면 그 구간이 빠진 채 "정상 분석"으로 보이는데,
* 중간 구멍은 coversAudio 가 마지막 도달 시각만 보므로 잡지 못한다 —
* 사용자가 무엇이 없는지 모른 채 그 요약을 믿게 된다.
*/
말없이 빠진 것이 시끄럽게 실패한 것보다 나쁩니다. 요약은 있는 내용으로 그럴듯하게 나오고, 사용자는 그게 전부인 줄 압니다.
시각 검증도 조각 단위로 합니다
조각은 저마다 0초부터 셉니다. 합친 뒤 통화 전체 길이로 한 번에 검증하면, 한 조각의 어긋난 시각 하나가 그 뒤 모든 조각의 시각까지 끌고 내려갑니다. 실측에서 그렇게 155개의 시각이 폐기됐고, 그중 상당수는 자기 시간축상 멀쩡했습니다.
경계는 겹쳐서 자르고 뒤 조각을 씁니다
조각 경계에서 말이 잘리지 않게 조금 겹쳐서 자릅니다. 겹친 구간은 두 조각에 다 들어 있으니 하나를 골라야 합니다.
// 겹침 구간은 뒤 조각 것으로 덮는다 — 실측(6개 경계)에서
// 조각 «끝» 부분은 두 화자를 한 명으로 뭉개는 반면,
// 같은 구간을 담은 다음 조각의 «시작» 부분은 정확히 나눴다.
모델이 오디오 끝부분에서 화자 분리를 흐리게 하는 경향이 있었습니다. 같은 소리인데 어디에 놓였느냐로 품질이 달라집니다.
시각은 고치지 않고 버립니다
전사 결과의 타임스탬프가 자주 어긋났습니다. 처음에는 고치려 했습니다.
- 표기 문제(
분.초혼입)를 고쳤더니 → 시계 리셋(9:56 → 1:00)이 나왔습니다 - 그걸 보정했더니 → 길이 초과가 나왔습니다. 12분 통화에 커버리지 1521%
고칠 때마다 새 양상이 나오는 두더지 잡기였습니다. 반면 길이별 경계는 뚜렷했습니다.
- 10분 미만 — 55/55 정상
- 20~45분 — 3/7
- 45분 이상 — 0/3
그래서 방침을 바꿨습니다. 앞에서부터 일관된 구간까지만 시각을 남기고, 어긋나는 지점부터 끝까지 버립니다. 텍스트는 전부 그대로 둡니다.
val ok = start != null && end != null &&
start <= end &&
end <= durationSec * MAX_END_RATIO &&
start >= lastStart - OVERLAP_TOLERANCE_SEC
if (!ok) { broken = true; segment.withoutTime() }
겹쳐 말하기 때문에 시작 시각이 조금 뒤로 가는 건 정상입니다(실측 2~14초). 30초를 넘어가면 시계가 어긋난 것으로 봅니다.
시각을 잃어도 분석 가치는 그대로입니다. 요약·관계분석은 전사문만 씁니다. 잃는 건 그 구간의 시각 표시와 재생 시크뿐입니다.
쓸 수 있는 전사인지 판정하기
전사가 「성공했다」는 응답이 왔다고 쓸 수 있는 건 아닙니다. 세 가지를 봅니다.
fun isUsable(durationSec: Int): Boolean =
isTranscribed
&& coversAudio(durationSec)
&& !isDegenerated
① 세그먼트가 하나라도 있는가
Gemini는 전사가 불가능하면 영문 사과문을 본문에 담아 정상 응답처럼 돌려줍니다. 무음·초단통화·미지원 포맷에서 그렇습니다.
② 통화 전체를 훑었는가
간헐적으로 앞부분 한 마디만 반환하고 끝냅니다. 실측으로 101초 통화에서 「여보세요」 세그먼트 하나가 나왔습니다. 같은 파일을 다시 넣으니 44개로 정상 전사됐습니다.
요구 비율을 통화 길이별로 나눴습니다.
private const val LONG_CALL_SEC = 300
private const val MIN_COVERAGE_RATIO_SHORT = 0.1
private const val MIN_COVERAGE_RATIO_LONG = 0.4
처음엔 전 구간 10%였는데 21분 통화가 145초(11%)만 전사되고도 통과했습니다. 짧은 통화는 「앞부분만 말하고 끊은 정상 통화」가 흔해 관대해야 하지만(60초 중 20초 대화 = 33%), 긴 통화가 앞 10%만 전사되는 건 정상일 수 없습니다.
③ 내용이 무너지지 않았는가
가장 까다로운 판정입니다. 모델이 생성 궤도를 이탈하면 JSON 구조는 멀쩡한 채 내용만 쓰레기가 됩니다. 스키마 검증에도 파싱에도 안 걸립니다.
관측된 양상이 셋이라 각각 잡습니다.
초장문 세그먼트. 한 발화가 500자를 넘습니다. 실측 정상 6,075개 세그먼트의 상위 0.1%가 134자였고, 500자를 넘긴 건 이탈 건 하나뿐이었습니다.
반복 루프. 짧은 토큰 하나가 발화의 절반 이상을 차지합니다. 실측에서 "말" 이 4,700회 반복돼 99%를 차지한 적이 있습니다.
private fun String.tokenDominance(): Double {
if (length < MIN_LEN_FOR_REPEAT_CHECK) return 0.0
val tokens = split(" ").filter { it.isNotBlank() }
if (tokens.isEmpty()) return 0.0
val topShort = tokens
.filter { it.length <= SHORT_TOKEN_LEN }
.groupingBy { it }.eachCount()
.maxByOrNull { it.value } ?: return 0.0
return topShort.toDouble() / tokens.size
}
짧은 발화(「네. 네.」)를 오탐하지 않게 200자 미만은 검사하지 않습니다.
추론문 누출. 이탈하면 모델의 내부 사고가 전사문에 섞여 들어옵니다.
private val REASONING_LEAK = Regex(
"The user wants|I need to (extract|transcribe)|" +
"JSON output|transcription of the audio|" +
"Let me (analyze|start|begin)",
RegexOption.IGNORE_CASE,
)
한국어 통화 전사문에 나올 이유가 없는 문구들입니다.
커버리지 판정이 시각 폐기와 충돌했습니다
두 방어가 서로를 망가뜨린 사례입니다.
시각을 버리면 남은 세그먼트의 마지막 시각이 짧아집니다. 그 값으로 커버리지를 재면 시각이 깨진 통화가 「전사가 짧은」 통화로 둔갑합니다.
실측 사례가 아팠습니다. 25분 통화에서 폐기 전 961초(63.9%)였던 커버리지가 폐기 후 599초(39.8%)로 떨어졌습니다. 문턱이 40%였습니다. 0.2%p 차이로 366 세그먼트·9,513자 전사문이 통째로 버려졌습니다.
/**
* withValidatedTimestamps 가 시각을 버리기 «전에»
* 전사가 도달했던 마지막 시각.
* 시각이 깨진 것과 전사가 짧은 것은 다른 문제다.
*/
val lastEndBeforeValidation: Double? = null
버리는 순간의 도달 시각을 박제해 두고, 커버리지는 그 값으로 잽니다.
동시에 반대 방향도 막아야 했습니다. 시계가 폭주하면(커버리지 1521%) 그 값이 최댓값이 되어 검사가 통째로 무력화됩니다. 그래서 녹음 길이를 넘는 시각은 아예 세지 않습니다. 완화가 검사 무력화로 번지면 안 됩니다.
단계마다 격리 수준이 다릅니다
// 전사 실패 → FAILED 마킹 후 throw → SQS 가 재전송
// 요약 실패 → FAILED 마킹만 하고 ack (재전송 안 함)
전사는 없으면 아무것도 못 하니 재시도합니다. 요약·관계·링커 감지는 전사문만 있으면 되고 서로 독립이라, 하나가 실패해도 나머지는 돌고 메시지는 확인 처리합니다.
「쓸 수 없는 전사」도 재시도하지 않습니다. 클라이언트가 이미 두 번 시도한 뒤라 같은 결과가 나올 가능성이 높습니다.
정리
- 큐에는 식별자만 싣습니다. 개인정보가 큐에 남지 않고, 오래 머물러도 최신 정보로 처리됩니다.
- 비싼 단계는 부분 재개가 되게 나눕니다. 재시도마다 전사를 다시 하면 요금이 배로 나갑니다.
- 요금은 먼저 깎고 실패하면 되돌립니다. 확인만 하면 동시 요청이 다 통과합니다.
- 모델이 못 하는 크기는 파라미터로 안 풀립니다. 나누는 편이 빠르고 쌉니다.
- 나눈 것을 합칠 때는 검증도 조각 단위로 합니다. 합쳐 놓고 검증하면 하나가 전부를 오염시킵니다.
- 부분 성공을 인정하지 마세요. 말없이 빠진 구간은 사용자가 모른 채 믿습니다.
- 고칠 때마다 새 양상이 나오면 고치지 말고 버리세요. 못 믿을 데이터를 버려도 쓸 데가 남으면 그게 낫습니다.
- LLM 출력은 구조가 멀쩡해도 내용이 무너질 수 있습니다. 길이·반복·누출을 따로 봅니다.
- 판정 문턱은 관측된 분포에서 정합니다. 정상 상위 0.1%가 134자였기에 500자가 안전한 문턱이 됩니다.
- 방어를 여럿 두면 서로 간섭합니다. 하나를 고칠 때 다른 하나의 입력이 바뀌지 않는지 봅니다.