← 노트

DeepSRT가 정확한 이중 자막을 구현하는 방법

부서진 자막을 온전한 문장으로 꿰매고, 번호를 붙여 LLM에 넘긴 뒤, 번역 한 줄 한 줄을 제 번호 자리에 앉힌다 — 타임라인은 처음부터 끝까지 하나뿐이다.

2026년 7월 · DeepSRT 개발 노트

DeepSRT에서 SNL 영상 재생: 번역 자막과 원문 자막이 같은 1초에 정렬되고 오른쪽에는 AI 요약 사이드바
실제 결과: 번역 줄과 원문 줄이 같은 1초에 놓인다 — 영상 전체의 매 순간이 그렇다.

이중 자막은 겉보기에 UI 기능입니다. 위에는 번역, 아래에는 원문. 어려운 것은 「정확」이라는 단어입니다. 번역된 한 줄 한 줄이 지금 말하고 있는 바로 그 문장이어야 합니다. 영상 전체에서, 어디로 탐색하든.

이중 자막을 출시한 직후 특이한 제보를 받았습니다. 번역 줄은 멀쩡한 문장인데, 내용이 3분 뒤 장면의 것이었습니다. 심할 때는 같은 구절이 8번 반복되어 글자 벽이 되었습니다. 이 노트는 우리가 찾아낸 원인과, 그것을 끝낸 세 가지 설계 결정에 관한 이야기입니다.

공짜 점심에는 독이 있다

영상 플랫폼은 기계 번역 자막 트랙을 제공합니다. 무료, 즉시, 요청 한 번. 자막 제품을 만드는 사람이라면 누구나 먼저 손을 뻗고, 우리도 그랬습니다. 그러나 원문 트랙과 대조해 측정한 결론은 — 텍스트는 쓸 만하지만 타이밍은 신뢰할 수 없다. 번역과 음성의 어긋남은 일정하지 않고 재생될수록 누적됩니다. 누적되는 오차에는 「보정」이 존재하지 않습니다. 어떤 양만큼 어긋난 것이 아니라, 애초에 정렬된 적이 없는 것입니다.

제품의 신뢰성으로 값을 치르는 공짜 점심은 공짜가 아닙니다.

조각에서 문장으로

플랫폼에 원문 트랙을 요청해 돌아오는 것은 문장이 아닙니다. 발화의 쉼에 따라 잘린 수백 개의 조각이며, 각각 밀리초 단위의 시각이 붙어 있습니다:

RAW FRAGMENTS (split by speech pauses)
  382.1s  "I guess heterosexual women"
  383.3s  "aren't allowed to have hair anymore."
  385.7s  "They have to shave their heads instead..."
        |
        |  deterministic sentence merge
        v
SPINE CUES (split by sentences)
  #153   382.1s - 385.0s   "I guess heterosexual women
                            aren't allowed to have hair anymore."
  #154   385.7s - ...      "They have to shave their heads..."

timing = exact union of the fragments, never invented

처음 두 조각은 사실 한 문장입니다. 자동 자막은 어디서나 이렇습니다 — 하나의 생각이 두세 개의 큐로 쪼개지는 것이 상례이지 예외가 아닙니다.

이것을 그대로 번역기에 넘길 수는 없습니다. 반 토막 난 생각은 아무렇게나 번역됩니다 — "I guess heterosexual women" 단독으로는 어디로든 갈 수 있습니다. 그대로 표시할 수도 없습니다. 조각은 너무 빨리 지나가 읽을 수 없습니다. 그래서 첫 단계로 조각을 문장 모양의 큐로 꿰매는 결정적 병합을 거칩니다. 문장이 끝날 때까지 이어 붙이되 안전밸브를 둡니다 — 너무 길어지는 큐, 화면에 너무 오래 머물 큐, 뚜렷한 침묵을 가로지르는 큐(쉼은 천연의 경계입니다)는 강제로 자릅니다. 병합은 문자 체계도 인식합니다. CJK는 공백 없이 결합하고 길이 예산도 라틴 문자보다 빡빡합니다.

그중 다른 무엇보다 중요한 규칙이 하나 있습니다. 병합은 조각의 경계에서만 일어난다. 병합된 큐의 시작과 끝은 구성 조각들의 시각의 정확한 합집합입니다 — 지어낸 타임스탬프를 단 큐는 하나도 없습니다.

그리고 이 병합이 결정적이기 때문에 — 같은 입력이면 언제나 같은 문장, 같은 번호 — 뒤의 두 가지가 성립합니다. ID가 결합 키로 쓸 만큼 안정적이라는 것, 완성된 번역을 「그 트랙의 정확한 형태」에 대해 캐시할 수 있다는 것. 둘 다 곧 쓰입니다.

결정 1: 타임라인은 하나뿐

번역 트랙을 통째로 버리고 하나의 규칙으로 다시 만들었습니다. 시스템에 존재하는 타임라인은 정확히 하나. 원문 자막 트랙 — 한 번 가져와 문장 단위로 병합한 것 — 이 내부에서 spine(척추)이라 부르는 구조가 됩니다. 각 줄에 ID, 시작 시각, 길이, 원문:

YouTube native caption track        <-- the only source of time
        |
        |  parse -> merge into sentences
        v
SPINE
  #0    0.0s -  4.5s   "Apple is releasing eight new..."
  #1    4.5s -  8.2s   "Here to comment are the most..."
  #2    8.2s - 11.9s   "Red Heart and Aerial Tramway."
  ...
        |
        v  pushed to screen immediately (native text)

translations attach later, keyed by id -- never by time

이 규칙의 이빨은 여기 있습니다. 번역은 시간 정보를 전혀 갖지 않는다. 번역은 ID에 붙는 주석이지 두 번째 트랙이 아닙니다. 어느 순간 화면에 무엇이 나올지는 spine 혼자 결정합니다. 번역은 「#21은 한국어로 무엇인가」에만 답할 뿐, 「지금 무엇을 표시해야 하는가」에는 결코 답하지 않습니다.

결정 2: ID를 붙여 보내고, ID로 받는다

spine이 준비되면 문장을 ID와 함께 배치로 LLM에 보냅니다. 관용구와 이어지는 농담이 끊기지 않도록 앞뒤 몇 줄을 문맥으로 첨부합니다(번역 제외로 명시):

SEND                                RECEIVE
  context    #18, #19 (do not translate)
  translate  #20 "They have to..."      #20 -> translated
             #21 "Under what..."        #21 -> translated
             #22 "Let me think..."      #23 -> translated
             #23 "[Laughs] I..."
                                        set check: #22 missing
                                                   |
                                        next round: resend #22 ONLY

LLM의 출력은 구조적으로 신뢰할 수 없습니다. 줄을 건너뛰고, 두 줄을 합치고, 없는 ID를 만들어냅니다. 그래서 모든 배치는 수신 시 집합 검증을 거칩니다 — 돌아온 ID 집합은 보낸 집합과 같아야 합니다. 남는 것은 버리고, 빠진 ID는 더 작은 배치로 다시 보내며, 계속 실패하면 그 한 줄만 포기하고 원문을 표시합니다. 실패는 구조적으로 격리됩니다.

스케줄링은 재생 위치를 따릅니다. 지금 보고 있는 배치가 최우선, 다음 배치는 미리 번역, 나머지는 백그라운드에서 채웁니다. 탐색은 「다음 배치가 무엇인가」를 바꿀 뿐 — 끝난 번역은 다시 하지 않으며, 번역이 끝난 영상은 당신의 Mac에 영구 캐시됩니다.

DeepSRT에서 Jimmy O. Yang 스탠드업 재생, 타임라인 0:04 — 재생 4초 만에 번역과 원문이 이미 화면에 함께 있다
재생 위치 우선 스케줄링의 실제: 영상 시작 4초 만에 이중 자막이 이미 자리에 있다.

결정 3: 왜 드리프트가 불가능한가

JOIN BY POSITION (fragile)          JOIN BY ID (cannot slip)

native      translated              native        translations
[0] ------- [0]                     #0 --lookup-- {#0: "..."}
[1] ------- [1]                     #1 --lookup-- {#1: "..."}
[2] --+     (blank -- dropped)      #2 --lookup-- missing -> native
[3]   +---- [2]   <- off by one     #3 --lookup-- {#3: "..."}
[4] --+
[5]   +---- [3]   <- drift grows

one hole shifts every line          a hole stays a hole:
after it, forever                   one native line, nothing else

왼쪽은 순서나 시각 정렬에 의존하는 모든 결합의 고질병입니다. 구멍 하나가 이후의 모든 줄을 밀어내고 오차는 복리로 자랍니다 — 플랫폼 번역 트랙에서 우리가 측정한 바로 그 동작입니다. 오른쪽에서는 결합 키가 ID 그 자체입니다. 요청에 명시하고, 응답에도 그대로 돌려주기를 요구합니다. 잘못된 ID는 아무것도 밀어내지 못합니다. 검증에 걸려 재전송될 뿐입니다. 「어긋났지만 수용된」 상태가 존재하지 않습니다.

우리는 드리프트를 고친 것이 아닙니다. 드리프트가 성립하기 위한 전제를 없앤 것입니다. 드리프트에는 「번역이 자기 시간을 갖는다」는 가정이 필요하지만, 우리의 번역은 어떤 시간도 갖지 않습니다.

DeepSRT에서 KARINA의 WATERBOMB 서울 라이브 재생: 빠른 가사와 문장 중간의 [음악] 마커까지, 번역과 원문이 같은 1초에 정렬
가장 가혹한 경우: 페스티벌 라이브 무대 — 빠른 가사, 함성, 문장 중간의 사운드 마커 — 그래도 같은 1초에 놓인다.

당신이 얻는 것

영상이 시작되는 순간 원문 자막이 나타납니다. 번역은 몇 초 안에 따라붙고 1분 안에 영상 전체가 끝나며 이후 영구 캐시됩니다. LLM은 앞뒤 대화를 볼 수 있으므로 구어체와 농담이 단어 대 단어 기계 번역이 아니라 언어로 나옵니다. 그리고 언제나처럼 서버는 없습니다. 번역은 당신의 Gemini 키로 당신의 Mac에서 직접 실행되며, 당신이 무엇을 보는지 우리가 볼 곳은 존재하지 않습니다.

자막 제품은 같은 교훈을 두 번 배웁니다. 플랫폼 데이터는 내용을 믿기 전에 시계를 검증하라. 이 수업료는 이미 냈습니다. 이 노트가 그 영수증입니다.