"녹취 파일에서 개인정보를 찾아서 마스킹해야 한다."
단순해 보이지만, 이 한 줄에는 세 가지 기술적 난관이 숨어 있습니다:
특히 3번이 핵심입니다. "김철수"라는 이름을 텍스트에서 찾았더라도, 원본 오디오에서 정확히 몇 초부터 몇 초까지인지 모르면 마스킹할 수 없습니다.

OpenAI의 원본 Whisper 대신 Faster-Whisper를 선택한 데는 세 가지 이유가 있습니다.
첫째, 속도와 메모리입니다.
CTranslate2 기반으로 추론 속도가 최대 4배 빠르고, 메모리는 절반 수준입니다. large-v3처럼 무거운 모델을 실서비스에 올려야 하는 상황에서 이 차이는 결정적이었습니다.
둘째, 양자화 지원입니다.
GPU 환경에서는 float16, CPU 환경에서는 int8로 양자화할 수 있어 별도 코드 변경 없이 환경에 맞는 최적화가 가능합니다.
셋째, Word-level Timestamps 내장 지원입니다.
개인정보 마스킹을 하려면 단어 단위의 정확한 시간 정보가 필수인데, Faster-Whisper는 이를 한 번의 호출로 제공합니다. 원본 Whisper는 별도 라이브러리가 필요한 기능입니다.
| 비교 항목 | OpenAI Whisper | Faster-Whisper |
|---|---|---|
| 추론 엔진 | PyTorch | CTranslate2 |
| 속도 | 기준 | 최대 4배 빠름 |
| 메모리 | 기준 | 약 절반 |
| 양자화 | 미지원 | int8, float16 지원 |
| Word Timestamps | 별도 라이브러리 필요 | 내장 지원 |
| VAD 필터 | 미지원 | 내장 (Silero VAD) |
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,
)
두 가지 설계 선택:
__init__이 아니라 첫 transcribe() 호출 시에 모델을 로드합니다. 서버 시작 시간을 줄이고, Whisper를 쓰지 않는 요청에는 메모리를 차지하지 않습니다.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 | 활성화 | 문맥을 반영하여 "이름은 김철수입니다"에서 "김철수" 인식률 향상 |
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},
]
이 단어별 타임스탬프가 이후 마스킹의 핵심 데이터가 됩니다.
STT로 얻은 텍스트에서 개인정보를 찾습니다. 단일 방식으로는 충분하지 않아 두 가지 방식을 병합합니다.
한국어 개인정보에 특화된 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 기반 파인튜닝)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% 정확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)가 겹치는 항목은 먼저 들어온 것(정규식 우선)을 유지하고 중복을 제거합니다.
텍스트에서 찾은 PII의 문자열 위치(start=5, end=8)를 Whisper의 시간 위치(start=2.14s, end=2.68s)로 변환해야 합니다.
문제는 Whisper가 항상 깔끔하게 단어를 분리하지 않는다는 것입니다:
이를 해결하기 위해 3단계 매칭 전략을 구현했습니다.
# 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
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
전화번호 "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
타임스탬프 매칭이 끝나면, 해당 구간을 1kHz 사인파(비프음) 로 교체합니다.
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)
Whisper의 타임스탬프는 밀리초 단위의 반올림 오차가 있습니다. "김철수"의 실제 발화가 2.135s~2.684s인데 Whisper가 2.14s~2.68s로 보고하면, 발화의 시작 5ms와 끝 4ms가 마스킹되지 않습니다.
이를 방지하기 위해 양쪽 25ms씩 안전 버퍼를 추가합니다:
원본 타임스탬프: 2.140s ─────────── 2.680s
안전 버퍼 적용: 2.115s ─────────── 2.705s
←25ms→ ←25ms→
25ms는 사람이 인지하기 어려운 시간이지만, 개인정보 누출을 방지하기에는 충분합니다.
타임스탬프 계산 결과 마스킹 구간이 50ms 미만이면, 비프음이 너무 짧아 들리지 않을 수 있습니다. 최소 50ms를 보장합니다:
if end_ms - start_ms < 50:
end_ms = max(start_ms + 50, end_ms)
이 파이프라인은 비디오 파일에도 동일하게 적용됩니다. 비디오 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)
비디오 마스킹 시에는 역으로, 마스킹된 오디오 트랙을 원본 영상에 다시 합성합니다.
Whisper의 자동 언어 감지(language=None)는 오디오 첫 30초를 분석합니다. 녹취 파일이 무음이나 배경음악으로 시작하면 영어로 오인식되어 전체 텍스트가 깨집니다. 한국어 서비스라면 반드시 고정하세요.
vad_filter=True는 무음 구간을 건너뛰어 속도를 크게 향상시킵니다. 하지만 타임스탬프가 미세하게 밀리는 부작용이 있을 수 있습니다. 이를 안전 버퍼(25ms)로 보완했습니다.
CPU 환경에서 int8 양자화를 적용하면:
EC2 CPU 인스턴스에서도 실용적인 속도로 운영할 수 있었던 핵심 요인입니다.
3가지 매칭 방법을 정확히 일치 → 부분 매칭 → 다중 단어 순서로 시도합니다. 순서를 바꾸면 "김철수"를 "김철수이고"의 부분 매칭으로 먼저 잡아서 불필요한 비율 계산이 발생합니다.
이 파이프라인의 핵심을 정리하면:
"음성에서 개인정보를 지운다"는 단순한 목표 뒤에, Whisper의 단어 분리 특성을 이해하고 정확한 시간 매핑을 구현하는 것이 가장 큰 기술적 챌린지였습니다.