
Forced Aligner 출력으로 SRT 자막을 만들었더니 결과가 처참했다. "안녕하세요, 반갑습니다."가 "안녕 하세요 반갑 습니다"로 바뀌어 있었다. 구두점은 어디 갔고 띄어쓰기는 왜 저 모양인가. Qwen3-ForcedAligner가 내부적으로 구두점을 제거하고 어절까지 쪼개서 토큰을 뱉는다는 걸 그때서야 알았다.
문제는 하나 더 있었다. 자막 줄바꿈을 글자 수로만 끊으니 문장 한가운데서 뚝 잘리거나, 어절이 반으로 쪼개지는 일이 비일비재했다. 이걸 두고 볼 수는 없어서 원문 텍스트 기반 복원과 한국어 문법 인식 분할 두 가지를 구현하게 됐다.
Forced Aligner는 오디오와 텍스트를 정렬해서 단어별 타임스탬프를 돌려준다. 여기까지는 좋은데 한국어에서 두 가지 함정이 있다.
첫째, 구두점을 전부 날려버린다. 마침표, 쉼표, 물음표 할 것 없이 다 지운다. 영어에서는 이게 큰 문제가 안 되는데 한국어에서는 "안녕하세요,"의 쉼표가 문장 구조를 결정하는 단서가 된다.
둘째, 어절 내부를 쪼갠다. "안녕하세요"가 "안녕"과 "하세요"로 분리된다. 영어는 단어 단위가 곧 띄어쓰기 단위지만 한국어는 교착어라서 하나의 어절 안에 여러 형태소가 붙어 있다. Aligner가 이걸 형태소 단위로 쪼개버리니 공백 join만으로는 "안녕 하세요"가 되고 만다.
ASR이 인식한 원문 텍스트는 이미 가지고 있다. Aligner 토큰도 있다. 둘 다 같은 문장에서 나왔으니 "글자" 순서는 같을 수밖에 없다. 핵심 아이디어는 단순하다. 양쪽에서 word character(\w 정규식에 매칭되는 문자)만 뽑아서 순서대로 대응시키면 된다.
_token_char_ranges() 함수가 이 매핑을 담당한다. 원문에서 word character의 인덱스 목록을 뽑고, 각 토큰이 소비하는 word character 수만큼 인덱스를 배정한다. 토큰 "안녕"은 원문 "안녕하세요,"에서 인덱스 0~1에 대응하고, "하세요"는 2~4에 대응하는 식이다.
def _token_char_ranges(words, original_text):
orig_wp = [i for i, ch in enumerate(original_text) if _WORD_CHAR_RE.match(ch)]
ranges = []
wp_idx = 0
for word in words:
n = sum(1 for ch in word.text if _WORD_CHAR_RE.match(ch))
start = orig_wp[wp_idx]
end = orig_wp[wp_idx + n - 1] + 1
ranges.append((start, end))
wp_idx += n
return ranges이렇게 매핑이 생기면 _extract_text()로 원문에서 해당 구간을 잘라낸다. 구두점과 띄어쓰기가 살아있는 원본 그대로의 텍스트를 얻을 수 있다.
토큰 i와 토큰 i+1 사이 원문 텍스트를 "갭"이라고 부르기로 했다. 이 갭에는 구두점이나 공백 같은 정보가 들어 있다. 예를 들어 "안녕하세요,"와 "반갑습니다" 사이의 갭은 ", " 즉 쉼표+공백이다.
def _gap(i):
"""토큰 i와 i+1 사이의 원문 텍스트 (구두점/공백)"""
return original_text[char_ranges[i][1]:char_ranges[i+1][0]]이 갭 정보를 바탕으로 우선순위 기반 분할을 한다.
P1. 문장 끝 (.?!) 갭에 마침표나 물음표가 있으면 무조건 거기서 자른다. 자막 한 줄이 하나의 완전한 문장이 되는 게 가장 좋으니까.
P2a. 쉼표/세미콜론 글자 수나 시간이 한도를 넘었을 때 쉼표가 있는 지점에서 분할한다.
P2b. 한국어 절 경계 쉼표가 없어도 "-서", "-고", "-면", "-는데", "-지만" 같은 연결어미로 끝나는 어절 뒤에서 분할 가능. 한국어 특성을 살린 부분이다.
P3. 직전 단어 위 조건 아무것도 안 걸리면 그냥 직전 단어 경계에서 자르는 폴백.
연결어미 감지는 정규식으로 구현했다.
_KOREAN_CLAUSE_RE = re.compile(
r"(?:서|고|며|면서|지만|는데|니까|면|으면|도록|거나|든지|하여|되어)$"
)문제는 오탐이다. "서울에서"의 "서"를 연결어미 "-서"로 오인하는 경우가 대표적. 이걸 막기 위해 두 가지 조건을 걸었다.
물론 완벽하지는 않다. 형태소 분석기를 붙이면 더 정확하겠지만 자막 생성이라는 맥락에서 정규식 수준이면 충분히 쓸만했다.
가장 바보 같았던 실수는 초기 구현이다. Aligner 토큰을 그냥 공백으로 이어 붙이면 된다고 생각했다. 결과는 "안녕 하세요 반갑 습니다". 구두점이 전부 사라지고 어절 내부에 공백이 들어간 괴상한 자막이 나왔다.
매핑 로직을 짜고 나서는 word character 수가 안 맞는 경우도 있었다. ASR이 인식한 텍스트와 Aligner가 받아들인 텍스트가 미묘하게 다를 수 있기 때문이다. 이건 try-except로 감싸고 실패 시 기존 방식(공백 join)으로 fallback하는 식으로 처리했다. 자막이 좀 못생겨지더라도 아예 안 나오는 것보단 낫다.
NLP 도구의 출력을 그대로 쓰면 안 되는 경우가 생각보다 많다. 특히 한국어처럼 교착어 구조를 가진 언어에서는 영어 중심으로 설계된 도구가 전제하는 "단어" 개념 자체가 맞지 않는다. Aligner는 형태소 단위로 쪼개서 타임스탬프를 주는데 한국어 사용자가 기대하는 자막은 어절 단위의 자연스러운 텍스트다.
이번에 만든 토큰-원문 매핑은 사실 NLP의 token alignment 기법을 단순화한 거다. 형태소 분석기 없이 정규식만으로 구현했는데도 실용적으로 잘 동작한다. 비슷한 문제를 겪고 있다면 원문 텍스트를 "정답지"로 삼고 토큰을 거기에 맞추는 전략을 추천한다. 한국어뿐 아니라 일본어나 중국어처럼 어절 구조가 다른 언어에서도 같은 접근이 가능할 거다.