-
한국어 자막 sync는 한 알고리즘으로 안 된다 — 4단 fallback을 쌓아 올린 이유IT 2026. 5. 25. 21:00
영상 한 편을 만들면서 의외로 어려웠던 게 자막이었다. BytePlus Seedance 2.0 Fast로 클립을 19개 만들고 ffmpeg로 합본하는 데까지는 비교적 정리된 길이 있었는데, 어린이가 직접 녹음한 한국어 음성 위에 자막을 ffmpeg burn-in하는 단계에서 문제가 생겼다. 첫 시도는 자막을 클립 길이로 단순히 등분해 보여주는 거였다. "한 호흡 한 줄" 원칙이면 N등분이 어느 정도는 맞을 거라고 가정한 거다. 결과는 듣는 단어와 보이는 자막이 1초씩 어긋나는 영상이었다.
다음 시도는 Whisper로 음성을 word-level로 받아 자막 줄과 직접 매칭하는 거였다. 이것도 안 됐다. 한국어 ASR이 발화를 항상 똑같이 적어주지 않는다. 사용자가 "복도에서"라고 말해도 Whisper는 "복둔서"라고 적고, "합격"이 "학교"로 들리는 일이 종종 일어난다. 단순 substring 매칭이라는 가정 위에서 다 무너진다.
그래서 자막 sync 알고리즘이 한 단계 짜리로 끝나지 않고 4단계가 쌓이게 됐다. exact substring → fuzzy partial → 줄별 부분 fallback + word-boundary 보정 → duration N등분 의 단계적 후퇴(graceful degradation) 구조다. 이 글은 그 네 단계가 왜 각각 필요했는지, 그리고 거기서 얻은 인사이트를 정리한다.
한국어 Whisper drift라는 첫 벽
먼저 문제의 구체적 모양을 보자. 한 클립의 자막은 디렉터가 직접 입력한 5줄이다. 음성은 어린이가 녹음한 13초짜리 한국어 mp3이다. faster-whisper(small, int8, CPU)로 word-level transcribe를 돌리면 다음과 같이 나온다.
Whisper 출력 word 시퀀스: 0.00- 0.98 "모두가" 0.98- 2.34 "168명이야" ... 6.66- 7.16 "그래서" 7.16- 7.58 "우리" 7.58- 8.20 "조직은" 8.20- 9.10 "복둔서" ← 디렉터 자막은 "복도에서" 9.10- 9.72 "모르는" ... 디렉터가 입력한 자막 3번째 줄: "그래서 우리 조직은 복도에서 모르는 사람이 없어"이 줄을 normalize한 후 transcript에서 exact substring으로 찾으면 실패한다. "복도에서" 한 군데가 "복둔서"로 다르기 때문이다. 단순 substring 매칭은 이걸 0% 매칭으로 본다.
여기서 첫 번째 설계 선택지가 나온다. 한 줄이 실패했을 때 어떻게 할 것인가? 처음에는 "전체 자막 alignment를 포기하고 N등분으로 떨어뜨리자"고 했다. 가장 단순하니까. 그러나 그 결과 다른 4개 줄은 멀쩡히 매칭됐는데도 N등분으로 갈아엎으면서 4초씩 어긋난 자막이 영상에 burn-in됐다. 단일 실패가 전체를 무력화시키는 구조였다.
4단 fallback의 풀버전
1단 — exact substring
가장 단순한 매칭이다. 자막 줄과 transcript를 둘 다 정규화(공백·구두점 제거)한 뒤 substring을 찾는다. cursor를 두어 sequential하게 진행한다.
line_norm = "재미있는게있는가" transcript = "...재미있는게있는거..." transcript.find(line_norm, cursor) # -1 (있는가 vs 있는거 — 마지막 글자 drift)한국어 자막이 ASR 출력과 정확히 일치할 때는 1단에서 끝난다. 영상의 9개 클립 중 4개가 이 단계에서 그대로 통과했다. 가장 빠르고 가장 안전한 길이다.
2단 — fuzzy partial 매칭 + word-boundary 보정
exact가 실패하면 그 줄에 한해 fuzzy를 시도한다.
rapidfuzz.fuzz.partial_ratio_alignment가 needle(자막 줄)을 haystack(transcript[cursor:])의 가장 비슷한 부분과 매칭해서 점수와 위치를 함께 반환한다.partial_ratio("그래서우리조직은복도에서모르는사람이없어", "그래서우리조직은복둔서모르는사람이없어") → score = 89.5% (한 글자 drift라 점수가 높음) → dest_start = 0, dest_end = 18임계점은 80%로 잡았다. 한국어 ASR의 흔한 한두 글자 오인은 점수 85~95%로 잡히고, 완전히 다른 단어("조집" → "조직" 같은 의역)는 0~30%로 잡힌다. 80%는 그 사이를 가르는 경계점이다.
fuzzy가 80%를 넘으면 매칭 위치를 알 수 있다. 그러나 곧바로 timestamp로 변환하면 안 된다. partial_ratio_alignment가 반환하는 char index가 항상 word boundary와 일치하지는 않기 때문이다. 그래서 fuzzy 성공 줄에는 두 가지 미세 보정을 적용한 뒤 timestamp를 도출한다.
보정 ①: dest_start 첫 char shift. fuzzy 매칭이 "거 아침마다…"의 "거"부터 시작하는 위치로 잡혔을 때, 사용자 자막 "아침마다…"의 첫 char "아"가 매칭 window 안 1~2 위치에 있으면 그만큼 dest_start를 미룬다. 1.06초의 시작점 어긋남을 잡아낸다.
보정 ②: dest_end gap-aware 확장. "재미있는게 있는가"에서 마지막 "가"가 ASR의 "거"로 나타났을 때, 매칭 길이가 needle보다 1자 짧다. 이 때 transcript의 다음 word 길이가 정확히 1이면(즉 "거" 한 글자) 그 word를 마저 포함시킨다. 0.26초의 마무리 어긋남을 잡아낸다.
3단 — 줄별 인접 보간 (fuzzy 실패 줄 fallback)
fuzzy 점수가 80% 미만으로 떨어지는 줄도 있다. ASR이 "합격!"을 "학교!"로 완전히 다르게 적어 fuzzy score가 30%대로 떨어지는 경우가 그렇다. 이 줄만 따로 처리한다. 직전에 매칭에 성공한 줄의 마지막 word + 1 부터, 다음에 매칭에 성공한 줄의 첫 word - 1 까지를 이 줄의 word range로 본다. word index 기반 인접 보간이다.
4단 — duration N등분 (최종 안전망)
모든 줄이 fuzzy까지 실패하는 경우는 거의 없지만, 안전망은 있어야 한다. ASR이 음성을 통째로 잘못 들었거나, 입력 자막이 음성과 완전히 다른 내용이거나(디렉터 의역), 음성에 발화가 거의 없는 경우가 여기에 해당한다. 이 때만 클립 duration을 자막 줄 수로 단순 N등분한다.
핵심은 4단계가 자동으로 흘러간다는 것이다. 호출자는 "이 자막을 sync해줘"라고 한 번 부르면 시스템이 알아서 1단→2단→3단→4단까지 내려가며 가장 정밀한 매칭을 찾는다. UI에 결과만 표시한다 — "음성 sync 적용됨" 또는 "N등분 fallback".
4단 fallback flow 다이어그램
다이어그램 설명. 자막 N줄과 음성을 입력으로 받으면 faster-whisper가 word-level timestamp를 추출한다. 그 뒤 자막을 줄 단위로 1단 exact substring 매칭부터 시도한다. 1단이 통과한 줄은 별도 처리 없이 우측 bypass로 결과 검사 단계로 넘어간다. 1단이 실패한 줄만 2단 fuzzy로 내려간다. fuzzy 점수가 80%를 넘으면 같은 2단 안에서 dest_start/end 보정을 적용해 word-boundary를 다듬은 뒤 우측 bypass에 합류한다. 80% 미만으로 떨어지는 줄만 3단으로 가서, 모든 줄 처리가 끝날 때 앞·뒤 OK 줄의 word index 사이로 끼워 넣어 보간한다. 우측 bypass(1단·2단 성공 줄)와 3단 출력(인접 보간 줄)이 한 지점(merge dot)으로 합쳐지고, 결정 노드 "모든 줄 매칭 실패?"가 발동한다. 한 줄이라도 OK면 줄별 (start, end) timestamp가 출력되고, 전부 실패한 드문 케이스만 4단 duration N등분으로 떨어진다. 화살표 색은 통과 경로(초록)와 실패·후퇴 경로(빨강)를 구분한다. 영상 19개 클립 중 16개가 1단·2단에서 끝났고, 2개가 3단 인접 보간을 거쳤으며, 4단까지 떨어진 클립은 0개였다.
핵심 코드 — align_lines_to_words
실제 구현은
video_project/subtitle_sync.py안의 함수 하나다. 골격만 보면 위 다이어그램이 거의 그대로 보인다.def align_lines_to_words( lines: list[str], words: list[Word], fuzzy_threshold: float = 80.0, ) -> list[tuple[float, float]] | None: """디렉터 자막 줄을 whisper word timestamps와 정렬. 1단: exact substring → 2단: fuzzy → 3단: 인접 보간 → 4단: None(호출자가 N등분). """ if not lines: return [] if not words: return None # transcript 전체를 정규화한 문자열 + 누적 char index (word index 변환용) word_norm = [normalize_text(w.text) for w in words] cumulative = [0] for n in word_norm: cumulative.append(cumulative[-1] + len(n)) transcript = "".join(word_norm) # ── Phase 1: 줄별 매칭 시도 (exact → fuzzy → 보정) ── matches: list[tuple[int, int] | None] = [] cursor = 0 for line in lines: ln = normalize_text(line) if not ln: matches.append(None) continue # 1단: exact substring (가장 빠른 경로) pos = transcript.find(ln, cursor) if pos != -1: matches.append((pos, pos + len(ln))) cursor = pos + len(ln) continue # 2단: fuzzy partial_ratio_alignment (한국어 ASR drift 흡수) fuzzy = _find_fuzzy_window(ln, transcript, cursor, fuzzy_threshold) if fuzzy is not None: # 3단 보정 ①·②: 첫 char shift + dest_end gap-aware 확장 refined = _refine_alignment(ln, transcript, cumulative, fuzzy[0], fuzzy[1]) matches.append(refined) cursor = refined[1] else: # 이 줄은 실패 — 일단 None으로 두고 다음 줄로 matches.append(None) # 모든 줄 실패 → 호출자에게 N등분 fallback 신호 if all(m is None for m in matches): return None # ── Phase 2: word index 도출 + 실패 줄 인접 보간 (3단 fallback) ── result: list[tuple[float, float]] = [] for i, m in enumerate(matches): if m is not None: # 매칭 OK: char index → word index → timestamp s_idx = char_to_word_idx_start(m[0]) e_idx = char_to_word_idx_end(m[1]) result.append((words[s_idx].start, words[max(e_idx, s_idx)].end)) else: # 매칭 실패: 인접 OK 줄의 마지막 word + 1 ~ 다음 OK 줄의 첫 word - 1 prev_end_w = _scan_back_for_ok(matches, i, cumulative) next_start_w = _scan_forward_for_ok(matches, i, cumulative) s_idx = (prev_end_w + 1) if prev_end_w >= 0 else 0 e_idx = (next_start_w - 1) if next_start_w >= 0 else len(words) - 1 result.append((words[s_idx].start, words[max(e_idx, s_idx)].end)) return result코드 설명. 함수는 두 가지 반환을 가진다 — 줄별 (start, end) timestamp 리스트, 또는 None. None은 호출자(
handle_burn_subtitles)에게 "포기. 4단 N등분으로 떨어뜨리라"는 신호다. Phase 1은 cursor를 두어 자막 줄을 순서대로 transcript에서 찾는다 — 자막 줄 N개와 transcript word M개에 대해 O(N·M)이지만 한국어 영상 한 클립이면 N≤5, M≤30이라 사실상 O(1). Phase 2는 word index 보간을 한다.char_to_word_idx_start와_end는bisect로 cumulative char count 배열을 검색해 char index를 word index로 변환한다. 자막 실패 줄에 대한 인접 보간(_scan_back_for_ok/_scan_forward_for_ok)은 매우 단순한 선형 탐색이다 — 5줄 이내라 비용 0._find_fuzzy_window는 rapidfuzz의partial_ratio_alignment한 번 호출이고,_refine_alignment는 50줄 정도의 보정 로직이다. 전체 합쳐 200줄 미만에서 4단계가 작동한다.multi-stage degradation이 가르쳐 준 것
이 시스템이 안정되기까지 3주에 걸쳐 단계가 하나씩 늘어났다. 처음에는 그냥 N등분, 그 다음 word-level + exact만, 그 다음 fuzzy 추가, 마지막에 word-boundary 보정과 인접 보간이 추가됐다. 그 과정에서 일반화할 수 있는 패턴 몇 가지가 보였다.
외부 ML 모델의 출력을 binary로 신뢰하지 말 것. Whisper는 word-level timestamp를 95% 정확하게 준다. 그런데 5%의 오류가 "한 글자 drift" 같은 매우 작은 형태로 들어온다. 이 5%를 "실패"로 간주하고 알고리즘 전체를 포기시키면 사용자가 보는 결과의 100%가 무너진다. 외부 ML 출력은 항상 "정확한 부분"과 "drift된 부분"이 섞여 있다는 가정으로 설계해야 한다. exact만 받는 게 아니라 fuzzy + 보정의 다층을 만들어야 한다.
fallback은 "다 포기"가 아니라 "한 줄씩 포기"여야 한다. 자막 5줄 중 1줄만 매칭 실패했다고 5줄 전체를 N등분으로 갈아엎으면, 다른 4줄의 정확한 매칭 정보를 버린다. 부분 fallback(줄별 보간)이 핵심이다. AI 시스템에서도 마찬가지 — 한 단계의 일부 실패가 전체 파이프라인 중단으로 이어지지 않도록 partial degradation을 설계해야 한다.
boundary 보정은 char level에서 word level로 끌어올리는 다리. rapidfuzz 같은 char-level 알고리즘은 정밀하지만 word boundary 정보를 모른다. 반면 영상의 자막은 word 단위로 끊어 들어가야 발화와 일치한다. char level 결과를 그대로 쓰면 자막 시작이 단어 중간에서 튀어나오거나 끝이 일찍 잘린다. 두 layer 사이의 "boundary 보정"이 알고리즘의 품질을 결정한다.
UI에 fallback level을 노출한다. 자막 burn-in이 끝나면 토스트로 "음성 sync 적용됨" 또는 "N등분 fallback — 음성과 자막 불일치"가 뜬다. 사용자(영상 디렉터)는 결과만 보고 알아챈다. AI 시스템에서 "이 결과가 어느 신뢰도 layer에서 나왔는지" 사용자에게 한 줄로 알려주는 게 운영 안전을 크게 높인다. 4단 fallback이 자동으로 흐르더라도, 4단까지 떨어진 케이스는 디렉터가 즉시 인지하고 자막을 다시 검토할 수 있어야 한다.
여러 단계가 자동으로 흐르되, 각 단계는 단순하게. 4단계 시스템이라고 해서 한 단계가 복잡할 필요는 없다. 1단은 한 줄(
str.find), 2단은 한 함수(partial_ratio_alignment), 3단은 50줄, 4단은 단순 N등분이다. 단순한 알고리즘을 결합해서 견고한 시스템을 만드는 게 multi-stage degradation의 본질이다.
이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.
'IT' 카테고리의 다른 글
가벼운 그래프 데이터 처리 — NetworkX + SQLite WAL 조합의 정체와 효과 (0) 2026.05.27 Cypher — SQL은 알지만 그래프 쿼리는 처음인 사람에게 (0) 2026.05.27 코드 위키는 mermaid를 얼마나 쓸까 — React 위키 115개·Express 위키 221개 실측과 의미 (0) 2026.05.26 코드 위키 8섹션 표준 — Overview부터 Glossary까지 하나씩 풀어보기 (0) 2026.05.26 DeepWiki·CodeWiki·deepwiki-open — 같아 보이는 코드 위키 도구 세 개의 진짜 차이 (0) 2026.05.26 Seedance 2.0 fast 영상의 음성이 1.3초 빨리 나왔다 — 재생성 0원, ffmpeg adelay로 5초만에 해결 (0) 2026.05.24 ffmpeg concat이 Seedance 클립 두 번째부터 깨졌다 — video duration이 진실의 원천 (0) 2026.05.24 ffmpeg -c copy로 0.5초 trim했더니 0초 trim됐다 — keyframe-aligned의 함정 (Seedance 후처리 사례) (0) 2026.05.24 Seedance 2.0 fast 영상의 첫 0.5초가 사진처럼 정지된 이유 — photo prefix 자동 제거 (0) 2026.05.23 Seedance 2.0 fast로 영상 만들 때도 reshoot이 있다 — 한 번 촬영으로 끝나지 않는 production (0) 2026.05.23