Faster-Whisper로 오디오 속 개인정보 찾아 지우기

오늘내일·2026년 2월 21일

Audio

목록 보기
1/1
post-thumbnail

1. 문제 정의

"녹취 파일에서 개인정보를 찾아서 마스킹해야 한다."

단순해 보이지만, 이 한 줄에는 세 가지 기술적 난관이 숨어 있습니다:

  1. 음성 → 텍스트 변환: 한국어 음성을 정확하게 텍스트로 변환해야 한다
  2. 개인정보 탐지: 변환된 텍스트에서 이름, 전화번호, 주소 등을 찾아야 한다
  3. 타임스탬프 매핑: 텍스트에서 찾은 개인정보의 위치를 원본 오디오의 시간 구간으로 역매핑해야 한다

특히 3번이 핵심입니다. "김철수"라는 이름을 텍스트에서 찾았더라도, 원본 오디오에서 정확히 몇 초부터 몇 초까지인지 모르면 마스킹할 수 없습니다.


2. 전체 파이프라인


3. Faster-Whisper: 왜 OpenAI Whisper가 아닌가

3.1 Faster-Whisper vs OpenAI Whisper

OpenAI의 원본 Whisper 대신 Faster-Whisper를 선택한 데는 세 가지 이유가 있습니다.

첫째, 속도와 메모리입니다.
CTranslate2 기반으로 추론 속도가 최대 4배 빠르고, 메모리는 절반 수준입니다. large-v3처럼 무거운 모델을 실서비스에 올려야 하는 상황에서 이 차이는 결정적이었습니다.

둘째, 양자화 지원입니다.
GPU 환경에서는 float16, CPU 환경에서는 int8로 양자화할 수 있어 별도 코드 변경 없이 환경에 맞는 최적화가 가능합니다.

셋째, Word-level Timestamps 내장 지원입니다.
개인정보 마스킹을 하려면 단어 단위의 정확한 시간 정보가 필수인데, Faster-Whisper는 이를 한 번의 호출로 제공합니다. 원본 Whisper는 별도 라이브러리가 필요한 기능입니다.

비교 항목OpenAI WhisperFaster-Whisper
추론 엔진PyTorchCTranslate2
속도기준최대 4배 빠름
메모리기준약 절반
양자화미지원int8, float16 지원
Word Timestamps별도 라이브러리 필요내장 지원
VAD 필터미지원내장 (Silero VAD)

3.2 모델 초기화

class WhisperSTT:
    """Whisper 모델을 이용한 음성→텍스트 변환"""

    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance._initialized = False
        return cls._instance

    def _load_model(self):
        """무거운 모델 지연 로딩"""
        if self.model is not None:
            return

        from faster_whisper import WhisperModel

        device = "cuda" if torch.cuda.is_available() else "cpu"
        compute_type = "float16" if device == "cuda" else "int8"
        self.model = WhisperModel(
            "large-v3", device=device, compute_type=compute_type,
            download_root=download_root,
        )

두 가지 설계 선택:

  • 싱글톤 패턴: large-v3 모델은 약 3GB입니다. 요청마다 로딩하면 수십 초가 걸리므로, 프로세스 생애 주기 동안 한 번만 로드합니다.
  • 지연 로딩: __init__이 아니라 첫 transcribe() 호출 시에 모델을 로드합니다. 서버 시작 시간을 줄이고, Whisper를 쓰지 않는 요청에는 메모리를 차지하지 않습니다.
  • 양자화 전략: GPU가 있으면 float16, CPU 환경이면 int8로 양자화하여 메모리와 속도를 최적화합니다.

3.3 Transcribe 파라미터 튜닝

segments, info = self.model.transcribe(
    audio_path,
    language='ko',              # 한국어 고정 (자동 감지보다 정확)
    word_timestamps=True,       # 단어별 타임스탬프 (마스킹 핵심)
    vad_filter=True,            # Silero VAD로 무음 구간 스킵
    no_speech_threshold=0.4,    # 기본값 0.6보다 민감하게
    log_prob_threshold=-1.0,    # 낮은 확률 발화도 인식
    beam_size=5,                # 빔 서치 (정확도↑ 속도↓)
    condition_on_previous_text=True,  # 이전 문맥 반영
)

각 파라미터의 선택 이유:

파라미터이유
language='ko'한국어 고정자동 감지 시 초반 무음에서 영어로 인식되는 문제 방지
word_timestamps=True활성화PII 마스킹의 전제조건. 단어별 시작/끝 시간 필요
vad_filter=True활성화Silero VAD로 무음 구간을 건너뛰어 처리 속도 향상
no_speech_threshold=0.4기본값(0.6)보다 낮게작은 목소리, 배경 소음 속 발화도 놓치지 않기 위해
beam_size=5기본값 유지정확도와 속도의 균형점. 개인정보는 오탐보다 미탐이 위험
condition_on_previous_text=True활성화문맥을 반영하여 "이름은 김철수입니다"에서 "김철수" 인식률 향상

3.4 출력 형태

Whisper의 출력을 단어 단위로 파싱합니다:

for segment in segments:
    for word in segment.words:
        all_words.append({
            'word': word.word.strip(),    # "김철수"
            'start': word.start,           # 2.14 (초)
            'end': word.end,               # 2.68 (초)
            'probability': word.probability # 0.92
        })

실제 출력 예시:

[
    {word: "제",     start: 0.00, end: 0.24, probability: 0.95},
    {word: "이름은", start: 0.24, end: 0.72, probability: 0.91},
    {word: "김철수", start: 2.14, end: 2.68, probability: 0.88},
    {word: "이고",   start: 2.68, end: 2.96, probability: 0.93},
    {word: "전화번호는", start: 3.12, end: 3.84, probability: 0.90},
    {word: "010",   start: 3.84, end: 4.20, probability: 0.87},
    {word: "1234",  start: 4.20, end: 4.68, probability: 0.85},
    {word: "5678",  start: 4.68, end: 5.24, probability: 0.83},
    {word: "입니다", start: 5.24, end: 5.60, probability: 0.94},
]

이 단어별 타임스탬프가 이후 마스킹의 핵심 데이터가 됩니다.


4. PII 탐지 — NER + Regex 하이브리드

STT로 얻은 텍스트에서 개인정보를 찾습니다. 단일 방식으로는 충분하지 않아 두 가지 방식을 병합합니다.

4.1 KoELECTRA NER (딥러닝 기반)

한국어 개인정보에 특화된 NER 모델을 사용합니다:

from app.ml.pii_detectors.dl_detector import KoELECTRAPIIDetector
ner_results = self.pii_detector.detect_pii(full_text)
# → [{text: "김철수", label: "p_nm", start: 5, end: 8, confidence: 0.92}]
  • 모델: ParkJunSeong/PIILOT_NER_Model (KoELECTRA 기반 파인튜닝)
  • 강점: 문맥을 고려하여 "서울시 강남구 테헤란로 123"처럼 연속된 주소를 하나의 엔티티로 인식
  • 약점: 정형 패턴(전화번호, 주민등록번호)에서는 정규식보다 불안정

4.2 정규식 기반 탐지

from app.ml.pii_detectors.regex_detector import GeneralizedRegexPIIDetector
regex_results = self.regex_detector.detect_all(full_text)
# → [{text: "010-1234-5678", label: "p_ph", start: 23, end: 36, confidence: 1.0}]
  • 강점: 전화번호(010-XXXX-XXXX), 주민등록번호(XXXXXX-XXXXXXX), 이메일 등 정형 패턴에 100% 정확
  • 약점: 문맥 무관하므로 "123-456-7890"이 전화번호인지 일련번호인지 구분 불가

4.3 결과 병합 및 중복 제거

merged = regex_results + ner_results
merged.sort(key=lambda x: x['start'])

unique_pii = []
for item in merged:
    if not any(is_overlapping(item, existing) for existing in unique_pii):
        unique_pii.append(item)

NER과 정규식이 같은 개인정보를 동시에 찾으면 텍스트 위치가 겹칩니다. 위치(start, end)가 겹치는 항목은 먼저 들어온 것(정규식 우선)을 유지하고 중복을 제거합니다.


5. 타임스탬프 매칭 — 가장 어려운 부분

텍스트에서 찾은 PII의 문자열 위치(start=5, end=8)를 Whisper의 시간 위치(start=2.14s, end=2.68s)로 변환해야 합니다.

문제는 Whisper가 항상 깔끔하게 단어를 분리하지 않는다는 것입니다:

  • PII "김철수"가 Whisper 단어 "김철수"와 정확히 일치하면 좋지만
  • "김철수이고"처럼 붙어 나오거나
  • "010", "1234", "5678"처럼 전화번호가 여러 단어로 쪼개질 수 있습니다

이를 해결하기 위해 3단계 매칭 전략을 구현했습니다.

5.1 방법 1: 정확히 일치

# PII 텍스트가 Whisper 단어와 완벽히 일치
for word in words:
    if word['word'].strip() == pii_text:
        start_time = word['start']
        end_time = word['end']
        break
PII: "김철수"  →  Whisper: {word: "김철수", start: 2.14, end: 2.68}
결과: start_time=2.14, end_time=2.68

5.2 방법 2: 부분 매칭 (비율 계산)

PII가 Whisper 단어의 일부일 때, 문자 비율로 시간을 보간합니다:

# "김철수이고"에서 "김철수"만 찾기
pii_start_in_word = word_text.find(pii_text)      # 0
pii_end_in_word = pii_start_in_word + len(pii_text) # 3
word_total_chars = len(word_text)                    # 5 ("김철수이고")
word_duration = word['end'] - word['start']          # 0.54s

ratio_start = 0 / 5  # 0.0
ratio_end = 3 / 5    # 0.6

start_time = word['start'] + (word_duration * 0.0)  # 2.14s
end_time = word['start'] + (word_duration * 0.6)     # 2.46s
Whisper: {word: "김철수이고", start: 2.14, end: 2.68}
PII:     "김철수" (앞 60%)
결과:    start_time=2.14, end_time=2.46

5.3 방법 3: 여러 단어에 걸치는 경우

전화번호 "010-1234-5678"이 Whisper에서 "010", "1234", "5678"으로 나뉘면:

# 연속된 단어들을 하나씩 합치면서 PII 텍스트가 나타나는지 확인
pii_no_space = pii_text.replace(' ', '')  # "01012345678"
combined_text = ""

for word in words:
    combined_text += word['word'].strip()
    if pii_no_space in combined_text.replace(' ', ''):
        # 첫 번째 매칭 단어의 start ~ 마지막 매칭 단어의 end
        start_time = matched_words[0]['start']
        end_time = matched_words[-1]['end']
        break
Whisper: [{word:"010", start:3.84}, {word:"1234", start:4.20}, {word:"5678", end:5.24}]
PII:     "010-1234-5678"
결과:    start_time=3.84, end_time=5.24

6. 오디오 마스킹 — 정밀한 "삐-" 처리

타임스탬프 매칭이 끝나면, 해당 구간을 1kHz 사인파(비프음) 로 교체합니다.

6.1 마스킹 프로세스

audio = AudioSegment.from_file(audio_path)
masked_segments = []
current_time = 0

MASK_VOLUME = -25       # 삐- 소리 볼륨 (대화보다 약간 낮게)
SAFE_BUFFER_MS = 25     # 25ms 안전 버퍼

for item in detected_items:
    start_ms = max(0, int(item['start_time'] * 1000) - SAFE_BUFFER_MS)
    end_ms = min(int(item['end_time'] * 1000) + SAFE_BUFFER_MS, len(audio))
    
    # 마스킹 전 구간 (원본 유지)
    if current_time < start_ms:
        masked_segments.append(audio[current_time:start_ms])
    
    # PII 구간 → 비프음으로 교체
    duration = end_ms - start_ms
    tone = Sine(1000).to_audio_segment(duration=duration, volume=MASK_VOLUME)
    masked_segments.append(tone)
    
    current_time = end_ms

# 나머지 구간 (원본 유지)
if current_time < len(audio):
    masked_segments.append(audio[current_time:])

final_audio = sum(masked_segments)

6.2 안전 버퍼 (Safety Buffer)

Whisper의 타임스탬프는 밀리초 단위의 반올림 오차가 있습니다. "김철수"의 실제 발화가 2.135s~2.684s인데 Whisper가 2.14s~2.68s로 보고하면, 발화의 시작 5ms와 끝 4ms가 마스킹되지 않습니다.

이를 방지하기 위해 양쪽 25ms씩 안전 버퍼를 추가합니다:

원본 타임스탬프:  2.140s ─────────── 2.680s
안전 버퍼 적용:   2.115s ─────────── 2.705s
                   ←25ms→           ←25ms→

25ms는 사람이 인지하기 어려운 시간이지만, 개인정보 누출을 방지하기에는 충분합니다.

6.3 최소 구간 보장

타임스탬프 계산 결과 마스킹 구간이 50ms 미만이면, 비프음이 너무 짧아 들리지 않을 수 있습니다. 최소 50ms를 보장합니다:

if end_ms - start_ms < 50:
    end_ms = max(start_ms + 50, end_ms)

7. 비디오 통합 — ffmpeg 오디오 추출

이 파이프라인은 비디오 파일에도 동일하게 적용됩니다. 비디오 PII 탐지 시 영상(얼굴, 화면 텍스트)과 음성을 각각 처리하는데, 음성 부분이 이 Whisper 파이프라인입니다:

# video_detector.py에서 오디오 추출
extract_cmd = [
    'ffmpeg', '-i', input_video_path,
    '-vn',                    # 영상 스트림 제거
    '-acodec', 'libmp3lame',  # MP3 인코딩
    '-y', audio_extract_path
]
subprocess.run(extract_cmd, check=True, capture_output=True)

# 추출된 오디오로 동일한 파이프라인 실행
audio_detections = self.audio_detector.detect(audio_extract_path)

비디오 마스킹 시에는 역으로, 마스킹된 오디오 트랙을 원본 영상에 다시 합성합니다.


8. 실전에서 배운 것들

8.1 language='ko' 고정이 필수

Whisper의 자동 언어 감지(language=None)는 오디오 첫 30초를 분석합니다. 녹취 파일이 무음이나 배경음악으로 시작하면 영어로 오인식되어 전체 텍스트가 깨집니다. 한국어 서비스라면 반드시 고정하세요.

8.2 VAD 필터의 양면성

vad_filter=True는 무음 구간을 건너뛰어 속도를 크게 향상시킵니다. 하지만 타임스탬프가 미세하게 밀리는 부작용이 있을 수 있습니다. 이를 안전 버퍼(25ms)로 보완했습니다.

8.3 CTranslate2 int8 양자화의 효과

CPU 환경에서 int8 양자화를 적용하면:

  • 메모리: ~3GB → ~1.5GB (약 50% 절감)
  • 속도: 약 2~3배 향상
  • 정확도: 체감 차이 거의 없음 (WER 기준 1% 미만 차이)

EC2 CPU 인스턴스에서도 실용적인 속도로 운영할 수 있었던 핵심 요인입니다.

8.4 타임스탬프 매칭 순서가 중요

3가지 매칭 방법을 정확히 일치 → 부분 매칭 → 다중 단어 순서로 시도합니다. 순서를 바꾸면 "김철수"를 "김철수이고"의 부분 매칭으로 먼저 잡아서 불필요한 비율 계산이 발생합니다.


9. 마무리

이 파이프라인의 핵심을 정리하면:

  1. Faster-Whisper: CTranslate2 기반으로 원본 Whisper 대비 4배 빠르고 메모리 절반. Word-level 타임스탬프 내장.
  2. 하이브리드 PII 탐지: KoELECTRA NER(문맥 기반)과 정규식(패턴 기반)을 병합하여 커버리지 확보.
  3. 3단계 타임스탬프 매칭: 정확 일치 → 부분 비율 보간 → 다중 단어 결합으로 Whisper의 불규칙한 단어 분리에 대응.
  4. 안전 버퍼 마스킹: 25ms 버퍼와 50ms 최소 구간으로 타임스탬프 오차에 의한 개인정보 누출 방지.

"음성에서 개인정보를 지운다"는 단순한 목표 뒤에, Whisper의 단어 분리 특성을 이해하고 정확한 시간 매핑을 구현하는 것이 가장 큰 기술적 챌린지였습니다.


참고 자료

0개의 댓글