Skip to content

Latest commit

 

History

85 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

OOPS — 공개 전 영상 검수 도우미

편집본 전체를 다시 보는 대신, 다시 확인할 구간만 보세요.

발언과 화면 글자를 외부 자료와 대조해, 공개 전에 확인할 지점을 타임스탬프와 근거 기사로 짚어줍니다.

AI가 삭제 여부를 결정하지 않습니다. 확인할 근거를 제공합니다.

왜 만드나

제작자와 편집자는 영상을 수십 번 반복해서 봅니다. 눈에 띄는 문제는 대부분 이미 알고 있고, 알면서 넣은 장면도 있습니다. "13분 20초에 이런 농담이 있습니다" 같은 알림은 새로운 가치가 없습니다.

문제는 반복 작업에서 남는 사각지대입니다.

  1. 익숙해져서 무뎌진다
  2. 본인이 모르는 사회·문화·역사적 맥락이 있다
  3. 이름·수치·날짜 같은 세부에서 오류가 난다
  4. 편집자가 출연자 의도와 다르게 표현한다 (기능은 있으나 현재 비활성 — 아래 참고)

여기만 찾아서 확인할 구간과 근거를 줍니다. 판단은 제작자가 합니다.

근거는 말로만 주지 않습니다. AI 가 실제로 본 기사를 링크로 함께 줍니다. 근거를 확인할 수 없으면 결국 AI 말을 믿으라는 것과 같기 때문입니다.

누구를 위한 것인가

편집을 다른 사람에게 맡기는, 20분 이상 토크·인터뷰 채널.

  • 본인이 직접 출연하고 편집자가 따로 있음
  • 20~60분 분량을 주기적으로 업로드
  • 2인 이상 대화, 즉흥 발언 비중이 높음
  • 다른 사람·회사·사건을 자주 언급
  • 방송사 수준의 QA 팀은 없음

구성

oops/
├── oops-backend/    Spring Boot 4 · Java 17    (8080)
├── oops-analysis/   FastAPI · Python 3.11      (8000)
└── docs/                  인수인계 문서

서버는 한 대면 됩니다. 음성 인식과 화면 문자 인식이 파이썬 생태계라 프로세스만 나눴고, 같은 머신에서 통신합니다. Python 서버는 외부에 노출하지 않습니다.


빠른 시작

1. 분석 서버

cd oops-analysis
copy .env.example .env      # OPENAI_API_KEY 채우기
.\run.ps1 -Setup

사전 준비: ffmpeg, Python 3.11

winget install -e --id Gyan.FFmpeg
winget install -e --id Python.Python.3.11

2. 백엔드

API 키부터 넣습니다. src/main/resources/ 에서:

copy application-secret.yml.example application-secret.yml

복사한 파일에 키를 채우면 됩니다. 이 파일은 .gitignore 에 있어 커밋되지 않습니다.

oops:
  openai:
    api-key: sk-proj-...

그다음 IntelliJ 에서 oops-backend 를 열고 OopsApplication 실행. 환경변수 OPENAI_API_KEY 를 쓰던 방식도 그대로 동작합니다.

키가 제대로 들어갔는지는 시작 로그로 확인합니다.

[openai] 키=sk-proj-...abcd 조직=(계정 기본값) 모델=gpt-4o-mini

키가 없으면 서버는 뜨지만 AI 분석기 4개(발언 검토·맥락 표현 확인·맥락 참고· 화면 자막 검토)가 통째로 스킵됩니다. 분석이 몇 초 만에 끝나고 결과가 거의 안 나옵니다. 이 경우 로그에 경고가 뜨고, 리포트의 coverage 에도 남습니다.

DB는 설치할 필요가 없습니다. H2 파일 모드라 켜면 알아서 만들어집니다.

3. 확인

cd oops-backend
.\scripts\smoke-test.ps1 -File "C:\경로\영상.mp4"
.\scripts\smoke-test.ps1 -Url  "https://www.youtube.com/watch?v=..."
.\scripts\smoke-test.ps1 -VideoId 1     # 재분석 없이 결과만 다시 보기

유튜브 분석이 안 되면 yt-dlp 부터 올려 보세요. 유튜브가 수시로 막습니다.

cd oops-analysis; .\.venv\Scripts\activate; pip install --upgrade yt-dlp
URL 용도
/swagger-ui.html API 문서 · 바로 호출 가능
/report-test.html 타임라인 + 영상 재생 + 캡처 이미지
/ws-test.html WebSocket 진행률
/h2-console DB 조회

어떻게 분석하나

영상
 ├─ 음성 → 타임스탬프 대본        (Whisper)
 └─ 화면 → 글자 + 캡처 · 로고 제거  (PaddleOCR)
        ↓
   발언과 화면 글자를 함께 훑는다   ← 둘을 서로 비교하지는 않는다
     이름 · 날짜 · 수치가 어긋난 곳   ← 발언 + 화면 글자 · 기사와 대조
     최근 이슈와 얽힌 주제           ← 제작자가 모를 수 있는 맥락 · 기사와 대조
     낯선 뜻으로 쓰인 표현           ← 사전 매칭 후 앞뒤 맥락 확인
     집단 전체를 묶는 발언
     소개 중인 대상을 평가한 대목
     성별 · 국적 · 장애를 근거로 삼은 발언
     편집 자막의 도발적 문구         ← 화면 글자를 LLM 이 직접 읽는다
        ↓
   같은 지점 병합 · 확인 우선순위
        ↓
   시간순 검수 리스트 + 근거 기사 링크
        +
   분석 수행 현황 (어느 단계가 실제로 돌았는지)

확인할 지점 세 가지

1. 이름 · 날짜 · 수치

즉흥적으로 말하다 보면 사람 이름, 소속, 연도, 숫자가 자주 어긋납니다. 제작자도 편집자도 그 자리에서는 맞다고 믿기 때문에 반복해서 봐도 걸러지지 않습니다. 도덕 판단이 아니라 단순 정확성 문제라서 확인만 하면 해결됩니다.

주장의 성격에 맞는 자료를 먼저 봅니다.

주장 먼저 보는 자료
"그때 이런 의도였다고 했다" 본인 인터뷰·직접 인용
"2019년에 설립됐다", "100만 명" 공식 자료·통계

모든 사실을 당사자 발언으로 확인하려 들면 오히려 틀립니다. 본인 기억보다 공식 기록이 정확한 것도 있습니다.

자료끼리 다른 말을 하면 한쪽을 진실로 정하지 않습니다. "자료에 따라 설명이 다릅니다" 라고 적고 각각 무엇이라 하는지 보여줍니다.

사실 확인에는 기간 제한을 두지 않습니다. 예전에는 최근 30일 기사만 뒤져서 "2019년에 설립됐다" 같은 오래된 사실은 관련 자료가 아예 안 나왔습니다. 최근 뉴스 검색은 맥락 참고에만 씁니다.

말한 것뿐 아니라 화면에 박은 것도 확인합니다.

발언 (STT)  ─┐
             ├→ 확인할 사실 → 자료 검색 → 대조 → 사실 확인 후보
화면 (OCR)  ─┘

편집자는 지난 영상의 자막을 복사해 쓰다가 숫자만 고치는 걸 잊습니다. 발매 연도, 나이, 순위, 직함이 옛날 값 그대로 남습니다. 말로 한 실수는 다시 들으면 걸리지만, 화면에 박힌 숫자는 만든 사람이 맞다고 믿고 넣은 것이라 몇 번을 봐도 안 걸립니다.

화면에서 나온 후보는 typeCAPTION 으로 오고 그 장면 캡처가 함께 붙습니다. 발언에서 나온 것은 SPEECH 입니다. 둘 다 candidateTypeFACT_CHECK 입니다.

화면 글자는 기계가 읽은 것이라 깨진 글자가 섞입니다. 그래서 같은 자막의 중복을 걷어내고, 뜻이 불분명하면 뽑지 않으며, 숫자나 연도가 자료와 분명히 다를 때만 올립니다. 글자를 잘못 읽은 것을 "사실이 틀렸다" 로 올리면 고칠 것이 없는 카드가 됩니다.

사실 확인은 발언만 켜져 있습니다. 화면 글자는 꺼져 있습니다.

껐던 이유가 전부 화면 글자에서 나왔습니다. 메뉴판의 김치찌개 8000원 을 "평균 가격" 기사와 대조해 틀렸다고 올리는 식이었습니다. 가게마다 값이 다른 게 당연한데 그걸 오류로 봤습니다.

발언 쪽은 성격이 다릅니다. "그 회사 2019년에 만들어졌죠" 는 근거 기사를 붙여 대조할 수 있고, 이 도구가 가장 잘하는 일입니다. 그래서 둘을 갈랐습니다.

화면 쪽은 메뉴판·가격표를 코드에서 걸러내지만(ScreenTextShape) 간판·영수증·자료화면은 아직 못 가릅니다. 자막에 연도가 박힌 테스트 영상으로 확인한 뒤에 켜세요 — application.ymlfact-check-screen-text: true 한 줄입니다.

오탐은 한 건만 있어도 목록 전체의 신뢰를 깎습니다. 열어봤는데 고칠 것이 없으면 제작자는 나머지 카드도 같은 수준이라고 봅니다. 오탐이 보이면 enabled-analyzers 에서 entity-check 를 지우면 즉시 꺼집니다.

사실 확인은 지금 발언만 봅니다. 화면 글자 쪽은 코드는 있지만 꺼져 있어서 FACT_CHECK 카드의 type 은 항상 SPEECH 입니다.

사실 확인 후보는 이름·수치 확인 분석기만 만들 수 있습니다. 발언 검토는 검색을 하지 않아 근거 자료를 붙일 수 없습니다. 근거 없이 "이건 틀린 것 같다" 고 말하는 게 이 도구가 가장 하면 안 되는 일입니다. 그래서 발언 검토가 사실 관계를 지적하면 코드에서 버립니다. 덕분에 사실 확인 카드에는 항상 참고 자료가 붙습니다.

2. 제작자가 모를 수 있는 맥락 ← 이 도구의 핵심

특정 맥락에서 다른 뜻으로 쓰이는 표현, 최근 사건과 얽힌 주제. 편집자가 모든 사회적 이슈를 알기는 어렵습니다.

13분 20초에 욕설이 있습니다 는 편집자도 압니다. 알면서 넣었을 수도 있습니다. 하지만 7시, 포도, 수박 이 어떤 맥락에서 다른 뜻으로 쓰인다는 건 모르면 그냥 지나갑니다. 그게 진짜 사각지대입니다.

여기서 우리가 하는 일은 "이건 나쁜 표현입니다"가 아니라 "이 표현은 이런 맥락에서 논의된 적이 있습니다. 확인이 필요합니다" 입니다.

특히 집중해서 보는 세 영역

커뮤니티 맥락 은어·밈, ~노 같은 어미와 말버릇
집단 일반화 "요즘 20대는 다 책임감이 없어"
정치 맥락 정치인·정당·지지층을 향한 은어나 낙인

셋은 제작자가 스스로 알아채기 가장 어려운 것들이라 우선순위를 높게 뒀습니다.

소개 중인 대상 평가와, 성별·국적·장애를 근거로 삼은 발언도 함께 봅니다. 조롱은 범위에서 뺐습니다 — 자학이나 농담을 조롱으로 잡는 일이 많았고, 정말 깎아내리는 대목은 대상 평가로 대부분 걸립니다. 위 셋이 함께 걸리면 그쪽을 먼저 적습니다.

집단 일반화는 세 가지가 함께 있을 때만 올립니다 — 집단을 가리키는 말 + 전체로 묶는 표현 + 그 집단에 붙인 성질. "제 친구는 책임감이 없어요"(개인)나 "20대 투표율이 낮습니다"(사실 서술)는 빠집니다.

정치 맥락은 좌·우를 가리지 않습니다. 한쪽만 잡으면 그 자체가 편향입니다.

사전에 있다고 바로 알리지 않습니다

사전에는 7시, 수박, 포도, 초딩 같은 일상어가 들어 있습니다. 단어만 보고 카드를 만들면 "저녁 7시에 만나요" 가 전부 걸립니다. 오탐이 쏟아지면 제작자는 두 번째 영상부터 이 도구를 쓰지 않습니다.

그래서 세 단계로 거릅니다.

사전 매칭
  ↓  근처에 일반 용법 신호가 있으면 버림      ← AI 를 부르지도 않음
  ↓  애매하면 앞뒤 줄을 붙여 AI 에게 한 번에 확인
일반 의미 · 인용 → 버림
특수 의미 · 애매 → 검토 후보
문장 결과
저녁 7시에 만나기로 했어요 버림 (저녁, 만나 가 근처에 있음)
여름엔 역시 수박이 맛있죠 버림 (여름, )
머하노 버림 (사투리)
저 의원도 결국 수박이더라고 후보 (의원 이 근처에 있음)

AI 에게는 다섯 가지를 고정해서 묻습니다 — 일반적 의미로 썼는지, 알려진 맥락과 연결되는지, 화자가 직접 쓴 건지 인용·비판인지, 대상이 누구인지, 제작자가 모르고 지나쳤을 만한지.

영토·역사 관련 문구도 담았습니다. 독도는 일본땅, 동해 표기, 강제동원 서술 같은 것들입니다. 예능 자막으로 재미 삼아 넣었더라도 그 한 장면만 잘려 퍼지면 앞뒤 맥락은 남지 않습니다. 그 주장을 비판하는 맥락은 빠지도록 억제 신호를 넣었습니다.

정치 표현은 좌·우를 같이 담았습니다. 한쪽만 잡으면 그 자체가 편향입니다. 윤어게인 같은 구호는 비하어가 아니므로 "정치적 맥락이 있다" 고만 알립니다. 보이루 는 법원이 표현 자체를 여성혐오로 보기 어렵다고 판단한 사례라 단정하지 않고 논쟁이 있었다는 사실만 전합니다.

3. 이름이 붙은 대상에 대한 평가

가게, 메뉴, 브랜드·체인, 지역 음식, 제품, 사람을 두고 지나가듯 한 말이 당사자에게 닿을 수 있습니다. 말한 본인은 대수롭지 않게 넘긴 대목입니다.

영상이 그 대상을 소개·방문·시식하는 중이어야 하는 게 아닙니다. 타깃이 토크·인터뷰 채널이라 앉아서 이야기만 하다가 특정 체인이나 지역 음식을 깎아내리는 대목이 훨씬 흔합니다. 예전에는 "소개 중인 대상" 으로 좁게 적어둬서 그런 발언이 통째로 빠졌습니다.

무엇을 두고 한 말인지 적을 수 있을 때만 올립니다. 고유명사가 없어도 됩니다 — 가게 이름을 한 번도 말하지 않는 영상이 대부분이고, 그래도 당사자는 자기 가게인 걸 압니다. 특정 인물, 어떤 가게 처럼 분류명만 적힌 카드는 열어봐도 확인할 것이 없어서 버립니다.

완곡하게 말한 것도 평가로 봅니다. 특색이 없다, 무난하다, 그냥 그렇다 는 중립 서술이 아니라 낮춰 말한 것입니다. 세게 말하지 않았다고 넘기면 정작 이 도구가 짚어야 할 대목이 빠집니다.

발언과 편집 자막을 대조하는 기능은 지금 꺼져 있습니다. 화면 글자에서 편집 자막만 골라내지 못해 간판·메뉴판까지 비교하게 됩니다. 코드는 남아 있어 설정 한 줄로 다시 켤 수 있습니다. 기획에서도 MVP 제외로 확정한 항목입니다.

화면 자막의 표현 검토는 껐다가 다시 켰습니다.

편집 자막은 편집자가 재미로 넣는 경우가 많아 출연자도 모르고 나갑니다. 화면에 박힌 독도는 일본땅 같은 문장은 사전으로 미리 담을 수 없어서, 화면 글자를 LLM 이 직접 읽는 단계가 필요합니다.

다만 프론트에서 챙길 것이 있습니다. 여기서 나온 후보는 candidateTypeSPEECH_REVIEW 로 붙습니다. 그걸 "발언" 이라고만 쓰면 사용자는 출연자가 한 말로 읽습니다. 실제로는 편집자가 쓴 자막입니다. typeCAPTION 이면 "화면 글자" 로 그려야 합니다.

결과는 이렇게 나옵니다

14:31  확인 필요                                          [이름·수치 확인]
발언: "그 회사는 2019년에 만들어졌죠"
확인 이유: 영상에서는 2019년이라고 했는데 기사에는 2020년으로 나옵니다.

AI 가 확인한 자료 2건 — 직접 열어서 확인하세요
· OO그룹 창립 20주년...2020년 설립 후 성장세    한국경제 · 8월 3일
· OO그룹, 설립 6년 만에 매출 1조                 매일경제 · 7월 28일
(등장: 14:31, 15:02)

최종 수정 여부는 제작자가 판단합니다.

확률 점수나 "부적절합니다" 같은 판정은 내지 않습니다.

근거 기사를 같이 줍니다. 예전에는 검색해서 AI 에게 넣고 그냥 버렸습니다. 사용자에게는 "기사에는 2020년으로 나옵니다" 라는 문장 하나만 갔습니다.

이 도구는 잘못 잡는 일이 반드시 있습니다. "여기 롯데리아 없나?" 를 "롯데리아 싱가포르 2호점 오픈" 기사와 대조한 적이 있습니다. 링크가 있으면 30초 만에 무관한 기사라고 넘길 수 있지만, 없으면 판단할 방법이 없어 도구 전체를 의심하게 됩니다.

쓰는 AI 모델

하는 일 모델 비용
음성 → 대본 whisper-1 분당 약 8원
화면 글자 인식 PaddleOCR 로컬, 무료
텍스트 판단 전부 gpt-4o-mini 토큰당

분석기별로 모델이 나뉘어 있지 않습니다. 유형판별·발언검토·맥락표현확인· 맥락참고·화면자막검토가 전부 같은 모델을 씁니다. 바꾸려면 application.ymloops.openai.model 한 줄만 고치면 되지만, pricing 도 같이 고쳐야 비용 로그가 안 틀립니다.

OCR 이 분석 시간의 절반 가까이를 차지하는데 그건 돈이 안 나가는 시간입니다. 느린 것과 비싼 것은 다릅니다.

요청 속도는 계정 한도에 맞춥니다

크레딧이 남아 있어도 요청 수는 따로 제한됩니다. gpt-4o-mini 기준으로 계정 등급에 따라 이렇게 갈립니다.

분당 요청 하루 요청 분당 토큰
무료 3 200 낮음
Tier 1 ($5 구매) 500 10,000 200,000

지원받은 크레딧은 등급을 올려주지 않습니다. 잔액이 $100 있어도 직접 구매한 적이 없으면 무료 등급이고, 하루 200건에 막힙니다. 60분 영상 하나에 60~90건을 쓰므로 하루 두세 편이면 끝납니다.

분당 토큰(TPM)이 요청 수보다 먼저 걸립니다. 호출 하나가 3천 토큰쯤 쓰므로 200,000 TPM 이면 실제 상한은 분당 60여 건입니다. 분당 500건까지 밀어붙이면 토큰 쪽에서 막힙니다.

oops.openai.requests-per-minute: 10      # 시작값. 헤더 보고 자동 조정
oops.openai.requests-per-day: 10000      # 로그 표시용

첫 응답 헤더(x-ratelimit-limit-requests)로 실제 한도가 확인되면 자동으로 맞춰집니다. 설정값은 그 전까지 쓸 초기값입니다.

예전에는 250ms 고정 간격이었습니다. 이론상 분당 240건인데 계정 한도는 10건이라, 첫 몇 초 만에 벽에 부딪혔습니다. OpenAI 는 429 와 함께 "28분 뒤에 오세요" 를 답했고, 코드는 30초 넘게 못 기다리게 되어 있어 분석기 여러 개가 통째로 스킵됐습니다.

화면에는 "확인할 지점 1곳" 만 떴습니다. 물어보지도 못한 것이 괜찮다로 보인 겁니다.

지금은 네 가지로 막습니다.

순번 예약 "마지막 호출 시각" 대신 "다음 차례" 를 잡습니다. 스레드가 겹쳐도 동시에 안 나갑니다
한도 반영 헤더의 분당 한도를 읽어 간격에 실제로 적용합니다. 예전엔 로그로 찍기만 했습니다
선제 대기 남은 횟수가 바닥이면 창이 열릴 때까지 쉽니다. 맞고 대응하는 것보다 훨씬 쌉니다
자동 감속 그래도 429 를 맞으면 간격을 두 배로 늘립니다. 재시도가 같은 벽에 안 부딪히게

분당 10건이면 60분 영상 하나에 5~7분 정도 대기가 생깁니다. OCR·음성 인식 시간에 묻히는 수준이고, 결과가 비는 것보다 낫습니다. 급하면 계정 등급을 올리세요 — 한도는 누적 결제액으로 올라갑니다.

한도를 얼마나 먹는지 보는 법

분석이 끝나면 [openai-quota] 로 남습니다.

[openai-quota] videoId=110 요청 13건 (성공 13 · 한도거절 0)
[openai-quota] videoId=110 분석기별 — entity-check 7건, context-check 3건, speech-review 2건, ...
[openai-quota] videoId=110 60분 환산 약 55건 (고정 19 + 길이비례 36) · 토큰 약 210000
[openai-quota] videoId=110 하루 한도가 200건이면 60분짜리 약 3편

60분 환산에서 총합을 그냥 60배 하지 않습니다. 분석기 대부분은 상한이 걸려 있어 영상이 길어져도 호출이 안 늘어납니다.

분석기 상한 60분이어도
이름·수치 확인 주장 6개 7회
맥락 참고 주제 8개 9회
맥락 표현 확인 표현 24개, 12개씩 묶음 2회
발언 검토 없음 — 대본 17줄마다 1회 (창 20줄, 3줄 겹침) 약 35회
화면 자막 검토 없음 — 자막을 창 단위로 훑는다 자막 수에 비례

늘어나는 건 발언 검토와 화면 자막 검토 둘입니다. 짧은 영상일수록 고정 호출의 비중이 커서, 그냥 곱하면 실제보다 몇 배 크게 나옵니다. 1분짜리 13건을 60배 하면 780건이지만 실제 60분 영상은 50~60건입니다. ContentAnalyzer.scalesWithLength() 가 이 둘을 가릅니다.

비용과 한도는 다른 이야기입니다.

무엇으로 깎이나
비용 응답을 받은 호출만
요청 한도 거절당한 요청도 포함

그래서 [openai-cost] 호출 1회 를 보고 "한 번밖에 안 썼는데 왜 걸리지" 가 됩니다. 실제로는 거절당한 요청까지 12건을 보낸 것입니다.

60분 환산이 붙는 이유는 짧은 영상으로 시험하고 실제 대상(20~60분)에 들어가기 전에 한도에 걸릴지 알기 위해서입니다. 요청 수는 대본 길이에 거의 비례하므로 이 환산이 꽤 잘 맞습니다.

1분짜리로 한 번 돌려보고 60분 환산 약 N건 을 계정의 RPD 와 비교하면 됩니다.

누적으로 얼마 썼는지는 서버가 알 수 없습니다. OpenAI 에서 봐야 합니다.

보고 싶은 것 주소
오늘 쓴 요청 수 https://platform.openai.com/usage
계정 한도 (RPM · RPD · TPM) https://platform.openai.com/settings/organization/limits

API 한눈에

Base URL /api/v1 · 문서는 /swagger-ui.html

Method Path 설명
POST /videos 영상 업로드 (multipart). 업로드 즉시 분석 시작
POST /videos 유튜브 링크 등록 (JSON). 응답 형태는 업로드와 같다
GET /videos/{id}/status 진행률 폴링
GET /videos/history 검수 이력 (페이징)
GET /videos/{id}/report 검수 리포트
PUT /videos/{id}/review-actions/{eventId} 확인함·수정함·보류·유용하지않음
POST /videos/{id}/review-completion 검수 완료
POST /videos/{id}/analysis/retry 재분석
POST /videos/{id}/analysis/cancel 분석 취소
GET /videos/{id}/stream 영상 재생 (HTTP Range)
GET /videos/{id}/frames/{frameId} 화면 캡처 이미지
DELETE /videos/{id} 영상·결과·파일 삭제
GET /videos/{id}/review-actions 저장된 검수 결정 목록
GET /videos/{id}/transcript 대본 원문 (진단용)
GET /videos/{id}/screen-texts OCR 이 읽은 화면 글자 (진단용)
GET /metrics 검수 품질 지표 (팀 내부용)

WebSocket /ws/topic/videos/{videoId}/progress 구독

응답 규칙

{ "success": true,  "data": { }, "error": null }
{ "success": false, "data": null, "error": { "code": "...", "message": "...", "details": {} } }

모든 id 는 문자열("123"), 시각은 ISO-8601 UTC, 구간은 밀리초 정수입니다.

null 인 필드는 키째로 빠집니다. streamUrl, reviewAction, acceptanceRate 처럼 "없으면 null" 이라고 적힌 값은 JSON 에 아예 안 들어옵니다 (default-property-inclusion: non_null). 자바스크립트에서 === null 은 false 가 되니 == null 이나 ?? 를 쓰세요.

/report 에서 눈여겨볼 것

필드
events[].type 어디서 나왔나. SPEECH 발언 / CAPTION 화면 글자
events[].candidateType 왜 확인하나. SPEECH_REVIEW / FACT_CHECK. FACT_CHECK 는 발언에서만 나옵니다
events[].references[] AI 가 근거로 본 기사. 항상 배열
events[].contextBefore/After 발언 앞뒤 대본 줄
events[].reviewAction 저장된 결정. 아직이면 키가 빠집니다
coverage 무엇을 분석했는지
warnings[] 비어 있지 않으면 화면에 띄워야 합니다

위험도 점수(severity)와 내부 분류(riskTypes)는 내려가지 않습니다. 이 도구는 판정하지 않고 확인할 지점만 올립니다. 점수가 화면에 보이는 순간 "AI 가 0.8 이라고 했으니 문제다" 가 되고, 그건 판정입니다. 두 값 모두 서버 내부에는 남아 정렬·병합·품질 지표에 계속 쓰입니다.

화면 문구는 candidateType 이 아니라 type 으로 정하세요. SPEECH_REVIEW 라고 무조건 "발언" 이라 쓰면 안 됩니다. typeCAPTION 이면 그건 편집자가 쓴 화면 글자입니다.

streamUrlnull 이면 유튜브 링크 영상이거나 보관 기간이 지나 원본을 지운 것입니다. 후자는 재생 시 410 VIDEO_SOURCE_PURGED 가 오고, 리포트는 그대로 열립니다.

프론트는 candidateType 으로 카드를 나누고 type 은 뱃지로 쓰면 됩니다.

전체 계약은 docs/API명세-구현-대조표.md 에 있습니다.

영상 길이

90분까지 받습니다. 타깃이 2060분 롱폼인데 실제로는 7080분짜리도 흔해서요.

프레임 수는 영상 길이와 무관하게 300장으로 묶여 있습니다. 간격을 고정하면 60분 영상에 900장이 되어 화면 인식에만 7분 넘게 걸립니다. 길면 간격이 자동으로 늘어납니다.

영상 길이 프레임 간격 화면 인식 시간
10초 1.2초 몇 초
5분 4초 40초
20분 4초 2.5분
60분 12초 2.5분

짧은 영상은 반대로 간격을 좁힙니다. 10초짜리를 4초 간격으로 뜨면 23장뿐인데, 자막은 23초마다 바뀌므로 그 사이를 통째로 놓칩니다. 화면에 자막이 큼직하게 박혀 있는데 0건으로 끝나는 일이 실제로 있었습니다.

또한 채널 로고를 걸러냅니다. 화면 구석에 늘 떠 있는 로고가 자막으로 섞여 들어갑니다. 같은 자리에 계속 나오면서 글자도 매번 비슷하면 로고로 봅니다. 자리만 보면 하단 자막까지 지워지고, 글자만 보면 OCR 오인식(POGUESPO6UFS)에 걸립니다.

긴 영상은 비용과 시간이 크게 늡니다. 60분 기준으로 대략 이렇습니다.

음성 인식 약 500원
AI 분석 약 40~60원
합계 약 540~560원

500원은 음성 인식만의 값입니다($0.006 × 60분 × 1400). 비용의 90%가 음성 인식이고 AI 분석은 나머지입니다.

돈보다 걸리는 건 요청 한도입니다. 대본이 300줄을 넘어 AI 호출이 수십 번 나가는데, 계정 등급이 낮으면 중간에 끊깁니다. 시연은 3~5분짜리로 하는 것을 권합니다.

얼마 나갔는지 로그로 확인합니다

분석이 끝나면 실제 사용량이 찍힙니다. 짐작하지 않고 숫자로 봅니다.

[openai-usage] videoId=65 analyzer=speech-review model=gpt-4o-mini input=2841 cached=0 output=327 total=3168
[openai-usage] videoId=65 analyzer=entity-check  model=gpt-4o-mini input=1850 cached=0 output=126 total=1976
...
[openai-quota] videoId=65 요청 9건 (성공 9 · 한도거절 0)
[openai-quota] videoId=65 영상 1분당 1.8건 → 60분이면 약 108건 · 하루 200건 한도라면 약 2편
[openai-cost]  videoId=65 호출 9회 · 입력 18420토큰(캐시 0) · 출력 1633토큰
[openai-cost]  videoId=65 분석 $0.00374 + 음성인식 $0.00080 = $0.00454 (약 6원)
[openai-cost]  videoId=65 1분당 약 48원 (영상 0.1분)

호출마다 어느 분석기가 썼는지 남기므로, 대본이 긴 게 문제인지 분석기 하나가 유독 비싼지 구분됩니다.

음성 인식도 같이 셉니다. 긴 영상에서는 이쪽이 대부분을 차지해서, LLM 만 보여주면 "생각보다 싸네" 라고 잘못 판단하게 됩니다.

단가는 application.ymloops.openai.pricing 에 있습니다. 코드에 박아두면 OpenAI 가 가격을 바꿨을 때 숫자가 조용히 틀리기 시작합니다.

분석에 실패하면 실패라고 말합니다

음성도 화면 글자도 못 읽으면 분석을 실패로 끝냅니다.

그냥 진행하면 모든 검사가 건너뛰어지고 "완료 · 확인할 지점 0곳" 으로 끝납니다. 사용자에게는 "검수했는데 문제없다" 로 읽히지만 실제로는 아무것도 보지 못한 것입니다.

"봤는데 없다" 와 "보지도 못했다" 는 반드시 구분되어야 합니다. 실패하면 왜 실패했는지 그대로 보여줍니다.

[FAIL] 분석 실패: 영상이 너무 깁니다 (4330초). 최대 5400초까지 지원합니다.

일부만 실패해도 알려줍니다

분석기 하나가 죽어도 나머지 결과로 분석은 끝납니다. 그게 맞습니다. 문제는 그걸 "확인할 지점 0곳" 으로만 보여줄 때입니다.

그래서 /report 가 단계별 수행 결과를 함께 줍니다.

분석 수행 현황  음성 인식 완료  화면 글자 완료  발언 검토 완료
                이름·수치 실패  맥락 참고 실패  화면 자료 미사용

⚠ 아래 분석은 수행하지 못했습니다. 결과가 전부가 아닙니다.
  · 이름·수치 확인 — AI 요청 한도를 모두 써서 이 단계를 수행하지 못했습니다.

특히 예외 없이 조용히 실패하는 경우를 잡습니다. AI 요청 한도에 걸리면 호출이 빈손으로 돌아올 뿐 에러가 나지 않습니다. 그대로 두면 성공한 것처럼 보이므로, 호출 실패 횟수를 세서 실패로 기록합니다.

한 단계를 분석기 둘이 나눠 맡으면 나쁜 쪽을 보고합니다. 룰 기반은 성공했는데 AI 검토가 한도에 걸렸다면 "실패"로 나갑니다. 절반만 본 것을 다 봤다고 하면 안 됩니다.

서버를 재시작해도 멈추지 않습니다

분석은 메모리 위 스레드에서 돕니다. 서버가 죽으면 스레드는 사라지는데 DB 기록은 "분석 중" 으로 남아, 그 영상은 결과 조회도 재시도도 삭제도 전부 막혔습니다.

이제 서버가 뜰 때 끊긴 분석을 찾아 실패로 정리하고 재시도할 수 있게 엽니다.

사전 채우기 — 개발 지식 없이

oops-backend/src/main/resources/context-lexicon.json 에 54개가 들어 있습니다. 코드가 아니라 데이터 파일이라 누구나 늘릴 수 있습니다. 고치고 서버를 다시 켜면 반영됩니다.

{
  "id": "CTX_051",
  "patterns": ["표현", "다른 표기"],
  "triggerMode": "CONTEXT_REQUIRED",
  "contextHints": ["근처에 있으면 특수 용법일 가능성이 올라가는 말"],
  "suppressHints": ["근처에 있으면 일반 용법이므로 버릴 말"],
  "needsContext": true,
  "reason": "판정하지 말고 사실만. '이런 맥락에서 쓰인 사례가 있습니다'",
  "reviewedAt": "2026-08-17"
}

suppressHints 가 가장 중요합니다. 일상적으로도 쓰는 말이라면 그 일상 맥락에 따라붙는 단어를 최대한 넣어주세요. 이게 오탐을 막습니다.

needsContext 는 일반적인 다른 뜻이 있으면 true 입니다. 틀딱 처럼 다른 뜻이 없는 말만 false 로 둡니다. false 면 AI 없이 바로 알리므로, 애매하면 true 가 안전합니다.

triggerMode 8가지는 파일 맨 위 설명에 정리돼 있습니다. SAFETY_NET 은 편집자도 쉽게 알아채는 표현이라 우선순위가 낮습니다. 우리가 잘해야 하는 건 CONTEXT_REQUIREDHISTORICAL_EVENT 쪽입니다.

reviewedAt 은 꼭 채워주세요. 영포티 처럼 몇 년 사이 뜻이 바뀌는 표현이 있습니다. 이 사전은 고정된 목록이 아니라 계속 손봐야 하는 데이터입니다.

추가한 뒤에는 짧은 영상으로 돌려서 로그를 확인하세요.

[lexicon] videoId=65 매칭 4건 → 확인요청 4건 → 후보 1건 (제외 3건)

제외 건수가 0이면 오히려 의심스럽습니다. 걸러내는 쪽이 제대로 도는지가 핵심입니다.


데이터 관리

  • DB: H2 파일 모드. 별도 설치가 필요 없고 재시작해도 남습니다
  • 스키마: 엔티티에서 자동 생성 (ddl-auto: update)

원본 영상은 24시간 뒤에 지웁니다

지우는 건 원본 영상 파일 하나뿐입니다.

지웁니다 남깁니다
videos/{id}/original.mp4 리포트 · 대본 · 검토 후보
참고 자료 · 검수 이력 · 화면 캡처

원본은 분석이 끝나면 더 필요 없습니다. 출연자 얼굴과 목소리가 그대로 담긴 파일이라 오래 둘수록 부담만 커집니다. 반대로 검수 결과는 어제 것도 다시 볼 수 있어야 합니다. 둘을 한 번에 지우면 사용자가 원하는 쪽을 못 고릅니다.

oops.storage.source-retention-hours: 24   # 원본만 삭제. 매시 정각 확인
oops.storage.retention-days: 0            # 전부 삭제. 기본 꺼짐

매시간 도는 이유가 있습니다. 하루 한 번만 돌면 24시간을 못 지킵니다. 새벽 4시 실행인데 분석이 4시 1분에 끝나면 다음 실행 때 23시간 59분이라 아직 안 지워지고, 그다음까지 기다리면 거의 48시간이 됩니다.

원본을 지운 영상은 streamUrlnull 로 나갑니다. 재생을 시도하면 410 VIDEO_SOURCE_PURGED 입니다. 404 가 아닌 이유는 없었던 게 아니라 있다가 없어진 것이기 때문입니다. 프론트는 "보관 기간이 지나 원본은 삭제되었습니다" 를 보여주면 됩니다. 리포트는 그대로 열립니다.

  • 전체 삭제: DELETE /videos/{id} — 사용자가 이 영상을 목록에서 없앨 때만. DB 행과 디스크 파일을 전부 지웁니다
  • 자동 전체 정리: retention-days 를 켜면 매일 새벽 4시. 기본은 꺼짐. 검수 결과를 며칠 만에 없애는 건 보통 원하는 동작이 아니고, 용량 문제는 위의 원본 정리로 대부분 해결됩니다

예전에는 정리 스케줄러가 VideoDeletionService.delete() 를 불러서 리포트·대본·검수 이력까지 같이 날렸습니다. 되돌릴 방법이 없는 실수라 테스트(VideoSourcePurgeTest)로 못 박아 뒀습니다.

상태

항목 상태
업로드 → 분석 → 결과 완료 (실제 영상 검증)
유튜브 링크 분석 완료
API 명세 일치 완료 (docs/API명세-구현-대조표.md)
WebSocket 진행률 완료
영상 재생 · 구간 이동 완료
화면 캡처 이미지 완료
Swagger 문서 완료
영상 유형 판별 완료
배경 확인 (최근 이슈 대조) 완료
이름 · 수치 확인 (발언) 완료 · 켜짐 — 근거 기사 링크 포함
이름 · 수치 확인 (화면 글자) 완료 · 기본 비활성 (fact-check-screen-text)
원본만 24시간 뒤 삭제 완료 — 리포트·검수 이력 유지
낯선 맥락 표현 사전 54개 완료 (계속 채워야 함)
사전 매칭 후 앞뒤 맥락 확인 완료
채널 로고 제거 완료
근거 기사 저장 · 노출 완료
분석 수행 현황 · 경고 완료
검수 액션 저장 · 복구 완료
업로드 시 90분 검증 완료
서버 재시작 복구 완료
분석 취소 · 재분석 완료 — 취소는 단계 경계에서 멈춥니다
검수 품질 지표 완료
요청 한도 자동 조절 완료 — 계정 한도에 맞춰 속도 조정
한도 사용량 측정 완료 — [openai-quota] 로 60분 환산까지
테스트 Java 84건 · Python 14건
영상 삭제 · 저장소 자동 정리 완료
테스트 영상으로 화면 사실확인 검증 미착수 — 자막에 연도가 박힌 영상 필요
노란 딱지 예측 구현됨 · 기본 비활성 (방향이 달라 보류)
발언 ↔ 자막 비교 구현됨 · 기본 비활성 (편집 자막만 골라내지 못함)
배포 완료 — 커스텀 도메인 + Vercel 프론트. 서버에서 겪은 것은 맨 아래에
DB 마이그레이션 도구 미도입 — 배포 전 Flyway 권장
인증 없음 — 주소가 공개되면 누구나 업로드 가능
MySQL 전환 설정만 있음, 미검증
실제 영상 기반 품질 회귀 세트 미착수 — 테스트 영상이 쌓여야 의미 있음
유튜브 댓글 분석 범위에서 제외 (공개 전엔 댓글이 없음)
포즈 · 제스처 판별 범위에서 제외

테스트

cd oops-backend  ; .\gradlew test
cd oops-analysis ; pip install -r requirements-dev.txt ; pytest

백엔드 테스트는 메모리 DB 를 쓰므로 서버를 켜둔 채 돌려도 됩니다. 운영 설정은 H2 파일 모드라, 그대로 두면 파일이 잠겨 있어 실패합니다.

붙여둔 건 실제로 틀렸던 로직입니다. 새 기능보다 이미 고친 것을 지킵니다.

대상
ocr._find_watermarks 세 번 고쳐서 맞은 로고/자막 구분
FindingFusionService 같은 카드가 7장, 11장씩 쌓였던 문제
ContextLexicon 일상어를 안 잡는지가 핵심. 테스트 절반이 "안 잡아야 하는 것"
VagueReasonFilter 오탐 잡으려다 진짜 탐지를 죽인 적이 있음
CandidateType · AnalyzerStatus 매핑이 틀어지면 프론트가 엉뚱한 카드를 그림

문서

파일 대상
docs/개발자-인수인계.md 백엔드 개발자. 먼저 읽으세요
docs/API명세-구현-대조표.md 프론트엔드
docs/사용중인-프롬프트-전문.md 탐지 품질 담당
docs/원출처-우선순위-반영결과.md 사실 확인 개선안 반영 결과 · 기획
docs/프로젝트-현황-정리.md 비개발자 · 발표 준비
oops-backend/.../context-lexicon.json 맥락 표현 사전. 개발 지식 없이 채울 수 있습니다
oops-backend/README.md 백엔드 상세
oops-analysis/README.md 분석 서버 상세

인수인계 문서의 "알아둘 함정들" 장은 꼭 읽어주세요. Spring Boot 4의 Jackson 3 전환, Java HttpClient의 HTTP/2 문제 등 모르면 하루씩 날리는 것들을 정리해 뒀습니다.


주의

API 키를 커밋하지 마세요. .env, application-secret.yml, .idea/.gitignore 에 있습니다. 키는 각자 로컬에서 관리하고, 배포 시에는 서버 환경변수로 넣습니다.

저장소를 받으면 application-secret.yml.example 을 복사해 본인 키를 채우세요.

비용이 발생합니다. 60분 영상 하나에 약 550원입니다. 그중 500원이 음성 인식이고 AI 분석은 4060원입니다. 짧은 영상은 1분당 단가가 더 높게 나옵니다 — 대본 길이와 무관한 고정 호출이 있어서입니다. 개발 중에는 12분짜리 짧은 영상을 쓰세요.

OpenAI 요청 한도에 자주 걸립니다. 분석기가 여러 번 호출하므로 영상 하나에 수십 건이 나갑니다. 계정 등급이 낮으면 금방 막히므로, 시연 전에 결제 수단을 등록해 등급을 올려 두세요. 시작 로그에 계정의 실제 한도와 요청 간격이 찍힙니다.

[openai] 호출 속도 — 분당 10건 (설정값) → 요청 간격 6667ms

한도에 걸리면 어떤 한도인지 알려줍니다. 셋은 대응이 완전히 다릅니다.

대응
RPM 분당 요청 수 requests-per-minute 를 낮춘다
RPD 하루 요청 수 간격을 늘려도 소용없다. 초기화를 기다리거나 등급 인상
TPM 분당 토큰 수 요청 수가 아니라 프롬프트 크기 문제

잔액이 없어도 429 로 옵니다(insufficient_quota). 그건 기다려도 안 풀립니다. 셋 다 구분해서 다른 문구로 알려주고, 응답 원문과 한도 헤더를 그대로 남깁니다.

예전에는 429 를 전부 "요청 한도 초과" 로 뭉뚱그리고 응답 본문을 버렸습니다. 그래서 원인을 못 잡고 간격만 만지며 한참을 헤맸습니다. 로그에 찍힌 "일일 요청 한도를 소진한 것으로 보입니다" 도 근거 없는 추측이었습니다.

한도에 걸려도 결과가 조용히 비지는 않습니다. /reportwarnings[] 에 어느 단계를 수행하지 못했는지 나옵니다. 예전에는 이걸 프롬프트 문제로 착각해 엉뚱한 곳을 고치느라 시간을 날렸습니다.

분석 시간의 대부분은 OCR 입니다. oops.analysis-server.ocr-interval-sec 로 조절합니다. 파이프라인이 끝나면 콘솔에 단계별 소요 시간이 한 줄로 찍힙니다.


서버에서 겪은 문제와 해결

배포하고 실제로 돌리면서 겪은 것들입니다. 같은 자리에서 또 막히지 않도록 남깁니다.

파이썬 분석 서버가 OCR 중에 사라진다

[analysis-server] OCR 건너뜀 : I/O error on POST "/ocr":
                  HTTP/1.1 header parser received no bytes

"응답을 한 바이트도 못 받았다" 는 뜻입니다. 오류 응답이 아니라 프로세스가 죽은 겁니다. dmesg -T | grep -i "killed process" 로 확인하세요.

원인은 메모리였습니다. 3.8GB 서버에서 PaddleOCR 이 3.3GB 를 쓰고 OOM 으로 죽었습니다.

조치 효과
스왑 2GB 완충 장치. 느려져도 안 죽습니다
MAX_OCR_FRAMES=80 300 → 80. 메모리가 대략 1/4
sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile
sudo mkswap /swapfile && sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

OCR 이 도는 동안 단계별 로그를 찍습니다. 어디서 끊기는지가 곧 원인입니다.

[ocr] 시작 — 영상 8초, 요청 간격 4.0초
[ocr] 엔진 준비 완료 (12.3초)      ← 여기서 끊기면 모델 로딩
[ocr] 프레임 8장 추출 완료          ← 여기서 끊기면 ffmpeg
[ocr] 인식 중 5/8                  ← 여기서 끊기면 그 프레임

업로드가 413 으로 거절된다

nginx 기본 상한이 1MB 입니다. server 블록 안에 넣으세요.

client_max_body_size 500M;
client_body_timeout 300s;
proxy_read_timeout 300s;

CORS 에러로 보이지만 아닌 경우가 많습니다. nginx 가 거절할 때는 응답에 CORS 헤더를 안 붙여서, 413·502·504 가 전부 "CORS 에러" 로 나타납니다. 브라우저 콘솔에 CORS 메시지가 뜨면 Network 탭의 실제 상태 코드를 먼저 보세요.

WebSocket 이 안 붙거나 자꾸 끊긴다

/ws 프록시에 이 두 줄이 없으면 아예 안 붙습니다.

location /ws {
    proxy_pass http://127.0.0.1:8080;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 3600s;      # 기본 60s 는 OCR 구간을 못 버팁니다
}

서버 쪽은 10초 하트비트를 켜뒀습니다. OCR 이 몇 분씩 도는 동안 연결이 조용해지면 프록시가 죽은 연결로 보고 끊어버리기 때문입니다.

로그에서 연결 여부를 확인할 수 있습니다.

stompSubProtocol[processed CONNECT(1)-CONNECTED(1)]   ← 붙음
stompSubProtocol[processed CONNECT(0)-CONNECTED(0)]   ← 폴링으로만 동작 중

백엔드가 안 뜬다

DialectFactoryImpl.buildDialect
JdbcEnvironmentInitiator.getJdbcEnvironment

H2 파일 DB 는 한 프로세스만 열 수 있습니다. 옛 백엔드를 안 죽이고 새로 띄우면 두 번째가 잠금 때문에 못 뜹니다.

pkill -f 'oops-backend.*\.jar' && sleep 3
ps -ef | grep '[j]ava'                       # 아무것도 안 나와야 함
nohup java -jar build/libs/*.jar > app.log 2>&1 &
sleep 25 && grep "Started OopsApplication" app.log

pkillsleep 이 중요합니다. 잠금이 풀릴 시간이 필요합니다.

후보를 잡았는데 화면에 안 나온다

병합 단계에서 합쳐졌을 수 있습니다. 분석기별 결과와 [fusion] 줄을 비교하세요.

[lexicon]            후보 2건
[screen-text-review] 3건
[fusion] 후보 10건 → 최종 1건 (제거 9건)      ← 9건이 여기서 사라짐
[fusion] '드릉드릉' 로 4건 합침                ← 무엇에 흡수됐는지

짧은 영상에서 특히 그렇습니다. 같은 장면으로 보는 기준이 3초인데, 8초 영상은 모든 후보가 서로 3초 안에 있습니다. 지금은 가리키는 대상이 분명히 다르면 시간이 붙어 있어도 합치지 않습니다.

OCR 이 글자를 못 읽었는지 확인

사전이나 프롬프트를 고치기 전에 입력에 그 글자가 있는지 먼저 보세요.

curl -s localhost:8080/api/v1/videos/{id}/screen-texts | python3 -m json.tool

큰 장식 서체는 인식이 어렵고, 한 글자만 틀려도 사전 매칭이 실패합니다. 읽지 못한 것이면 사전을 아무리 손봐도 소용없습니다.

About

[14기] 중앙해커톤 우리는 장원영 백엔드

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages