무색
기술블로그
에세이
연구
소개

무색

소프트웨어로 비즈니스의 가능성을 만듭니다. 웹·앱 개발, 음성 AI, 자동화 콘텐츠 제작까지 — 기술이 필요한 곳에 무색이 있습니다.

연락처

contact@museck.com

사업자 정보

상호: 무색

대표: 배성재

사업자등록번호: 577-58-00836

인천광역시 연수구 인천타워대로 323, 에이동 8층 801-802호 AB-132 (송도동, 송도 센트로드)

© 2026 무색. All rights reserved.
개인정보처리방침·이용약관·연락처
INCHEON, KR
Forced Aligner 토큰에서 원문 복원하기 — pencil-sketch 스타일 키 비주얼
자막 파이프라인
2026. 1. 16.

Forced Aligner 토큰에서 원문 복원하기: 한국어 자막의 자연스러운 줄바꿈

forced-alignmentsubtitlekorean-nlpsrttoken-alignment

Forced Aligner 출력으로 SRT 자막을 만들었더니 결과가 처참했다. "안녕하세요, 반갑습니다."가 "안녕 하세요 반갑 습니다"로 바뀌어 있었다. 구두점은 어디 갔고 띄어쓰기는 왜 저 모양인가. Qwen3-ForcedAligner가 내부적으로 구두점을 제거하고 어절까지 쪼개서 토큰을 뱉는다는 걸 그때서야 알았다.

문제는 하나 더 있었다. 자막 줄바꿈을 글자 수로만 끊으니 문장 한가운데서 뚝 잘리거나, 어절이 반으로 쪼개지는 일이 비일비재했다. 이걸 두고 볼 수는 없어서 원문 텍스트 기반 복원과 한국어 문법 인식 분할 두 가지를 구현하게 됐다.

Aligner가 뱉는 토큰은 원문이 아니다

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()로 원문에서 해당 구간을 잘라낸다. 구두점과 띄어쓰기가 살아있는 원본 그대로의 텍스트를 얻을 수 있다.

갭(Gap)으로 분할 지점을 결정한다

토큰 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"(?:서|고|며|면서|지만|는데|니까|면|으면|도록|거나|든지|하여|되어)$"
)

문제는 오탐이다. "서울에서"의 "서"를 연결어미 "-서"로 오인하는 경우가 대표적. 이걸 막기 위해 두 가지 조건을 걸었다.

  1. 어절 경계(공백)가 있는 위치에서만 판단한다. 어절 내부에서는 아예 검사를 안 한다.
  2. 매칭 대상은 토큰이 아니라 원문 텍스트에서 역추적한 어절 전체다. 토큰 "서"만 보면 판단이 안 되지만 원문에서 "서울에서"라는 전체 어절을 복원하면 이게 조사 "-에서"의 일부인지 연결어미 "-서"인지 좀 더 정확하게 구분된다.

물론 완벽하지는 않다. 형태소 분석기를 붙이면 더 정확하겠지만 자막 생성이라는 맥락에서 정규식 수준이면 충분히 쓸만했다.

삽질 기록

가장 바보 같았던 실수는 초기 구현이다. Aligner 토큰을 그냥 공백으로 이어 붙이면 된다고 생각했다. 결과는 "안녕 하세요 반갑 습니다". 구두점이 전부 사라지고 어절 내부에 공백이 들어간 괴상한 자막이 나왔다.

매핑 로직을 짜고 나서는 word character 수가 안 맞는 경우도 있었다. ASR이 인식한 텍스트와 Aligner가 받아들인 텍스트가 미묘하게 다를 수 있기 때문이다. 이건 try-except로 감싸고 실패 시 기존 방식(공백 join)으로 fallback하는 식으로 처리했다. 자막이 좀 못생겨지더라도 아예 안 나오는 것보단 낫다.

배운 것

NLP 도구의 출력을 그대로 쓰면 안 되는 경우가 생각보다 많다. 특히 한국어처럼 교착어 구조를 가진 언어에서는 영어 중심으로 설계된 도구가 전제하는 "단어" 개념 자체가 맞지 않는다. Aligner는 형태소 단위로 쪼개서 타임스탬프를 주는데 한국어 사용자가 기대하는 자막은 어절 단위의 자연스러운 텍스트다.

이번에 만든 토큰-원문 매핑은 사실 NLP의 token alignment 기법을 단순화한 거다. 형태소 분석기 없이 정규식만으로 구현했는데도 실용적으로 잘 동작한다. 비슷한 문제를 겪고 있다면 원문 텍스트를 "정답지"로 삼고 토큰을 거기에 맞추는 전략을 추천한다. 한국어뿐 아니라 일본어나 중국어처럼 어절 구조가 다른 언어에서도 같은 접근이 가능할 거다.

자주 묻는 질문

Forced Aligner가 한국어 구두점을 삭제하는 이유는?
Forced Aligner는 오디오와 텍스트를 음소 단위로 정렬하는데, 구두점은 발음되지 않으므로 내부적으로 제거합니다. 원문 복원이 필요하면 별도 매핑 로직을 구현해야 합니다.
한국어 자막 줄바꿈에서 연결어미 감지가 왜 중요한가요?
한국어는 쉼표 없이도 '-서', '-고', '-면' 같은 연결어미로 절이 구분됩니다. 이 위치에서 줄바꿈하면 글자 수 기반보다 자연스러운 자막이 됩니다.
토큰-원문 매핑은 다른 언어에도 적용할 수 있나요?
네, word character 순서 매칭 방식이라 일본어나 중국어 같은 교착어/비분절 언어에서도 같은 접근이 가능합니다.
자막 파이프라인(5/10)
Prev

subprocess 환경변수, 기본값이 함정이다

Next

CLI 자막 도구를 웹 서비스로: n8n OOM에서 직접 라우팅까지