Meloming
· 62 min read

노래클립 자동생성 파이프라인 - 방송 녹화, STT, Agent 판정

조현우
조현우

CEO & Fullstack Engineer

1. 만들려던 것

스트리머가 방송에서 신청곡을 부릅니다. 그 한 곡만 잘라서 클립으로 남기면, 시청자는 나중에 그 노래만 다시 들을 수 있고 스트리머는 별도 편집 없이 아카이브를 갖게 됩니다.

말로는 간단합니다. 실제로는 이런 문제가 겹칩니다.

  • 방송은 몇 시간 이어지고, 그 안의 어느 구간이 그 곡인지 아무도 표시해주지 않습니다.
  • 노래는 신청이 접수된 시각에 시작하지 않습니다. 스트리머가 잡담하다가 몇 분 뒤에 부르기도 합니다.
  • 신청곡 제목마저 틀릴 수 있습니다. 신청은 A곡으로 받고 실제로는 B곡을 부르는 경우가 드물지 않습니다.
  • 방송이 끝나야 잘라낼 수 있는데 그때는 이미 원본이 수십 GB입니다.
  • 이 전체가 사람 개입 없이 돌아야 합니다.

이 글은 녹화에서 클립 게시까지의 전 과정과 그 사이에 만난 사고를 정리한 기록입니다.


2. 전체 구조

flowchart TB
    LIVE["라이브 방송<br/>chzzk / soop / cime"] --> REC["recording Job<br/>yt-dlp + ffmpeg HLS 세그먼트"]
    REC --> S3[("S3 녹화 버퍼 버킷<br/>sessions 프리픽스 아래 seg_NNNNN.ts")]
    S3 --> EXT["extract Job<br/>세그먼트 다운로드 + 오디오 추출"]
    EXT --> STT["stt-server<br/>faster-whisper large-v3 GPU 추론"]
    STT --> AG["gpt-5.4 세션 단위 에이전트<br/>가사 매칭 / 키프레임 / 구간 청취"]
    AG --> GATE["post-hoc 검증 게이트<br/>evidence + 자모 3-gram"]
    GATE --> CUT["ffmpeg 구간 추출<br/>clips 프리픽스 아래 mp4 + jpg"]
    CUT --> CB["백엔드 콜백"]
    CB --> PUB["신뢰도 + 길이 게이트<br/>자동 게시"]
    ORC["orchestrator Deployment<br/>HTTP API + Job CRUD"] -.->|"Job 생성 / 삭제 / 조회"| REC
    ORC -.-> EXT
    CRON["extract-batch CronJob"] -.->|"pending 세션 배치 트리거"| ORC

핵심은 오케스트레이터가 직접 일하지 않는다는 점입니다. HTTP API를 받고 Kubernetes Job을 만들고 상태를 확인할 뿐, ffmpeg를 돌리거나 세그먼트 파일을 만지지 않습니다. server.py가 노출하는 것은 다섯 개뿐입니다.

TEXT
POST /recording/start            -> recording-{sid} Job 생성
POST /recording/stop/{sid}       -> recording-{sid} Job graceful 삭제
GET  /recording/sessions         -> active recording Job 목록
POST /recording/clips/extract    -> extract-{sid}-{ts} Job 생성
GET  /health

처음부터 이랬던 것은 아닙니다.


3. 파드 하나가 모든 걸 하던 시절

처음 만든 구조는 단일 파드였습니다. 녹화 세션 정보를 프로세스 메모리에 들고, 그 안에서 ffmpeg를 띄우고, 클립 추출도 같은 파드에서 했습니다.

Python
s3 = boto3.client("s3", region_name="ap-northeast-2")
sessions: dict = {}

이 한 줄이 나중에 문제가 됩니다. 그때는 몰랐습니다.

이 시절에도 안전장치는 있었습니다. 오히려 지금 봐도 필요한 장치였습니다.

Python
MAX_CONCURRENT = int(os.environ.get("MAX_CONCURRENT_SESSIONS", "30"))
WATCHDOG_IDLE_SECONDS = int(os.environ.get("WATCHDOG_IDLE_SECONDS", "300"))
WATCHDOG_CHECK_INTERVAL = int(os.environ.get("WATCHDOG_CHECK_INTERVAL", "30"))

동시 녹화 상한은 30개입니다. 그 이상 요청이 오면 503으로 거절합니다. 와치독은 30초마다 돌면서, 새 세그먼트가 300초(5분) 넘게 안 떨어진 세션을 자동으로 정지시킵니다. 스트리머가 방송을 껐는데 종료 이벤트가 안 왔을 때 ffmpeg가 죽은 m3u8을 무한 폴링하는 것을 막기 위한 장치입니다.

세 번째 안전장치는 가장 비싼 값을 치르고 배웠습니다.

Python
# ffmpeg stderr 를 file 로 redirect. PIPE 로 두면 buffer (~65KB) 차서 block 됨
# (4분 stuck 의 진짜 원인). file 로 redirect 하면 ffmpeg 가 무한히 write 가능하고
# 디버깅 시 log 검사도 가능.
log_path = work / "ffmpeg.log"
log_file = open(log_path, "wb")

녹화가 4분쯤 지나면 멈추는 증상이 있었습니다. 원인은 ffmpeg도 네트워크도 아니었습니다. ffmpeg의 stderr를 파이프로 받아놓고 아무도 읽지 않았기 때문입니다. 파이프 버퍼가 약 65KB에서 가득 차면 write가 블록되고, 그 순간 ffmpeg 프로세스 전체가 멈춥니다. HLS 세그먼트가 4분 정도 쌓이면 ffmpeg의 진행 로그가 딱 그만큼 나옵니다.

stderr를 안 읽는 것은 로그를 버리는 것이 아니라 프로세스를 정지시키는 행위입니다. 그래서 이 리다이렉트는 지금도 건드리지 않는 코드로 남아 있습니다.

파드가 축출된 사고

단일 파드 구조에서 사고가 났습니다.

클립 추출 작업이 파드의 EmptyDir 한도인 5Gi를 넘겼습니다. kubelet은 규칙대로 파드를 축출했습니다. 그리고 그 순간 진행 중이던 녹화 세션 4건이 전부 사라졌습니다.

세션 상태가 sessions 딕셔너리, 즉 프로세스 메모리에만 있었기 때문입니다. 파드가 죽으면 어느 세션이 어디까지 녹화했는지 아무도 모릅니다. 이어서 할 방법 자체가 없었습니다.

여기서 두 가지 문제가 동시에 드러났습니다.

격리가 없었습니다. 추출 작업의 디스크 사용이 무관한 녹화 작업을 죽였습니다. 수명도 자원 요구도 완전히 다른 두 종류의 작업이 한 파드를 공유하고 있었습니다. 녹화는 몇 시간 동안 CPU를 조금 쓰면서 계속 살아 있어야 하고, 추출은 몇십 분 동안 수십 GB의 디스크와 여러 코어를 쓰고 끝납니다.

상태가 휘발성이었습니다. 재시작 후 복구할 근거가 아무 데도 남지 않았습니다.

방향은 분명했습니다. 녹화도 추출도 전부 Kubernetes Job으로 내리고, 메인 파드는 그 Job을 만들고 상태를 확인하고 다시 살리는 역할만 맡는 것입니다.


4. 두 개의 Job과 하나의 오케스트레이터

그래서 구조를 바꿨습니다. 녹화 하나당 Job 하나, 추출 하나당 Job 하나입니다.

세 컴포넌트는 같은 이미지를 쓰고 command만 다릅니다.

Dockerfile
# Default command: orchestrator (HTTP API + K8s Job CRUD).
# Recording / extract Job 의 container 는 K8s Job spec 에서 command 로 override:
#   command: ["recording-job"]   (recording_worker.recording_job:main)
#   command: ["extract-job"]     (recording_worker.extract_job:main)
CMD ["recording-orchestrator"]

이미지를 하나로 유지하면 S3 유틸리티나 ffmpeg 바이너리 같은 공통 자산을 세 번 관리하지 않아도 됩니다. 배포도 이미지 태그 하나만 굴리면 됩니다.

자원 요구가 다른 부분은 Job 매니페스트에서 갈립니다.

recording Jobextract Job
requestscpu 300m / mem 512Micpu 500m / mem 1Gi
limitscpu 1 / mem 2Gicpu 4 / mem 8Gi
EmptyDir sizeLimit5Gi30Gi
terminationGracePeriod300초120초
backoffLimit66

사고를 낸 5Gi는 그대로 두고, 추출 쪽만 30Gi로 올렸습니다. 한도를 올린 것이 해결이 아니라, 한도를 넘긴 작업이 다른 작업을 죽이지 못하게 만든 것이 해결입니다.

Job 스펙에는 몇 가지 장치가 더 들어갑니다.

Python
"backoffLimit": 6,
"podFailurePolicy": {
    "rules": [{
        "action": "FailJob",
        "onExitCodes": {
            "containerName": "recording",
            "operator": "In",
            "values": [1],
        },
    }]
},
"ttlSecondsAfterFinished": 600,

종료 코드로 재시도 여부를 나눕니다. recording_job.py는 코드 1을 치명적 초기화 실패(환경변수 누락 등)로, 코드 2를 ffmpeg 비정상 종료로 씁니다. 코드 1은 재시도해봐야 똑같이 실패하므로 podFailurePolicy가 Job 자체를 즉시 실패시킵니다. 코드 2는 라이브가 잠깐 끊겼거나 yt-dlp가 일시적으로 실패한 경우이므로 재시도가 의미 있습니다. 재시도 가치가 없는 실패에 재시도 예산을 쓰지 않는 것이 이 규칙의 목적입니다.

ttlSecondsAfterFinished가 600초인 데는 이유가 있습니다. Job 파드에는 karpenter.sh/do-not-disrupt: "true" 어노테이션이 붙어 있습니다. 녹화 중인 파드를 Karpenter가 노드 통합 과정에서 죽이면 안 되기 때문입니다. 그런데 이 어노테이션은 파드가 종료 상태가 되어도 남아 있어서, 끝난 파드가 온디맨드 노드를 계속 붙잡습니다. TTL을 짧게 잡아 끝난 Job이 노드를 고정하지 못하게 했습니다.

권한을 나눕니다

RBAC와 IRSA를 두 갈래로 나눴습니다.

주체Kubernetes 권한AWS 권한
오케스트레이터 서비스 계정jobs/pods CRUD RoleIRSA로 버킷 읽기/쓰기
Job 파드 서비스 계정없음별도 IRSA (같은 버킷 권한)

실제 작업을 하는 쪽이 클러스터를 조작할 수 있으면 그 작업이 다루는 외부 입력이 곧 클러스터의 공격면이 됩니다. 녹화 Job은 외부 스트리밍 플랫폼의 HLS를 받고, 추출 Job은 LLM 응답을 받아 ffmpeg 인자로 씁니다. 둘 다 Kubernetes API를 만질 이유가 없습니다.

버킷 이름도 오케스트레이터 환경변수에서 Job으로 전파합니다. 녹화 버퍼 버킷은 QA와 프로덕션이 서로 다릅니다. Job 매니페스트를 코드로 빌드하는 구조라, 이 전파를 빼먹으면 QA Job이 프로덕션 버킷에 쓰게 됩니다. 실제로 첫 배포 직전 이 누락을 잡아 고쳤습니다.

한 가지 함정이 있었습니다. 여러 서비스가 함께 쓰는 Helm 차트의 selectorLabelsapp.kubernetes.io/component가 포함되어 있었습니다. values에서 app.component를 바꾸자 Deployment의 selector immutable 제약을 위반해 ArgoCD sync가 다섯 번 연속 실패했습니다. 차트를 고치는 것은 그 차트를 쓰는 전 서비스에 영향이 가므로, component 값을 원복하는 쪽을 택했습니다. 공용 차트의 수정 비용은 그 차트 사용자 수에 비례합니다.

추출 Job에 큰 입력을 전달하는 법

추출 Job은 그 세션의 신청곡 목록 전체를 받아야 합니다. 곡마다 제목, 아티스트, 재생 시각, 가사 본문, 트랙 길이가 들어갑니다. 가사가 붙으면 환경변수로 넣기에는 너무 큽니다.

그래서 payload를 ConfigMap 파일로 마운트합니다.

Python
EXTRACT_PAYLOAD_MOUNT_DIR = "/var/run/recording-worker/extract"
EXTRACT_PAYLOAD_FILENAME = "song_requests.json"

ConfigMap을 먼저 만들고 Job을 만든 다음, ConfigMap에 Job을 ownerReference로 붙입니다. 이렇게 하면 Job이 TTL로 정리될 때 ConfigMap도 가비지 컬렉션됩니다. 순서가 반대면 Job 생성이 실패했을 때 ConfigMap이 고아로 남습니다. 그 경우를 대비해 Job 생성 실패 시 ConfigMap을 명시적으로 지우는 경로도 있습니다.


5. 재시작을 별도로 구현하지 않았습니다

Job 패턴으로 바꾸면서 이어하기 기능을 따로 만들 필요가 없어졌습니다.

녹화 Job은 세그먼트를 sessions/{session_id}/seg_00000.ts 형식으로 순번을 붙여 S3에 올립니다. 파드가 죽고 Job이 재시도되면 새 파드의 진입점에서 이렇게 합니다.

Python
last_num = await loop.run_in_executor(
    None, s3_utils.last_segment_number, BUCKET, session_id
)
start_number = (last_num + 1) if last_num is not None else 0

playlist_local = work / "playlist.m3u8"
had_playlist = await loop.run_in_executor(
    None, s3_utils.download_playlist_if_exists, BUCKET, session_id, playlist_local
)
hls_flags = ["-hls_flags", "+append_list"] if had_playlist else []

last_segment_number는 S3 prefix를 페이지네이터로 훑으면서 ^seg_(\d{5})\.ts$에 맞는 키의 최대 번호를 찾습니다. download_playlist_if_exists는 기존 playlist.m3u8를 내려받고, 404면 새 녹화로 판단합니다.

그리고 ffmpeg에 그대로 넘깁니다.

TEXT
ffmpeg -y -i <m3u8> -c copy -f hls
       -hls_time 6 -hls_list_size 0
       -hls_segment_filename seg_%05d.ts
       -start_number <N+1>
       -hls_flags +append_list
       playlist.m3u8

-start_number N+1은 세그먼트 번호를 이어서 붙이고, -hls_flags +append_list는 기존 플레이리스트를 덮어쓰지 않고 뒤에 붙입니다. 두 인자가 전부입니다.

상태를 저장소에 두면 이어하기는 별도 기능이 아니라 시작 절차의 일부가 됩니다. 재시작 로직을 설계했다기보다 상태의 위치를 프로세스 메모리에서 S3로 옮기자 재시작이 자연스러워진 쪽에 가깝습니다.

플레이리스트가 반쯤 죽어 있는 경우

이어하기를 만들자 그다음 문제가 보였습니다. ffmpeg가 SIGTERM을 받으면 플레이리스트를 truncate한 뒤 다시 쓰는데, 그 사이에 죽으면 0바이트이거나 #EXTINF 항목이 없는 껍데기가 남습니다. 실제로 프로덕션 세션 하나가 이 상태였습니다.

이 경우 S3에 올라간 세그먼트 목록에서 플레이리스트를 재구성합니다.

Python
if "#EXTINF:" not in text:
    seg_keys = await loop.run_in_executor(
        None, s3_utils.list_segment_keys, BUCKET, session_id,
    )
    lines = ["#EXTM3U", "#EXT-X-VERSION:3",
             "#EXT-X-TARGETDURATION:6", "#EXT-X-MEDIA-SEQUENCE:0"]
    for name in seg_names:
        lines.append("#EXTINF:6.000000,")
        lines.append(name)
    lines.append("#EXT-X-ENDLIST")

세그먼트 길이는 -hls_time 6으로 지정했으므로 6초로 가정합니다. 정확하지는 않지만 추출 단계에서 실제 duration은 ffprobe로 다시 재기 때문에 문제가 되지 않습니다.

#EXT-X-ENDLIST도 중요합니다. ffmpeg가 SIGINT가 아닌 방식으로 갑자기 죽으면 이 마커가 안 붙습니다. 마커가 없는 m3u8은 HLS demuxer가 라이브 스트림으로 인식해서, 추출 Job이 끝나지 않는 매니페스트 리로드 폴링에 빠집니다. drain 직전에 반드시 붙입니다.

중단 지점에서 재개하는 기능을 만들면 중단이 남긴 반쯤 쓰인 파일도 같이 처리해야 합니다. 이어하기는 마지막 세그먼트 번호를 아는 것만으로 끝나지 않습니다.

이후 오케스트레이터는 계속 얇아졌습니다. S3 히스토리 조회, HLS 매니페스트 서빙, 클립 presigned URL 발급을 전부 백엔드의 RecordingStorageService로 넘기고 오케스트레이션만 남겼습니다. server.py가 651줄에서 94줄로 줄었습니다.


6. 첫 번째 경계 찾기: 가사와 STT를 겹칩니다

여기가 이 파이프라인에서 가장 어려운 부분입니다. 몇 시간짜리 녹화에서 노래 한 곡의 시작과 끝을 찾아야 합니다.

신청이 접수된 시각은 힌트일 뿐입니다. 스트리머가 오버레이에서 “재생 시작”을 누른 시점과 실제 첫 소절이 나오는 시점은 다르고, “완료”를 누르는 시점은 더 다릅니다. 아예 안 누르는 경우도 있습니다.

접근은 이렇습니다. 구간을 STT로 전사하면 단어마다 타임스탬프가 붙습니다. 한편 곡의 가사는 이미 데이터베이스에 있습니다. 가사의 각 줄이 전사된 단어 흐름의 어디에 있는지 찾으면, 첫 줄의 시작과 마지막 줄의 끝이 곧 곡의 경계입니다.

먼저 정규화합니다.

Python
def normalize_for_match(text: str) -> str:
    """공백/구두점 제거. 한국어는 띄어쓰기 변동 큼."""
    return "".join(ch for ch in text if ch.isalnum())

한국어는 띄어쓰기 변동이 커서 공백을 유지한 채 비교하면 손해만 봅니다. STT가 “그리워 하다가”로 뽑고 가사는 “그리워하다가”인 경우가 흔합니다.

그다음 각 가사 줄을 단어 스트림 위에서 슬라이딩 윈도우로 퍼지 매칭합니다.

Python
for i in range(len(words)):
    window_text = ""
    for j in range(i, len(words)):
        window_text += words[j].text
        norm_window = normalize_for_match(window_text)
        if len(norm_window) >= target_len * 0.5:
            score = int(fuzz.partial_ratio(norm_line, norm_window))
            if score > best.score:
                best = LineMatch(line=line, score=score,
                                 start=words[i].start, end=words[j].end)
        if len(norm_window) > target_len * 1.5:
            break

윈도우 규칙이 두 개 있습니다. 윈도우가 가사 줄 길이의 0.5배 미만이면 점수를 재지 않고, 1.5배를 넘으면 확장을 멈춥니다.

앞의 규칙은 partial_ratio의 성질 때문입니다. 부분 문자열 매칭이라 짧은 윈도우는 우연히 높은 점수를 받기 쉽습니다. “그대여”라는 세 글자가 긴 가사 줄 어딘가에 들어 있으면 점수가 튀어오릅니다. 뒤의 규칙은 계산량 때문입니다. 이 이중 루프는 단어 수의 제곱에 비례하는데 조기 종료가 없으면 한 세션 전사 결과가 수천 단어일 때 감당이 안 됩니다.

임계값은 65입니다.

Python
# whisper-1 word timestamp 가 K-pop 영어 발음 차이로 부정확할 수 있어
# 65 가 검증된 threshold.
MATCH_THRESHOLD = 65

100점 만점에 65는 낮아 보입니다. 이유가 있습니다. 한국어 노래에 섞인 영어 가사는 전사가 자주 어긋납니다. “shine”이 “샤인”으로, “love”가 “럽”으로 나옵니다. 임계값을 80으로 올리면 정확한 줄만 남고 대부분의 줄이 탈락합니다. 매칭 줄 수가 줄면 경계 추정 자체가 불가능해집니다.

임계값을 높이는 것은 정확도를 높이는 것이 아니라 재현율을 버리는 것입니다. 여기서 필요한 것은 개별 줄의 정확도보다 곡 전체를 덮는 매칭 집합이었습니다. 개별 오매칭은 다음 단계에서 걸러집니다.


7. 후렴이 만드는 함정과 LIS

여기서 실제로 문제가 되는 것이 후렴입니다.

후렴은 곡 안에서 여러 번 반복됩니다. 그래서 가사 3번 줄이 곡 후반부의 반복 구간에 잘못 매칭될 수 있습니다. 점수만 보면 그게 최고점일 수도 있습니다. best_window_match는 줄마다 최고점 하나만 반환하는데, 그 하나가 틀린 위치일 수 있다는 뜻입니다.

이 오매칭 하나가 경계 전체를 망칩니다. 시작점이 곡 후반으로 잡히거나 끝점이 앞으로 당겨집니다.

여기에 쓸 수 있는 제약이 하나 있습니다.

곡을 한 번 재생하는 동안, 가사 줄의 순서와 시간의 순서는 같습니다.

앞줄이 뒷줄보다 늦게 나올 수는 없습니다. 그러니 매칭 결과 중 타임스탬프가 단조증가하는 가장 긴 부분수열만 남기면 순서를 깨는 오매칭이 자동으로 떨어져 나갑니다. 최장 증가 부분수열 문제 그대로입니다.

Python
def _lis_by_timestamp(matches):
    n = len(matches)
    if n == 0:
        return []
    dp = [1] * n
    parent = [-1] * n
    for i in range(n):
        for j in range(i):
            if (
                matches[j][1].start is not None
                and matches[i][1].start is not None
                and matches[j][1].start < matches[i][1].start
                and dp[j] + 1 > dp[i]
            ):
                dp[i] = dp[j] + 1
                parent[i] = j
    end = max(range(n), key=lambda k: dp[k])
    seq = []
    cur = end
    while cur != -1:
        seq.append(matches[cur])
        cur = parent[cur]
    seq.reverse()
    return seq

O(n log n) 구현이 있다는 것은 알고 있습니다. 여기서는 쓰지 않았습니다. 가사 줄 수는 수십 개 수준이고 병목은 이 함수가 아니라 그 앞의 슬라이딩 윈도우와 그 앞의 STT 호출입니다. 도메인의 물리적 제약을 필터로 번역한 것이 성과였지, 알고리즘 복잡도를 낮춘 것이 성과는 아니었습니다.

그리고 마지막에 확신이 없으면 포기합니다.

Python
valid_indexed = [
    (i, m) for i, m in enumerate(matches)
    if m.score >= MATCH_THRESHOLD and m.start is not None
]
if len(valid_indexed) < max(3, len(lines) * 0.4):
    return None

lis = _lis_by_timestamp(valid_indexed)
if len(lis) < max(3, len(valid_indexed) * 0.5):
    return None

조건이 두 겹입니다. 가사 줄의 40% 미만만 매칭되면 포기합니다. 매칭이 충분해도 그중 절반도 순서가 일관되지 않으면 포기합니다. max(3, ...)가 붙은 것은 가사 줄이 대여섯 줄뿐인 짧은 곡에서 비율 조건이 무의미해지는 것을 막기 위함입니다.

None을 받은 호출자는 다른 근거로 넘어갑니다. 틀린 경계를 자신 있게 내놓는 것보다 모른다고 말하는 편이 낫습니다. 잘못된 경계는 조용히 이상한 클립을 양산하고 그 클립은 사람이 하나씩 보기 전에는 발견되지 않습니다.

PoC 검증 결과

이 알고리즘은 experiments/stt-lyrics-match/에서 먼저 검증하고 프로덕션 모듈로 옮겼습니다.

기준 케이스는 cime 플랫폼의 한 세션에서 뽑은 아이유의 Modern Times였습니다. 결과는 이랬습니다.

지표
매칭된 가사 줄37줄 중 33줄 (89%)
LIS 채택33줄 중 23줄
추정 경계36초 ~ 3분 33초
단순 방식 경계1분 24초 ~ 3분 33초

LIS가 유효 매칭 33개 중 10개를 잘라낸 것이 보입니다. 그 10개가 후렴 오매칭입니다. 시작점은 1분 24초에서 36초로 48초 당겨졌습니다. 단순 방식은 매칭 점수가 가장 높은 줄부터 잡느라 인트로와 첫 절을 통째로 놓치고 있었습니다.

배치 검증은 세 세션의 아홉 곡으로 돌렸습니다. chzzk 세션 둘(하나는 마크툽/한로로/박원으로 전부 한국어, 다른 하나는 아이유/HoneyWorks/(G)I-DLE/DECO*27/안예은으로 한국어와 일본어가 섞인 구성)과 위의 기준 세션입니다. 일본어 곡을 일부러 섞은 것은 언어 힌트가 바뀌었을 때 매칭률이 무너지는지 보기 위해서였습니다.

전사 비용은 4분 클립 하나에 0.024달러였습니다. PoC 스크립트가 STT 결과를 JSON으로 캐시할 수 있게 만든 것은 이 때문입니다. 알고리즘을 고칠 때마다 Whisper API를 다시 부르면 비용도 시간도 감당이 안 됩니다. 실험 스크립트의 캐시는 편의 기능이 아니라 반복 횟수를 결정하는 장치입니다.

한 가지 구조적 한계가 남았습니다. 이 알고리즘은 가사가 있어야 동작하는데 당시 곡 가사 확보율이 28,605곡 중 4,883곡, 즉 **17%**였습니다. 나머지 83%에서는 매칭할 참조 자체가 없습니다. 알고리즘의 정확도가 아니라 입력의 부재가 상한이었습니다.


8. 결정론으로는 부족했습니다

PoC는 통과했는데 프로덕션에서 막혔습니다. 두 가지 이유였습니다.

첫째, 입력 구간이 너무 넓었습니다. PoC는 이미 4분에서 7분으로 잘린 클립을 입력으로 받았습니다. 실제로는 스트리머의 마킹이 부정확해서 한 신청곡의 윈도우가 10분에서 30분에 이르는 경우가 있었습니다. 그 안에는 다른 곡과 잡담이 섞여 있고 가사 매칭은 이런 긴 오디오에서 무너집니다.

둘째, 라벨 자체가 틀렸습니다. 신청은 A곡으로 받아놓고 실제로는 B곡을 부릅니다. 이 경우 A곡의 가사로 아무리 매칭해도 나오는 것이 없습니다. 알고리즘은 정직하게 None을 반환하고 클립은 만들어지지 않습니다.

가사 매칭은 “이 구간이 이 곡인가”를 검증할 수는 있어도 “이 구간이 무슨 곡인가”를 답할 수는 없습니다. 그래서 LLM 에이전트를 붙였습니다.

단곡 정밀화에서 세션 단위 분석으로

boundary.py는 첫 시도였습니다. 대략 잘라낸 클립 하나를 STT로 전사하고 전사 결과와 가사와 트랙 길이를 gpt-5.4에 주고 시작과 끝을 물어봅니다.

전사 결과를 프롬프트에 넣을 때는 잘라서 넣습니다.

Python
def _build_transcript_text(words: list[Word], max_words: int = 800) -> str:
    """LLM input 용 transcript. [Ms] word 형식."""
    sample = words[:max_words]
    return "\n".join(f"[{w.start:.1f}s] {w.text}" for w in sample)

800 단어입니다. 4분에서 7분 클립이면 대부분 여기에 들어갑니다. 넘치는 경우가 있어도 곡의 앞부분만 있으면 시작점은 잡히고, 끝점은 트랙 길이로 검증합니다.

응답은 Pydantic 모델로 강제했습니다.

Python
class BoundaryResult(BaseModel):
    start_sec: float | None
    end_sec: float | None
    confidence: Literal["high", "medium", "low"]
    reason: str

confidence가 low이거나 시작과 끝 중 하나가 null이면 호출자가 데이터베이스 타임스탬프로 폴백합니다. 자유 텍스트가 아니라 타입이 있는 출력을 받으면 모델이 모른다고 답할 자리를 스키마에 만들어둘 수 있습니다.

그래도 라벨 문제는 풀리지 않았습니다. 한 곡씩 처리하는 한, 이 곡의 윈도우에 다른 곡이 섞여 있다는 사실 자체를 알 수 없기 때문입니다. 그래서 extract_job.pyagent.py에서 세션 전체를 한 번에 보는 구조로 다시 설계했습니다.

에이전트가 받는 것은 이렇습니다.

  • 세션 전체 오디오의 전사 결과(단어마다 타임스탬프)
  • 60초 간격 키프레임 이미지, 최대 100장
  • 신청곡 목록 전체(제목, 아티스트, 재생 시각 오프셋, 가사, 트랙 길이)

그리고 도구 네 개를 씁니다.

도구용도
musixmatch_search곡 검색으로 실제 곡 식별
musixmatch_lyrics추정한 곡의 가사를 다시 가져와 대조
web_search (Serper)Musixmatch에 없는 곡의 외부 정보
audio_inspect의심 구간을 gpt-audio-mini로 직접 청취

audio_inspect가 결정적입니다. 전사 결과만으로는 “노래가 끝나고 반주가 이어지는 중”과 “노래가 끝나고 잡담이 시작된 상태”를 구분할 수 없습니다. 둘 다 전사에는 아무것도 안 나오거나 잡음만 나오기 때문입니다. 시스템 프롬프트는 모든 곡의 경계 결정 전에 이 도구를 최소 두 번 호출하도록 강제하고, 질문 형식까지 지정합니다.

이 구간에서 (1) 마지막 가사 후 자연 코다(반주)가 끝나는 timestamp (2) 토크/잡담이 시작되는 timestamp 를 각각 초 단위로 답하라. 형식: ‘가창 종료=XX초, 토크 시작=YY초’.

“가창 종료”와 “토크 시작” 사이가 5초 이상이면 종료점을 가창 종료 + 3초로 잡으라고 지시합니다. 모호한 프롬프트는 모호한 답을 받습니다.

시스템 프롬프트에는 전사 타임스탬프에 대한 경고도 들어 있습니다.

transcript 의 [start-end] timestamp 는 faster-whisper word_timestamps (DTW 부산물)로 한국어 음악 환경에서 1초에서 3초 부정확하다. coarse anchor 로만 신뢰하고 boundary 결정은 audio_inspect / lyrics line 매칭으로 직접 검증할 것.

이 경고는 관측에서 나왔습니다. 자체 STT로 전환한 뒤 집계에서 클립 길이의 p90이 33% 늘고 high 신뢰도 판정이 71% 줄었습니다. 모델이 단어 타임스탬프를 그대로 믿고 종료점을 다음 곡이나 멘트까지 늘리는 것으로 보였습니다. 이 숫자는 나중에 다시 검증하는데 결론이 달라집니다. 9절에서 이어집니다.

잘못 게시된 클립 하나가 알려준 것

에이전트를 붙이고 자동 게시를 켠 직후, 클립 하나가 잘못 게시됐습니다.

라벨은 “네모네모 / 최예나”였고 에이전트는 신뢰도 high에 label_mismatch=false를 줬습니다. 실제로 그 구간에서 부른 곡은 다른 곡이었습니다.

판정 근거를 따라가 보니 이랬습니다. 4020초 지점 키프레임의 오버레이에 곡명이 떠 있었고, 4200초 지점에 가사가 부분적으로 맞았습니다. 이 둘을 근거로 4045초에서 4238초를 곡 구간으로 추정했습니다. 오버레이는 스트리머가 신청 정보를 그대로 띄운 것이라 라벨이 틀리면 같이 틀리고, 부분 가사 매칭은 흔한 관용구에서도 발생합니다. 둘 다 약한 근거인데 둘을 합쳐 high가 나온 것입니다.

원인을 파고들다 보니 더 근본적인 문제가 있었습니다. 시스템 프롬프트 어디에도 high가 무엇인지 정의되어 있지 않았습니다. 신뢰도 필드를 만들어놓고 그 등급의 기준을 적지 않았으니, 모델은 자기 나름의 기준으로 채웠습니다. 모델을 탓할 일이 아니었습니다.

그래서 두 가지를 동시에 했습니다. 프롬프트에 high의 정의를 조건 두 개로 명시했고, 코드가 그 조건을 강제로 검사하게 만들었습니다.

그리고 코드가 다시 검증합니다

에이전트가 high를 주면 그대로 게시되기 때문에 코드 단에서 두 겹의 게이트를 통과시킵니다.

첫 번째는 스키마 게이트입니다. 출력 모델에 evidence_sources 필드를 두고, high를 주려면 두 가지 근거가 모두 있어야 한다고 정의했습니다.

Python
REQUIRED_HIGH_EVIDENCES = frozenset(
    ["transcript_lyric_match_3plus", "audio_inspect_confirm"]
)

전사 구간에 그 곡의 가사가 3줄 이상 연속으로 매칭됐다는 근거와 오디오를 직접 들어보고 그 곡이 맞다고 확인했다는 근거입니다. 키프레임 오버레이에 곡명이 떠 있다는 것은 단독으로 high 근거가 되지 않습니다. 오버레이는 스트리머가 신청 정보를 그대로 띄우는 것이라, 라벨이 틀렸으면 오버레이도 같이 틀립니다.

근거가 빠지면 medium으로 강등합니다. 시스템 프롬프트에는 이 사실을 명시했습니다. “evidence_sources 누락 시 코드 단에서 high에서 medium으로 자동 강등됨. 정직하게 채울 것.” 모델에게 정직하라고 요청하는 것과 정직하지 않으면 통과하지 못하게 만드는 것은 다릅니다.

두 번째는 가사 겹침 게이트입니다. 여기서는 LLM을 전혀 쓰지 않습니다.

Python
LANG_THRESHOLD = {
    "ko": 0.15,
    "en": 0.25,
    "ja": 0.18,
    "zh": 0.18,
    "default": 0.20,
}
MIN_SEGMENT_LEN_CHARS = 30
MIN_REFERENCE_LEN_CHARS = 100

에이전트가 정한 구간의 전사 텍스트와 검증된 가사를 문자 3-gram Jaccard 유사도로 비교합니다. 한국어는 NFD로 자모 분해한 뒤 비교합니다. “안녕”과 “안뇽”은 음절 단위로는 완전히 다른 문자지만, 자모로 분해하면 상당 부분 겹칩니다. STT가 발음을 조금 다르게 적었을 때 이 차이가 그대로 점수에 반영되는 것을 막습니다.

임계값이 언어마다 다른 것은 자모 분해의 효과가 언어마다 다르기 때문입니다. 한국어는 분해 후 3-gram 공간이 조밀해져 점수가 낮게 깔리므로 0.15, 영어는 알파벳이 그대로라 0.25로 잡았습니다.

길이 조건도 있습니다. 구간 전사가 30자 미만이거나 참조 가사가 100자 미만이면 검증할 근거가 부족하다고 보고 low로 강등합니다. 검증할 수 없는 것을 통과시키는 것과 떨어뜨리는 것 중에서는 떨어뜨리는 쪽이 안전합니다. low는 삭제가 아니라 관리자 검수 큐로 가는 것이므로 되살릴 수 있습니다.

게이트를 통과한 신뢰도와 에이전트가 원래 준 신뢰도는 둘 다 백엔드로 보냅니다. 게이트가 얼마나 자주 발동하는지, 어느 언어에서 많이 걸리는지를 나중에 볼 수 있어야 하기 때문입니다.

마지막으로 백엔드에 길이 게이트가 하나 더 있습니다.

TypeScript
const CLIP_MIN_DURATION_SEC = 120;
const CLIP_MAX_DURATION_SEC = 360;

2분 미만이거나 6분 초과인 클립은 신뢰도와 무관하게 low로 떨어뜨립니다. 2분 미만은 부르다 만 것이고 6분 초과는 경계가 잘못 잡혀 잡담이 섞였을 가능성이 높습니다. 도메인 지식을 숫자 두 개로 압축한 규칙입니다.


9. 이상할 때만 다시 확인합니다

모든 결과를 다시 검증하면 비용이 두 배가 됩니다. 그래서 사전에 정한 이상 조건에 걸린 것만 재확인합니다.

Python
def _should_verify(result, track_length_sec) -> bool:
    if result.start_sec is None or result.end_sec is None:
        return False
    duration = result.end_sec - result.start_sec
    if result.confidence == "low":
        return True
    if track_length_sec is not None and track_length_sec > 0:
        return duration > track_length_sec * 1.15
    return duration > 360.0

Musixmatch에서 가져온 트랙 길이가 있으면 그 길이의 1.15배를 넘을 때만 검증합니다. 트랙 길이를 모르면 6분을 기준으로 씁니다. 신뢰도가 low면 무조건 검증합니다. 검증 비용이 있는 시스템에서 전수 검증은 설계 실패이고, 조건이 없는 샘플링은 근거 없는 안심입니다.

검증기를 고르는 과정

검증기 후보는 세 개였습니다. silero VAD, wav2vec2 강제 정렬, Qwen 기반 정렬 모델입니다. 조건은 하나였습니다. 외부 API를 쓰지 않는 로컬 전용이어야 합니다. 이 시점에 외부 전사 API 지출이 월 1,500달러 규모까지 갔던 것이 그 조건의 배경입니다.

40개 클립으로 만든 기준 세트에서 재봤습니다.

후보종료점 차이 중앙값판정
silero VAD0.02초채택
wav2vec20.03초탈락
Qwen 정렬-36.40초탈락

Qwen은 음악 구간에서는 -3.56초로 그럴듯했는데 발화 구간에서 -33.48초로 무너졌습니다. 전사 전체를 윈도우로 주면 -45.0초에서 +58.2초까지 흔들렸습니다. 이 정도 분산이면 신호가 아니라 잡음입니다.

wav2vec2가 흥미로웠습니다. 숫자만 보면 silero와 거의 같습니다. 그런데 탈락시켰습니다. wav2vec2에 넣는 입력이 faster-whisper가 뽑은 마지막 10개 단어이기 때문입니다. 검증하려는 대상이 만든 결과를 다시 그 대상에게 물어보는 셈이라, 새로운 정보가 하나도 추가되지 않습니다. 숫자가 좋았던 이유가 정확해서가 아니라 원본과 같은 것을 보고 있어서였습니다.

두 신호가 일치한다는 사실이 검증이 되려면 두 신호가 서로 독립이어야 합니다. silero는 전사 결과를 전혀 안 보고 원본 오디오의 파형만 봅니다. 그래서 남겼습니다.

검증 자체는 그래서 LLM 대신 silero VAD로 합니다. 추출 Job이 에이전트가 정한 종료점 기준으로 앞 60초 뒤 5초를 잘라 STT 서버의 /v1/boundary/verify에 보내면, 서버가 그 65초 안에서 마지막으로 음성이 있었던 지점을 돌려줍니다.

Python
TRIGGER_THRESHOLD = 5.0
SANITY_BOUND = 30.0

VAD가 찾은 지점과 에이전트의 종료점이 5초 이내면 within_tolerance로 그냥 통과시킵니다. VAD 쪽이 5초 이상 앞서면 corrected로 종료점을 당깁니다. 차이가 30초를 넘으면 VAD 신호 자체를 못 믿겠다고 보고 sanity_violation으로 거절합니다.

40개 클립으로 돌린 PoC 결과는 이랬습니다.

결과비율
corrected (보정됨)12.5%
within_tolerance (그대로 통과)55%
silero_null (음성 구간 없음)25%
sanity_violation (신호 거절)7.5%

보정이 필요한 경우가 여덟에 하나였습니다. 이 비율이라면 전수 검증은 낭비고, 조건부 검증이 맞습니다.

클라이언트 쪽에도 방어가 있습니다. 서버가 보정값을 줘도 그대로 쓰지 않습니다.

Python
if not (agent_end - 60 <= new_end <= agent_end + 5):
    return None, "sanity_violation_post"
if new_end <= agent_start + 30:
    return None, "sanity_violation_too_short"

보정 결과가 클립을 30초 미만으로 만들면 거절합니다. 서버가 올바른 프로토콜로 올바른 형식의 답을 줘도, 그 답이 말이 되는지는 별개 문제입니다.

서킷 브레이커도 붙어 있습니다. 5xx가 연속 5번 나면 30초 동안 아예 호출하지 않습니다. 회로가 열려 있는 동안에는 에이전트의 원래 판정을 그대로 씁니다. 타임아웃도 프로토콜 오류도 마찬가지입니다. 검증기는 보조 신호이므로 검증기가 죽었을 때 파이프라인이 같이 죽으면 안 됩니다.

여기서도 재미있는 함정이 하나 있었습니다. silero의 첫 GPU 추론 지연이 16.6초였습니다. 클라이언트의 총 타임아웃이 15초라 첫 호출이 무조건 실패합니다. 모듈 임포트 시점에 0.1초짜리 더미로 워밍업을 하고 있었는데, 그 길이로는 실제 부하가 흡수되지 않았습니다.

Python
warmup = torch.zeros(
    int(SR * (WINDOW_PRE_DEFAULT + WINDOW_POST_DEFAULT)), dtype=torch.float32
)

워밍업 더미를 실제 윈도우 길이인 65초로 바꾸자 첫 실제 호출이 warm path를 타서 중앙값 0.94초, p95 0.96초가 됐습니다. 워밍업은 함수를 한 번 부르는 절차가 아니라 실제 입력 크기로 한 번 부르는 절차입니다.

그런데 전제가 틀렸습니다

이 검증기를 만든 출발점은 8절에서 언급한 숫자였습니다. 자체 STT 전환 후 클립 길이 p90이 33% 늘고, 6분 초과 클립이 3%에서 11.1%로 3.7배가 됐다는 관측입니다.

검증기를 붙인 뒤 프로덕션 클립 테이블로 다시 재봤습니다. 결과가 달랐습니다.

지표
클립 길이 p90 (전환 전)319초
클립 길이 p90 (전환 후)296초
6분 초과 비율 (전환 전)7.08%

전환 후가 오히려 짧았습니다. 6분 초과 비율도 애초에 3%가 아니라 전환 전에 이미 7%였습니다. 전환 전 클립 중에는 트랙 길이의 4.72배짜리도 있었습니다.

즉 종료점 과확장은 STT 전환으로 생긴 회귀가 아니라 처음부터 있던 결함이었습니다. 원래의 33%와 71%는 프로덕션 테이블에서 잰 숫자가 아니었습니다. 수동 재추출과 분석 도구를 섞은 다른 측정 경로에서 나온 값이었습니다.

가설이 틀렸다고 해서 만든 것이 쓸모없어지지는 않았습니다. 검증기는 12.5%의 클립에서 실제로 종료점을 당겼고, 그건 회귀 여부와 무관하게 개선입니다. 다만 “전환이 회귀를 만들었다”는 서술을 고치지 않고 뒀다면, 다음 사람이 전환을 되돌리는 잘못된 결론에 도달했을 것입니다.

측정 경로가 다르면 같은 이름의 지표라도 다른 숫자가 나옵니다. 원인을 단정하기 전에 결론을 내릴 그 테이블에서 직접 재는 것이 맞습니다.


10. STT를 우리 클러스터로 옮깁니다

여기까지의 전사는 OpenAI Whisper API였습니다. 클립 4분에 0.024달러입니다. 단곡 클립 하나를 다시 전사할 때는 문제가 아니었는데, 세션 전체를 전사하는 구조로 바꾸면서 비용 구조가 바뀌었습니다.

일주일치 OpenAI 지출을 뜯어봤습니다.

항목금액비중
transcribe_long (whisper-1 전사)96.21달러61%
audio_inspect (gpt-audio-mini 구간 청취)37.99달러24%
에이전트 (gpt-5.x)14.09달러9%
그 외9.66달러6%
합계 (7일)157.95달러

월 환산 680달러입니다. 피크였던 하루는 오디오 71시간을 처리하며 51달러가 나갔고, API 호출은 443회, 시간당 약 18건이었습니다.

전사가 61%였습니다. 그리고 전사는 셋 중 유일하게 대체 가능한 항목이었습니다. 에이전트의 추론 능력이나 오디오 청취는 다른 모델로 바꾸면 품질이 흔들리지만, 전사는 같은 faster-whisper 모델을 우리가 돌리면 됩니다.

대안을 훑었습니다. 분당 단가로 whisper-1이 0.006달러, gpt-4o-mini-transcribe가 0.003달러, Deepgram nova-2가 0.0043달러, Groq의 whisper-large-v3-turbo가 0.000666달러였습니다. 자체 호스팅은 서울 리전 g4dn.xlarge 스팟 기준 시간당 0.2314달러로 상시 가동하면 월 195달러, 유휴 시 0으로 줄이면 월 28달러였습니다.

그래서 speaches-ai/speaches 이미지를 사내 컨테이너 레지스트리로 미러링해 클러스터 안에서 faster-whisper large-v3를 돌리기로 했습니다. OpenAI 호환 API를 그대로 제공하므로 STT_BASE_URL 환경변수만 바꾸면 전환됩니다.

이때 IaC 저장소 다섯 개를 전부 뒤져보니 nvidia.com/gpu라는 문자열이 0회 나왔습니다. 회사에 GPU 워크로드가 하나도 없었다는 뜻입니다. Karpenter NodePool, 두 종류의 taint, NVIDIA device plugin, dcgm-exporter를 처음부터 깔아야 했습니다. 인스턴스 타입도 g4dn으로 고정됐습니다. g5와 g6은 가용 용량이 2b와 2d 가용 영역에 있는데 우리 프라이빗 서브넷은 2a와 2c에만 있었고, g6 스팟은 중단율이 20%를 넘었습니다.

그런데 그대로 쓰면 가사가 거의 안 나왔습니다.

프로덕션 세션 하나의 30분에서 40분 구간, 노래가 10분 이어지는 오디오로 패치를 하나씩 대조했습니다.

패치단어 수마지막 단어 종료가사 인식
v0 (기본 speaches)4228.6초멘트만
v2 (silence/max_speech 조정)3328.6초첫 줄만
v3 (VAD threshold 0.0)3228.6초변화 없음
v4 (silero 우회)3228.6초변화 없음
v5 (BatchedInferencePipeline 우회)오류TypeError
v6 (+ clip_timestamps=“0”)490600.0초가사 완전

v0부터 v4까지 마지막 단어 종료가 전부 28.6초에 멈춰 있는 것이 보입니다. 10분 오디오의 앞 28.6초만 처리하고 나머지 9분 30초를 통째로 버리고 있었습니다.

VAD 설정을 아무리 만져도 이 숫자가 안 움직였습니다. 진짜 원인은 VAD가 아니라 BatchedInferencePipeline이었습니다. speaches가 처리량을 위해 쓰는 배치 파이프라인이 30초를 넘는 오디오의 후반부를 처리하지 않았습니다. 일반 WhisperModel.transcribe로 같은 오디오를 돌리면 1,053 단어가 나옵니다.

증상이 나타나는 계층과 원인이 있는 계층이 다르면 증상 쪽 설정을 아무리 조절해도 숫자가 안 움직입니다. VAD threshold를 0.0까지 내려도 28.6초가 그대로였던 것이 그 신호였습니다.

최종 패치는 Dockerfile 안에서 sed로 소스 다섯 줄을 바꾸는 형태입니다. silero VAD 호출을 오디오 전체를 하나의 발화 구간으로 보는 상수로 치환하고, 배치 파이프라인을 일반 모델로 바꾸고, clip_timestamps"0"으로 고정합니다. 각 sed 뒤에 grep으로 치환 결과를 검증해서, 업스트림이 바뀌어 패치가 안 먹으면 빌드가 실패하게 했습니다.

비교하자면 OpenAI whisper-1은 같은 오디오에서 284 단어를 뽑았습니다. v6는 490 단어로 더 세밀합니다. 일반 한국어 발화만 처리한다면 이 패치들이 필요 없습니다. 노래와 멘트가 섞인 라이브라서 필요했습니다.

전환이 조용히 아무 일도 안 할 뻔했습니다

애플리케이션 쪽 코드 변경은 12줄 남짓이었습니다. STT 클라이언트 팩토리에 분기 5줄, import 1줄, 그리고 jobs.py의 환경변수 허용 목록에 5줄입니다.

마지막 5줄이 함정이었습니다. 추출 Job은 오케스트레이터가 코드로 빌드한 매니페스트로 뜨는데, 그 매니페스트는 명시적으로 나열한 환경변수만 전달합니다. 원래 목록에는 다섯 개뿐이었습니다.

Python
for key in (
    "OPENAI_API_KEY",
    "MUSIXMATCH_API_KEY",
    "SERPER_API_KEY",
    "RECORDING_WORKER_TOKEN",
    "MELOMING_BACK_URL",
    "STT_PROVIDER",
    "STT_BASE_URL",
    "STT_API_KEY",
    "STT_MODEL",
    ...
):

STT_* 다섯 개를 추가하지 않은 채로 배포했다면 어떻게 됐을까요? 오케스트레이터 파드에는 STT_PROVIDER=local이 잘 들어가 있고, Deployment를 확인하면 전부 정상으로 보입니다. 그런데 실제 전사를 하는 Job 파드에는 그 변수가 없으니 기본값인 openai로 동작합니다. 에러 없이, 로그도 없이, 비용이 그대로 나갑니다.

STT_MODEL도 비슷한 함정이 있었습니다. 이 변수에는 Systran/faster-whisper-large-v3 같은 HuggingFace 모델 ID가 들어갑니다. 이 값이 OpenAI 분기로 새면 OpenAI는 자기 카탈로그에 없는 모델명이라며 400을 반환합니다. 그래서 OpenAI 분기는 STT_OPENAI_MODEL이라는 별도 변수만 읽고 기본값 whisper-1을 쓰도록 나눴습니다.

환경변수를 허용 목록으로 전달하는 구조에서는 목록에 추가하는 것을 잊는 실패가 조용합니다.

그리고 상시 가동을 포기했습니다

처음 계획은 GPU 파드를 스팟으로 24시간 띄우는 것이었습니다. 월 201달러입니다.

여기서 “방송이 몰리는 시간대에만 켜자”는 아이디어가 나왔습니다. 크론으로 저녁부터 새벽까지만 GPU를 띄우는 방식입니다. 검증하려고 30일치 라이브 세션 1,216건의 시작 시각 분포를 뽑았습니다.

40.1%가 그 시간대 바깥에서 시작했습니다. 라이브 시간 기준으로도 42%가 바깥이었고, 낮에 정기적으로 방송하는 채널이 132개였습니다. 크론 창을 넓히면 넓힐수록 상시 가동에 수렴하고 좁히면 그 채널들의 클립이 안 나옵니다. 시간대별 창을 넓혀 계산해보니 오히려 상시 스팟보다 비싼 시나리오도 나왔습니다.

그래서 시간이 아니라 일감을 기준으로 켜는 쪽으로 바꿨습니다. 라이브가 끝나면 즉시 추출하지 않고 대기 상태로 표시만 하고, 배치가 모아서 처리합니다. 추출 Job이 활성 상태라는 것 자체를 KEDA의 확장 신호로 씁니다.

TEXT
sum(kube_job_status_active{namespace="<프로덕션 네임스페이스>", job_name=~"extract-.*"})

이 값이 1 이상이면 STT 서버를 띄우고, 0이 되고 쿨다운이 지나면 0으로 내립니다. 시간대 가정이 전혀 들어가지 않습니다.

대신 사용자가 기다리는 시간이 늘어납니다. 클립 게시까지 p50이 23분에서 3시간에서 4시간 사이로, p90이 18.7시간에서 6.5시간으로 바뀝니다. p90은 오히려 좋아지는데 p50이 크게 나빠지는 형태입니다. 비용은 월 201달러에서 116달러가 되고 스팟 중단은 0이 됩니다.

이건 기술적 최적화가 아니라 제품 결정입니다. “클립은 방송이 끝나고 몇 시간 뒤에 나오는 것”으로 사용자 기대를 정의할 수 있는지가 판단 기준이었고, 그 트레이드오프를 받아들이기로 하고 진행했습니다.


11. 클립이 0개가 된 날

어느 날 클립이 하나도 만들어지지 않았습니다.

처음에는 녹화 원본까지 대량으로 사라진 것처럼 보였습니다. 실제로는 원본 녹화가 S3에 그대로 있었습니다. 만료 규칙이 없어 하나도 지워지지 않은 상태였습니다. 사라진 것은 클립을 만드는 체인이었습니다.

원인은 하나가 아니라 네 겹이었습니다.

첫째, STT 서버가 영원히 준비되지 않았습니다

비용을 줄이려고 자체 GPU 서버로 옮기면서 KEDA로 유휴 시 0으로 줄이는 설정을 켰습니다. 모델 파일은 파드가 뜬 뒤 postStart 훅에서 백그라운드 curl로 내려받는 구조였습니다.

새 이미지에서 이 다운로드가 동작하지 않았습니다. preload.log가 비어 있었습니다. 모델이 없으니 /v1/models 조회가 404를 반환하고, 이 응답을 보는 readinessProbe가 영원히 통과하지 못합니다. 파드는 Ready가 되지 않고 서비스 엔드포인트에서 빠지고, 추출 Job은 connection refused를 받습니다.

이 조합에는 한 단계 전사가 있었습니다. 그전에는 반대 방향의 문제가 있었습니다. 그때는 readinessProbe가 /health를 보고 있었는데, 이 엔드포인트는 모델 설치 여부와 무관하게 200을 반환합니다. 모델 다운로드가 60초쯤 걸리는데 파드는 즉시 Ready가 되고, 그 사이에 들어온 전사 요청이 “Model not installed”로 실패합니다. 추출 Job 하나가 18회 연속 실패했습니다.

그래서 probe를 모델 설치 여부를 직접 확인하는 형태로 바꿨습니다.

YAML
readinessProbe:
  enabled: true
  httpGet: null
  exec:
    command: [sh, -c, 'curl -sf -H "Authorization: Bearer $API_KEY" http://localhost:8000/v1/models/Systran%2Ffaster-whisper-large-v3']

httpGet: null이 필요한 이유가 있습니다. 차트 기본값에 httpGet: /health가 들어 있어서, values에 exec만 추가하면 deep merge로 둘 다 렌더링됩니다. Kubernetes는 handler를 하나만 허용하므로 ArgoCD sync가 실패합니다. 명시적으로 null을 박아 차트 기본값을 무효화해야 합니다.

probe가 확인하는 것과 서비스가 실제로 필요로 하는 것이 다르면 probe는 통과 도장을 찍는 기계일 뿐입니다. 이 서버에 필요한 것은 프로세스가 살아 있는지가 아니라 모델이 로드되어 있는지였습니다.

이 과정에서 HPA와 KEDA를 동시에 켜서 admission webhook이 “deployment already managed by hpa”로 거절하는 문제도 겹쳤습니다. 차트의 autoscaling.enabled=true가 HPA를 렌더링하는데 KEDA ScaledObject도 같은 Deployment를 관리하려 들어, ArgoCD sync가 무한 재시도에 빠지고 이미지 태그가 반영되지 않았습니다.

둘째, 그 실패가 외부 API 폴백을 폭주시켰습니다

자체 서버 연결이 실패하면 OpenAI whisper-1로 넘어가게 해뒀습니다. 안전장치라고 생각했습니다.

자체 서버가 영구 NotReady가 되자 모든 전사 요청이 폴백으로 갔습니다. 라이브 전체 오디오를 10분 청크로 쪼개 부르는 구조라, 세 시간 방송 하나가 18번의 API 호출입니다. 이 호출이 밀린 세션 수만큼 곱해졌습니다. OpenAI 쿼터가 소진됐습니다.

셋째, 쿼터 소진이 무관한 단계까지 죽였습니다

경계 판정 에이전트도 같은 OpenAI 계정을 씁니다. 전사가 아니라 gpt-5.4 호출입니다. 쿼터가 마르자 에이전트 호출이 429를 받기 시작했습니다.

전사 실패가 판정 실패로 번졌습니다. 두 기능은 코드상 아무 관계가 없지만 계정이라는 자원을 공유하고 있었습니다.

이 계정에서 나가는 OpenAI 호출은 세 종류였습니다. gpt-5.4(에이전트, 항상), gpt-audio-mini(구간 청취 도구), whisper-1(전사 폴백)입니다. 세 번째가 첫 번째를 굶겼습니다.

넷째, 게시 조건이 조용히 좁아져 있었습니다

여기까지 고쳐도 클립은 게시되지 않았습니다.

TypeScript
// 자동 게시 confidence: high+medium.
// 이후 high only 로 좁혔으나 (medium 차단) agent 가 대부분 medium
// 판정이라 사실상 자동 게시 0.
const AUTO_PUBLISH_CONFIDENCE_LEVELS = new Set(['high', 'medium']);

원래는 medium까지 자동 게시하기로 결정했는데, 그 뒤 다른 개선을 넣으면서 high만 허용하도록 좁혀졌습니다. 그런데 앞 절에서 만든 evidence 스키마 게이트 때문에 에이전트 판정의 대부분이 medium입니다. high 조건이 엄격하니 medium이 많은 것이 정상인데, 게시 조건은 그 사실을 반영하지 않고 있었습니다.

결과적으로 클립 mp4는 S3에 만들어졌는데 데이터베이스의 clipId가 null인 상태로 835개가 쌓였습니다.

다섯째, 대기 목록이 영원히 처리되지 않았습니다

배치 처리 쿼리에도 문제가 있었습니다.

TypeScript
OR: [
  { extractScheduledAt: { lte: new Date() } },
  { extractScheduledAt: null },
],

OR가 원래는 없었습니다. extractScheduledAt <= NOW() 조건만 있었는데, 배치 모드를 도입하기 전에 끝난 세션은 이 필드가 null입니다. SQL에서 null <= NOW()는 참이 아니므로 이 세션들은 영구히 선택되지 않습니다. 대기 중인 1,313건 전부가 이 상태였습니다.

네 겹이 아니라 다섯 겹이었습니다. 그리고 어느 하나만 고쳐도 최종 산출물은 0 그대로였습니다.

폴백에서 배운 것

여기서 가장 크게 배운 것은 폴백이었습니다.

외부 API 폴백은 안전장치처럼 보였지만 실제로는 장애 증폭기였습니다. 자체 서버가 정상일 때는 폴백이 거의 호출되지 않으니 문제가 안 보입니다. 자체 서버가 죽는 순간 전체 트래픽이 폴백으로 쏟아지고, 폴백은 그 부하를 감당하도록 설계되지 않았습니다. 소진된 쿼터는 같은 계정을 쓰는 다른 기능까지 끌고 내려갑니다.

그래서 전사 경로의 OpenAI 폴백을 전면 제거했습니다. 네 군데의 폴백 호출을 전부 raise로 바꿨습니다.

대신 연결 실패를 재시도로 다르게 처리했습니다.

Python
except openai.APIConnectionError as e:
    # stt-server NotReady (scale-up / model-load 미완). retry 대상.
    raise STTConnectionError(f"STT connection error (provider={provider}): {e}") from e

연결 거부는 “서버가 아직 안 떴다”는 뜻이므로 기다렸다 다시 부릅니다. 타임아웃이나 모델 오류는 진짜 실패이므로 그대로 올립니다. 연결 실패와 처리 실패를 같은 예외로 다루면 기다리면 되는 것과 기다려도 안 되는 것을 구분할 수 없습니다.

그리고 모델을 이미지에 함께 굽는 쪽으로 바꿨습니다.

Dockerfile
ENV HF_HOME=/home/ubuntu/.cache/huggingface
ENV TORCH_HOME=/home/ubuntu/.cache/torch
RUN python -c "from huggingface_hub import snapshot_download; snapshot_download('Systran/faster-whisper-large-v3')" \
 && python -c "import torch; torch.hub.load('snakers4/silero-vad', 'silero_vad', trust_repo=True)"

빌드는 root로, 런타임은 ubuntu 사용자로 돌기 때문에 캐시 경로를 환경변수로 맞춰야 둘 다 같은 자리를 봅니다. 이 두 줄을 안 맞추면 굽는 데는 성공하고 런타임에는 다시 받습니다.

폴백으로 버티는 대신 원래 경로가 확실히 뜨도록 고치는 쪽을 택했습니다.


12. 되돌린 방법

체인을 고친 뒤에는 밀린 것을 되돌려야 했습니다. 진단의 핵심은 “클립 0”과 “영상 소멸”을 분리하는 것이었습니다. 원본 녹화가 S3에 남아 있으면 재처리는 STT를 다시 도는 비용일 뿐이고, 없으면 재처리해도 아무것도 안 나옵니다.

구분 기준으로 extractScheduledAt을 썼습니다. 이 값이 세팅되어 있으면 라이브 종료 훅을 거친 세션이므로 원본이 있고, null이면 녹화 기능 도입 전이거나 훅을 안 거친 세션이라 원본이 없습니다. 표본 몇 개를 실제로 추출해보니 playlist.m3u8 not found가 나와 이 가설이 확인됐습니다.

그렇게 나눈 뒤 네 갈래로 처리했습니다.

대상처리건수
mp4는 있는데 게시 안 된 클립POST admin/recording/clips/republish-unpublished769건 게시
원본 없는 대기/처리중 세션재추출 없이 completed로 직접 마킹660건
원본 있는데 처리중에 멈춘 세션pending으로 되돌려 재추출50건
이미 게시됐지만 길이 게이트 밖POST admin/recording/clips/demote-out-of-duration153건 강등

republish-unpublished는 신뢰도 정책이 복구된 뒤 이미 추출된 mp4를 STT 없이 다시 게시하는 경로입니다. 게시 판정 로직을 그대로 재호출하기 때문에 정책 일관성이 유지됩니다. 클립 키는 규칙에서 재구성합니다.

TypeScript
key: `clips/${t.liveSessionId}/${t.id}.mp4`,
thumb_key: `clips/${t.liveSessionId}/${t.id}.jpg`,

키를 규칙으로 정해두면 백필할 때 조회 없이 재구성할 수 있습니다. 키를 데이터베이스에 저장했다면 그 값이 비어 있는 이 상황에서 백필할 방법이 없었습니다.

원본 없는 660건을 completed로 직접 마킹한 것은 비용 판단입니다. 이 세션을 재추출 큐에 넣으면 추출 Job이 660개 뜨고, 전부 playlist.m3u8 not found로 끝납니다. GPU 시간과 에이전트 호출 비용이 그대로 나갑니다. 대신 추출 Job이 원본을 못 찾았을 때의 동작도 함께 고쳤습니다.

Python
except Exception as e:
    log.error("playlist/segments fetch failed: %s", e)
    # raw 없음 (오래된 session / recording 도입 전 / S3 미존재). 재시도 무의미.
    # 빈 결과 callback 으로 session extract_status='completed' 마킹 후 정상 종료.
    await _post_empty_result(session_id)
    return 0

원본이 없는 것은 재시도한다고 생기지 않습니다. 그런데 그전까지는 이 경우 Job이 실패로 끝나면서 backoffLimit만큼 재시도하고 Error 파드로 남았습니다. 132개가 그 상태였습니다. 빈 결과를 콜백으로 보내고 0으로 종료하도록 바꿔서, 이 경우가 조용히 완료 처리되게 했습니다.

재시도해도 결과가 바뀌지 않는 실패는 실패로 처리하면 안 됩니다. 재시도 메커니즘은 그 구분을 스스로 하지 못합니다.

처리 순서도 바꿨습니다.

TypeScript
orderBy: { extractScheduledAt: { sort: 'desc', nulls: 'last' } },

오름차순에 null이 앞이면 원본 없는 오래된 세션부터 큐를 소진해서, 사용자가 실제로 기다리는 최근 영상의 게시가 13라운드 이상 밀립니다. 내림차순에 null을 뒤로 보내니 최근 영상부터 나왔습니다. 백필 순서는 성능 문제가 아니라 체감 문제입니다.


13. 콜드 스타트와 재시도 예산

모델을 이미지에 굽자 이미지가 11.79GB가 됐습니다. 그다음 GPU 노드를 스팟으로 바꿨습니다.

Terraform
capacity-type = ["spot", "on-demand"]
disruption {
  budgets = [{ nodes = "100%" }]
}

g4dn.xlarge 온디맨드가 시간당 0.647달러, 스팟이 0.25달러입니다. 61% 절감입니다. 최대 4노드까지 전부 스팟으로 프로비저닝되는 것을 확인했습니다.

disruption budget을 10%에서 100%로 올린 것도 같이 했습니다. 이건 버그 수정에 가까웠습니다. 노드가 4개일 때 10%는 0.4인데 1 미만이라 빈 노드를 하나도 회수하지 않았습니다. 놀고 있는 GPU 노드가 5일 동안 남아 약 77달러가 낭비되고 있었습니다.

그런데 이 두 변경이 다음 문제를 만들었습니다.

스팟이거나 새 노드면 이미지 캐시가 없습니다. 11.79GB를 매번 전부 내려받습니다. 약 5분 걸립니다. 여기에 GPU device plugin이 노드에 등록되기까지 약 3분이 더 걸립니다. 그전까지 파드는 Insufficient nvidia.com/gpu로 스케줄되지 않습니다. 합쳐서 콜드 스타트가 5분에서 6분입니다.

budget 100%는 놀고 있는 노드를 즉시 회수하므로 이미지 캐시가 남아 있을 확률이 줄어듭니다. 비용을 줄이는 변경이 콜드 스타트 빈도를 높였습니다. 감수하고 가야 하는 트레이드오프입니다. 무엇을 내주고 무엇을 얻었는지 함께 알고 있지 않으면 나중에 결과만 보고 원인 불명의 퇴보로 오진하게 됩니다.

추출 Job의 연결 재시도 예산은 이랬습니다.

Python
LOCAL_CONN_RETRY = int(os.environ.get("STT_LOCAL_CONN_RETRY", "20"))
LOCAL_CONN_BACKOFF_SEC = float(os.environ.get("STT_LOCAL_CONN_BACKOFF_SEC", "20"))

20회에 20초 간격이니 400초입니다. 콜드 스타트가 이 예산을 넘습니다. 첫 청크에서 재시도를 다 소진하고 파드가 종료 코드 1로 죽습니다. Job이 재시도하면 새 파드가 뜨는데 그 파드도 여전히 차가운 서버를 만납니다.

여기에 닭과 달걀 문제가 하나 더 있습니다. STT 서버를 깨우는 KEDA 신호가 바로 추출 Job이 활성 상태라는 사실입니다. Job이 기다려주지 않고 죽으면 서버가 뜰 이유 자체가 사라집니다.

그래서 청크 처리 루프에 들어가기 전에 준비 상태를 폴링하는 관문을 뒀습니다.

Python
async def _await_local_stt_ready() -> bool:
    if os.environ.get("STT_PROVIDER", "openai").lower() != "local":
        return True
    client, model = build_stt_client(provider_override="local")
    deadline = loop.time() + LOCAL_READY_TIMEOUT_SEC   # 900s
    attempt = 0
    while True:
        attempt += 1
        try:
            await loop.run_in_executor(None, lambda: client.models.retrieve(model))
            return True
        except Exception:
            if loop.time() >= deadline:
                return False
            await asyncio.sleep(LOCAL_READY_POLL_SEC)   # 10s

Job이 활성 상태인 것 자체가 확장 신호가 되고 관문은 서버가 준비될 때까지 최대 900초를 기다립니다. 반환값이 bool인 것이 중요합니다. 타임아웃은 “느리다”가 아니라 “인프라 장애다”라는 뜻이므로, 호출부가 예외를 올려 Job 재시도로 넘깁니다. 무한 대기는 만들지 않습니다.

이 관문은 두 곳에서 씁니다. 첫 청크 전에 한 번, 그리고 청크 처리 도중 연결이 끊겼을 때 다시 씁니다. 후자는 나중에 발견했습니다. 스팟 통합이나 스케일 다운으로 STT 파드가 중간에 교체되면, chunk_007 같은 중간 지점에서 연결이 끊깁니다. 고정 400초 예산으로는 새 파드의 콜드 스타트를 못 버팁니다. 고정 sleep 대신 준비 상태 폴링을 재사용해서 이 경우도 흡수했습니다.

재시도가 처음부터 다시 하지 않게

재시도를 늘리기만 하면 부하가 됩니다. 세 시간 방송의 18번째 청크에서 실패했는데 재시도가 첫 청크부터 다시 시작하면, 재시도 자체가 GPU를 태웁니다.

그래서 청크 전사 결과를 S3에 저장하도록 했습니다.

Python
def _chunk_transcript_key(session_id: int, chunk_idx: int) -> str:
    return f"{session_prefix(session_id)}transcripts/chunk_{chunk_idx:03d}.json"

저장하는 것은 청크 안의 원본 오프셋 기준 단어 목록입니다. 전체 오프셋으로 보정하기 전 값입니다. 이렇게 두면 복원할 때 같은 청크 인덱스의 시작 오프셋을 다시 더하기만 하면 됩니다.

Python
if resume:
    cached = s3_utils.get_chunk_transcript(resume_bucket, resume_session_id, chunk_idx)
    if cached is not None:
        for d in cached:
            all_words.append(
                Word(text=d["text"], start=d["start"] + start, end=d["end"] + start)
            )
        start += length
        chunk_idx += 1
        continue

같은 변경에서 추출 Job의 backoffLimit을 1에서 6으로 올렸습니다. 재시도가 싸졌으니 재시도 횟수를 늘릴 수 있게 됐습니다. 순서가 반대였다면 재시도를 늘릴수록 상황이 나빠졌을 것입니다.

세션이 정상적으로 끝나면 캐시를 지웁니다.

Python
n = await asyncio.to_thread(s3_utils.delete_chunk_transcripts, BUCKET, session_id)
if n:
    log.info("cleaned %d chunk transcript cache session=%d", n, session_id)

결과와 남은 문제들

적용 후 첫 배치에서 이렇게 됐습니다. 배치가 추출 Job 4개를 띄웠고, GPU 노드가 0에서 스팟 4대로 늘었습니다. 첫 파드 4개는 전멸했습니다. 재시도 예산 20회를 전부 소진했습니다. 그리고 backoffLimit으로 재시도된 파드가 전부 성공했습니다. 게시 누락은 0이었습니다.

readiness gate가 적용된 뒤 배치에서는 첫 파드 실패가 0이 됐습니다. 준비 상태 폴링이 14회에서 42회 돌면서 기다린 뒤 전사가 성공했습니다.

콜드 스타트 시간 자체는 이 관문으로도 줄지 않습니다. 첫 파드가 실패하고 재시도하는 사이클이 사라졌을 뿐입니다. 클립은 라이브 종료 후 배치로 처리하므로 6분 지연이 체감에 묻힙니다. 더 줄이려면 KEDA 최소 replica를 1로 두는 warm pool(월 180달러 정도, scale-to-zero 절감의 일부 반납)이나 노드 이미지 프리로드(비용 0, 커스텀 AMI 빌드 노력)가 필요합니다. 배치 특성상 관문으로 충분하다고 판단해 여기서 멈췄습니다.

그 밖에 이 시기에 붙인 것들입니다.

  • 동시 처리 자기 제한: 배치가 한 번에 8건 고정으로 트리거하던 것을 STT_MAX_CONCURRENT(4)에서 현재 처리중 건수를 뺀 빈 슬롯만큼만 트리거하도록 바꿨습니다. 동시 추출 Job 수가 KEDA 최대 replica와 1대1로 맞아 STT 큐 대기가 타임아웃 안에 유지됩니다. GPU 노드 한도가 4인 이유는 8로 올려봤더니 5개가 Insufficient nvidia.com/gpu로 대기했기 때문입니다. CronJob 주기도 6시간에서 1분으로 바꿔, 빈 슬롯을 계속 채우게 했습니다.
  • 고아 복구: 추출 Job이 스팟 축출 등으로 콜백을 못 보내면 세션이 처리중에 영구 잔류합니다. 6시간을 넘긴 처리중 세션을 pending으로 되돌립니다. 청크 캐시가 있으니 재추출은 이어서 갑니다.
  • STT 타임아웃 상향: 180초에서 600초, 다시 900초로 올렸습니다. GPU 노드 한도 때문에 청크가 큐에서 대기하는 시간을 허용해야 했습니다.
  • 키프레임을 base64 인라인으로: 키프레임을 S3에 올리고 presigned URL을 모델에 넘기던 것을 jpeg 바이트를 직접 첨부하는 방식으로 바꿨습니다. 처음에는 긴 세션에서 URL이 2시간 만료를 넘겨 400이 나는 문제로 생성 시점을 STT 뒤로 옮겼는데, 만료와 무관하게 모델 쪽 S3 fetch가 간헐적으로 실패하는 것이 관측되어 fetch 자체를 없앴습니다. 키프레임은 모델 입력 전용이라 S3에 둘 이유가 없었습니다.
  • 콜백 길이 제한: 콜백이 400으로 실패한 적이 있습니다. 에이전트가 만든 reason 문자열이 500자를 넘어 백엔드 검증에 걸렸습니다. Pydantic 모델에 max_length=255max_length=500을 붙이고, 콜백을 보내기 직전에 한 번 더 잘랐습니다. 이모지가 섞이면 파이썬의 문자 길이와 JavaScript의 UTF-16 길이가 달라지기 때문입니다.

그 뒤 다시 OpenAI 429가 났습니다. 이번에는 폴백이 아니라 결제 한도였습니다. 에이전트 호출이 전부 insufficient_quota로 실패해 클립이 3시간 넘게 끊겼습니다. 이건 코드로 고칠 수 있는 것이 아니어서 결제를 충전하자 곧바로 재개됐습니다. 원본이 남아 있고 배치가 1분마다 돌기 때문에 데이터 손실은 없었고 지연만 있었습니다.

자체 호스팅으로 STT 비용은 없앴지만 남은 OpenAI 비용의 핵심은 gpt-5.4 에이전트였습니다. 키프레임 이미지 토큰과 긴 전사 텍스트가 라이브 길이에 비례해 쌓입니다.


14. 아무도 안 보는 화면이 하루 종일 S3를 훑고 있었습니다

녹화도 추출도 돌지 않는 한산한 시간대에 이상한 것을 발견했습니다. 오케스트레이터 파드의 네트워크 사용량이 초당 9.9메가비트로 계속 나오고 있었습니다. 아무 일도 안 하는 파드가 낼 수 있는 숫자가 아닙니다.

로그를 보니 약 110초마다 한 줄이 반복되고 있었습니다.

TEXT
history cache refreshed: 264 sessions in 81.2s

관리자 화면에서 녹화 이력을 보여주기 위한 캐시 갱신 루프였습니다. 구조는 이랬습니다.

Python
while True:
    compute()        # 실제로는 81초 걸림
    await asyncio.sleep(30)

계산이 81초 걸리는데 TTL을 30초로 잡아뒀습니다. 즉 캐시가 만료된 상태로 사는 시간이 없습니다. 갱신이 끝나면 이미 만료 시점이 지나 있어서 곧바로 다음 갱신이 시작됩니다. 듀티 사이클이 74%였습니다.

계산 자체도 문제였습니다. 세션 목록을 얻는 ListObjects 한 번에, 세션마다 상세를 얻는 ListObjects가 264번 더 붙는 형태였습니다. 세션이 늘수록 선형으로 늘어나는 구조입니다.

이 캐시를 쓰는 곳은 관리자 화면의 GET 하나뿐이었습니다. 아무도 안 보는 화면을 위해 파드 하나가 하루 종일 S3를 훑고 있었습니다.

고친 방식은 이렇습니다. 영구 타이머를 없애고, 요청이 들어왔을 때 캐시가 오래됐으면 낡은 값을 즉시 반환하면서 백그라운드로 한 번만 갱신하는 방식으로 바꿨습니다. 같은 시점에 여러 요청이 와도 갱신은 하나만 돕니다.

TEXT
유휴 시 8.37 Mbps  ->  3.25 kbps  (약 2,570배 감소)

여기서 배운 것은 두 가지입니다.

첫째, 계산 시간보다 짧은 TTL은 TTL이 아니라 무한 루프입니다. 30초라는 숫자는 코드만 보면 합리적입니다. 그 함수가 81초 걸린다는 사실을 같이 봐야 문제가 보입니다.

둘째, 수요가 없는 곳에 공급을 미리 만들어두면 그 비용은 수요와 무관하게 나갑니다. 이 루프는 파이프라인이 무엇을 하고 있는지 전혀 보지 않습니다. 요청이 올 때 계산하는 구조라면 요청이 0인 순간 비용도 0이 됩니다.

이 9.9메가비트는 파이프라인이 한창 돌 때는 다른 트래픽에 묻혀 보이지 않았습니다. 부하가 걷히면 평소에 안 보이던 상시 비용이 드러납니다.


15. 마무리

이 글은 라이브 방송 녹화에서 클립 게시까지의 한 줄을 따라간 기록입니다. 단일 파드가 축출되며 녹화 4건이 사라진 사고에서 시작해 녹화와 추출을 각각 Kubernetes Job으로 내렸고, 진행 상태를 프로세스 메모리에서 S3로 옮기자 이어하기가 시작 절차의 일부가 됐습니다.

경계 판정은 가사와 STT를 겹치는 결정론적 매칭에서 출발했습니다. LIS로 후렴 오매칭을 걷어내고, 라벨이 틀린 신청곡까지 다루려고 세션 단위 LLM 에이전트를 얹은 다음, evidence 스키마와 자모 3-gram과 silero VAD로 그 판정을 코드가 다시 검증하게 했습니다. 전사를 클러스터 안으로 옮기면서 비용은 줄었지만 콜드 스타트와 재시도 예산이라는 새 문제가 따라왔고, 클립이 0개가 된 날에는 그 다섯 겹이 한꺼번에 드러났습니다.

되돌릴 수 있었던 것은 녹화 원본이 S3에 그대로 남아 있었기 때문입니다. 769건을 다시 게시하고 50건을 재추출했습니다. 그 버퍼 버킷을 다루다 만난 다른 사고는 비용을 줄이려 넣은 S3 Lifecycle 한 줄이 만든 장애에 따로 정리했습니다.

시리즈 · 제품에 LLM 넣기

3 / 4

LLM을 실제 사용자 흐름에 넣으면서 붙인 판정 기준과 안전장치.

  1. 1.!신청 사랑하긴 그 긴거 : 개떡같이 말해도 찰떡같이 알아듣는 LLM 기반 신청곡 매칭 로직
  2. 2.노래 제목과 Musixmatch 트랙 매칭하기 - LLM Agent와 HITL(Human-in-the-Loop)
  3. 3.노래클립 자동생성 파이프라인 - 방송 녹화, STT, Agent 판정
  4. 4.Vibe Infra로 장애를 만들고, 장애에서 배우다

시리즈 · 노래책과 신청곡

5 / 5

개떡같이 말해도 알아듣고, 키를 바꾸고, 클립으로 남기기까지 노래책을 이루는 조각들.

  1. 1.!신청 사랑하긴 그 긴거 : 개떡같이 말해도 찰떡같이 알아듣는 LLM 기반 신청곡 매칭 로직
  2. 2.노래 제목과 Musixmatch 트랙 매칭하기 - LLM Agent와 HITL(Human-in-the-Loop)
  3. 3.확장 프로그램 없이 브라우저에서 영상 키 조절하기
  4. 4.유튜브 임베드를 벗어나 직접 재생하기 : 광고 없이 video 태그로 유튜브 영상 서빙하기
  5. 5.노래클립 자동생성 파이프라인 - 방송 녹화, STT, Agent 판정