profiledltmdrbtjd
All posts

AI를 활용한 미디어 스크립트 생성 작업

GCP에서 첫 AI 파이프라인을 구축하며 배운 것들

dltmdrbtjd10 min read

AI를 활용한 미디어 스크립트 생성 작업

들어가며

콘텐츠 구독 서비스를 운영하다 보면 "이 영상 30분짜린데 핵심만 보고 싶다"는 유저 피드백을 자주 받게 됩니다. 긴 영상·오디오 콘텐츠는 그 자체로 가치 있지만, 소비 진입장벽이라는 양면을 동시에 가지고 있죠.

이번 작업은 그 진입장벽을 AI로 낮추는 일이었습니다. 콘텐츠 업로드 시점에 백그라운드 파이프라인이 돌면서 4가지 결과물을 자동으로 만들어줍니다.

  • 3줄 요약 — 재생하지 않고도 핵심 파악
  • 전체 스크립트 + 타임스탬프 — 텍스트로 탐색, 원하는 구간 바로 이동
  • 키워드 — 주제 한눈에 확인
  • 유사 콘텐츠 추천 — 시청 후 자연스러운 다음 콘텐츠 유도

GCP 인프라에서 AI 파이프라인을 처음 직접 설계·구축해본 작업이었고, 결과적으로 콘텐츠 1건을 평균 3~5분 안에 4가지 결과물로 변환하는 시스템이 됐습니다. 다만 그 과정에서 겪은 함정이 정말 많았습니다. 이 글은 그 기록입니다.

전체 파이프라인 — 한 장 요약

md
클라이언트 → S3 업로드 (Presigned URL) ↓ 업로드 완료 알림 백엔드 → Cloud Tasks에 Job 푸시 Cloud Run Jobs 워커 ├─ 1. ffmpeg 오디오 추출 (mp3) ├─ 2. Whisper API STT + 타임스탬프 ├─ 3. Claude Haiku 3줄 요약 + 키워드 └─ 4. ada-002 임베딩 벡터 생성 Cloud SQL (PostgreSQL + pgvector) ai_status = ready → 프론트엔드 AI 기능 노출

설계 원칙은 두 가지였습니다.

  1. 기존 업로드 플로우를 건드리지 않는다. 비동기 Job으로 분리해서 콘텐츠 발행 UX를 막지 않게.
  2. 각 단계가 독립적으로 실패할 수 있다. STT는 성공했는데 요약이 실패했다면, 스크립트만 저장하고 재시도 가능하게.

한 가지 짚어둘 점은 발행 API와 워커가 분리돼 있다는 것입니다. 콘텐츠 발행 요청을 받아 큐에 넣는 /process API는 가볍게 Cloud Functions에 두고, 실제 무거운 변환 작업은 Cloud Run Jobs 워커에서 돌렸습니다. 그리고 미디어 원본은 이미 콘텐츠를 생산하는 쪽에서 S3에 저장하고 있었기 때문에, 파이프라인은 그 S3 객체를 입력으로 받는 구조가 자연스러웠습니다. 메인 인프라는 GCP인데 스토리지만 S3인 건 그래서고요.

기술 선택 — "메인 클라우드에 모든 걸 가두지 않기"

가장 먼저 부딪힌 질문은 "왜 GCP STT가 아니라 OpenAI Whisper API인가?" 였습니다.

md
| 옵션 | 30분 단가 | 한국어 품질 | |---|---|---| | **OpenAI Whisper API**| $0.18 | ★★★★★ | | GCP Speech-to-Text | $0.48 | ★★★★☆ | | AWS Transcribe | $0.36 | ★★★☆☆ | | Naver CLOVA | $0.20 | ★★★★★ |

메인 인프라가 GCP라고 해서 STT까지 GCP에 묶을 필요는 없었습니다. 콘텐츠 1건당 $0.48 → $0.18, 약 62% 절감. 단어 단위 타임스탬프도 기본 제공이라 후속 단계에서 따로 처리할 필요가 없었고요.

LLM과 임베딩도 같은 기준으로 골랐습니다.

  • 요약·키워드: Claude Haiku — 한국어 금융 콘텐츠 요약 품질 최상위, $0.001/건
  • 임베딩: OpenAI ada-002 — pgvector 레퍼런스 풍부
  • 벡터 DB: pgvector — Pinecone 같은 별도 서비스 없이 PostgreSQL 한 군데에서 JOIN까지 끝남

"메인 클라우드 = 모든 서비스를 그 안에서"라는 가정은 비용·품질 양쪽에서 손해 보기 쉽습니다. 외부 API 혼용을 두려워하지 말 것.

겪었던 함정 4가지

운영 중 #001~#017까지 총 17건의 트러블슈팅 기록이 쌓였는데, 그중 가장 임팩트가 컸던 4개만 골라봤습니다.

함정 ① — Whisper 환각(Hallucination) 방어

증상: 음성이 거의 없는 오디오에서 Whisper가 "자막 제작에 협조해주신 모든 분들께 감사드립니다" 같은 문구를 멋대로 만들어내는 현상.

처음엔 빈 결과만 막으면 된다고 생각했는데, 실제로는 그럴듯한 가짜 텍스트가 튀어나옵니다. 빈 응답보다 더 위험한 케이스죠. 유저 입장에서 "이 콘텐츠의 스크립트"라고 노출된 게 사실은 모델이 만들어낸 가짜 문장이라면, 신뢰도가 한 번에 무너집니다.

3단계 검증으로 차단했습니다.

python
# pipeline/transcribe.py def _detect_hallucination(segments): # 1. no_speech_prob ≥ 0.6인 비율이 70% 초과 → 차단 # 2. 동일 문장이 전체 50% 이상 반복 → 차단 # 3. 알려진 환각 패턴 매칭 (한국어 대표 문구 하드코딩) ...

이후 운영 중에 false positive 1건도 발견됐습니다. 발음이 약한 콘텐츠가 환각으로 차단되는 케이스였죠. 처음엔 no_speech_prob 임계값을 0.6으로 잡았는데, 운영 데이터가 쌓일수록 너무 공격적이라는 게 드러났습니다. 90일치 로그를 분석하면서 0.6 → 0.7 → … → 지금은 0.9까지 점진적으로 보수화했고, 표본 부족 케이스(MIN_SEGMENTS_FOR_RATIO=10)는 검사 자체를 스킵하도록 보정했습니다.

교훈: AI 출력은 "비어있는가"보다 "그럴듯하지만 가짜인가" 가 더 위험합니다. 그리고 차단 로직은 만들고 끝이 아니라, 운영 데이터로 임계값을 다시 튜닝하는 사이클이 필수입니다.

함정 ② — Whisper 2-pass 전사가 한국어를 영어로 만들었던 사건

"더 정확하게 만들려고" 추가한 게 오히려 품질을 무너뜨린 케이스입니다.

1차 전사 결과 500자를 다음 호출의 prompt로 넣어서 문맥을 강화하면 정확도가 올라갈 거라 가정했습니다. 결과는 정반대였습니다.

md
1차 전사: "오늘 시장 분위기는 좀 조심스럽습니다..." 2-pass 결과: "Today's market sentiment is cautious..."

한국어 콘텐츠가 영어 번역체로 출력됐습니다. Whisper 디코더가 prompt에 들어간 긴 텍스트의 패턴에 끌려간 것이었죠. API 호출은 2배, 품질은 더 나쁨. 즉시 롤백했습니다.

교훈: "더 좋게" 만드는 옵션을 추가할 때는 반드시 A/B 비교 검증부터. 직관에 따라 추가하면 비용은 2배, 품질은 마이너스가 되는 일이 흔합니다. Whisper의 prompt는 짧은 키워드 힌트 용도로만.

함정 ③ — ffmpeg URL 직접 스트리밍으로 메모리 절반

초기 구현은 평범했습니다. S3 미디어를 일단 디스크에 다운로드한 뒤 ffmpeg로 변환.

python
# Before — 전체 다운로드 후 변환 urllib.request.urlretrieve(media_url, source_path) # 1.8GB 디스크 점유 subprocess.run(["ffmpeg", "-i", source_path, "-vn", ...])

문제는 1시간짜리 1.8GB 영상에서 Cloud Run Job이 OOM으로 죽기 시작했다는 것이었습니다. 메모리를 4Gi로 올리려다가, 그 전에 한 번 더 의심해봤습니다. ffmpeg는 입력으로 URL을 직접 받을 수 있다는 사실이 떠올랐고요.

python
# After — URL에서 직접 스트리밍 추출 subprocess.run(["ffmpeg", "-i", media_url, "-vn", ...]) # 다운로드 없음
md
| 항목 | Before | After | |---|---|---| | 디스크 사용량 | ~1.8GB | ~수 MB | | Cloud Run 메모리 | 4Gi 필요 | **2Gi 충분** | | 처리 속도 | 다운로드 → 변환 (직렬) | 다운로드 + 변환 (병렬) |

코드 한 줄 차이로 인스턴스 스펙을 절반으로 내렸습니다.

교훈: 자원 부족이 보일 때 가장 먼저 의심해야 할 건 "내가 굳이 쥐고 있을 필요 없는 데이터를 디스크/메모리에 들고 있는 건 아닌가" 입니다. 스펙업은 마지막 카드.

함정 ④ — OpenAI 임베딩 API 400, 범인은 NUL 바이트

증상: Step 4(임베딩)에서 일부 콘텐츠만 400 — We could not parse the JSON body of your request. 전사·요약은 정상.

처음엔 텍스트 길이 문제인 줄 알았습니다. 토큰 한도, 한글 인코딩, SDK 버전… 다 의심해봤지만 패턴이 안 잡혔어요. 결국 실패한 텍스트를 hex dump로 확인한 순간:

md
\x00\x00... ← NUL 바이트

Whisper STT 결과나 Claude 응답에 제어 문자(\x00 등)가 섞여 들어오는 케이스가 있었습니다. OpenAI SDK가 JSON 직렬화할 때 이게 유효하지 않은 body를 만들어서 400을 뱉었던 것.

해결은 정규식 한 줄.

python
# pipeline/embed.py _CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") cleaned = _CONTROL_CHAR_RE.sub("", text) # 탭(\t), 개행(\n), CR(\r)은 유지

교훈: AI 출력 → 다른 외부 API로 전달하는 모든 경계에는 입력 검증/클리닝 레이어가 있어야 합니다. LLM 출력이 항상 깨끗한 ASCII일 거라는 가정은 운영에서 깨집니다.

작은 최적화, 큰 체감 — Process API 5.8초 → 0.3초

파이프라인이 다 돌아간 뒤에도 신경 쓰이는 부분이 있었습니다. 콘텐츠 발행 직후 호출되는 /process API의 응답이 5.8초였거든요. 발행 UX 자체는 fire-and-forget으로 차단하지 않게 만들었지만, API 호출자 입장에서 5.8초 응답은 늘 "뭔가 문제 있는 것 같다"는 인상을 남깁니다.

원인은 두 가지였습니다.

  1. Cloud Tasks 태스크 생성을 동기적으로 기다림
  2. Cloud Functions 콜드 스타트
python
# 1. 태스크 큐잉을 백그라운드 스레드로 분리 threading.Thread(target=enqueue_task, args=(...)).start() return {"status": "accepted"}
hcl
# 2. terraform — 콜드 스타트 제거 min_instance_count = 1

결과: 5.8초 → 0.3~0.5초. 약 10배 이상 빨라졌습니다.

사용자 가치 = 기능 + 응답 속도. 한쪽만 챙기면 절반만 만든 셈입니다.

결과 — 숫자로 끝내기

파이프라인 전 단계 작업 완료, 인기 콘텐츠 소급 처리 진행 중.

md
| 항목 || |---|---| | 콘텐츠 1건당 처리 비용 | **~$0.18** | | 월 운영 비용 (일 20건 기준) | **$120 ~ $170** | | 콘텐츠 1건 평균 처리 시간 | 3 ~ 5분 | | 비용 구성 | **Whisper 99% / Claude 거의 0 / 임베딩 거의 0** |

마지막 줄이 가장 흥미로웠습니다. "비용 절감 = Whisper만 보면 된다" 는 단순한 결론이 나왔거든요. 향후 Whisper OSS 자체 호스팅을 검토하기 시작한 이유입니다.

마무리 — 처음 AI 인프라를 셋업하며 배운 패턴 5가지

이번 작업은 단순히 "AI API 4개 엮어서 파이프라인 만들기"가 아니었습니다. 17건의 트러블슈팅을 거치면서 패턴이 보였습니다.

  1. 메인 클라우드에 모든 서비스를 가두지 말 것 — 외부 API 혼용으로 비용 60%대 절감.
  2. AI 출력은 "비었나"가 아니라 "그럴듯한 가짜인가"를 의심할 것 — 환각 방어가 필수.
  3. "더 좋게 만드는" 옵션은 반드시 A/B 비교 — 직관 추가는 비용 2배 + 품질 마이너스의 지름길.
  4. AI 출력 → 외부 API 전달 경계는 클리닝 레이어 필수 — 제어 문자, 길이, 인코딩 모두.
  5. 자원 부족이 보이면 스펙업 전에 "굳이 들고 있을 필요 없는 데이터"부터 의심.

처음 GCP에서 AI 파이프라인을 직접 설계해본 작업이었고, 트러블슈팅이 쌓일수록 같은 종류의 문제는 두 번 박지 않게 됐습니다. 다음에 비슷한 시스템을 만든다면, 적어도 이 함정들에서는 시간을 안 쓸 것 같습니다.

비슷한 작업을 시작하시는 분들에게 이 글이 작은 길잡이가 됐으면 좋겠습니다.

참고 자료